From eb8653c9f5e137fb470b7eb0762a4e9c9bea36c2 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Tue, 6 Oct 2026 11:16:17 +0200 Subject: [PATCH] escape parentheses and other special characters in tab completion Co-Authored-By: Claude Opus 5.5 --- main.go | 8 +++++--- main_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 9befd27..3d61ebc 100644 --- a/main.go +++ b/main.go @@ -963,11 +963,13 @@ func currentWord(s string) (word string, start int, quote byte) { return b.String(), start, quote } -// escapeWord escapes characters that would otherwise be interpreted by shlex +// escapeWord escapes characters that would otherwise be interpreted by the +// shell. # and ~ are only special at the start of a word, so they are only +// escaped at the start of s. func escapeWord(s string) string { var b strings.Builder - for _, r := range s { - if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) { + for i, r := range s { + if strings.ContainsRune(" \t\\'\"`;|&<>()*?[]{}$!", r) || (i == 0 && (r == '#' || r == '~')) { b.WriteByte('\\') } b.WriteRune(r) diff --git a/main_test.go b/main_test.go index 2539696..09a0d1d 100644 --- a/main_test.go +++ b/main_test.go @@ -879,6 +879,43 @@ func TestShellCompleter_Do(t *testing.T) { } } +func TestEscapeWord(t *testing.T) { + tests := []struct{ in, want string }{ + {"plain.txt", "plain.txt"}, + {"my file (1).txt", `my\ file\ \(1\).txt`}, + {"a`b`$c", "a\\`b\\`\\$c"}, + {"a;b|c&df", `a\;b\|c\&d\f`}, + {"*?[x]{y}!", `\*\?\[x\]\{y\}\!`}, + {`it's "q"\`, `it\'s\ \"q\"\\`}, + {"#a#b", `\#a#b`}, + {"~a~b", `\~a~b`}, + } + for _, tt := range tests { + if got := escapeWord(tt.in); got != tt.want { + t.Errorf("escapeWord(%q) = %q; want %q", tt.in, got, tt.want) + } + } +} + +func TestShellCompleter_SpecialCharacters(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(t.TempDir()) + os.Create("song (live) & more.mp3") + + got, length := completeLine("ls so") + want := []string{`ng\ \(live\)\ \&\ more.mp3`} + if !reflect.DeepEqual(got, want) || length != 2 { + t.Errorf("Do(%q) = %q, %d; want %q, 2", "ls so", got, length, want) + } + // Inside quotes nothing is escaped + got, _ = completeLine(`ls "so`) + want = []string{"ng (live) & more.mp3"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Do(%q) = %q; want %q", `ls "so`, got, want) + } +} + // ============================================================================= // Security fixes: history file, quoting/expansion, escape sequences, SIGINT // =============================================================================