rmux-server 0.1.1

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use std::collections::VecDeque;
use std::path::PathBuf;
use std::str::FromStr;

use rmux_core::BoxLines;
use rmux_proto::{PaneTarget, RmuxError, Target};

use super::super::prompt_support::{decode_prompt_key, PromptInputEvent};

#[derive(Debug, Clone)]
pub(in super::super) enum ParsedOverlayCommand {
    Menu(ParsedDisplayMenuCommand),
    Popup(ParsedDisplayPopupCommand),
}

#[derive(Debug, Clone)]
pub(in super::super) struct ParsedDisplayMenuCommand {
    pub(super) target_client: Option<String>,
    pub(super) target_pane: Option<PaneTarget>,
    pub(super) title: String,
    pub(super) x: Option<String>,
    pub(super) y: Option<String>,
    pub(super) style: Option<String>,
    pub(super) selected_style: Option<String>,
    pub(super) border_style: Option<String>,
    pub(super) border_lines: Option<BoxLines>,
    pub(super) force_mouse: bool,
    pub(super) stay_open: bool,
    pub(super) starting_choice: Option<Option<usize>>,
    pub(super) items: Vec<ParsedMenuItem>,
}

#[derive(Debug, Clone)]
pub(super) struct ParsedMenuItem {
    pub(super) label: String,
    pub(super) shortcut: String,
    pub(super) command: String,
}

#[derive(Debug, Clone)]
pub(in super::super) struct ParsedDisplayPopupCommand {
    pub(super) target_client: Option<String>,
    pub(super) target_pane: Option<PaneTarget>,
    pub(super) title: String,
    pub(super) x: Option<String>,
    pub(super) y: Option<String>,
    pub(super) width: Option<PopupSizeSpec>,
    pub(super) height: Option<PopupSizeSpec>,
    pub(super) style: Option<String>,
    pub(super) border_style: Option<String>,
    pub(super) border_lines: Option<BoxLines>,
    pub(super) close_existing: bool,
    pub(super) close_on_exit: bool,
    pub(super) close_on_zero_exit: bool,
    pub(super) close_any_key: bool,
    pub(super) no_job: bool,
    pub(super) start_directory: Option<PathBuf>,
    pub(super) environment: Vec<String>,
    pub(super) command: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PopupSizeSpec {
    Absolute(u16),
    Percent(u8),
}

#[derive(Debug)]
struct OverlayCommandTokens {
    tokens: VecDeque<String>,
}

impl OverlayCommandTokens {
    fn new(tokens: Vec<String>) -> Self {
        Self {
            tokens: tokens.into_iter().collect(),
        }
    }

    fn peek(&self) -> Option<&str> {
        self.tokens.front().map(String::as_str)
    }

    fn pop(&mut self, description: &str) -> Result<String, RmuxError> {
        self.tokens
            .pop_front()
            .ok_or_else(|| RmuxError::Server(format!("missing {description}")))
    }

    fn optional(&mut self) -> Option<String> {
        self.tokens.pop_front()
    }

    fn remaining(self) -> Vec<String> {
        self.tokens.into_iter().collect()
    }

