From 68d118986cbed4d86ccc3006ce5244a358f244ee Mon Sep 17 00:00:00 2001 From: Alex Pasmantier <47638216+alexpasmantier@users.noreply.github.com> Date: Wed, 25 Dec 2024 18:53:50 +0100 Subject: [PATCH] feat(shell): autocompletion plugin for zsh (#145) --- .config/config.toml | 58 ++++++++ README.md | 8 ++ cable/unix-channels.toml | 44 ++++++ cable/windows-channels.toml | 21 +++ .../television-channels/src/channels/cable.rs | 12 +- crates/television-utils/src/lib.rs | 1 + crates/television-utils/src/shell.rs | 22 +++ crates/television/app.rs | 6 +- crates/television/cable.rs | 26 +++- crates/television/cli.rs | 133 +++++++++++++++--- crates/television/config.rs | 24 +++- crates/television/config/shell_integration.rs | 24 ++++ crates/television/main.rs | 45 ++++-- shell/completion.zsh | 29 ++++ 14 files changed, 416 insertions(+), 37 deletions(-) create mode 100644 cable/unix-channels.toml create mode 100644 cable/windows-channels.toml create mode 100644 crates/television-utils/src/shell.rs create mode 100644 crates/television/config/shell_integration.rs create mode 100644 shell/completion.zsh diff --git a/.config/config.toml b/.config/config.toml index 6b96508..5ec5a19 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -12,6 +12,12 @@ # In that case, television will expect the configuration file to be in: # `$XDG_CONFIG_HOME/television/config.toml` # + +# General settings +# ---------------------------------------------------------------------------- +frame_rate = 60 +tick_rate = 50 + [ui] # Whether to use nerd font icons in the UI # This option requires a font patched with Nerd Font in order to properly @@ -118,3 +124,55 @@ select_entry = "enter" toggle_send_to_channel = "ctrl-s" # Toggle the help bar toggle_help = "ctrl-g" + + +# Shell integration +# ---------------------------------------------------------------------------- +# +# The shell integration feature allows you to use television as a picker for +# your shell commands. E.g. typing `git checkout ` will open television +# with a list of branches to checkout. + +[shell_integration.commands] +# Which commands should trigger which channels +# The keys are the commands that should trigger the channels, and the values +# are the channels that should be triggered. +# Example: `git checkout` should trigger the `git-branches` channel +# `ls` should trigger the `dirs` channel +# `cat` should trigger the `files` channel +# +# Would be written as: +# ``` +# [shell_integration.commands] +# "git checkout" = "git-branches" +# "ls" = "dirs" +# "cat" = "files" +# ``` + +# The following are an example, you can remove/modify them and add your own. + +# shell history (according to your shell) +"" = "zsh-history" + +# dirs channel +"cd" = "dirs" +"ls" = "dirs" +"rmdir" = "dirs" + +# files channel +"cat" = "files" +"less" = "files" +"head" = "files" +"tail" = "files" +"vim" = "files" +"bat" = "files" + +# git-branch channel +"git checkout" = "git-branch" +"git branch -d" = "git-branch" + +# docker-images channel +"docker run" = "docker-images" + +# gitrepos channel +"nvim" = "gitrepos" diff --git a/README.md b/README.md index 0f7d59a..75129db 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,14 @@ It is inspired by the neovim [telescope](https://github.com/nvim-telescope/teles ``` +### Shell integration (currently only zsh) +To enable shell integration, run: +```bash +echo 'eval "$(tv init zsh)"' >> ~/.zshrc +``` +And then restart your shell. + + ## Usage ```bash tv [channel] #[default: files] [possible values: env, files, gitrepos, text, alias] diff --git a/cable/unix-channels.toml b/cable/unix-channels.toml new file mode 100644 index 0000000..9b1fad7 --- /dev/null +++ b/cable/unix-channels.toml @@ -0,0 +1,44 @@ +# GIT +[[cable_channel]] +name = "git-reflog" +source_command = 'git reflog' +preview_command = 'git show -p --stat --pretty=fuller --color=always {0}' + +[[cable_channel]] +name = "git-log" +source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\"" +preview_command = "git show -p --stat --pretty=fuller --color=always {0}" + +[[cable_channel]] +name = "git-branch" +source_command = "git --no-pager branch --all --format=\"%(refname:short)\"" +preview_command = "git show -p --stat --pretty=fuller --color=always {0}" + +# Docker +[[cable_channel]] +name = "docker-images" +source_command = "docker image list --format \"{{.ID}}\"" +preview_command = "docker image inspect {0} | jq -C" + +# S3 +[[cable_channel]] +name = "s3-buckets" +source_command = "aws s3 ls | cut -d \" \" -f 3" +preview_command = "aws s3 ls s3://{0}" + +# Dotfiles +[[cable_channel]] +name = "my-dotfiles" +source_command = "fd -t f . $HOME/.config" +preview_command = "bat -n --color=always {0}" + +# Shell history +[[cable_channel]] +name = "zsh-history" +source_command = "tac $HOME/.zsh_history | cut -d\";\" -f 2-" +preview_command = "echo '{}'" + +[[cable_channel]] +name = "bash-history" +source_command = "tac $HOME/.bash_history" +preview_command = "echo '{}'" diff --git a/cable/windows-channels.toml b/cable/windows-channels.toml new file mode 100644 index 0000000..fbda4b0 --- /dev/null +++ b/cable/windows-channels.toml @@ -0,0 +1,21 @@ +# GIT +[[cable_channel]] +name = "git-reflog" +source_command = 'git reflog' +preview_command = 'git show -p --stat --pretty=fuller --color=always {0}' + +[[cable_channel]] +name = "git-log" +source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\"" +preview_command = "git show -p --stat --pretty=fuller --color=always {0}" + +[[cable_channel]] +name = "git-branch" +source_command = "git --no-pager branch --all --format=\"%(refname:short)\"" +preview_command = "git show -p --stat --pretty=fuller --color=always {0}" + +# Docker +[[cable_channel]] +name = "docker-images" +source_command = "docker image list --format \"{{.ID}}\"" +preview_command = "docker image inspect {0} | jq -C" diff --git a/crates/television-channels/src/channels/cable.rs b/crates/television-channels/src/channels/cable.rs index 0fa8d58..7d1d051 100644 --- a/crates/television-channels/src/channels/cable.rs +++ b/crates/television-channels/src/channels/cable.rs @@ -63,12 +63,14 @@ async fn load_candidates(command: String, injector: Injector) { .output() .expect("failed to execute process"); - let branches = String::from_utf8(output.stdout).unwrap(); + let decoded_output = String::from_utf8(output.stdout).unwrap(); - for line in branches.lines() { - let () = injector.push(line.to_string(), |e, cols| { - cols[0] = e.clone().into(); - }); + for line in decoded_output.lines() { + if !line.trim().is_empty() { + let () = injector.push(line.to_string(), |e, cols| { + cols[0] = e.clone().into(); + }); + } } } diff --git a/crates/television-utils/src/lib.rs b/crates/television-utils/src/lib.rs index f99a6b5..8328621 100644 --- a/crates/television-utils/src/lib.rs +++ b/crates/television-utils/src/lib.rs @@ -4,6 +4,7 @@ pub mod files; pub mod indices; pub mod input; pub mod metadata; +pub mod shell; pub mod stdin; pub mod strings; pub mod syntax; diff --git a/crates/television-utils/src/shell.rs b/crates/television-utils/src/shell.rs new file mode 100644 index 0000000..46ef950 --- /dev/null +++ b/crates/television-utils/src/shell.rs @@ -0,0 +1,22 @@ +use color_eyre::Result; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Shell { + Bash, + Zsh, + Fish, + PowerShell, + Cmd, +} + +const COMPLETION_ZSH: &str = include_str!("../../../shell/completion.zsh"); + +pub fn completion_script(shell: Shell) -> Result<&'static str> { + match shell { + Shell::Zsh => Ok(COMPLETION_ZSH), + _ => color_eyre::eyre::bail!( + "This shell is not yet supported: {:?}", + shell + ), + } +} diff --git a/crates/television/app.rs b/crates/television/app.rs index f301b9f..d2b5ec1 100644 --- a/crates/television/app.rs +++ b/crates/television/app.rs @@ -78,15 +78,15 @@ impl From for AppOutput { impl App { pub fn new( channel: TelevisionChannel, - tick_rate: f64, - frame_rate: f64, + config: Config, passthrough_keybindings: &[String], ) -> Result { let (action_tx, action_rx) = mpsc::unbounded_channel(); let (render_tx, _) = mpsc::unbounded_channel(); let (_, event_rx) = mpsc::unbounded_channel(); let (event_abort_tx, _) = mpsc::unbounded_channel(); - let config = Config::new()?; + let frame_rate = config.config.frame_rate; + let tick_rate = config.config.tick_rate; let keymap = Keymap::from(&config.keybindings).with_mode_mappings( Mode::Channel, passthrough_keybindings diff --git a/crates/television/cable.rs b/crates/television/cable.rs index 4b315a8..516a4a5 100644 --- a/crates/television/cable.rs +++ b/crates/television/cable.rs @@ -16,6 +16,14 @@ struct ChannelPrototypes { const CABLE_FILE_NAME_SUFFIX: &str = "channels"; const CABLE_FILE_FORMAT: &str = "toml"; +#[cfg(unix)] +const DEFAULT_CABLE_CHANNELS: &str = + include_str!("../../cable/unix-channels.toml"); + +#[cfg(not(unix))] +const DEFAULT_CABLE_CHANNELS: &str = + include_str!("../../cable/windows-channels.toml"); + /// Load the cable configuration from the config directory. /// /// Cable is loaded by compiling all files that match the following @@ -40,7 +48,7 @@ pub fn load_cable_channels() -> Result { .filter(|p| { p.extension() .and_then(|e| e.to_str()) - .map_or(false, |e| e == CABLE_FILE_FORMAT) + .map_or(false, |e| e.to_lowercase() == CABLE_FILE_FORMAT) }) .filter(|p| { p.file_stem() @@ -48,7 +56,7 @@ pub fn load_cable_channels() -> Result { .map_or(false, |s| s.ends_with(CABLE_FILE_NAME_SUFFIX)) }); - let all_prototypes = file_paths.fold(Vec::new(), |mut acc, p| { + let user_defined_prototypes = file_paths.fold(Vec::new(), |mut acc, p| { let r: ChannelPrototypes = toml::from_str( &std::fs::read_to_string(p) .expect("Unable to read configuration file"), @@ -58,10 +66,20 @@ pub fn load_cable_channels() -> Result { acc }); - debug!("Loaded cable channels: {:?}", all_prototypes); + debug!("Loaded cable channels: {:?}", user_defined_prototypes); + + let default_prototypes: ChannelPrototypes = + toml::from_str(DEFAULT_CABLE_CHANNELS) + .expect("Unable to parse default cable channels"); let mut cable_channels = HashMap::new(); - for prototype in all_prototypes { + // chaining default with user defined prototypes so that users may override the + // default prototypes + for prototype in default_prototypes + .prototypes + .into_iter() + .chain(user_defined_prototypes) + { cable_channels.insert(prototype.name.clone(), prototype); } Ok(CableChannels(cable_channels)) diff --git a/crates/television/cli.rs b/crates/television/cli.rs index 5c84214..6d3f34f 100644 --- a/crates/television/cli.rs +++ b/crates/television/cli.rs @@ -1,7 +1,8 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use color_eyre::{eyre::eyre, Result}; +use tracing::debug; use crate::{ cable, @@ -11,6 +12,7 @@ use television_channels::{ cable::CableChannelPrototype, channels::CliTvChannel, entry::PreviewCommand, }; +use television_utils::shell::Shell as UtilShell; #[derive(Parser, Debug)] #[command(author, version = version(), about)] @@ -29,12 +31,12 @@ pub struct Cli { pub delimiter: String, /// Tick rate, i.e. number of ticks per second - #[arg(short, long, value_name = "FLOAT", default_value_t = 50.0)] - pub tick_rate: f64, + #[arg(short, long, value_name = "FLOAT")] + pub tick_rate: Option, /// Frame rate, i.e. number of frames per second - #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] - pub frame_rate: f64, + #[arg(short, long, value_name = "FLOAT")] + pub frame_rate: Option, /// Passthrough keybindings (comma separated, e.g. "q,ctrl-w,ctrl-t") These keybindings will /// trigger selection of the current entry and be passed through to stdout along with the entry @@ -46,6 +48,10 @@ pub struct Cli { #[arg(value_name = "PATH", index = 2)] pub working_directory: Option, + /// Try to guess the channel from the provided input prompt + #[arg(long, value_name = "STRING")] + pub autocomplete_prompt: Option, + #[command(subcommand)] command: Option, } @@ -54,17 +60,46 @@ pub struct Cli { pub enum Command { /// Lists available channels ListChannels, + /// Initializes shell completion ("tv init zsh") + #[clap(name = "init")] + InitShell { + /// The shell for which to generate the autocompletion script + #[arg(value_enum)] + shell: Shell, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)] +pub enum Shell { + Bash, + Zsh, + Fish, + PowerShell, + Cmd, +} + +impl From for UtilShell { + fn from(val: Shell) -> Self { + match val { + Shell::Bash => UtilShell::Bash, + Shell::Zsh => UtilShell::Zsh, + Shell::Fish => UtilShell::Fish, + Shell::PowerShell => UtilShell::PowerShell, + Shell::Cmd => UtilShell::Cmd, + } + } } #[derive(Debug)] pub struct PostProcessedCli { pub channel: ParsedCliChannel, pub preview_command: Option, - pub tick_rate: f64, - pub frame_rate: f64, + pub tick_rate: Option, + pub frame_rate: Option, pub passthrough_keybindings: Vec, pub command: Option, pub working_directory: Option, + pub autocomplete_prompt: Option, } impl From for PostProcessedCli { @@ -84,7 +119,7 @@ impl From for PostProcessedCli { let channel: ParsedCliChannel; let working_directory: Option; - match channel_parser(&cli.channel) { + match parse_channel(&cli.channel) { Ok(p) => { channel = p; working_directory = cli.working_directory; @@ -112,6 +147,7 @@ impl From for PostProcessedCli { passthrough_keybindings, command: cli.command, working_directory, + autocomplete_prompt: cli.autocomplete_prompt, } } } @@ -129,7 +165,7 @@ pub enum ParsedCliChannel { Cable(CableChannelPrototype), } -fn channel_parser(channel: &str) -> Result { +fn parse_channel(channel: &str) -> Result { let cable_channels = cable::load_cable_channels().unwrap_or_default(); CliTvChannel::try_from(channel) .map(ParsedCliChannel::Builtin) @@ -170,6 +206,69 @@ pub fn list_channels() { } } +/// Backtrack from the end of the prompt and try to match each word to a known command +/// if a match is found, return the corresponding channel +/// if no match is found, throw an error +/// +/// ## Example: +/// ``` +/// let prompt = "ls -l"; +/// let command_mapping = hashmap! { +/// "ls".to_string() => "files".to_string(), +/// "cat".to_string() => "files".to_string(), +/// }; +/// let channel = guess_channel_from_prompt(prompt, &command_mapping).unwrap(); +/// +/// assert_eq!(channel, ParsedCliChannel::Builtin(CliTvChannel::Files)); +/// ``` +/// NOTE: this is a very naive implementation and needs to be improved +/// - it should be able to handle prompts containing multiple commands +/// e.g. "ls -l && cat " +/// - it should be able to handle commands within delimiters (quotes, brackets, etc.) +pub fn guess_channel_from_prompt( + prompt: &str, + command_mapping: &HashMap, +) -> Result { + debug!("Guessing channel from prompt: {}", prompt); + // git checkout -qf + // --- -------- --- <--------- + if prompt.trim().is_empty() { + match command_mapping.get("") { + Some(channel) => return parse_channel(channel), + None => { + return Err(eyre!("No channel found for prompt: {}", prompt)) + } + } + } + let rev_prompt_words = prompt.split_whitespace().rev(); + let mut stack = Vec::new(); + // for each patern + for (pattern, channel) in command_mapping { + if pattern.trim().is_empty() { + continue; + } + // push every word of the pattern onto the stack + stack.extend(pattern.split_whitespace()); + for word in rev_prompt_words.clone() { + // if the stack is empty, we have a match + if stack.is_empty() { + return parse_channel(channel); + } + // if the word matches the top of the stack, pop it + if stack.last() == Some(&word) { + stack.pop(); + } + } + // if the stack is empty, we have a match + if stack.is_empty() { + return parse_channel(channel); + } + // reset the stack + stack.clear(); + } + Err(eyre!("No channel found for prompt: {}", prompt)) +} + fn delimiter_parser(s: &str) -> Result { Ok(match s { "" => ":".to_string(), @@ -230,11 +329,12 @@ mod tests { channel: "files".to_string(), preview: Some("bat -n --color=always {}".to_string()), delimiter: ":".to_string(), - tick_rate: 50.0, - frame_rate: 60.0, + tick_rate: Some(50.0), + frame_rate: Some(60.0), passthrough_keybindings: Some("q,ctrl-w,ctrl-t".to_string()), command: None, working_directory: Some("/home/user".to_string()), + autocomplete_prompt: None, }; let post_processed_cli: PostProcessedCli = cli.into(); @@ -250,8 +350,8 @@ mod tests { delimiter: ":".to_string() }) ); - assert_eq!(post_processed_cli.tick_rate, 50.0); - assert_eq!(post_processed_cli.frame_rate, 60.0); + assert_eq!(post_processed_cli.tick_rate, Some(50.0)); + assert_eq!(post_processed_cli.frame_rate, Some(60.0)); assert_eq!( post_processed_cli.passthrough_keybindings, vec!["q".to_string(), "ctrl-w".to_string(), "ctrl-t".to_string()] @@ -269,11 +369,12 @@ mod tests { channel: ".".to_string(), preview: None, delimiter: ":".to_string(), - tick_rate: 50.0, - frame_rate: 60.0, + tick_rate: Some(50.0), + frame_rate: Some(60.0), passthrough_keybindings: None, command: None, working_directory: None, + autocomplete_prompt: None, }; let post_processed_cli: PostProcessedCli = cli.into(); diff --git a/crates/television/config.rs b/crates/television/config.rs index c78de0c..906a682 100644 --- a/crates/television/config.rs +++ b/crates/television/config.rs @@ -8,6 +8,7 @@ pub use keybindings::KeyBindings; use lazy_static::lazy_static; use previewers::PreviewersConfig; use serde::Deserialize; +use shell_integration::ShellIntegrationConfig; use styles::Styles; pub use themes::Theme; use tracing::{debug, warn}; @@ -15,6 +16,7 @@ use ui::UiConfig; mod keybindings; mod previewers; +mod shell_integration; mod styles; mod themes; mod ui; @@ -28,6 +30,10 @@ pub struct AppConfig { pub data_dir: PathBuf, #[serde(default)] pub config_dir: PathBuf, + #[serde(default = "default_frame_rate")] + pub frame_rate: f64, + #[serde(default = "default_tick_rate")] + pub tick_rate: f64, } #[allow(dead_code)] @@ -44,6 +50,8 @@ pub struct Config { pub ui: UiConfig, #[serde(default)] pub previewers: PreviewersConfig, + #[serde(default)] + pub shell_integration: ShellIntegrationConfig, } lazy_static! { @@ -79,9 +87,15 @@ impl Config { let mut builder = config::Config::builder() .set_default("data_dir", data_dir.to_str().unwrap())? .set_default("config_dir", config_dir.to_str().unwrap())? + .set_default("frame_rate", default_config.config.frame_rate)? + .set_default("tick_rate", default_config.config.tick_rate)? .set_default("ui", default_config.ui.clone())? .set_default("previewers", default_config.previewers.clone())? - .set_default("theme", default_config.ui.theme.clone())?; + .set_default("theme", default_config.ui.theme.clone())? + .set_default( + "shell_integration", + default_config.shell_integration.clone(), + )?; // Load the user's config file let source = config::File::from(config_dir.join(CONFIG_FILE_NAME)) @@ -163,3 +177,11 @@ pub fn get_config_dir() -> PathBuf { fn project_directory() -> Option { ProjectDirs::from("com", "", env!("CARGO_PKG_NAME")) } + +fn default_frame_rate() -> f64 { + 60.0 +} + +fn default_tick_rate() -> f64 { + 50.0 +} diff --git a/crates/television/config/shell_integration.rs b/crates/television/config/shell_integration.rs new file mode 100644 index 0000000..1fbf302 --- /dev/null +++ b/crates/television/config/shell_integration.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct ShellIntegrationConfig { + pub commands: HashMap, +} + +impl From for config::ValueKind { + fn from(val: ShellIntegrationConfig) -> Self { + let mut m = HashMap::new(); + m.insert( + String::from("commands"), + config::ValueKind::Table( + val.commands + .into_iter() + .map(|(k, v)| (k, config::ValueKind::String(v).into())) + .collect(), + ) + .into(), + ); + config::ValueKind::Table(m) + } +} diff --git a/crates/television/main.rs b/crates/television/main.rs index d0c8172..5febd5f 100644 --- a/crates/television/main.rs +++ b/crates/television/main.rs @@ -4,16 +4,23 @@ use std::path::Path; use std::process::exit; use clap::Parser; -use cli::{list_channels, ParsedCliChannel, PostProcessedCli}; use color_eyre::Result; -use television_channels::channels::TelevisionChannel; -use television_channels::entry::PreviewType; use tracing::{debug, error, info}; use crate::app::App; -use crate::cli::Cli; -use television_channels::channels::stdin::Channel as StdinChannel; -use television_utils::stdin::is_readable_stdin; +use crate::cli::{ + guess_channel_from_prompt, list_channels, Cli, ParsedCliChannel, + PostProcessedCli, +}; +use crate::config::Config; +use television_channels::{ + channels::{stdin::Channel as StdinChannel, TelevisionChannel}, + entry::PreviewType, +}; +use television_utils::{ + shell::{completion_script, Shell}, + stdin::is_readable_stdin, +}; pub mod action; pub mod app; @@ -44,9 +51,20 @@ async fn main() -> Result<()> { list_channels(); exit(0); } + cli::Command::InitShell { shell } => { + let script = completion_script(Shell::from(shell))?; + println!("{script}"); + exit(0); + } } } + let mut config = Config::new()?; + config.config.tick_rate = + args.tick_rate.unwrap_or(config.config.tick_rate); + config.config.frame_rate = + args.frame_rate.unwrap_or(config.config.frame_rate); + if let Some(working_directory) = args.working_directory { let path = Path::new(&working_directory); if !path.exists() { @@ -70,6 +88,18 @@ async fn main() -> Result<()> { TelevisionChannel::Stdin(StdinChannel::new( args.preview_command.map(PreviewType::Command), )) + } else if let Some(prompt) = args.autocomplete_prompt { + let channel = guess_channel_from_prompt( + &prompt, + &config.shell_integration.commands, + )?; + debug!("Using guessed channel: {:?}", channel); + match channel { + ParsedCliChannel::Builtin(c) => c.to_channel(), + ParsedCliChannel::Cable(c) => { + TelevisionChannel::Cable(c.into()) + } + } } else { debug!("Using {:?} channel", args.channel); match args.channel { @@ -80,8 +110,7 @@ async fn main() -> Result<()> { } } }, - args.tick_rate, - args.frame_rate, + config, &args.passthrough_keybindings, ) { Ok(mut app) => { diff --git a/shell/completion.zsh b/shell/completion.zsh new file mode 100644 index 0000000..eeda6f4 --- /dev/null +++ b/shell/completion.zsh @@ -0,0 +1,29 @@ +_tv_search() { + emulate -L zsh + zle -I + + local current_prompt + current_prompt=$LBUFFER + + local output + + output=$(tv --autocomplete-prompt "$current_prompt" $*) + + zle reset-prompt + + if [[ -n $output ]]; then + RBUFFER="" + LBUFFER=$current_prompt$output + + # uncomment this to automatically accept the line + # (i.e. run the command without having to press enter twice) + # zle accept-line + fi +} + + +zle -N tv-search _tv_search + + +bindkey '^T' tv-search +