diff options
Diffstat (limited to 'internal/repl')
| -rw-r--r-- | internal/repl/commands.go | 95 | ||||
| -rw-r--r-- | internal/repl/commands_test.go | 70 | ||||
| -rw-r--r-- | internal/repl/repl.go | 185 |
3 files changed, 350 insertions, 0 deletions
diff --git a/internal/repl/commands.go b/internal/repl/commands.go new file mode 100644 index 0000000..7800653 --- /dev/null +++ b/internal/repl/commands.go @@ -0,0 +1,95 @@ +package repl + +import ( + "fmt" + "strings" +) + +// builtinCommands defines the built-in REPL commands +var builtinCommands = []string{"help", "clear", "quit", "exit"} + +// Commands returns the list of built-in command names +func Commands() []string { + return builtinCommands +} + +// ExecuteCommand runs a built-in command and returns its output or error +func ExecuteCommand(cmd string) (string, error) { + args := strings.Fields(cmd) + if len(args) == 0 { + return "", nil + } + + switch strings.ToLower(args[0]) { + case "help": + return cmdHelp(args[1:]), nil + case "clear": + return "", cmdClear() + case "quit", "exit": + return "", cmdQuit() + default: + return "", fmt.Errorf("unknown command: %s. Available commands: help, clear, quit, exit", args[0]) + } +} + +func cmdHelp(subCmds []string) string { + helpText := `PERC - Percentage Calculator REPL + +Built-in Commands: + help Show this help message + help <command> Show help for a specific topic + clear Clear the screen + quit / exit Exit the REPL + +Usage Examples: + 20% of 150 Calculate 20% of 150 + what is 20% of 150 Same as above (what is prefix is optional) + 30 is what % of 150 Calculate what percentage 30 is of 150 + 30 is 20% of what Calculate what number 30 is 20% of + +Keyboard Shortcuts (Vi Mode): + Normal Mode: + i Enter Insert mode + a Append after cursor + 0 Move to beginning of line + $ Move to end of line + gg Move to top of history + G Move to bottom of history + Insert Mode: + ESC Return to Normal mode + Ctrl+C Cancel current input + +History Navigation: + Up Arrow Previous command + Down Arrow Next command + +Press Ctrl+D or type 'quit'/'exit' to exit. +` + + if len(subCmds) == 0 { + return helpText + } + + subCmd := strings.ToLower(subCmds[0]) + switch subCmd { + case "help": + return "help - Show this help message\nUsage: help [command]" + case "clear": + return "clear - Clear the screen\nUsage: clear" + case "quit", "exit": + return "quit / exit - Exit the REPL\nUsage: quit or exit" + default: + return fmt.Sprintf("No help available for: %s\nAvailable commands: help, clear, quit, exit", subCmd) + } +} + +func cmdClear() error { + // Clear screen using ANSI escape sequence + fmt.Print("\033[2J\033[H") + return nil +} + +func cmdQuit() error { + fmt.Println("Goodbye!") + return nil +} diff --git a/internal/repl/commands_test.go b/internal/repl/commands_test.go new file mode 100644 index 0000000..7a37579 --- /dev/null +++ b/internal/repl/commands_test.go @@ -0,0 +1,70 @@ +package repl + +import ( + "strings" + "testing" +) + +func TestExecuteCommandHelp(t *testing.T) { + output, err := ExecuteCommand("help") + if err != nil { + t.Fatalf("ExecuteCommand('help') returned error: %v", err) + } + if output == "" { + t.Error("ExecuteCommand('help') returned empty output") + } + if !strings.Contains(output, "PERC") { + t.Errorf("ExecuteCommand('help') output should contain 'PERC', got: %s", output[:50]) + } +} + +func TestExecuteCommandHelpWithSubcommand(t *testing.T) { + output, err := ExecuteCommand("help clear") + if err != nil { + t.Fatalf("ExecuteCommand('help clear') returned error: %v", err) + } + if output == "" { + t.Error("ExecuteCommand('help clear') returned empty output") + } + if !strings.Contains(output, "Clear") { + t.Errorf("ExecuteCommand('help clear') output should contain 'Clear', got: %s", output[:50]) + } +} + +func TestExecuteCommandUnknownCommand(t *testing.T) { + _, err := ExecuteCommand("unknown") + if err == nil { + t.Error("ExecuteCommand('unknown') should return error, got nil") + } + if !strings.Contains(err.Error(), "unknown command") { + t.Errorf("Error should mention 'unknown command', got: %v", err) + } +} + +func TestExecuteCommandClear(t *testing.T) { + _, err := ExecuteCommand("clear") + if err != nil { + t.Fatalf("ExecuteCommand('clear') returned error: %v", err) + } +} + +func TestExecuteCommandQuit(t *testing.T) { + _, err := ExecuteCommand("quit") + if err != nil { + t.Fatalf("ExecuteCommand('quit') returned error: %v", err) + } +} + +func TestExecuteCommandExit(t *testing.T) { + _, err := ExecuteCommand("exit") + if err != nil { + t.Fatalf("ExecuteCommand('exit') returned error: %v", err) + } +} + +func TestExecuteCommandEmpty(t *testing.T) { + _, err := ExecuteCommand("") + if err != nil { + t.Fatalf("ExecuteCommand('') returned error: %v", err) + } +} 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] +} |
