tmux-workspace 0.1.0-alpha.16

Build tmux workspaces from tmuxp-style YAML using libtmux (alpha)
Documentation
use std::collections::BTreeMap;

use clap::ArgMatches;
use regex::Regex;
use serde_json::{Value, json};

use super::{CliError, Result, document};

fn field(name: &str) -> Option<&'static str> {
    match name {
        "name" => Some("name"),
        "session" | "s" => Some("session"),
        "path" | "p" => Some("path"),
        "window" | "w" => Some("window"),
        "pane" => Some("pane"),
        _ => None,
    }
}

pub(super) struct Query {
    patterns: Vec<(Option<String>, Regex)>,
    fields: Vec<String>,
    any: bool,
    invert: bool,
}

impl Query {
    pub(super) fn new(args: &ArgMatches) -> Result<Self> {
        let terms: Vec<_> = args
            .get_many::<String>("query_terms")
            .into_iter()
            .flatten()
            .collect();
        if terms.is_empty() {
            return Err(CliError::usage("search requires at least one query term"));
        }
        let fields = args
            .get_many::<String>("field")
            .into_iter()
            .flatten()
            .flat_map(|value| value.split(','))
            .map(|value| {
                field(value)
                    .map(str::to_owned)
                    .ok_or_else(|| CliError::usage(format!("unknown search field {value:?}")))
            })
            .collect::<Result<Vec<_>>>()?;
        let mut patterns = Vec::new();
        for term in terms {
            let (restriction, pattern) = term
                .split_once(':')
                .and_then(|(key, value)| field(key).map(|key| (Some(key.to_owned()), value)))
                .unwrap_or((None, term.as_str()));
            let insensitive = args.get_flag("ignore-case")
                || (args.get_flag("smart-case") && !pattern.chars().any(char::is_uppercase));
            let mut pattern = if args.get_flag("fixed-strings") {
                escape(pattern)
            } else {
                pattern.to_owned()
            };
            if args.get_flag("word-regexp") {
                pattern = format!(r"\b(?:{pattern})\b");
            }
            if insensitive {
                pattern = format!("(?i){pattern}");
            }
            let regex = Regex::new(&pattern)
                .map_err(|e| CliError::usage(format!("invalid pattern: {e}")))?;
            patterns.push((restriction, regex));
        }
        Ok(Self {
            patterns,
            fields,
            any: args.get_flag("any"),
            invert: args.get_flag("invert-match"),
        })
    }

    pub(super) fn run(&self, records: Vec<Value>) -> Vec<Value> {
        let mut results = Vec::new();
        for record in records {
            if record.get("error").is_some() {
                continue;
            }
            let mut fields: BTreeMap<&str, Vec<String>> = BTreeMap::new();
            for (name, key) in [
                ("name", "name"),
                ("path", "path"),
                ("session", "session_name"),
            ] {
                fields.insert(
                    name,
                    record
                        .get(key)
                        .and_then(Value::as_str)
                        .map(|v| vec![v.to_owned()])
                        .unwrap_or_default(),
                );
            }
            if let Some(windows) = record["config"]["windows"].as_array() {
                for window in windows {
                    if let Some(name) = window["window_name"].as_str() {
                        fields.entry("window").or_default().push(name.into());
                    }
                    if let Some(panes) = window["panes"].as_array() {
                        for pane in panes {
                            fields
                                .entry("pane")
                                .or_default()
                                .push(document::scalar(pane.get("shell_command").unwrap_or(pane)));
                        }
                    }
                }
            }
            let mut matches = BTreeMap::<String, Vec<String>>::new();
            let mut outcomes = Vec::new();
            for (restriction, pattern) in &self.patterns {
                let mut found = false;
                for (name, values) in &fields {
                    if restriction.as_deref().is_some_and(|f| f != *name)
                        || (!self.fields.is_empty() && !self.fields.iter().any(|f| f == name))
                    {
                        continue;
                    }
                    for value in values {
                        if pattern.is_match(value) {
                            found = true;
                            let values = matches.entry((*name).to_owned()).or_default();
                            if !values.contains(value) {
                                values.push(value.clone());
                            }
                        }
                    }
                }
                outcomes.push(found);
            }
            let selected = if self.any {
                outcomes.iter().any(|v| *v)
            } else {
                outcomes.iter().all(|v| *v)
            };
            if selected != self.invert {
                results.push(json!({"name":record["name"],"path":record["path"],"session_name":record["session_name"],"source":record["source"],"matched_fields":matches.keys().collect::<Vec<_>>(),"matches":matches}));
            }
        }
        results
    }
}

fn escape(text: &str) -> String {
    let mut output = String::new();
    for ch in text.chars() {
        if "\\.^$|?*+()[]{}".contains(ch) {
            output.push('\\');
        }
        output.push(ch);
    }
    output
}