summaryrefslogtreecommitdiff
path: root/internal/repl/repl.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-16 23:01:59 +0200
committerPaul Buetow <paul@buetow.org>2026-03-16 23:01:59 +0200
commited32c7fea5ad0fefccab9c99a51ebdae13b3a2f2 (patch)
tree583f793af4287444346ff63d4a448625cc2346c9 /internal/repl/repl.go
parent57a7b5961037eb565d9cddc324bd0246daff440b (diff)
bump version to v0.2.0v0.2.0
- Add REPL mode with vi keybindings - Add built-in commands (help, clear, quit/exit) - Add mage repl target Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/repl/repl.go')
-rw-r--r--internal/repl/repl.go185
1 files changed, 185 insertions, 0 deletions
diff --git a/internal/repl/repl.go b/internal/repl/repl.go
new file mode 100644
index 0000000..0f690e9
--- /dev/null
+++ b/internal/repl/repl.go
@@ -0,0 +1,185 @@
+package repl
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "syscall"
+
+ "codeberg.org/snonux/perc/internal/calculator"
+ "github.com/mattn/go-isatty"
+
+ "github.com/c-bata/go-prompt"
+)
+
+const historyFile = ".perc_history"
+
+// executor runs a calculation command and returns the result
+func executor(input string) {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return
+ }
+
+ // Check if it's a built-in command
+ if cmd, ok := isBuiltinCommand(input); ok {
+ output, err := ExecuteCommand(cmd)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ }
+ if output != "" {
+ fmt.Println(output)
+ }
+ // Don't add built-in commands to history
+ return
+ }
+
+ // Run the calculation
+ result, err := calculator.Parse(input)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ return
+ }
+ fmt.Println(result)
+}
+
+// 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
+}
+
+// getHistoryPath returns the path to the history file
+func getHistoryPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ return filepath.Join(home, historyFile)
+}
+
+// loadHistory loads history from file
+func loadHistory() []string {
+ historyPath := getHistoryPath()
+ if historyPath == "" {
+ return nil
+ }
+
+ file, err := os.Open(historyPath)
+ if err != nil {
+ return nil
+ }
+ defer file.Close()
+
+ var history []string
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ history = append(history, scanner.Text())
+ }
+ return history
+}
+
+// saveHistory saves history to file
+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 file.Close()
+
+ writer := bufio.NewWriter(file)
+ for _, entry := range history {
+ if _, err := writer.WriteString(entry + "\n"); err != nil {
+ return err
+ }
+ }
+ return writer.Flush()
+}
+
+// RunREPL starts the interactive REPL
+func RunREPL() error {
+ // Check if stdin is a TTY
+ if !isatty.IsTerminal(os.Stdin.Fd()) {
+ fmt.Fprintln(os.Stderr, "REPL mode requires a TTY. Use 'perc <calculation>' for non-interactive mode.")
+ return fmt.Errorf("stdin is not a TTY")
+ }
+
+ history := loadHistory()
+
+ p := prompt.New(
+ executor,
+ completer,
+ prompt.OptionTitle("perc - Percentage Calculator"),
+ prompt.OptionPrefix("perc> "),
+ prompt.OptionLivePrefix(func() (string, bool) {
+ return "perc> ", true
+ }),
+ prompt.OptionHistory(history),
+ )
+
+ // Handle SIGINT gracefully
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT)
+
+ go func() {
+ <-sigChan
+ 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
+
+ return nil
+}
+
+// completer provides auto-completion for built-in commands
+func completer(d prompt.Document) []prompt.Suggest {
+ text := d.GetWordBeforeCursor()
+ if text == "" {
+ return nil
+ }
+
+ 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)})
+ }
+ }
+ 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",
+ }
+ return descriptions[cmd]
+}