    fn is_empty(&self) -> bool {
        self.tokens.is_empty()
    }
}

pub(super) fn parse_display_menu(
    arguments: Vec<String>,
) -> Result<ParsedDisplayMenuCommand, RmuxError> {
    let mut args = OverlayCommandTokens::new(arguments);
    let mut target_client = None;
    let mut target_pane = None;
    let mut title = String::new();
    let mut x = None;
    let mut y = None;
    let mut style = None;
    let mut selected_style = None;
    let mut border_style = None;
    let mut border_lines = None;
    let mut force_mouse = false;
    let mut stay_open = false;
    let mut starting_choice = None;

    while let Some(token) = args.peek() {
        if token == "--" {
            let _ = args.optional();
            break;
        }
        if !token.starts_with('-') || token == "-" {
            break;
        }
        let token = args.pop("display-menu flag")?;
        match token.as_str() {
            "-b" => {
                let value = args.pop("-b border-lines")?;
                border_lines = Some(BoxLines::parse(Some(value.as_str())));
            }
            "-c" => target_client = Some(args.pop("-c target-client")?),
            "-C" => {
                let value = args.pop("-C starting-choice")?;
                starting_choice = Some(if value == "-" {
                    None
                } else {
                    Some(value.parse::<usize>().map_err(|_| {
                        RmuxError::Server(format!("invalid display-menu starting choice '{value}'"))
                    })?)
                });
            }
            "-H" => selected_style = Some(args.pop("-H style")?),
            "-M" => force_mouse = true,
            "-O" => stay_open = true,
            "-s" => style = Some(args.pop("-s style")?),
            "-S" => border_style = Some(args.pop("-S style")?),
            "-t" => {
                target_pane = Some(parse_overlay_pane_target(
                    "display-menu",
                    args.pop("-t target")?,
                )?)
            }
            "-T" => title = args.pop("-T title")?,
            "-x" => x = Some(args.pop("-x position")?),
            "-y" => y = Some(args.pop("-y position")?),
            flag => {
                return Err(RmuxError::Server(format!(
                    "unsupported flag '{flag}' for display-menu"
                )));
            }
        }
    }

    let mut items = Vec::new();
    while !args.is_empty() {
        let label = args.pop("display-menu item label")?;
        let shortcut = args.pop("display-menu item shortcut")?;
        let command = args.pop("display-menu item command")?;
        items.push(ParsedMenuItem {
            label,
            shortcut,
            command,
        });
    }

    Ok(ParsedDisplayMenuCommand {
        target_client,
        target_pane,
        title,
        x,
        y,
        style,
        selected_style,
        border_style,
        border_lines,
        force_mouse,
        stay_open,
        starting_choice,
        items,
    })
}

pub(super) fn parse_display_popup(
    arguments: Vec<String>,
) -> Result<ParsedDisplayPopupCommand, RmuxError> {
    let mut args = OverlayCommandTokens::new(arguments);
    let mut target_client = None;
    let mut target_pane = None;
    let mut title = String::new();
    let mut x = None;
    let mut y = None;
    let mut width = None;
    let mut height = None;
    let mut style = None;
    let mut border_style = None;
    let mut border_lines = None;
    let mut close_existing = false;
    let mut close_on_exit = false;
    let mut close_on_zero_exit = false;
    let mut close_any_key = false;
    let mut no_job = false;
    let mut start_directory = None;
    let mut environment = Vec::new();

    while let Some(token) = args.peek() {
        if token == "--" {
            let _ = args.optional();
            break;
        }
        if !token.starts_with('-') || token == "-" {
            break;
        }
        let token = args.pop("display-popup flag")?;
        if token.starts_with("-EE") || token == "-EE" {
            close_on_zero_exit = true;
            continue;
        }
        match token.as_str() {
            "-B" => border_lines = Some(BoxLines::None),
            "-b" => {
                let value = args.pop("-b border-lines")?;
                border_lines = Some(BoxLines::parse(Some(value.as_str())));
            }
            "-C" => close_existing = true,
            "-c" => target_client = Some(args.pop("-c target-client")?),
            "-d" => start_directory = Some(PathBuf::from(args.pop("-d start-directory")?)),
            "-e" => environment.push(args.pop("-e name=value")?),
            "-E" => close_on_exit = true,
            "-h" => height = Some(parse_popup_size_spec(&args.pop("-h height")?)?),
            "-k" => {
                close_any_key = true;
                no_job = true;
            }
            "-N" => no_job = true,
            "-s" => style = Some(args.pop("-s style")?),
            "-S" => border_style = Some(args.pop("-S style")?),
            "-t" => {
                target_pane = Some(parse_overlay_pane_target(
                    "display-popup",
                    args.pop("-t target")?,
                )?)
            }
            "-T" => title = args.pop("-T title")?,
            "-w" => width = Some(parse_popup_size_spec(&args.pop("-w width")?)?),
            "-x" => x = Some(args.pop("-x position")?),
            "-y" => y = Some(args.pop("-y position")?),
            flag => {
                return Err(RmuxError::Server(format!(
                    "unsupported flag '{flag}' for display-popup"
                )));
            }
        }
    }

    let command = {
        let remaining = args.remaining();
        if remaining.is_empty() {
            None
        } else {
            Some(rebuild_shell_command(remaining))
        }
    };

    Ok(ParsedDisplayPopupCommand {
        target_client,
        target_pane,
        title,
        x,
        y,
        width,
        height,
        style,
        border_style,
        border_lines,
        close_existing,
        close_on_exit,
        close_on_zero_exit,
        close_any_key,
        no_job,
        start_directory,
        environment,
        command,
    })
}

pub(super) fn parse_menu_shortcut(value: &str) -> Option<PromptInputEvent> {
    if value.is_empty() {
        return None;
    }
    rmux_core::key_string_lookup_string(value)
        .map(decode_prompt_key)
        .or_else(|| {
            let mut chars = value.chars();
            match (chars.next(), chars.next(), chars.next()) {
                (Some(ch), None, None) => Some(PromptInputEvent::Char(ch)),
                _ => None,
            }
        })
}

pub(super) fn parse_popup_size_spec(value: &str) -> Result<PopupSizeSpec, RmuxError> {
    if let Some(percent) = value.strip_suffix('%') {
        let percent = percent
            .parse::<u8>()
            .map_err(|_| RmuxError::Server(format!("invalid popup percentage '{value}'")))?;
        return Ok(PopupSizeSpec::Percent(percent.clamp(1, 100)));
    }
    let absolute = value
        .parse::<u16>()
        .map_err(|_| RmuxError::Server(format!("invalid popup size '{value}'")))?;
    Ok(PopupSizeSpec::Absolute(absolute.max(1)))
}

fn parse_overlay_pane_target(command: &str, value: String) -> Result<PaneTarget, RmuxError> {
    match Target::from_str(&value) {
        Ok(Target::Pane(target)) => Ok(target),
        Ok(_) => Err(RmuxError::Server(format!(
            "{command} target must match 'session:window.pane'"
        ))),
        Err(error) => Err(RmuxError::Server(format!(
            "invalid {command} target '{value}': {error}"
        ))),
    }
}

fn rebuild_shell_command(command_parts: Vec<String>) -> String {
    if command_parts.len() == 1 {
        return command_parts
            .into_iter()
            .next()
            .expect("single popup shell token");
    }
    command_parts
        .into_iter()
        .map(|token| format!("'{}'", token.replace('\'', "'\\''")))
        .collect::<Vec<_>>()
        .join(" ")
}