diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f99ed14..51fe9059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Replace JSONPath querying with general purpose shell commands for querying response bodies + - Now you can access any CLI tools you want for transforming response bodies, such as `jq` or `grep` + - By default, commands are executed via `sh` (or `cmd` on Windows), but this is configured via the [`commands.shell` field](https://slumber.lucaspickering.me/book/api/configuration/index.html) - Add `slumber history` subcommand. Currently it has two operations: - `slumber history list` lists all stored requests for a recipe - `slumber history get` prints a specific request/response diff --git a/Cargo.lock b/Cargo.lock index 588e54b5..6bc1db39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2322,12 +2322,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" -[[package]] -name = "shellish_parse" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c29b912ad681a28566f37b936bba1f3580a93b9391c4a0b12cb1c6b4ed79973" - [[package]] name = "shlex" version = "1.3.0" @@ -2513,7 +2507,7 @@ dependencies = [ "rstest", "serde", "serde_yaml", - "shellish_parse", + "shell-words", "slumber_config", "slumber_core", "strum", diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 635c9235..e8717a03 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -39,6 +39,8 @@ const FILE: &str = "config.yml"; #[derive(Debug, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Config { + /// Configuration for in-app query and side effect commands + pub commands: CommandsConfig, /// Command to use for in-app editing. If provided, overrides /// `VISUAL`/`EDITOR` environment variables pub editor: Option, @@ -114,6 +116,7 @@ impl Config { impl Default for Config { fn default() -> Self { Self { + commands: CommandsConfig::default(), editor: None, pager: None, http: HttpEngineConfig::default(), @@ -125,6 +128,35 @@ impl Default for Config { } } +/// Configuration for in-app query and side effect commands +#[derive(Debug, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CommandsConfig { + /// Wrapping shell to parse and execute commands + /// If empty, commands will be parsed with shell-words and run natievly + pub shell: Vec, + /// Default query command for responses + pub query_default: Option, +} + +impl Default for CommandsConfig { + fn default() -> Self { + // We use the defaults from docker, because it's well tested and + // reasonably intuitive + // https://docs.docker.com/reference/dockerfile/#shell + let default_shell: &[&str] = if cfg!(windows) { + &["cmd", "/S", "/C"] + } else { + &["/bin/sh", "-c"] + }; + + Self { + shell: default_shell.iter().map(|s| s.to_string()).collect(), + query_default: None, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 7a05a7a7..d53a8e75 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,7 +27,7 @@ ratatui = {workspace = true, features = ["crossterm", "underline-color", "unstab reqwest = {workspace = true} serde = {workspace = true} serde_yaml = {workspace = true} -shellish_parse = "2.2.0" +shell-words = "1.1.0" slumber_config = {workspace = true} slumber_core = {workspace = true} strum = {workspace = true} diff --git a/crates/tui/src/util.rs b/crates/tui/src/util.rs index b14c423c..cd50c337 100644 --- a/crates/tui/src/util.rs +++ b/crates/tui/src/util.rs @@ -3,7 +3,7 @@ use crate::{ message::{Message, MessageSender}, view::Confirm, }; -use anyhow::{anyhow, bail, Context}; +use anyhow::{bail, Context}; use bytes::Bytes; use crossterm::event; use editor_command::EditorBuilder; @@ -230,19 +230,35 @@ pub fn get_pager_command(file: &Path) -> anyhow::Result { }) } -/// Run a shellish command, optionally piping some stdin to it +/// Run a command, optionally piping some stdin to it. This will use given shell +/// (e.g. `["sh", "-c"]`) to execute the command, or parse+run it natively if no +/// shell is set. The shell should generally come from the config, but is +/// taken as param for testing. pub async fn run_command( - command: &str, + shell: &[String], + command_str: &str, stdin: Option<&[u8]>, ) -> anyhow::Result> { - let _ = debug_span!("Command", command).entered(); - - let mut parsed = shellish_parse::parse(command, true)?; - let mut tokens = parsed.drain(..); - let program = tokens.next().ok_or_else(|| anyhow!("Command is empty"))?; - let args = tokens; - let mut process = tokio::process::Command::new(program) - .args(args) + let _ = debug_span!("Command", command = command_str).entered(); + + let mut command = if let [program, args @ ..] = shell { + // Invoke the shell with our command as the final arg + let mut command = tokio::process::Command::new(program); + command.args(args).arg(command_str); + command + } else { + // Shell command is empty - we should execute the command directly. + // We'll have to do our own parsing of it + let tokens = shell_words::split(command_str)?; + let [program, args @ ..] = tokens.as_slice() else { + bail!("Command is empty") + }; + let mut command = tokio::process::Command::new(program); + command.args(args); + command + }; + + let mut process = command // Stop the command on drop. This will leave behind a zombie process, // but tokio should reap it in the background. See method docs .kill_on_drop(true) @@ -308,6 +324,7 @@ mod tests { use super::*; use crate::test_util::{harness, TestHarness}; use rstest::rstest; + use slumber_config::CommandsConfig; use slumber_core::{ assert_matches, test_util::{temp_dir, TempDir}, @@ -381,4 +398,27 @@ mod tests { "{expected_path:?}" ); } + + #[rstest] + #[case::default_shell( + &CommandsConfig::default().shell, + "echo test | head -c 1", + "t", + )] + #[case::no_shell(&[], "echo -n test | head -c 1", "test | head -c 1")] + #[case::custom_shell( + &["bash".into(), "-c".into()], + "echo test | head -c 1", + "t", + )] + #[tokio::test] + async fn test_run_command( + #[case] shell: &[String], + #[case] command: &str, + #[case] expected: &str, + ) { + let bytes = run_command(shell, command, None).await.unwrap(); + let s = std::str::from_utf8(&bytes).unwrap(); + assert_eq!(s, expected); + } } diff --git a/crates/tui/src/view/common/text_box.rs b/crates/tui/src/view/common/text_box.rs index 12954746..e8cdffab 100644 --- a/crates/tui/src/view/common/text_box.rs +++ b/crates/tui/src/view/common/text_box.rs @@ -47,8 +47,7 @@ type Validator = Box bool>; impl TextBox { /// Set initialize value for the text box pub fn default_value(mut self, default: String) -> Self { - self.state.text = default; - self.state.end(); + self.set_text(default); self } @@ -101,10 +100,15 @@ impl TextBox { self.state.text } - /// Set text, and move the cursor to the end. This will **not** emit events + /// Set text, and move the cursor to the end. If the text changed, emit a + /// change event. pub fn set_text(&mut self, text: String) { + let changed = text != self.state.text; self.state.text = text; self.state.end(); + if changed { + self.change(); + } } /// Check if the current input text is valid. Always returns true if there @@ -402,7 +406,6 @@ impl PersistedContainer for TextBox { fn restore_persisted(&mut self, value: Self::Value) { self.set_text(value); - self.submit(); } } diff --git a/crates/tui/src/view/component/queryable_body.rs b/crates/tui/src/view/component/queryable_body.rs index 897e39bc..7d10da17 100644 --- a/crates/tui/src/view/component/queryable_body.rs +++ b/crates/tui/src/view/component/queryable_body.rs @@ -62,9 +62,10 @@ impl QueryableBody { let input_engine = &TuiContext::get().input_engine; let binding = input_engine.binding_display(Action::Search); - let text_box = TextBox::default() + let query_text_box = TextBox::default() .placeholder(format!("{binding} to filter")) .placeholder_focused("Enter command (ex: `jq .results`)") + .default_value(Self::default_query()) .debounce(); let text_state = TextState::new(response.content_type(), &response.body, true); @@ -75,7 +76,7 @@ impl QueryableBody { query_focused: false, query_command: None, query_state: QueryState::None, - query_text_box: text_box.into(), + query_text_box: query_text_box.into(), text_window: Default::default(), text_state, } @@ -99,10 +100,26 @@ impl QueryableBody { &self.text_state.text } + /// Get the query command to use by default on initial load + fn default_query() -> String { + TuiContext::get() + .config + .commands + .query_default + .clone() + .map(|mut s| { + // Assume the user wants to start by typing a new token, so add + // a space + s.push(' '); + s + }) + .unwrap_or_default() + } + /// Update query command based on the current text in the box, and start /// a task to run the command fn update_query(&mut self) { - let command = self.query_text_box.data().text(); + let command = self.query_text_box.data().text().trim(); let response = &self.response; // If a command is already running, abort it @@ -128,7 +145,8 @@ impl QueryableBody { let command = command.to_owned(); let emitter = self.detach(); let handle = task::spawn_local(async move { - let result = run_command(&command, Some(&body)) + let shell = &TuiContext::get().config.commands.shell; + let result = run_command(shell, &command, Some(&body)) .await .with_context(|| format!("Error running `{command}`")); emitter.emit(QueryComplete(result.map_err(Rc::new))); @@ -247,7 +265,17 @@ impl PersistedContainer for QueryableBody { } fn restore_persisted(&mut self, value: Self::Value) { - self.query_text_box.data_mut().restore_persisted(value) + let text_box = self.query_text_box.data_mut(); + text_box.restore_persisted(value); + + // It's pretty common to clear the whole text box without thinking about + // it. In that case, we want to restore the default the next time we + // reload from persistence (probably either app restart or next response + // for this recipe). It's possible the user really wants an empty box + // and this is annoying, but I think it'll be more good than bad. + if text_box.text().is_empty() { + text_box.set_text(Self::default_query()); + } } } @@ -355,13 +383,13 @@ enum QueryState { impl QueryState { fn take_abort_handle(&mut self) -> Option { - if let Self::Running(_) = self { - let Self::Running(handle) = mem::take(self) else { - unreachable!() - }; - Some(handle) - } else { - None + match mem::take(self) { + Self::Running(handle) => Some(handle), + other => { + // Put it back! + *self = other; + None + } } } } diff --git a/docs/src/api/configuration/editor.md b/docs/src/api/configuration/editor.md index 80559064..055b41a4 100644 --- a/docs/src/api/configuration/editor.md +++ b/docs/src/api/configuration/editor.md @@ -31,7 +31,7 @@ pager: bat To open a body in the pager, use the actions menu keybinding (`x` by default, see [input bindings](./input_bindings.md)), and select `View Body`. -Some popular file viewers: +Some popular pagers: - [bat](https://github.com/sharkdp/bat) - [fx](https://fx.wtf/) diff --git a/docs/src/api/configuration/index.md b/docs/src/api/configuration/index.md index 013b79f6..7d567b4f 100644 --- a/docs/src/api/configuration/index.md +++ b/docs/src/api/configuration/index.md @@ -30,14 +30,36 @@ SLUMBER_CONFIG_PATH=~/dotfiles/slumber.yml slumber ## Fields -| Field | Type | Description | Default | -| -------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `debug` | `boolean` | Enable developer information | `false` | -| `editor` | `string` | Command to use when opening files for in-app editing. [More info](./editor.md) | `VISUAL`/`EDITOR` env vars, or `vim` | -| `ignore_certificate_hosts` | `string[]` | Hostnames whose TLS certificate errors will be ignored. [More info](../../troubleshooting/tls.md) | `[]` | -| `input_bindings` | `mapping[Action, KeyCombination[]]` | Override default input bindings. [More info](./input_bindings.md) | `{}` | -| `large_body_size` | `number` | Size over which request/response bodies are not formatted/highlighted, for performance (bytes) | `1000000` (1 MB) | -| `preview_templates` | `boolean` | Render template values in the TUI? If false, the raw template will be shown. | `true` | -| `theme` | [`Theme`](./theme.md) | Visual customizations | `{}` | -| `pager` | `string` | Command to use when opening files for viewing. [More info](./editor.md) | `less` (Unix), `more` (Windows) | -| `viewer` | See `pager` | Alias for `pager`, for backward compatibility | See `pager` | +| Field | Type | Description | Default | +| -------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `commands.shell` | `string[]` | Shell used to execute commands within the TUI. [More info](#commands) | `[sh, -c]` (Unix), `[cmd, /S, /C]` (Windows) | +| `commands.query_default` | `string` | Default query command for all responses | `""` | +| `debug` | `boolean` | Enable developer information | `false` | +| `editor` | `string` | Command to use when opening files for in-app editing. [More info](./editor.md) | `VISUAL`/`EDITOR` env vars, or `vim` | +| `ignore_certificate_hosts` | `string[]` | Hostnames whose TLS certificate errors will be ignored. [More info](../../troubleshooting/tls.md) | `[]` | +| `input_bindings` | `mapping[Action, KeyCombination[]]` | Override default input bindings. [More info](./input_bindings.md) | `{}` | +| `large_body_size` | `number` | Size over which request/response bodies are not formatted/highlighted, for performance (bytes) | `1000000` (1 MB) | +| `preview_templates` | `boolean` | Render template values in the TUI? If false, the raw template will be shown. | `true` | +| `theme` | [`Theme`](./theme.md) | Visual customizations | `{}` | +| `pager` | `string` | Command to use when opening files for viewing. [More info](./editor.md) | `less` (Unix), `more` (Windows) | +| `viewer` | See `pager` | Alias for `pager`, for backward compatibility | See `pager` | + +## Commands + +Slumber allows you to execute shell commands within the TUI, e.g. for querying and transforming response bodies. By default, the command you enter is passed to `sh` (or `cmd` on Windows) for parsing and execution. This allows you to access shell behavior such as piping. The command to execute is passed as the final argument to the shell, and the response body is passed as stdin to the spawned process. + +If you want to use a different shell (e.g. to access your shell aliases), you can override the `commands.shell` config field. For example, to use [fish](https://fishshell.com/): + +```yaml +commands: + shell: ["fish", "-c"] +``` + +If you don't want to use a shell at all, you can pass `[]`: + +```yaml +commands: + shell: [] +``` + +In this case, any commands to be executed will be parsed with [shell-words](https://docs.rs/shell-words/1.1.0/shell_words/fn.split.html) and executed directly. For example, `echo -n test` will run `echo` with the arguments `-n` and `test`.