diff --git a/README.md b/README.md index 4afcba3..d7e827a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Both support the same query format, see the below annotated queries: |---|---| | `psql` | Find all commands containing `psql` | | `psql db.example.com` | Find all commands containing `psql` and `db.example.com` | -| `docker hostname:my-server` | Find all commands containing `docker` that were run on the computer with hostname `my-server` | +| `"docker run" hostname:my-server` | Find all commands containing `docker run` that were run on the computer with hostname `my-server` | | `nano user:root` | Find all commands containing `nano` that were run as `root` | | `exit_code:127` | Find all commands that exited with code `127` | | `service before:2022-02-01` | Find all commands containing `service` run before February 1st 2022 | @@ -74,7 +74,6 @@ If you would like to: -
TUI key bindings The TUI (opened via `Control+R`) supports a number of key bindings: diff --git a/client/client_test.go b/client/client_test.go index 82e867b..c058adb 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1864,6 +1864,35 @@ func testTui_search(t *testing.T, onlineStatus OnlineStatus) { }) out = stripTuiCommandPrefix(t, out) testutils.CompareGoldens(t, out, "TestTui-InvalidSearchBecomesValid") + + // Record a couple commands that we can use to test for supporting quoted searches + db := hctx.GetDb(hctx.MakeContext()) + require.NoError(t, db.Create(testutils.MakeFakeHistoryEntry("for i in 1")).Error) + require.NoError(t, db.Create(testutils.MakeFakeHistoryEntry("for i in 2")).Error) + require.NoError(t, db.Create(testutils.MakeFakeHistoryEntry("i for in")).Error) + out = tester.RunInteractiveShell(t, `hishtory export`) + testutils.CompareGoldens(t, out, "TestTui-ExportWithAdditionalEntries") + + // Check the behavior when it is unquoted and fuzzy + out = stripTuiCommandPrefix(t, captureTerminalOutput(t, tester, []string{ + "hishtory SPACE tquery ENTER", + "for SPACE i SPACE in", + })) + testutils.CompareGoldens(t, out, "TestTui-SearchUnquoted") + + // Check the behavior when it is quoted and exact + out = stripTuiCommandPrefix(t, captureTerminalOutput(t, tester, []string{ + "hishtory SPACE tquery ENTER", + "'\"'for SPACE i SPACE in'\"'", + })) + testutils.CompareGoldens(t, out, "TestTui-SearchQuoted") + + // Check the behavior when it is backslashed + out = stripTuiCommandPrefix(t, captureTerminalOutput(t, tester, []string{ + "hishtory SPACE tquery ENTER", + "for\\\\ SPACE i\\\\ SPACE in", + })) + testutils.CompareGoldens(t, out, "TestTui-SearchBackslash") } func testTui_general(t *testing.T, onlineStatus OnlineStatus) { diff --git a/client/lib/goldens/TestTui-ExportWithAdditionalEntries b/client/lib/goldens/TestTui-ExportWithAdditionalEntries new file mode 100644 index 0000000..c973dd6 --- /dev/null +++ b/client/lib/goldens/TestTui-ExportWithAdditionalEntries @@ -0,0 +1,5 @@ +ls ~/ +echo 'aaaaaa bbbb' +for i in 1 +for i in 2 +i for in diff --git a/client/lib/goldens/TestTui-SearchBackslash b/client/lib/goldens/TestTui-SearchBackslash new file mode 100644 index 0000000..76d5e98 --- /dev/null +++ b/client/lib/goldens/TestTui-SearchBackslash @@ -0,0 +1,27 @@ +Search Query: > for\ i\ in + +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Hostname CWD Timestamp Runtime Exit Code Command │ +│────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 for i in 2 │ +│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 for i in 1 │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +hiSHtory: Search your shell history • ctrl+h help \ No newline at end of file diff --git a/client/lib/goldens/TestTui-SearchQuoted b/client/lib/goldens/TestTui-SearchQuoted new file mode 100644 index 0000000..8260ced --- /dev/null +++ b/client/lib/goldens/TestTui-SearchQuoted @@ -0,0 +1,27 @@ +Search Query: > "for i in" + +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Hostname CWD Timestamp Runtime Exit Code Command │ +│────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 for i in 2 │ +│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 for i in 1 │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +hiSHtory: Search your shell history • ctrl+h help \ No newline at end of file diff --git a/client/lib/goldens/TestTui-SearchUnquoted b/client/lib/goldens/TestTui-SearchUnquoted new file mode 100644 index 0000000..179f8f9 --- /dev/null +++ b/client/lib/goldens/TestTui-SearchUnquoted @@ -0,0 +1,27 @@ +Search Query: > for i in + +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Hostname CWD Timestamp Runtime Exit Code Command │ +│────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ localhost /tmp/ Oct 17 2022 21:43:36 PDT 3s 2 i for in │ +│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 for i in 2 │ +│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 for i in 1 │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +hiSHtory: Search your shell history • ctrl+h help \ No newline at end of file diff --git a/client/lib/lib.go b/client/lib/lib.go index ebbb077..bcde3a4 100644 --- a/client/lib/lib.go +++ b/client/lib/lib.go @@ -978,19 +978,29 @@ func tokenize(query string) []string { return splitEscaped(query, ' ', -1) } +// TODO: Maybe add support for searching for the backslash character itself? func splitEscaped(query string, separator rune, maxSplit int) []string { var token []rune var tokens []string splits := 1 runeQuery := []rune(query) + isInQuotedString := false for i := 0; i < len(runeQuery); i++ { - if (maxSplit < 0 || splits < maxSplit) && runeQuery[i] == separator { + if (maxSplit < 0 || splits < maxSplit) && runeQuery[i] == separator && !isInQuotedString { tokens = append(tokens, string(token)) token = token[:0] splits++ } else if runeQuery[i] == '\\' && i+1 < len(runeQuery) { - token = append(token, runeQuery[i], runeQuery[i+1]) + if runeQuery[i+1] == '-' || runeQuery[i+1] == ':' || runeQuery[i+1] == '\\' { + // Note that we need to keep the backslash before the dash to support searches like `ls \-Slah`. + // And we need it before the colon so that we can search for things like `foo\:bar` + // And we need it before the backslash so that we can search for literal backslashes. + token = append(token, runeQuery[i]) + } i++ + token = append(token, runeQuery[i]) + } else if runeQuery[i] == '"' { + isInQuotedString = !isInQuotedString } else { token = append(token, runeQuery[i]) } diff --git a/client/lib/lib_test.go b/client/lib/lib_test.go index 61a60d2..29b8177 100644 --- a/client/lib/lib_test.go +++ b/client/lib/lib_test.go @@ -257,7 +257,7 @@ func TestContainsUnescaped(t *testing.T) { for _, tc := range testcases { actual := containsUnescaped(tc.input, tc.token) if !reflect.DeepEqual(actual, tc.expected) { - t.Fatalf("containsUnescaped failure for containsUnescaped(%#v, %#v), actual=%#v", tc.input, tc.token, actual) + t.Fatalf("failure for containsUnescaped(%#v, %#v), actual=%#v", tc.input, tc.token, actual) } } } @@ -274,16 +274,30 @@ func TestSplitEscaped(t *testing.T) { {"foo bar baz", ' ', 3, []string{"foo", "bar", "baz"}}, {"foo bar baz", ' ', 1, []string{"foo bar baz"}}, {"foo bar baz", ' ', -1, []string{"foo", "bar", "baz"}}, - {"foo\\ bar baz", ' ', -1, []string{"foo\\ bar", "baz"}}, - {"foo\\bar baz", ' ', -1, []string{"foo\\bar", "baz"}}, - {"foo\\bar baz foob", ' ', 2, []string{"foo\\bar", "baz foob"}}, - {"foo\\ bar\\ baz", ' ', -1, []string{"foo\\ bar\\ baz"}}, - {"foo\\ bar\\ baz", ' ', -1, []string{"foo\\ bar\\ ", "baz"}}, + {"foo\\ bar baz", ' ', -1, []string{"foo bar", "baz"}}, + {"foo\\bar baz", ' ', -1, []string{"foobar", "baz"}}, + {"foo\\bar baz foob", ' ', 2, []string{"foobar", "baz foob"}}, + {"foo\\ bar\\ baz", ' ', -1, []string{"foo bar baz"}}, + {"foo\\ bar\\ baz", ' ', -1, []string{"foo bar ", "baz"}}, + {"\"foo bar\"", ' ', -1, []string{"foo bar"}}, + {"\"foo bar\" \" \"", ' ', -1, []string{"foo bar", " "}}, + {"\"foo bar baz\" and", ' ', -1, []string{"foo bar baz", "and"}}, + {"\"foo bar baz\" and", ' ', -1, []string{"foo bar baz", "and"}}, + {"\"foo bar baz", ' ', -1, []string{"foo bar baz"}}, + {"\"foo bar baz\\\"\"", ' ', -1, []string{"foo bar baz\""}}, + {"cwd:\"foo bar :baz\\\"\"", ':', -1, []string{"cwd", "foo bar :baz\""}}, + {"cwd:\"foo bar :baz\\\"\"", ' ', -1, []string{"cwd:foo bar :baz\""}}, + {"ls \\-foo", ' ', -1, []string{"ls", "\\-foo"}}, + {"ls \\-foo \\a \\\\", ' ', -1, []string{"ls", "\\-foo", "a", "\\\\"}}, + {"foo:bar", ':', -1, []string{"foo", "bar"}}, + {"foo:bar", ' ', -1, []string{"foo:bar"}}, + {"foo\\:bar", ':', -1, []string{"foo\\:bar"}}, + {"foo\\:bar", ' ', -1, []string{"foo\\:bar"}}, } for _, tc := range testcases { actual := splitEscaped(tc.input, tc.char, tc.limit) if !reflect.DeepEqual(actual, tc.expected) { - t.Fatalf("containsUnescaped failure for splitEscaped(%#v, %#v, %#v), actual=%#v", tc.input, string(tc.char), tc.limit, actual) + t.Fatalf("failure for splitEscaped(%#v, %#v, %#v), actual=%#v", tc.input, string(tc.char), tc.limit, actual) } } }