summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/eventstream/export.go91
-rw-r--r--internal/tui/eventstream/export_test.go80
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)
+ }
+ }
+}