tastty-driver 0.1.0

Terminal automation driver built on tastty
use tastty::input::{KeyCode, KeyEvent, KeyModifiers};

/// One ordered piece of parsed driver input.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum InputSegment {
    /// UTF-8 text to send as bytes.
    Text(String),
    /// A logical key event to encode through tastty.
    Key(KeyEvent),
    /// Raw bytes to send directly.
    Bytes(Vec<u8>),
}

/// Error returned when parsing driver input fails.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ParseInputError {
    /// A `<...>` key tag was not closed.
    #[error("unclosed input tag starting with '<'")]
    UnclosedTag,
    /// A key tag was not recognized.
    #[error("invalid input tag '<{tag}>'")]
    InvalidTag {
        /// Tag contents without angle brackets.
        tag: String,
    },
}

/// Parse angle-bracket input syntax into ordered input segments.
///
/// Literal text is grouped into [`InputSegment::Text`]. Named keys use one
/// grammar: `<Enter>`, `<Ctrl-C>`, `<Alt-Shift-x>`, `<Space>`, arrows,
/// navigation keys, and function keys.
///
/// # Errors
///
/// - [`ParseInputError::UnclosedTag`] when the input ends inside a `<...>`
///   key tag.
/// - [`ParseInputError::InvalidTag`] when a tag does not name a known key
///   (after modifier prefixes are stripped).
pub fn parse_input(input: &str) -> Result<Vec<InputSegment>, ParseInputError> {
    let mut segments = Vec::new();
    let mut text = String::new();
    let mut chars = input.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch != '<' {
            text.push(ch);
            continue;
        }

        flush_text(&mut text, &mut segments);
        let mut tag = String::new();
        loop {
            match chars.next() {
                Some('>') => break,
                Some(ch) => tag.push(ch),
                None => return Err(ParseInputError::UnclosedTag),
            }
        }
        segments.push(resolve_key_name(&tag)?);
    }

    flush_text(&mut text, &mut segments);
    Ok(segments)
}

fn flush_text(text: &mut String, segments: &mut Vec<InputSegment>) {
    if !text.is_empty() {
        segments.push(InputSegment::Text(std::mem::take(text)));
    }
}

fn resolve_key_name(tag: &str) -> Result<InputSegment, ParseInputError> {
    let lower = tag.to_ascii_lowercase();
    let mut modifiers = KeyModifiers::NONE;
    let mut key_name = None;

    for part in lower.split('-') {
        match part {
            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
            "alt" => modifiers |= KeyModifiers::ALT,
            "shift" => modifiers |= KeyModifiers::SHIFT,
            "super" => modifiers |= KeyModifiers::SUPER,
            "hyper" => modifiers |= KeyModifiers::HYPER,
            "meta" => modifiers |= KeyModifiers::META,
            _ => {
                key_name = Some(part);
                if lower.split('-').skip_while(|p| *p != part).count() > 1 {
                    return Err(invalid(tag));
                }
            }
        }
    }

    let Some(name) = key_name else {
        return Err(invalid(tag));
    };

    let code = match name {
        "enter" | "cr" | "return" => KeyCode::Enter,
        "tab" => KeyCode::Tab,
        "backtab" => KeyCode::BackTab,
        "space" => KeyCode::Char(' '),
        "escape" | "esc" => KeyCode::Esc,
        "backspace" | "bs" => KeyCode::Backspace,
        "delete" | "del" => KeyCode::Delete,
        "insert" | "ins" => KeyCode::Insert,
        "up" => KeyCode::Up,
        "down" => KeyCode::Down,
        "right" => KeyCode::Right,
        "left" => KeyCode::Left,
        "home" => KeyCode::Home,
        "end" => KeyCode::End,
        "pageup" | "pgup" => KeyCode::PageUp,
        "pagedown" | "pgdn" => KeyCode::PageDown,
        one if one.chars().count() == 1 => {
            KeyCode::Char(one.chars().next().expect("count checked"))
        }
        f if f.starts_with('f') => {
            let n = f[1..].parse::<u8>().map_err(|_err| invalid(tag))?;
            if !(1..=24).contains(&n) {
                return Err(invalid(tag));
            }
            KeyCode::F(n)
        }
        _ => return Err(invalid(tag)),
    };

    if code == KeyCode::BackTab && modifiers == KeyModifiers::SHIFT {
        modifiers = KeyModifiers::NONE;
    }

    Ok(InputSegment::Key(KeyEvent::new(code, modifiers)))
}

fn invalid(tag: &str) -> ParseInputError {
    ParseInputError::InvalidTag {
        tag: tag.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn key(code: KeyCode, modifiers: KeyModifiers) -> InputSegment {
        InputSegment::Key(KeyEvent::new(code, modifiers))
    }

    #[test]
    fn parses_mixed_text_and_keys() {
        assert_eq!(
            parse_input("cd /tmp<Enter><Up>").unwrap(),
            vec![
                InputSegment::Text("cd /tmp".to_string()),
                key(KeyCode::Enter, KeyModifiers::NONE),
                key(KeyCode::Up, KeyModifiers::NONE),
            ]
        );
    }

    #[test]
    fn parses_modifier_keys() {
        assert_eq!(
            parse_input("<Ctrl-C><Alt-Shift-x>").unwrap(),
            vec![
                key(KeyCode::Char('c'), KeyModifiers::CONTROL),
                key(KeyCode::Char('x'), KeyModifiers::ALT | KeyModifiers::SHIFT),
            ]
        );
    }

    #[test]
    fn rejects_invalid_tags() {
        parse_input("<Bogus>").unwrap_err();
        parse_input("abc<Enter").unwrap_err();
    }
}