diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-25 09:12:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-25 09:12:39 +0300 |
| commit | 9e4e8c9eae0849a6096133c8701a1c1105600374 (patch) | |
| tree | 93cc4f9c6839b0c3f8043da1531d888f327910f9 | |
| parent | 2ebfe46fe6e75aa876cee4fb5b543427ae07a368 (diff) | |
fix: don't offer exact-match completions to prevent readline duplication
When the typed word already exactly matches a command (e.g. 'help'),
return no completions. Readline would append the match, producing
'helphelp'. Partial matches still work normally (e.g. 'he' → 'help').
| -rw-r--r-- | completions/gt.fish | 395 | ||||
| -rw-r--r-- | internal/repl/completer.go | 5 | ||||
| -rw-r--r-- | internal/repl/completer_adapter_test.go | 16 |
3 files changed, 408 insertions, 8 deletions
diff --git a/completions/gt.fish b/completions/gt.fish new file mode 100644 index 0000000..d82e1bb --- /dev/null +++ b/completions/gt.fish @@ -0,0 +1,395 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Paul Buetow + +# Fish shell completions for gt — a command-line calculator with RPN and +# percentage arithmetic. +# +# Install: +# cp completions/gt.fish ~/.config/fish/completions/ +# # or system-wide: +# sudo cp completions/gt.fish /usr/local/share/fish/vendor_completions.d/ + +# ── Helpers ────────────────────────────────────────────────────────────────── + +# Return true when we are at the top-level (first argument after 'gt'). +function __fish_gt_needs_top_level + # Only match if we haven't seen any subcommand or flag yet + not __fish_seen_command_argument --exclusive +end + +# Return true when the current token is part of an RPN expression (i.e. we are +# NOT inside 'metric …' or 'custom …' subcommand trees that have their own +# completions). Used as a condition for completing operators / constants / +# units anywhere in the expression. +function __fish_gt_not_in_metric_or_custom + not __fish_seen_subcommand_from metric custom +end + +# ── Flags ──────────────────────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_needs_top_level" \ + -l log -r -d "Session log file (REPL mode)" + +# ── Top-level subcommands ──────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_needs_top_level" \ + -a version -d "Show version" + +# ── Expression prefixes ────────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "rpn" -d "RPN expression prefix" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "calc" -d "Calculation prefix (alias)" + +# ── Arithmetic operators ───────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "+" -d "Addition" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "-" -d "Subtraction" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "*" -d "Multiplication" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "/" -d "Division" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "^" -d "Power" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "**" -d "Fast integer power" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "%" -d "Modulo" + +# ── Logarithmic operators ──────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "lg" -d "Log base 2" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "log" -d "Log base 10" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "ln" -d "Natural log" + +# ── Comparison operators ───────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "gt" -d "Greater than" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "lt" -d "Less than" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a ">" -d "Greater than" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "<" -d "Less than" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "gte" -d "Greater or equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a ">=" -d "Greater or equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "lte" -d "Less or equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "<=" -d "Less or equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "eq" -d "Equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "==" -d "Equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "neq" -d "Not equal" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "!=" -d "Not equal" + +# ── Hyper (n-ary) operators ────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[+]" -d "Hyper add (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[-]" -d "Hyper subtract (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[*]" -d "Hyper multiply (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[/]" -d "Hyper divide (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[^]" -d "Hyper power (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[%]" -d "Hyper modulo (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[lg]" -d "Hyper log2 (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[log]" -d "Hyper log10 (n-ary)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "[ln]" -d "Hyper ln (n-ary)" + +# ── Stack operators ────────────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "dup" -d "Duplicate top of stack" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "swap" -d "Swap top two stack values" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "pop" -d "Discard top of stack" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "d" -d "Delete top two stack values" + +# ── Assignment / conversion operators ──────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a ":=" -d "Assign right-to-left" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "=:" -d "Assign left-to-right" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "convert" -d "Convert metric unit (with @unit)" + +# ── Command operators ──────────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "show" -d "Show stack" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "showstack" -d "Show stack (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "print" -d "Show stack (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "vars" -d "List variables" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "constants" -d "List constants" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "clear" -d "Clear all variables" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "clearconstants" -d "Reset constants to defaults" + +# ── Boolean literals ───────────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "true" -d "Boolean true (coerces to 1)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "false" -d "Boolean false (coerces to 0)" + +# ── Constants ──────────────────────────────────────────────────────────────── + +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "pi" -d "π (3.14159…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "e" -d "Euler's number (2.71828…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "euler" -d "Euler's number (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "phi" -d "Golden ratio (1.61803…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "sqrt2" -d "√2 (1.41421…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "sqrt3" -d "√3 (1.73205…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "sqrt5" -d "√5 (2.23606…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "ln2" -d "ln(2) (0.69314…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "log2" -d "ln(2) (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "ln10" -d "ln(10) (2.30258…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "log10" -d "ln(10) (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "log_e" -d "log₁₀(e) (0.43429…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "log_e10" -d "log₁₀(e) (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "tau" -d "τ = 2π (6.28318…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "inv_pi" -d "1/π (0.31830…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "inv_e" -d "1/e (0.36787…)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "inf" -d "Positive infinity" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "infinity" -d "Positive infinity (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "-inf" -d "Negative infinity" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "-infinity" -d "Negative infinity (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "nan" -d "NaN (Not a Number)" + +# ── Metric units ───────────────────────────────────────────────────────────── + +# DataRate (base: bps) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "bps" -d "Bits per second" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "Kbps" -d "Kilobits per second" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "Mbps" -d "Megabits per second" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "Gbps" -d "Gigabits per second" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "Tbps" -d "Terabits per second" + +# DataSize (base: bits) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "bits" -d "Bits" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "bytes" -d "Bytes" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "KB" -d "Kilobyte (SI / IEC)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "MB" -d "Megabyte (SI / IEC)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "GB" -d "Gigabyte (SI / IEC)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "TB" -d "Terabyte (SI / IEC)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "PB" -d "Petabyte (SI / IEC)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "KiB" -d "Kibibyte (1024 bytes)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "MiB" -d "Mebibyte (1024 KiB)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "GiB" -d "Gibibyte (1024 MiB)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "TiB" -d "Tebibyte (1024 GiB)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "PiB" -d "Pebibyte (1024 TiB)" + +# Time (base: seconds) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "ms" -d "Milliseconds" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "s" -d "Seconds" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "sec" -d "Seconds (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "secs" -d "Seconds (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "min" -d "Minutes" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "hr" -d "Hours" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "day" -d "Days" + +# Weight (base: kilograms) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "mg" -d "Milligrams" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "g" -d "Grams" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "kg" -d "Kilograms" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "ton" -d "Metric tons" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "lb" -d "Pounds" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "oz" -d "Ounces" + +# Speed (base: m/s) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "mps" -d "Meters per second" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "kmh" -d "Kilometers per hour" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "mph" -d "Miles per hour" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "knots" -d "Knots" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "knot" -d "Knots (alias)" + +# Distance (base: meters) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "m" -d "Meters" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "km" -d "Kilometers" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "mi" -d "Miles" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "mile" -d "Miles (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "miles" -d "Miles (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "ft" -d "Feet" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "foot" -d "Feet (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "feet" -d "Feet (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "in" -d "Inches" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "nm" -d "Nautical miles" + +# DataRate aliases (bit/s notation) +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "bit/s" -d "Bits per second (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "kbit/s" -d "Kilobits per second (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "mbit/s" -d "Megabits per second (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "gbit/s" -d "Gigabits per second (alias)" +complete -c gt -n "__fish_gt_not_in_metric_or_custom" \ + -a "tbit/s" -d "Terabits per second (alias)" + +# ── `metric` subcommand ────────────────────────────────────────────────────── + +complete -c gt -n "__fish_seen_subcommand_from metric" \ + -a "show" -d "Show metric info for top of stack" +complete -c gt -n "__fish_seen_subcommand_from metric" \ + -a "list" -d "List all metric categories" +complete -c gt -n "__fish_seen_subcommand_from metric" \ + -a "compatible" -d "Check metric compatibility (top 2 values)" +complete -c gt -n "__fish_seen_subcommand_from metric" \ + -a "decimal set" -d "Switch to SI prefix mode (1000-based)" +complete -c gt -n "__fish_seen_subcommand_from metric" \ + -a "binary set" -d "Switch to IEC prefix mode (1024-based)" + +# metric <category> — list metrics in a category +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "DataRate" -d "bps, Kbps, Mbps, Gbps, Tbps" +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "DataSize" -d "bits, bytes, KB, MB, GB, …" +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "Distance" -d "m, km, mi, ft, in, nm" +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "Speed" -d "mps, kmh, mph, knots" +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "Time" -d "ms, s, min, hr, day" +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "Universal" -d "Cool (unitless)" +complete -c gt \ + -n "__fish_seen_subcommand_from metric; and not __fish_seen_subcommand_from show list compatible 'decimal set' 'binary set'" \ + -a "Weight" -d "mg, g, kg, ton, lb, oz" + +# ── `custom` subcommand ────────────────────────────────────────────────────── + +complete -c gt -n "__fish_seen_subcommand_from custom" \ + -a "show" -d "Show custom metric(s)" +complete -c gt -n "__fish_seen_subcommand_from custom" \ + -a "list" -d "List all custom metrics" +complete -c gt -n "__fish_seen_subcommand_from custom" \ + -a "define" -d "Define a new custom metric" +complete -c gt -n "__fish_seen_subcommand_from custom" \ + -a "undefine" -d "Remove a custom metric" + +# custom define <name> <factor> <category> — complete categories +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "Custom" -d "Custom (user-defined)" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "DataRate" -d "Data rate category" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "DataSize" -d "Data size category" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "Distance" -d "Distance category" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "Speed" -d "Speed category" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "Time" -d "Time category" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "Universal" -d "Universal (unitless)" +complete -c gt \ + -n "__fish_seen_subcommand_from define" \ + -a "Weight" -d "Weight category" diff --git a/internal/repl/completer.go b/internal/repl/completer.go index f87209b..e19a521 100644 --- a/internal/repl/completer.go +++ b/internal/repl/completer.go @@ -84,6 +84,11 @@ func (a *AutoCompleteAdapter) completeCommands(lastWord string) ([][]rune, int) matches = append(matches, []rune(cmd)) } } + // If the word already exactly matches one command, don't offer it as a + // completion — readline would append it, giving e.g. "helphelp". + if len(matches) == 1 && string(matches[0]) == lastWord { + return nil, 0 + } return a.withCommonPrefix(matches, lastWord) } diff --git a/internal/repl/completer_adapter_test.go b/internal/repl/completer_adapter_test.go index 397a036..5f94a3b 100644 --- a/internal/repl/completer_adapter_test.go +++ b/internal/repl/completer_adapter_test.go @@ -48,19 +48,19 @@ func TestAutoCompleteAdapterDo(t *testing.T) { wantLen: len(commands), wantMinLen: 0, }, - // Exact matches + // Exact matches — no completion offered (readline would append the word) { name: "exact match help", line: []rune("help"), pos: 4, - wantLen: 1, + wantLen: 0, wantMinLen: 0, }, { name: "exact match clear", line: []rune("clear"), pos: 5, - wantLen: 1, + wantLen: 0, wantMinLen: 0, }, // Partial matches (single match, commonLen always 0 since minLen capped at len(lastWord)) @@ -165,12 +165,12 @@ func TestAutoCompleteAdapterDo(t *testing.T) { wantLen: 2, wantMinLen: -1, }, - // Multi-word input completes last word + // Multi-word input completes last word (exact match returns nothing) { name: "multi word input completes last word", line: []rune("rpn help"), pos: 8, - wantLen: 1, + wantLen: 0, wantMinLen: 0, }, // Cursor position matters @@ -272,10 +272,10 @@ func TestAutoCompleteAdapterDoPreserveCommandOrder(t *testing.T) { func TestAutoCompleteAdapterDoMultilineInput(t *testing.T) { adapter := NewAutoCompleter() - // Tab-separated words + // Tab-separated words — exact match "help" returns no completions matches, _ := adapter.Do([]rune("rpn\thelp"), 8) - if len(matches) != 1 { - t.Errorf("tab-separated 'rpn\\thelp' should match 'help', got %d: %v", + if len(matches) != 0 { + t.Errorf("tab-separated 'rpn\\thelp' (exact match) should return 0, got %d: %v", len(matches), runeSliceToStringSlice(matches)) } } |
