From 29698dfdd74a9eb6e259a244a86a14a2de68474b Mon Sep 17 00:00:00 2001 From: Adrian Benavides Date: Fri, 8 Mar 2024 09:39:16 +0100 Subject: [PATCH] feat(rust): add syntax highlighting for command's fenced code blocks --- Cargo.lock | 15 ++ .../rust/ockam/ockam_command/Cargo.toml | 4 +- .../rust/ockam/ockam_command/src/docs.rs | 197 ++++++++++++------ 3 files changed, 154 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 316fa1a6164..f3b3c15d3c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4365,6 +4365,7 @@ dependencies = [ "opentelemetry-otlp", "pem-rfc7468", "proptest", + "r3bl_ansi_color", "r3bl_rs_utils_core", "r3bl_tui", "r3bl_tuify", @@ -4383,6 +4384,7 @@ dependencies = [ "strip-ansi-escapes", "syntect", "tempfile", + "termbg", "thiserror", "time", "tiny_http", @@ -7022,6 +7024,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "termbg" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c12e6e0bf9bc6ac887681aeddabfcc5dbdea20d3f43d6d18f237c34fe942dde" +dependencies = [ + "async-std", + "crossterm", + "is-terminal", + "thiserror", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/implementations/rust/ockam/ockam_command/Cargo.toml b/implementations/rust/ockam/ockam_command/Cargo.toml index 845611bd025..a2a6442071d 100644 --- a/implementations/rust/ockam/ockam_command/Cargo.toml +++ b/implementations/rust/ockam/ockam_command/Cargo.toml @@ -40,7 +40,7 @@ dockerfile = "../../../../tools/cross/Cross.Dockerfile.armv7" # `../ockam`) and the `ockam` binary (in `./src/bin/ockam.rs`). I won't # enumerate them here, but an example: `rustdoc` will try to place the docs for # both of these in the same path, without realizing it, which may result in one -# overwriting the other) +# overwriting the other # # Anyway a result, we disable them for the binary crate, which is just a single # file (`src/bin/ockam.rs`) which contains a single function call into @@ -86,6 +86,7 @@ open = "5.1.2" opentelemetry = { version = "0.22.0", features = ["metrics", "trace"] } opentelemetry-otlp = { version = "0.15.0", features = ["metrics", "tls", "logs", "trace"], default-features = false } pem-rfc7468 = { version = "0.7.0", features = ["std"] } +r3bl_ansi_color = "0.6.9" r3bl_rs_utils_core = "0.9.12" r3bl_tui = "0.5.2" r3bl_tuify = "0.1.25" @@ -103,6 +104,7 @@ serde_yaml = "0.9" shellexpand = { version = "3.1.0", default-features = false, features = ["base-0"] } strip-ansi-escapes = "0.2.0" syntect = { version = "5.2.0", default-features = false, features = ["default-syntaxes", "regex-onig"] } +termbg = "0" thiserror = "1" time = { version = "0.3", default-features = false, features = ["std", "local-offset"] } tiny_http = "0.12.0" diff --git a/implementations/rust/ockam/ockam_command/src/docs.rs b/implementations/rust/ockam/ockam_command/src/docs.rs index 3649516b056..10d8f7fda64 100644 --- a/implementations/rust/ockam/ockam_command/src/docs.rs +++ b/implementations/rust/ockam/ockam_command/src/docs.rs @@ -1,13 +1,14 @@ -use crate::terminal::TerminalBackground; use colorful::Colorful; use ockam_core::env::get_env_with_default; use once_cell::sync::Lazy; +use r3bl_ansi_color::{AnsiStyledText, Color, Style as StyleAnsi}; +use std::time::Duration; use syntect::{ easy::HighlightLines, - highlighting::{Style, Theme, ThemeSet}, + highlighting::{Style, Theme as SyntectTheme, ThemeSet}, parsing::Regex, parsing::SyntaxSet, - util::{as_24_bit_terminal_escaped, LinesWithEndings}, + util::LinesWithEndings, }; const FOOTER: &str = " @@ -26,15 +27,25 @@ discord channel https://discord.ockam.io static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); static HEADER_RE: Lazy = Lazy::new(|| Regex::new("^[A-Za-z][A-Za-z0-9 ]+:$".into())); -static THEME: Lazy> = Lazy::new(|| { - let theme_name = match TerminalBackground::detect_background_color() { - TerminalBackground::Light => "base16-ocean.light", - TerminalBackground::Dark => "base16-ocean.dark", - TerminalBackground::Unknown => return None, - }; +static THEME: Lazy> = Lazy::new(|| { let mut theme_set = ThemeSet::load_defaults(); - let theme = theme_set.themes.remove(theme_name).unwrap(); - Some(theme) + match termbg::theme(Duration::from_millis(100)) { + Ok(termbg::Theme::Light) => theme_set.themes.remove("base16-ocean.light"), + Ok(termbg::Theme::Dark) => theme_set.themes.remove("base16-ocean.dark"), + Err(_) => None, + } +}); +static DEFAULT_THEME: Lazy = Lazy::new(|| { + let mut theme_set = ThemeSet::load_defaults(); + theme_set.themes.remove("base16-ocean.dark").unwrap_or( + theme_set.themes.remove("base16-ocean.light").unwrap_or( + theme_set + .themes + .pop_first() + .map(|(_, theme)| theme) + .unwrap_or_default(), + ), + ) }); fn is_markdown() -> bool { @@ -74,7 +85,7 @@ pub(crate) fn after_help(text: &str) -> &'static str { /// Render the string if the document should be displayed in a terminal /// Otherwise, if it is a Markdown document just return a static string -pub(crate) fn render(body: &str) -> &'static str { +fn render(body: &str) -> &'static str { if is_markdown() { Box::leak(body.to_string().into_boxed_str()) } else { @@ -86,76 +97,88 @@ pub(crate) fn render(body: &str) -> &'static str { /// Use a shell syntax highlighter to render the fenced code blocks in terminals fn process_terminal_docs(input: String) -> String { let mut output: Vec = Vec::new(); + let mut code_highlighter = FencedCodeBlockHighlighter::new(); - let mut _code_highlighter = FencedCodeBlockHighlighter::new(); - - for line in LinesWithEndings::from(input.as_str()) { - // TODO: fix the fenced code block highlighter (currently disabled) - then use _code_highlighter here + for line in LinesWithEndings::from(&input) { + // Check if the current line is a code block start/end or content. + let is_code_line = code_highlighter.process_line(&mut output, line); - // Replace headers with bold and underline text - if HEADER_RE.is_match(line) { - output.push(line.to_string().bold().underlined().to_string()); - } - // Replace subheaders with underlined text - else if line.starts_with("#### ") { - output.push(line.replace("#### ", "").underlined().to_string()); - } - // Catch all - else { - output.push(line.to_string()); + // The line is not part of a code block, so process normally. + if !is_code_line { + // Replace headers with bold and underline text + if HEADER_RE.is_match(line) { + output.push(line.to_string().bold().underlined().to_string()); + } + // Replace subheaders with underlined text + else if line.starts_with("#### ") { + output.push(line.replace("#### ", "").underlined().to_string()); + } + // No processing + else { + output.push(line.to_string()); + } } } output.join("") } struct FencedCodeBlockHighlighter<'a> { - inner: Option>, + inner: HighlightLines<'a>, in_fenced_block: bool, } impl FencedCodeBlockHighlighter<'_> { fn new() -> Self { - let inner = match &*THEME { - Some(theme) => { - let syntax = SYNTAX_SET.find_syntax_by_extension("sh").unwrap(); - Some(HighlightLines::new(syntax, theme)) - } - None => None, - }; + let syntax = SYNTAX_SET.find_syntax_by_extension("sh").unwrap(); + let theme = THEME.as_ref().unwrap_or(&DEFAULT_THEME); Self { - inner, + inner: HighlightLines::new(syntax, theme), in_fenced_block: false, } } - // TODO: fix the fenced code block highlighter, as it does not work on macOS or Linux - #[allow(dead_code)] - fn process_line(&mut self, line: &str, output: &mut Vec) -> bool { - if let Some(highlighter) = &mut self.inner { - if line == "```sh\n" { - self.in_fenced_block = true; - return true; - } + fn process_line(&mut self, output: &mut Vec, line: &str) -> bool { + if line == "```sh\n" { + self.in_fenced_block = true; + return true; + } - if !self.in_fenced_block { - return false; - } + if !self.in_fenced_block { + return false; + } - if line == "```\n" { - // Push a reset to clear the coloring. - output.push("\x1b[0m".to_string()); - self.in_fenced_block = false; - return true; - } + if line == "```\n" { + // Push a reset to clear the coloring. + output.push("\x1b[0m".to_string()); + self.in_fenced_block = false; + return true; + } - // Highlight the code line - let ranges: Vec<(Style, &str)> = highlighter - .highlight_line(line, &SYNTAX_SET) - .unwrap_or_default(); - output.push(as_24_bit_terminal_escaped(&ranges[..], false)); - true - } else { - false + // Highlight the code line + let highlighted: Vec<(Style, &str)> = self + .inner + .highlight_line(line, &SYNTAX_SET) + .unwrap_or_default(); + + // Convert each syntect range to an ANSI styled string + Self::write(output, &highlighted); + + true + } + + /// Writes a vector of syntect highlighted strings as ANSI styled strings + fn write(output: &mut Vec, highlighted: &Vec<(Style, &str)>) { + for (style, text) in highlighted { + let ansi_styled_text = AnsiStyledText { + text, + style: &[StyleAnsi::Foreground(Color::Rgb( + style.foreground.r, + style.foreground.g, + style.foreground.b, + ))], + }; + + output.push(ansi_styled_text.to_string()); } } } @@ -174,3 +197,55 @@ fn enrich_preview_tag(text: &str) -> String { let container = format!("
{}{}
", preview, tooltip); text.replace("[Preview]", &container) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_syntax_highlighting() { + let mut highlighter = FencedCodeBlockHighlighter::new(); + let mut output = Vec::new(); + + // Simulate the start of a code block + assert!(highlighter.process_line(&mut output, "```sh\n")); + + // Simulate processing a line of code within the code block + let code_line = "echo \"Hello, world!\"\n"; + let highlighted = highlighter.process_line(&mut output, code_line); + + // We expect this line to be processed (highlighted) + assert!(highlighted); + + // The output should contain the syntax highlighted version of the code line + // This is a simplistic check for ANSI escape codes + assert!(output.last().unwrap().contains("\x1b[")); + + // Simulate the end of a code block + assert!(highlighter.process_line(&mut output, "```\n")); + + // Check that the highlighting is reset at the end + assert!(output.last().unwrap().contains("\x1b[0m")); + } + + #[test] + fn test_process_terminal_docs_with_code_blocks() { + let input = "```sh + # To enroll a known identity + $ ockam project ticket --member id_identifier + + # To generate an enrollment ticket that can be used to enroll a device + $ ockam project ticket --attribute component=control + ```"; + + let result = render(input); + assert!( + result.contains("\x1b["), + "The output should contain ANSI escape codes." + ); + assert!( + result.contains("\x1b[0m"), + "The output should reset ANSI coloring at the end." + ); + } +}