From a62bf8dfd567fa6dc1aa16efaf1d71dbcf7e3935 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Wed, 15 Feb 2023 21:02:15 -0700 Subject: [PATCH 01/11] Flexible unix pipes search plugin --- Cargo.lock | 17 +++ bin/src/main.rs | 2 +- plugins/Cargo.toml | 2 + plugins/src/lib.rs | 1 + plugins/src/search/app.rs | 245 ++++++++++++++++++++++++++++++++++ plugins/src/search/config.ron | 22 +++ plugins/src/search/config.rs | 93 +++++++++++++ plugins/src/search/mod.rs | 101 ++++++++++++++ plugins/src/search/plugin.ron | 7 + plugins/src/search/util.rs | 132 ++++++++++++++++++ 10 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 plugins/src/search/app.rs create mode 100644 plugins/src/search/config.ron create mode 100644 plugins/src/search/config.rs create mode 100644 plugins/src/search/mod.rs create mode 100644 plugins/src/search/plugin.ron create mode 100644 plugins/src/search/util.rs diff --git a/Cargo.lock b/Cargo.lock index bea4557..b83ca95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,6 +1534,8 @@ dependencies = [ "ron", "serde", "serde_json", + "shell-words", + "shellexpand", "slab", "strsim", "sysfs-class", @@ -1971,6 +1973,21 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shellexpand" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1c7ddea665294d484c39fd0c0d2b7e35bbfe10035c5fe1854741a57f6880e1" +dependencies = [ + "dirs 4.0.0", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" diff --git a/bin/src/main.rs b/bin/src/main.rs index f9b37ac..23abea2 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs @@ -20,8 +20,8 @@ async fn main() { match cmd { "calc" => plugins::calc::main().await, "desktop-entries" => plugins::desktop_entries::main().await, - "find" => plugins::find::main().await, "files" => plugins::files::main().await, + "search" => plugins::search::main().await, "pop-launcher" => service::main().await, "pop-shell" => plugins::pop_shell::main().await, "pulse" => plugins::pulse::main().await, diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index 5307309..691a583 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -33,6 +33,8 @@ dirs = "4.0.0" futures = "0.3.25" bytes = "1.2.1" recently-used-xbel = "1.0.0" +shell-words = "1.1.0" +shellexpand = "3.0.0" # dependencies cosmic toplevel cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit" } diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index b2f5977..ed92e94 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -6,6 +6,7 @@ pub mod cosmic_toplevel; pub mod desktop_entries; pub mod files; pub mod find; +pub mod search; pub mod pop_shell; pub mod pulse; pub mod recent; diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs new file mode 100644 index 0000000..18f8f65 --- /dev/null +++ b/plugins/src/search/app.rs @@ -0,0 +1,245 @@ +use flume::Receiver; +use regex::Regex; +use std::cell::Cell; +// use std::future::Future; +use std::io; +// use std::ops::Deref; +use std::rc::Rc; +// use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, BufReader, Lines}; +use tokio::process::ChildStdout; + +use pop_launcher::{async_stdout, PluginResponse, PluginSearchResult}; + +use crate::search::config::Definition; +use crate::search::util::interpolate_result; + +use super::config::{load, Config, DisplayLine}; +use super::util::{exec, interpolate_command}; + +/// Maintains state for search requests +pub struct App { + pub config: Config, + + // Indicates if a search is being performed in the background. + pub active: Rc>, + + // Flume channel where we can send interrupt + pub cancel: Option>, + + pub out: tokio::io::Stdout, + pub search_results: Vec, +} + +impl Default for App { + fn default() -> Self { + Self { + config: load(), + search_results: Vec::with_capacity(128), + active: Rc::new(Cell::new(false)), + cancel: None, + out: async_stdout(), + } + } +} + +impl App { + pub async fn make_listener( + &mut self, + defn: &Definition, + stdout: &mut Lines>, + search_terms: &[String], + ) { + let mut id = 0; + let mut append; + eprintln!("start listener"); + + 'stream: loop { + let interrupt = async { + let x: Option<&Receiver<()>> = self.cancel.as_ref(); + // let x: Option<&Receiver<()>> = (*cancel).as_ref(); + + if let Some(cancel) = x { + let _ = cancel.recv_async().await; + } else { + eprintln!("no interrupt receiver"); + tracing::error!("no interrupt receiver"); + } + Ok(None) + }; + + match crate::or(interrupt, stdout.next_line()).await { + Ok(Some(line)) => { + eprintln!("append line: {}", line); + append = line + } + Ok(None) => { + eprintln!("listener; break stream"); + break 'stream; + } + Err(why) => { + eprintln!("error on stdout line read: {}", why); + tracing::error!("error on stdout line read: {}", why); + break 'stream; + } + } + + self.append(id, &append, defn, search_terms).await; + + id += 1; + + if id == 10 { + break 'stream; + } + } + } + + /// Appends a new search result to the context. + pub async fn append<'a>( + &mut self, + id: u32, + line: &'a str, + defn: &'a Definition, + vars: &'a [String], + ) { + eprintln!("append: {:?} {:?}", id, line); + + let match_display_line = |display_line: &'a DisplayLine| -> Option { + match display_line { + DisplayLine::Label(label) => Some(label.clone()), + DisplayLine::Capture(pattern) => { + if let Ok(re) = Regex::new(&pattern) { + re.captures(&line) + .and_then(|caps| caps.get(1)) + .map(|cap| cap.as_str().to_owned()) + } else { + tracing::error!("failed to build Capture regex: {}", pattern); + + None + } + } + DisplayLine::Replace(pattern, replace) => { + if let Ok(re) = Regex::new(&pattern) { + if let Some(capture) = re + .captures(&line) + .and_then(|caps| caps.get(1)) + .map(|cap| cap.as_str()) + { + let replacement = interpolate_result(replace, &vars, capture); + if let Ok(replacement) = replacement { + Some(replacement) + } else { + tracing::error!( + "unable to interpolate Replace: {}, {}", + pattern, + replace + ); + + None + } + } else { + None + } + } else { + tracing::error!("failed to build Replace regex: {}", pattern); + + None + } + } + } + }; + + let title: Option = match_display_line(&defn.title); + let detail: Option = match_display_line(&defn.detail); + + if let Some(title) = title { + if let Some(detail) = detail { + let response = PluginResponse::Append(PluginSearchResult { + id, + name: title.to_owned(), + description: detail.to_owned(), + ..Default::default() + }); + + eprintln!("append; send response {:?}", response); + + crate::send(&mut self.out, response).await; + self.search_results.push(line.to_string()); + } + } + } + + /// Submits the query to `fdfind` and actively monitors the search results while handling interrupts. + pub async fn search(&mut self, search: String) { + eprintln!("config: {:?}", self.config); + + self.search_results.clear(); + + if let Some(search_terms) = shell_words::split(&search).ok().as_deref() { + if let Some(word) = search_terms.first() { + eprintln!("look for word: '{}'", word); + + let word_defn: Option = self.config.get(word).cloned(); + + if let Some(defn) = word_defn { + if let Some(parts) = interpolate_command(&defn.query, search_terms).ok() { + eprintln!("search parts: {:?}", parts); + + if let Some((program, args)) = parts.split_first() { + // We're good to exec the command! + + let (mut child, mut stdout) = match exec(program, args).await { + Ok((child, stdout)) => { + eprintln!("spawned process"); + (child, tokio::io::BufReader::new(stdout).lines()) + } + Err(why) => { + eprintln!("failed to spawn process: {}", why); + tracing::error!("failed to spawn process: {}", why); + + let _ = crate::send( + &mut self.out, + PluginResponse::Append(PluginSearchResult { + id: 0, + name: if why.kind() == io::ErrorKind::NotFound { + String::from("command not found") + } else { + format!("failed to spawn process: {}", why) + }, + ..Default::default() + }), + ) + .await; + + return; + } + }; + + let timeout = async { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + }; + + let listener = self.make_listener(&defn, &mut stdout, search_terms); + + futures::pin_mut!(timeout); + futures::pin_mut!(listener); + + let _ = futures::future::select(timeout, listener).await; + + let _ = child.kill().await; + let _ = child.wait().await; + } + } else { + eprintln!("can't interpolate command"); + } + } else { + eprintln!("no matching definition"); + } + } else { + eprintln!("search term has no head word"); + } + } else { + eprintln!("can't split search terms"); + } + } +} diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron new file mode 100644 index 0000000..28c1ead --- /dev/null +++ b/plugins/src/search/config.ron @@ -0,0 +1,22 @@ +( + rules: [ + ( + matches: ["f", "find"], + action: ( + title: Capture("^.+/([^/]*)$", "File"), + detail: Capture("^(.+)$", "Path"), + query: "fdfind --ignore-case --full-path $1 --type ${2:-file}", + command: "xdg-open" + ) + ), + ( + matches: ["ls"], + action: ( + title: Label("File"), + detail: Capture("^(.+)$", "Path"), + query: "ls -1 $1", + command: "xdg-open" + ) + ), + ] +) diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs new file mode 100644 index 0000000..02962cb --- /dev/null +++ b/plugins/src/search/config.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright © 2023 System76 + +use serde::Deserialize; +use slab::Slab; +use std::collections::HashMap; + +#[derive(Default, Clone, Debug)] +pub struct Config { + matches: HashMap, + definitions: Slab, +} + +impl Config { + pub fn append(&mut self, config: RawConfig) { + for rule in config.rules { + let idx = self.definitions.insert(rule.action); + for keyword in rule.matches { + self.matches.entry(keyword).or_insert(idx as u32); + } + } + } + + pub fn get(&self, word: &str) -> Option<&Definition> { + self.matches + .get(word) + .and_then(|idx| self.definitions.get(*idx as usize)) + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RawConfig { + pub rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Rule { + pub matches: Vec, + pub action: Definition, +} + +#[derive(Debug, Deserialize, Clone)] +pub enum DisplayLine { + // Constant label used for each result + Label(String), + + // A Regex capture on the result (everything in parens is captured) + // e.g. name: Capture("^.+/([^/]*)$"), + Capture(String), + + // Same as Capture above, but with replace + // e.g. name: Replace("^(.+)$", "http://${CAPTURE}"), + Replace(String, String), +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Definition { + pub query: String, + pub command: String, + pub title: DisplayLine, + pub detail: DisplayLine, +} + +pub fn load() -> Config { + eprintln!("load config"); + let mut config = Config::default(); + + for path in pop_launcher::config::find("search") { + let string = match std::fs::read_to_string(&path) { + Ok(string) => string, + Err(why) => { + eprintln!("load config err A"); + tracing::error!("failed to read config: {}", why); + continue; + } + }; + + match ron::from_str::(&string) { + Ok(raw) => { + eprintln!("raw: {:?}", raw); + config.append(raw) + } + Err(why) => { + eprintln!("load config err B: {}", why); + tracing::error!("failed to deserialize config: {}", why); + } + } + } + + eprintln!("load config: {:?}", config); + + config +} diff --git a/plugins/src/search/mod.rs b/plugins/src/search/mod.rs new file mode 100644 index 0000000..caeec8e --- /dev/null +++ b/plugins/src/search/mod.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright © 2021 System76 + +use app::App; +use futures::*; +use pop_launcher::{async_stdin, json_input_stream, PluginResponse, Request}; + +mod app; +mod config; +mod util; + +#[derive(Debug)] +enum Event { + Activate(u32), + Search(String), +} + +pub async fn main() { + let (event_tx, event_rx) = flume::bounded::(8); + + // Channel for cancelling searches that are in progress. + let (interrupt_tx, interrupt_rx) = flume::bounded::<()>(1); + + let mut app = App::default(); + + app.cancel = Some(interrupt_rx); + + let active = app.active.clone(); + + // Manages the external process, tracks search results, and executes activate requests + let search_handler = async move { + while let Ok(search) = event_rx.recv_async().await { + match search { + Event::Activate(id) => { + if let Some(selection) = app.search_results.get(id as usize) { + let path = selection.clone(); + tokio::spawn(async move { + // exec(app.config.) + crate::xdg_open(&path); + }); + + crate::send(&mut app.out, PluginResponse::Close).await; + } + } + + Event::Search(search) => { + app.search(search).await; + app.active.set(false); + crate::send(&mut app.out, PluginResponse::Finished).await; + } + } + } + }; + + // Forwards requests to the search handler, and performs an interrupt as necessary. + let request_handler = async move { + let interrupt = || { + let active = active.clone(); + let tx = interrupt_tx.clone(); + async move { + if active.get() { + let _ = tx.try_send(()); + } + } + }; + + let mut requests = json_input_stream(async_stdin()); + + while let Some(result) = requests.next().await { + match result { + Ok(request) => match request { + // Launch the default application with the selected file + Request::Activate(id) => { + event_tx.send_async(Event::Activate(id)).await?; + } + + // Interrupt any active searches being performed + Request::Interrupt => interrupt().await, + + // Schedule a new search process to be launched + Request::Search(query) => { + interrupt().await; + + event_tx.send_async(Event::Search(query.to_owned())).await?; + active.set(true); + } + + _ => (), + }, + + Err(why) => { + tracing::error!("malformed JSON input: {}", why); + } + } + } + + Ok::<(), flume::SendError>(()) + }; + + let _ = futures::future::join(request_handler, search_handler).await; +} diff --git a/plugins/src/search/plugin.ron b/plugins/src/search/plugin.ron new file mode 100644 index 0000000..995986d --- /dev/null +++ b/plugins/src/search/plugin.ron @@ -0,0 +1,7 @@ +( + name: "Search", + description: "Syntax: { find | ... } ", + query: (help: "find ", priority: High), + bin: (path: "search"), + icon: Name("system-search"), +) diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs new file mode 100644 index 0000000..8180f43 --- /dev/null +++ b/plugins/src/search/util.rs @@ -0,0 +1,132 @@ +use std::io; +use std::process::Stdio; +use tokio::process::{Child, ChildStdout, Command}; + +use std::{env, fmt}; + +use shell_words::{self, ParseError}; +use shellexpand::{self, LookupError}; + +/** + * + * Suppose `config.ron` contains the following: + * ( + * ( + * matches: ["f", "find"], + * action: ( + * title: Label("File"), + * detail: Capture("^.+/([^/]*)$"), + * query: "fdfind --ignore-case --glob --full-path $1 --type ${2:-file}" + * command: "xdg-open" + * ) + * ), + * ... + * ) + * + * And in the launcher frontend, the user types the following search: + * + * "find 'My Document'" + * + * Perform an interpolation as follows: + * + * 1. Search all rules to find a match for the 'find' command + * 2. Construct the command-line query by interpolating the search terms: + * $1: "My Document" + * $2: "file" (using default) + * 3. Final result: + * ["fdfind", "--ignore-case", "--glob", "--full-path", "My Document", "--type", "file"] + * + */ + +fn home_dir() -> Option { + env::var("HOME").ok() +} + +#[derive(Debug)] +pub enum InterpolateError { + LookupError(String), + SplitError, +} + +impl From> for InterpolateError { + fn from(err: LookupError) -> InterpolateError { + InterpolateError::LookupError(format!("{}", err)) + } +} +impl From for InterpolateError { + fn from(_err: ParseError) -> InterpolateError { + InterpolateError::SplitError + } +} + +pub fn interpolate_result( + input: &str, + vars: &[String], + capture: &str, +) -> Result { + let expanded = shellexpand::full_with_context( + input, + home_dir, + |var: &str| -> Result, std::num::ParseIntError> { + if var.eq("QUERY") { + // All search terms as one arg + Ok(Some(vars[1..].join(" "))) + } else if var.eq("FULLQUERY") { + // All search terms (including search command) as one arg + Ok(Some(vars.join(" "))) + } else if var.eq("CAPTURE") { + // Use the regex capture (first set of parens in regex) + Ok(Some(capture.to_owned())) + } else { + // If this is a numeric variable (e.g. "$1", "$2", ...), look up the search term + let idx = var.parse::()?; + let value = vars.get(idx); + Ok(value.map(|s| format!("'{}'", s))) + } + }, + )?; + + Ok(expanded.to_string()) +} + +pub fn interpolate_command(input: &str, vars: &[String]) -> Result, InterpolateError> { + let expanded = shellexpand::full_with_context( + input, + home_dir, + |var: &str| -> Result, std::num::ParseIntError> { + if var.eq("QUERY") { + // All search terms as one arg + Ok(Some(format!("'{}'", vars[1..].join(" ")))) + } else if var.eq("FULLQUERY") { + // All search terms (including search command) as one arg + Ok(Some(format!("'{}'", vars.join(" ")))) + } else { + // If this is a numeric variable (e.g. "$1", "$2", ...), look up the search term + let idx = var.parse::()?; + let value = vars.get(idx); + Ok(value.map(|s| format!("'{}'", s))) + } + }, + )?; + + let parts = shell_words::split(&expanded)?; + + Ok(parts) +} + +pub async fn exec(program: &str, args: &[String]) -> io::Result<(Child, ChildStdout)> { + eprintln!("exec {:?} with {:?}", program, args); + // Closure to spawn the process + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + + child + .stdout + .take() + .map(move |stdout| (child, stdout)) + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "stdout pipe is missing")) +} From 7df8a1e3448ec1a97c2044e181002b7e41fe4b2d Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sat, 18 Feb 2023 17:06:56 -0700 Subject: [PATCH 02/11] Experiment with CaptureOne, CaptureMany --- plugins/src/search/app.rs | 10 ++++++---- plugins/src/search/config.ron | 35 +++++++++++++++++++++++++++++++---- plugins/src/search/config.rs | 28 ++++++++++++++++++++++------ 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 18f8f65..29db472 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -106,8 +106,10 @@ impl App { let match_display_line = |display_line: &'a DisplayLine| -> Option { match display_line { + DisplayLine::Blank => Some("".to_string()), + DisplayLine::Echo => Some(line.to_string()), DisplayLine::Label(label) => Some(label.clone()), - DisplayLine::Capture(pattern) => { + DisplayLine::CaptureOne(pattern) => { if let Ok(re) = Regex::new(&pattern) { re.captures(&line) .and_then(|caps| caps.get(1)) @@ -118,7 +120,7 @@ impl App { None } } - DisplayLine::Replace(pattern, replace) => { + DisplayLine::CaptureMany(pattern, replace) => { if let Ok(re) = Regex::new(&pattern) { if let Some(capture) = re .captures(&line) @@ -149,8 +151,8 @@ impl App { } }; - let title: Option = match_display_line(&defn.title); - let detail: Option = match_display_line(&defn.detail); + let title: Option = match_display_line(&defn.name); + let detail: Option = match_display_line(&defn.description); if let Some(title) = title { if let Some(detail) = detail { diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron index 28c1ead..4e98cd0 100644 --- a/plugins/src/search/config.ron +++ b/plugins/src/search/config.ron @@ -3,8 +3,8 @@ ( matches: ["f", "find"], action: ( - title: Capture("^.+/([^/]*)$", "File"), - detail: Capture("^(.+)$", "Path"), + name: CaptureOne("^.+/([^/]*)$"), + description: CaptureOne("^(.+)$"), query: "fdfind --ignore-case --full-path $1 --type ${2:-file}", command: "xdg-open" ) @@ -12,11 +12,38 @@ ( matches: ["ls"], action: ( - title: Label("File"), - detail: Capture("^(.+)$", "Path"), + name: Label("File"), + description: CaptureMany("^(.+)$", "hi ${CAPTURE}"), query: "ls -1 $1", command: "xdg-open" ) ), + ( + matches: ["apt"], + action: ( + name: CaptureOne("^([^/]+)"), + description: CaptureOne("/(.+)$"), + query: "apt list $1", + command: "notify-send apt" + ) + ), + ( + matches: ["ps"], + action: ( + name: CaptureOne("^\\s+[0-9]+\\s+(.*)$"), + description: CaptureOne("^\\s+([0-9]+)"), + query: "/bin/bash -c 'ps --sort=-pcpu -axo pid,ucmd,pcpu | head -10'", + command: "notify-send ps", + ) + ), + ( + matches: ["drives"], + action: ( + name: Echo, + query: "lsblk -lno NAME,SIZE,MOUNTPOINTS", + command: "notify-send ps", + ) + ), + ] ) diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index 02962cb..d3dcee5 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -39,26 +39,42 @@ pub struct Rule { pub action: Definition, } +/** + * The DisplayLine configures what to show in the results list, based on what the + * shell command's STDOUT produces. + */ #[derive(Debug, Deserialize, Clone)] pub enum DisplayLine { - // Constant label used for each result + // Show nothing + Blank, + + // Echo whatever the command outputs + Echo, + + // Constant label to be repeated for every result Label(String), - // A Regex capture on the result (everything in parens is captured) + // A Regex capture on the result (everything in first set of parens is captured) // e.g. name: Capture("^.+/([^/]*)$"), - Capture(String), + CaptureOne(String), // Same as Capture above, but with replace // e.g. name: Replace("^(.+)$", "http://${CAPTURE}"), - Replace(String, String), + CaptureMany(String, String), } #[derive(Debug, Deserialize, Clone)] pub struct Definition { pub query: String, pub command: String, - pub title: DisplayLine, - pub detail: DisplayLine, + pub name: DisplayLine, + + #[serde(default = "display_line_blank")] + pub description: DisplayLine, +} + +fn display_line_blank() -> DisplayLine { + DisplayLine::Blank } pub fn load() -> Config { From 680cab337caf2fc4d5bfbef6e7b89c51c1440d81 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sat, 18 Feb 2023 18:48:51 -0700 Subject: [PATCH 03/11] Simplify config.ron syntax and property names --- plugins/src/search/app.rs | 156 ++++++++++++++++++++-------------- plugins/src/search/config.ron | 41 ++++----- plugins/src/search/config.rs | 47 +++++----- plugins/src/search/util.rs | 70 +++++++++------ 4 files changed, 182 insertions(+), 132 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 29db472..20bf421 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -14,7 +14,7 @@ use pop_launcher::{async_stdout, PluginResponse, PluginSearchResult}; use crate::search::config::Definition; use crate::search::util::interpolate_result; -use super::config::{load, Config, DisplayLine}; +use super::config::{load, Config}; use super::util::{exec, interpolate_command}; /// Maintains state for search requests @@ -46,12 +46,13 @@ impl Default for App { impl App { pub async fn make_listener( &mut self, - defn: &Definition, stdout: &mut Lines>, - search_terms: &[String], + defn: &Definition, + query_string: &str, + keywords: &[String], ) { let mut id = 0; - let mut append; + let mut output_line; eprintln!("start listener"); 'stream: loop { @@ -71,7 +72,7 @@ impl App { match crate::or(interrupt, stdout.next_line()).await { Ok(Some(line)) => { eprintln!("append line: {}", line); - append = line + output_line = line } Ok(None) => { eprintln!("listener; break stream"); @@ -84,7 +85,8 @@ impl App { } } - self.append(id, &append, defn, search_terms).await; + self.append(id, &output_line, defn, query_string, keywords) + .await; id += 1; @@ -98,93 +100,116 @@ impl App { pub async fn append<'a>( &mut self, id: u32, - line: &'a str, + output_line: &'a str, defn: &'a Definition, - vars: &'a [String], + query_string: &'a str, + keywords: &'a [String], ) { - eprintln!("append: {:?} {:?}", id, line); - - let match_display_line = |display_line: &'a DisplayLine| -> Option { - match display_line { - DisplayLine::Blank => Some("".to_string()), - DisplayLine::Echo => Some(line.to_string()), - DisplayLine::Label(label) => Some(label.clone()), - DisplayLine::CaptureOne(pattern) => { - if let Ok(re) = Regex::new(&pattern) { - re.captures(&line) - .and_then(|caps| caps.get(1)) - .map(|cap| cap.as_str().to_owned()) + eprintln!("append: {:?} {:?}", id, output_line); + + let interpolate = |result_line: &'a str| -> Option { + if let Ok(re) = Regex::new(&defn.output_captures) { + if let Some(captures) = re.captures(&output_line) { + let interpolated = interpolate_result( + result_line, + output_line, + query_string, + keywords, + &captures, + ); + if let Ok(interpolated) = interpolated { + Some(interpolated) } else { - tracing::error!("failed to build Capture regex: {}", pattern); - - None - } - } - DisplayLine::CaptureMany(pattern, replace) => { - if let Ok(re) = Regex::new(&pattern) { - if let Some(capture) = re - .captures(&line) - .and_then(|caps| caps.get(1)) - .map(|cap| cap.as_str()) - { - let replacement = interpolate_result(replace, &vars, capture); - if let Ok(replacement) = replacement { - Some(replacement) - } else { - tracing::error!( - "unable to interpolate Replace: {}, {}", - pattern, - replace - ); - - None - } - } else { - None - } - } else { - tracing::error!("failed to build Replace regex: {}", pattern); - + // tracing::error!("unable to interpolate Replace: {}, {}", pattern, replace); None } + } else { + None } + } else { + None } }; - - let title: Option = match_display_line(&defn.name); - let detail: Option = match_display_line(&defn.description); - - if let Some(title) = title { - if let Some(detail) = detail { + // let match_display_line = |display_line: &'a DisplayLine| -> Option { + // match display_line { + // DisplayLine::Blank => Some("".to_string()), + // DisplayLine::Echo => Some(line.to_string()), + // DisplayLine::Label(label) => Some(label.clone()), + // DisplayLine::CaptureOne(pattern) => { + // if let Ok(re) = Regex::new(&pattern) { + // re.captures(&line) + // .and_then(|caps| caps.get(1)) + // .map(|cap| cap.as_str().to_owned()) + // } else { + // tracing::error!("failed to build Capture regex: {}", pattern); + + // None + // } + // } + // DisplayLine::CaptureMany(pattern, replace) => { + // if let Ok(re) = Regex::new(&pattern) { + // if let Some(captures) = re.captures(&line) { + // let replacement = interpolate_result(replace, &keywords, &captures); + // if let Ok(replacement) = replacement { + // Some(replacement) + // } else { + // tracing::error!( + // "unable to interpolate Replace: {}, {}", + // pattern, + // replace + // ); + + // None + // } + // } else { + // None + // } + // } else { + // tracing::error!("failed to build Replace regex: {}", pattern); + + // None + // } + // } + // } + // }; + + let result_name: Option = interpolate(&defn.result_name); + let result_desc: Option = interpolate(&defn.result_desc); + + if let Some(name) = result_name { + if let Some(description) = result_desc { let response = PluginResponse::Append(PluginSearchResult { id, - name: title.to_owned(), - description: detail.to_owned(), + name: name.to_owned(), + description: description.to_owned(), ..Default::default() }); eprintln!("append; send response {:?}", response); crate::send(&mut self.out, response).await; - self.search_results.push(line.to_string()); + self.search_results.push(output_line.to_string()); } } } - /// Submits the query to `fdfind` and actively monitors the search results while handling interrupts. - pub async fn search(&mut self, search: String) { + // Given a query string, identify whether or not it matches one of the rules in our definition set, and + // if so, execute the corresponding query_command. + pub async fn search(&mut self, query_string: String) { eprintln!("config: {:?}", self.config); self.search_results.clear(); - if let Some(search_terms) = shell_words::split(&search).ok().as_deref() { - if let Some(word) = search_terms.first() { + if let Some(keywords) = shell_words::split(&query_string).ok().as_deref() { + if let Some(word) = keywords.first() { eprintln!("look for word: '{}'", word); let word_defn: Option = self.config.get(word).cloned(); if let Some(defn) = word_defn { - if let Some(parts) = interpolate_command(&defn.query, search_terms).ok() { + if let Some(parts) = + interpolate_command(&defn.query_command, &query_string, keywords).ok() + { eprintln!("search parts: {:?}", parts); if let Some((program, args)) = parts.split_first() { @@ -221,7 +246,8 @@ impl App { tokio::time::sleep(std::time::Duration::from_secs(3)).await; }; - let listener = self.make_listener(&defn, &mut stdout, search_terms); + let listener = + self.make_listener(&mut stdout, &defn, &query_string, keywords); futures::pin_mut!(timeout); futures::pin_mut!(listener); diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron index 4e98cd0..aa52a52 100644 --- a/plugins/src/search/config.ron +++ b/plugins/src/search/config.ron @@ -3,47 +3,48 @@ ( matches: ["f", "find"], action: ( - name: CaptureOne("^.+/([^/]*)$"), - description: CaptureOne("^(.+)$"), - query: "fdfind --ignore-case --full-path $1 --type ${2:-file}", - command: "xdg-open" + query_command: "fdfind --ignore-case --full-path $KEYWORD1", + output_captures: "^(.+)/([^/]+)$", + result_name: "$CAPTURE2", + result_desc: "$CAPTURE1", + run_command: "xdg-open \"$OUTPUT\"", ) ), ( matches: ["ls"], action: ( - name: Label("File"), - description: CaptureMany("^(.+)$", "hi ${CAPTURE}"), - query: "ls -1 $1", - command: "xdg-open" + query_command: "ls -1 $KEYWORD1", + result_name: "File", + result_desc: "hi $OUTPUT", + run_command: "xdg-open \"$KEYWORD1/$OUTPUT\"" ) ), ( matches: ["apt"], action: ( - name: CaptureOne("^([^/]+)"), - description: CaptureOne("/(.+)$"), - query: "apt list $1", - command: "notify-send apt" + query_command: "apt list $KEYWORD1", + output_captures: "^([^/]+)/(.+)$", + result_name: "$CAPTURE1", + result_desc: "$CAPTURE2", + run_command: "notify-send $OUTPUT", ) ), ( matches: ["ps"], action: ( - name: CaptureOne("^\\s+[0-9]+\\s+(.*)$"), - description: CaptureOne("^\\s+([0-9]+)"), - query: "/bin/bash -c 'ps --sort=-pcpu -axo pid,ucmd,pcpu | head -10'", - command: "notify-send ps", + query_command: "/bin/bash -c 'ps --sort=-pcpu -axo pid,ucmd,pcpu | head -10'", + output_captures: "^\\s+([0-9]+)\\s+(.*)$", + result_name: "${CAPTURE2}", + result_desc: "${CAPTURE1}", + run_command: "notify-send ps", ) ), ( matches: ["drives"], action: ( - name: Echo, - query: "lsblk -lno NAME,SIZE,MOUNTPOINTS", - command: "notify-send ps", + query_command: "lsblk -lno NAME,SIZE,MOUNTPOINTS", + run_command: "notify-send ps", ) ), - ] ) diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index d3dcee5..dbbc83c 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -44,37 +44,40 @@ pub struct Rule { * shell command's STDOUT produces. */ #[derive(Debug, Deserialize, Clone)] -pub enum DisplayLine { - // Show nothing - Blank, +pub struct Definition { + // NOTE: In each field below, the variables $QUERY, $KEYWORDS, and $KEYWORDn are available. + + // REQUIRED: The shell command to run whose STDOUT will be interpreted as a series of query results + // Each line of output is available as $OUTPUT in result_name, result_desc, and run_command. + pub query_command: String, - // Echo whatever the command outputs - Echo, + // An optional regex applied to each STDOUT line; each capture will be available as $CAPTUREn + // variables in result_name, result_desc, and run_command, where "n" is a number from 1..len(captures) + #[serde(default = "regex_match_all")] + pub output_captures: String, - // Constant label to be repeated for every result - Label(String), + // An optional string; shown as the "name" line of the query result. + #[serde(default = "result_echo")] + pub result_name: String, - // A Regex capture on the result (everything in first set of parens is captured) - // e.g. name: Capture("^.+/([^/]*)$"), - CaptureOne(String), + // An optional string; shown as the "description" line of the query result. + #[serde(default = "string_blank")] + pub result_desc: String, - // Same as Capture above, but with replace - // e.g. name: Replace("^(.+)$", "http://${CAPTURE}"), - CaptureMany(String, String), + // REQUIRED: The shell command to run when the user selects a result (usually, "Enter" key pressed) + pub run_command: String, } -#[derive(Debug, Deserialize, Clone)] -pub struct Definition { - pub query: String, - pub command: String, - pub name: DisplayLine, +fn regex_match_all() -> String { + "^.*$".to_string() +} - #[serde(default = "display_line_blank")] - pub description: DisplayLine, +fn result_echo() -> String { + "$OUTPUT".to_string() } -fn display_line_blank() -> DisplayLine { - DisplayLine::Blank +fn string_blank() -> String { + "".to_string() } pub fn load() -> Config { diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index 8180f43..b9b97bb 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -1,3 +1,4 @@ +use regex::Captures; use std::io; use std::process::Stdio; use tokio::process::{Child, ChildStdout, Command}; @@ -61,27 +62,39 @@ impl From for InterpolateError { pub fn interpolate_result( input: &str, - vars: &[String], - capture: &str, + output: &str, + query_string: &str, + keywords: &[String], + captures: &Captures, ) -> Result { let expanded = shellexpand::full_with_context( input, home_dir, |var: &str| -> Result, std::num::ParseIntError> { - if var.eq("QUERY") { - // All search terms as one arg - Ok(Some(vars[1..].join(" "))) - } else if var.eq("FULLQUERY") { - // All search terms (including search command) as one arg - Ok(Some(vars.join(" "))) - } else if var.eq("CAPTURE") { - // Use the regex capture (first set of parens in regex) - Ok(Some(capture.to_owned())) + if var.eq("OUTPUT") { + Ok(Some(output.to_string())) + } else if var.eq("QUERY") { + // The full query string (i.e. all keywords, including the search prefix) as one string + Ok(Some(query_string.to_string())) + } else if var.eq("KEYWORDS") { + // Just the keywords (absent the search prefix) as one string. + // NOTE: Whitespace may not be preserved + Ok(Some(keywords[1..].join(" "))) + } else if let Some(number) = var.strip_prefix("KEYWORD") { + // Look up an individual keyword, e.g. $KEYWORD1, $KEYWORD2, etc. + let idx = number.parse::()?; + Ok(keywords.get(idx).cloned()) + } else if let Some(number) = var.strip_prefix("CAPTURE") { + // Look up an individual regex capture, e.g. $CAPTURE0, $CAPTURE1, etc. + let idx = number.parse::()?; + if let Some(capture) = captures.get(idx) { + Ok(Some(capture.as_str().to_owned())) + } else { + Ok(None) + } } else { - // If this is a numeric variable (e.g. "$1", "$2", ...), look up the search term - let idx = var.parse::()?; - let value = vars.get(idx); - Ok(value.map(|s| format!("'{}'", s))) + // TODO: Add env vars + Ok(None) } }, )?; @@ -89,22 +102,29 @@ pub fn interpolate_result( Ok(expanded.to_string()) } -pub fn interpolate_command(input: &str, vars: &[String]) -> Result, InterpolateError> { +pub fn interpolate_command( + input: &str, + query_string: &str, + keywords: &[String], +) -> Result, InterpolateError> { let expanded = shellexpand::full_with_context( input, home_dir, |var: &str| -> Result, std::num::ParseIntError> { if var.eq("QUERY") { - // All search terms as one arg - Ok(Some(format!("'{}'", vars[1..].join(" ")))) - } else if var.eq("FULLQUERY") { - // All search terms (including search command) as one arg - Ok(Some(format!("'{}'", vars.join(" ")))) + // The full query string (i.e. all keywords, including the search prefix) as one string + Ok(Some(format!("'{}'", query_string.to_string()))) + } else if var.eq("KEYWORDS") { + // Just the keywords (absent the search prefix) as one string. + // NOTE: Whitespace may not be preserved + Ok(Some(keywords[1..].join(" "))) + } else if let Some(number) = var.strip_prefix("KEYWORD") { + // Look up an individual keyword, e.g. $KEYWORD1, $KEYWORD2, etc. + let idx = number.parse::()?; + Ok(keywords.get(idx).cloned()) } else { - // If this is a numeric variable (e.g. "$1", "$2", ...), look up the search term - let idx = var.parse::()?; - let value = vars.get(idx); - Ok(value.map(|s| format!("'{}'", s))) + // TODO: Add env vars + Ok(None) } }, )?; From 3918bcd7d9ddf8e8a27ea2e90ebdb10c9146a163 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sat, 18 Feb 2023 19:08:53 -0700 Subject: [PATCH 04/11] Remove commented code --- plugins/src/search/app.rs | 42 --------------------------------------- 1 file changed, 42 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 20bf421..381e121 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -130,48 +130,6 @@ impl App { None } }; - // let match_display_line = |display_line: &'a DisplayLine| -> Option { - // match display_line { - // DisplayLine::Blank => Some("".to_string()), - // DisplayLine::Echo => Some(line.to_string()), - // DisplayLine::Label(label) => Some(label.clone()), - // DisplayLine::CaptureOne(pattern) => { - // if let Ok(re) = Regex::new(&pattern) { - // re.captures(&line) - // .and_then(|caps| caps.get(1)) - // .map(|cap| cap.as_str().to_owned()) - // } else { - // tracing::error!("failed to build Capture regex: {}", pattern); - - // None - // } - // } - // DisplayLine::CaptureMany(pattern, replace) => { - // if let Ok(re) = Regex::new(&pattern) { - // if let Some(captures) = re.captures(&line) { - // let replacement = interpolate_result(replace, &keywords, &captures); - // if let Ok(replacement) = replacement { - // Some(replacement) - // } else { - // tracing::error!( - // "unable to interpolate Replace: {}, {}", - // pattern, - // replace - // ); - - // None - // } - // } else { - // None - // } - // } else { - // tracing::error!("failed to build Replace regex: {}", pattern); - - // None - // } - // } - // } - // }; let result_name: Option = interpolate(&defn.result_name); let result_desc: Option = interpolate(&defn.result_desc); From d979ac99d4a5821b232f279d4ecc6e708f2a4d43 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sat, 18 Feb 2023 22:14:48 -0700 Subject: [PATCH 05/11] Interpolate run_command --- plugins/src/search/app.rs | 87 ++++++++++++++++++----------------- plugins/src/search/config.ron | 2 +- plugins/src/search/mod.rs | 12 +++-- plugins/src/search/util.rs | 81 ++++++++++++++++++-------------- 4 files changed, 102 insertions(+), 80 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 381e121..172f8ba 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -1,21 +1,18 @@ use flume::Receiver; use regex::Regex; use std::cell::Cell; -// use std::future::Future; use std::io; -// use std::ops::Deref; use std::rc::Rc; -// use std::sync::Arc; use tokio::io::{AsyncBufReadExt, BufReader, Lines}; use tokio::process::ChildStdout; use pop_launcher::{async_stdout, PluginResponse, PluginSearchResult}; use crate::search::config::Definition; -use crate::search::util::interpolate_result; +use crate::search::util::{interpolate_result, interpolate_run_command}; use super::config::{load, Config}; -use super::util::{exec, interpolate_command}; +use super::util::{exec, interpolate_query_command}; /// Maintains state for search requests pub struct App { @@ -28,7 +25,7 @@ pub struct App { pub cancel: Option>, pub out: tokio::io::Stdout, - pub search_results: Vec, + pub search_results: Vec>, } impl Default for App { @@ -58,7 +55,6 @@ impl App { 'stream: loop { let interrupt = async { let x: Option<&Receiver<()>> = self.cancel.as_ref(); - // let x: Option<&Receiver<()>> = (*cancel).as_ref(); if let Some(cancel) = x { let _ = cancel.recv_async().await; @@ -107,9 +103,9 @@ impl App { ) { eprintln!("append: {:?} {:?}", id, output_line); - let interpolate = |result_line: &'a str| -> Option { - if let Ok(re) = Regex::new(&defn.output_captures) { - if let Some(captures) = re.captures(&output_line) { + if let Ok(re) = Regex::new(&defn.output_captures) { + if let Some(captures) = re.captures(&output_line) { + let interpolate = |result_line: &'a str| -> Option { let interpolated = interpolate_result( result_line, output_line, @@ -120,33 +116,42 @@ impl App { if let Ok(interpolated) = interpolated { Some(interpolated) } else { - // tracing::error!("unable to interpolate Replace: {}, {}", pattern, replace); + tracing::error!( + "unable to interpolate result: {}, {}", + result_line, + output_line + ); None } - } else { - None + }; + + let result_name: Option = interpolate(&defn.result_name); + let result_desc: Option = interpolate(&defn.result_desc); + let run_command_parts = interpolate_run_command( + &defn.run_command, + output_line, + query_string, + keywords, + &captures, + ); + + if let Some(name) = result_name { + if let Some(description) = result_desc { + if let Ok(run_command_parts) = run_command_parts { + let response = PluginResponse::Append(PluginSearchResult { + id, + name: name.to_owned(), + description: description.to_owned(), + ..Default::default() + }); + + eprintln!("append; send response {:?}", response); + + crate::send(&mut self.out, response).await; + self.search_results.push(run_command_parts); + } + } } - } else { - None - } - }; - - let result_name: Option = interpolate(&defn.result_name); - let result_desc: Option = interpolate(&defn.result_desc); - - if let Some(name) = result_name { - if let Some(description) = result_desc { - let response = PluginResponse::Append(PluginSearchResult { - id, - name: name.to_owned(), - description: description.to_owned(), - ..Default::default() - }); - - eprintln!("append; send response {:?}", response); - - crate::send(&mut self.out, response).await; - self.search_results.push(output_line.to_string()); } } } @@ -159,21 +164,19 @@ impl App { self.search_results.clear(); if let Some(keywords) = shell_words::split(&query_string).ok().as_deref() { - if let Some(word) = keywords.first() { - eprintln!("look for word: '{}'", word); - - let word_defn: Option = self.config.get(word).cloned(); + if let Some(prefix) = keywords.first() { + let defn: Option = self.config.get(prefix).cloned(); - if let Some(defn) = word_defn { + if let Some(defn) = defn { if let Some(parts) = - interpolate_command(&defn.query_command, &query_string, keywords).ok() + interpolate_query_command(&defn.query_command, &query_string, keywords).ok() { - eprintln!("search parts: {:?}", parts); + eprintln!("query command: {:?}", parts); if let Some((program, args)) = parts.split_first() { // We're good to exec the command! - let (mut child, mut stdout) = match exec(program, args).await { + let (mut child, mut stdout) = match exec(program, args, true).await { Ok((child, stdout)) => { eprintln!("spawned process"); (child, tokio::io::BufReader::new(stdout).lines()) diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron index aa52a52..ae3f5c6 100644 --- a/plugins/src/search/config.ron +++ b/plugins/src/search/config.ron @@ -36,7 +36,7 @@ output_captures: "^\\s+([0-9]+)\\s+(.*)$", result_name: "${CAPTURE2}", result_desc: "${CAPTURE1}", - run_command: "notify-send ps", + run_command: "notify-send '$OUTPUT'", ) ), ( diff --git a/plugins/src/search/mod.rs b/plugins/src/search/mod.rs index caeec8e..fbb2cc8 100644 --- a/plugins/src/search/mod.rs +++ b/plugins/src/search/mod.rs @@ -5,6 +5,8 @@ use app::App; use futures::*; use pop_launcher::{async_stdin, json_input_stream, PluginResponse, Request}; +use crate::search::util::exec; + mod app; mod config; mod util; @@ -33,10 +35,14 @@ pub async fn main() { match search { Event::Activate(id) => { if let Some(selection) = app.search_results.get(id as usize) { - let path = selection.clone(); + let run_command_parts = selection.clone(); tokio::spawn(async move { - // exec(app.config.) - crate::xdg_open(&path); + eprintln!("run command: {:?}", run_command_parts); + + if let Some((program, args)) = run_command_parts.split_first() { + // We're good to exec the command! + let _ = exec(program, args, false).await; + } }); crate::send(&mut app.out, PluginResponse::Close).await; diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index b9b97bb..1498ceb 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -8,37 +8,6 @@ use std::{env, fmt}; use shell_words::{self, ParseError}; use shellexpand::{self, LookupError}; -/** - * - * Suppose `config.ron` contains the following: - * ( - * ( - * matches: ["f", "find"], - * action: ( - * title: Label("File"), - * detail: Capture("^.+/([^/]*)$"), - * query: "fdfind --ignore-case --glob --full-path $1 --type ${2:-file}" - * command: "xdg-open" - * ) - * ), - * ... - * ) - * - * And in the launcher frontend, the user types the following search: - * - * "find 'My Document'" - * - * Perform an interpolation as follows: - * - * 1. Search all rules to find a match for the 'find' command - * 2. Construct the command-line query by interpolating the search terms: - * $1: "My Document" - * $2: "file" (using default) - * 3. Final result: - * ["fdfind", "--ignore-case", "--glob", "--full-path", "My Document", "--type", "file"] - * - */ - fn home_dir() -> Option { env::var("HOME").ok() } @@ -102,7 +71,7 @@ pub fn interpolate_result( Ok(expanded.to_string()) } -pub fn interpolate_command( +pub fn interpolate_query_command( input: &str, query_string: &str, keywords: &[String], @@ -114,6 +83,42 @@ pub fn interpolate_command( if var.eq("QUERY") { // The full query string (i.e. all keywords, including the search prefix) as one string Ok(Some(format!("'{}'", query_string.to_string()))) + } else if var.eq("KEYWORDS") { + // Just the keywords (absent the search prefix) as one string. + // NOTE: Whitespace may not be preserved + Ok(Some(format!("'{}'", keywords[1..].join(" ")))) + } else if let Some(number) = var.strip_prefix("KEYWORD") { + // Look up an individual keyword, e.g. $KEYWORD1, $KEYWORD2, etc. + let idx = number.parse::()?; + Ok(keywords.get(idx).map(|kw| format!("'{}'", kw))) + } else { + // TODO: Add env vars + Ok(None) + } + }, + )?; + + let parts = shell_words::split(&expanded)?; + + Ok(parts) +} + +pub fn interpolate_run_command( + input: &str, + output: &str, + query_string: &str, + keywords: &[String], + captures: &Captures, +) -> Result, InterpolateError> { + let expanded = shellexpand::full_with_context( + input, + home_dir, + |var: &str| -> Result, std::num::ParseIntError> { + if var.eq("OUTPUT") { + Ok(Some(output.to_string())) + } else if var.eq("QUERY") { + // The full query string (i.e. all keywords, including the search prefix) as one string + Ok(Some(query_string.to_string())) } else if var.eq("KEYWORDS") { // Just the keywords (absent the search prefix) as one string. // NOTE: Whitespace may not be preserved @@ -122,6 +127,14 @@ pub fn interpolate_command( // Look up an individual keyword, e.g. $KEYWORD1, $KEYWORD2, etc. let idx = number.parse::()?; Ok(keywords.get(idx).cloned()) + } else if let Some(number) = var.strip_prefix("CAPTURE") { + // Look up an individual regex capture, e.g. $CAPTURE0, $CAPTURE1, etc. + let idx = number.parse::()?; + if let Some(capture) = captures.get(idx) { + Ok(Some(capture.as_str().to_owned())) + } else { + Ok(None) + } } else { // TODO: Add env vars Ok(None) @@ -134,13 +147,13 @@ pub fn interpolate_command( Ok(parts) } -pub async fn exec(program: &str, args: &[String]) -> io::Result<(Child, ChildStdout)> { +pub async fn exec(program: &str, args: &[String], piped: bool) -> io::Result<(Child, ChildStdout)> { eprintln!("exec {:?} with {:?}", program, args); // Closure to spawn the process let mut child = Command::new(program) .args(args) .stdin(Stdio::null()) - .stdout(Stdio::piped()) + .stdout(if piped { Stdio::piped() } else { Stdio::null() }) .stderr(Stdio::null()) .spawn()?; From 72d382143f8c7507c0575c295d2582d7c7083657 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sun, 19 Feb 2023 14:16:54 -0700 Subject: [PATCH 06/11] Use split_query util --- plugins/src/search/app.rs | 116 +++++++++++++++++-------------------- plugins/src/search/util.rs | 14 +++++ 2 files changed, 67 insertions(+), 63 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 172f8ba..cf341e5 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -9,7 +9,7 @@ use tokio::process::ChildStdout; use pop_launcher::{async_stdout, PluginResponse, PluginSearchResult}; use crate::search::config::Definition; -use crate::search::util::{interpolate_result, interpolate_run_command}; +use crate::search::util::{interpolate_result, interpolate_run_command, split_query}; use super::config::{load, Config}; use super::util::{exec, interpolate_query_command}; @@ -159,76 +159,66 @@ impl App { // Given a query string, identify whether or not it matches one of the rules in our definition set, and // if so, execute the corresponding query_command. pub async fn search(&mut self, query_string: String) { - eprintln!("config: {:?}", self.config); - self.search_results.clear(); - if let Some(keywords) = shell_words::split(&query_string).ok().as_deref() { - if let Some(prefix) = keywords.first() { - let defn: Option = self.config.get(prefix).cloned(); - - if let Some(defn) = defn { - if let Some(parts) = - interpolate_query_command(&defn.query_command, &query_string, keywords).ok() - { - eprintln!("query command: {:?}", parts); - - if let Some((program, args)) = parts.split_first() { - // We're good to exec the command! - - let (mut child, mut stdout) = match exec(program, args, true).await { - Ok((child, stdout)) => { - eprintln!("spawned process"); - (child, tokio::io::BufReader::new(stdout).lines()) - } - Err(why) => { - eprintln!("failed to spawn process: {}", why); - tracing::error!("failed to spawn process: {}", why); - - let _ = crate::send( - &mut self.out, - PluginResponse::Append(PluginSearchResult { - id: 0, - name: if why.kind() == io::ErrorKind::NotFound { - String::from("command not found") - } else { - format!("failed to spawn process: {}", why) - }, - ..Default::default() - }), - ) - .await; - - return; - } - }; - - let timeout = async { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - }; - - let listener = - self.make_listener(&mut stdout, &defn, &query_string, keywords); - - futures::pin_mut!(timeout); - futures::pin_mut!(listener); - - let _ = futures::future::select(timeout, listener).await; - - let _ = child.kill().await; - let _ = child.wait().await; - } - } else { - eprintln!("can't interpolate command"); + if let Some((prefix, keywords)) = split_query(&query_string) { + let defn: Option = self.config.get(&prefix).cloned(); + + if let Some(defn) = defn { + if let Some(parts) = + interpolate_query_command(&defn.query_command, &query_string, &keywords).ok() + { + if let Some((program, args)) = parts.split_first() { + // We're good to exec the command! + + let (mut child, mut stdout) = match exec(program, args, true).await { + Ok((child, stdout)) => { + (child, tokio::io::BufReader::new(stdout).lines()) + } + Err(why) => { + tracing::error!("failed to spawn process: {}", why); + + let _ = crate::send( + &mut self.out, + PluginResponse::Append(PluginSearchResult { + id: 0, + name: if why.kind() == io::ErrorKind::NotFound { + String::from("command not found") + } else { + format!("failed to spawn process: {}", why) + }, + ..Default::default() + }), + ) + .await; + + return; + } + }; + + let timeout = async { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + }; + + let listener = + self.make_listener(&mut stdout, &defn, &query_string, &keywords); + + futures::pin_mut!(timeout); + futures::pin_mut!(listener); + + let _ = futures::future::select(timeout, listener).await; + + let _ = child.kill().await; + let _ = child.wait().await; } } else { - eprintln!("no matching definition"); + tracing::error!("can't interpolate query command"); } } else { - eprintln!("search term has no head word"); + tracing::error!("no matching definition"); } } else { - eprintln!("can't split search terms"); + tracing::error!("can't split search keywords"); } } } diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index 1498ceb..1dcd105 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -29,6 +29,17 @@ impl From for InterpolateError { } } +pub fn split_query(query_string: &str) -> Option<(String, Vec)> { + let parts = shell_words::split(&query_string).ok(); + if let Some(mut keywords) = parts { + if let Some(first) = keywords.first() { + return Some((first.to_owned(), keywords)); + } + } + + None +} + pub fn interpolate_result( input: &str, output: &str, @@ -110,10 +121,12 @@ pub fn interpolate_run_command( keywords: &[String], captures: &Captures, ) -> Result, InterpolateError> { + eprintln!("irunc 1: {} {:?}", input, keywords); let expanded = shellexpand::full_with_context( input, home_dir, |var: &str| -> Result, std::num::ParseIntError> { + eprintln!("irunc 2: {}", var); if var.eq("OUTPUT") { Ok(Some(output.to_string())) } else if var.eq("QUERY") { @@ -122,6 +135,7 @@ pub fn interpolate_run_command( } else if var.eq("KEYWORDS") { // Just the keywords (absent the search prefix) as one string. // NOTE: Whitespace may not be preserved + eprintln!("KEYWORDS: {}", keywords[1..].join(" ")); Ok(Some(keywords[1..].join(" "))) } else if let Some(number) = var.strip_prefix("KEYWORD") { // Look up an individual keyword, e.g. $KEYWORD1, $KEYWORD2, etc. From f13041ec659655b97c0361807824d887311f8e80 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sun, 19 Feb 2023 19:01:59 -0700 Subject: [PATCH 07/11] Use StartsWith in anticipation of future regex pattern support --- plugins/src/search/config.ron | 27 ++++++++++++++++++--------- plugins/src/search/config.rs | 28 +++++++++++++++++++++++----- plugins/src/search/util.rs | 4 ++-- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron index ae3f5c6..2f33fae 100644 --- a/plugins/src/search/config.ron +++ b/plugins/src/search/config.ron @@ -1,36 +1,45 @@ ( rules: [ ( - matches: ["f", "find"], + pattern: StartsWith(["f", "find"]), action: ( query_command: "fdfind --ignore-case --full-path $KEYWORD1", output_captures: "^(.+)/([^/]+)$", result_name: "$CAPTURE2", result_desc: "$CAPTURE1", - run_command: "xdg-open \"$OUTPUT\"", + run_command: "xdg-open '$OUTPUT'", ) ), ( - matches: ["ls"], + pattern: StartsWith(["="]), + action: ( + query_command: "qalc -u8 -set 'maxdeci 9' -t $KEYWORDS", + output_captures: "^\\s\\s(.+)$", + result_name: "$CAPTURE1", + run_command: "/bin/bash -c 'wl-copy \"$CAPTURE1\" && notify-send \"Copied to clipboard\"'", + ) + ), + ( + pattern: StartsWith(["ls"]), action: ( query_command: "ls -1 $KEYWORD1", result_name: "File", result_desc: "hi $OUTPUT", - run_command: "xdg-open \"$KEYWORD1/$OUTPUT\"" + run_command: "xdg-open '$KEYWORD1/$OUTPUT'" ) ), ( - matches: ["apt"], + pattern: StartsWith(["apt"]), action: ( query_command: "apt list $KEYWORD1", output_captures: "^([^/]+)/(.+)$", result_name: "$CAPTURE1", result_desc: "$CAPTURE2", - run_command: "notify-send $OUTPUT", + run_command: "notify-send '$OUTPUT'", ) ), ( - matches: ["ps"], + pattern: StartsWith(["ps"]), action: ( query_command: "/bin/bash -c 'ps --sort=-pcpu -axo pid,ucmd,pcpu | head -10'", output_captures: "^\\s+([0-9]+)\\s+(.*)$", @@ -40,10 +49,10 @@ ) ), ( - matches: ["drives"], + pattern: StartsWith(["drives"]), action: ( query_command: "lsblk -lno NAME,SIZE,MOUNTPOINTS", - run_command: "notify-send ps", + run_command: "notify-send '$OUTPUT'", ) ), ] diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index dbbc83c..189d5cc 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; #[derive(Default, Clone, Debug)] pub struct Config { - matches: HashMap, + match_starts_with: HashMap, definitions: Slab, } @@ -15,14 +15,22 @@ impl Config { pub fn append(&mut self, config: RawConfig) { for rule in config.rules { let idx = self.definitions.insert(rule.action); - for keyword in rule.matches { - self.matches.entry(keyword).or_insert(idx as u32); + match rule.pattern { + Pattern::StartsWith(matches) => { + for keyword in matches { + self.match_starts_with.entry(keyword).or_insert(idx as u32); + } + } + Pattern::Regex(_) => { + // TODO + tracing::error!("regular expression patterns not implemented"); + } } } } pub fn get(&self, word: &str) -> Option<&Definition> { - self.matches + self.match_starts_with .get(word) .and_then(|idx| self.definitions.get(*idx as usize)) } @@ -35,10 +43,16 @@ pub struct RawConfig { #[derive(Debug, Deserialize, Clone)] pub struct Rule { - pub matches: Vec, + pub pattern: Pattern, pub action: Definition, } +#[derive(Debug, Deserialize, Clone)] +pub enum Pattern { + StartsWith(Vec), + Regex(String), +} + /** * The DisplayLine configures what to show in the results list, based on what the * shell command's STDOUT produces. @@ -72,6 +86,10 @@ fn regex_match_all() -> String { "^.*$".to_string() } +fn regex_split_whitespace() -> String { + "\\s+".to_string() +} + fn result_echo() -> String { "$OUTPUT".to_string() } diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index 1dcd105..87549a6 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -1,4 +1,4 @@ -use regex::Captures; +use regex::{Captures, Regex}; use std::io; use std::process::Stdio; use tokio::process::{Child, ChildStdout, Command}; @@ -31,7 +31,7 @@ impl From for InterpolateError { pub fn split_query(query_string: &str) -> Option<(String, Vec)> { let parts = shell_words::split(&query_string).ok(); - if let Some(mut keywords) = parts { + if let Some(keywords) = parts { if let Some(first) = keywords.first() { return Some((first.to_owned(), keywords)); } From b598cdabc8a92660d1abf31c75aced5633f195b1 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sun, 19 Feb 2023 19:03:10 -0700 Subject: [PATCH 08/11] Fix warnings --- plugins/src/search/config.rs | 4 ---- plugins/src/search/util.rs | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index 189d5cc..1aca5bc 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -86,10 +86,6 @@ fn regex_match_all() -> String { "^.*$".to_string() } -fn regex_split_whitespace() -> String { - "\\s+".to_string() -} - fn result_echo() -> String { "$OUTPUT".to_string() } diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index 87549a6..50c0ca3 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -1,4 +1,4 @@ -use regex::{Captures, Regex}; +use regex::Captures; use std::io; use std::process::Stdio; use tokio::process::{Child, ChildStdout, Command}; From 28d0e8bd5054ce05440c64c4d44ca306f71fc34d Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sun, 19 Feb 2023 19:32:01 -0700 Subject: [PATCH 09/11] Remove eprintln debug info --- plugins/src/search/app.rs | 9 --------- plugins/src/search/config.rs | 10 +--------- plugins/src/search/mod.rs | 2 -- plugins/src/search/util.rs | 4 ---- 4 files changed, 1 insertion(+), 24 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index cf341e5..7ce03cf 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -50,7 +50,6 @@ impl App { ) { let mut id = 0; let mut output_line; - eprintln!("start listener"); 'stream: loop { let interrupt = async { @@ -59,7 +58,6 @@ impl App { if let Some(cancel) = x { let _ = cancel.recv_async().await; } else { - eprintln!("no interrupt receiver"); tracing::error!("no interrupt receiver"); } Ok(None) @@ -67,15 +65,12 @@ impl App { match crate::or(interrupt, stdout.next_line()).await { Ok(Some(line)) => { - eprintln!("append line: {}", line); output_line = line } Ok(None) => { - eprintln!("listener; break stream"); break 'stream; } Err(why) => { - eprintln!("error on stdout line read: {}", why); tracing::error!("error on stdout line read: {}", why); break 'stream; } @@ -101,8 +96,6 @@ impl App { query_string: &'a str, keywords: &'a [String], ) { - eprintln!("append: {:?} {:?}", id, output_line); - if let Ok(re) = Regex::new(&defn.output_captures) { if let Some(captures) = re.captures(&output_line) { let interpolate = |result_line: &'a str| -> Option { @@ -145,8 +138,6 @@ impl App { ..Default::default() }); - eprintln!("append; send response {:?}", response); - crate::send(&mut self.out, response).await; self.search_results.push(run_command_parts); } diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index 1aca5bc..67b42ce 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -95,32 +95,24 @@ fn string_blank() -> String { } pub fn load() -> Config { - eprintln!("load config"); let mut config = Config::default(); for path in pop_launcher::config::find("search") { let string = match std::fs::read_to_string(&path) { Ok(string) => string, Err(why) => { - eprintln!("load config err A"); tracing::error!("failed to read config: {}", why); continue; } }; match ron::from_str::(&string) { - Ok(raw) => { - eprintln!("raw: {:?}", raw); - config.append(raw) - } + Ok(raw) => config.append(raw), Err(why) => { - eprintln!("load config err B: {}", why); tracing::error!("failed to deserialize config: {}", why); } } } - eprintln!("load config: {:?}", config); - config } diff --git a/plugins/src/search/mod.rs b/plugins/src/search/mod.rs index fbb2cc8..5f2c321 100644 --- a/plugins/src/search/mod.rs +++ b/plugins/src/search/mod.rs @@ -37,8 +37,6 @@ pub async fn main() { if let Some(selection) = app.search_results.get(id as usize) { let run_command_parts = selection.clone(); tokio::spawn(async move { - eprintln!("run command: {:?}", run_command_parts); - if let Some((program, args)) = run_command_parts.split_first() { // We're good to exec the command! let _ = exec(program, args, false).await; diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index 50c0ca3..c4823d4 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -121,12 +121,10 @@ pub fn interpolate_run_command( keywords: &[String], captures: &Captures, ) -> Result, InterpolateError> { - eprintln!("irunc 1: {} {:?}", input, keywords); let expanded = shellexpand::full_with_context( input, home_dir, |var: &str| -> Result, std::num::ParseIntError> { - eprintln!("irunc 2: {}", var); if var.eq("OUTPUT") { Ok(Some(output.to_string())) } else if var.eq("QUERY") { @@ -135,7 +133,6 @@ pub fn interpolate_run_command( } else if var.eq("KEYWORDS") { // Just the keywords (absent the search prefix) as one string. // NOTE: Whitespace may not be preserved - eprintln!("KEYWORDS: {}", keywords[1..].join(" ")); Ok(Some(keywords[1..].join(" "))) } else if let Some(number) = var.strip_prefix("KEYWORD") { // Look up an individual keyword, e.g. $KEYWORD1, $KEYWORD2, etc. @@ -162,7 +159,6 @@ pub fn interpolate_run_command( } pub async fn exec(program: &str, args: &[String], piped: bool) -> io::Result<(Child, ChildStdout)> { - eprintln!("exec {:?} with {:?}", program, args); // Closure to spawn the process let mut child = Command::new(program) .args(args) From 17855d474547390c5f6f646cc747ce1a2c082f82 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Sun, 19 Feb 2023 23:31:45 -0700 Subject: [PATCH 10/11] Add pattern types, including regex; add split regex --- plugins/src/search/app.rs | 30 ++++++----- plugins/src/search/config.ron | 15 +++--- plugins/src/search/config.rs | 97 +++++++++++++++++++++++++++-------- plugins/src/search/util.rs | 24 +++++---- 4 files changed, 113 insertions(+), 53 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 7ce03cf..7997507 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -9,10 +9,12 @@ use tokio::process::ChildStdout; use pop_launcher::{async_stdout, PluginResponse, PluginSearchResult}; use crate::search::config::Definition; -use crate::search::util::{interpolate_result, interpolate_run_command, split_query}; +use crate::search::util::{interpolate_result, interpolate_run_command}; use super::config::{load, Config}; -use super::util::{exec, interpolate_query_command}; +use super::util::{ + exec, interpolate_query_command, split_query_by_regex, split_query_by_shell_words, +}; /// Maintains state for search requests pub struct App { @@ -64,9 +66,7 @@ impl App { }; match crate::or(interrupt, stdout.next_line()).await { - Ok(Some(line)) => { - output_line = line - } + Ok(Some(line)) => output_line = line, Ok(None) => { break 'stream; } @@ -152,13 +152,17 @@ impl App { pub async fn search(&mut self, query_string: String) { self.search_results.clear(); - if let Some((prefix, keywords)) = split_query(&query_string) { - let defn: Option = self.config.get(&prefix).cloned(); - - if let Some(defn) = defn { + if let Some(rule) = self.config.match_rule(&query_string).cloned() { + if let Some(keywords) = match rule.split { + Some(re) => split_query_by_regex(&query_string, &re), + None => split_query_by_shell_words(&query_string), + } { + eprintln!("keywords: {:?}", keywords); if let Some(parts) = - interpolate_query_command(&defn.query_command, &query_string, &keywords).ok() + interpolate_query_command(&rule.action.query_command, &query_string, &keywords) + .ok() { + eprintln!("query command: {:?}", parts); if let Some((program, args)) = parts.split_first() { // We're good to exec the command! @@ -192,7 +196,7 @@ impl App { }; let listener = - self.make_listener(&mut stdout, &defn, &query_string, &keywords); + self.make_listener(&mut stdout, &rule.action, &query_string, &keywords); futures::pin_mut!(timeout); futures::pin_mut!(listener); @@ -206,10 +210,8 @@ impl App { tracing::error!("can't interpolate query command"); } } else { - tracing::error!("no matching definition"); + tracing::error!("can't split search keywords"); } - } else { - tracing::error!("can't split search keywords"); } } } diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron index 2f33fae..df0e323 100644 --- a/plugins/src/search/config.ron +++ b/plugins/src/search/config.ron @@ -1,7 +1,7 @@ ( rules: [ ( - pattern: StartsWith(["f", "find"]), + pattern: StartsWithKeyword(["f", "find"]), action: ( query_command: "fdfind --ignore-case --full-path $KEYWORD1", output_captures: "^(.+)/([^/]+)$", @@ -11,16 +11,15 @@ ) ), ( - pattern: StartsWith(["="]), + pattern: StartsWithKeyword(["="]), + split: Regex("=|\\s+"), action: ( query_command: "qalc -u8 -set 'maxdeci 9' -t $KEYWORDS", - output_captures: "^\\s\\s(.+)$", - result_name: "$CAPTURE1", run_command: "/bin/bash -c 'wl-copy \"$CAPTURE1\" && notify-send \"Copied to clipboard\"'", ) ), ( - pattern: StartsWith(["ls"]), + pattern: StartsWithKeyword(["ls"]), action: ( query_command: "ls -1 $KEYWORD1", result_name: "File", @@ -29,7 +28,7 @@ ) ), ( - pattern: StartsWith(["apt"]), + pattern: StartsWithKeyword(["apt"]), action: ( query_command: "apt list $KEYWORD1", output_captures: "^([^/]+)/(.+)$", @@ -39,7 +38,7 @@ ) ), ( - pattern: StartsWith(["ps"]), + pattern: StartsWithKeyword(["ps"]), action: ( query_command: "/bin/bash -c 'ps --sort=-pcpu -axo pid,ucmd,pcpu | head -10'", output_captures: "^\\s+([0-9]+)\\s+(.*)$", @@ -49,7 +48,7 @@ ) ), ( - pattern: StartsWith(["drives"]), + pattern: StartsWithKeyword(["drives"]), action: ( query_command: "lsblk -lno NAME,SIZE,MOUNTPOINTS", run_command: "notify-send '$OUTPUT'", diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index 67b42ce..09e5651 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -1,38 +1,74 @@ // SPDX-License-Identifier: GPL-3.0-only // Copyright © 2023 System76 +use regex::Regex; use serde::Deserialize; -use slab::Slab; -use std::collections::HashMap; -#[derive(Default, Clone, Debug)] +#[derive(Debug, Clone)] pub struct Config { - match_starts_with: HashMap, - definitions: Slab, + pub rules: Vec, +} + +#[derive(Debug, Clone)] +pub struct CompiledRule { + pub pattern: Regex, + pub action: Definition, + pub split: Option, } impl Config { pub fn append(&mut self, config: RawConfig) { + let escape = |keywords: &Vec| { + keywords + .into_iter() + .map(|m| regex::escape(&m)) + .collect::>() + .join("|") + }; for rule in config.rules { - let idx = self.definitions.insert(rule.action); - match rule.pattern { - Pattern::StartsWith(matches) => { - for keyword in matches { - self.match_starts_with.entry(keyword).or_insert(idx as u32); - } + let pattern_re = match rule.pattern { + Pattern::StartsWith(keywords) => { + Regex::new(&format!("^({})", escape(&keywords))).unwrap() + } + Pattern::StartsWithKeyword(keywords) => { + Regex::new(&format!("^({})\\b", escape(&keywords))).unwrap() } - Pattern::Regex(_) => { - // TODO - tracing::error!("regular expression patterns not implemented"); + Pattern::EndsWith(keywords) => { + Regex::new(&format!("({})$", escape(&keywords))).unwrap() } + Pattern::EndsWithKeyword(keywords) => { + Regex::new(&format!("\\b({})$", escape(&keywords))).unwrap() + } + Pattern::Regex(uncompiled) => Regex::new(&uncompiled).unwrap(), + }; + + let split_re = match rule.split { + Split::ShellWords => None, + Split::Whitespace => Regex::new("\\s+").ok(), + Split::Regex(uncompiled) => Regex::new(&uncompiled).ok(), + }; + + self.rules.push(CompiledRule { + pattern: pattern_re, + action: rule.action, + split: split_re, + }) + } + } + + pub fn match_rule(&self, query_string: &str) -> Option<&CompiledRule> { + for rule in &self.rules { + if rule.pattern.is_match(query_string) { + return Some(&rule); } } + None } +} - pub fn get(&self, word: &str) -> Option<&Definition> { - self.match_starts_with - .get(word) - .and_then(|idx| self.definitions.get(*idx as usize)) +impl Default for Config { + fn default() -> Self { + Config { rules: Vec::new() } } } @@ -45,11 +81,24 @@ pub struct RawConfig { pub struct Rule { pub pattern: Pattern, pub action: Definition, + + #[serde(default = "split_shell_words")] + pub split: Split, } #[derive(Debug, Deserialize, Clone)] pub enum Pattern { StartsWith(Vec), + StartsWithKeyword(Vec), + EndsWith(Vec), + EndsWithKeyword(Vec), + Regex(String), +} + +#[derive(Debug, Deserialize, Clone)] +pub enum Split { + ShellWords, + Whitespace, Regex(String), } @@ -71,11 +120,11 @@ pub struct Definition { pub output_captures: String, // An optional string; shown as the "name" line of the query result. - #[serde(default = "result_echo")] + #[serde(default = "echo_result")] pub result_name: String, // An optional string; shown as the "description" line of the query result. - #[serde(default = "string_blank")] + #[serde(default = "blank_string")] pub result_desc: String, // REQUIRED: The shell command to run when the user selects a result (usually, "Enter" key pressed) @@ -86,14 +135,18 @@ fn regex_match_all() -> String { "^.*$".to_string() } -fn result_echo() -> String { +fn echo_result() -> String { "$OUTPUT".to_string() } -fn string_blank() -> String { +fn blank_string() -> String { "".to_string() } +fn split_shell_words() -> Split { + Split::ShellWords +} + pub fn load() -> Config { let mut config = Config::default(); diff --git a/plugins/src/search/util.rs b/plugins/src/search/util.rs index c4823d4..8719270 100644 --- a/plugins/src/search/util.rs +++ b/plugins/src/search/util.rs @@ -1,4 +1,4 @@ -use regex::Captures; +use regex::{Captures, Regex}; use std::io; use std::process::Stdio; use tokio::process::{Child, ChildStdout, Command}; @@ -29,15 +29,21 @@ impl From for InterpolateError { } } -pub fn split_query(query_string: &str) -> Option<(String, Vec)> { - let parts = shell_words::split(&query_string).ok(); - if let Some(keywords) = parts { - if let Some(first) = keywords.first() { - return Some((first.to_owned(), keywords)); - } - } +pub fn split_query_by_shell_words(query_string: &str) -> Option> { + shell_words::split(&query_string).ok() +} - None +pub fn split_query_by_regex(query_string: &str, split_re: &Regex) -> Option> { + let keywords = split_re + .split(query_string) + .into_iter() + .map(|p| p.to_owned()) + .collect::>(); + if keywords.len() > 0 { + Some(keywords) + } else { + None + } } pub fn interpolate_result( From 099ecfdd0148aa3e7a71e53a602c8dacdb8c2961 Mon Sep 17 00:00:00 2001 From: Duane Johnson Date: Mon, 20 Feb 2023 16:51:36 -0700 Subject: [PATCH 11/11] Improve qalc example --- plugins/src/search/app.rs | 1 + plugins/src/search/config.ron | 10 ++++++---- plugins/src/search/config.rs | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/src/search/app.rs b/plugins/src/search/app.rs index 7997507..246a924 100644 --- a/plugins/src/search/app.rs +++ b/plugins/src/search/app.rs @@ -127,6 +127,7 @@ impl App { keywords, &captures, ); + eprintln!("run command: {:?}", run_command_parts); if let Some(name) = result_name { if let Some(description) = result_desc { diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron index df0e323..425ac7e 100644 --- a/plugins/src/search/config.ron +++ b/plugins/src/search/config.ron @@ -11,11 +11,13 @@ ) ), ( - pattern: StartsWithKeyword(["="]), - split: Regex("=|\\s+"), + pattern: StartsWith(["="]), + split: Regex("^="), action: ( - query_command: "qalc -u8 -set 'maxdeci 9' -t $KEYWORDS", - run_command: "/bin/bash -c 'wl-copy \"$CAPTURE1\" && notify-send \"Copied to clipboard\"'", + query_command: "qalc -u8 -set 'maxdeci 9' -t $KEYWORD1", + result_name: "$KEYWORD1", + result_desc: "$OUTPUT", + run_command: "/bin/bash -c 'wl-copy \"$OUTPUT\" && notify-send \"Copied to clipboard\"'", ) ), ( diff --git a/plugins/src/search/config.rs b/plugins/src/search/config.rs index 09e5651..331a1e6 100644 --- a/plugins/src/search/config.rs +++ b/plugins/src/search/config.rs @@ -54,6 +54,7 @@ impl Config { split: split_re, }) } + // eprintln!("rules: {:?}", self.rules); } pub fn match_rule(&self, query_string: &str) -> Option<&CompiledRule> {