diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-13 10:23:00 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-13 10:23:00 +0300 |
| commit | d799f3f04da8067669c90a755e90cd723f9746e7 (patch) | |
| tree | 02c0c5dda7aaa0d79a230e043297ad822cf2b9f0 /internal/tui | |
| parent | 5856360a696b958a4c5c57cf512c5a04f0cfd66f (diff) | |
fix: use shell-aware tokenizer in resolveEditorCommand to handle EDITOR paths with spaces
Replace strings.Fields with a new shellSplit function that implements
POSIX-like quoting rules (single-quotes, double-quotes, backslash escapes),
so EDITOR values such as '/My Editor/hx' or "/path/with spaces/hx" --wait
are correctly parsed rather than mangled into multiple path components.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/eventstream/export.go | 91 | ||||
| -rw-r--r-- | internal/tui/eventstream/export_test.go | 80 |
2 files changed, 170 insertions, 1 deletions
diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go index f9534ff..155a551 100644 --- a/internal/tui/eventstream/export.go +++ b/internal/tui/eventstream/export.go @@ -11,6 +11,92 @@ import ( "time" ) +// shellSplit tokenizes s using POSIX-like shell quoting rules so that paths +// containing spaces (e.g. EDITOR='/My Editor/hx') are preserved as a single +// token. It supports: +// - single-quoted strings : no escape processing inside ' … ' +// - double-quoted strings : \" and \\ are recognised; other backslashes +// are kept verbatim +// - unquoted tokens : backslash escapes the next character +// +// Unterminated quotes are treated as if the closing delimiter is implicit at +// end-of-string, matching common shell lenient behaviour. +func shellSplit(s string) []string { + var tokens []string + var current strings.Builder + inToken := false + + i := 0 + for i < len(s) { + ch := s[i] + switch { + case ch == '\'': + // Single-quote: copy until the matching closing quote verbatim. + inToken = true + i++ + for i < len(s) && s[i] != '\'' { + current.WriteByte(s[i]) + i++ + } + // Skip closing quote if present; if missing we just fall through. + if i < len(s) { + i++ // consume the closing ' + } + + case ch == '"': + // Double-quote: process backslash escapes for \" and \\. + inToken = true + i++ + for i < len(s) && s[i] != '"' { + if s[i] == '\\' && i+1 < len(s) { + next := s[i+1] + if next == '"' || next == '\\' { + current.WriteByte(next) + i += 2 + continue + } + } + current.WriteByte(s[i]) + i++ + } + if i < len(s) { + i++ // consume the closing " + } + + case ch == '\\': + // Backslash outside quotes: escape the next character. + inToken = true + if i+1 < len(s) { + current.WriteByte(s[i+1]) + i += 2 + } else { + // Trailing backslash: keep it. + current.WriteByte('\\') + i++ + } + + case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r': + // Whitespace: flush current token if any. + if inToken { + tokens = append(tokens, current.String()) + current.Reset() + inToken = false + } + i++ + + default: + inToken = true + current.WriteByte(ch) + i++ + } + } + + if inToken { + tokens = append(tokens, current.String()) + } + return tokens +} + func defaultStreamExportFilename() string { return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405")) } @@ -144,7 +230,10 @@ func resolveEditorCommand() ([]string, string, error) { if value == "" { continue } - parts := strings.Fields(value) + // Use shellSplit instead of strings.Fields so that quoted paths with + // spaces (e.g. EDITOR='/My Editor/hx') are not broken into multiple + // tokens. + parts := shellSplit(value) if len(parts) == 0 { continue } diff --git a/internal/tui/eventstream/export_test.go b/internal/tui/eventstream/export_test.go index 9bdb2d2..6bebe6b 100644 --- a/internal/tui/eventstream/export_test.go +++ b/internal/tui/eventstream/export_test.go @@ -3,6 +3,7 @@ package eventstream import ( "os" "path/filepath" + "reflect" "testing" ) @@ -81,3 +82,82 @@ func TestResolveEditorCommandFallsBackToViWhenHxMissing(t *testing.T) { t.Fatalf("expected vi fallback, got %#v", parts) } } + +// TestResolveEditorCommandSingleQuotedPath verifies that an EDITOR value with +// a single-quoted path containing spaces is not broken into multiple tokens. +func TestResolveEditorCommandSingleQuotedPath(t *testing.T) { + t.Setenv("SUDO_EDITOR", "") + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "'/My Editor/hx'") + + parts, source, err := resolveEditorCommand() + if err != nil { + t.Fatalf("resolve editor: %v", err) + } + if source != "EDITOR" { + t.Fatalf("expected EDITOR source, got %q", source) + } + want := []string{"/My Editor/hx"} + if !reflect.DeepEqual(parts, want) { + t.Fatalf("expected %#v, got %#v", want, parts) + } +} + +// TestResolveEditorCommandDoubleQuotedPathWithArgs verifies that double-quoted +// paths with trailing arguments are all preserved correctly. +func TestResolveEditorCommandDoubleQuotedPathWithArgs(t *testing.T) { + t.Setenv("SUDO_EDITOR", "") + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", `"/path/with spaces/hx" --wait`) + + parts, source, err := resolveEditorCommand() + if err != nil { + t.Fatalf("resolve editor: %v", err) + } + if source != "EDITOR" { + t.Fatalf("expected EDITOR source, got %q", source) + } + want := []string{"/path/with spaces/hx", "--wait"} + if !reflect.DeepEqual(parts, want) { + t.Fatalf("expected %#v, got %#v", want, parts) + } +} + +// TestShellSplitVariousCases covers the tokenizer with a table-driven approach. +func TestShellSplitVariousCases(t *testing.T) { + cases := []struct { + input string + want []string + }{ + // Plain tokens — behaviour identical to strings.Fields. + {"vi", []string{"vi"}}, + {"nvim --wait", []string{"nvim", "--wait"}}, + // Single-quoted path with spaces — the whole quoted span is one token. + {"'/path/with spaces/hx'", []string{"/path/with spaces/hx"}}, + // Double-quoted path. + {`"/path/with spaces/hx"`, []string{"/path/with spaces/hx"}}, + // Double-quoted path with escaped double quote inside. + {`"/path/\"hx\""`, []string{`/path/"hx"`}}, + // Mixed: quoted binary + unquoted flag. + {`"/My Editor/hx" --wait`, []string{"/My Editor/hx", "--wait"}}, + // Backslash escaping a space outside quotes. + {`/path/with\ spaces/hx`, []string{"/path/with spaces/hx"}}, + // Empty string returns no tokens. + {"", nil}, + // Whitespace-only returns no tokens. + {" ", nil}, + // Unterminated single quote: treated as implicit close at end. + {"'unterminated", []string{"unterminated"}}, + } + + for _, tc := range cases { + got := shellSplit(tc.input) + // Treat nil and empty slice as equivalent for comparison purposes. + if len(got) == 0 && len(tc.want) == 0 { + continue + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("shellSplit(%q): got %#v, want %#v", tc.input, got, tc.want) + } + } +} |
