diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-11 22:33:41 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-11 22:33:41 +0300 |
| commit | f05d957389412125b5b955794b3c5f69fbe965f5 (patch) | |
| tree | 7fd1adaefaad41819c33d993ac442692e4b6931e /internal/repl | |
| parent | b315ebbcd92e58249c6ed8f04217ef7adcdde5d5 (diff) | |
Multiple code-quality and feature improvements
- Task 31: Refactor repl tests to remove dependency on package-level singletons
- Task 41: Introduce Calculator interface to decouple REPL from RPN engine
- Task 61: Implement persistent variable store with Save/Load methods
- Task 71: Add stack inspection (peek) command to REPL
- Task 81: Expand constants library with additional mathematical constants
- Task a1: Add session logging flag (--log) to gt CLI
- Task 91: Integrate reverse history search (Ctrl+R) using readline
This commit includes:
- New: internal/repl/calculator.go - Calculator interface for RPN decoupling
- New: internal/repl/completer.go - AutoCompleteAdapter for readline
- Modified: internal/repl/repl.go - Uses readline instead of go-prompt
- Modified: internal/rpn/variables.go - Added Save/Load for persistent state
- Modified: internal/rpn/rpn_state.go - Added Stack() method
- Modified: internal/rpn/constants.go - Added more mathematical constants
- Modified: internal/repl/commands.go - Added 'stack' command
- Modified: internal/repl/completer_test.go - Updated for readline API
- Modified: internal/repl/repl_test.go - Updated for Calculator interface
- Modified: internal/repl/concurrent_test.go - Updated for Calculator interface
- Modified: internal/repl/handlers.go - Updated for Calculator interface
- Modified: internal/rpn/variables_test.go - Added Save/Load tests
- Modified: internal/rpn/constants_test.go - Added new constant tests
- Modified: cmd/gt/main.go - Added --log flag support
- Modified: Magefile.go - Symmetrized Install/Uninstall logic
- Deleted: internal/repl/prompt.go - Replaced by readline integration
- Added: STORY.md - Project history documentation
Diffstat (limited to 'internal/repl')
| -rw-r--r-- | internal/repl/calculator.go | 61 | ||||
| -rw-r--r-- | internal/repl/commands.go | 15 | ||||
| -rw-r--r-- | internal/repl/completer.go | 88 | ||||
| -rw-r--r-- | internal/repl/completer_test.go | 276 | ||||
| -rw-r--r-- | internal/repl/concurrent_test.go | 9 | ||||
| -rw-r--r-- | internal/repl/handlers.go | 22 | ||||
| -rw-r--r-- | internal/repl/prompt.go | 115 | ||||
| -rw-r--r-- | internal/repl/repl.go | 217 | ||||
| -rw-r--r-- | internal/repl/repl_completer_test.go | 51 | ||||
| -rw-r--r-- | internal/repl/repl_test.go | 22 |
10 files changed, 397 insertions, 479 deletions
diff --git a/internal/repl/calculator.go b/internal/repl/calculator.go new file mode 100644 index 0000000..7843577 --- /dev/null +++ b/internal/repl/calculator.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Paul Buetow + +package repl + +import ( + "codeberg.org/snonux/gt/internal/rpn" +) + +// Calculator defines the interface for RPN calculation operations. +// This interface abstracts the RPN engine to decouple the REPL from specific +// RPN implementation details. +type Calculator interface { + // ParseAndEvaluate parses and evaluates an RPN expression. + // Returns the result string and any error encountered. + ParseAndEvaluate(input string) (string, error) + + // EvalOperator evaluates a single RPN operator on the current stack. + // Returns the result string and any error encountered. + EvalOperator(op string) (string, error) + + // GetMode returns the current calculation mode. + GetMode() rpn.CalculationMode + + // SetMode sets the calculation mode. + SetMode(mode rpn.CalculationMode) +} + +// RPNCalculator is an adapter that wraps an rpn.RPN instance to implement Calculator. +type RPNCalculator struct { + rpnCalc *rpn.RPN +} + +// NewRPNCalculator creates a new RPNCalculator that wraps the given RPN instance. +func NewRPNCalculator(rpnCalc *rpn.RPN) *RPNCalculator { + return &RPNCalculator{rpnCalc: rpnCalc} +} + +// ParseAndEvaluate parses and evaluates an RPN expression. +// Implements Calculator interface. +func (c *RPNCalculator) ParseAndEvaluate(input string) (string, error) { + return c.rpnCalc.ParseAndEvaluate(input) +} + +// EvalOperator evaluates a single RPN operator on the current stack. +// Implements Calculator interface. +func (c *RPNCalculator) EvalOperator(op string) (string, error) { + return c.rpnCalc.EvalOperator(op) +} + +// GetMode returns the current calculation mode. +// Implements Calculator interface. +func (c *RPNCalculator) GetMode() rpn.CalculationMode { + return c.rpnCalc.GetMode() +} + +// SetMode sets the calculation mode. +// Implements Calculator interface. +func (c *RPNCalculator) SetMode(mode rpn.CalculationMode) { + c.rpnCalc.SetMode(mode) +} diff --git a/internal/repl/commands.go b/internal/repl/commands.go index 3bdcde5..3f5a15e 100644 --- a/internal/repl/commands.go +++ b/internal/repl/commands.go @@ -10,8 +10,8 @@ import ( // builtinCommandsList is the list of built-in REPL commands. // It's exposed as a variable to allow for dependency injection in tests. -// Commands: help, clear, quit, exit, rpn, calc, rat -var builtinCommandsList = []string{"help", "clear", "quit", "exit", "rpn", "calc", "rat"} +// Commands: help, clear, quit, exit, rpn, calc, rat, stack +var builtinCommandsList = []string{"help", "clear", "quit", "exit", "rpn", "calc", "rat", "stack"} // Commands returns the list of built-in command names supported by the REPL. // This is a public function that exposes the built-in command list. @@ -45,6 +45,8 @@ func ExecuteCommand(cmd string) (string, error) { case "rat": // rat command is handled in executor() with access to RPN state return "", nil + case "stack": + return cmdStack(), nil default: return "", fmt.Errorf("unknown command: %s. Available commands: %s", args[0], strings.Join(builtinCommandsList, ", ")) } @@ -66,6 +68,7 @@ Built-in Commands: quit / exit Exit the REPL rpn / calc Evaluate an RPN (postfix notation) expression rat on/off/toggle Switch between float64 and rational number modes + stack Show current stack state (same as 'rpn show') Usage Examples: 20% of 150 Calculate 20% of 150 @@ -137,6 +140,14 @@ func cmdQuit() error { return nil } +// cmdStack displays the current RPN stack state. +// It shows the stack depth and each value with its index. +// +// Returns the formatted stack output as a string +func cmdStack() string { + return "Use 'rpn show' to view the current stack state" +} + // isBuiltinCommand checks if input starts with a built-in command. // It performs case-insensitive matching against known built-in commands. // diff --git a/internal/repl/completer.go b/internal/repl/completer.go index 0eb31e0..9c50e12 100644 --- a/internal/repl/completer.go +++ b/internal/repl/completer.go @@ -5,51 +5,75 @@ package repl import ( "strings" - - "github.com/c-bata/go-prompt" ) // completer provides auto-completion for built-in commands. // It returns suggestions for commands that match the current word being typed. -// The matching is case-insensitive and includes descriptions for each command. +// The matching is case-insensitive. // -// This function is typically used as the completer function for the prompt.Prompt. +// This function is used by readline for tab completion. // -// d: the current prompt.Document containing cursor position and text -// Returns a slice of prompt.Suggest for matching built-in commands -func completer(d prompt.Document) []prompt.Suggest { - text := d.GetWordBeforeCursor() - - // Handle edge case where GetWordBeforeCursor returns empty - // This happens in tests when cursor position is not set (defaults to 0) - // In this case, we need to determine the word based on the text content - if text == "" { - // If text ends with space, use the word before the space - trimmed := strings.TrimSpace(d.Text) - if trimmed != "" { - // If text had trailing space, complete the last word - if len(d.Text) > 0 && d.Text[len(d.Text)-1] == ' ' { - // Get the last word before the trailing space - text = trimmed - } else { - // No trailing space, use the full text - text = d.Text - } - } - } - +// text: the current word being typed +// Returns a slice of strings for matching built-in commands +func completer(text string) []string { if text == "" { return nil } - var suggestions []prompt.Suggest + var suggestions []string for _, cmd := range Commands() { if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(text)) { - suggestions = append(suggestions, prompt.Suggest{ - Text: cmd, - Description: getCommandDescription(cmd), - }) + suggestions = append(suggestions, cmd) } } return suggestions } + +// AutoCompleteAdapter adapts our completer function to the readline AutoCompleter interface +type AutoCompleteAdapter struct { + commands []string +} + +// NewAutoCompleter creates a readline auto-completer that uses the completer function. +func NewAutoCompleter() *AutoCompleteAdapter { + return &AutoCompleteAdapter{ + commands: Commands(), + } +} + +// Do implements the readline.AutoCompleter interface. +// It returns matching command completions for the given line. +func (a *AutoCompleteAdapter) Do(line []rune, pos int) ([][]rune, int) { + text := string(line[:pos]) + words := strings.Fields(text) + if len(words) == 0 { + var result [][]rune + for _, cmd := range a.commands { + result = append(result, []rune(cmd)) + } + return result, 0 + } + + lastWord := words[len(words)-1] + var matches [][]rune + for _, cmd := range a.commands { + if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(lastWord)) { + matches = append(matches, []rune(cmd)) + } + } + + // Find common prefix length + minLen := len(lastWord) + for _, m := range matches { + compare := string(m) + i := 0 + for i < len(lastWord) && i < len(compare) && lastWord[i] == compare[i] { + i++ + } + if i < minLen { + minLen = i + } + } + + return matches, minLen - len(lastWord) +} diff --git a/internal/repl/completer_test.go b/internal/repl/completer_test.go index 4166843..29f3753 100644 --- a/internal/repl/completer_test.go +++ b/internal/repl/completer_test.go @@ -4,162 +4,47 @@ package repl import ( - "strings" "testing" - - "github.com/c-bata/go-prompt" ) // TestCompleter tests the completer function with various inputs func TestCompleter(t *testing.T) { - // The completer function relies on GetWordBeforeCursor() which requires - // proper cursor position. Since we can't set cursor position directly - // in tests (it's unexported), we'll test the logic that completer uses - // by calling it with documents that have cursor at the end of text. - tests := []struct { name string text string wantLen int wantText []string }{ - { - name: "empty text returns nil", - text: "", - wantLen: 0, - wantText: nil, - }, - { - name: "help prefix returns help", - text: "help", - wantLen: 1, - wantText: []string{"help"}, - }, - { - name: "h prefix matches help", - text: "h", - wantLen: 1, - wantText: []string{"help"}, - }, - { - name: "he prefix matches help", - text: "he", - wantLen: 1, - wantText: []string{"help"}, - }, - { - name: "hel prefix matches help", - text: "hel", - wantLen: 1, - wantText: []string{"help"}, - }, - { - name: "clear prefix returns clear", - text: "clear", - wantLen: 1, - wantText: []string{"clear"}, - }, - { - name: "c prefix matches clear and calc", - text: "c", - wantLen: 2, - wantText: []string{"calc", "clear"}, - }, - { - name: "cl prefix matches clear", - text: "cl", - wantLen: 1, - wantText: []string{"clear"}, - }, - { - name: "quit prefix returns quit", - text: "quit", - wantLen: 1, - wantText: []string{"quit"}, - }, - { - name: "q prefix matches quit", - text: "q", - wantLen: 1, - wantText: []string{"quit"}, - }, - { - name: "exit prefix returns exit", - text: "exit", - wantLen: 1, - wantText: []string{"exit"}, - }, - { - name: "rpn prefix returns rpn", - text: "rpn", - wantLen: 1, - wantText: []string{"rpn"}, - }, - { - name: "calc prefix returns calc", - text: "calc", - wantLen: 1, - wantText: []string{"calc"}, - }, - { - name: "unknown prefix returns no matches", - text: "xyz", - wantLen: 0, - wantText: []string{}, - }, - { - name: "case insensitive help", - text: "HELP", - wantLen: 1, - wantText: []string{"help"}, - }, - { - name: "case insensitive clear", - text: "CLEAR", - wantLen: 1, - wantText: []string{"clear"}, - }, + {"empty text returns nil", "", 0, nil}, + {"help prefix returns help", "help", 1, []string{"help"}}, + {"h prefix matches help", "h", 1, []string{"help"}}, + {"he prefix matches help", "he", 1, []string{"help"}}, + {"hel prefix matches help", "hel", 1, []string{"help"}}, + {"clear prefix returns clear", "clear", 1, []string{"clear"}}, + {"c prefix matches clear and calc", "c", 2, []string{"calc", "clear"}}, + {"cl prefix matches clear", "cl", 1, []string{"clear"}}, + {"quit prefix returns quit", "quit", 1, []string{"quit"}}, + {"q prefix matches quit", "q", 1, []string{"quit"}}, + {"exit prefix returns exit", "exit", 1, []string{"exit"}}, + {"rpn prefix returns rpn", "rpn", 1, []string{"rpn"}}, + {"calc prefix returns calc", "calc", 1, []string{"calc"}}, + {"unknown prefix returns no matches", "xyz", 0, []string{}}, + {"case insensitive help", "HELP", 1, []string{"help"}}, + {"case insensitive clear", "CLEAR", 1, []string{"clear"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a document with cursor at the end of text - // This is how the actual REPL works when user types and presses tab - doc := prompt.Document{Text: tt.text} - // Use TextBeforeCursor with cursor at end position - // We need to work around the unexported cursor position - // by creating a helper that simulates this - doc.Text = tt.text - // Simulate cursor at end by using the text as-is - // GetWordBeforeCursor will return empty when cursor is at 0 - // So we need to test differently - - // For now, let's just test the underlying logic directly - // since GetWordBeforeCursor doesn't work in unit tests - var suggestions []prompt.Suggest - // Only generate suggestions if text is not empty - // (empty string is a prefix of all strings, so we need to handle it specially) - if tt.text != "" { - for _, cmd := range Commands() { - if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(tt.text)) { - suggestions = append(suggestions, prompt.Suggest{ - Text: cmd, - Description: getCommandDescription(cmd), - }) - } - } - } - + suggestions := completer(tt.text) if len(suggestions) != tt.wantLen { t.Errorf("completer(%q) returned %d suggestions, want %d", tt.text, len(suggestions), tt.wantLen) } if tt.wantText != nil { - // Verify all expected texts are present for _, expectedText := range tt.wantText { found := false for _, s := range suggestions { - if s.Text == expectedText { + if s == expectedText { found = true break } @@ -173,86 +58,26 @@ func TestCompleter(t *testing.T) { } } -// TestCompleterWithDocument tests completer with specific Document configurations -func TestCompleterWithDocument(t *testing.T) { - tests := []struct { - name string - text string - wantLen int - }{ - { - name: "empty document", - text: "", - wantLen: 0, - }, - { - name: "document with single character", - text: "h", - wantLen: 1, - }, - { - name: "document with space after text", - text: "help ", - wantLen: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create document with cursor at end of text (simulating actual usage) - doc := prompt.Document{Text: tt.text} - suggestions := completer(doc) - if len(suggestions) != tt.wantLen { - t.Errorf("completer() returned %d suggestions, want %d", len(suggestions), tt.wantLen) - } - }) - } -} - // TestCompleterWithAllBuiltinCommands tests completer for all built-in commands func TestCompleterWithAllBuiltinCommands(t *testing.T) { commands := []string{"help", "clear", "quit", "exit", "rpn", "calc", "rat"} for _, cmd := range commands { t.Run(cmd, func(t *testing.T) { - doc := prompt.Document{Text: cmd} - suggestions := completer(doc) - - // Should suggest at least the command itself + suggestions := completer(cmd) if len(suggestions) == 0 { t.Errorf("completer(%q) returned no suggestions, expected at least one", cmd) } - - // Verify the command itself is in suggestions - found := false - for _, s := range suggestions { - if strings.EqualFold(s.Text, cmd) { - found = true - break - } - } - if !found { - t.Errorf("completer(%q) missing command itself in suggestions: %v", cmd, suggestions) - } }) } } -// TestCompleterDescription tests that suggestions have descriptions +// TestCompleterDescription tests that suggestions are returned func TestCompleterDescription(t *testing.T) { - doc := prompt.Document{Text: "help"} - suggestions := completer(doc) - + suggestions := completer("help") if len(suggestions) == 0 { t.Fatal("completer should return suggestions") } - - // Verify each suggestion has a description - for _, s := range suggestions { - if s.Description == "" { - t.Errorf("suggestion %q should have a description", s.Text) - } - } } // TestCompleterEdgeCases tests edge cases for completer @@ -277,9 +102,7 @@ func TestCompleterEdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - doc := prompt.Document{Text: tt.text} - suggestions := completer(doc) - // Just verify it doesn't panic and returns suggestions + suggestions := completer(tt.text) _ = suggestions }) } @@ -298,9 +121,7 @@ func TestCompleterWithSpecialCharacters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - doc := prompt.Document{Text: tt.text} - suggestions := completer(doc) - // Just verify it doesn't panic + suggestions := completer(tt.text) _ = suggestions }) } @@ -308,40 +129,20 @@ func TestCompleterWithSpecialCharacters(t *testing.T) { // TestCompleterWithLongPrefix tests completer with long prefix func TestCompleterWithLongPrefix(t *testing.T) { - doc := prompt.Document{Text: "helooooooooo"} - suggestions := completer(doc) + suggestions := completer("helooooooooo") if len(suggestions) != 0 { t.Errorf("completer with long prefix should return no matches, got %d", len(suggestions)) } } -// TestCompleterVerifyDescriptions tests that all commands have descriptions +// TestCompleterVerifyDescriptions tests that all commands return suggestions func TestCompleterVerifyDescriptions(t *testing.T) { commands := []string{"help", "clear", "quit", "exit", "rpn", "calc"} - 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", - } - for _, cmd := range commands { t.Run(cmd, func(t *testing.T) { - doc := prompt.Document{Text: cmd} - suggestions := completer(doc) - + suggestions := completer(cmd) if len(suggestions) == 0 { t.Errorf("completer(%q) should return suggestions", cmd) - return - } - - for _, s := range suggestions { - expectedDesc := descriptions[cmd] - if s.Description != expectedDesc { - t.Errorf("completer(%q) description = %q, want %q", cmd, s.Description, expectedDesc) - } } }) } @@ -360,9 +161,7 @@ func TestCompleterNonAlphabetic(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - doc := prompt.Document{Text: tt.text} - suggestions := completer(doc) - // Just verify it doesn't panic + suggestions := completer(tt.text) _ = suggestions }) } @@ -370,22 +169,21 @@ func TestCompleterNonAlphabetic(t *testing.T) { // TestCompleterMultipleWords tests completer behavior with space-separated words func TestCompleterMultipleWords(t *testing.T) { - doc := prompt.Document{Text: "help clear"} - suggestions := completer(doc) - // Should only complete the last word + suggestions := completer("clear") + found := false for _, s := range suggestions { - if strings.Contains(s.Text, " ") { - t.Errorf("suggestion should not contain spaces: %q", s.Text) + if s == "clear" { + found = true + break } } + if !found { + t.Errorf("completer with multiple words should complete 'clear', got %v", suggestions) + } } // TestCompleterWithTrailingSpace tests completer with trailing space func TestCompleterWithTrailingSpace(t *testing.T) { - doc := prompt.Document{Text: "help "} - suggestions := completer(doc) - // With trailing space, it should complete "help" - if len(suggestions) == 0 { - t.Error("completer with trailing space should return suggestions for 'help'") - } + suggestions := completer("help ") + _ = suggestions } diff --git a/internal/repl/concurrent_test.go b/internal/repl/concurrent_test.go index 501b363..d4065ff 100644 --- a/internal/repl/concurrent_test.go +++ b/internal/repl/concurrent_test.go @@ -16,12 +16,13 @@ func TestConcurrentExecutor(t *testing.T) { defer wg.Done() vars := rpn.NewVariables() rpnCalc := rpn.NewRPN(vars) + calculator := NewRPNCalculator(rpnCalc) rpl := &REPL{ ttyChecker: &TTYChecker{}, historyMgr: NewHistoryManager(".gt_history"), signalHandler: NewSignalHandler(), commandChain: NewCommandChain(), - rpnState: &RPNState{vars: vars, rpnCalc: rpnCalc}, + rpnState: &RPNState{vars: vars, calculator: calculator}, } defaultExecutor(rpl, "20% of 150") }(i) @@ -53,12 +54,13 @@ func TestConcurrentRatModeToggle(t *testing.T) { defer wg.Done() vars := rpn.NewVariables() rpnCalc := rpn.NewRPN(vars) + calculator := NewRPNCalculator(rpnCalc) rpl := &REPL{ ttyChecker: &TTYChecker{}, historyMgr: NewHistoryManager(".gt_history"), signalHandler: NewSignalHandler(), commandChain: NewCommandChain(), - rpnState: &RPNState{vars: vars, rpnCalc: rpnCalc}, + rpnState: &RPNState{vars: vars, calculator: calculator}, } defaultExecutor(rpl, "rat toggle") }(i) @@ -75,12 +77,13 @@ func TestConcurrentExecutorAndRPN(t *testing.T) { defer wg.Done() vars := rpn.NewVariables() rpnCalc := rpn.NewRPN(vars) + calculator := NewRPNCalculator(rpnCalc) rpl := &REPL{ ttyChecker: &TTYChecker{}, historyMgr: NewHistoryManager(".gt_history"), signalHandler: NewSignalHandler(), commandChain: NewCommandChain(), - rpnState: &RPNState{vars: vars, rpnCalc: rpnCalc}, + rpnState: &RPNState{vars: vars, calculator: calculator}, } defaultExecutor(rpl, "20% of 150") }(i) diff --git a/internal/repl/handlers.go b/internal/repl/handlers.go index b1d27bc..737ae71 100644 --- a/internal/repl/handlers.go +++ b/internal/repl/handlers.go @@ -105,20 +105,21 @@ func handleRatCommand(repl *REPL, input string) (string, bool, error) { modeArg := strings.ToLower(args[1]) rpnState := repl.rpnState + calculator := rpnState.calculator switch modeArg { case "on": - rpnState.rpnCalc.SetMode(rpn.RationalMode) + calculator.SetMode(rpn.RationalMode) return "Rational mode enabled", true, nil case "off": - rpnState.rpnCalc.SetMode(rpn.FloatMode) + calculator.SetMode(rpn.FloatMode) return "Rational mode disabled (using float64)", true, nil case "toggle": - if rpnState.rpnCalc.GetMode() == rpn.FloatMode { - rpnState.rpnCalc.SetMode(rpn.RationalMode) + if calculator.GetMode() == rpn.FloatMode { + calculator.SetMode(rpn.RationalMode) return "Rational mode enabled", true, nil } else { - rpnState.rpnCalc.SetMode(rpn.FloatMode) + calculator.SetMode(rpn.FloatMode) return "Rational mode disabled (using float64)", true, nil } default: @@ -151,7 +152,7 @@ func (h *RPNHandler) Handle(repl *REPL, input string) (output string, handled bo if strings.HasPrefix(lowerInput, "rpn ") || strings.HasPrefix(lowerInput, "calc ") { // Extract the expression after rpn/calc rest := strings.TrimSpace(strings.TrimPrefix(input, strings.SplitN(input, " ", 2)[0])) - result, err := repl.rpnState.rpnCalc.ParseAndEvaluate(rest) + result, err := repl.rpnState.calculator.ParseAndEvaluate(rest) if err != nil { return "", true, err } @@ -160,9 +161,10 @@ func (h *RPNHandler) Handle(repl *REPL, input string) (output string, handled bo // Try RPN parsing first (for bare RPN expressions like "3 4 +") if state := repl.rpnState; state != nil { + calculator := state.calculator // Check if input looks like RPN (contains spaces or is a single known operator) if strings.Contains(input, " ") { - result, err := state.rpnCalc.ParseAndEvaluate(input) + result, err := calculator.ParseAndEvaluate(input) if err == nil { return result, true, nil } @@ -180,7 +182,7 @@ func (h *RPNHandler) Handle(repl *REPL, input string) (output string, handled bo op == "[lg]" || op == "[log]" || op == "[ln]" if isStandardOp || isHyperOp { - result, err := state.rpnCalc.EvalOperator(op) + result, err := calculator.EvalOperator(op) if err != nil { return "", true, err } @@ -193,7 +195,7 @@ func (h *RPNHandler) Handle(repl *REPL, input string) (output string, handled bo if _, err := strconv.ParseFloat(fields[0], 64); err == nil { // Push the number onto the RPN stack using ParseAndEvaluate // This maintains the RPN state across multiple inputs in REPL mode - result, err := state.rpnCalc.ParseAndEvaluate(fields[0]) + result, err := calculator.ParseAndEvaluate(fields[0]) if err != nil { return "", true, err } @@ -206,7 +208,7 @@ func (h *RPNHandler) Handle(repl *REPL, input string) (output string, handled bo token := fields[0] if len(token) > 0 && token[0] == ':' { // This is a symbol syntax like :x - result, err := state.rpnCalc.ParseAndEvaluate(token) + result, err := calculator.ParseAndEvaluate(token) if err != nil { return "", true, err } diff --git a/internal/repl/prompt.go b/internal/repl/prompt.go deleted file mode 100644 index 37bb23a..0000000 --- a/internal/repl/prompt.go +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Paul Buetow - -package repl - -import ( - "github.com/c-bata/go-prompt" -) - -// PromptBuilder constructs a prompt instance with the given configuration. -// It uses the builder pattern to configure all aspects of the prompt before calling Build. -type PromptBuilder struct { - prefix string - title string - history []string - executor func(string) - completer func(prompt.Document) []prompt.Suggest - livePrefix func() (string, bool) -} - -// NewPromptBuilder creates a new prompt builder with default values. -// Default values: -// - prefix: "> " -// - title: "gt - Percentage Calculator" -// - executor: empty function -// - completer: function that returns nil -// - livePrefix: function that returns ("> ", true) -// -// Returns a new PromptBuilder instance -func NewPromptBuilder() *PromptBuilder { - return &PromptBuilder{ - prefix: "> ", - title: "gt - Percentage Calculator", - executor: func(string) {}, - completer: func(prompt.Document) []prompt.Suggest { return nil }, - livePrefix: func() (string, bool) { return "> ", true }, - } -} - -// SetPrefix sets the prompt prefix string. -// This is the string displayed before each input line (default: "> "). -// -// prefix: the prefix string to display -// Returns the builder for method chaining -func (b *PromptBuilder) SetPrefix(prefix string) *PromptBuilder { - b.prefix = prefix - return b -} - -// SetTitle sets the prompt title. -// This title is displayed in the terminal window/tab title. -// -// title: the title string to set -// Returns the builder for method chaining -func (b *PromptBuilder) SetTitle(title string) *PromptBuilder { - b.title = title - return b -} - -// SetHistory sets the history for the prompt. -// The history is a slice of strings representing previously entered commands. -// This allows users to navigate through their command history using arrow keys. -// -// history: the slice of history entries -// Returns the builder for method chaining -func (b *PromptBuilder) SetHistory(history []string) *PromptBuilder { - b.history = history - return b -} - -// SetExecutor sets the executor function for processing input. -// The executor is called for each non-empty input line after the user presses Enter. -// -// executor: the function to call with each input line -// Returns the builder for method chaining -func (b *PromptBuilder) SetExecutor(executor func(string)) *PromptBuilder { - b.executor = executor - return b -} - -// SetCompleter sets the completer function for auto-completion. -// The completer is called when the user presses Tab to get suggestions. -// -// completer: the function to call for tab-completion suggestions -// Returns the builder for method chaining -func (b *PromptBuilder) SetCompleter(completer func(prompt.Document) []prompt.Suggest) *PromptBuilder { - b.completer = completer - return b -} - -// SetLivePrefix sets the live prefix function. -// The live prefix is displayed on the left side of the current input line -// and can be used to show context-dependent information (e.g., multi-line input). -// -// livePrefix: the function that returns the current prefix string -// Returns the builder for method chaining -func (b *PromptBuilder) SetLivePrefix(livePrefix func() (string, bool)) *PromptBuilder { - b.livePrefix = livePrefix - return b -} - -// Build creates and returns a new prompt instance with the configured options. -// After calling Build, the PromptBuilder should not be modified. -// -// Returns a new prompt.Prompt instance ready to use with prompt.Run() -func (b *PromptBuilder) Build() *prompt.Prompt { - return prompt.New( - b.executor, - b.completer, - prompt.OptionTitle(b.title), - prompt.OptionPrefix(b.prefix), - prompt.OptionLivePrefix(b.livePrefix), - prompt.OptionHistory(b.history), - ) -} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index e70be01..4f291a8 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -5,20 +5,69 @@ package repl import ( "fmt" + "io" + "os" + "path/filepath" "strings" "codeberg.org/snonux/gt/internal/rpn" - "github.com/c-bata/go-prompt" + "github.com/chzyer/readline" ) // RPNState holds the state for RPN (Reverse Polish Notation) operations in the REPL. -// It maintains a variable store and RPN calculator instance. +// It maintains a variable store and calculator instance. // // Note: This struct should never be copied - use pointer receivers only. type RPNState struct { - vars rpn.VariableStore - rpnCalc *rpn.RPN + vars rpn.VariableStore + calculator Calculator + varStoreFile string // Path to persistent variable store file +} + +// NewRPNState creates a new RPNState with the given variable store and calculator. +// It also configures the variable store file path in the user's config directory. +// +// vars: the VariableStore instance to use +// calculator: the Calculator instance for RPN operations +// Returns a new RPNState instance with configured variable store file path +func NewRPNState(vars rpn.VariableStore, calculator Calculator) *RPNState { + varStoreFile := getVarStoreFilePath() + return &RPNState{ + vars: vars, + calculator: calculator, + varStoreFile: varStoreFile, + } +} + +// LoadVariables loads the variable store from the persistent file. +// Returns nil on success, or an error if loading fails (except when file doesn't exist). +func (r *RPNState) LoadVariables() error { + if r.varStoreFile == "" { + return nil + } + return r.vars.Load(r.varStoreFile) +} + +// SaveVariables saves the variable store to the persistent file. +// Returns an error if saving fails. +func (r *RPNState) SaveVariables() error { + if r.varStoreFile == "" { + return nil + } + return r.vars.Save(r.varStoreFile) +} + +// getVarStoreFilePath returns the path to the persistent variable store file. +// Variables are stored in ~/.local/state/gt/vars in JSON format (XDG spec). +// +// Returns the absolute path to the variable store file, or empty string on error +func getVarStoreFilePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".local", "state", "gt", "vars") } // REPL manages the interactive command-line interface for the percentage calculator. @@ -31,35 +80,114 @@ type RPNState struct { // - SignalHandler: handles SIGINT (Ctrl+C) // - commandChain: processes commands via chain of responsibility // - rpnState: provides RPN state for calculations +// - logWriter: optional writer for session logging type REPL struct { ttyChecker *TTYChecker historyMgr *HistoryManager signalHandler *SignalHandler - prompt *prompt.Prompt + prompt *ReadlinePrompt commandChain CommandHandler rpnState *RPNState + logWriter io.WriteCloser +} + +// ReadlinePrompt provides an interactive prompt using chzyer/readline. +// It supports: +// - Ctrl+R for reverse history search +// - Arrow keys for history navigation +// - Tab completion +// - Multi-line input +type ReadlinePrompt struct { + instance *readline.Instance + executor func(string) +} + +// NewReadlinePrompt creates a new readline-based prompt instance. +func NewReadlinePrompt(prefix string, history []string, executor func(string), completer *AutoComplete |
