diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-03-12 15:54:59 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-03-12 15:54:59 +0800 |
| commit | 9d812580557cdc343378816cd65678b8aa75d944 (patch) | |
| tree | b1a3397e38d9620a487aed409fc94310f101bc27 /utils/src | |
| parent | 0a95bae451c1847f4f0b9601e60959f4e8e6b669 (diff) | |
Add lang field to command context and reorganize utils modules
Diffstat (limited to 'utils/src')
| -rw-r--r-- | utils/src/display.rs | 1 | ||||
| -rw-r--r-- | utils/src/display/colorful.rs | 150 | ||||
| -rw-r--r-- | utils/src/display/pager.rs | 37 | ||||
| -rw-r--r-- | utils/src/env.rs | 3 | ||||
| -rw-r--r-- | utils/src/env/editor.rs | 19 | ||||
| -rw-r--r-- | utils/src/env/locales.rs | 28 | ||||
| -rw-r--r-- | utils/src/env/pager.rs | 15 | ||||
| -rw-r--r-- | utils/src/input.rs | 2 | ||||
| -rw-r--r-- | utils/src/input/confirm.rs | 52 | ||||
| -rw-r--r-- | utils/src/input/editor.rs | 66 | ||||
| -rw-r--r-- | utils/src/lib.rs | 2 |
11 files changed, 359 insertions, 16 deletions
diff --git a/utils/src/display.rs b/utils/src/display.rs index a9c48e8..16c94a9 100644 --- a/utils/src/display.rs +++ b/utils/src/display.rs @@ -1,2 +1,3 @@ pub mod colorful; +pub mod pager; pub mod table; diff --git a/utils/src/display/colorful.rs b/utils/src/display/colorful.rs index 7daa6f2..40f83bf 100644 --- a/utils/src/display/colorful.rs +++ b/utils/src/display/colorful.rs @@ -3,19 +3,19 @@ use std::collections::VecDeque; use crossterm::style::Stylize; /// Trait for adding markdown formatting to strings -pub trait Colorful { - fn colorful(&self) -> String; +pub trait Markdown { + fn markdown(&self) -> String; } -impl Colorful for &str { - fn colorful(&self) -> String { - colorful(self) +impl Markdown for &str { + fn markdown(&self) -> String { + markdown(self) } } -impl Colorful for String { - fn colorful(&self) -> String { - colorful(self) +impl Markdown for String { + fn markdown(&self) -> String { + markdown(self) } } @@ -29,6 +29,8 @@ impl Colorful for String { /// - 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: @@ -66,23 +68,139 @@ impl Colorful for String { /// /// # Examples /// ``` -/// use testing::fmt::colorful; -/// -/// let formatted = colorful("Hello **world**!"); +/// # use cli_utils::display::colorful::markdown; +/// let formatted = markdown("Hello **world**!"); /// println!("{}", formatted); /// -/// let colored = colorful("[[red]]Red text[[/]] and normal text"); +/// let colored = markdown("[[red]]Red text[[/]] and normal text"); /// println!("{}", colored); /// -/// let nested = colorful("[[blue]]Blue [[green]]Green[[/]] Blue[[/]] normal"); +/// let nested = markdown("[[blue]]Blue [[green]]Green[[/]] Blue[[/]] normal"); /// println!("{}", nested); /// ``` -pub fn colorful(text: impl AsRef<str>) -> String { - let text = text.as_ref().trim(); +pub fn markdown(text: impl AsRef<str>) -> 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<char> = 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<char> = 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::<String>() + } 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<String> = VecDeque::new(); - let chars: Vec<char> = text.chars().collect(); + let chars: Vec<char> = line.chars().collect(); let mut i = 0; while i < chars.len() { diff --git a/utils/src/display/pager.rs b/utils/src/display/pager.rs new file mode 100644 index 0000000..79c2ccb --- /dev/null +++ b/utils/src/display/pager.rs @@ -0,0 +1,37 @@ +use crate::env::pager::get_default_pager; +use tokio::{fs, process::Command}; + +/// Show text using the system pager (less) +/// Opens the system pager (less) with the given text content written to the specified file +/// If less is not found, directly outputs the content to stdout +pub async fn pager( + content: impl AsRef<str>, + cache_file: impl AsRef<std::path::Path>, +) -> Result<(), std::io::Error> { + let content_str = content.as_ref(); + let cache_path = cache_file.as_ref(); + + // Write content to cache file + fs::write(cache_path, content_str).await?; + + // Get the default pager + let pager_cmd = get_default_pager().await; + + // Try to use the pager + let status = Command::new(&pager_cmd).arg(cache_path).status().await; + + match status { + Ok(status) if status.success() => Ok(()), + _ => { + // If pager failed, output directly to stdout + use tokio::io::{self, AsyncWriteExt}; + let mut stdout = io::stdout(); + stdout + .write_all(content_str.as_bytes()) + .await + .expect("Failed to write content"); + stdout.flush().await.expect("Failed to flush stdout"); + Ok(()) + } + } +} diff --git a/utils/src/env.rs b/utils/src/env.rs new file mode 100644 index 0000000..98aaa6b --- /dev/null +++ b/utils/src/env.rs @@ -0,0 +1,3 @@ +pub mod editor; +pub mod locales; +pub mod pager; diff --git a/utils/src/env/editor.rs b/utils/src/env/editor.rs new file mode 100644 index 0000000..c7bd446 --- /dev/null +++ b/utils/src/env/editor.rs @@ -0,0 +1,19 @@ +/// Gets the default text editor based on environment variables. +/// +/// The function checks the JV_TEXT_EDITOR and EDITOR environment variables +/// and returns their values if they are set. If neither variable is set, +/// it returns "jvii" as the default editor. +/// +/// # Returns +/// A String containing the default text editor +pub async fn get_default_editor() -> String { + if let Ok(editor) = std::env::var("JV_TEXT_EDITOR") { + return editor; + } + + if let Ok(editor) = std::env::var("EDITOR") { + return editor; + } + + "jvii".to_string() +} diff --git a/utils/src/env/locales.rs b/utils/src/env/locales.rs new file mode 100644 index 0000000..302c874 --- /dev/null +++ b/utils/src/env/locales.rs @@ -0,0 +1,28 @@ +/// Returns the current locale string based on environment variables. +/// +/// The function checks for locale settings in the following order: +/// 1. JV_LANG environment variable +/// 2. APP_LANG environment variable +/// 3. LANG environment variable (extracts base language before dot and replaces underscores with hyphens) +/// 4. Defaults to "en" if no locale environment variables are found +/// +/// # Returns +/// A String containing the detected locale code +pub fn current_locales() -> String { + if let Ok(lang) = std::env::var("JV_LANG") { + return lang; + } + + if let Ok(lang) = std::env::var("APP_LANG") { + return lang; + } + + if let Ok(lang) = std::env::var("LANG") { + if let Some(base_lang) = lang.split('.').next() { + return base_lang.replace('_', "-"); + } + return lang; + } + + "en".to_string() +} diff --git a/utils/src/env/pager.rs b/utils/src/env/pager.rs new file mode 100644 index 0000000..3fdb1c3 --- /dev/null +++ b/utils/src/env/pager.rs @@ -0,0 +1,15 @@ +/// Gets the default pager based on environment variables. +/// +/// The function checks the JV_PAGER environment variable +/// and returns its value if it is set. If the variable is not set, +/// it returns "less" as the default pager. +/// +/// # Returns +/// A String containing the default pager +pub async fn get_default_pager() -> String { + if let Ok(pager) = std::env::var("JV_PAGER") { + return pager; + } + + "less".to_string() +} diff --git a/utils/src/input.rs b/utils/src/input.rs new file mode 100644 index 0000000..bf60b88 --- /dev/null +++ b/utils/src/input.rs @@ -0,0 +1,2 @@ +pub mod confirm; +pub mod editor; diff --git a/utils/src/input/confirm.rs b/utils/src/input/confirm.rs new file mode 100644 index 0000000..91d91a7 --- /dev/null +++ b/utils/src/input/confirm.rs @@ -0,0 +1,52 @@ +use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Confirm the current operation +/// Waits for user input of 'y' or 'n' +pub async fn confirm_hint(text: impl Into<String>) -> bool { + let prompt = text.into().trim().to_string(); + + let mut stdout = io::stdout(); + let mut stdin = BufReader::new(io::stdin()); + + stdout + .write_all(prompt.as_bytes()) + .await + .expect("Failed to write prompt"); + stdout.flush().await.expect("Failed to flush stdout"); + + let mut input = String::new(); + stdin + .read_line(&mut input) + .await + .expect("Failed to read input"); + + input.trim().eq_ignore_ascii_case("y") +} + +/// Confirm the current operation, or execute a closure if rejected +/// Waits for user input of 'y' or 'n' +/// If 'n' is entered, executes the provided closure and returns false +pub async fn confirm_hint_or<F>(text: impl Into<String>, on_reject: F) -> bool +where + F: FnOnce(), +{ + let confirmed = confirm_hint(text).await; + if !confirmed { + on_reject(); + } + confirmed +} + +/// Confirm the current operation, and execute a closure if confirmed +/// Waits for user input of 'y' or 'n' +/// If 'y' is entered, executes the provided closure and returns true +pub async fn confirm_hint_then<F>(text: impl Into<String>, on_confirm: F) -> bool +where + F: FnOnce(), +{ + let confirmed = confirm_hint(text).await; + if confirmed { + on_confirm(); + } + confirmed +} diff --git a/utils/src/input/editor.rs b/utils/src/input/editor.rs new file mode 100644 index 0000000..34377fd --- /dev/null +++ b/utils/src/input/editor.rs @@ -0,0 +1,66 @@ +use tokio::{fs, process::Command}; + +use crate::env::editor::get_default_editor; + +/// Input text using the system editor +/// Opens the system editor (from EDITOR environment variable) with default text in a cache file, +/// then reads back the modified content after the editor closes, removing comment lines +pub async fn input_with_editor( + default_text: impl AsRef<str>, + cache_file: impl AsRef<std::path::Path>, + comment_char: impl AsRef<str>, +) -> Result<String, std::io::Error> { + input_with_editor_cutsom( + default_text, + cache_file, + comment_char, + get_default_editor().await, + ) + .await +} + +pub async fn input_with_editor_cutsom( + default_text: impl AsRef<str>, + cache_file: impl AsRef<std::path::Path>, + comment_char: impl AsRef<str>, + editor: String, +) -> Result<String, std::io::Error> { + let cache_path = cache_file.as_ref(); + let default_content = default_text.as_ref(); + let comment_prefix = comment_char.as_ref(); + + // Write default text to cache file + fs::write(cache_path, default_content).await?; + + // Open editor with cache file + let status = Command::new(editor).arg(cache_path).status().await?; + + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Editor exited with non-zero status", + )); + } + + // Read the modified content + let content = fs::read_to_string(cache_path).await?; + + // Remove comment lines and trim + let processed_content: String = content + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with(comment_prefix) { + None + } else { + Some(line) + } + }) + .collect::<Vec<&str>>() + .join("\n"); + + // Delete the cache file + let _ = fs::remove_file(cache_path).await; + + Ok(processed_content) +} diff --git a/utils/src/lib.rs b/utils/src/lib.rs index ca2be9c..f61179e 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -1,4 +1,6 @@ pub mod display; +pub mod env; +pub mod input; pub mod macros; pub mod math; |
