summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-25 09:02:59 +0300
committerPaul Buetow <paul@buetow.org>2026-05-25 09:02:59 +0300
commit7b3ff83f749724d452215fe135765b7e6aba92f5 (patch)
treead1c208a885b71ecc7625382988f5b3833464f53
parent8d1ded528b34ccdeb86cf18f0aaf2b9085430efd (diff)
feat: inline help system with per-operator topics and auto-completion
Replace the old static help text with a data-driven help system that provides one help entry per operator, function, or REPL command, each with category, description, usage, and examples. - help.go: 35+ help topics covering all operators (arithmetic, comparison, stack, hyper, variables, constants, REPL commands) - GetHelp(topic) returns formatted help; GetHelp("") returns overview - help categories lists all topics grouped by category - Aliases supported (e.g. help gt shows help for > operator) - Auto-completion for help topics when typing 'help <TAB>' - REPL entries take priority in helpByTopic (e.g. 'help clear' shows screen clear, not RPN variable clear) - Comprehensive tests for all public functions
-rw-r--r--internal/repl/commands.go66
-rw-r--r--internal/repl/commands_test.go11
-rw-r--r--internal/repl/completer.go38
-rw-r--r--internal/repl/help.go654
-rw-r--r--internal/repl/help_test.go247
5 files changed, 946 insertions, 70 deletions
diff --git a/internal/repl/commands.go b/internal/repl/commands.go
index 5698478..8d181a4 100644
--- a/internal/repl/commands.go
+++ b/internal/repl/commands.go
@@ -53,72 +53,16 @@ func ExecuteCommand(cmd string) (string, error) {
}
// cmdHelp returns help text for built-in commands.
-// When called with no subcommands, it returns comprehensive help for all commands.
-// When called with a subcommand, it returns specific help for that command.
+// When called with no subcommands, it returns the general help overview.
+// When called with a subcommand, it returns specific inline help for that topic.
//
-// subCmds: optional slice of subcommand arguments (e.g., ["help"] for "help help")
+// subCmds: optional slice of subcommand arguments (e.g., ["+"] for "help +")
// Returns the help text as a string
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
- 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
- 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
-
-RPN (Reverse Polish Notation) Examples:
- rpn 3 4 + 3 + 4 = 7
- rpn 3 4 + 4 4 - * (3 + 4) * (4 - 4) = 0
- rpn x 5 = x x + Assign x=5, then x + x = 10
- rpn 2 3 ^ 2^3 = 8
- rpn 1 2 swap Swap top two stack values
- rpn 1 2 3 dup Duplicate top value
- rpn show Show current stack state
-
-Keyboard Shortcuts (Emacs Mode - default):
- Ctrl+A Go to beginning of line
- Ctrl+E Go to end of line
- Ctrl+L Clear the screen
- Ctrl+D Delete character under cursor
- Ctrl+H Delete character before cursor (Backspace)
- Ctrl+F Forward one character
- Ctrl+B Backward one character
- Ctrl+W Cut word before cursor
- Ctrl+K Cut line after cursor
- Ctrl+U Cut line before cursor
-
-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, rpn, calc, rat, stack", subCmd)
+ return GetHelp("")
}
+ return GetHelp(strings.ToLower(subCmds[0]))
}
// cmdClear clears the terminal screen using ANSI escape sequences.
diff --git a/internal/repl/commands_test.go b/internal/repl/commands_test.go
index 7d38d16..9e29932 100644
--- a/internal/repl/commands_test.go
+++ b/internal/repl/commands_test.go
@@ -34,8 +34,8 @@ func TestExecuteCommandHelpWithUnknownSubcommand(t *testing.T) {
if err != nil {
t.Fatalf("ExecuteCommand('help unknown') returned error: %v", err)
}
- if !strings.Contains(output, "No help available") {
- t.Errorf("ExecuteCommand('help unknown') should mention 'No help available', got: %s", output[:50])
+ if !strings.Contains(output, "No help for") {
+ t.Errorf("ExecuteCommand('help unknown') should mention 'No help for', got: %s", output[:80])
}
}
@@ -57,8 +57,11 @@ func TestExecuteCommandHelp(t *testing.T) {
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])
+ if !strings.Contains(output, "gt") {
+ t.Errorf("ExecuteCommand('help') output should contain 'gt', got: %s", output[:80])
+ }
+ if !strings.Contains(output, "RPN") {
+ t.Errorf("ExecuteCommand('help') output should mention RPN, got: %s", output[:80])
}
}
diff --git a/internal/repl/completer.go b/internal/repl/completer.go
index 70a0b86..ccb4a33 100644
--- a/internal/repl/completer.go
+++ b/internal/repl/completer.go
@@ -36,6 +36,7 @@ var _ readline.AutoCompleter = (*AutoCompleteAdapter)(nil)
// AutoCompleteAdapter implements the readline AutoCompleter interface,
// providing tab-completion suggestions for built-in commands.
+// When the first word is "help", it completes with help topics instead.
type AutoCompleteAdapter struct {
commands []string
}
@@ -49,24 +50,51 @@ func NewAutoCompleter() *AutoCompleteAdapter {
// Do implements the readline.AutoCompleter interface.
// It returns matching command completions for the given line.
+// When the first word is "help", it offers help topic completions.
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
+ return a.completeCommands("")
+ }
+
+ // If first word is "help", complete help topics
+ if strings.ToLower(words[0]) == "help" && len(words) > 1 {
+ return a.completeHelpTopics(words[len(words)-1])
}
lastWord := words[len(words)-1]
+ return a.completeCommands(lastWord)
+}
+
+// completeCommands returns matching command completions and prefix length.
+func (a *AutoCompleteAdapter) completeCommands(lastWord string) ([][]rune, int) {
var matches [][]rune
for _, cmd := range a.commands {
if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(lastWord)) {
matches = append(matches, []rune(cmd))
}
}
+ return a.withCommonPrefix(matches, lastWord)
+}
+
+// completeHelpTopics returns matching help topic completions and prefix length.
+func (a *AutoCompleteAdapter) completeHelpTopics(lastWord string) ([][]rune, int) {
+ var matches [][]rune
+ topics := GetCompletionTopics()
+ for _, topic := range topics {
+ if strings.HasPrefix(strings.ToLower(topic), strings.ToLower(lastWord)) {
+ matches = append(matches, []rune(topic))
+ }
+ }
+ return a.withCommonPrefix(matches, lastWord)
+}
+
+// withCommonPrefix calculates the common prefix adjustment for readline.
+func (a *AutoCompleteAdapter) withCommonPrefix(matches [][]rune, lastWord string) ([][]rune, int) {
+ if len(matches) == 0 {
+ return matches, 0
+ }
// Find common prefix length
minLen := len(lastWord)
diff --git a/internal/repl/help.go b/internal/repl/help.go
new file mode 100644
index 0000000..5192085
--- /dev/null
+++ b/internal/repl/help.go
@@ -0,0 +1,654 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package repl
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+)
+
+// HelpTopic holds the inline help content for one operator, function, or REPL command.
+type HelpTopic struct {
+ Category string // e.g. "Arithmetic", "Stack", "Comparison", "Variables", "Hyper", "REPL"
+ Operator string // the operator or command name (e.g. "+", "help", "rat")
+ Aliases []string
+ Description string
+ Usage string // short usage hint
+ Examples []string
+}
+
+// helpTopics is the single source of truth for all inline help entries.
+// Each entry documents one operator, function, or REPL command with examples.
+var helpTopics = []HelpTopic{
+ // ── REPL commands ──
+
+ {
+ Category: "REPL",
+ Operator: "help",
+ Description: "Show help information",
+ Usage: "help [topic]",
+ Examples: []string{
+ "help Show general help overview",
+ "help + Show help for the + operator",
+ "help dup Show help for dup",
+ },
+ },
+ {
+ Category: "REPL",
+ Operator: "clear",
+ Description: "Clear the terminal screen",
+ Usage: "clear",
+ Examples: []string{
+ "clear",
+ },
+ },
+ {
+ Category: "REPL",
+ Operator: "quit",
+ Aliases: []string{"exit"},
+ Description: "Exit the REPL",
+ Usage: "quit or exit",
+ Examples: []string{
+ "quit",
+ },
+ },
+ {
+ Category: "REPL",
+ Operator: "rpn",
+ Aliases: []string{"calc"},
+ Description: "Evaluate an RPN (Reverse Polish Notation) expression",
+ Usage: "rpn <expression>",
+ Examples: []string{
+ "rpn 3 4 + Evaluate 3 + 4 = 7",
+ "rpn 10 2 / Evaluate 10 / 2 = 5",
+ "calc 5 3 * Same as 'rpn 5 3 *'",
+ },
+ },
+ {
+ Category: "REPL",
+ Operator: "rat",
+ Description: "Switch between float64 and rational number modes",
+ Usage: "rat on|off|toggle",
+ Examples: []string{
+ "rat on Enable rational mode",
+ "rat off Disable rational mode (float64)",
+ "rat toggle Toggle between modes",
+ },
+ },
+ {
+ Category: "REPL",
+ Operator: "stack",
+ Description: "Show current RPN stack state",
+ Usage: "stack",
+ Examples: []string{
+ "1 2 3 stack Show stack: 1 2 3",
+ },
+ },
+
+ // ── Arithmetic operators ──
+
+ {
+ Category: "Arithmetic",
+ Operator: "+",
+ Description: "Add two numbers (metric-aware)",
+ Usage: "a b +",
+ Examples: []string{
+ "3 4 + 3 + 4 = 7",
+ "100Mbps 50 + 100Mbps + 50Mbps = 150Mbps (Cool absorbs metric)",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "-",
+ Description: "Subtract two numbers (metric-aware)",
+ Usage: "a b -",
+ Examples: []string{
+ "10 3 - 10 - 3 = 7",
+ "200Mbps 50 - 200Mbps - 50Mbps = 150Mbps",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "*",
+ Description: "Multiply two numbers",
+ Usage: "a b *",
+ Examples: []string{
+ "3 4 * 3 * 4 = 12",
+ "100Mbps 2 * 100Mbps * 2 = 200Mbps (Cool preserves metric)",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "/",
+ Description: "Divide two numbers (a / b)",
+ Usage: "a b /",
+ Examples: []string{
+ "10 3 / 10 / 3 = 3.333...",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "^",
+ Description: "Raise to power (a ^ b), result is always unitless",
+ Usage: "a b ^",
+ Examples: []string{
+ "2 3 ^ 2 ^ 3 = 8",
+ "10 0.5 ^ 10 ^ 0.5 = sqrt(10) = 3.162...",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "**",
+ Description: "Raise to integer power (a ** b) using fast binary exponentiation",
+ Usage: "a b **",
+ Examples: []string{
+ "2 10 ** 2 ^ 10 = 1024",
+ "3 6 ** 3 ^ 6 = 729",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "%",
+ Description: "Modulo (remainder of a / b)",
+ Usage: "a b %",
+ Examples: []string{
+ "10 3 % 10 mod 3 = 1",
+ "7 2 % 7 mod 2 = 1",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "lg",
+ Description: "Logarithm base 2",
+ Usage: "a lg",
+ Examples: []string{
+ "8 lg log2(8) = 3",
+ "1024 lg log2(1024) = 10",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "log",
+ Description: "Logarithm base 10",
+ Usage: "a log",
+ Examples: []string{
+ "100 log log10(100) = 2",
+ "1000 log log10(1000) = 3",
+ },
+ },
+ {
+ Category: "Arithmetic",
+ Operator: "ln",
+ Description: "Natural logarithm (base e)",
+ Usage: "a ln",
+ Examples: []string{
+ "2.71828 ln ln(2.71828) ~= 1",
+ "1 ln ln(1) = 0",
+ },
+ },
+
+ // ── Comparison operators ──
+
+ {
+ Category: "Comparison",
+ Operator: ">",
+ Aliases: []string{"gt"},
+ Description: "Greater than: pushes true (1) or false (0)",
+ Usage: "a b >",
+ Examples: []string{
+ "5 3 > 5 > 3 = true (1)",
+ "3 5 > 3 > 5 = false (0)",
+ },
+ },
+ {
+ Category: "Comparison",
+ Operator: "<",
+ Aliases: []string{"lt"},
+ Description: "Less than: pushes true (1) or false (0)",
+ Usage: "a b <",
+ Examples: []string{
+ "3 5 < 3 < 5 = true (1)",
+ "5 3 < 5 < 3 = false (0)",
+ },
+ },
+ {
+ Category: "Comparison",
+ Operator: ">=",
+ Aliases: []string{"gte"},
+ Description: "Greater than or equal: pushes true (1) or false (0)",
+ Usage: "a b >=",
+ Examples: []string{
+ "5 5 >= 5 >= 5 = true (1)",
+ "3 5 >= 3 >= 5 = false (0)",
+ },
+ },
+ {
+ Category: "Comparison",
+ Operator: "<=",
+ Aliases: []string{"lte"},
+ Description: "Less than or equal: pushes true (1) or false (0)",
+ Usage: "a b <=",
+ Examples: []string{
+ "3 3 <= 3 <= 3 = true (1)",
+ "5 3 <= 5 <= 3 = false (0)",
+ },
+ },
+ {
+ Category: "Comparison",
+ Operator: "==",
+ Aliases: []string{"eq"},
+ Description: "Equal: pushes true (1) or false (0)",
+ Usage: "a b ==",
+ Examples: []string{
+ "5 5 == 5 == 5 = true (1)",
+ "5 3 == 5 == 3 = false (0)",
+ },
+ },
+ {
+ Category: "Comparison",
+ Operator: "!=",
+ Aliases: []string{"neq"},
+ Description: "Not equal: pushes true (1) or false (0)",
+ Usage: "a b !=",
+ Examples: []string{
+ "5 3 != 5 != 3 = true (1)",
+ "5 5 != 5 != 5 = false (0)",
+ },
+ },
+
+ // ── Stack operators ──
+
+ {
+ Category: "Stack",
+ Operator: "dup",
+ Description: "Duplicate the top stack value",
+ Usage: "a dup",
+ Examples: []string{
+ "5 dup Stack: 5 5",
+ "3 dup dup Stack: 3 3 3",
+ },
+ },
+ {
+ Category: "Stack",
+ Operator: "swap",
+ Description: "Swap the top two stack values",
+ Usage: "a b swap",
+ Examples: []string{
+ "1 2 swap Stack: 2 1",
+ },
+ },
+ {
+ Category: "Stack",
+ Operator: "pop",
+ Description: "Remove and discard the top stack value",
+ Usage: "pop",
+ Examples: []string{
+ "1 2 3 pop Stack: 1 2",
+ },
+ },
+ {
+ Category: "Stack",
+ Operator: "d",
+ Description: "Pop a symbol from stack and delete that variable",
+ Usage: ":x d",
+ Examples: []string{
+ ":x d Delete variable x",
+ },
+ },
+ {
+ Category: "Stack",
+ Operator: "show",
+ Aliases: []string{"showstack", "print"},
+ Description: "Display the current stack contents",
+ Usage: "show",
+ Examples: []string{
+ "1 2 3 show Prints: 1 2 3",
+ "show Prints: Stack is empty",
+ },
+ },
+
+ // ── Variable operators ──
+
+ {
+ Category: "Variables",
+ Operator: ":=",
+ Description: "Assign value to variable (name on bottom, value on top)",
+ Usage: ":name value :=",
+ Examples: []string{
+ ":x 5 := Set variable x to 5",
+ ":y 3.14 := Set variable y to 3.14",
+ },
+ },
+ {
+ Category: "Variables",
+ Operator: "=:",
+ Description: "Assign value to variable (value on bottom, name on top)",
+ Usage: "value :name =:",
+ Examples: []string{
+ "5 :x =: Set variable x to 5",
+ },
+ },
+ {
+ Category: "Variables",
+ Operator: "vars",
+ Description: "List all defined variables and their values",
+ Usage: "vars",
+ Examples: []string{
+ "vars List all variables",
+ },
+ },
+ {
+ Category: "Variables",
+ Operator: "clear",
+ Description: "Clear all user-defined variables",
+ Usage: "clear",
+ Examples: []string{
+ "clear Remove all variables",
+ },
+ },
+ {
+ Category: "Variables",
+ Operator: "convert",
+ Description: "Convert a value to a target metric (@X syntax)",
+ Usage: "value @target convert",
+ Examples: []string{
+ "100Mbps @bps convert Convert 100Mbps to bps",
+ },
+ },
+
+ // ── Constants ──
+
+ {
+ Category: "Constants",
+ Operator: "constants",
+ Description: "List all available constants",
+ Usage: "constants",
+ Examples: []string{
+ "constants List all built-in constants",
+ },
+ },
+ {
+ Category: "Constants",
+ Operator: "clearconstants",
+ Description: "Reset all constants to built-in defaults",
+ Usage: "clearconstants",
+ Examples: []string{
+ "clearconstants Reset constants",
+ },
+ },
+
+ // ── Hyper (n-ary) operators ──
+
+ {
+ Category: "Hyper",
+ Operator: "[+]",
+ Description: "Add all stack values together (n-ary)",
+ Usage: "a b c [+] ...",
+ Examples: []string{
+ "1 2 3 [+] 1 + 2 + 3 = 6",
+ "10 20 30 40 [+] 100",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[-]",
+ Description: "Subtract all stack values left-associative (n-ary)",
+ Usage: "a b c [-] ...",
+ Examples: []string{
+ "10 3 2 [-] 10 - 3 - 2 = 5",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[*]",
+ Description: "Multiply all stack values together (n-ary)",
+ Usage: "a b c [*] ...",
+ Examples: []string{
+ "2 3 4 [*] 2 * 3 * 4 = 24",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[/]",
+ Description: "Divide all stack values left-associative (n-ary)",
+ Usage: "a b c [/] ...",
+ Examples: []string{
+ "100 2 5 [/] 100 / 2 / 5 = 10",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[^]",
+ Description: "Power all stack values left-associative (n-ary)",
+ Usage: "a b c [^] ...",
+ Examples: []string{
+ "2 3 2 [^] 2 ^ 3 ^ 2 = 8",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[%]",
+ Description: "Modulo all stack values left-associative (n-ary)",
+ Usage: "a b c [%] ...",
+ Examples: []string{
+ "100 10 3 [%] 100 % 10 % 3 = 1",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[lg]",
+ Description: "Sum of log2 of all stack values (n-ary)",
+ Usage: "a b [lg] ...",
+ Examples: []string{
+ "2 4 [lg] log2(2) + log2(4) = 1 + 2 = 3",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[log]",
+ Description: "Sum of log10 of all stack values (n-ary)",
+ Usage: "a b [log] ...",
+ Examples: []string{
+ "10 100 [log] log10(10) + log10(100) = 1 + 2 = 3",
+ },
+ },
+ {
+ Category: "Hyper",
+ Operator: "[ln]",
+ Description: "Sum of natural log of all stack values (n-ary)",
+ Usage: "a b [ln] ...",
+ Examples: []string{
+ "1 2.71828 [ln] ln(1) + ln(2.71828) = 0 + 1 = 1",
+ },
+ },
+}
+
+// buildHelpIndex builds lookup maps from helpTopics at package init.
+var (
+ helpByTopic = make(map[string]*HelpTopic) // operator -> topic
+ helpByAlias = make(map[string]*HelpTopic) // alias -> topic
+ helpByCat = make(map[string][]string) // category -> []operator names
+ categoryOrder []string // insertion order of categories
+)
+
+func init() {
+ seenCat := make(map[string]bool)
+ for i := range helpTopics {
+ t := &helpTopics[i]
+ for _, a := range t.Aliases {
+ helpByAlias[a] = t
+ }
+ if !seenCat[t.Category] {
+ seenCat[t.Category] = true
+ categoryOrder = append(categoryOrder, t.Category)
+ }
+ helpByCat[t.Category] = append(helpByCat[t.Category], t.Operator)
+ }
+ // Iterate in reverse so REPL entries (last in slice) take priority
+ // in helpByTopic. This means `help clear` shows the REPL screen-clear
+ // help rather than the RPN variable-clear help.
+ for i := len(helpTopics) - 1; i >= 0; i-- {
+ t := &helpTopics[i]
+ helpByTopic[t.Operator] = t
+ }
+}
+
+// GetHelp returns the formatted help text for a topic.
+// If topic is empty, it returns the general help overview.
+// If topic is "categories", it lists all categories.
+func GetHelp(topic string) string {
+ if topic == "" {
+ return getGeneralHelp()
+ }
+ if topic == "categories" {
+ return getCategoriesHelp()
+ }
+
+ // Look up by operator name or alias
+ topic = strings.ToLower(topic)
+ t, ok := helpByTopic[topic]
+ if !ok {
+ t, ok = helpByAlias[topic]
+ }
+ if !ok {
+ return fmt.Sprintf("No help for %q.\n\nType 'help' for an overview, or 'help categories' to list all topics.", topic)
+ }
+ return formatTopic(t)
+}
+
+// GetAllTopics returns a sorted list of all help topic names.
+func GetAllTopics() []string {
+ topics := make([]string, 0, len(helpByTopic))
+ for op := range helpByTopic {
+ topics = append(topics, op)
+ }
+ slices.Sort(topics)
+ return topics
+}
+
+// GetCompletionTopics returns all help topics suitable for tab completion.
+// Includes operator names, aliases, and special topics like "categories".
+func GetCompletionTopics() []string {
+ seen := make(map[string]bool)
+ var topics []string
+
+ // Add "categories" as a special topic
+ topics = append(topics, "categories")
+
+ // Add all operator names
+ for op, t := range helpByTopic {
+ topics = append(topics, op)
+ seen[op] = true
+ // Add aliases
+ for _, a := range t.Aliases {
+ if !seen[a] {
+ topics = append(topics, a)
+ seen[a] = true
+ }
+ }
+ }
+ slices.Sort(topics)
+ return topics
+}
+
+// getGeneralHelp returns the overview help text.
+func getGeneralHelp() string {
+ var sb strings.Builder
+
+ sb.WriteString("gt - Reverse Polish Notation (RPN) Calculator\n\n")
+ sb.WriteString("Help Topics:\n")
+ sb.WriteString(" help [topic] Show help for a specific topic\n")
+ sb.WriteString(" help categories List all available help topics by category\n\n")
+
+ sb.WriteString("REPL Commands:\n")
+ for _, op := range helpByCat["REPL"] {
+ t := helpByTopic[op]
+ sb.WriteString(fmt.Sprintf(" %-12s %s\n", op, t.Description))
+ }
+
+ sb.WriteString("\nArithmetic Operators:\n")
+ for _, op := range helpByCat["Arithmetic"] {
+ t := helpByTopic[op]
+ sb.WriteString(fmt.Sprintf(" %-8s %s\n", op, t.Description))
+ }
+
+ sb.WriteString("\nComparison Operators:\n")
+ for _, op := range helpByCat["Comparison"] {
+ t := helpByTopic[op]
+ sb.WriteString(fmt.Sprintf(" %-8s %s\n", op, t.Description))
+ }
+
+ sb.WriteString("\nStack Operators:\n")
+ for _, op := range helpByCat["Stack"] {
+ t := helpByTopic[op]
+ sb.WriteString(fmt.Sprintf(" %-12s %s\n", op, t.Description))
+ }
+
+ sb.WriteString("\nHyper (n-ary) Operators:\n")
+ for _, op := range helpByCat["Hyper"] {
+ t := helpByTopic[op]
+ sb.WriteString(fmt.Sprintf(" %-8s %s\n", op, t.Description))
+ }
+
+ sb.WriteString("\nVariables and Constants:\n")
+ for _, cat := range []string{"Variables", "Constants"} {
+ for _, op := range helpByCat[cat] {
+ t := helpByTopic[op]
+ sb.WriteString(fmt.Sprintf(" %-16s %s\n", op, t.Description))
+ }
+ }
+
+ sb.WriteString("\nKeyboard Shortcuts:\n")
+ sb.WriteString(" Ctrl+A Go to beginning of line\n")
+ sb.WriteString(" Ctrl+E Go to end of line\n")
+ sb.WriteString(" Ctrl+L Clear screen\n")
+ sb.WriteString(" Ctrl+D Exit / delete character\n")
+ sb.WriteString(" Up/Down History navigation\n")
+ sb.WriteString(" Tab Auto-complete\n")
+
+ sb.WriteString("\nUse 'help <topic>' for details on any operator or command.\n")
+ sb.WriteString("Use 'help categories' to see all available topics.\n")
+
+ return sb.String()
+}
+
+// getCategoriesHelp lists all categories and their topics.
+func getCategoriesHelp() string {
+ var sb strings.Builder
+ sb.WriteString("Available help topics by category:\n\n")
+ for _, cat := range categoryOrder {
+ sb.WriteString(fmt.Sprintf(" %s:\n", cat))
+ for _, op := range helpByCat[cat] {
+ t := helpByTopic[op]
+ aliasStr := ""
+ if len(t.Aliases) > 0 {
+ aliasStr = " (" + strings.Join(t.Aliases, ", ") + ")"
+ }
+ sb.WriteString(fmt.Sprintf(" %-16s %s%s\n", op, t.Description, aliasStr))
+ }
+ sb.WriteString("\n")
+ }
+ sb.WriteString("Use 'help <topic>' for details on any topic.\n")
+ return sb.String()
+}
+
+// formatTopic returns the detailed help for a single HelpTopic.
+func formatTopic(t *HelpTopic) string {
+ var sb strings.Builder
+
+ sb.WriteString(fmt.Sprintf("Topic: %s (Category: %s)\n", t.Operator, t.Category))
+ if len(t.Aliases) > 0 {
+ sb.WriteString(fmt.Sprintf("Aliases: %s\n", strings.Join(t.Aliases, ", ")))
+ }
+ sb.WriteString(fmt.Sprintf("Usage: %s\n", t.Usage))
+ sb.WriteString(fmt.Sprintf("Desc: %s\n", t.Description))
+
+ sb.WriteString("\nExamples:\n")
+ for _, ex := range t.Examples {
+ sb.WriteString(fmt.Sprintf(" %s\n", ex))
+ }
+
+ return sb.String()
+}
diff --git a/internal/repl/help_test.go b/internal/repl/help_test.go
new file mode 100644
index 0000000..ca77462
--- /dev/null
+++ b/internal/repl/help_test.go
@@ -0,0 +1,247 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package repl
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGetHelpEmptyReturnsOverview(t *testing.T) {
+ output := GetHelp("")
+ if !strings.Contains(output, "gt") {
+ t.Error("General help should mention 'gt'")
+ }
+ if !strings.Contains(output, "help categories") {
+ t.Error("General help should mention 'help categories'")
+ }
+ if !strings.Contains(output, "Arithmetic") {
+ t.Error("General help should list Arithmetic section")
+ }
+}
+
+func TestGetHelpCategories(t *testing.T) {
+ output := GetHelp("categories")
+ if !strings.Contains(output, "REPL") {
+ t.Error("Categories should list REPL")
+ }
+ if !strings.Contains(output, "Arithmetic") {
+ t.Error("Categories should list Arithmetic")
+ }
+ if !strings.Contains(output, "Hyper") {
+ t.Error("Categories should list Hyper")
+ }
+}
+
+func TestGetHelpClearReturnsREPL(t *testing.T) {
+ // "clear" exists in both REPL (screen clear) and Variables (clear vars)
+ // helpByTopic should prefer the REPL entry
+ output := GetHelp("clear")
+ if !strings.Contains(output, "screen") && !strings.Contains(output, "terminal") {
+ t.Errorf("'help clear' should show REPL screen clear, got: %s", output[:80])
+ }
+}
+
+func TestGetHelpKnownOperator(t *testing.T) {
+ output := GetHelp("+")
+ if !strings.Contains(output, "Add") {
+ t.Errorf("'help +' should describe Add, got: %s", output[:80])
+ }
+ if !strings.Contains(output, "Examples:") {
+ t.Error("'help +' should have examples")
+ }
+ if !strings.Contains(output, "3 4 +") {
+ t.Error("'help +' should have example '3 4 +'")
+ }
+}
+
+func TestGetHelpOperatorWithAliases(t *testing.T) {
+ // Test by alias
+ output := GetHelp("gt")
+ if !strings.Contains(output, ">") {
+ t.Errorf("'help gt' should show > operator, got: %s", output[:80])
+ }
+ if !strings.Contains(output, "Aliases:") {
+ t.Error("'help gt' should show aliases")
+ }
+}
+
+func TestGetHelpHyperOperator(t *testing.T) {
+ output := GetHelp("[+]")
+ if !strings.Contains(output, "Add all stack values") {
+ t.Errorf("'help [+]' should describe hyper add, got: %s", output[:80])
+ }
+ if !strings.Contains(output, "Hyper") {
+ t.Error("'help [+]' should be in Hyper category")
+ }
+}
+
+func TestGetHelpVariableOperator(t *testing.T) {
+ output := GetHelp(":=")
+ if !strings.Contains(output, "Assign") {
+ t.Errorf("'help :=' should describe assignment, got: %s", output[:80])
+ }
+ if !strings.Contains(output, "Variables") {
+ t.Error("'help :=' should be in Variables category")
+ }
+}
+
+func TestGetHelpUnknownTopic(t *testing.T) {
+ output := GetHelp("nonexistent")
+ if !strings.Contains(output, "No help for") {
+ t.Errorf("'help nonexistent' should say no help, got: %s", output)
+ }
+ if !strings.Contains(output, "help categories") {
+ t.Error("'help nonexistent' should suggest 'help categories'")
+ }
+}
+
+func TestGetHelpCaseInsensitive(t *testing.T) {
+ output := GetHelp("lg")
+ outputUpper := GetHelp("LG")
+ if output != outputUpper {
+ t.Error("Help should handle case consistently for lg/LG")
+ }
+}
+
+func TestGetAllTopics(t *testing.T) {
+ topics := GetAllTopics()
+ if len(topics) == 0 {
+ t.Error("GetAllTopics() should return topics")
+ }
+
+ // Check known topics are present
+ expectedTopics := []string{"+", "-", "*", "/", "dup", "swap", "help", "rat", "[+]"}
+ for _, expected := range expectedTopics {
+ found := false
+ for _, t := range topics {
+ if t == expected {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("GetAllTopics() missing topic %q", expected)
+ }
+ }
+}
+
+func TestGetCompletionTopicsIncludesAliases(t *testing.T) {
+ topics := GetCompletionTopics()
+
+ // Should include aliases
+ expectedAliases := []string{"exit", "calc", "gt", "lt", "categories"}
+ for _, expected := range expectedAliases {
+ found := false
+ for _, t := range topics {
+ if t == expected {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("GetCompletionTopics() missing alias/topic %q", expected)
+ }
+ }
+}
+
+func TestGetCompletionTopicsIsSorted(t *testing.T) {
+ topics := GetCompletionTopics()
+ for i := 1; i < len(topics); i++ {
+ if topics[i] < topics[i-1] {
+ t.Errorf("Topics not sorted: %q > %q at index %d", topics[i-1], topics[i], i)
+ }
+ }
+}
+
+func TestFormatTopic(t *testing.T) {
+ topic := helpByTopic["+"]
+ if topic == nil {
+ t.Fatal("helpByTopic[\"+\"] is nil")
+ }
+
+ output := formatTopic(topic)
+ if !strings.Contains(output, "Topic:") {
+ t.Error("formatTopic should contain 'Topic:'")
+ }
+ if !strings.Contains(output, "Usage:") {
+ t.Error("formatTopic should contain 'Usage:'")
+ }
+ if !strings.Contains(output, "Desc:") {
+ t.Error("formatTopic should contain 'Desc:'")
+ }
+ if !strings.Contains(output, "Examples:") {
+ t.Error("formatTopic should contain 'Examples:'")
+ }
+}
+
+func TestHelpTopicsNoDuplicates(t *testing.T) {
+ // Operators can appear in multiple categories (e.g. "clear" in REPL and Variables)
+ // Check for true duplicates: same operator within the same category
+ seen := make(map[string]map[string]bool) // category -> operators
+ for _, topic := range helpTopics {
+ if seen[topic.Category] == nil {
+ seen[topic.Category] = make(map[string]bool)
+ }
+ if seen[topic.Category][topic.Operator] {
+ t.Errorf("Duplicate topic operator %q in category %q", topic.Operator, topic.Category)
+ }
+ seen[topic.Category][topic.Operator] = true
+ }
+}
+
+func TestHelpByAliasResolution(t *testing.T) {
+ // Test that aliases resolve to the correct topic
+ output := GetHelp("exit")
+ if !strings.Contains(output, "Exit") {
+ t.Errorf("'help exit' should describe exit, got: %s", output[:80])
+ }
+
+ output = GetHelp("calc")
+ if !strings.Contains(output, "RPN") {
+ t.Errorf("'help calc' should describe RPN, got: %s", output[:80])
+ }
+
+ output = GetHelp("showstack")
+ if !strings.Contains(output, "stack") {
+ t.Errorf("'help showstack' should mention stack, got: %s", output[:80])
+ }
+}
+
+func TestHelpCompleterIntegration(t *testing.T) {
+ adapter := NewAutoCompleter()
+ if adapter == nil {
+ t.Fatal("NewAutoCompleter returned nil")
+ }
+
+ // Test help topic completion
+ matches, _ := adapter.Do([]rune("help +"), 6)
+ if len(matches) != 1 {
+ t.Errorf("'help +' should match +, got %d matches: %v", len(matches), matches)
+ }
+
+ // Test partial help topic completion
+ matches, _ = adapter.Do([]rune("help du"), 7)
+ if len(matches) != 1 {
+ t.Errorf("'help du' should match dup, got %d matches: %v", len(matches), matches)
+ }
+}
+
+func TestCmdHelpIntegration(t *testing.T) {
+ // cmdHelp should delegate to GetHelp
+ output := cmdHelp(nil)
+ if !strings.Contains(output, "gt") {
+ t.Error("cmdHelp(nil) should return general help")
+ }
+
+ output = cmdHelp([]string{"+"})
+ if !strings.Contains(output, "Add") {
+ t.Errorf("cmdHelp(['+']) should return + help, got: %s", output[:80])
+ }
+
+ output = cmdHelp([]string{"categories"})
+ if !strings.Contains(output, "REPL") {
+ t.Error("cmdHelp(['categories']) should list categories")
+ }
+}