feat(vt): port Ghostty's VT parsing layer
Ports the parsing half of ZIG/src/terminal into the ghostty-vt crate: - Parser state machine with a compile-time generated parse table and the UTF-8 decoder - OSC parser incl. all sub-parsers (titles, colors, clipboard, hyperlinks, semantic prompts/OSC 133, OSC 9 / ConEmu, iTerm2, kitty clipboard, color and text-sizing protocols, context signals) - CSI, SGR, modes table with save/restore, charsets, ansi, device status, mouse shapes, APC, DCS (incl. XTGETTCAP), tmux control-mode parser - color palette / X11 color names, kitty color and graphics command parser Deviations from Zig (documented in docs/rewrite/notes/ghostty-vt-W*.md): parser actions own their payloads instead of borrowing parser buffers; OSC strings are Vec<u8>; non-exhaustive Zig enums gain an Other(raw) variant; tmux regexes replaced by equivalent hand-written matchers. Known follow-up: semantic_prompt.rs carries local copies of the printf-q and percent decoders that belong in ghostty-os. Test Plan: - cargo test -p ghostty-vt: 395 passed, 1 ignored (skipped in Zig too) - cargo clippy -p ghostty-vt --all-targets -- -D warnings: clean
This commit is contained in:
1 parent
987f2957ad
commit
bf84326c2f
44 files changed
+15040
-6
No files matched your search
@@ -0,0 +1,33 @@
|
||||
//! Specialized encodings used in some OSC protocols
|
||||
//! (ZIG/src/terminal/osc/encoding.zig).
|
||||
|
||||
/// Kitty defines "Escape code safe UTF-8" as valid UTF-8 with the
|
||||
/// additional requirement of not containing any C0 escape codes
|
||||
/// (0x00-0x1f), DEL (0x7f) and C1 escape codes (0x80-0x9f).
|
||||
///
|
||||
/// Used by OSC 66 (text sizing) and OSC 99 (Kitty notifications).
|
||||
///
|
||||
/// See: https://sw.kovidgoyal.net/kitty/desktop-notifications/#safe-utf8
|
||||
pub fn is_safe_utf8(s: &[u8]) -> bool {
|
||||
let Ok(s) = std::str::from_utf8(s) else {
|
||||
return false;
|
||||
};
|
||||
!s.chars()
|
||||
.any(|c| matches!(c as u32, 0x00..=0x1f | 0x7f | 0x80..=0x9f))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_safe_utf8_cases() {
|
||||
assert!(is_safe_utf8(b"Hello world!"));
|
||||
assert!(is_safe_utf8("安全的ユニコード☀️".as_bytes()));
|
||||
assert!(!is_safe_utf8(b"No linebreaks\nallowed"));
|
||||
assert!(!is_safe_utf8(b"\x07no bells"));
|
||||
assert!(!is_safe_utf8(b"\x1b]9;no OSCs\x1b\\\x1b[m"));
|
||||
assert!(!is_safe_utf8(b"\x9f8-bit escapes are clever, but no"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
//! OSC (Operating System Command) related functions and types
|
||||
//! (ZIG/src/terminal/osc.zig).
|
||||
//!
|
||||
//! OSC is another set of control sequences for terminal programs that start with
|
||||
//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings
|
||||
//! and other irregular formatting so a dedicated parser is created to handle it.
|
||||
//!
|
||||
//! Rust port notes (W1):
|
||||
//! - Zig `[:0]const u8` fields become owned `Vec<u8>` (no NUL terminator, not
|
||||
//! necessarily valid UTF-8).
|
||||
//! - Zig's "fixed buffer vs. allocating writer" is a single `Vec<u8>` with an
|
||||
//! optional maximum length (`MAX_BUF`, which always reserves one byte for
|
||||
//! the NUL terminator the Zig code writes). The Zig `alloc: ?Allocator` is
|
||||
//! `alloc: bool` ("an allocator is available").
|
||||
//! - `Parser::end` returns an owned `Command` instead of a pointer into the
|
||||
//! parser.
|
||||
|
||||
pub mod encoding;
|
||||
pub mod parsers;
|
||||
|
||||
pub use parsers::color;
|
||||
pub use parsers::semantic_prompt;
|
||||
|
||||
use crate::kitty::color as kitty_color;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
/// This generally shouldn't ever be set except as an initial zero value.
|
||||
/// Ignore it.
|
||||
Invalid,
|
||||
|
||||
/// Set the window title of the terminal
|
||||
///
|
||||
/// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
|
||||
/// with each code unit further encoded with two hex digits).
|
||||
///
|
||||
/// If title mode 2 is set or the terminal is setup for unconditional
|
||||
/// utf-8 titles text is interpreted as utf-8. Else text is interpreted
|
||||
/// as latin1.
|
||||
ChangeWindowTitle(Vec<u8>),
|
||||
|
||||
/// Set the icon of the terminal window. The name of the icon is not
|
||||
/// well defined, so this is currently ignored by Ghostty at the time
|
||||
/// of writing this. We just parse it so that we don't get parse errors
|
||||
/// in the log.
|
||||
ChangeWindowIcon(Vec<u8>),
|
||||
|
||||
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
|
||||
SemanticPrompt(SemanticPrompt),
|
||||
|
||||
/// Set or get clipboard contents. If data is null, then the current
|
||||
/// clipboard contents are sent to the pty. If data is set, this
|
||||
/// contents is set on the clipboard.
|
||||
ClipboardContents {
|
||||
kind: u8,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
|
||||
/// OSC 7. Reports the current working directory of the shell. This is
|
||||
/// a moderately flawed escape sequence but one that many major terminals
|
||||
/// support so we also support it. To understand the flaws, read through
|
||||
/// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20
|
||||
ReportPwd {
|
||||
/// The reported pwd value. This is not checked for validity. It should
|
||||
/// be a file URL but it is up to the caller to utilize this value.
|
||||
value: Vec<u8>,
|
||||
},
|
||||
|
||||
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
|
||||
/// naming scheme for cursors but it looks like terminals such as Foot
|
||||
/// are moving towards using the W3C CSS cursor names. For OSC parsing,
|
||||
/// we just parse whatever string is given.
|
||||
MouseShape {
|
||||
value: Vec<u8>,
|
||||
},
|
||||
|
||||
/// OSC color operations to set, reset, or report color settings. Some OSCs
|
||||
/// allow multiple operations to be specified in a single OSC so we need a
|
||||
/// list-like datastructure to manage them.
|
||||
///
|
||||
/// Currently, these OSCs are handled by `color_operation`:
|
||||
///
|
||||
/// 4, 5, 10-19, 104, 105, 110-119
|
||||
ColorOperation {
|
||||
op: color::Operation,
|
||||
requests: color::List,
|
||||
terminator: Terminator,
|
||||
},
|
||||
|
||||
/// Kitty color protocol, OSC 21
|
||||
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
|
||||
KittyColorProtocol(kitty_color::OSC),
|
||||
|
||||
/// Show a desktop notification (OSC 9 or OSC 777)
|
||||
ShowDesktopNotification {
|
||||
title: Vec<u8>,
|
||||
body: Vec<u8>,
|
||||
},
|
||||
|
||||
/// Start a hyperlink (OSC 8)
|
||||
HyperlinkStart {
|
||||
id: Option<Vec<u8>>,
|
||||
uri: Vec<u8>,
|
||||
},
|
||||
|
||||
/// End a hyperlink (OSC 8)
|
||||
HyperlinkEnd,
|
||||
|
||||
/// ConEmu sleep (OSC 9;1)
|
||||
ConemuSleep {
|
||||
duration_ms: u16,
|
||||
},
|
||||
|
||||
/// ConEmu show GUI message box (OSC 9;2)
|
||||
ConemuShowMessageBox(Vec<u8>),
|
||||
|
||||
/// ConEmu change tab title (OSC 9;3)
|
||||
ConemuChangeTabTitle(ConemuChangeTabTitle),
|
||||
|
||||
/// ConEmu progress report (OSC 9;4)
|
||||
ConemuProgressReport(ProgressReport),
|
||||
|
||||
/// ConEmu wait input (OSC 9;5)
|
||||
ConemuWaitInput,
|
||||
|
||||
/// ConEmu GUI macro (OSC 9;6)
|
||||
ConemuGuimacro(Vec<u8>),
|
||||
|
||||
/// ConEmu run process (OSC 9;7)
|
||||
ConemuRunProcess(Vec<u8>),
|
||||
|
||||
/// ConEmu output environment variable (OSC 9;8)
|
||||
ConemuOutputEnvironmentVariable(Vec<u8>),
|
||||
|
||||
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
|
||||
/// https://conemu.github.io/en/TerminalModes.html
|
||||
ConemuXtermEmulation {
|
||||
/// None => do not change
|
||||
/// Some(false) => turn off
|
||||
/// Some(true) => turn on
|
||||
keyboard: Option<bool>,
|
||||
/// None => do not change
|
||||
/// Some(false) => turn off
|
||||
/// Some(true) => turn on
|
||||
output: Option<bool>,
|
||||
},
|
||||
|
||||
/// ConEmu comment (OSC 9;11)
|
||||
ConemuComment(Vec<u8>),
|
||||
|
||||
/// Kitty text sizing protocol (OSC 66)
|
||||
KittyTextSizing(parsers::kitty_text_sizing::OSC),
|
||||
|
||||
KittyClipboardProtocol(KittyClipboardProtocol),
|
||||
|
||||
/// OSC 3008. Hierarchical context signalling (UAPI spec).
|
||||
/// https://uapi-group.org/specifications/specs/osc_context/
|
||||
ContextSignal(parsers::context_signal::Command),
|
||||
}
|
||||
|
||||
pub type SemanticPrompt = parsers::semantic_prompt::Command;
|
||||
pub type KittyClipboardProtocol = parsers::kitty_clipboard_protocol::OSC;
|
||||
|
||||
/// Payload of `Command::ConemuChangeTabTitle`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConemuChangeTabTitle {
|
||||
Reset,
|
||||
Value(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The discriminant of a [`Command`] (Zig `Command.Key`). Order matches Zig.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Key {
|
||||
Invalid,
|
||||
ChangeWindowTitle,
|
||||
ChangeWindowIcon,
|
||||
SemanticPrompt,
|
||||
ClipboardContents,
|
||||
ReportPwd,
|
||||
MouseShape,
|
||||
ColorOperation,
|
||||
KittyColorProtocol,
|
||||
ShowDesktopNotification,
|
||||
HyperlinkStart,
|
||||
HyperlinkEnd,
|
||||
ConemuSleep,
|
||||
ConemuShowMessageBox,
|
||||
ConemuChangeTabTitle,
|
||||
ConemuProgressReport,
|
||||
ConemuWaitInput,
|
||||
ConemuGuimacro,
|
||||
ConemuRunProcess,
|
||||
ConemuOutputEnvironmentVariable,
|
||||
ConemuXtermEmulation,
|
||||
ConemuComment,
|
||||
KittyTextSizing,
|
||||
KittyClipboardProtocol,
|
||||
ContextSignal,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn key(&self) -> Key {
|
||||
match self {
|
||||
Command::Invalid => Key::Invalid,
|
||||
Command::ChangeWindowTitle(_) => Key::ChangeWindowTitle,
|
||||
Command::ChangeWindowIcon(_) => Key::ChangeWindowIcon,
|
||||
Command::SemanticPrompt(_) => Key::SemanticPrompt,
|
||||
Command::ClipboardContents { .. } => Key::ClipboardContents,
|
||||
Command::ReportPwd { .. } => Key::ReportPwd,
|
||||
Command::MouseShape { .. } => Key::MouseShape,
|
||||
Command::ColorOperation { .. } => Key::ColorOperation,
|
||||
Command::KittyColorProtocol(_) => Key::KittyColorProtocol,
|
||||
Command::ShowDesktopNotification { .. } => Key::ShowDesktopNotification,
|
||||
Command::HyperlinkStart { .. } => Key::HyperlinkStart,
|
||||
Command::HyperlinkEnd => Key::HyperlinkEnd,
|
||||
Command::ConemuSleep { .. } => Key::ConemuSleep,
|
||||
Command::ConemuShowMessageBox(_) => Key::ConemuShowMessageBox,
|
||||
Command::ConemuChangeTabTitle(_) => Key::ConemuChangeTabTitle,
|
||||
Command::ConemuProgressReport(_) => Key::ConemuProgressReport,
|
||||
Command::ConemuWaitInput => Key::ConemuWaitInput,
|
||||
Command::ConemuGuimacro(_) => Key::ConemuGuimacro,
|
||||
Command::ConemuRunProcess(_) => Key::ConemuRunProcess,
|
||||
Command::ConemuOutputEnvironmentVariable(_) => Key::ConemuOutputEnvironmentVariable,
|
||||
Command::ConemuXtermEmulation { .. } => Key::ConemuXtermEmulation,
|
||||
Command::ConemuComment(_) => Key::ConemuComment,
|
||||
Command::KittyTextSizing(_) => Key::KittyTextSizing,
|
||||
Command::KittyClipboardProtocol(_) => Key::KittyClipboardProtocol,
|
||||
Command::ContextSignal(_) => Key::ContextSignal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ConEmu progress report (OSC 9;4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProgressReport {
|
||||
pub state: ProgressState,
|
||||
pub progress: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProgressState {
|
||||
Remove,
|
||||
Set,
|
||||
Error,
|
||||
Indeterminate,
|
||||
Pause,
|
||||
}
|
||||
|
||||
/// The terminator used to end an OSC command. For OSC commands that demand
|
||||
/// a response, we try to match the terminator used in the request since that
|
||||
/// is most likely to be accepted by the calling program.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Terminator {
|
||||
/// The preferred string terminator is ESC followed by \
|
||||
#[default]
|
||||
St,
|
||||
|
||||
/// Some applications and terminals use BELL (0x07) as the string terminator.
|
||||
Bel,
|
||||
}
|
||||
|
||||
impl Terminator {
|
||||
/// Initialize the terminator based on the last byte seen. If the
|
||||
/// last byte is a BEL then we use BEL, otherwise we just assume ST.
|
||||
pub fn init(ch: Option<u8>) -> Terminator {
|
||||
match ch {
|
||||
Some(0x07) => Terminator::Bel,
|
||||
_ => Terminator::St,
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminator as a string.
|
||||
pub fn string(self) -> &'static [u8] {
|
||||
match self {
|
||||
Terminator::St => b"\x1b\\",
|
||||
Terminator::Bel => b"\x07",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Terminator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// The terminator is always ASCII.
|
||||
f.write_str(std::str::from_utf8(self.string()).unwrap_or(""))
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the OSC parser.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Start,
|
||||
Invalid,
|
||||
|
||||
// OSC command prefixes. Not all of these are valid OSCs, but may be
|
||||
// needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to
|
||||
// have a state "77" even though there is no OSC 77).
|
||||
S0,
|
||||
S1,
|
||||
S2,
|
||||
S3,
|
||||
S4,
|
||||
S5,
|
||||
S6,
|
||||
S7,
|
||||
S8,
|
||||
S9,
|
||||
S30,
|
||||
S300,
|
||||
S3008,
|
||||
S10,
|
||||
S11,
|
||||
S12,
|
||||
S13,
|
||||
S14,
|
||||
S15,
|
||||
S16,
|
||||
S17,
|
||||
S18,
|
||||
S19,
|
||||
S21,
|
||||
S22,
|
||||
S52,
|
||||
S55,
|
||||
S66,
|
||||
S77,
|
||||
S104,
|
||||
S110,
|
||||
S111,
|
||||
S112,
|
||||
S113,
|
||||
S114,
|
||||
S115,
|
||||
S116,
|
||||
S117,
|
||||
S118,
|
||||
S119,
|
||||
S133,
|
||||
S552,
|
||||
S777,
|
||||
S1337,
|
||||
S5522,
|
||||
}
|
||||
|
||||
/// How OSC payload data is being accumulated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Writer {
|
||||
/// Not accumulating (still parsing the numeric prefix).
|
||||
None,
|
||||
/// Fixed-size buffer of `MAX_BUF` bytes.
|
||||
Fixed,
|
||||
/// Growable buffer.
|
||||
Allocating,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Parser {
|
||||
/// Whether an allocator is available, i.e. whether data longer than
|
||||
/// MAX_BUF may be accepted for some commands (e.g. OSC 52), and whether
|
||||
/// commands that require an allocator are processed. (Zig:
|
||||
/// `alloc: ?Allocator`.)
|
||||
pub alloc: bool,
|
||||
|
||||
/// Current state of the parser.
|
||||
pub state: State,
|
||||
|
||||
/// Buffer for temporary storage of OSC data
|
||||
buffer: Vec<u8>,
|
||||
writer: Writer,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Self::init(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
/// Maximum size of a "normal" OSC.
|
||||
pub const MAX_BUF: usize = 2048;
|
||||
|
||||
pub fn init(alloc: bool) -> Parser {
|
||||
Parser {
|
||||
alloc,
|
||||
state: State::Start,
|
||||
buffer: Vec::new(),
|
||||
writer: Writer::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the parser state.
|
||||
pub fn reset(&mut self) {
|
||||
self.state = State::Start;
|
||||
self.buffer.clear();
|
||||
self.writer = Writer::None;
|
||||
}
|
||||
|
||||
/// Make sure that we have an allocator. If we don't, set the state to
|
||||
/// invalid so that any additional OSC data is discarded.
|
||||
fn ensure_allocator(&mut self) -> bool {
|
||||
if self.alloc {
|
||||
return true;
|
||||
}
|
||||
log::warn!(
|
||||
"An allocator is required to process OSC {:?} but none was provided.",
|
||||
self.state
|
||||
);
|
||||
self.state = State::Invalid;
|
||||
false
|
||||
}
|
||||
|
||||
/// Set up a fixed Writer to collect the rest of the OSC data.
|
||||
fn write_to_fixed(&mut self) {
|
||||
self.writer = Writer::Fixed;
|
||||
}
|
||||
|
||||
/// Set up an allocating Writer to collect the rest of the OSC data. If we
|
||||
/// don't have an allocator, fall back to writing to a fixed buffer and
|
||||
/// hope that it's big enough.
|
||||
fn write_to_allocating(&mut self) {
|
||||
if !self.alloc {
|
||||
self.write_to_fixed();
|
||||
return;
|
||||
}
|
||||
self.writer = Writer::Allocating;
|
||||
}
|
||||
|
||||
/// Helper for the per-command parsers. Mirrors the Zig prologue
|
||||
/// `writer orelse invalid; writer.writeByte(0) catch invalid;
|
||||
/// writer.buffered()`: returns a copy of the accumulated data (without
|
||||
/// the NUL terminator) or `None` (and marks the parser invalid) if no
|
||||
/// data was being accumulated or there was no room for the terminator.
|
||||
pub fn data(&mut self) -> Option<Vec<u8>> {
|
||||
if self.writer == Writer::None {
|
||||
self.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
// Reserve room for the NUL terminator.
|
||||
if self.writer == Writer::Fixed && self.buffer.len() >= Self::MAX_BUF {
|
||||
self.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(self.buffer.clone())
|
||||
}
|
||||
|
||||
/// Like [`Parser::data`] but without reserving room for the NUL
|
||||
/// terminator (for parsers that, like Zig's color parsers, read
|
||||
/// `writer.buffered()` directly).
|
||||
pub fn buffered(&mut self) -> Option<Vec<u8>> {
|
||||
if self.writer == Writer::None {
|
||||
self.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(self.buffer.clone())
|
||||
}
|
||||
|
||||
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
|
||||
pub fn has_writer(&self) -> bool {
|
||||
self.writer != Writer::None
|
||||
}
|
||||
|
||||
fn write_byte(&mut self, c: u8) {
|
||||
match self.writer {
|
||||
Writer::None => unreachable!(),
|
||||
Writer::Fixed => {
|
||||
if self.buffer.len() >= Self::MAX_BUF {
|
||||
// Overflow: discard any further input.
|
||||
self.state = State::Invalid;
|
||||
} else {
|
||||
self.buffer.push(c);
|
||||
}
|
||||
}
|
||||
Writer::Allocating => self.buffer.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the next character c and advance the parser state.
|
||||
pub fn next(&mut self, c: u8) {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use State::*;
|
||||
|
||||
// If the state becomes invalid for any reason, just discard
|
||||
// any further input.
|
||||
if self.state == Invalid {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a writer has been initialized, we just accumulate the rest of the
|
||||
// OSC sequence in the writer's buffer and skip the state machine.
|
||||
if self.writer != Writer::None {
|
||||
self.write_byte(c);
|
||||
return;
|
||||
}
|
||||
|
||||
match self.state {
|
||||
// handled above, so should never be here
|
||||
Invalid => unreachable!(),
|
||||
|
||||
Start => {
|
||||
self.state = match c {
|
||||
b'0' => S0,
|
||||
b'1' => S1,
|
||||
b'2' => S2,
|
||||
b'3' => S3,
|
||||
b'4' => S4,
|
||||
b'5' => S5,
|
||||
b'6' => S6,
|
||||
b'7' => S7,
|
||||
b'8' => S8,
|
||||
b'9' => S9,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S3 => {
|
||||
self.state = match c {
|
||||
b'0' => S30,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S30 => {
|
||||
self.state = match c {
|
||||
b'0' => S300,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S300 => {
|
||||
self.state = match c {
|
||||
b'8' => S3008,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S3008 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S1 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'0' => self.state = S10,
|
||||
b'1' => self.state = S11,
|
||||
b'2' => self.state = S12,
|
||||
b'3' => self.state = S13,
|
||||
b'4' => self.state = S14,
|
||||
b'5' => self.state = S15,
|
||||
b'6' => self.state = S16,
|
||||
b'7' => self.state = S17,
|
||||
b'8' => self.state = S18,
|
||||
b'9' => self.state = S19,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S10 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'4' => self.state = S104,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S104 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S11 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'0' => self.state = S110,
|
||||
b'1' => self.state = S111,
|
||||
b'2' => self.state = S112,
|
||||
b'3' => self.state = S113,
|
||||
b'4' => self.state = S114,
|
||||
b'5' => self.state = S115,
|
||||
b'6' => self.state = S116,
|
||||
b'7' => self.state = S117,
|
||||
b'8' => self.state = S118,
|
||||
b'9' => self.state = S119,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S4 | S12 | S14 | S15 | S16 | S17 | S18 | S19 | S21 | S110 | S111 | S112 | S113
|
||||
| S114 | S115 | S116 | S117 | S118 | S119 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S13 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'3' => self.state = S133,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S2 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'1' => self.state = S21,
|
||||
b'2' => self.state = S22,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S5 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'2' => self.state = S52,
|
||||
b'5' => self.state = S55,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S6 => match c {
|
||||
b'6' => self.state = S66,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S52 | S66 => match c {
|
||||
b';' => self.write_to_allocating(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S55 => match c {
|
||||
b'2' => self.state = S552,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S7 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'7' => self.state = S77,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S77 => match c {
|
||||
b'7' => self.state = S777,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S133 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'7' => self.state = S1337,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S552 => match c {
|
||||
b'2' => self.state = S5522,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S1337 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S5522 => match c {
|
||||
b';' => self.write_to_allocating(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S0 | S22 | S777 | S8 | S9 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// End the sequence and return the command, if any. If the return value
|
||||
/// is None, then no valid command was found. The optional terminator_ch
|
||||
/// is the final character in the OSC sequence. This is used to determine
|
||||
/// the response terminator.
|
||||
pub fn end(&mut self, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use State::*;
|
||||
match self.state {
|
||||
Start => None,
|
||||
|
||||
Invalid => None,
|
||||
|
||||
S0 | S2 => parsers::change_window_title::parse(self, terminator_ch),
|
||||
|
||||
S1 => parsers::change_window_icon::parse(self, terminator_ch),
|
||||
|
||||
S4 | S5 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S104 | S110
|
||||
| S111 | S112 | S113 | S114 | S115 | S116 | S117 | S118 | S119 => {
|
||||
parsers::color::parse(self, terminator_ch)
|
||||
}
|
||||
|
||||
S7 => parsers::report_pwd::parse(self, terminator_ch),
|
||||
|
||||
S8 => parsers::hyperlink::parse(self, terminator_ch),
|
||||
|
||||
S9 => parsers::osc9::parse(self, terminator_ch),
|
||||
|
||||
S21 => parsers::kitty_color::parse(self, terminator_ch),
|
||||
|
||||
S22 => parsers::mouse_shape::parse(self, terminator_ch),
|
||||
|
||||
S52 => parsers::clipboard_operation::parse(self, terminator_ch),
|
||||
|
||||
S55 => None,
|
||||
|
||||
S3 | S30 | S300 => None,
|
||||
|
||||
S3008 => parsers::context_signal::parse(self, terminator_ch),
|
||||
|
||||
S6 => None,
|
||||
|
||||
S66 => parsers::kitty_text_sizing::parse(self, terminator_ch),
|
||||
|
||||
S77 => None,
|
||||
|
||||
S133 => parsers::semantic_prompt::parse(self, terminator_ch),
|
||||
|
||||
S552 => None,
|
||||
|
||||
S777 => parsers::rxvt_extension::parse(self, terminator_ch),
|
||||
|
||||
S1337 => parsers::iterm2::parse(self, terminator_ch),
|
||||
|
||||
S5522 => parsers::kitty_clipboard_protocol::parse(self, terminator_ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! OSC 1 (ZIG/src/terminal/osc/parsers/change_window_icon.zig).
|
||||
use crate::osc::{Command, Parser};
|
||||
|
||||
/// Parse OSC 1
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
Some(Command::ChangeWindowIcon(data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_1_change_window_icon() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"1;ab" {
|
||||
p.next(*c);
|
||||
}
|
||||
let cmd = p.end(None).unwrap();
|
||||
assert_eq!(cmd, Command::ChangeWindowIcon(b"ab".to_vec()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! OSC 0 and OSC 2 (ZIG/src/terminal/osc/parsers/change_window_title.zig).
|
||||
use crate::osc::{Command, Parser};
|
||||
|
||||
/// Parse OSC 0 and OSC 2
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
Some(Command::ChangeWindowTitle(data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn feed(p: &mut Parser, s: &[u8]) {
|
||||
for c in s {
|
||||
p.next(*c);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_change_window_title() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"0;ab");
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ChangeWindowTitle(b"ab".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_longer_than_buffer() {
|
||||
let mut p = Parser::init(false);
|
||||
let mut input = b"0;".to_vec();
|
||||
input.extend(std::iter::repeat_n(b'a', Parser::MAX_BUF + 2));
|
||||
feed(&mut p, &input);
|
||||
assert!(p.end(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_one_shorter_than_buffer_length() {
|
||||
let mut p = Parser::init(false);
|
||||
let title = vec![b'a'; Parser::MAX_BUF - 1];
|
||||
let mut input = b"0;".to_vec();
|
||||
input.extend(&title);
|
||||
feed(&mut p, &input);
|
||||
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(title));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_exactly_at_buffer_length() {
|
||||
let mut p = Parser::init(false);
|
||||
let mut input = b"0;".to_vec();
|
||||
input.extend(vec![b'a'; Parser::MAX_BUF]);
|
||||
feed(&mut p, &input);
|
||||
// This should be None because we always reserve space for a null terminator.
|
||||
assert!(p.end(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_2_change_window_title_with_2() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"2;ab");
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ChangeWindowTitle(b"ab".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_2_change_window_title_with_utf8() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"2;");
|
||||
// '—' EM DASH U+2014 (E2 80 94)
|
||||
feed(&mut p, &[0xE2, 0x80, 0x94]);
|
||||
p.next(b' ');
|
||||
// '‐' HYPHEN U+2010 (E2 80 90)
|
||||
// Intententionally chosen to conflict with the 0x90 C1 control
|
||||
feed(&mut p, &[0xE2, 0x80, 0x90]);
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ChangeWindowTitle("— ‐".as_bytes().to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_2_change_window_title_empty() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"2;");
|
||||
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! OSC 52 (ZIG/src/terminal/osc/parsers/clipboard_operation.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 52
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S52);
|
||||
let data = parser.data()?;
|
||||
// Zig's `data` includes the NUL terminator, so `data.len == 1` there
|
||||
// means the payload is empty.
|
||||
if data.is_empty() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if data[0] == b';' {
|
||||
Some(Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: data[1..].to_vec(),
|
||||
})
|
||||
} else {
|
||||
if data.len() < 2 {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if data[1] != b';' {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(Command::ClipboardContents {
|
||||
kind: data[0],
|
||||
data: data[2..].to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn run(alloc: bool, input: &[u8]) -> Option<Command> {
|
||||
let mut p = Parser::init(alloc);
|
||||
for c in input {
|
||||
p.next(*c);
|
||||
}
|
||||
p.end(None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_get_set_clipboard() {
|
||||
assert_eq!(
|
||||
run(false, b"52;s;?").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b's',
|
||||
data: b"?".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_get_set_clipboard_optional_parameter() {
|
||||
assert_eq!(
|
||||
run(false, b"52;;?").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: b"?".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_get_set_clipboard_with_allocator() {
|
||||
assert_eq!(
|
||||
run(true, b"52;s;?").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b's',
|
||||
data: b"?".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_clear_clipboard() {
|
||||
assert_eq!(
|
||||
run(false, b"52;;").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
|
||||
//! (ZIG/src/terminal/osc/parsers/color.zig).
|
||||
|
||||
use crate::color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor};
|
||||
use crate::osc::{Command, Parser, State, Terminator};
|
||||
|
||||
/// The possible operations we support for colors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Operation {
|
||||
Osc4,
|
||||
Osc5,
|
||||
Osc10,
|
||||
Osc11,
|
||||
Osc12,
|
||||
Osc13,
|
||||
Osc14,
|
||||
Osc15,
|
||||
Osc16,
|
||||
Osc17,
|
||||
Osc18,
|
||||
Osc19,
|
||||
Osc104,
|
||||
Osc105,
|
||||
Osc110,
|
||||
Osc111,
|
||||
Osc112,
|
||||
Osc113,
|
||||
Osc114,
|
||||
Osc115,
|
||||
Osc116,
|
||||
Osc117,
|
||||
Osc118,
|
||||
Osc119,
|
||||
}
|
||||
|
||||
/// Parse OSCs 4, 5, 10-19, 104, 110-119
|
||||
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
if !parser.alloc {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
// If we've collected any extra data parse that, otherwise use an empty
|
||||
// string.
|
||||
let data = if parser.has_writer() {
|
||||
parser.buffered()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Check and make sure that we're parsing the correct OSCs
|
||||
let op = match parser.state {
|
||||
State::S4 => Operation::Osc4,
|
||||
State::S5 => Operation::Osc5,
|
||||
State::S10 => Operation::Osc10,
|
||||
State::S11 => Operation::Osc11,
|
||||
State::S12 => Operation::Osc12,
|
||||
State::S13 => Operation::Osc13,
|
||||
State::S14 => Operation::Osc14,
|
||||
State::S15 => Operation::Osc15,
|
||||
State::S16 => Operation::Osc16,
|
||||
State::S17 => Operation::Osc17,
|
||||
State::S18 => Operation::Osc18,
|
||||
State::S19 => Operation::Osc19,
|
||||
State::S104 => Operation::Osc104,
|
||||
State::S110 => Operation::Osc110,
|
||||
State::S111 => Operation::Osc111,
|
||||
State::S112 => Operation::Osc112,
|
||||
State::S113 => Operation::Osc113,
|
||||
State::S114 => Operation::Osc114,
|
||||
State::S115 => Operation::Osc115,
|
||||
State::S116 => Operation::Osc116,
|
||||
State::S117 => Operation::Osc117,
|
||||
State::S118 => Operation::Osc118,
|
||||
State::S119 => Operation::Osc119,
|
||||
_ => {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Command::ColorOperation {
|
||||
op,
|
||||
requests: parse_color(op, &data),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
})
|
||||
}
|
||||
|
||||
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
|
||||
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Zig `std.fmt.parseInt(u9, s, 10)`: optional sign, digits only.
|
||||
fn parse_u9(s: &[u8]) -> Option<u16> {
|
||||
let (neg, digits) = match s.first()? {
|
||||
b'+' => (false, &s[1..]),
|
||||
b'-' => (true, &s[1..]),
|
||||
_ => (false, s),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
acc = acc * 10 + (c - b'0') as u32;
|
||||
if acc > 511 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u16)
|
||||
}
|
||||
|
||||
/// Parse any color operation string. This should NOT include the operation
|
||||
/// itself, but only the body of the operation. e.g. for "4;a;b;c" the body
|
||||
/// should be "a;b;c" and the operation should be set accordingly.
|
||||
///
|
||||
/// Color parsing is fairly complicated so we pull this out to a specialized
|
||||
/// function rather than go through our OSC parsing state machine. This is
|
||||
/// much slower and requires more memory (since we need to buffer the full
|
||||
/// request) but grants us an easier to understand and testable implementation.
|
||||
///
|
||||
/// If color changing ends up being a bottleneck we can optimize this later.
|
||||
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
|
||||
let mut it = tokenize(buf);
|
||||
match op {
|
||||
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
|
||||
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
|
||||
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
|
||||
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
|
||||
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
|
||||
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
|
||||
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
|
||||
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
|
||||
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
|
||||
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
|
||||
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
|
||||
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
|
||||
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
|
||||
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
|
||||
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
|
||||
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
|
||||
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
|
||||
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
|
||||
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
|
||||
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
|
||||
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
|
||||
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
|
||||
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
|
||||
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a parsed numeric color to a target for OSC 4/5/104/105.
|
||||
/// `special_only` is true for OSC 5/105; otherwise 0-255 is the palette and
|
||||
/// 256+ is the special offset.
|
||||
fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
|
||||
if special_only {
|
||||
// std.math.cast(u3, color) then intToEnum
|
||||
let v = u8::try_from(color).ok().filter(|&v| v < 8)?;
|
||||
return SpecialColor::from_u8(v).map(Target::Special);
|
||||
}
|
||||
if let Ok(idx) = u8::try_from(color) {
|
||||
return Some(Target::Palette(idx));
|
||||
}
|
||||
let v = u8::try_from(color - 256).ok().filter(|&v| v < 8)?;
|
||||
SpecialColor::from_u8(v).map(Target::Special)
|
||||
}
|
||||
|
||||
/// OSC 4/5
|
||||
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
// Note: in ANY error scenario below we return the accumulated results.
|
||||
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
|
||||
|
||||
let mut result = List::new();
|
||||
loop {
|
||||
// We expect a `c; spec` pair. If either doesn't exist then
|
||||
// we return the results up to this point.
|
||||
let Some(color_str) = it.next() else {
|
||||
return result;
|
||||
};
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Color must be numeric. u9 because that'll fit our palette + special
|
||||
let Some(color) = parse_u9(color_str) else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Parse the color.
|
||||
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// "?" always results in a query.
|
||||
if spec_str == b"?" {
|
||||
result.push(Request::Query(target));
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget { target, color: rgb }));
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 104/105: Reset ANSI Colors
|
||||
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
// Note: xterm stops parsing the reset list on any error, but we're
|
||||
// more flexible and try the next value. This matches the behavior of
|
||||
// Kitty and I don't see a downside to being more flexible here. Hopefully
|
||||
// no one depends on the exact behavior of xterm.
|
||||
|
||||
let mut result = List::new();
|
||||
loop {
|
||||
let Some(color_str) = it.next() else {
|
||||
// If no parameters are given, we reset the full table.
|
||||
if result.is_empty() {
|
||||
result.push(match op {
|
||||
Operation::Osc104 => Request::ResetPalette,
|
||||
Operation::Osc105 => Request::ResetSpecial,
|
||||
_ => unreachable!(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Empty color strings are ignored, not treated as an error.
|
||||
if color_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Color must be numeric. u9 because that'll fit our palette + special
|
||||
let Some(color) = parse_u9(color_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Parse the color.
|
||||
let Some(target) = ansi_target(color, op == Operation::Osc105) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
result.push(Request::Reset(target));
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 10-19: Get/Set Dynamic Colors
|
||||
fn parse_get_set_dynamic_color<'a>(
|
||||
start: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
// Note: in ANY error scenario below we return the accumulated results.
|
||||
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
|
||||
|
||||
let mut result = List::new();
|
||||
let mut color = start;
|
||||
loop {
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
};
|
||||
|
||||
if spec_str == b"?" {
|
||||
result.push(Request::Query(Target::Dynamic(color)));
|
||||
} else {
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget {
|
||||
target: Target::Dynamic(color),
|
||||
color: rgb,
|
||||
}));
|
||||
}
|
||||
|
||||
// Each successive value uses the next color so long as it exists.
|
||||
match color.next() {
|
||||
Some(n) => color = n,
|
||||
None => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 110-119: Reset Dynamic Colors
|
||||
fn parse_reset_dynamic_color<'a>(
|
||||
color: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
let mut result = List::new();
|
||||
if it.next().is_some() {
|
||||
return result;
|
||||
}
|
||||
result.push(Request::Reset(Target::Dynamic(color)));
|
||||
result
|
||||
}
|
||||
|
||||
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
|
||||
/// avoid copying; a `Vec` is equivalent.)
|
||||
pub type List = Vec<Request>;
|
||||
|
||||
/// A single operation related to the terminal color palette.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Set(ColoredTarget),
|
||||
Query(Target),
|
||||
Reset(Target),
|
||||
ResetPalette,
|
||||
ResetSpecial,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
Palette(u8),
|
||||
Special(SpecialColor),
|
||||
Dynamic(DynamicColor),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ColoredTarget {
|
||||
pub target: Target,
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
const RED: Rgb = Rgb { r: 255, g: 0, b: 0 };
|
||||
const BLUE: Rgb = Rgb { r: 0, g: 0, b: 255 };
|
||||
|
||||
fn set(target: Target, color: Rgb) -> Request {
|
||||
Request::Set(ColoredTarget { target, color })
|
||||
}
|
||||
|
||||
const SPECIALS: [SpecialColor; 5] = [
|
||||
SpecialColor::Bold,
|
||||
SpecialColor::Underline,
|
||||
SpecialColor::Blink,
|
||||
SpecialColor::Reverse,
|
||||
SpecialColor::Italic,
|
||||
];
|
||||
|
||||
const DYNAMICS: [(DynamicColor, Operation, Operation); 10] = [
|
||||
(
|
||||
DynamicColor::Foreground,
|
||||
Operation::Osc10,
|
||||
Operation::Osc110,
|
||||
),
|
||||
(
|
||||
DynamicColor::Background,
|
||||
Operation::Osc11,
|
||||
Operation::Osc111,
|
||||
),
|
||||
(DynamicColor::Cursor, Operation::Osc12, Operation::Osc112),
|
||||
(
|
||||
DynamicColor::PointerForeground,
|
||||
Operation::Osc13,
|
||||
Operation::Osc113,
|
||||
),
|
||||
(
|
||||
DynamicColor::PointerBackground,
|
||||
Operation::Osc14,
|
||||
Operation::Osc114,
|
||||
),
|
||||
(
|
||||
DynamicColor::TektronixForeground,
|
||||
Operation::Osc15,
|
||||
Operation::Osc115,
|
||||
),
|
||||
(
|
||||
DynamicColor::TektronixBackground,
|
||||
Operation::Osc16,
|
||||
Operation::Osc116,
|
||||
),
|
||||
(
|
||||
DynamicColor::HighlightBackground,
|
||||
Operation::Osc17,
|
||||
Operation::Osc117,
|
||||
),
|
||||
(
|
||||
DynamicColor::TektronixCursor,
|
||||
Operation::Osc18,
|
||||
Operation::Osc118,
|
||||
),
|
||||
(
|
||||
DynamicColor::HighlightForeground,
|
||||
Operation::Osc19,
|
||||
Operation::Osc119,
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn osc_4_empty_param() {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in "4;;".bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
assert!(p.end(Some(0x1b)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_4() {
|
||||
// Test every palette index
|
||||
for idx in 0..u8::MAX {
|
||||
// printf '\e]4;0;red\\'
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};red").as_bytes());
|
||||
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
|
||||
|
||||
// printf '\e]4;0;?\\'
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};?").as_bytes());
|
||||
assert_eq!(vec![Request::Query(Target::Palette(idx))], list);
|
||||
|
||||
// Trailing invalid data produces results up to that point
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};red;").as_bytes());
|
||||
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
|
||||
|
||||
// Whitespace doesn't produce a working value in xterm but we
|
||||
// allow it because Kitty does and it seems harmless.
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};red ").as_bytes());
|
||||
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
|
||||
}
|
||||
|
||||
// Test every special color
|
||||
for (i, special) in SPECIALS.iter().enumerate() {
|
||||
let list = parse_color(Operation::Osc4, format!("{};red", 256 + i).as_bytes());
|
||||
assert_eq!(vec![set(Target::Special(*special), RED)], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5() {
|
||||
for (i, special) in SPECIALS.iter().enumerate() {
|
||||
let list = parse_color(Operation::Osc5, format!("{i};red").as_bytes());
|
||||
assert_eq!(vec![set(Target::Special(*special), RED)], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_4_multiple_requests() {
|
||||
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
|
||||
assert_eq!(
|
||||
vec![set(Target::Palette(0), RED), set(Target::Palette(1), BLUE)],
|
||||
list
|
||||
);
|
||||
|
||||
// Multiple requests with same index overwrite each other
|
||||
let list = parse_color(Operation::Osc4, b"0;red;0;blue");
|
||||
assert_eq!(
|
||||
vec![set(Target::Palette(0), RED), set(Target::Palette(0), BLUE)],
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104() {
|
||||
for idx in 0..u8::MAX {
|
||||
let list = parse_color(Operation::Osc104, idx.to_string().as_bytes());
|
||||
assert_eq!(vec![Request::Reset(Target::Palette(idx))], list);
|
||||
}
|
||||
|
||||
for (i, special) in SPECIALS.iter().enumerate() {
|
||||
let list = parse_color(Operation::Osc104, (256 + i).to_string().as_bytes());
|
||||
assert_eq!(vec![Request::Reset(Target::Special(*special))], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_empty_index() {
|
||||
let list = parse_color(Operation::Osc104, b"0;;1");
|
||||
assert_eq!(
|
||||
vec![
|
||||
Request::Reset(Target::Palette(0)),
|
||||
Request::Reset(Target::Palette(1))
|
||||
],
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_invalid_index() {
|
||||
let list = parse_color(Operation::Osc104, b"ffff;1");
|
||||
assert_eq!(vec![Request::Reset(Target::Palette(1))], list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_reset_all() {
|
||||
let list = parse_color(Operation::Osc104, b"");
|
||||
assert_eq!(vec![Request::ResetPalette], list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_105_reset_all() {
|
||||
let list = parse_color(Operation::Osc105, b"");
|
||||
assert_eq!(vec![Request::ResetSpecial], list);
|
||||
}
|
||||
|
||||
// OSC 10-19: Get/Set Dynamic Colors
|
||||
#[test]
|
||||
fn osc_10_to_19_dynamic() {
|
||||
for (color, op, _) in DYNAMICS {
|
||||
// printf '\e]10;red\e\\'
|
||||
let list = parse_color(op, b"red");
|
||||
assert_eq!(vec![set(Target::Dynamic(color), RED)], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_10_to_19_dynamic_multiple() {
|
||||
// printf '\e]11;red;blue\e\\'
|
||||
let list = parse_color(Operation::Osc11, b"red;blue");
|
||||
assert_eq!(
|
||||
vec![
|
||||
set(Target::Dynamic(DynamicColor::Background), RED),
|
||||
set(Target::Dynamic(DynamicColor::Cursor), BLUE)
|
||||
],
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
// OSC 110-119: Reset Dynamic Colors
|
||||
#[test]
|
||||
fn osc_110_to_119_reset_dynamic() {
|
||||
for (color, _, op) in DYNAMICS {
|
||||
let expected = vec![Request::Reset(Target::Dynamic(color))];
|
||||
|
||||
// printf '\e]110\e\\'
|
||||
assert_eq!(expected, parse_color(op, b""));
|
||||
|
||||
// xterm allows a trailing semicolon.
|
||||
assert_eq!(expected, parse_color(op, b";"));
|
||||
|
||||
// xterm does NOT allow any whitespace
|
||||
assert_eq!(0, parse_color(op, b" ").len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_112_incomplete_sequence() {
|
||||
// https://github.com/darrenstarr/VtNetCore/pull/14
|
||||
let mut p = Parser::init(true);
|
||||
for ch in b"112" {
|
||||
p.next(*ch);
|
||||
}
|
||||
let cmd = p.end(Some(0x07)).unwrap();
|
||||
let Command::ColorOperation {
|
||||
op,
|
||||
requests,
|
||||
terminator,
|
||||
} = cmd
|
||||
else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
assert_eq!(Terminator::Bel, terminator);
|
||||
assert_eq!(Operation::Osc112, op);
|
||||
assert_eq!(
|
||||
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
|
||||
requests
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//! OSC 3008: Hierarchical Context Signalling (UAPI spec)
|
||||
//! Specification: https://uapi-group.org/specifications/specs/osc_context/
|
||||
//! (ZIG/src/terminal/osc/parsers/context_signal.zig)
|
||||
//!
|
||||
//! OSC 3008 allows programs to signal context changes to the terminal emulator.
|
||||
//! Each context has an identifier and metadata fields. Contexts are hierarchical
|
||||
//! and form a stack.
|
||||
|
||||
use crate::osc::{Command as OscCommand, Parser, State};
|
||||
|
||||
/// Maximum length of a context identifier (per spec).
|
||||
const MAX_CONTEXT_ID_LEN: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// OSC 3008;start=<id> - initiates, updates, or returns to a context.
|
||||
Start,
|
||||
/// OSC 3008;end=<id> - terminates a context.
|
||||
End,
|
||||
}
|
||||
|
||||
/// A single OSC 3008 context signal command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub action: Action,
|
||||
/// The context identifier. Must be 1-64 characters in the 32..126 byte range.
|
||||
pub id: Vec<u8>,
|
||||
/// Raw unparsed metadata fields after the context ID.
|
||||
/// Fields are semicolon-separated key=value pairs.
|
||||
/// Parsed lazily via `read_option`.
|
||||
pub metadata: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Value of a decoded metadata field (Zig `Field.Type()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FieldValue<'a> {
|
||||
Type(ContextType),
|
||||
Exit(ExitStatus),
|
||||
U64(u64),
|
||||
Str(&'a [u8]),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Read a metadata field value from the raw fields string.
|
||||
/// Returns None if the field is not present or malformed.
|
||||
pub fn read_option(&self, option: Field) -> Option<FieldValue<'_>> {
|
||||
option.read(&self.metadata)
|
||||
}
|
||||
|
||||
pub fn context_type(&self) -> Option<ContextType> {
|
||||
match self.read_option(Field::Type)? {
|
||||
FieldValue::Type(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit(&self) -> Option<ExitStatus> {
|
||||
match self.read_option(Field::Exit)? {
|
||||
FieldValue::Exit(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For the numeric fields (`pid`, `pidfdid`, `status`).
|
||||
pub fn read_u64(&self, option: Field) -> Option<u64> {
|
||||
match self.read_option(option)? {
|
||||
FieldValue::U64(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For the string fields.
|
||||
pub fn read_str(&self, option: Field) -> Option<&[u8]> {
|
||||
match self.read_option(option)? {
|
||||
FieldValue::Str(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context types defined by the specification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContextType {
|
||||
Boot,
|
||||
Container,
|
||||
Vm,
|
||||
Elevate,
|
||||
Chpriv,
|
||||
Subcontext,
|
||||
Remote,
|
||||
Shell,
|
||||
Command,
|
||||
App,
|
||||
Service,
|
||||
Session,
|
||||
}
|
||||
|
||||
impl ContextType {
|
||||
pub fn parse(value: &[u8]) -> Option<ContextType> {
|
||||
Some(match value {
|
||||
b"boot" => ContextType::Boot,
|
||||
b"container" => ContextType::Container,
|
||||
b"vm" => ContextType::Vm,
|
||||
b"elevate" => ContextType::Elevate,
|
||||
b"chpriv" => ContextType::Chpriv,
|
||||
b"subcontext" => ContextType::Subcontext,
|
||||
b"remote" => ContextType::Remote,
|
||||
b"shell" => ContextType::Shell,
|
||||
b"command" => ContextType::Command,
|
||||
b"app" => ContextType::App,
|
||||
b"service" => ContextType::Service,
|
||||
b"session" => ContextType::Session,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit status for the `exit` end-sequence field.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExitStatus {
|
||||
Success,
|
||||
Failure,
|
||||
Crash,
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
impl ExitStatus {
|
||||
pub fn parse(value: &[u8]) -> Option<ExitStatus> {
|
||||
Some(match value {
|
||||
b"success" => ExitStatus::Success,
|
||||
b"failure" => ExitStatus::Failure,
|
||||
b"crash" => ExitStatus::Crash,
|
||||
b"interrupt" => ExitStatus::Interrupt,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata fields that can appear in OSC 3008 sequences.
|
||||
/// Fields are read lazily from the raw string using the `read` method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Field {
|
||||
// Start sequence fields
|
||||
Type,
|
||||
User,
|
||||
Hostname,
|
||||
Machineid,
|
||||
Bootid,
|
||||
Pid,
|
||||
Pidfdid,
|
||||
Comm,
|
||||
Cwd,
|
||||
Cmdline,
|
||||
Vm,
|
||||
Container,
|
||||
Targetuser,
|
||||
Targethost,
|
||||
Sessionid,
|
||||
|
||||
// End sequence fields
|
||||
Exit,
|
||||
Status,
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Field::Type => "type",
|
||||
Field::User => "user",
|
||||
Field::Hostname => "hostname",
|
||||
Field::Machineid => "machineid",
|
||||
Field::Bootid => "bootid",
|
||||
Field::Pid => "pid",
|
||||
Field::Pidfdid => "pidfdid",
|
||||
Field::Comm => "comm",
|
||||
Field::Cwd => "cwd",
|
||||
Field::Cmdline => "cmdline",
|
||||
Field::Vm => "vm",
|
||||
Field::Container => "container",
|
||||
Field::Targetuser => "targetuser",
|
||||
Field::Targethost => "targethost",
|
||||
Field::Sessionid => "sessionid",
|
||||
Field::Exit => "exit",
|
||||
Field::Status => "status",
|
||||
Field::Signal => "signal",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the field value from the raw fields string.
|
||||
///
|
||||
/// The raw fields string contains semicolon-separated key=value pairs
|
||||
/// e.g. "type=container;user=lennart;hostname=zeta".
|
||||
///
|
||||
/// Unknown or malformed fields are ignored per the specification.
|
||||
pub fn read(self, raw: &[u8]) -> Option<FieldValue<'_>> {
|
||||
let key = self.key().as_bytes();
|
||||
for full in raw.split(|&c| c == b';') {
|
||||
let value = match full.iter().position(|&c| c == b'=') {
|
||||
Some(eql) if &full[..eql] == key => &full[eql + 1..],
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
return match self {
|
||||
Field::Type => ContextType::parse(value).map(FieldValue::Type),
|
||||
Field::Exit => ExitStatus::parse(value).map(FieldValue::Exit),
|
||||
Field::Pid | Field::Pidfdid | Field::Status => {
|
||||
if value.iter().any(|c| !c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(value)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(FieldValue::U64)
|
||||
}
|
||||
// String fields
|
||||
_ => {
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(FieldValue::Str(value))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Not found
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OSC 3008: hierarchical context signalling.
|
||||
///
|
||||
/// Expected data format (after "3008;" prefix has been consumed by the state machine):
|
||||
/// start=<id>[;<field>=<value>]*
|
||||
/// end=<id>[;<field>=<value>]*
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
|
||||
let data = parser.buffered()?;
|
||||
if data.is_empty() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine the action (start= or end=)
|
||||
let (action, prefix_len) = if data.starts_with(b"start=") {
|
||||
(Action::Start, b"start=".len())
|
||||
} else if data.starts_with(b"end=") {
|
||||
(Action::End, b"end=".len())
|
||||
} else {
|
||||
log::warn!(
|
||||
"OSC 3008: expected 'start=' or 'end=' prefix, got: {}",
|
||||
String::from_utf8_lossy(&data[..data.len().min(10)])
|
||||
);
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
|
||||
// Skip past the "start=" or "end=" prefix
|
||||
let rest = &data[prefix_len..];
|
||||
|
||||
if rest.is_empty() {
|
||||
log::warn!("OSC 3008: missing context ID");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract the context ID (up to the first semicolon or end of data)
|
||||
let id_end = rest.iter().position(|&c| c == b';').unwrap_or(rest.len());
|
||||
let id = &rest[..id_end];
|
||||
|
||||
// Validate context ID length (1-64 chars per spec)
|
||||
if id.is_empty() || id.len() > MAX_CONTEXT_ID_LEN {
|
||||
log::warn!(
|
||||
"OSC 3008: context ID length {} out of range (1-{})",
|
||||
id.len(),
|
||||
MAX_CONTEXT_ID_LEN
|
||||
);
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Validate context ID characters (32..126 byte range per spec)
|
||||
for &c in id {
|
||||
if !(0x20..=0x7e).contains(&c) {
|
||||
log::warn!("OSC 3008: invalid character 0x{c:02x} in context ID");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract raw metadata fields (everything after the ID)
|
||||
let metadata = if id_end < rest.len() {
|
||||
rest[id_end + 1..].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Some(OscCommand::ContextSignal(Command {
|
||||
action,
|
||||
id: id.to_vec(),
|
||||
metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<OscCommand> {
|
||||
let mut p = Parser::init(false);
|
||||
for &ch in input.as_bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(None)
|
||||
}
|
||||
|
||||
fn cs(input: &str) -> Command {
|
||||
match run(input) {
|
||||
Some(OscCommand::ContextSignal(c)) => c,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_basic_start_command() {
|
||||
let c = cs("3008;start=abc123");
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"abc123", &c.id[..]);
|
||||
assert_eq!(b"", &c.metadata[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_basic_end_command() {
|
||||
let c = cs("3008;end=abc123");
|
||||
assert_eq!(Action::End, c.action);
|
||||
assert_eq!(b"abc123", &c.id[..]);
|
||||
assert_eq!(b"", &c.metadata[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_start_with_metadata_fields() {
|
||||
let c = cs(
|
||||
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta",
|
||||
);
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
|
||||
assert_eq!(Some(ContextType::Container), c.context_type());
|
||||
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
|
||||
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_start_with_all_common_fields() {
|
||||
let c = cs(
|
||||
"3008;start=myctx;type=shell;user=root;hostname=myhost;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=bash",
|
||||
);
|
||||
assert_eq!(Some(ContextType::Shell), c.context_type());
|
||||
assert_eq!(b"root", c.read_str(Field::User).unwrap());
|
||||
assert_eq!(b"myhost", c.read_str(Field::Hostname).unwrap());
|
||||
assert_eq!(
|
||||
b"3deb5353d3ba43d08201c136a47ead7b",
|
||||
c.read_str(Field::Machineid).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
b"d4a3d0fdf2e24fdea6d971ce73f4fbf2",
|
||||
c.read_str(Field::Bootid).unwrap()
|
||||
);
|
||||
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
|
||||
assert_eq!(Some(1063162), c.read_u64(Field::Pidfdid));
|
||||
assert_eq!(b"bash", c.read_str(Field::Comm).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_end_with_exit_metadata() {
|
||||
let c = cs("3008;end=myctx;exit=success;status=0");
|
||||
assert_eq!(Action::End, c.action);
|
||||
assert_eq!(b"myctx", &c.id[..]);
|
||||
assert_eq!(Some(ExitStatus::Success), c.exit());
|
||||
assert_eq!(Some(0), c.read_u64(Field::Status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_end_with_failure_exit() {
|
||||
let c = cs("3008;end=myctx;exit=failure;status=1;signal=SIGKILL");
|
||||
assert_eq!(Some(ExitStatus::Failure), c.exit());
|
||||
assert_eq!(Some(1), c.read_u64(Field::Status));
|
||||
assert_eq!(b"SIGKILL", c.read_str(Field::Signal).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_unknown_fields_are_ignored() {
|
||||
let c = cs("3008;start=myctx;type=shell;unknownfield=value;user=root");
|
||||
assert_eq!(Some(ContextType::Shell), c.context_type());
|
||||
assert_eq!(b"root", c.read_str(Field::User).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_missing_field_returns_null() {
|
||||
let c = cs("3008;start=myctx;user=lennart");
|
||||
assert!(c.context_type().is_none());
|
||||
assert!(c.read_str(Field::Hostname).is_none());
|
||||
assert!(c.read_u64(Field::Pid).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_invalid_prefix() {
|
||||
assert!(run("3008;bogus=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_empty_data() {
|
||||
assert!(run("3008;start=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_max_length_context_id() {
|
||||
let id = "a".repeat(64);
|
||||
let c = cs(&format!("3008;start={id}"));
|
||||
assert_eq!(id.as_bytes(), &c.id[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_over_length_context_id() {
|
||||
let id = "a".repeat(65);
|
||||
assert!(run(&format!("3008;start={id}")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_context_type_enum_coverage() {
|
||||
let types = [
|
||||
("boot", ContextType::Boot),
|
||||
("container", ContextType::Container),
|
||||
("vm", ContextType::Vm),
|
||||
("elevate", ContextType::Elevate),
|
||||
("chpriv", ContextType::Chpriv),
|
||||
("subcontext", ContextType::Subcontext),
|
||||
("remote", ContextType::Remote),
|
||||
("shell", ContextType::Shell),
|
||||
("command", ContextType::Command),
|
||||
("app", ContextType::App),
|
||||
("service", ContextType::Service),
|
||||
("session", ContextType::Session),
|
||||
];
|
||||
for (s, e) in types {
|
||||
assert_eq!(Some(e), ContextType::parse(s.as_bytes()));
|
||||
}
|
||||
assert!(ContextType::parse(b"invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_exit_status_enum_coverage() {
|
||||
assert_eq!(Some(ExitStatus::Success), ExitStatus::parse(b"success"));
|
||||
assert_eq!(Some(ExitStatus::Failure), ExitStatus::parse(b"failure"));
|
||||
assert_eq!(Some(ExitStatus::Crash), ExitStatus::parse(b"crash"));
|
||||
assert_eq!(Some(ExitStatus::Interrupt), ExitStatus::parse(b"interrupt"));
|
||||
assert!(ExitStatus::parse(b"invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_spec_example_container_start() {
|
||||
let c = cs(
|
||||
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=systemd-nspawn;container=foobar",
|
||||
);
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
|
||||
assert_eq!(Some(ContextType::Container), c.context_type());
|
||||
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
|
||||
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
|
||||
assert_eq!(b"systemd-nspawn", c.read_str(Field::Comm).unwrap());
|
||||
assert_eq!(b"foobar", c.read_str(Field::Container).unwrap());
|
||||
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_spec_example_context_end() {
|
||||
let c = cs("3008;end=bed86fab93af4328bbed0a1224af6d40");
|
||||
assert_eq!(Action::End, c.action);
|
||||
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_cwd_and_cmdline_fields() {
|
||||
let c = cs("3008;start=myctx;type=command;cwd=/home/user;cmdline=ls -la");
|
||||
assert_eq!(b"/home/user", c.read_str(Field::Cwd).unwrap());
|
||||
assert_eq!(b"ls -la", c.read_str(Field::Cmdline).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_start_command_with_no_fields() {
|
||||
let c = cs("3008;start=simpleid");
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"simpleid", &c.id[..]);
|
||||
assert!(c.context_type().is_none());
|
||||
assert!(c.read_str(Field::User).is_none());
|
||||
assert!(c.exit().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! OSC 8 hyperlinks (ZIG/src/terminal/osc/parsers/hyperlink.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 8 hyperlinks
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
let Some(s) = data.iter().position(|&b| b == b';') else {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
|
||||
let uri = data[s + 1..].to_vec();
|
||||
let mut id: Option<Vec<u8>> = None;
|
||||
|
||||
// The params section is `key=value` pairs separated by ':'. This follows
|
||||
// the Zig implementation byte for byte: ':' and the ';' terminator are
|
||||
// turned into NULs and the segments are walked by searching for the next
|
||||
// NUL starting one past the segment start.
|
||||
let mut kvs = data[..s].to_vec();
|
||||
kvs.push(0);
|
||||
for b in &mut kvs {
|
||||
if *b == b':' {
|
||||
*b = 0;
|
||||
}
|
||||
}
|
||||
let mut kv_start = 0;
|
||||
while kv_start < kvs.len() {
|
||||
let Some(kv_end) = kvs
|
||||
.iter()
|
||||
.skip(kv_start + 1)
|
||||
.position(|&b| b == 0)
|
||||
.map(|i| i + kv_start + 1)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let kv = &kvs[kv_start..kv_end];
|
||||
let Some(v) = kv.iter().position(|&b| b == b'=') else {
|
||||
break;
|
||||
};
|
||||
let key = &kv[..v];
|
||||
let value = &kv[v + 1..];
|
||||
if key == b"id" {
|
||||
if !value.is_empty() {
|
||||
id = Some(value.to_vec());
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"unknown hyperlink option: '{}'",
|
||||
String::from_utf8_lossy(key)
|
||||
);
|
||||
}
|
||||
kv_start = kv_end + 1;
|
||||
}
|
||||
|
||||
if uri.is_empty() {
|
||||
if id.is_some() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
return Some(Command::HyperlinkEnd);
|
||||
}
|
||||
|
||||
Some(Command::HyperlinkStart { id, uri })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> Option<Command> {
|
||||
let mut p = Parser::init(false);
|
||||
for c in input {
|
||||
p.next(*c);
|
||||
}
|
||||
p.end(Some(0x1b))
|
||||
}
|
||||
|
||||
fn start(id: Option<&str>, uri: &str) -> Command {
|
||||
Command::HyperlinkStart {
|
||||
id: id.map(|s| s.as_bytes().to_vec()),
|
||||
uri: uri.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink() {
|
||||
assert_eq!(
|
||||
run(b"8;;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_id_set() {
|
||||
assert_eq!(
|
||||
run(b"8;id=foo;http://example.com").unwrap(),
|
||||
start(Some("foo"), "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_id() {
|
||||
assert_eq!(
|
||||
run(b"8;id=;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_incomplete_key() {
|
||||
assert_eq!(
|
||||
run(b"8;id;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_key() {
|
||||
assert_eq!(
|
||||
run(b"8;=value;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_key_and_id() {
|
||||
assert_eq!(
|
||||
run(b"8;=value:id=foo;http://example.com").unwrap(),
|
||||
start(Some("foo"), "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_uri() {
|
||||
assert!(run(b"8;id=foo;").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_end() {
|
||||
assert_eq!(run(b"8;;").unwrap(), Command::HyperlinkEnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! OSC 1337 (iTerm2) (ZIG/src/terminal/osc/parsers/iterm2.zig).
|
||||
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// iTerm2 OSC 1337 keys. Lookup is ASCII case-insensitive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Key {
|
||||
AddAnnotation,
|
||||
AddHiddenAnnotation,
|
||||
Block,
|
||||
Button,
|
||||
ClearCapturedOutput,
|
||||
ClearScrollback,
|
||||
Copy,
|
||||
CopyToClipboard,
|
||||
CurrentDir,
|
||||
CursorShape,
|
||||
Custom,
|
||||
Disinter,
|
||||
EndCopy,
|
||||
File,
|
||||
FileEnd,
|
||||
FilePart,
|
||||
HighlightCursorLine,
|
||||
MultipartFile,
|
||||
OpenURL,
|
||||
PopKeyLabels,
|
||||
PushKeyLabels,
|
||||
RemoteHost,
|
||||
ReportCellSize,
|
||||
ReportVariable,
|
||||
RequestAttention,
|
||||
RequestUpload,
|
||||
SetBackgroundImageFile,
|
||||
SetBadgeFormat,
|
||||
SetColors,
|
||||
SetKeyLabel,
|
||||
SetMark,
|
||||
SetProfile,
|
||||
SetUserVar,
|
||||
ShellIntegrationVersion,
|
||||
StealFocus,
|
||||
UnicodeVersion,
|
||||
}
|
||||
|
||||
const KEYS: &[(&str, Key)] = &[
|
||||
("AddAnnotation", Key::AddAnnotation),
|
||||
("AddHiddenAnnotation", Key::AddHiddenAnnotation),
|
||||
("Block", Key::Block),
|
||||
("Button", Key::Button),
|
||||
("ClearCapturedOutput", Key::ClearCapturedOutput),
|
||||
("ClearScrollback", Key::ClearScrollback),
|
||||
("Copy", Key::Copy),
|
||||
("CopyToClipboard", Key::CopyToClipboard),
|
||||
("CurrentDir", Key::CurrentDir),
|
||||
("CursorShape", Key::CursorShape),
|
||||
("Custom", Key::Custom),
|
||||
("Disinter", Key::Disinter),
|
||||
("EndCopy", Key::EndCopy),
|
||||
("File", Key::File),
|
||||
("FileEnd", Key::FileEnd),
|
||||
("FilePart", Key::FilePart),
|
||||
("HighlightCursorLine", Key::HighlightCursorLine),
|
||||
("MultipartFile", Key::MultipartFile),
|
||||
("OpenURL", Key::OpenURL),
|
||||
("PopKeyLabels", Key::PopKeyLabels),
|
||||
("PushKeyLabels", Key::PushKeyLabels),
|
||||
("RemoteHost", Key::RemoteHost),
|
||||
("ReportCellSize", Key::ReportCellSize),
|
||||
("ReportVariable", Key::ReportVariable),
|
||||
("RequestAttention", Key::RequestAttention),
|
||||
("RequestUpload", Key::RequestUpload),
|
||||
("SetBackgroundImageFile", Key::SetBackgroundImageFile),
|
||||
("SetBadgeFormat", Key::SetBadgeFormat),
|
||||
("SetColors", Key::SetColors),
|
||||
("SetKeyLabel", Key::SetKeyLabel),
|
||||
("SetMark", Key::SetMark),
|
||||
("SetProfile", Key::SetProfile),
|
||||
("SetUserVar", Key::SetUserVar),
|
||||
("ShellIntegrationVersion", Key::ShellIntegrationVersion),
|
||||
("StealFocus", Key::StealFocus),
|
||||
("UnicodeVersion", Key::UnicodeVersion),
|
||||
];
|
||||
|
||||
fn lookup(s: &[u8]) -> Option<Key> {
|
||||
KEYS.iter()
|
||||
.find(|(name, _)| name.as_bytes().eq_ignore_ascii_case(s))
|
||||
.map(|(_, k)| *k)
|
||||
}
|
||||
|
||||
/// Parse OSC 1337
|
||||
/// https://iterm2.com/documentation-escape-codes.html
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S1337);
|
||||
|
||||
let data = parser.data()?;
|
||||
|
||||
let (key_str, value): (&[u8], Option<&[u8]>) = match data.iter().position(|&b| b == b'=') {
|
||||
None => (&data[..], None),
|
||||
Some(index) => (&data[..index], Some(&data[index + 1..])),
|
||||
};
|
||||
|
||||
// (Zig sets `parser.command = .invalid` and returns null on all of the
|
||||
// failure paths below.)
|
||||
let key = lookup(key_str)?;
|
||||
|
||||
match key {
|
||||
Key::Copy => {
|
||||
let value = value?;
|
||||
|
||||
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
|
||||
// make sure that is invalid here.
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// base64 value must be prefixed by a colon
|
||||
if value[0] != b':' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = &value[1..];
|
||||
|
||||
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
|
||||
// make sure that is invalid here.
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sending a '?' to query the clipboard is an OSC 52-ism, make sure
|
||||
// that is invalid here.
|
||||
if value == b"?" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// It would be better to check for valid base64 data here, but that
|
||||
// would mean parsing the base64 data twice in the "normal" case.
|
||||
|
||||
Some(Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: value.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
Key::CurrentDir => {
|
||||
let value = value?;
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ReportPwd {
|
||||
value: value.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
_ => {
|
||||
log::debug!("unimplemented OSC 1337: {key:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<Command> {
|
||||
let mut p = Parser::init(true);
|
||||
for ch in input.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(Some(0x1b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_unimplemented_key_with_no_value() {
|
||||
assert!(run("1337;SetBadgeFormat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_unimplemented_key_with_empty_value() {
|
||||
assert!(run("1337;SetBadgeFormat=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_unimplemented_key_with_non_empty_value() {
|
||||
assert!(run("1337;SetBadgeFormat=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_key_with_lower_case_and_with_no_value() {
|
||||
assert!(run("1337;setbadgeformat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_key_with_lower_case_and_with_empty_value() {
|
||||
assert!(run("1337;setbadgeformat=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_key_with_lower_case_and_with_non_empty_value() {
|
||||
assert!(run("1337;setbadgeformat=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_invalid_key_with_no_value() {
|
||||
assert!(run("1337;BobrKurwa").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_invalid_key_with_empty_value() {
|
||||
assert!(run("1337;BobrKurwa=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_invalid_key_with_non_empty_value() {
|
||||
assert!(run("1337;BobrKurwa=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_no_value() {
|
||||
assert!(run("1337;Copy").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_empty_value() {
|
||||
assert!(run("1337;Copy=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_only_prefix_colon() {
|
||||
assert!(run("1337;Copy=:").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_question_mark() {
|
||||
assert!(run("1337;Copy=:?").is_none());
|
||||
}
|
||||
|
||||
// Skipped in Zig too (`return error.SkipZigTest`): for performance reasons
|
||||
// base64 validity is not checked.
|
||||
#[test]
|
||||
#[ignore = "skipped in Zig: base64 is not validated for performance"]
|
||||
fn osc_1337_test_copy_with_non_empty_value_that_is_invalid_base64() {
|
||||
assert!(run("1337;Copy=:abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64_but_not_prefixed_with_a_colon()
|
||||
{
|
||||
assert!(run("1337;Copy=YWJjMTIz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64() {
|
||||
assert_eq!(
|
||||
run("1337;Copy=:YWJjMTIz"),
|
||||
Some(Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: b"YWJjMTIz".to_vec()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_current_dir_with_no_value() {
|
||||
assert!(run("1337;CurrentDir").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_current_dir_with_empty_value() {
|
||||
assert!(run("1337;CurrentDir=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_current_dir_with_non_empty_value() {
|
||||
assert_eq!(
|
||||
run("1337;CurrentDir=abc123"),
|
||||
Some(Command::ReportPwd {
|
||||
value: b"abc123".to_vec()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
//! Kitty's clipboard protocol (OSC 5522)
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
|
||||
//! https://rockorager.dev/misc/bracketed-paste-mime/
|
||||
//! (ZIG/src/terminal/osc/parsers/kitty_clipboard_protocol.zig)
|
||||
|
||||
use crate::osc::{Command, Parser, State, Terminator};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OSC {
|
||||
/// The raw metadata that was received. It can be parsed by using the
|
||||
/// `read_option` method (or the typed helpers).
|
||||
pub metadata: Vec<u8>,
|
||||
/// The raw payload. It may be Base64 encoded, check the `e` option.
|
||||
pub payload: Option<Vec<u8>>,
|
||||
/// The terminator that was used in case we need to send a response.
|
||||
pub terminator: Terminator,
|
||||
}
|
||||
|
||||
/// Value of a decoded option (Zig `Option.Type()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionValue<'a> {
|
||||
Str(&'a [u8]),
|
||||
Loc(Location),
|
||||
Status(Status),
|
||||
Type(Operation),
|
||||
}
|
||||
|
||||
impl OSC {
|
||||
/// Decode an option from the metadata.
|
||||
pub fn read_option(&self, key: Opt) -> Option<OptionValue<'_>> {
|
||||
key.read(&self.metadata)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Id)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loc(&self) -> Option<Location> {
|
||||
match self.read_option(Opt::Loc)? {
|
||||
OptionValue::Loc(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Mime)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Name)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn password(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Password)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pw(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Pw)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<Status> {
|
||||
match self.read_option(Opt::Status)? {
|
||||
OptionValue::Status(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_(&self) -> Option<Operation> {
|
||||
match self.read_option(Opt::Type)? {
|
||||
OptionValue::Type(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Location {
|
||||
Primary,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub fn init(s: &[u8]) -> Option<Location> {
|
||||
match s {
|
||||
b"primary" => Some(Location::Primary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Operation {
|
||||
Read,
|
||||
Walias,
|
||||
Wdata,
|
||||
Write,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
pub fn init(s: &[u8]) -> Option<Operation> {
|
||||
match s {
|
||||
b"read" => Some(Operation::Read),
|
||||
b"walias" => Some(Operation::Walias),
|
||||
b"wdata" => Some(Operation::Wdata),
|
||||
b"write" => Some(Operation::Write),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum Status {
|
||||
DATA,
|
||||
DONE,
|
||||
EBUSY,
|
||||
EINVAL,
|
||||
EIO,
|
||||
ENOSYS,
|
||||
EPERM,
|
||||
OK,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn init(s: &[u8]) -> Option<Status> {
|
||||
match s {
|
||||
b"DATA" => Some(Status::DATA),
|
||||
b"DONE" => Some(Status::DONE),
|
||||
b"EBUSY" => Some(Status::EBUSY),
|
||||
b"EINVAL" => Some(Status::EINVAL),
|
||||
b"EIO" => Some(Status::EIO),
|
||||
b"ENOSYS" => Some(Status::ENOSYS),
|
||||
b"EPERM" => Some(Status::EPERM),
|
||||
b"OK" => Some(Status::OK),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Option keys (Zig `Option`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Opt {
|
||||
Id,
|
||||
Loc,
|
||||
Mime,
|
||||
Name,
|
||||
Password,
|
||||
Pw,
|
||||
Status,
|
||||
Type,
|
||||
}
|
||||
|
||||
/// Zig `std.ascii.isWhitespace`.
|
||||
fn is_ws(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
|
||||
}
|
||||
|
||||
fn trim_ws(mut s: &[u8]) -> &[u8] {
|
||||
while let [first, rest @ ..] = s {
|
||||
if is_ws(*first) {
|
||||
s = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let [rest @ .., last] = s {
|
||||
if is_ws(*last) {
|
||||
s = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn index_of_from(hay: &[u8], from: usize, needle: u8) -> Option<usize> {
|
||||
hay[from..]
|
||||
.iter()
|
||||
.position(|&c| c == needle)
|
||||
.map(|i| i + from)
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
pub fn tag_name(self) -> &'static str {
|
||||
match self {
|
||||
Opt::Id => "id",
|
||||
Opt::Loc => "loc",
|
||||
Opt::Mime => "mime",
|
||||
Opt::Name => "name",
|
||||
Opt::Password => "password",
|
||||
Opt::Pw => "pw",
|
||||
Opt::Status => "status",
|
||||
Opt::Type => "type",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the option value from the raw metadata string.
|
||||
pub fn read(self, metadata: &[u8]) -> Option<OptionValue<'_>> {
|
||||
let tag = self.tag_name().as_bytes();
|
||||
let value: &[u8] = 'value: {
|
||||
let mut pos: usize = 0;
|
||||
while pos < metadata.len() {
|
||||
// skip any whitespace
|
||||
while pos < metadata.len() && is_ws(metadata[pos]) {
|
||||
pos += 1;
|
||||
}
|
||||
// bail if we are out of metadata
|
||||
if pos >= metadata.len() {
|
||||
return None;
|
||||
}
|
||||
if !metadata[pos..].starts_with(tag) {
|
||||
// this isn't the key we are looking for, skip to the next option, or bail if
|
||||
// there is no next option
|
||||
pos = index_of_from(metadata, pos, b':')?;
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
// skip past the key
|
||||
pos += tag.len();
|
||||
// skip any whitespace
|
||||
while pos < metadata.len() && is_ws(metadata[pos]) {
|
||||
pos += 1;
|
||||
}
|
||||
// bail if we are out of metadata
|
||||
if pos >= metadata.len() {
|
||||
return None;
|
||||
}
|
||||
// a valid option has an '='
|
||||
if metadata[pos] != b'=' {
|
||||
return None;
|
||||
}
|
||||
// the end of the value is bounded by a ':' or the end of the metadata
|
||||
let end = index_of_from(metadata, pos, b':').unwrap_or(metadata.len());
|
||||
let start = pos + 1;
|
||||
// strip any leading or trailing whitespace
|
||||
break 'value trim_ws(&metadata[start..end]);
|
||||
}
|
||||
// the key was not found
|
||||
return None;
|
||||
};
|
||||
|
||||
// return the parsed value
|
||||
match self {
|
||||
Opt::Id => parse_identifier(value).map(OptionValue::Str),
|
||||
Opt::Loc => Location::init(value).map(OptionValue::Loc),
|
||||
Opt::Mime | Opt::Name | Opt::Password | Opt::Pw => Some(OptionValue::Str(value)),
|
||||
Opt::Status => Status::init(value).map(OptionValue::Status),
|
||||
Opt::Type => Operation::init(value).map(OptionValue::Type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters that are valid in identifiers.
|
||||
const VALID_IDENTIFIER_CHARACTERS: &[u8] =
|
||||
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
|
||||
|
||||
fn is_valid_identifier(s: &[u8]) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
s.iter().all(|c| VALID_IDENTIFIER_CHARACTERS.contains(c))
|
||||
}
|
||||
|
||||
fn parse_identifier(s: &[u8]) -> Option<&[u8]> {
|
||||
if is_valid_identifier(s) {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S5522);
|
||||
|
||||
let data = parser.buffered()?;
|
||||
|
||||
let (metadata, payload) = match data.iter().position(|&c| c == b';') {
|
||||
None => (data, None),
|
||||
Some(start) => (data[..start].to_vec(), Some(data[start + 1..].to_vec())),
|
||||
};
|
||||
|
||||
Some(Command::KittyClipboardProtocol(OSC {
|
||||
metadata,
|
||||
payload,
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> OSC {
|
||||
let mut p = Parser::init(true);
|
||||
for &ch in input.as_bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
match p.end(Some(0x1b)) {
|
||||
Some(Command::KittyClipboardProtocol(o)) => o,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
/// Expected values for every option; `None` means "must be null".
|
||||
#[derive(Default)]
|
||||
struct Exp<'a> {
|
||||
metadata: Option<&'a str>,
|
||||
payload: Option<Option<&'a str>>,
|
||||
id: Option<&'a str>,
|
||||
loc: Option<Location>,
|
||||
mime: Option<&'a str>,
|
||||
name: Option<&'a str>,
|
||||
password: Option<&'a str>,
|
||||
pw: Option<&'a str>,
|
||||
status: Option<Status>,
|
||||
type_: Option<Operation>,
|
||||
}
|
||||
|
||||
fn check(c: &OSC, e: &Exp) {
|
||||
if let Some(m) = e.metadata {
|
||||
assert_eq!(m.as_bytes(), &c.metadata[..]);
|
||||
}
|
||||
if let Some(p) = e.payload {
|
||||
assert_eq!(p.map(|s| s.as_bytes().to_vec()), c.payload);
|
||||
}
|
||||
assert_eq!(e.id.map(str::as_bytes), c.id());
|
||||
assert_eq!(e.loc, c.loc());
|
||||
assert_eq!(e.mime.map(str::as_bytes), c.mime());
|
||||
assert_eq!(e.name.map(str::as_bytes), c.name());
|
||||
assert_eq!(e.password.map(str::as_bytes), c.password());
|
||||
assert_eq!(e.pw.map(str::as_bytes), c.pw());
|
||||
assert_eq!(e.status, c.status());
|
||||
assert_eq!(e.type_, c.type_());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_empty_metadata_and_missing_payload() {
|
||||
let c = run("5522;");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
metadata: Some(""),
|
||||
payload: Some(None),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_empty_metadata_and_empty_payload() {
|
||||
let c = run("5522;;");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
metadata: Some(""),
|
||||
payload: Some(Some("")),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_non_empty_metadata_and_payload() {
|
||||
let c = run("5522;type=read;dGV4dC9wbGFpbg==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
metadata: Some("type=read"),
|
||||
payload: Some(Some("dGV4dC9wbGFpbg==")),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_empty_id() {
|
||||
let c = run("5522;id=");
|
||||
assert!(c.id().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_valid_id() {
|
||||
let c = run("5522;id=5c076ad9-d36f-4705-847b-d4dbf356cc0d");
|
||||
assert_eq!(b"5c076ad9-d36f-4705-847b-d4dbf356cc0d", c.id().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_invalid_id() {
|
||||
let c = run("5522;id=*42*");
|
||||
assert!(c.id().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_invalid_status() {
|
||||
let c = run("5522;status=BOBR");
|
||||
assert!(c.status().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_valid_status() {
|
||||
let c = run("5522;status=DONE");
|
||||
assert_eq!(Some(Status::DONE), c.status());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_invalid_location() {
|
||||
let c = run("5522;loc=bobr");
|
||||
assert!(c.loc().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_valid_location() {
|
||||
let c = run("5522;loc=primary");
|
||||
assert_eq!(Some(Location::Primary), c.loc());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_password_1() {
|
||||
let c = run("5522;pw=R2hvc3R0eQ==:name=Qk9CUiBLVVJXQQ==");
|
||||
assert_eq!(b"R2hvc3R0eQ==", c.pw().unwrap());
|
||||
assert_eq!(b"Qk9CUiBLVVJXQQ==", c.name().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_password_2() {
|
||||
let c = run("5522;password=R2hvc3R0eQ==");
|
||||
assert_eq!(b"R2hvc3R0eQ==", c.password().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_1() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_2() {
|
||||
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("R2hvc3R0eQ==")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_3() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_4() {
|
||||
let c = run("5522;type=write");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
type_: Some(Operation::Write),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_5() {
|
||||
let c = run("5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("R2hvc3R0eQ==")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
type_: Some(Operation::Wdata),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_6() {
|
||||
let c = run("5522;type=wdata");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
type_: Some(Operation::Wdata),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_7() {
|
||||
let c = run("5522;type=write:status=DONE");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::DONE),
|
||||
type_: Some(Operation::Write),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_8() {
|
||||
let c = run("5522;type=write:status=EPERM");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::EPERM),
|
||||
type_: Some(Operation::Write),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_9() {
|
||||
let c = run("5522;type=walias:mime=dGV4dC9wbGFpbg==;dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
type_: Some(Operation::Walias),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_10() {
|
||||
let c = run("5522;type=read:status=OK:password=Qk9CUiBLVVJXQQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
password: Some("Qk9CUiBLVVJXQQ=="),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_11() {
|
||||
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
status: Some(Status::DATA),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_12() {
|
||||
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==:password=Qk9CUiBLVVJXQQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
password: Some("Qk9CUiBLVVJXQQ=="),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_13() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_14() {
|
||||
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;Qk9CUiBLVVJXQQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("Qk9CUiBLVVJXQQ==")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
status: Some(Status::DATA),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_15() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! OSC 21, the Kitty Color Protocol (ZIG/src/terminal/osc/parsers/kitty_color.zig).
|
||||
|
||||
use crate::color::Rgb;
|
||||
use crate::kitty::color::{Kind, OSC, Request};
|
||||
use crate::osc::{Command, Parser, State, Terminator};
|
||||
|
||||
/// Parse OSC 21, the Kitty Color Protocol.
|
||||
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S21);
|
||||
|
||||
if !parser.alloc {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if !parser.has_writer() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
let data = parser.buffered()?;
|
||||
|
||||
let mut osc = OSC {
|
||||
list: Vec::new(),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
};
|
||||
for kv in data.split(|&c| c == b';') {
|
||||
if osc.list.len() >= Kind::MAX * 2 {
|
||||
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
let (k, rest) = match kv.iter().position(|&c| c == b'=') {
|
||||
Some(p) => (&kv[..p], &kv[p + 1..]),
|
||||
None => (kv, &kv[kv.len()..]),
|
||||
};
|
||||
if k.is_empty() {
|
||||
log::warn!("zero length key in kitty color protocol");
|
||||
continue;
|
||||
}
|
||||
let Some(key) = Kind::parse(k) else {
|
||||
log::warn!(
|
||||
"unknown key in kitty color protocol: {}",
|
||||
String::from_utf8_lossy(k)
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let value = rest.trim_ascii_matching(b' ');
|
||||
if value.is_empty() {
|
||||
osc.list.push(Request::Reset(key));
|
||||
} else if value == b"?" {
|
||||
osc.list.push(Request::Query(key));
|
||||
} else if let Ok(color) = Rgb::parse_bytes(value) {
|
||||
osc.list.push(Request::Set { key, color });
|
||||
} else {
|
||||
log::warn!(
|
||||
"invalid color format in kitty color protocol: {}",
|
||||
String::from_utf8_lossy(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Command::KittyColorProtocol(osc))
|
||||
}
|
||||
|
||||
trait TrimMatching {
|
||||
fn trim_ascii_matching(&self, c: u8) -> &[u8];
|
||||
}
|
||||
|
||||
impl TrimMatching for [u8] {
|
||||
fn trim_ascii_matching(&self, c: u8) -> &[u8] {
|
||||
let mut s = self;
|
||||
while let [first, rest @ ..] = s {
|
||||
if *first != c {
|
||||
break;
|
||||
}
|
||||
s = rest;
|
||||
}
|
||||
while let [rest @ .., last] = s {
|
||||
if *last != c {
|
||||
break;
|
||||
}
|
||||
s = rest;
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use crate::kitty::color::Special;
|
||||
|
||||
const INPUT: &str = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0";
|
||||
|
||||
fn feed(p: &mut Parser, s: &str) {
|
||||
for ch in s.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
|
||||
Rgb { r, g, b }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, INPUT);
|
||||
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
let Command::KittyColorProtocol(osc) = cmd else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
let list = osc.list;
|
||||
assert_eq!(9, list.len());
|
||||
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Special(Special::Background),
|
||||
color: rgb(0xf0, 0xf8, 0xff)
|
||||
},
|
||||
list[1]
|
||||
);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Special(Special::Cursor),
|
||||
color: rgb(0xf0, 0xf8, 0xff)
|
||||
},
|
||||
list[2]
|
||||
);
|
||||
assert_eq!(Request::Reset(Kind::Special(Special::CursorText)), list[3]);
|
||||
assert_eq!(Request::Reset(Kind::Special(Special::VisualBell)), list[4]);
|
||||
assert_eq!(
|
||||
Request::Query(Kind::Special(Special::SelectionBackground)),
|
||||
list[5]
|
||||
);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Special(Special::SelectionBackground),
|
||||
color: rgb(0xaa, 0xbb, 0xcc)
|
||||
},
|
||||
list[6]
|
||||
);
|
||||
assert_eq!(Request::Query(Kind::Palette(2)), list[7]);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Palette(3),
|
||||
color: rgb(0xff, 0xff, 0xff)
|
||||
},
|
||||
list[8]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_without_allocator() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, "21;foreground=?");
|
||||
assert!(p.end(Some(0x1b)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_double_reset() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, INPUT);
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
|
||||
|
||||
p.reset();
|
||||
p.reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_reset_after_invalid() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, INPUT);
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
|
||||
|
||||
p.reset();
|
||||
|
||||
assert_eq!(State::Start, p.state);
|
||||
p.next(b'X');
|
||||
assert_eq!(State::Invalid, p.state);
|
||||
|
||||
p.reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_no_key() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, "21;");
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
let Command::KittyColorProtocol(osc) = cmd else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
assert_eq!(0, osc.list.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Kitty's text sizing protocol (OSC 66)
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
|
||||
//! (ZIG/src/terminal/osc/parsers/kitty_text_sizing.zig)
|
||||
|
||||
use crate::osc::{Command, Parser, State, encoding};
|
||||
|
||||
pub const MAX_PAYLOAD_LENGTH: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Bottom,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl VAlign {
|
||||
fn from_int(v: u8) -> Option<VAlign> {
|
||||
match v {
|
||||
0 => Some(VAlign::Top),
|
||||
1 => Some(VAlign::Bottom),
|
||||
2 => Some(VAlign::Center),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl HAlign {
|
||||
fn from_int(v: u8) -> Option<HAlign> {
|
||||
match v {
|
||||
0 => Some(HAlign::Left),
|
||||
1 => Some(HAlign::Right),
|
||||
2 => Some(HAlign::Center),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OSC {
|
||||
/// 1 - 7 (u3)
|
||||
pub scale: u8,
|
||||
/// 0 - 7 (u3), 0 means default
|
||||
pub width: u8,
|
||||
/// u4
|
||||
pub numerator: u8,
|
||||
/// u4
|
||||
pub denominator: u8,
|
||||
pub valign: VAlign,
|
||||
pub halign: HAlign,
|
||||
pub text: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum UpdateError {
|
||||
UnknownKey,
|
||||
InvalidValue,
|
||||
}
|
||||
|
||||
/// Equivalent of Zig `std.fmt.parseInt(u4, value, 10)`.
|
||||
fn parse_u4(value: &[u8]) -> Option<u8> {
|
||||
let (neg, digits) = match value.first() {
|
||||
Some(b'+') => (false, &value[1..]),
|
||||
Some(b'-') => (true, &value[1..]),
|
||||
_ => (false, value),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
acc = acc * 10 + (c - b'0') as u32;
|
||||
if acc > 15 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u8)
|
||||
}
|
||||
|
||||
impl OSC {
|
||||
fn update(&mut self, key: u8, value: &[u8]) -> Result<(), UpdateError> {
|
||||
// All values are numeric, so we can do a small hack here
|
||||
let v = parse_u4(value).ok_or(UpdateError::InvalidValue)?;
|
||||
|
||||
match key {
|
||||
b's' => {
|
||||
if v == 0 || v > 7 {
|
||||
return Err(UpdateError::InvalidValue);
|
||||
}
|
||||
self.scale = v;
|
||||
}
|
||||
b'w' => {
|
||||
if v > 7 {
|
||||
return Err(UpdateError::InvalidValue);
|
||||
}
|
||||
self.width = v;
|
||||
}
|
||||
b'n' => self.numerator = v,
|
||||
b'd' => self.denominator = v,
|
||||
b'v' => self.valign = VAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
|
||||
b'h' => self.halign = HAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
|
||||
_ => return Err(UpdateError::UnknownKey),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S66);
|
||||
|
||||
let data = parser.data()?;
|
||||
|
||||
let Some(payload_start) = data.iter().position(|&c| c == b';') else {
|
||||
log::warn!("missing semicolon before payload");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
let payload = &data[payload_start + 1..];
|
||||
|
||||
// Payload has to be a URL-safe UTF-8 string,
|
||||
// and be under the size limit.
|
||||
if payload.len() > MAX_PAYLOAD_LENGTH {
|
||||
log::warn!("payload is too long");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if !encoding::is_safe_utf8(payload) {
|
||||
log::warn!("payload is not escape code safe UTF-8");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cmd = OSC {
|
||||
scale: 1,
|
||||
width: 0,
|
||||
numerator: 0,
|
||||
denominator: 0,
|
||||
valign: VAlign::Top,
|
||||
halign: HAlign::Left,
|
||||
text: payload.to_vec(),
|
||||
};
|
||||
|
||||
// Parse any arguments if given
|
||||
if payload_start > 0 {
|
||||
for kv in data[..payload_start].split(|&c| c == b':') {
|
||||
let mut it = kv.split(|&c| c == b'=');
|
||||
let Some(k) = it.next() else {
|
||||
log::warn!("missing key");
|
||||
continue;
|
||||
};
|
||||
if k.len() != 1 {
|
||||
log::warn!("key must be a single character");
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(value) = it.next() else {
|
||||
log::warn!("missing value");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(err) = cmd.update(k[0], value) {
|
||||
match err {
|
||||
UpdateError::UnknownKey => log::warn!("unknown key: '{}'", k[0] as char),
|
||||
UpdateError::InvalidValue => {
|
||||
log::warn!("invalid value for key '{}': {:?}", k[0] as char, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Command::KittyTextSizing(cmd))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> Option<Command> {
|
||||
let mut p = Parser::init(false);
|
||||
for &ch in input {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(Some(0x1b))
|
||||
}
|
||||
|
||||
fn sizing(input: &[u8]) -> OSC {
|
||||
match run(input) {
|
||||
Some(Command::KittyTextSizing(o)) => o,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_empty_parameters() {
|
||||
let c = sizing(b"66;;bobr");
|
||||
assert_eq!(1, c.scale);
|
||||
assert_eq!(b"bobr", &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_single_parameter() {
|
||||
let c = sizing(b"66;s=2;kurwa");
|
||||
assert_eq!(2, c.scale);
|
||||
assert_eq!(b"kurwa", &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_multiple_parameters() {
|
||||
let c = sizing(b"66;s=2:w=7:n=13:d=15:v=1:h=2;long");
|
||||
assert_eq!(2, c.scale);
|
||||
assert_eq!(7, c.width);
|
||||
assert_eq!(13, c.numerator);
|
||||
assert_eq!(15, c.denominator);
|
||||
assert_eq!(VAlign::Bottom, c.valign);
|
||||
assert_eq!(HAlign::Center, c.halign);
|
||||
assert_eq!(b"long", &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_scale_is_zero() {
|
||||
let c = sizing(b"66;s=0;nope");
|
||||
assert_eq!(1, c.scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_invalid_parameters() {
|
||||
let c = sizing(b"66;w=8:v=3:n=16;");
|
||||
assert_eq!(0, c.width);
|
||||
assert_eq!(VAlign::Top, c.valign);
|
||||
assert_eq!(0, c.numerator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_utf8() {
|
||||
let c = sizing("66;;👻魑魅魍魉ゴースッティ".as_bytes());
|
||||
assert_eq!("👻魑魅魍魉ゴースッティ".as_bytes(), &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_unsafe_utf8() {
|
||||
assert!(run(b"66;;\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_overlong_utf8() {
|
||||
let mut input = b"66;;".to_vec();
|
||||
input.extend(b"bobr".repeat(1025));
|
||||
assert!(run(&input).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! OSC parsers (ZIG/src/terminal/osc/parsers.zig).
|
||||
|
||||
pub mod change_window_icon;
|
||||
pub mod change_window_title;
|
||||
pub mod clipboard_operation;
|
||||
pub mod color;
|
||||
pub mod context_signal;
|
||||
pub mod hyperlink;
|
||||
pub mod iterm2;
|
||||
pub mod kitty_clipboard_protocol;
|
||||
pub mod kitty_color;
|
||||
pub mod kitty_text_sizing;
|
||||
pub mod mouse_shape;
|
||||
pub mod osc9;
|
||||
pub mod report_pwd;
|
||||
pub mod rxvt_extension;
|
||||
pub mod semantic_prompt;
|
||||
@@ -0,0 +1,29 @@
|
||||
//! OSC 22 (ZIG/src/terminal/osc/parsers/mouse_shape.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 22
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S22);
|
||||
let data = parser.data()?;
|
||||
Some(Command::MouseShape { value: data })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_22_pointer_cursor() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"22;pointer" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::MouseShape {
|
||||
value: b"pointer".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
//! OSC 9 (iTerm2 notification / ConEmu extensions)
|
||||
//! (ZIG/src/terminal/osc/parsers/osc9.zig).
|
||||
|
||||
use super::semantic_prompt;
|
||||
use crate::osc::{Command, ConemuChangeTabTitle, Parser, ProgressReport, ProgressState};
|
||||
|
||||
/// Parse a base-10 unsigned integer from raw bytes (Zig `parseUnsigned`).
|
||||
fn parse_unsigned<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
|
||||
std::str::from_utf8(bytes).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
// Zig prologue: writer must exist. (The NUL-terminator capacity check is
|
||||
// folded into `data()`.)
|
||||
let data = parser.data()?;
|
||||
|
||||
// Check first to see if this is a ConEmu OSC
|
||||
// https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
|
||||
if let Some(cmd) = conemu(&data) {
|
||||
return Some(cmd);
|
||||
}
|
||||
|
||||
// If it's not a ConEmu OSC, it's an iTerm2 notification
|
||||
Some(Command::ShowDesktopNotification {
|
||||
title: Vec::new(),
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns None where the Zig code does `break :conemu`.
|
||||
fn conemu(data: &[u8]) -> Option<Command> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let msg = |skip: usize| data[skip..].to_vec();
|
||||
match data[0] {
|
||||
// Check for OSC 9;1 9;10 9;11 9;12
|
||||
b'1' => {
|
||||
if data.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
match data[1] {
|
||||
// OSC 9;1 sleep
|
||||
b';' => Some(Command::ConemuSleep {
|
||||
duration_ms: match parse_unsigned::<u16>(&data[2..]) {
|
||||
Some(num) => num.min(10_000),
|
||||
None => 100,
|
||||
},
|
||||
}),
|
||||
// OSC 9;10 xterm keyboard and output emulation
|
||||
b'0' => {
|
||||
if data.len() == 2 {
|
||||
return Some(Command::ConemuXtermEmulation {
|
||||
keyboard: Some(true),
|
||||
output: Some(true),
|
||||
});
|
||||
}
|
||||
if data.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
if data[2] != b';' {
|
||||
return None;
|
||||
}
|
||||
let (keyboard, output) = match data[3] {
|
||||
b'0' => (Some(false), Some(false)),
|
||||
b'1' => (Some(true), Some(true)),
|
||||
b'2' => (None, Some(false)),
|
||||
b'3' => (None, Some(true)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Command::ConemuXtermEmulation { keyboard, output })
|
||||
}
|
||||
// OSC 9;11 comment
|
||||
b'1' => {
|
||||
if data.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
if data[2] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuComment(msg(3)))
|
||||
}
|
||||
// OSC 9;12 mark prompt start
|
||||
b'2' => Some(Command::SemanticPrompt(semantic_prompt::Command::init(
|
||||
semantic_prompt::Action::FreshLineNewPrompt,
|
||||
))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// OSC 9;2 show message box
|
||||
b'2' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuShowMessageBox(msg(2)))
|
||||
}
|
||||
// OSC 9;3 change tab title
|
||||
b'3' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
if data.len() == 2 {
|
||||
return Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset));
|
||||
}
|
||||
Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(
|
||||
msg(2),
|
||||
)))
|
||||
}
|
||||
// OSC 9;4 progress report
|
||||
b'4' => {
|
||||
if data.len() < 2 || data[1] != b';' || data.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut report = match data[2] {
|
||||
b'0' => ProgressReport {
|
||||
state: ProgressState::Remove,
|
||||
progress: None,
|
||||
},
|
||||
b'1' => ProgressReport {
|
||||
state: ProgressState::Set,
|
||||
progress: Some(0),
|
||||
},
|
||||
b'2' => ProgressReport {
|
||||
state: ProgressState::Error,
|
||||
progress: None,
|
||||
},
|
||||
b'3' => ProgressReport {
|
||||
state: ProgressState::Indeterminate,
|
||||
progress: None,
|
||||
},
|
||||
b'4' => ProgressReport {
|
||||
state: ProgressState::Pause,
|
||||
progress: None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
match report.state {
|
||||
ProgressState::Remove | ProgressState::Indeterminate => {}
|
||||
ProgressState::Set | ProgressState::Error | ProgressState::Pause => {
|
||||
if data.len() >= 4 && data[3] == b';' {
|
||||
// parse the progress value
|
||||
report.progress =
|
||||
parse_unsigned::<usize>(&data[4..]).map(|v| v.min(100) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::ConemuProgressReport(report))
|
||||
}
|
||||
// OSC 9;5 wait for input
|
||||
b'5' => Some(Command::ConemuWaitInput),
|
||||
// OSC 9;6 guimacro
|
||||
b'6' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuGuimacro(msg(2)))
|
||||
}
|
||||
// OSC 9;7 run process
|
||||
b'7' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuRunProcess(msg(2)))
|
||||
}
|
||||
// OSC 9;8 output environment variable
|
||||
b'8' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuOutputEnvironmentVariable(msg(2)))
|
||||
}
|
||||
// OSC 9;9 current working directory
|
||||
b'9' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ReportPwd { value: msg(2) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Command {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in input.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(Some(0x1b)).unwrap()
|
||||
}
|
||||
|
||||
fn notif(input: &str, body: &str) {
|
||||
match run(input) {
|
||||
Command::ShowDesktopNotification { title, body: b } => {
|
||||
assert_eq!(title, b"");
|
||||
assert_eq!(b, body.as_bytes());
|
||||
}
|
||||
c => panic!("unexpected {c:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(input: &str, state: ProgressState, progress: Option<u8>) {
|
||||
assert_eq!(
|
||||
run(input),
|
||||
Command::ConemuProgressReport(ProgressReport { state, progress })
|
||||
);
|
||||
}
|
||||
|
||||
fn xterm(input: &str, keyboard: Option<bool>, output: Option<bool>) {
|
||||
assert_eq!(
|
||||
run(input),
|
||||
Command::ConemuXtermEmulation { keyboard, output }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_show_desktop_notification() {
|
||||
notif("9;Hello world", "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_show_single_character_desktop_notification() {
|
||||
notif("9;H", "H");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep() {
|
||||
assert_eq!(run("9;1;420"), Command::ConemuSleep { duration_ms: 420 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_with_no_value_default_to_100ms() {
|
||||
assert_eq!(run("9;1;"), Command::ConemuSleep { duration_ms: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_cannot_exceed_10000ms() {
|
||||
assert_eq!(
|
||||
run("9;1;12345"),
|
||||
Command::ConemuSleep { duration_ms: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_invalid_input() {
|
||||
assert_eq!(run("9;1;foo"), Command::ConemuSleep { duration_ms: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_desktop_notification_1() {
|
||||
notif("9;1", "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_desktop_notification_2() {
|
||||
notif("9;1a", "1a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box() {
|
||||
assert_eq!(
|
||||
run("9;2;hello world"),
|
||||
Command::ConemuShowMessageBox(b"hello world".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box_invalid_input() {
|
||||
notif("9;2", "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box_empty_message() {
|
||||
assert_eq!(run("9;2;"), Command::ConemuShowMessageBox(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box_spaces_only_message() {
|
||||
assert_eq!(
|
||||
run("9;2; "),
|
||||
Command::ConemuShowMessageBox(b" ".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_message_box_desktop_notification_1() {
|
||||
notif("9;2", "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_message_box_desktop_notification_2() {
|
||||
notif("9;2a", "2a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_conemu_change_tab_title() {
|
||||
assert_eq!(
|
||||
run("9;3;foo bar"),
|
||||
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b"foo bar".to_vec()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_conemu_change_tab_title_reset() {
|
||||
assert_eq!(
|
||||
run("9;3;"),
|
||||
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_conemu_change_tab_title_spaces_only() {
|
||||
assert_eq!(
|
||||
run("9;3; "),
|
||||
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b" ".to_vec()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_change_tab_title_desktop_notification_1() {
|
||||
notif("9;3", "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_message_box_desktop_notification_2() {
|
||||
notif("9;3a", "3a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set() {
|
||||
progress("9;4;1;100", ProgressState::Set, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_overflow() {
|
||||
progress("9;4;1;900", ProgressState::Set, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_single_digit() {
|
||||
progress("9;4;1;9", ProgressState::Set, Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_double_digit() {
|
||||
progress("9;4;1;94", ProgressState::Set, Some(94));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_extra_semicolon_ignored() {
|
||||
progress("9;4;1;100", ProgressState::Set, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_with_no_progress() {
|
||||
progress("9;4;0;", ProgressState::Remove, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_with_double_semicolon() {
|
||||
progress("9;4;0;;", ProgressState::Remove, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_ignores_progress() {
|
||||
progress("9;4;0;100", ProgressState::Remove, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_extra_semicolon() {
|
||||
match run("9;4;0;100;") {
|
||||
Command::ConemuProgressReport(r) => assert_eq!(r.state, ProgressState::Remove),
|
||||
c => panic!("unexpected {c:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_error() {
|
||||
progress("9;4;2", ProgressState::Error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_error_with_progress() {
|
||||
progress("9;4;2;100", ProgressState::Error, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_pause() {
|
||||
progress("9;4;4", ProgressState::Pause, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_pause_with_progress() {
|
||||
progress("9;4;4;100", ProgressState::Pause, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_1() {
|
||||
notif("9;4", "4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_2() {
|
||||
notif("9;4;", "4;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_3() {
|
||||
notif("9;4;5", "4;5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_4() {
|
||||
notif("9;4;5a", "4;5a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_5_conemu_wait_input() {
|
||||
assert_eq!(run("9;5"), Command::ConemuWaitInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_5_conemu_wait_ignores_trailing_characters() {
|
||||
assert_eq!(run("9;5;foo"), Command::ConemuWaitInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_6_conemu_guimacro_1() {
|
||||
assert_eq!(run("9;6;a"), Command::ConemuGuimacro(b"a".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_6_conemu_guimacro_2() {
|
||||
assert_eq!(run("9;6;ab"), Command::ConemuGuimacro(b"ab".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_6_conemu_guimacro_3_incomplete_desktop_notification() {
|
||||
notif("9;6", "6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_7_conemu_run_process_1() {
|
||||
assert_eq!(run("9;7;ab"), Command::ConemuRunProcess(b"ab".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_7_conemu_run_process_2() {
|
||||
assert_eq!(run("9;7;"), Command::ConemuRunProcess(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_7_conemu_run_process_incomplete_desktop_notification() {
|
||||
notif("9;7", "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_8_conemu_output_environment_variable_1() {
|
||||
assert_eq!(
|
||||
run("9;8;ab"),
|
||||
Command::ConemuOutputEnvironmentVariable(b"ab".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_8_conemu_output_environment_variable_2() {
|
||||
assert_eq!(
|
||||
run("9;8;"),
|
||||
Command::ConemuOutputEnvironmentVariable(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_8_conemu_output_environment_variable_incomplete_desktop_notification() {
|
||||
notif("9;8", "8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_9_conemu_set_current_working_directory() {
|
||||
assert_eq!(
|
||||
run("9;9;ab"),
|
||||
Command::ReportPwd {
|
||||
value: b"ab".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_9_conemu_set_current_working_directory_incomplete_desktop_notification() {
|
||||
notif("9;9", "9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_1() {
|
||||
xterm("9;10", Some(true), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_2() {
|
||||
xterm("9;10;0", Some(false), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_3() {
|
||||
xterm("9;10;1", Some(true), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_4() {
|
||||
xterm("9;10;2", None, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_5() {
|
||||
xterm("9;10;3", None, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_6() {
|
||||
notif("9;10;4", "10;4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_7() {
|
||||
notif("9;10;", "10;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_8() {
|
||||
notif("9;10;abc", "10;abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_11_conemu_comment() {
|
||||
assert_eq!(run("9;11;ab"), Command::ConemuComment(b"ab".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_11_conemu_comment_incomplete_desktop_notification() {
|
||||
notif("9;11", "11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_12_conemu_mark_prompt_start_1() {
|
||||
assert!(matches!(run("9;12"), Command::SemanticPrompt(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_12_conemu_mark_prompt_start_2() {
|
||||
assert!(matches!(run("9;12;abc"), Command::SemanticPrompt(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! OSC 7 (ZIG/src/terminal/osc/parsers/report_pwd.zig).
|
||||
use crate::osc::{Command, Parser};
|
||||
|
||||
/// Parse OSC 7
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
Some(Command::ReportPwd { value: data })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_7_report_pwd() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"7;file:///tmp/example" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ReportPwd {
|
||||
value: b"file:///tmp/example".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_report_pwd_empty() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"7;" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ReportPwd { value: Vec::new() }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! OSC 777 (ZIG/src/terminal/osc/parsers/rxvt_extension.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 777
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
let Some(k) = data.iter().position(|&b| b == b';') else {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
let ext = &data[..k];
|
||||
if ext != b"notify" {
|
||||
log::warn!("unknown rxvt extension: {}", String::from_utf8_lossy(ext));
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
let Some(t) = data[k + 1..]
|
||||
.iter()
|
||||
.position(|&b| b == b';')
|
||||
.map(|i| i + k + 1)
|
||||
else {
|
||||
log::warn!("rxvt notify extension is missing the title");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
Some(Command::ShowDesktopNotification {
|
||||
title: data[k + 1..t].to_vec(),
|
||||
body: data[t + 1..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_777_show_desktop_notification_with_title() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"777;notify;Title;Body" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(Some(0x1b)).unwrap(),
|
||||
Command::ShowDesktopNotification {
|
||||
title: b"Title".to_vec(),
|
||||
body: b"Body".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
//! OSC 133 semantic prompts
|
||||
//! (ZIG/src/terminal/osc/parsers/semantic_prompt.zig).
|
||||
//!
|
||||
//! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md>
|
||||
|
||||
use crate::osc::{Command as OscCommand, Parser, State};
|
||||
|
||||
/// Error returned when decoding a command line fails (Zig
|
||||
/// `error.DecodeError`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DecodeError;
|
||||
|
||||
impl std::fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("decode error")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
/// A single semantic prompt command.
|
||||
///
|
||||
/// Technically according to the spec, not all commands have options
|
||||
/// but it is easier to be "liberal in what we accept" here since
|
||||
/// all except one do and the spec does also say to ignore unknown
|
||||
/// options. So, I think this is a fair interpretation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub action: Action,
|
||||
pub options_unvalidated: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
FreshLine, // 'L'
|
||||
FreshLineNewPrompt, // 'A'
|
||||
NewCommand, // 'N'
|
||||
PromptStart, // 'P'
|
||||
EndPromptStartInput, // 'B'
|
||||
EndPromptStartInputTerminateEol, // 'I'
|
||||
EndInputStartOutput, // 'C'
|
||||
EndCommand, // 'D'
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn init(action: Action) -> Command {
|
||||
Command {
|
||||
action,
|
||||
options_unvalidated: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an option for this command. Returns None if unset or invalid.
|
||||
pub fn read_option(&self, option: OptionKey) -> Option<OptionValue<'_>> {
|
||||
option.read(&self.options_unvalidated)
|
||||
}
|
||||
|
||||
/// Typed convenience: `aid` option.
|
||||
pub fn aid(&self) -> Option<&[u8]> {
|
||||
match self.read_option(OptionKey::Aid)? {
|
||||
OptionValue::Bytes(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `cl` option.
|
||||
pub fn cl(&self) -> Option<Click> {
|
||||
match self.read_option(OptionKey::Cl)? {
|
||||
OptionValue::Click(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `k` option.
|
||||
pub fn prompt_kind(&self) -> Option<PromptKind> {
|
||||
match self.read_option(OptionKey::PromptKind)? {
|
||||
OptionValue::PromptKind(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `err` option.
|
||||
pub fn err(&self) -> Option<&[u8]> {
|
||||
match self.read_option(OptionKey::Err)? {
|
||||
OptionValue::Bytes(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `redraw` option.
|
||||
pub fn redraw(&self) -> Option<Redraw> {
|
||||
match self.read_option(OptionKey::Redraw)? {
|
||||
OptionValue::Redraw(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `special_key` option.
|
||||
pub fn special_key(&self) -> Option<bool> {
|
||||
match self.read_option(OptionKey::SpecialKey)? {
|
||||
OptionValue::Bool(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `click_events` option.
|
||||
pub fn click_events(&self) -> Option<bool> {
|
||||
match self.read_option(OptionKey::ClickEvents)? {
|
||||
OptionValue::Bool(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `exit_code` (first positional value of 'D').
|
||||
pub fn exit_code(&self) -> Option<i32> {
|
||||
match self.read_option(OptionKey::ExitCode)? {
|
||||
OptionValue::Int(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the decoded command line (if any) to the writer. If an error
|
||||
/// occurs garbage may have been written to the writer.
|
||||
///
|
||||
/// TODO(integration): uses local copies of `printfQDecode` /
|
||||
/// `urlPercentDecode` (ZIG/src/os/string_encoding.zig); switch to
|
||||
/// `ghostty-os` once available (that crate would need to be a dependency
|
||||
/// of ghostty-vt, so these remain local for now).
|
||||
pub fn write_command_line(&self, writer: &mut Vec<u8>) -> Result<(), DecodeError> {
|
||||
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) {
|
||||
return printf_q_decode(writer, command_line);
|
||||
}
|
||||
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::CmdlineUrl) {
|
||||
return url_percent_decode(writer, command_line);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionKey {
|
||||
Aid,
|
||||
Cl,
|
||||
PromptKind,
|
||||
Err,
|
||||
Cmdline,
|
||||
CmdlineUrl,
|
||||
|
||||
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
// Kitty supports a "redraw" option for prompt_start. This is extended
|
||||
// by Ghostty with the "last" option. See Redraw the type for more details.
|
||||
Redraw,
|
||||
|
||||
// Use a special key instead of arrow keys to move the cursor on
|
||||
// mouse click. Useful if arrow keys have side-effets like triggering
|
||||
// auto-complete. The shell integration script should bind the special
|
||||
// key as needed.
|
||||
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
SpecialKey,
|
||||
|
||||
// If true, the shell is capable of handling mouse click events.
|
||||
// Ghostty will then send a click event to the shell when the user
|
||||
// clicks somewhere in the prompt. The shell can then move the cursor
|
||||
// to that position or perform some other appropriate action. If false,
|
||||
// Ghostty may generate a number of fake key events to move the cursor
|
||||
// which is not very robust.
|
||||
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
ClickEvents,
|
||||
|
||||
// Not technically an option that can be set with k=v and only
|
||||
// present currently with command 'D' but its easier to just
|
||||
// parse it into our options.
|
||||
ExitCode,
|
||||
}
|
||||
|
||||
/// The value of an option; the variant is determined by the `Option` (Zig
|
||||
/// `Option.Type()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionValue<'a> {
|
||||
Bytes(&'a [u8]),
|
||||
Click(Click),
|
||||
PromptKind(PromptKind),
|
||||
Redraw(Redraw),
|
||||
Bool(bool),
|
||||
Int(i32),
|
||||
}
|
||||
|
||||
impl OptionKey {
|
||||
fn key(self) -> &'static [u8] {
|
||||
match self {
|
||||
OptionKey::Aid => b"aid",
|
||||
OptionKey::Cl => b"cl",
|
||||
OptionKey::PromptKind => b"k",
|
||||
OptionKey::Err => b"err",
|
||||
OptionKey::Redraw => b"redraw",
|
||||
OptionKey::SpecialKey => b"special_key",
|
||||
OptionKey::ClickEvents => b"click_events",
|
||||
OptionKey::Cmdline => b"cmdline",
|
||||
OptionKey::CmdlineUrl => b"cmdline_url",
|
||||
|
||||
// special case, handled before ever calling key
|
||||
OptionKey::ExitCode => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the option value from the raw options string.
|
||||
///
|
||||
/// The raw options string is the raw unparsed data after the
|
||||
/// OSC 133 command. e.g. for `133;A;aid=14;cl=line`, the
|
||||
/// raw options string would be `aid=14;cl=line`.
|
||||
///
|
||||
/// Any errors in the raw string will return None since the OSC133
|
||||
/// specification says to ignore unknown or malformed options.
|
||||
pub fn read(self, raw: &[u8]) -> Option<OptionValue<'_>> {
|
||||
let mut remaining = raw;
|
||||
'outer: while !remaining.is_empty() {
|
||||
// Length of the next value is up to the `;` or the
|
||||
// end of the string.
|
||||
let len = remaining
|
||||
.iter()
|
||||
.position(|&b| b == b';')
|
||||
.unwrap_or(remaining.len());
|
||||
|
||||
// Grab our full value and move our cursor past the `;`
|
||||
let full = &remaining[..len];
|
||||
|
||||
// If we're looking for exit_code we special case it.
|
||||
// as the first value.
|
||||
if self == OptionKey::ExitCode {
|
||||
return std::str::from_utf8(full)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map(OptionValue::Int);
|
||||
}
|
||||
|
||||
// Parse our key=value and verify our key matches our
|
||||
// expectation.
|
||||
let value = 'value: {
|
||||
if let Some(eql_idx) = full
|
||||
.iter()
|
||||
.position(|&b| b == b'=')
|
||||
.filter(|&i| full[..i] == *self.key())
|
||||
{
|
||||
break 'value &full[eql_idx + 1..];
|
||||
}
|
||||
|
||||
// No match!
|
||||
if len < remaining.len() {
|
||||
remaining = &remaining[len + 1..];
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
break 'outer;
|
||||
};
|
||||
|
||||
return match self {
|
||||
OptionKey::Aid | OptionKey::Err | OptionKey::Cmdline | OptionKey::CmdlineUrl => {
|
||||
Some(OptionValue::Bytes(value))
|
||||
}
|
||||
OptionKey::Cl => Click::init(value).map(OptionValue::Click),
|
||||
OptionKey::PromptKind => {
|
||||
if value.len() == 1 {
|
||||
PromptKind::init(value[0]).map(OptionValue::PromptKind)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
OptionKey::Redraw => match value {
|
||||
b"0" => Some(OptionValue::Redraw(Redraw::False)),
|
||||
b"1" => Some(OptionValue::Redraw(Redraw::True)),
|
||||
b"last" => Some(OptionValue::Redraw(Redraw::Last)),
|
||||
_ => None,
|
||||
},
|
||||
OptionKey::SpecialKey | OptionKey::ClickEvents => match value {
|
||||
b"0" => Some(OptionValue::Bool(false)),
|
||||
b"1" => Some(OptionValue::Bool(true)),
|
||||
_ => None,
|
||||
},
|
||||
// Handled above
|
||||
OptionKey::ExitCode => unreachable!(),
|
||||
};
|
||||
}
|
||||
|
||||
// Not found
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The `cl` option specifies what kind of cursor key sequences are handled
|
||||
/// by the application for click-to-move-cursor functionality.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Click {
|
||||
/// Value: "line". Allows motion within a single input line using standard
|
||||
/// left/right arrow escape sequences. Only a single left/right sequence
|
||||
/// should be emitted for double-width characters.
|
||||
Line,
|
||||
|
||||
/// Value: "m". Allows movement between different lines in the same group,
|
||||
/// but only using left/right arrow escape sequences.
|
||||
Multiple,
|
||||
|
||||
/// Value: "v". Like `multiple` but cursor up/down should be used. The
|
||||
/// terminal should be conservative when moving between lines: move the
|
||||
/// cursor left to the start of line, emit the needed up/down sequences,
|
||||
/// then move the cursor right to the clicked destination.
|
||||
ConservativeVertical,
|
||||
|
||||
/// Value: "w". Like `conservative_vertical` but specifies that there are
|
||||
/// no spurious spaces at the end of the line, and the application editor
|
||||
/// handles "smart vertical movement" (moving 2 lines up from position 20,
|
||||
/// where the intermediate line is 15 chars wide and the destination is
|
||||
/// 18 chars wide, ends at position 18).
|
||||
SmartVertical,
|
||||
}
|
||||
|
||||
impl Click {
|
||||
pub fn init(value: &[u8]) -> Option<Click> {
|
||||
if value.len() == 1 {
|
||||
match value[0] {
|
||||
b'm' => Some(Click::Multiple),
|
||||
b'v' => Some(Click::ConservativeVertical),
|
||||
b'w' => Some(Click::SmartVertical),
|
||||
_ => None,
|
||||
}
|
||||
} else if value == b"line" {
|
||||
Some(Click::Line)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptKind {
|
||||
Initial,
|
||||
Right,
|
||||
Continuation,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
impl PromptKind {
|
||||
pub fn init(c: u8) -> Option<PromptKind> {
|
||||
match c {
|
||||
b'i' => Some(PromptKind::Initial),
|
||||
b'r' => Some(PromptKind::Right),
|
||||
b'c' => Some(PromptKind::Continuation),
|
||||
b's' => Some(PromptKind::Secondary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The values for the `redraw` extension to OSC133. This was
|
||||
/// started by Kitty[1] and extended by Ghostty (the "last" option).
|
||||
///
|
||||
/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Redraw {
|
||||
/// The shell supports redrawing the full prompt and all continuations.
|
||||
/// This is the default value, it does not need to be explicitly set
|
||||
/// unless it is to reset a prior other value.
|
||||
True,
|
||||
|
||||
/// The shell does NOT support redrawing. In this case, Ghostty will NOT
|
||||
/// clear any prompt lines on resize.
|
||||
False,
|
||||
|
||||
/// The shell supports redrawing only the LAST line of the prompt.
|
||||
/// Ghostty will only clear the last line of the prompt on resize.
|
||||
///
|
||||
/// This is specifically introduced because Bash only redraws the last
|
||||
/// line. It is literally the only shell that does this and it does this
|
||||
/// because its bad and they should feel bad. Don't be like Bash.
|
||||
Last,
|
||||
}
|
||||
|
||||
/// Parse OSC 133, semantic prompts
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
|
||||
let data = parser.buffered()?;
|
||||
if data.is_empty() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// All valid cases terminate within this block. Any fallthroughs
|
||||
// are invalid.
|
||||
let action = match data[0] {
|
||||
b'A' => Action::FreshLineNewPrompt,
|
||||
b'B' => Action::EndPromptStartInput,
|
||||
b'I' => Action::EndPromptStartInputTerminateEol,
|
||||
b'C' => Action::EndInputStartOutput,
|
||||
b'D' => Action::EndCommand,
|
||||
b'N' => Action::NewCommand,
|
||||
b'P' => Action::PromptStart,
|
||||
b'L' => {
|
||||
if data.len() > 1 {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
return Some(OscCommand::SemanticPrompt(Command::init(Action::FreshLine)));
|
||||
}
|
||||
_ => {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmd = Command::init(action);
|
||||
if data.len() > 1 {
|
||||
if data[1] != b';' {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
cmd.options_unvalidated = data[2..].to_vec();
|
||||
}
|
||||
Some(OscCommand::SemanticPrompt(cmd))
|
||||
}
|
||||
|
||||
// TODO(integration): local copies of ZIG/src/os/string_encoding.zig.
|
||||
|
||||
/// Decode data that has been encoded in the same way that `bash`'s
|
||||
/// `printf %q` encodes a string and write it to the writer. If an error is
|
||||
/// returned garbage may have been written to the buffer.
|
||||
pub fn printf_q_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
|
||||
let data: &[u8] = if buf.starts_with(b"$'") {
|
||||
// Strip off `$''` quoting.
|
||||
if buf.len() < 3 || !buf.ends_with(b"'") {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
&buf[2..buf.len() - 1]
|
||||
} else if buf.starts_with(b"'") {
|
||||
// Strip off `''` quoting.
|
||||
if buf.len() < 2 || !buf.ends_with(b"'") {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
&buf[1..buf.len() - 1]
|
||||
} else {
|
||||
buf
|
||||
};
|
||||
|
||||
let mut src = 0;
|
||||
while src < data.len() {
|
||||
match data[src] {
|
||||
b'\\' => {
|
||||
if src + 1 >= data.len() {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
match data[src + 1] {
|
||||
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => writer.push(c),
|
||||
b'e' => writer.push(0x1b),
|
||||
b'n' => writer.push(b'\n'),
|
||||
b'r' => writer.push(b'\r'),
|
||||
b't' => writer.push(b'\t'),
|
||||
b'v' => writer.push(0x0b),
|
||||
_ => return Err(DecodeError),
|
||||
}
|
||||
src += 2;
|
||||
}
|
||||
c => {
|
||||
writer.push(c);
|
||||
src += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode data that has been URL percent encoded and write it to the given
|
||||
/// buffer. If an error is returned garbage may have been written.
|
||||
pub fn url_percent_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
|
||||
fn hex(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut src = 0;
|
||||
while src < buf.len() {
|
||||
match buf[src] {
|
||||
b'%' => {
|
||||
if src + 2 >= buf.len() {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
match (hex(buf[src + 1]), hex(buf[src + 2])) {
|
||||
(Some(a), Some(b)) => {
|
||||
writer.push((a << 4) | b);
|
||||
src += 3;
|
||||
}
|
||||
_ => return Err(DecodeError),
|
||||
}
|
||||
}
|
||||
c => {
|
||||
writer.push(c);
|
||||
src += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<Command> {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in input.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
match p.end(None) {
|
||||
Some(OscCommand::SemanticPrompt(c)) => Some(c),
|
||||
Some(c) => panic!("unexpected {c:?}"),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmdline(input: &str) -> (Command, Result<Vec<u8>, DecodeError>) {
|
||||
let cmd = run(input).unwrap();
|
||||
assert_eq!(cmd.action, Action::EndInputStartOutput);
|
||||
let mut w = Vec::new();
|
||||
let r = cmd.write_command_line(&mut w).map(|()| w);
|
||||
(cmd, r)
|
||||
}
|
||||
|
||||
fn cmdline_ok(input: &str, expected: &str) {
|
||||
assert_eq!(cmdline(input).1.unwrap(), expected.as_bytes());
|
||||
}
|
||||
|
||||
fn cmdline_err(input: &str) {
|
||||
assert_eq!(cmdline(input).1, Err(DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output() {
|
||||
let cmd = run("133;C").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndInputStartOutput);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_extra_contents() {
|
||||
assert!(run("133;Cextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_options() {
|
||||
let cmd = run("133;C;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndInputStartOutput);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline() {
|
||||
cmdline_ok("133;C;cmdline=echo bobr kurwa", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_3() {
|
||||
cmdline_ok("133;C;cmdline=echo bobr\\nkurwa", "echo bobr\nkurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_4() {
|
||||
cmdline_ok("133;C;cmdline=$'echo bobr kurwa'", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_5() {
|
||||
cmdline_ok("133;C;cmdline='echo bobr kurwa'", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_6() {
|
||||
cmdline_err("133;C;cmdline='echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_7() {
|
||||
cmdline_err("133;C;cmdline=$'echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_8() {
|
||||
cmdline_err("133;C;cmdline=$'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_9() {
|
||||
cmdline_ok("133;C;cmdline=", "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_1() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr kurwa", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_2() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr%20kurwa", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_3() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr%3bkurwa", "echo bobr;kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_4() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr%3kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_5() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr%kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_6() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr kurwa%20", "echo bobr kurwa ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_7() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr kurwa%2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_8() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr kurwa%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line() {
|
||||
assert_eq!(run("133;L").unwrap().action, Action::FreshLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_extra_contents() {
|
||||
// Random
|
||||
assert!(run("133;Lol").is_none());
|
||||
// Options
|
||||
assert!(run("133;L;aid=foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt() {
|
||||
let cmd = run("133;A").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_aid() {
|
||||
let cmd = run("133;A;aid=14").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.aid().unwrap(), b"14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_equals_in_aid() {
|
||||
let cmd = run("133;A;aid=a=b").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.aid().unwrap(), b"a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_cl_line() {
|
||||
let cmd = run("133;A;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_cl_m() {
|
||||
let cmd = run("133;A;cl=m").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.cl(), Some(Click::Multiple));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_invalid_cl() {
|
||||
let cmd = run("133;A;cl=invalid").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_trailing_semicolon() {
|
||||
let cmd = run("133;A;").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_bare_key() {
|
||||
let cmd = run("133;A;barekey").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_multiple_options() {
|
||||
let cmd = run("133;A;aid=foo;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_default_redraw() {
|
||||
let cmd = run("133;A").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.redraw().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_redraw_0() {
|
||||
let cmd = run("133;A;redraw=0").unwrap();
|
||||
assert_eq!(cmd.redraw(), Some(Redraw::False));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_redraw_1() {
|
||||
let cmd = run("133;A;redraw=1").unwrap();
|
||||
assert_eq!(cmd.redraw(), Some(Redraw::True));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_invalid_redraw() {
|
||||
let cmd = run("133;A;redraw=x").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.redraw().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start() {
|
||||
let cmd = run("133;P").unwrap();
|
||||
assert_eq!(cmd.action, Action::PromptStart);
|
||||
assert!(cmd.prompt_kind().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_i() {
|
||||
let cmd = run("133;P;k=i").unwrap();
|
||||
assert_eq!(cmd.action, Action::PromptStart);
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Initial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_r() {
|
||||
let cmd = run("133;P;k=r").unwrap();
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_c() {
|
||||
let cmd = run("133;P;k=c").unwrap();
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Continuation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_s() {
|
||||
let cmd = run("133;P;k=s").unwrap();
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Secondary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_invalid_k() {
|
||||
let cmd = run("133;P;k=x").unwrap();
|
||||
assert_eq!(cmd.action, Action::PromptStart);
|
||||
assert!(cmd.prompt_kind().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_extra_contents() {
|
||||
assert!(run("133;Pextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command() {
|
||||
let cmd = run("133;N").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_with_aid() {
|
||||
let cmd = run("133;N;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_with_cl_line() {
|
||||
let cmd = run("133;N;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_with_multiple_options() {
|
||||
let cmd = run("133;N;aid=foo;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_extra_contents() {
|
||||
assert!(run("133;Nextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input() {
|
||||
let cmd = run("133;B").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_extra_contents() {
|
||||
assert!(run("133;Bextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_with_options() {
|
||||
let cmd = run("133;B;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInput);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_terminate_eol() {
|
||||
let cmd = run("133;I").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_terminate_eol_extra_contents() {
|
||||
assert!(run("133;Iextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_terminate_eol_with_options() {
|
||||
let cmd = run("133;I;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command() {
|
||||
let cmd = run("133;D").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndCommand);
|
||||
assert!(cmd.exit_code().is_none());
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.err().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command_extra_contents() {
|
||||
assert!(run("133;Dextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command_with_exit_code_0() {
|
||||
let cmd = run("133;D;0").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndCommand);
|
||||
assert_eq!(cmd.exit_code(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command_with_exit_code_and_aid() {
|
||||
let cmd = run("133;D;12;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndCommand);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
assert_eq!(cmd.exit_code(), Some(12));
|
||||
}
|
||||
|
||||
fn bytes(v: Option<OptionValue<'_>>) -> Option<&[u8]> {
|
||||
match v {
|
||||
Some(OptionValue::Bytes(b)) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_aid() {
|
||||
let r = |s: &'static str| bytes(OptionKey::Aid.read(s.as_bytes()));
|
||||
assert_eq!(r("aid=test123").unwrap(), b"test123");
|
||||
assert_eq!(r("cl=line;aid=myaid;k=i").unwrap(), b"myaid");
|
||||
assert!(r("cl=line;k=i").is_none());
|
||||
assert_eq!(r("aid=").unwrap(), b"");
|
||||
assert_eq!(r("k=i;aid=last").unwrap(), b"last");
|
||||
assert_eq!(r("aid=first;k=i").unwrap(), b"first");
|
||||
assert!(r("").is_none());
|
||||
assert!(r("aid").is_none());
|
||||
assert_eq!(r(";;aid=value;;").unwrap(), b"value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_cl() {
|
||||
let r = |s: &'static str| OptionKey::Cl.read(s.as_bytes());
|
||||
assert_eq!(r("cl=line"), Some(OptionValue::Click(Click::Line)));
|
||||
assert_eq!(r("cl=m"), Some(OptionValue::Click(Click::Multiple)));
|
||||
assert_eq!(
|
||||
r("cl=v"),
|
||||
Some(OptionValue::Click(Click::ConservativeVertical))
|
||||
);
|
||||
assert_eq!(r("cl=w"), Some(OptionValue::Click(Click::SmartVertical)));
|
||||
assert!(r("cl=invalid").is_none());
|
||||
assert!(r("aid=foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_prompt_kind() {
|
||||
let r = |s: &'static str| OptionKey::PromptKind.read(s.as_bytes());
|
||||
assert_eq!(r("k=i"), Some(OptionValue::PromptKind(PromptKind::Initial)));
|
||||
assert_eq!(r("k=r"), Some(OptionValue::PromptKind(PromptKind::Right)));
|
||||
assert_eq!(
|
||||
r("k=c"),
|
||||
Some(OptionValue::PromptKind(PromptKind::Continuation))
|
||||
);
|
||||
assert_eq!(
|
||||
r("k=s"),
|
||||
Some(OptionValue::PromptKind(PromptKind::Secondary))
|
||||
);
|
||||
assert!(r("k=x").is_none());
|
||||
assert!(r("k=ii").is_none());
|
||||
assert!(r("k=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_err() {
|
||||
assert_eq!(
|
||||
bytes(OptionKey::Err.read(b"err=some_error")).unwrap(),
|
||||
b"some_error"
|
||||
);
|
||||
assert!(OptionKey::Err.read(b"aid=foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_redraw() {
|
||||
let r = |s: &'static str| OptionKey::Redraw.read(s.as_bytes());
|
||||
assert_eq!(r("redraw=1"), Some(OptionValue::Redraw(Redraw::True)));
|
||||
assert_eq!(r("redraw=0"), Some(OptionValue::Redraw(Redraw::False)));
|
||||
assert_eq!(r("redraw=last"), Some(OptionValue::Redraw(Redraw::Last)));
|
||||
assert!(r("redraw=2").is_none());
|
||||
assert!(r("redraw=10").is_none());
|
||||
assert!(r("redraw=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_special_key() {
|
||||
let r = |s: &'static str| OptionKey::SpecialKey.read(s.as_bytes());
|
||||
assert_eq!(r("special_key=1"), Some(OptionValue::Bool(true)));
|
||||
assert_eq!(r("special_key=0"), Some(OptionValue::Bool(false)));
|
||||
assert!(r("special_key=x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_click_events() {
|
||||
let r = |s: &'static str| OptionKey::ClickEvents.read(s.as_bytes());
|
||||
assert_eq!(r("click_events=1"), Some(OptionValue::Bool(true)));
|
||||
assert_eq!(r("click_events=0"), Some(OptionValue::Bool(false)));
|
||||
assert!(r("click_events=yes").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_exit_code() {
|
||||
let r = |s: &'static str| OptionKey::ExitCode.read(s.as_bytes());
|
||||
assert_eq!(r("42"), Some(OptionValue::Int(42)));
|
||||
assert_eq!(r("0"), Some(OptionValue::Int(0)));
|
||||
assert_eq!(r("-1"), Some(OptionValue::Int(-1)));
|
||||
assert!(r("abc").is_none());
|
||||
assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_encoding_extra_printf_q_and_percent() {
|
||||
let mut w = Vec::new();
|
||||
printf_q_decode(&mut w, b"bobr\\ kurwa").unwrap();
|
||||
assert_eq!(w, b"bobr kurwa");
|
||||
assert_eq!(
|
||||
printf_q_decode(&mut Vec::new(), b"bobr\\dkurwa"),
|
||||
Err(DecodeError)
|
||||
);
|
||||
assert_eq!(
|
||||
printf_q_decode(&mut Vec::new(), b"bobr kurwa\\"),
|
||||
Err(DecodeError)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user