From 72c57380883a1c1cc796dea6d35048ab5bed5f53 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Thu, 12 Mar 2026 19:04:12 +0800 Subject: Add helpdoc system with interactive viewer --- utils/src/display/colorful.rs | 393 ------------------------------------------ utils/src/display/markdown.rs | 393 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+), 393 deletions(-) delete mode 100644 utils/src/display/colorful.rs create mode 100644 utils/src/display/markdown.rs (limited to 'utils/src/display') diff --git a/utils/src/display/colorful.rs b/utils/src/display/colorful.rs deleted file mode 100644 index 40f83bf..0000000 --- a/utils/src/display/colorful.rs +++ /dev/null @@ -1,393 +0,0 @@ -use std::collections::VecDeque; - -use crossterm::style::Stylize; - -/// Trait for adding markdown formatting to strings -pub trait Markdown { - fn markdown(&self) -> String; -} - -impl Markdown for &str { - fn markdown(&self) -> String { - markdown(self) - } -} - -impl Markdown for String { - fn markdown(&self) -> String { - markdown(self) - } -} - -/// Converts a string to colored/formatted text with ANSI escape codes. -/// -/// Supported syntax: -/// - Bold: `**text**` -/// - Italic: `*text*` -/// - Underline: `_text_` -/// - Angle-bracketed content: `` (displayed as cyan) -/// - Inline code: `` `text` `` (displayed as green) -/// - Color tags: `[[color_name]]` and `[[/]]` to close color -/// - Escape characters: `\*`, `\<`, `\>`, `` \` ``, `\_` for literal characters -/// - Headings: `# Heading 1`, `## Heading 2`, up to `###### Heading 6` -/// - Blockquote: `> text` (displays a gray background marker at the beginning of the line) -/// -/// Color tags support the following color names: -/// Color tags support the following color names: -/// -/// | Type | Color Names | -/// |-----------------------|-----------------------------------------------------------------------------| -/// | Standard colors | `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` | -/// | Bright colors | `bright_black` | -/// | | `bright_red` | -/// | | `bright_green` | -/// | | `bright_yellow` | -/// | | `bright_blue` | -/// | | `bright_magenta` | -/// | | `bright_cyan` | -/// | | `bright_white` | -/// | Bright color shorthands | `b_black` | -/// | | `b_red` | -/// | | `b_green` | -/// | | `b_yellow` | -/// | | `b_blue` | -/// | | `b_magenta` | -/// | | `b_cyan` | -/// | | `b_white` | -/// | Gray colors | `gray`/`grey` | -/// | | `bright_gray`/`bright_grey` | -/// | | `b_gray`/`b_grey` | -/// -/// Color tags can be nested, `[[/]]` will close the most recently opened color tag. -/// -/// # Arguments -/// * `text` - The text to format, can be any type that implements `AsRef` -/// -/// # Returns -/// Returns a `String` containing ANSI escape codes that can display colored/formatted text in ANSI-supported terminals. -/// -/// # Examples -/// ``` -/// # use cli_utils::display::colorful::markdown; -/// let formatted = markdown("Hello **world**!"); -/// println!("{}", formatted); -/// -/// let colored = markdown("[[red]]Red text[[/]] and normal text"); -/// println!("{}", colored); -/// -/// let nested = markdown("[[blue]]Blue [[green]]Green[[/]] Blue[[/]] normal"); -/// println!("{}", nested); -/// ``` -pub fn markdown(text: impl AsRef) -> String { - let text = text.as_ref(); - let lines: Vec<&str> = text.lines().collect(); - let mut result = String::new(); - let mut content_indent = 0; - - for line in lines { - // Don't trim the line initially, we need to check if it starts with # - let trimmed_line = line.trim(); - let mut line_result = String::new(); - - // Check if line starts with # for heading - // Check if the original line (not trimmed) starts with # - if line.trim_start().starts_with('#') { - let chars: Vec = line.trim_start().chars().collect(); - let mut level = 0; - - // Count # characters at the beginning - while level < chars.len() && level < 7 && chars[level] == '#' { - level += 1; - } - - // Cap level at 6 - let effective_level = if level > 6 { 6 } else { level }; - - // Skip # characters and any whitespace after them - let mut content_start = level; - while content_start < chars.len() && chars[content_start].is_whitespace() { - content_start += 1; - } - - // Extract heading content - let heading_content: String = if content_start < chars.len() { - chars[content_start..].iter().collect() - } else { - String::new() - }; - - // Process the heading content with formatting - let processed_content = process_line(&heading_content); - - // Format heading as white background, black text, bold - // ANSI codes: \x1b[1m for bold, \x1b[47m for white background, \x1b[30m for black text - let formatted_heading = - format!("\x1b[1m\x1b[47m\x1b[30m {} \x1b[0m", processed_content); - - // Add indentation to the heading line itself - // Heading indentation = level - 1 - let heading_indent = if effective_level > 0 { - effective_level - 1 - } else { - 0 - }; - let indent = " ".repeat(heading_indent); - line_result.push_str(&indent); - line_result.push_str(&formatted_heading); - - // Update content indent level for subsequent content - // Content after heading should be indented by effective_level - content_indent = effective_level; - } else if !trimmed_line.is_empty() { - // Process regular line with existing formatting - let processed_line = process_line_with_quote(trimmed_line); - - // Add indentation based on content_indent - let indent = " ".repeat(content_indent); - line_result.push_str(&indent); - line_result.push_str(&processed_line); - } else { - line_result.push_str(" "); - } - - if !line_result.is_empty() { - result.push_str(&line_result); - result.push('\n'); - } - } - - // Remove trailing newline - if result.ends_with('\n') { - result.pop(); - } - - result -} - -// Helper function to process a single line with existing formatting and handle > quotes -fn process_line_with_quote(line: &str) -> String { - let chars: Vec = line.chars().collect(); - - // Check if line starts with '>' and not escaped - if !chars.is_empty() && chars[0] == '>' { - // Check if it's escaped - if chars.len() > 1 && chars[1] == '\\' { - // It's \>, so treat as normal text starting from position 0 - return process_line(line); - } - - // It's a regular > at the beginning, replace with gray background gray text space - let gray_bg_space = "\x1b[48;5;242m\x1b[38;5;242m \x1b[0m"; - let rest_of_line = if chars.len() > 1 { - chars[1..].iter().collect::() - } else { - String::new() - }; - - // Process the rest of the line normally - let processed_rest = process_line(&rest_of_line); - - // Combine the gray background space with the processed rest - format!("{}{}", gray_bg_space, processed_rest) - } else { - // No > at the beginning, process normally - process_line(line) - } -} - -// Helper function to process a single line with existing formatting -fn process_line(line: &str) -> String { - let mut result = String::new(); - let mut color_stack: VecDeque = VecDeque::new(); - - let chars: Vec = line.chars().collect(); - let mut i = 0; - - while i < chars.len() { - // Check for escape character \ - if chars[i] == '\\' && i + 1 < chars.len() { - let escaped_char = chars[i + 1]; - // Only escape specific characters - if matches!(escaped_char, '*' | '<' | '>' | '`' | '_') { - let mut escaped_text = escaped_char.to_string(); - apply_color_stack(&mut escaped_text, &color_stack); - result.push_str(&escaped_text); - i += 2; - continue; - } - } - - // Check for color tag start [[color]] - if i + 1 < chars.len() && chars[i] == '[' && chars[i + 1] == '[' { - if let Some(end) = find_tag_end(&chars, i) { - let tag_content: String = chars[i + 2..end].iter().collect(); - - // Check if it's a closing tag [[/]] - if tag_content == "/" { - color_stack.pop_back(); - } else { - // It's a color tag - color_stack.push_back(tag_content.clone()); - } - i = end + 2; - continue; - } - } - - // Check for bold **text** - if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' { - if let Some(end) = find_matching(&chars, i + 2, "**") { - let bold_text: String = chars[i + 2..end].iter().collect(); - let mut formatted_text = bold_text.bold().to_string(); - apply_color_stack(&mut formatted_text, &color_stack); - result.push_str(&formatted_text); - i = end + 2; - continue; - } - } - - // Check for italic *text* - if chars[i] == '*' { - if let Some(end) = find_matching(&chars, i + 1, "*") { - let italic_text: String = chars[i + 1..end].iter().collect(); - let mut formatted_text = italic_text.italic().to_string(); - apply_color_stack(&mut formatted_text, &color_stack); - result.push_str(&formatted_text); - i = end + 1; - continue; - } - } - - // Check for underline _text_ - if chars[i] == '_' { - if let Some(end) = find_matching(&chars, i + 1, "_") { - let underline_text: String = chars[i + 1..end].iter().collect(); - let mut formatted_text = format!("\x1b[4m{}\x1b[0m", underline_text); - apply_color_stack(&mut formatted_text, &color_stack); - result.push_str(&formatted_text); - i = end + 1; - continue; - } - } - - // Check for angle-bracketed content - if chars[i] == '<' { - if let Some(end) = find_matching(&chars, i + 1, ">") { - // Include the angle brackets in the output - let angle_text: String = chars[i..=end].iter().collect(); - let mut formatted_text = angle_text.cyan().to_string(); - apply_color_stack(&mut formatted_text, &color_stack); - result.push_str(&formatted_text); - i = end + 1; - continue; - } - } - - // Check for inline code `text` - if chars[i] == '`' { - if let Some(end) = find_matching(&chars, i + 1, "`") { - // Include the backticks in the output - let code_text: String = chars[i..=end].iter().collect(); - let mut formatted_text = code_text.green().to_string(); - apply_color_stack(&mut formatted_text, &color_stack); - result.push_str(&formatted_text); - i = end + 1; - continue; - } - } - - // Regular character - let mut current_char = chars[i].to_string(); - apply_color_stack(&mut current_char, &color_stack); - result.push_str(¤t_char); - i += 1; - } - - result -} - -// Helper function to find matching delimiter -fn find_matching(chars: &[char], start: usize, delimiter: &str) -> Option { - let delim_chars: Vec = delimiter.chars().collect(); - let delim_len = delim_chars.len(); - - let mut j = start; - while j < chars.len() { - if delim_len == 1 { - if chars[j] == delim_chars[0] { - return Some(j); - } - } else if j + 1 < chars.len() - && chars[j] == delim_chars[0] - && chars[j + 1] == delim_chars[1] - { - return Some(j); - } - j += 1; - } - None -} - -// Helper function to find color tag end -fn find_tag_end(chars: &[char], start: usize) -> Option { - let mut j = start + 2; - while j + 1 < chars.len() { - if chars[j] == ']' && chars[j + 1] == ']' { - return Some(j); - } - j += 1; - } - None -} - -// Helper function to apply color stack to text -fn apply_color_stack(text: &mut String, color_stack: &VecDeque) { - let mut result = text.clone(); - for color in color_stack.iter().rev() { - result = apply_color(&result, color); - } - *text = result; -} - -// Helper function to apply color to text -fn apply_color(text: impl AsRef, color_name: impl AsRef) -> String { - let text = text.as_ref(); - let color_name = color_name.as_ref(); - match color_name { - // Normal colors - "black" => text.dark_grey().to_string(), - "red" => text.dark_red().to_string(), - "green" => text.dark_green().to_string(), - "yellow" => text.dark_yellow().to_string(), - "blue" => text.dark_blue().to_string(), - "magenta" => text.dark_magenta().to_string(), - "cyan" => text.dark_cyan().to_string(), - "white" => text.white().to_string(), - "bright_black" => text.black().to_string(), - "bright_red" => text.red().to_string(), - "bright_green" => text.green().to_string(), - "bright_yellow" => text.yellow().to_string(), - "bright_blue" => text.blue().to_string(), - "bright_magenta" => text.magenta().to_string(), - "bright_cyan" => text.cyan().to_string(), - "bright_white" => text.white().to_string(), - - // Short aliases for bright colors - "b_black" => text.black().to_string(), - "b_red" => text.red().to_string(), - "b_green" => text.green().to_string(), - "b_yellow" => text.yellow().to_string(), - "b_blue" => text.blue().to_string(), - "b_magenta" => text.magenta().to_string(), - "b_cyan" => text.cyan().to_string(), - "b_white" => text.white().to_string(), - - // Gray colors using truecolor - "gray" | "grey" => text.grey().to_string(), - "bright_gray" | "bright_grey" => text.white().to_string(), - "b_gray" | "b_grey" => text.white().to_string(), - - // Default to white if color not recognized - _ => text.to_string(), - } -} diff --git a/utils/src/display/markdown.rs b/utils/src/display/markdown.rs new file mode 100644 index 0000000..40f83bf --- /dev/null +++ b/utils/src/display/markdown.rs @@ -0,0 +1,393 @@ +use std::collections::VecDeque; + +use crossterm::style::Stylize; + +/// Trait for adding markdown formatting to strings +pub trait Markdown { + fn markdown(&self) -> String; +} + +impl Markdown for &str { + fn markdown(&self) -> String { + markdown(self) + } +} + +impl Markdown for String { + fn markdown(&self) -> String { + markdown(self) + } +} + +/// Converts a string to colored/formatted text with ANSI escape codes. +/// +/// Supported syntax: +/// - Bold: `**text**` +/// - Italic: `*text*` +/// - Underline: `_text_` +/// - Angle-bracketed content: `` (displayed as cyan) +/// - Inline code: `` `text` `` (displayed as green) +/// - Color tags: `[[color_name]]` and `[[/]]` to close color +/// - Escape characters: `\*`, `\<`, `\>`, `` \` ``, `\_` for literal characters +/// - Headings: `# Heading 1`, `## Heading 2`, up to `###### Heading 6` +/// - Blockquote: `> text` (displays a gray background marker at the beginning of the line) +/// +/// Color tags support the following color names: +/// Color tags support the following color names: +/// +/// | Type | Color Names | +/// |-----------------------|-----------------------------------------------------------------------------| +/// | Standard colors | `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` | +/// | Bright colors | `bright_black` | +/// | | `bright_red` | +/// | | `bright_green` | +/// | | `bright_yellow` | +/// | | `bright_blue` | +/// | | `bright_magenta` | +/// | | `bright_cyan` | +/// | | `bright_white` | +/// | Bright color shorthands | `b_black` | +/// | | `b_red` | +/// | | `b_green` | +/// | | `b_yellow` | +/// | | `b_blue` | +/// | | `b_magenta` | +/// | | `b_cyan` | +/// | | `b_white` | +/// | Gray colors | `gray`/`grey` | +/// | | `bright_gray`/`bright_grey` | +/// | | `b_gray`/`b_grey` | +/// +/// Color tags can be nested, `[[/]]` will close the most recently opened color tag. +/// +/// # Arguments +/// * `text` - The text to format, can be any type that implements `AsRef` +/// +/// # Returns +/// Returns a `String` containing ANSI escape codes that can display colored/formatted text in ANSI-supported terminals. +/// +/// # Examples +/// ``` +/// # use cli_utils::display::colorful::markdown; +/// let formatted = markdown("Hello **world**!"); +/// println!("{}", formatted); +/// +/// let colored = markdown("[[red]]Red text[[/]] and normal text"); +/// println!("{}", colored); +/// +/// let nested = markdown("[[blue]]Blue [[green]]Green[[/]] Blue[[/]] normal"); +/// println!("{}", nested); +/// ``` +pub fn markdown(text: impl AsRef) -> String { + let text = text.as_ref(); + let lines: Vec<&str> = text.lines().collect(); + let mut result = String::new(); + let mut content_indent = 0; + + for line in lines { + // Don't trim the line initially, we need to check if it starts with # + let trimmed_line = line.trim(); + let mut line_result = String::new(); + + // Check if line starts with # for heading + // Check if the original line (not trimmed) starts with # + if line.trim_start().starts_with('#') { + let chars: Vec = line.trim_start().chars().collect(); + let mut level = 0; + + // Count # characters at the beginning + while level < chars.len() && level < 7 && chars[level] == '#' { + level += 1; + } + + // Cap level at 6 + let effective_level = if level > 6 { 6 } else { level }; + + // Skip # characters and any whitespace after them + let mut content_start = level; + while content_start < chars.len() && chars[content_start].is_whitespace() { + content_start += 1; + } + + // Extract heading content + let heading_content: String = if content_start < chars.len() { + chars[content_start..].iter().collect() + } else { + String::new() + }; + + // Process the heading content with formatting + let processed_content = process_line(&heading_content); + + // Format heading as white background, black text, bold + // ANSI codes: \x1b[1m for bold, \x1b[47m for white background, \x1b[30m for black text + let formatted_heading = + format!("\x1b[1m\x1b[47m\x1b[30m {} \x1b[0m", processed_content); + + // Add indentation to the heading line itself + // Heading indentation = level - 1 + let heading_indent = if effective_level > 0 { + effective_level - 1 + } else { + 0 + }; + let indent = " ".repeat(heading_indent); + line_result.push_str(&indent); + line_result.push_str(&formatted_heading); + + // Update content indent level for subsequent content + // Content after heading should be indented by effective_level + content_indent = effective_level; + } else if !trimmed_line.is_empty() { + // Process regular line with existing formatting + let processed_line = process_line_with_quote(trimmed_line); + + // Add indentation based on content_indent + let indent = " ".repeat(content_indent); + line_result.push_str(&indent); + line_result.push_str(&processed_line); + } else { + line_result.push_str(" "); + } + + if !line_result.is_empty() { + result.push_str(&line_result); + result.push('\n'); + } + } + + // Remove trailing newline + if result.ends_with('\n') { + result.pop(); + } + + result +} + +// Helper function to process a single line with existing formatting and handle > quotes +fn process_line_with_quote(line: &str) -> String { + let chars: Vec = line.chars().collect(); + + // Check if line starts with '>' and not escaped + if !chars.is_empty() && chars[0] == '>' { + // Check if it's escaped + if chars.len() > 1 && chars[1] == '\\' { + // It's \>, so treat as normal text starting from position 0 + return process_line(line); + } + + // It's a regular > at the beginning, replace with gray background gray text space + let gray_bg_space = "\x1b[48;5;242m\x1b[38;5;242m \x1b[0m"; + let rest_of_line = if chars.len() > 1 { + chars[1..].iter().collect::() + } else { + String::new() + }; + + // Process the rest of the line normally + let processed_rest = process_line(&rest_of_line); + + // Combine the gray background space with the processed rest + format!("{}{}", gray_bg_space, processed_rest) + } else { + // No > at the beginning, process normally + process_line(line) + } +} + +// Helper function to process a single line with existing formatting +fn process_line(line: &str) -> String { + let mut result = String::new(); + let mut color_stack: VecDeque = VecDeque::new(); + + let chars: Vec = line.chars().collect(); + let mut i = 0; + + while i < chars.len() { + // Check for escape character \ + if chars[i] == '\\' && i + 1 < chars.len() { + let escaped_char = chars[i + 1]; + // Only escape specific characters + if matches!(escaped_char, '*' | '<' | '>' | '`' | '_') { + let mut escaped_text = escaped_char.to_string(); + apply_color_stack(&mut escaped_text, &color_stack); + result.push_str(&escaped_text); + i += 2; + continue; + } + } + + // Check for color tag start [[color]] + if i + 1 < chars.len() && chars[i] == '[' && chars[i + 1] == '[' { + if let Some(end) = find_tag_end(&chars, i) { + let tag_content: String = chars[i + 2..end].iter().collect(); + + // Check if it's a closing tag [[/]] + if tag_content == "/" { + color_stack.pop_back(); + } else { + // It's a color tag + color_stack.push_back(tag_content.clone()); + } + i = end + 2; + continue; + } + } + + // Check for bold **text** + if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' { + if let Some(end) = find_matching(&chars, i + 2, "**") { + let bold_text: String = chars[i + 2..end].iter().collect(); + let mut formatted_text = bold_text.bold().to_string(); + apply_color_stack(&mut formatted_text, &color_stack); + result.push_str(&formatted_text); + i = end + 2; + continue; + } + } + + // Check for italic *text* + if chars[i] == '*' { + if let Some(end) = find_matching(&chars, i + 1, "*") { + let italic_text: String = chars[i + 1..end].iter().collect(); + let mut formatted_text = italic_text.italic().to_string(); + apply_color_stack(&mut formatted_text, &color_stack); + result.push_str(&formatted_text); + i = end + 1; + continue; + } + } + + // Check for underline _text_ + if chars[i] == '_' { + if let Some(end) = find_matching(&chars, i + 1, "_") { + let underline_text: String = chars[i + 1..end].iter().collect(); + let mut formatted_text = format!("\x1b[4m{}\x1b[0m", underline_text); + apply_color_stack(&mut formatted_text, &color_stack); + result.push_str(&formatted_text); + i = end + 1; + continue; + } + } + + // Check for angle-bracketed content + if chars[i] == '<' { + if let Some(end) = find_matching(&chars, i + 1, ">") { + // Include the angle brackets in the output + let angle_text: String = chars[i..=end].iter().collect(); + let mut formatted_text = angle_text.cyan().to_string(); + apply_color_stack(&mut formatted_text, &color_stack); + result.push_str(&formatted_text); + i = end + 1; + continue; + } + } + + // Check for inline code `text` + if chars[i] == '`' { + if let Some(end) = find_matching(&chars, i + 1, "`") { + // Include the backticks in the output + let code_text: String = chars[i..=end].iter().collect(); + let mut formatted_text = code_text.green().to_string(); + apply_color_stack(&mut formatted_text, &color_stack); + result.push_str(&formatted_text); + i = end + 1; + continue; + } + } + + // Regular character + let mut current_char = chars[i].to_string(); + apply_color_stack(&mut current_char, &color_stack); + result.push_str(¤t_char); + i += 1; + } + + result +} + +// Helper function to find matching delimiter +fn find_matching(chars: &[char], start: usize, delimiter: &str) -> Option { + let delim_chars: Vec = delimiter.chars().collect(); + let delim_len = delim_chars.len(); + + let mut j = start; + while j < chars.len() { + if delim_len == 1 { + if chars[j] == delim_chars[0] { + return Some(j); + } + } else if j + 1 < chars.len() + && chars[j] == delim_chars[0] + && chars[j + 1] == delim_chars[1] + { + return Some(j); + } + j += 1; + } + None +} + +// Helper function to find color tag end +fn find_tag_end(chars: &[char], start: usize) -> Option { + let mut j = start + 2; + while j + 1 < chars.len() { + if chars[j] == ']' && chars[j + 1] == ']' { + return Some(j); + } + j += 1; + } + None +} + +// Helper function to apply color stack to text +fn apply_color_stack(text: &mut String, color_stack: &VecDeque) { + let mut result = text.clone(); + for color in color_stack.iter().rev() { + result = apply_color(&result, color); + } + *text = result; +} + +// Helper function to apply color to text +fn apply_color(text: impl AsRef, color_name: impl AsRef) -> String { + let text = text.as_ref(); + let color_name = color_name.as_ref(); + match color_name { + // Normal colors + "black" => text.dark_grey().to_string(), + "red" => text.dark_red().to_string(), + "green" => text.dark_green().to_string(), + "yellow" => text.dark_yellow().to_string(), + "blue" => text.dark_blue().to_string(), + "magenta" => text.dark_magenta().to_string(), + "cyan" => text.dark_cyan().to_string(), + "white" => text.white().to_string(), + "bright_black" => text.black().to_string(), + "bright_red" => text.red().to_string(), + "bright_green" => text.green().to_string(), + "bright_yellow" => text.yellow().to_string(), + "bright_blue" => text.blue().to_string(), + "bright_magenta" => text.magenta().to_string(), + "bright_cyan" => text.cyan().to_string(), + "bright_white" => text.white().to_string(), + + // Short aliases for bright colors + "b_black" => text.black().to_string(), + "b_red" => text.red().to_string(), + "b_green" => text.green().to_string(), + "b_yellow" => text.yellow().to_string(), + "b_blue" => text.blue().to_string(), + "b_magenta" => text.magenta().to_string(), + "b_cyan" => text.cyan().to_string(), + "b_white" => text.white().to_string(), + + // Gray colors using truecolor + "gray" | "grey" => text.grey().to_string(), + "bright_gray" | "bright_grey" => text.white().to_string(), + "b_gray" | "b_grey" => text.white().to_string(), + + // Default to white if color not recognized + _ => text.to_string(), + } +} -- cgit