Skip to content

Commit

Permalink
feat(rust): add syntax highlighting for command's fenced code blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
YorickdeJong authored and adrianbenavides committed Mar 6, 2024
1 parent 13366e7 commit 82bb6a9
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 61 deletions.
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion implementations/rust/ockam/ockam_command/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +86,7 @@ open = "5.1.0"
opentelemetry = { version = "0.21.0", features = ["metrics", "trace"] }
opentelemetry-otlp = { version = "0.14.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"
Expand All @@ -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"
Expand Down
195 changes: 135 additions & 60 deletions implementations/rust/ockam/ockam_command/src/docs.rs
Original file line number Diff line number Diff line change
@@ -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 = "
Expand All @@ -26,15 +27,25 @@ discord channel https://discord.ockam.io

static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
static HEADER_RE: Lazy<Regex> = Lazy::new(|| Regex::new("^[A-Za-z][A-Za-z0-9 ]+:$".into()));
static THEME: Lazy<Option<Theme>> = 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<Option<SyntectTheme>> = 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<SyntectTheme> = 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 {
Expand Down Expand Up @@ -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 {
pub fn render(body: &str) -> &'static str {
if is_markdown() {
Box::leak(body.to_string().into_boxed_str())
} else {
Expand All @@ -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<String> = 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(line, &mut output);

// 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<HighlightLines<'a>>,
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<String>) -> bool {
if let Some(highlighter) = &mut self.inner {
if line == "```sh\n" {
self.in_fenced_block = true;
return true;
}
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 ranges: Vec<(Style, &str)> = self
.inner
.highlight_line(line, &SYNTAX_SET)
.unwrap_or_default();

// Convert each syntect range to an ANSI styled string
Self::convert_syntect_style_to_ansi(output, &ranges);

true
}

/// Convert a vector of syntect ranges to ANSI styled strings
fn convert_syntect_style_to_ansi(output: &mut Vec<String>, ranges: &Vec<(Style, &str)>) {
for (style, text) in ranges {
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());
}
}
}
Expand All @@ -174,3 +197,55 @@ fn enrich_preview_tag(text: &str) -> String {
let container = format!("<div class=\"chip t\">{}{}</div>", 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("```sh\n", &mut output));

// Simulate processing a line of code within the code block
let code_line = "echo \"Hello, world!\"\n";
let highlighted = highlighter.process_line(code_line, &mut output);

// 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 - your actual check might be more complex
assert!(output.last().unwrap().contains("\x1b["));

// Simulate the end of a code block
assert!(highlighter.process_line("```\n", &mut output));

// 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."
);
}
}

0 comments on commit 82bb6a9

Please sign in to comment.