diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-25 09:02:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-25 09:02:59 +0300 |
| commit | 7b3ff83f749724d452215fe135765b7e6aba92f5 (patch) | |
| tree | ad1c208a885b71ecc7625382988f5b3833464f53 | |
| parent | 8d1ded528b34ccdeb86cf18f0aaf2b9085430efd (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.go | 66 | ||||
| -rw-r--r-- | internal/repl/commands_test.go | 11 | ||||
| -rw-r--r-- | internal/repl/completer.go | 38 | ||||
| -rw-r--r-- | internal/repl/help.go | 654 | ||||
| -rw-r--r-- | internal/repl/help_test.go | 247 |
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") + } +} |
