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..246a924 --- /dev/null +++ b/plugins/src/search/app.rs @@ -0,0 +1,218 @@ +use flume::Receiver; +use regex::Regex; +use std::cell::Cell; +use std::io; +use std::rc::Rc; +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, interpolate_run_command}; + +use super::config::{load, Config}; +use super::util::{ + exec, interpolate_query_command, split_query_by_regex, split_query_by_shell_words, +}; + +/// 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, + stdout: &mut Lines>, + defn: &Definition, + query_string: &str, + keywords: &[String], + ) { + let mut id = 0; + let mut output_line; + + 'stream: loop { + let interrupt = async { + let x: Option<&Receiver<()>> = self.cancel.as_ref(); + + if let Some(cancel) = x { + let _ = cancel.recv_async().await; + } else { + tracing::error!("no interrupt receiver"); + } + Ok(None) + }; + + match crate::or(interrupt, stdout.next_line()).await { + Ok(Some(line)) => output_line = line, + Ok(None) => { + break 'stream; + } + Err(why) => { + tracing::error!("error on stdout line read: {}", why); + break 'stream; + } + } + + self.append(id, &output_line, defn, query_string, keywords) + .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, + output_line: &'a str, + defn: &'a Definition, + query_string: &'a str, + keywords: &'a [String], + ) { + 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, + query_string, + keywords, + &captures, + ); + if let Ok(interpolated) = interpolated { + Some(interpolated) + } else { + tracing::error!( + "unable to interpolate result: {}, {}", + result_line, + output_line + ); + 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, + ); + eprintln!("run command: {:?}", run_command_parts); + + 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() + }); + + crate::send(&mut self.out, response).await; + self.search_results.push(run_command_parts); + } + } + } + } + } + } + + // 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) { + self.search_results.clear(); + + 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(&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! + + 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, &rule.action, &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 { + tracing::error!("can't interpolate query command"); + } + } else { + tracing::error!("can't split search keywords"); + } + } + } +} diff --git a/plugins/src/search/config.ron b/plugins/src/search/config.ron new file mode 100644 index 0000000..425ac7e --- /dev/null +++ b/plugins/src/search/config.ron @@ -0,0 +1,60 @@ +( + rules: [ + ( + pattern: StartsWithKeyword(["f", "find"]), + action: ( + query_command: "fdfind --ignore-case --full-path $KEYWORD1", + output_captures: "^(.+)/([^/]+)$", + result_name: "$CAPTURE2", + result_desc: "$CAPTURE1", + run_command: "xdg-open '$OUTPUT'", + ) + ), + ( + pattern: StartsWith(["="]), + split: Regex("^="), + action: ( + 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\"'", + ) + ), + ( + pattern: StartsWithKeyword(["ls"]), + action: ( + query_command: "ls -1 $KEYWORD1", + result_name: "File", + result_desc: "hi $OUTPUT", + run_command: "xdg-open '$KEYWORD1/$OUTPUT'" + ) + ), + ( + pattern: StartsWithKeyword(["apt"]), + action: ( + query_command: "apt list $KEYWORD1", + output_captures: "^([^/]+)/(.+)$", + result_name: "$CAPTURE1", + result_desc: "$CAPTURE2", + run_command: "notify-send '$OUTPUT'", + ) + ), + ( + pattern: StartsWithKeyword(["ps"]), + action: ( + 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 '$OUTPUT'", + ) + ), + ( + 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 new file mode 100644 index 0000000..331a1e6 --- /dev/null +++ b/plugins/src/search/config.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright © 2023 System76 + +use regex::Regex; +use serde::Deserialize; + +#[derive(Debug, Clone)] +pub struct Config { + 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 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::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, + }) + } + // eprintln!("rules: {:?}", self.rules); + } + + 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 + } +} + +impl Default for Config { + fn default() -> Self { + Config { rules: Vec::new() } + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RawConfig { + pub rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +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), +} + +/** + * The DisplayLine configures what to show in the results list, based on what the + * shell command's STDOUT produces. + */ +#[derive(Debug, Deserialize, Clone)] +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, + + // 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, + + // An optional string; shown as the "name" line of the query result. + #[serde(default = "echo_result")] + pub result_name: String, + + // An optional string; shown as the "description" line of the query result. + #[serde(default = "blank_string")] + pub result_desc: String, + + // REQUIRED: The shell command to run when the user selects a result (usually, "Enter" key pressed) + pub run_command: String, +} + +fn regex_match_all() -> String { + "^.*$".to_string() +} + +fn echo_result() -> String { + "$OUTPUT".to_string() +} + +fn blank_string() -> String { + "".to_string() +} + +fn split_shell_words() -> Split { + Split::ShellWords +} + +pub fn 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) => { + tracing::error!("failed to read config: {}", why); + continue; + } + }; + + match ron::from_str::(&string) { + Ok(raw) => config.append(raw), + Err(why) => { + tracing::error!("failed to deserialize config: {}", why); + } + } + } + + config +} diff --git a/plugins/src/search/mod.rs b/plugins/src/search/mod.rs new file mode 100644 index 0000000..5f2c321 --- /dev/null +++ b/plugins/src/search/mod.rs @@ -0,0 +1,105 @@ +// 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}; + +use crate::search::util::exec; + +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 run_command_parts = selection.clone(); + tokio::spawn(async move { + 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; + } + } + + 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..8719270 --- /dev/null +++ b/plugins/src/search/util.rs @@ -0,0 +1,181 @@ +use regex::{Captures, Regex}; +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}; + +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 split_query_by_shell_words(query_string: &str) -> Option> { + shell_words::split(&query_string).ok() +} + +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( + input: &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("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 { + // TODO: Add env vars + Ok(None) + } + }, + )?; + + Ok(expanded.to_string()) +} + +pub fn interpolate_query_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") { + // 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 + 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 { + // TODO: Add env vars + Ok(None) + } + }, + )?; + + let parts = shell_words::split(&expanded)?; + + Ok(parts) +} + +pub async fn exec(program: &str, args: &[String], piped: bool) -> io::Result<(Child, ChildStdout)> { + // Closure to spawn the process + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(if piped { Stdio::piped() } else { Stdio::null() }) + .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")) +}