summaryrefslogtreecommitdiff
path: root/internal/repl
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-11 22:33:41 +0300
committerPaul Buetow <paul@buetow.org>2026-04-11 22:33:41 +0300
commitf05d957389412125b5b955794b3c5f69fbe965f5 (patch)
tree7fd1adaefaad41819c33d993ac442692e4b6931e /internal/repl
parentb315ebbcd92e58249c6ed8f04217ef7adcdde5d5 (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.go61
-rw-r--r--internal/repl/commands.go15
-rw-r--r--internal/repl/completer.go88
-rw-r--r--internal/repl/completer_test.go276
-rw-r--r--internal/repl/concurrent_test.go9
-rw-r--r--internal/repl/handlers.go22
-rw-r--r--internal/repl/prompt.go115
-rw-r--r--internal/repl/repl.go217
-rw-r--r--internal/repl/repl_completer_test.go51
-rw-r--r--internal/repl/repl_test.go22
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