From 0a9c48c75051fb507ec1a801ca9d0cf96fadbc48 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 3 Aug 2020 09:46:56 -0400 Subject: New ANSI escape sequence parser based on vte Reimplement utility functions from `console` crate, but with support for OSC sequences. --- src/ansi/console_tests.rs | 75 +++++++ src/ansi/iterator.rs | 487 ++++++++++++++++++++++++++++++++++++++++++++++ src/ansi/mod.rs | 184 +++++++++++++++--- src/ansi/parse.rs | 205 ------------------- src/paint.rs | 4 +- src/style.rs | 24 ++- 6 files changed, 738 insertions(+), 241 deletions(-) create mode 100644 src/ansi/console_tests.rs create mode 100644 src/ansi/iterator.rs delete mode 100644 src/ansi/parse.rs diff --git a/src/ansi/console_tests.rs b/src/ansi/console_tests.rs new file mode 100644 index 00000000..d6372316 --- /dev/null +++ b/src/ansi/console_tests.rs @@ -0,0 +1,75 @@ +// This file contains some unit tests copied from the `console` project: +// https://github.com/mitsuhiko/console +// +// The MIT License (MIT) + +// Copyright (c) 2017 Armin Ronacher + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +#[cfg(test)] +mod tests { + use console::{self, style}; + + use crate::ansi::{measure_text_width, truncate_str}; + + #[test] + fn test_text_width() { + let s = style("foo") + .red() + .on_black() + .bold() + .force_styling(true) + .to_string(); + assert_eq!(measure_text_width(&s), 3); + } + + #[test] + fn test_truncate_str() { + let s = format!("foo {}", style("bar").red().force_styling(true)); + assert_eq!( + &truncate_str(&s, 5, ""), + &format!("foo {}", style("b").red().force_styling(true)) + ); + let s = format!("foo {}", style("bar").red().force_styling(true)); + // DED: I'm changing this test assertion: delta does not move `!` inside the styled region. + // assert_eq!( + // &truncate_str(&s, 5, "!"), + // &format!("foo {}", style("!").red().force_styling(true)) + // ); + assert_eq!( + &truncate_str(&s, 5, "!"), + &format!("foo {}!", style("").red().force_styling(true)) + ); + let s = format!("foo {} baz", style("bar").red().force_styling(true)); + assert_eq!( + &truncate_str(&s, 10, "..."), + &format!("foo {}...", style("bar").red().force_styling(true)) + ); + let s = format!("foo {}", style("バー").red().force_styling(true)); + assert_eq!( + &truncate_str(&s, 5, ""), + &format!("foo {}", style("").red().force_styling(true)) + ); + let s = format!("foo {}", style("バー").red().force_styling(true)); + assert_eq!( + &truncate_str(&s, 6, ""), + &format!("foo {}", style("バ").red().force_styling(true)) + ); + } + + #[test] + fn test_truncate_str_no_ansi() { + assert_eq!(&truncate_str("foo bar", 5, ""), "foo b"); + assert_eq!(&truncate_str("foo bar", 5, "!"), "foo !"); + assert_eq!(&truncate_str("foo bar baz", 10, "..."), "foo bar..."); + } +} diff --git a/src/ansi/iterator.rs b/src/ansi/iterator.rs new file mode 100644 index 00000000..44fe49fb --- /dev/null +++ b/src/ansi/iterator.rs @@ -0,0 +1,487 @@ +use core::str::Bytes; + +use ansi_term; +use vte; + +pub struct AnsiElementIterator<'a> { + // The input bytes + bytes: Bytes<'a>, + + // The state machine + machine: vte::Parser, + + // Becomes non-None when the parser finishes parsing an ANSI sequence. + // This is never Element::Text. + element: Option, + + // Number of text bytes seen since the last element was emitted. + text_length: usize, + + // Byte offset of start of current element. + start: usize, + + // Byte offset of most rightward byte processed so far + pos: usize, +} + +struct Performer { + // Becomes non-None when the parser finishes parsing an ANSI sequence. + // This is never Element::Text. + element: Option, + + // Number of text bytes seen since the last element was emitted. + text_length: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Element { + CSI(ansi_term::Style, usize, usize), + ESC(usize, usize), + OSC(usize, usize), + Text(usize, usize), +} + +impl<'a> AnsiElementIterator<'a> { + pub fn new(s: &'a str) -> Self { + Self { + machine: vte::Parser::new(), + bytes: s.bytes(), + element: None, + text_length: 0, + start: 0, + pos: 0, + } + } + + #[allow(dead_code)] + pub fn to_vec(self) -> Vec { + self.collect() + } + + #[allow(dead_code)] + pub fn dbg(s: &str) { + for el in AnsiElementIterator::new(s) { + match el { + Element::CSI(_, i, j) => println!("CSI({}, {}, {:?})", i, j, &s[i..j]), + Element::ESC(i, j) => println!("ESC({}, {}, {:?})", i, j, &s[i..j]), + Element::OSC(i, j) => println!("OSC({}, {}, {:?})", i, j, &s[i..j]), + Element::Text(i, j) => println!("Text({}, {}, {:?})", i, j, &s[i..j]), + } + } + } +} + +impl<'a> Iterator for AnsiElementIterator<'a> { + type Item = Element; + + fn next(&mut self) -> Option { + loop { + // If the last element emitted was text, then there may be a non-text element waiting + // to be emitted. In that case we do not consume a new byte. + let byte = if self.element.is_some() { + None + } else { + self.bytes.next() + }; + if byte.is_some() || self.element.is_some() { + if let Some(byte) = byte { + let mut performer = Performer { + element: None, + text_length: 0, + }; + self.machine.advance(&mut performer, byte); + self.element = performer.element; + self.text_length += performer.text_length; + self.pos += 1; + } + if self.element.is_some() { + // There is a non-text element waiting to be emitted, but it may have preceding + // text, which must be emitted first. + if self.text_length > 0 { + let start = self.start; + self.start += self.text_length; + self.text_length = 0; + return Some(Element::Text(start, self.start)); + } + let start = self.start; + self.start = self.pos; + let element = match self.element.as_ref().unwrap() { + Element::CSI(style, _, _) => Element::CSI(*style, start, self.pos), + Element::ESC(_, _) => Element::ESC(start, self.pos), + Element::OSC(_, _) => Element::OSC(start, self.pos), + Element::Text(_, _) => unreachable!(), + }; + self.element = None; + return Some(element); + } + } else if self.text_length > 0 { + self.text_length = 0; + return Some(Element::Text(self.start, self.pos)); + } else { + return None; + } + } + } +} + +// Based on https://github.com/alacritty/vte/blob/0310be12d3007e32be614c5df94653d29fcc1a8b/examples/parselog.rs +impl vte::Perform for Performer { + fn csi_dispatch(&mut self, params: &[i64], intermediates: &[u8], ignore: bool, c: char) { + if ignore || intermediates.len() > 1 { + return; + } + + match (c, intermediates.get(0)) { + ('m', None) => { + if params.is_empty() { + // Attr::Reset; + } else { + self.element = Some(Element::CSI( + ansi_term_style_from_sgr_parameters(params), + 0, + 0, + )); + } + } + _ => {} + } + } + + fn print(&mut self, c: char) { + self.text_length += c.len_utf8(); + } + + fn execute(&mut self, byte: u8) { + // E.g. '\n' + if byte < 128 { + self.text_length += 1; + } + } + + fn hook(&mut self, _params: &[i64], _intermediates: &[u8], _ignore: bool, _c: char) {} + + fn put(&mut self, _byte: u8) {} + + fn unhook(&mut self) {} + + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) { + self.element = Some(Element::OSC(0, 0)); + } + + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) { + self.element = Some(Element::ESC(0, 0)); + } +} + +// Based on https://github.com/alacritty/alacritty/blob/57c4ac9145a20fb1ae9a21102503458d3da06c7b/alacritty_terminal/src/ansi.rs#L1168 +fn ansi_term_style_from_sgr_parameters(parameters: &[i64]) -> ansi_term::Style { + let mut i = 0; + let mut style = ansi_term::Style::new(); + loop { + if i >= parameters.len() { + break; + } + + match parameters[i] { + // 0 => Some(Attr::Reset), + 1 => style.is_bold = true, + 2 => style.is_dimmed = true, + 3 => style.is_italic = true, + 4 => style.is_underline = true, + 5 => style.is_blink = true, // blink slow + 6 => style.is_blink = true, // blink fast + 7 => style.is_reverse = true, + 8 => style.is_hidden = true, + 9 => style.is_strikethrough = true, + // 21 => Some(Attr::CancelBold), + // 22 => Some(Attr::CancelBoldDim), + // 23 => Some(Attr::CancelItalic), + // 24 => Some(Attr::CancelUnderline), + // 25 => Some(Attr::CancelBlink), + // 27 => Some(Attr::CancelReverse), + // 28 => Some(Attr::CancelHidden), + // 29 => Some(Attr::CancelStrike), + 30 => style.foreground = Some(ansi_term::Color::Black), + 31 => style.foreground = Some(ansi_term::Color::Red), + 32 => style.foreground = Some(ansi_term::Color::Green), + 33 => style.foreground = Some(ansi_term::Color::Yellow), + 34 => style.foreground = Some(ansi_term::Color::Blue), + 35 => style.foreground = Some(ansi_term::Color::Purple), + 36 => style.foreground = Some(ansi_term::Color::Cyan), + 37 => style.foreground = Some(ansi_term::Color::White), + 38 => { + let mut start = 0; + if let Some(color) = parse_sgr_color(¶meters[i..], &mut start) { + i += start; + style.foreground = Some(color); + } + } + // 39 => Some(Attr::Foreground(Color::Named(NamedColor::Foreground))), + 40 => style.background = Some(ansi_term::Color::Black), + 41 => style.background = Some(ansi_term::Color::Red), + 42 => style.background = Some(ansi_term::Color::Green), + 43 => style.background = Some(ansi_term::Color::Yellow), + 44 => style.background = Some(ansi_term::Color::Blue), + 45 => style.background = Some(ansi_term::Color::Purple), + 46 => style.background = Some(ansi_term::Color::Cyan), + 47 => style.background = Some(ansi_term::Color::White), + 48 => { + let mut start = 0; + if let Some(color) = parse_sgr_color(¶meters[i..], &mut start) { + i += start; + style.background = Some(color); + } + } + // 49 => Some(Attr::Background(Color::Named(NamedColor::Background))), + // "bright" colors. ansi_term doesn't offer a way to emit them as, e.g., 90m; instead + // that would be 38;5;8. + 90 => style.foreground = Some(ansi_term::Color::Fixed(8)), + 91 => style.foreground = Some(ansi_term::Color::Fixed(9)), + 92 => style.foreground = Some(ansi_term::Color::Fixed(10)), + 93 => style.foreground = Some(ansi_term::Color::Fixed(11)), + 94 => style.foreground = Some(ansi_term::Color::Fixed(12)), + 95 => style.foreground = Some(ansi_term::Color::Fixed(13)), + 96 => style.foreground = Some(ansi_term::Color::Fixed(14)), + 97 => style.foreground = Some(ansi_term::Color::Fixed(15)), + 100 => style.background = Some(ansi_term::Color::Fixed(8)), + 101 => style.background = Some(ansi_term::Color::Fixed(9)), + 102 => style.background = Some(ansi_term::Color::Fixed(10)), + 103 => style.background = Some(ansi_term::Color::Fixed(11)), + 104 => style.background = Some(ansi_term::Color::Fixed(12)), + 105 => style.background = Some(ansi_term::Color::Fixed(13)), + 106 => style.background = Some(ansi_term::Color::Fixed(14)), + 107 => style.background = Some(ansi_term::Color::Fixed(15)), + _ => {} + }; + i += 1; + } + style +} + +// Based on https://github.com/alacritty/alacritty/blob/57c4ac9145a20fb1ae9a21102503458d3da06c7b/alacritty_terminal/src/ansi.rs#L1258 +fn parse_sgr_color(attrs: &[i64], i: &mut usize) -> Option { + if attrs.len() < 2 { + return None; + } + + match attrs[*i + 1] { + 2 => { + // RGB color spec. + if attrs.len() < 5 { + // debug!("Expected RGB color spec; got {:?}", attrs); + return None; + } + + let r = attrs[*i + 2]; + let g = attrs[*i + 3]; + let b = attrs[*i + 4]; + + *i += 4; + + let range = 0..256; + if !range.contains(&r) || !range.contains(&g) || !range.contains(&b) { + // debug!("Invalid RGB color spec: ({}, {}, {})", r, g, b); + return None; + } + + Some(ansi_term::Color::RGB(r as u8, g as u8, b as u8)) + } + 5 => { + if attrs.len() < 3 { + // debug!("Expected color index; got {:?}", attrs); + None + } else { + *i += 2; + let idx = attrs[*i]; + match idx { + 0..=255 => Some(ansi_term::Color::Fixed(idx as u8)), + _ => { + // debug!("Invalid color index: {}", idx); + None + } + } + } + } + _ => { + // debug!("Unexpected color attr: {}", attrs[*i + 1]); + None + } + } +} + +#[cfg(test)] +mod tests { + + use super::{AnsiElementIterator, Element}; + use crate::style; + + #[test] + fn test_iterator_parse_git_style_strings() { + for (git_style_string, git_output) in &*style::tests::GIT_STYLE_STRING_EXAMPLES { + let mut it = AnsiElementIterator::new(git_output); + + if *git_style_string == "normal" { + // This one has a different pattern + assert!( + matches!(it.next().unwrap(), Element::CSI(s, _, _) if s == ansi_term::Style::default()) + ); + assert!( + matches!(it.next().unwrap(), Element::Text(i, j) if &git_output[i..j] == "text") + ); + assert!( + matches!(it.next().unwrap(), Element::CSI(s, _, _) if s == ansi_term::Style::default()) + ); + continue; + } + + // First element should be a style + let element = it.next().unwrap(); + match element { + Element::CSI(style, _, _) => assert!(style::ansi_term_style_equality( + style, + style::Style::from_git_str(git_style_string).ansi_term_style + )), + _ => assert!(false), + } + + // Second element should be text: "+" + assert!(matches!( + it.next().unwrap(), + Element::Text(i, j) if &git_output[i..j] == "+")); + + // Third element is the reset style + assert!(matches!( + it.next().unwrap(), + Element::CSI(s, _, _) if s == ansi_term::Style::default())); + + // Fourth element should be a style + let element = it.next().unwrap(); + match element { + Element::CSI(style, _, _) => assert!(style::ansi_term_style_equality( + style, + style::Style::from_git_str(git_style_string).ansi_term_style + )), + _ => assert!(false), + } + + // Fifth element should be text: "text" + assert!(matches!( + it.next().unwrap(), + Element::Text(i, j) if &git_output[i..j] == "text")); + + // Sixth element is the reset style + assert!(matches!( + it.next().unwrap(), + Element::CSI(s, _, _) if s == ansi_term::Style::default())); + + assert!(matches!( + it.next().unwrap(), + Element::Text(i, j) if &git_output[i..j] == "\n")); + + assert!(it.next().is_none()); + } + } + + #[test] + fn test_iterator_1() { + let minus_line = "\x1b[31m0123\x1b[m\n"; + assert_eq!( + AnsiElementIterator::new(minus_line).to_vec(), + vec![ + Element::CSI( + ansi_term::Style { + foreground: Some(ansi_term::Color::Red), + ..ansi_term::Style::default() + }, + 0, + 5 + ), + Element::Text(5, 9), + Element::CSI(ansi_term::Style::default(), 9, 12), + Element::Text(12, 13), + ] + ); + assert_eq!("0123", &minus_line[5..9]); + assert_eq!("\n", &minus_line[12..13]); + } + + #[test] + fn test_iterator_2() { + let minus_line = "\x1b[31m0123\x1b[m456\n"; + assert_eq!( + AnsiElementIterator::new(minus_line).to_vec(), + vec![ + Element::CSI( + ansi_term::Style { + foreground: Some(ansi_term::Color::Red), + ..ansi_term::Style::default() + }, + 0, + 5 + ), + Element::Text(5, 9), + Element::CSI(ansi_term::Style::default(), 9, 12), + Element::Text(12, 16), + ] + ); + assert_eq!("0123", &minus_line[5..9]); + assert_eq!("456\n", &minus_line[12..16]); + } + + #[test] + fn test_iterator_styled_non_ascii() { + let s = "\x1b[31mバー\x1b[0m"; + assert_eq!( + AnsiElementIterator::new(s).to_vec(), + vec![ + Element::CSI( + ansi_term::Style { + foreground: Some(ansi_term::Color::Red), + ..ansi_term::Style::default() + }, + 0, + 5 + ), + Element::Text(5, 11), + Element::CSI(ansi_term::Style::default(), 11, 15), + ] + ); + assert_eq!("バー", &s[5..11]); + } + + #[test] + fn test_iterator_osc_hyperlinks_styled_non_ascii() { + let s = "\x1b[38;5;4m\x1b]8;;file:///Users/dan/src/delta/src/ansi/mod.rs\x1b\\src/ansi/modバー.rs\x1b]8;;\x1b\\\x1b[0m\n"; + assert_eq!(&s[0..9], "\x1b[38;5;4m"); + assert_eq!( + &s[9..58], + "\x1b]8;;file:///Users/dan/src/delta/src/ansi/mod.rs\x1b" + ); + assert_eq!(&s[58..59], "\\"); + assert_eq!(&s[59..80], "src/ansi/modバー.rs"); + assert_eq!(&s[80..86], "\x1b]8;;\x1b"); + assert_eq!(&s[86..87], "\\"); + assert_eq!(&s[87..91], "\x1b[0m"); + assert_eq!(&s[91..92], "\n"); + assert_eq!( + AnsiElementIterator::new(s).to_vec(), + vec![ + Element::CSI( + ansi_term::Style { + foreground: Some(ansi_term::Color::Fixed(4)), + ..ansi_term::Style::default() + }, + 0, + 9 + ), + Element::OSC(9, 58), + Element::ESC(58, 59), + Element::Text(59, 80), + Element::OSC(80, 86), + Element::ESC(86, 87), + Element::CSI(ansi_term::Style::default(), 87, 91), + Element::Text(91, 92), + ] + ); + } +} diff --git a/src/ansi/mod.rs b/src/ansi/mod.rs index 158b562c..e2fdaf7f 100644 --- a/src/ansi/mod.rs +++ b/src/ansi/mod.rs @@ -1,18 +1,79 @@ -pub mod parse; +mod console_tests; +mod iterator; -use std::cmp::min; +use std::borrow::Cow; -use console; use itertools::Itertools; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use iterator::{AnsiElementIterator, Element}; pub const ANSI_CSI_CLEAR_TO_EOL: &str = "\x1b[0K"; pub const ANSI_CSI_CLEAR_TO_BOL: &str = "\x1b[1K"; pub const ANSI_SGR_RESET: &str = "\x1b[0m"; -pub fn string_starts_with_ansi_escape_sequence(s: &str) -> bool { - console::AnsiCodeIterator::new(s) +pub fn strip_ansi_codes(s: &str) -> String { + strip_ansi_codes_from_strings_iterator(ansi_strings_iterator(s)) +} + +pub fn measure_text_width(s: &str) -> usize { + // TODO: how should e.g. '\n' be handled? + strip_ansi_codes(s).width() +} + +/// Truncate string such that `tail` is present as a suffix, preceded by as much of `s` as can be +/// displayed in the requested width. +// Return string constructed as follows: +// 1. `display_width` characters are available. If the string fits, return it. +// +// 2. Contribute graphemes and ANSI escape sequences from `tail` until either (1) `tail` is +// exhausted, or (2) the display width of the result would exceed `display_width`. +// +// 3. If tail was exhausted, then contribute graphemes and ANSI escape sequences from `s` until the +// display_width of the result would exceed `display_width`. +pub fn truncate_str<'a, 'b>(s: &'a str, display_width: usize, tail: &'b str) -> Cow<'a, str> { + let items = ansi_strings_iterator(s).collect::>(); + let width = strip_ansi_codes_from_strings_iterator(items.iter().map(|el| *el)).width(); + if width <= display_width { + return Cow::from(s); + } + let result_tail = if !tail.is_empty() { + truncate_str(tail, display_width, "").to_string() + } else { + String::new() + }; + let mut used = measure_text_width(&result_tail); + let mut result = String::new(); + for (t, is_ansi) in items { + if !is_ansi { + for g in t.graphemes(true) { + let w = g.width(); + if used + w > display_width { + break; + } + result.push_str(g); + used += w; + } + } else { + result.push_str(t); + } + } + + return Cow::from(format!("{}{}", result, result_tail)); +} + +pub fn parse_first_style(s: &str) -> Option { + AnsiElementIterator::new(s).find_map(|el| match el { + Element::CSI(style, _, _) => Some(style), + _ => None, + }) +} + +pub fn string_starts_with_ansi_style_sequence(s: &str) -> bool { + AnsiElementIterator::new(s) .nth(0) - .map(|(_, is_ansi)| is_ansi) + .map(|el| matches!(el, Element::CSI(_, _, _))) .unwrap_or(false) } @@ -20,36 +81,111 @@ pub fn string_starts_with_ansi_escape_sequence(s: &str) -> bool { /// counts bytes in non-ANSI-escape-sequence content only. All ANSI escape sequences in the /// original string are preserved. pub fn ansi_preserving_slice(s: &str, start: usize) -> String { - console::AnsiCodeIterator::new(s) - .scan(0, |i, (substring, is_ansi)| { - // i is the index in non-ANSI-escape-sequence content. - let substring_slice = if is_ansi || *i > start { - substring - } else { - &substring[min(substring.len(), start - *i)..] - }; - if !is_ansi { - *i += substring.len(); - } - Some(substring_slice) + AnsiElementIterator::new(s) + .scan(0, |index, element| { + // `index` is the index in non-ANSI-escape-sequence content. + Some(match element { + Element::CSI(_, a, b) => &s[a..b], + Element::ESC(a, b) => &s[a..b], + Element::OSC(a, b) => &s[a..b], + Element::Text(a, b) => { + let i = *index; + *index += b - a; + if *index <= start { + // This text segment ends before start, so contributes no bytes. + "" + } else if i > start { + // This section starts after `start`, so contributes all its bytes. + &s[a..b] + } else { + // This section contributes those bytes that are >= start + &s[(a + start - i)..b] + } + } + }) }) .join("") } +fn ansi_strings_iterator(s: &str) -> impl Iterator { + AnsiElementIterator::new(s).map(move |el| match el { + Element::CSI(_, i, j) => (&s[i..j], true), + Element::ESC(i, j) => (&s[i..j], true), + Element::OSC(i, j) => (&s[i..j], true), + Element::Text(i, j) => (&s[i..j], false), + }) +} + +fn strip_ansi_codes_from_strings_iterator<'a>( + strings: impl Iterator, +) -> String { + strings + .filter_map(|(el, is_ansi)| if !is_ansi { Some(el) } else { None }) + .join("") +} + #[cfg(test)] mod tests { - use crate::ansi::ansi_preserving_slice; - use crate::ansi::string_starts_with_ansi_escape_sequence; + use super::{ + ansi_preserving_slice, measure_text_width, parse_first_style, + string_starts_with_ansi_style_sequence, strip_ansi_codes, + }; + + #[test] + fn test_strip_ansi_codes() { + for s in &["src/ansi/mod.rs", "バー", "src/ansi/modバー.rs"] { + assert_eq!(strip_ansi_codes(s), *s); + } + assert_eq!(strip_ansi_codes("\x1b[31mバー\x1b[0m"), "バー"); + } + + #[test] + fn test_measure_text_width() { + assert_eq!(measure_text_width("src/ansi/mod.rs"), 15); + assert_eq!(measure_text_width("バー"), 4); + assert_eq!(measure_text_width("src/ansi/modバー.rs"), 19); + assert_eq!(measure_text_width("\x1b[31mバー\x1b[0m"), 4); + assert_eq!(measure_text_width("a\nb\n"), 2); + } + + #[test] + fn test_strip_ansi_codes_osc_hyperlink() { + assert_eq!(strip_ansi_codes("\x1b[38;5;4m\x1b]8;;file:///Users/dan/src/delta/src/ansi/mod.rs\x1b\\src/ansi/mod.rs\x1b]8;;\x1b\\\x1b[0m\n"), + "src/ansi/mod.rs\n"); + } + + #[test] + fn test_measure_text_width_osc_hyperlink() { + assert_eq!(measure_text_width("\x1b[38;5;4m\x1b]8;;file:///Users/dan/src/delta/src/ansi/mod.rs\x1b\\src/ansi/mod.rs\x1b]8;;\x1b\\\x1b[0m"), + measure_text_width("src/ansi/mod.rs")); + } + + #[test] + fn test_measure_text_width_osc_hyperlink_non_ascii() { + assert_eq!(measure_text_width("\x1b[38;5;4m\x1b]8;;file:///Users/dan/src/delta/src/ansi/mod.rs\x1b\\src/ansi/modバー.rs\x1b]8;;\x1b\\\x1b[0m"), + measure_text_width("src/ansi/modバー.rs")); + } + + #[test] + fn test_parse_first_style() { + let minus_line_from_unconfigured_git = "\x1b[31m-____\x1b[m\n"; + let style = parse_first_style(minus_line_from_unconfigured_git); + let expected_style = ansi_term::Style { + foreground: Some(ansi_term::Color::Red), + ..ansi_term::Style::default() + }; + assert_eq!(Some(expected_style), style); + } #[test] fn test_string_starts_with_ansi_escape_sequence() { - assert!(!string_starts_with_ansi_escape_sequence("")); - assert!(!string_starts_with_ansi_escape_sequence("-")); - assert!(string_starts_with_ansi_escape_sequence( + assert!(!string_starts_with_ansi_style_sequence("")); + assert!(!string_starts_with_ansi_style_sequence("-")); + assert!(string_starts_with_ansi_style_sequence( "\x1b[31m-XXX\x1b[m\n" )); - assert!(string_starts_with_ansi_escape_sequence("\x1b[32m+XXX")); + assert!(string_starts_with_ansi_style_sequence("\x1b[32m+XXX")); } #[test] diff --git a/src/ansi/parse.rs b/src/ansi/parse.rs deleted file mode 100644 index 94d902c4..00000000 --- a/src/ansi/parse.rs +++ /dev/null @@ -1,205 +0,0 @@ -use ansi_term; -use vte; - -pub fn parse_first_style(s: &str) -> Option { - let mut machine = vte::Parser::new(); - let mut performer = Performer { style: None }; - for b in s.bytes() { - if performer.style.is_some() { - return performer.style; - } - machine.advance(&mut performer, b) - } - None -} - -struct Performer { - style: Option, -} - -// Based on https://github.com/alacritty/vte/blob/0310be12d3007e32be614c5df94653d29fcc1a8b/examples/parselog.rs -impl vte::Perform for Performer { - fn csi_dispatch(&mut self, params: &[i64], intermediates: &[u8], ignore: bool, c: char) { - if ignore || intermediates.len() > 1 { - return; - } - - match (c, intermediates.get(0)) { - ('m', None) => { - if params.is_empty() { - // Attr::Reset; - } else { - self.style = Some(ansi_term_style_from_sgr_parameters(params)) - } - } - _ => {} - } - } - - fn print(&mut self, _c: char) {} - - fn execute(&mut self, _byte: u8) {} - - fn hook(&mut self, _params: &[i64], _intermediates: &[u8], _ignore: bool, _c: char) {} - - fn put(&mut self, _byte: u8) {} - - fn unhook(&mut self) {} - - fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} - - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} -} - -// Based on https://github.com/alacritty/alacritty/blob/57c4ac9145a20fb1ae9a21102503458d3da06c7b/alacritty_terminal/src/ansi.rs#L1168 -fn ansi_term_style_from_sgr_parameters(parameters: &[i64]) -> ansi_term::Style { - let mut i = 0; - let mut style = ansi_term::Style::new(); - loop { - if i >= parameters.len() { - break; - } - - match parameters[i] { - // 0 => Some(Attr::Reset), - 1 => style.is_bold = true, - 2 => style.is_dimmed = true, - 3 => style.is_italic = true, - 4 => style.is_underline = true, - 5 => style.is_blink = true, // blink slow - 6 => style.is_blink = true, // blink fast - 7 => style.is_reverse = true, - 8 => style.is_hidden = true, - 9 => style.is_strikethrough = true, - // 21 => Some(Attr::CancelBold), - // 22 => Some(Attr::CancelBoldDim), - // 23 => Some(Attr::CancelItalic), - // 24 => Some(Attr::CancelUnderline), - // 25 => Some(Attr::CancelBlink), - // 27 => Some(Attr::CancelReverse), - // 28 => Some(Attr::CancelHidden), - // 29 => Some(Attr::CancelStrike), - 30 => style.foreground = Some(ansi_term::Color::Black), - 31 => style.foreground = Some(ansi_term::Color::Red), - 32 => style.foreground = Some(ansi_term::Color::Green), - 33 => style.foreground = Some(ansi_term::Color::Yellow), - 34 => style.foreground = Some(ansi_term::Color::Blue), - 35 => style.foreground = Some(ansi_term::Color::Purple), - 36 => style.foreground = Some(ansi_term::Color::Cyan), - 37 => style.foreground = Some(ansi_term::Color::White), - 38 => { - let mut start = 0; - if let Some(color) = parse_sgr_color(¶meters[i..], &mut start) { - i += start; - style.foreground = Some(color); - } - } - // 39 => Some(Attr::Foreground(Color::Named(NamedColor::Foreground))), - 40 => style.background = Some(ansi_term::Color::Black), - 41 => style.background = Some(ansi_term::Color::Red), - 42 => style.background = Some(ansi_term::Color::Green), - 43 => style.background = Some(ansi_term::Color::Yellow), - 44 => style.background = Some(ansi_term::Color::Blue), - 45 => style.background = Some(ansi_term::Color::Purple), - 46 => style.background = Some(ansi_term::Color::Cyan), - 47 => style.background = Some(ansi_term::Color::White), - 48 => { - let mut start = 0; - if let Some(color) = parse_sgr_color(¶meters[i..], &mut start) { - i += start; - style.background = Some(color); - } - } - // 49 => Some(Attr::Background(Color::Named(NamedColor::Background))), - // "bright" colors. ansi_term doesn't offer a way to emit them as, e.g., 90m; instead - // that would be 38;5;8. - 90 => style.foreground = Some(ansi_term::Color::Fixed(8)), - 91 => style.foreground = Some(ansi_term::Color::Fixed(9)), - 92 => style.foreground = Some(ansi_term::Color::Fixed(10)), - 93 => style.foreground = Some(ansi_term::Color::Fixed(11)), - 94 => style.foreground = Some(ansi_term::Color::Fixed(12)), - 95 => style.foreground = Some(ansi_term::Color::Fixed(13)), - 96 => style.foreground = Some(ansi_term::Color::Fixed(14)), - 97 => style.foreground = Some(ansi_term::Color::Fixed(15)), - 100 => style.background = Some(ansi_term::Color::Fixed(8)), - 101 => style.background = Some(ansi_term::Color::Fixed(9)), - 102 => style.background = Some(ansi_term::Color::Fixed(10)), - 103 => style.background = Some(ansi_term::Color::Fixed(11)), - 104 => style.background = Some(ansi_term::Color::Fixed(12)), - 105 => style.background = Some(ansi_term::Color::Fixed(13)), - 106 => style.background = Some(ansi_term::Color::Fixed(14)), - 107 => style.background = Some(ansi_term::Color::Fixed(15)), - _ => {} - }; - i += 1; - } - style -} - -// Based on https://github.com/alacritty/alacritty/blob/57c4ac9145a20fb1ae9a21102503458d3da06c7b/alacritty_terminal/src/ansi.rs#L1258 -fn parse_sgr_color(attrs: &[i64], i: &mut usize) -> Option { - if attrs.len() < 2 { - return None; - } - - match attrs[*i + 1] { - 2 => { - // RGB color spec. - if attrs.len() < 5 { - // debug!("Expected RGB color spec; got {:?}", attrs); - return None; - } - - let r = attrs[*i + 2]; - let g = attrs[*i + 3]; - let b = attrs[*i + 4]; - - *i += 4; - - let range = 0..256; - if !range.contains(&r) || !range.contains(&g) || !range.contains(&b) { - // debug!("Invalid RGB color spec: ({}, {}, {})", r, g, b); - return None; - } - - Some(ansi_term::Color::RGB(r as u8, g as u8, b as u8)) - } - 5 => { - if attrs.len() < 3 { - // debug!("Expected color index; got {:?}", attrs); - None - } else { - *i += 2; - let idx = attrs[*i]; - match idx { - 0..=255 => Some(ansi_term::Color::Fixed(idx as u8)), - _ => { - // debug!("Invalid color index: {}", idx); - None - } - } - } - } - _ => { - // debug!("Unexpected color attr: {}", attrs[*i + 1]); - None - } - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn test_parse_first_style() { - let minus_line_from_unconfigured_git = "\x1b[31m-____\x1b[m\n"; - let style = parse_first_style(minus_line_from_unconfigured_git); - let expected_style = ansi_term::Style { - foreground: Some(ansi_term::Color::Red), - ..ansi_term::Style::default() - }; - assert_eq!(Some(expected_style), style); - } -} diff --git a/src/paint.rs b/src/paint.rs index 687b4f92..d31868c0 100644 --- a/src/paint.rs +++ b/src/paint.rs @@ -306,7 +306,7 @@ impl<'a> Painter<'a> { State::HunkMinus(None) => (config.minus_style, config.minus_non_emph_style), State::HunkMinus(Some(raw_line)) => { // TODO: This is the second time we are parsing the ANSI sequences - if let Some(ansi_term_style) = ansi::parse::parse_first_style(raw_line) { + if let Some(ansi_term_style) = ansi::parse_first_style(raw_line) { let style = Style { ansi_term_style, ..Style::new() @@ -320,7 +320,7 @@ impl<'a> Painter<'a> { State::HunkPlus(None) => (config.plus_style, config.plus_non_emph_style), State::HunkPlus(Some(raw_line)) => { // TODO: This is the second time we are parsing the ANSI sequences - if let Some(ansi_term_style) = ansi::parse::parse_first_style(raw_line) { + if let Some(ansi_term_style) = ansi::parse_first_style(raw_line) { let style = Style { ansi_term_style, ..Style::new() diff --git a/src/style.rs b/src/style.rs index 5eae52ab..7bf56c71 100644 --- a/src/style.rs +++ b/src/style.rs @@ -88,7 +88,7 @@ impl Style { } pub fn is_applied_to(&self, s: &str) -> bool { - match ansi::parse::parse_first_style(s) { + match ansi::parse_first_style(s) { Some(parsed_style) => ansi_term_style_equality(parsed_style, self.ansi_term_style), None => false, } @@ -143,7 +143,7 @@ impl Style { } } -fn ansi_term_style_equality(a: ansi_term::Style, b: ansi_term::Style) -> bool { +pub fn ansi_term_style_equality(a: ansi_term::Style, b: ansi_term::Style) -> bool { let a_attrs = ansi_term::Style { foreground: None, background: None, @@ -203,7 +203,7 @@ lazy_static! { } pub fn line_has_style_other_than<'a>(line: &str, styles: impl Iterator) -> bool { - if !ansi::string_starts_with_ansi_escape_sequence(line) { + if !ansi::string_starts_with_ansi_style_sequence(line) { return false; } for style in styles { @@ -215,7 +215,7 @@ pub fn line_has_style_other_than<'a>(line: &str, styles: impl Iterator = vec![ // "\x1b[32m+\x1b[m\x1b[32mtext\x1b[m\n" ("0", "\x1b[30m+\x1b[m\x1b[30mtext\x1b[m\n"), ("black", "\x1b[30m+\x1b[m\x1b[30mtext\x1b[m\n"), @@ -253,11 +252,16 @@ mod tests { ("bold #aabbcc ul 19 strike" , "\x1b[1;4;9;38;2;170;187;204;48;5;19m+\x1b[m\x1b[1;4;9;38;2;170;187;204;48;5;19mtext\x1b[m\n"), ("bold 19 ul #aabbcc strike" , "\x1b[1;4;9;38;5;19;48;2;170;187;204m+\x1b[m\x1b[1;4;9;38;5;19;48;2;170;187;204mtext\x1b[m\n"), ("bold 0 ul #aabbcc strike", "\x1b[1;4;9;30;48;2;170;187;204m+\x1b[m\x1b[1;4;9;30;48;2;170;187;204mtext\x1b[m\n"), - (r##"black "#ddeeff""##, "\x1b[30;48;2;221;238;255m+\x1b[m\x1b[30;48;2;221;238;255m .map(|(_, is_ansi)| is_ansi)\x1b[m\n"), + (r##"black "#ddeeff""##, "\x1b[30;48;2;221;238;255m+\x1b[m\x1b[30;48;2;221;238;255mtext\x1b[m\n"), ("brightred", "\x1b[91m+\x1b[m\x1b[91mtext\x1b[m\n"), - ("normal", "+\x1b[mtext\x1b[m\n"), + ("normal", "\x1b[mtext\x1b[m\n"), ("blink", "\x1b[5m+\x1b[m\x1b[5mtext\x1b[m\n"), - ] { + ]; + } + + #[test] + fn test_parse_git_style_string_and_ansi_code_iterator() { + for (git_style_string, git_output) in &*GIT_STYLE_STRING_EXAMPLES { assert!(Style::from_git_str(git_style_string).is_applied_to(git_output)); } } -- cgit v1.2.3