summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md (renamed from AGENT.md)0
-rw-r--r--Magefile.go4
-rw-r--r--cmd/perc/main.go16
-rw-r--r--go.mod10
-rw-r--r--go.sum25
-rw-r--r--internal/repl/commands.go95
-rw-r--r--internal/repl/commands_test.go70
-rw-r--r--internal/repl/repl.go185
-rw-r--r--internal/version.go2
9 files changed, 406 insertions, 1 deletions
diff --git a/AGENT.md b/AGENTS.md
index c6be708..c6be708 100644
--- a/AGENT.md
+++ b/AGENTS.md
diff --git a/Magefile.go b/Magefile.go
index bf51250..d5e5f91 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -23,3 +23,7 @@ func Test() error {
func Install() error {
return sh.RunV("go", "install", "./cmd/perc")
}
+
+func Repl() error {
+ return sh.RunV("go", "run", "./cmd/perc", "--repl")
+}
diff --git a/cmd/perc/main.go b/cmd/perc/main.go
index a5764b5..a9faf76 100644
--- a/cmd/perc/main.go
+++ b/cmd/perc/main.go
@@ -7,6 +7,8 @@ import (
"codeberg.org/snonux/perc/internal"
"codeberg.org/snonux/perc/internal/calculator"
+ "codeberg.org/snonux/perc/internal/repl"
+ "github.com/mattn/go-isatty"
)
func main() {
@@ -20,6 +22,11 @@ func main() {
func runCommand(args []string) (string, error) {
if len(args) < 2 {
+ // No args provided - check if stdin is a TTY for REPL mode
+ if isatty.IsTerminal(os.Stdin.Fd()) {
+ repl.RunREPL()
+ return "", nil
+ }
printUsage()
return "", fmt.Errorf("no input provided")
}
@@ -28,6 +35,12 @@ func runCommand(args []string) (string, error) {
return internal.Version, nil
}
+ // Check for --repl flag
+ if args[1] == "--repl" || args[1] == "repl" {
+ repl.RunREPL()
+ return "", nil
+ }
+
input := strings.Join(args[1:], " ")
result, err := calculator.Parse(input)
if err != nil {
@@ -40,9 +53,12 @@ func runCommand(args []string) (string, error) {
func printUsage() {
fmt.Println("Usage: perc <calculation>")
fmt.Println(" perc version")
+ fmt.Println(" perc [--repl|repl]")
fmt.Println("\nExamples:")
fmt.Println(" perc 20% of 150")
fmt.Println(" perc what is 20% of 150")
fmt.Println(" perc 30 is what % of 150")
fmt.Println(" perc 30 is 20% of what")
+ fmt.Println("\nStart REPL mode interactively by running without arguments:")
+ fmt.Println(" perc")
}
diff --git a/go.mod b/go.mod
index 1e5c5d2..c62105a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,3 +3,13 @@ module codeberg.org/snonux/perc
go 1.25.4
require github.com/magefile/mage v1.15.0
+
+require (
+ github.com/c-bata/go-prompt v0.2.6 // indirect
+ github.com/mattn/go-colorable v0.1.7 // indirect
+ github.com/mattn/go-isatty v0.0.12 // indirect
+ github.com/mattn/go-runewidth v0.0.9 // indirect
+ github.com/mattn/go-tty v0.0.3 // indirect
+ github.com/pkg/term v1.2.0-beta.2 // indirect
+ golang.org/x/sys v0.0.0-20200918174421-af09f7315aff // indirect
+)
diff --git a/go.sum b/go.sum
index 4ee1b87..7ba1af8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,27 @@
+github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI=
+github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
+github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
+github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI=
+github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
+github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw=
+github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200918174421-af09f7315aff h1:1CPUrky56AcgSpxz/KfgzQWzfG09u5YOL8MvPYBlrL8=
+golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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]
+}
diff --git a/internal/version.go b/internal/version.go
index 8826dc9..f85f296 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "v0.1.0"
+const Version = "v0.2.0"