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 --- Cargo.lock | 11 + Cargo.toml | 7 +- macros/helpdoc_system_macros/Cargo.toml | 12 + macros/helpdoc_system_macros/src/lib.rs | 192 ++++++++ resources/helpdoc/Welcome_To_JVCS.en.md | 1 + resources/helpdoc/Welcome_To_JVCS.zh-CN.md | 1 + resources/helpdoc/commands/hexdump.en.md | 6 + resources/helpdoc/commands/hexdump.zh-CN.md | 6 + resources/helpdoc/commands/sheetdump.en.md | 13 + resources/helpdoc/commands/sheetdump.zh-CN.md | 12 + resources/helpdoc/commands/sheetedit.en.md | 12 + resources/helpdoc/commands/sheetedit.zh-CN.md | 12 + resources/locales/jvn/en.yml | 27 +- resources/locales/jvn/helpdoc_viewer/en.yml | 5 + resources/locales/jvn/helpdoc_viewer/zh-CN.yml | 5 + resources/locales/jvn/zh-CN.yml | 27 +- src/bin/jvn.rs | 3 +- src/cmds/arg/helpdoc.rs | 6 + src/cmds/cmd/helpdoc.rs | 49 ++ src/cmds/cmd/hexdump.rs | 14 +- src/cmds/cmd/sheetdump.rs | 14 +- src/cmds/cmd/sheetedit.rs | 14 +- src/cmds/in/helpdoc.rs | 4 + src/systems.rs | 1 + src/systems/cmd/cmd_system.rs | 9 +- src/systems/cmd/macros.rs | 8 +- src/systems/helpdoc.rs | 18 + src/systems/helpdoc/helpdoc_viewer.rs | 636 +++++++++++++++++++++++++ utils/src/display.rs | 2 +- utils/src/display/colorful.rs | 393 --------------- utils/src/display/markdown.rs | 393 +++++++++++++++ 31 files changed, 1464 insertions(+), 449 deletions(-) create mode 100644 macros/helpdoc_system_macros/Cargo.toml create mode 100644 macros/helpdoc_system_macros/src/lib.rs create mode 100644 resources/helpdoc/Welcome_To_JVCS.en.md create mode 100644 resources/helpdoc/Welcome_To_JVCS.zh-CN.md create mode 100644 resources/helpdoc/commands/hexdump.en.md create mode 100644 resources/helpdoc/commands/hexdump.zh-CN.md create mode 100644 resources/helpdoc/commands/sheetdump.en.md create mode 100644 resources/helpdoc/commands/sheetdump.zh-CN.md create mode 100644 resources/helpdoc/commands/sheetedit.en.md create mode 100644 resources/helpdoc/commands/sheetedit.zh-CN.md create mode 100644 resources/locales/jvn/helpdoc_viewer/en.yml create mode 100644 resources/locales/jvn/helpdoc_viewer/zh-CN.yml create mode 100644 src/cmds/arg/helpdoc.rs create mode 100644 src/cmds/cmd/helpdoc.rs create mode 100644 src/cmds/in/helpdoc.rs create mode 100644 src/systems/helpdoc.rs create mode 100644 src/systems/helpdoc/helpdoc_viewer.rs delete mode 100644 utils/src/display/colorful.rs create mode 100644 utils/src/display/markdown.rs diff --git a/Cargo.lock b/Cargo.lock index afc5e63..47482bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1026,6 +1026,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "helpdoc_system_macros" +version = "0.1.0-dev" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "hex_display" version = "0.1.0" @@ -1207,6 +1216,7 @@ dependencies = [ "colored", "crossterm", "env_logger", + "helpdoc_system_macros", "just_enough_vcs", "just_fmt", "just_progress", @@ -1219,6 +1229,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "strip-ansi-escapes", "thiserror", "tokio", "toml 0.9.8", diff --git a/Cargo.toml b/Cargo.toml index e6c061f..4eba850 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,9 @@ homepage = "https://github.com/JustEnoughVCS/CommandLine/" members = [ "utils/", "tools/build_helper", - "macros/render_system_macros", "macros/cmd_system_macros", + "macros/helpdoc_system_macros", + "macros/render_system_macros", ] [workspace.package] @@ -58,8 +59,9 @@ serde = { version = "1", features = ["derive"] } [dependencies] cli_utils = { path = "utils" } -cmd_system_macros = { path = "macros/cmd_system_macros" } just_enough_vcs = { path = "../VersionControl", features = ["all"] } +cmd_system_macros = { path = "macros/cmd_system_macros" } +helpdoc_system_macros = { path = "macros/helpdoc_system_macros" } render_system_macros = { path = "macros/render_system_macros" } crossterm.workspace = true @@ -77,6 +79,7 @@ ron = "0.11.0" rust-i18n = "3" serde_json = "1" serde_yaml = "0.9" +strip_ansi_escapes = { version = "0.2", package = "strip-ansi-escapes" } tokio = { version = "1", features = ["full"] } toml = "0.9" walkdir = "2.5.0" diff --git a/macros/helpdoc_system_macros/Cargo.toml b/macros/helpdoc_system_macros/Cargo.toml new file mode 100644 index 0000000..c5c8483 --- /dev/null +++ b/macros/helpdoc_system_macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "helpdoc_system_macros" +edition = "2024" +version.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true diff --git a/macros/helpdoc_system_macros/src/lib.rs b/macros/helpdoc_system_macros/src/lib.rs new file mode 100644 index 0000000..a9008bf --- /dev/null +++ b/macros/helpdoc_system_macros/src/lib.rs @@ -0,0 +1,192 @@ +use proc_macro::TokenStream; +use quote::quote; +use std::fs; +use std::path::Path; +use syn; + +#[proc_macro] +pub fn generate_helpdoc_mapping(_input: TokenStream) -> TokenStream { + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir); + let helpdoc_dir = repo_root.join("resources").join("helpdoc"); + + if !helpdoc_dir.exists() { + return quote! { + fn get_doc(_doc_name: &str, _lang: &str) -> &'static str { + "" + } + } + .into(); + } + + let mut doc_entries = Vec::new(); + + scan_directory(&helpdoc_dir, &mut doc_entries, &helpdoc_dir); + + let match_arms = generate_match_arms(&doc_entries); + + let expanded = quote! { + fn get_doc(doc_name: &str, lang: &str) -> &'static str { + let key = format!("{}.{}", doc_name, lang); + match key.as_str() { + #(#match_arms)* + _ => "", + } + } + }; + + expanded.into() +} + +fn scan_directory(dir: &Path, entries: &mut Vec<(String, String)>, base_dir: &Path) { + if let Ok(entries_iter) = fs::read_dir(dir) { + for entry in entries_iter.filter_map(Result::ok) { + let path = entry.path(); + + if path.is_dir() { + scan_directory(&path, entries, base_dir); + } else if let Some(extension) = path.extension() { + if extension == "md" { + if let Ok(relative_path) = path.strip_prefix(base_dir) { + if let Some(file_stem) = path.file_stem() { + let file_stem_str = file_stem.to_string_lossy(); + + if let Some(dot_pos) = file_stem_str.rfind('.') { + let doc_name = &file_stem_str[..dot_pos]; + let lang = &file_stem_str[dot_pos + 1..]; + + let parent = relative_path.parent(); + let full_doc_name = if let Some(parent) = parent { + if parent.to_string_lossy().is_empty() { + doc_name.to_string() + } else { + format!("{}/{}", parent.to_string_lossy(), doc_name) + } + } else { + doc_name.to_string() + }; + + entries.push((full_doc_name, lang.to_string())); + } + } + } + } + } + } + } +} + +fn generate_match_arms(entries: &[(String, String)]) -> Vec { + let mut arms = Vec::new(); + + for (doc_name, lang) in entries { + let key = format!("{}.{}", doc_name, lang); + let file_path = format!("resources/helpdoc/{}.{}.md", doc_name, lang); + + let arm = quote! { + #key => include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #file_path)), + }; + + arms.push(arm); + } + + arms +} + +#[proc_macro] +pub fn generate_helpdoc_list(_input: TokenStream) -> TokenStream { + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir); + let helpdoc_dir = repo_root.join("resources").join("helpdoc"); + + if !helpdoc_dir.exists() { + return quote! { + fn get_docs_list() -> Vec<&'static str> { + Vec::new() + } + } + .into(); + } + + let mut doc_entries = Vec::new(); + scan_directory(&helpdoc_dir, &mut doc_entries, &helpdoc_dir); + + let mut unique_docs = std::collections::HashSet::new(); + for (doc_name, _) in &doc_entries { + unique_docs.insert(doc_name.clone()); + } + + let mut doc_list = Vec::new(); + for doc_name in unique_docs { + doc_list.push(quote! { + #doc_name + }); + } + + let expanded = quote! { + fn get_docs_list() -> Vec<&'static str> { + vec![ + #(#doc_list),* + ] + } + }; + + expanded.into() +} + +#[proc_macro] +pub fn generate_helpdoc_test(_input: TokenStream) -> TokenStream { + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir); + let helpdoc_dir = repo_root.join("resources").join("helpdoc"); + + if !helpdoc_dir.exists() { + return quote! { + #[cfg(test)] + mod helpdoc_tests { + #[test] + fn test_no_docs() { + } + } + } + .into(); + } + + let mut doc_entries = Vec::new(); + scan_directory(&helpdoc_dir, &mut doc_entries, &helpdoc_dir); + + let mut test_cases = Vec::new(); + + for (doc_name, lang) in &doc_entries { + let test_name_str = format!( + "test_doc_{}_{}", + doc_name + .replace('/', "_") + .replace('.', "_") + .replace('-', "_"), + lang.replace('-', "_") + ); + let test_name = syn::Ident::new(&test_name_str, proc_macro2::Span::call_site()); + let test_case = quote! { + #[test] + fn #test_name() { + let doc = super::get_doc(#doc_name, #lang); + assert!(!doc.is_empty(), "Document {}.{} should not be empty", #doc_name, #lang); + } + }; + + test_cases.push(test_case); + } + + let expanded = quote! { + #[cfg(test)] + mod helpdoc_tests { + #(#test_cases)* + } + }; + + expanded.into() +} diff --git a/resources/helpdoc/Welcome_To_JVCS.en.md b/resources/helpdoc/Welcome_To_JVCS.en.md new file mode 100644 index 0000000..f82f58e --- /dev/null +++ b/resources/helpdoc/Welcome_To_JVCS.en.md @@ -0,0 +1 @@ +# Welcome diff --git a/resources/helpdoc/Welcome_To_JVCS.zh-CN.md b/resources/helpdoc/Welcome_To_JVCS.zh-CN.md new file mode 100644 index 0000000..2dfd3ee --- /dev/null +++ b/resources/helpdoc/Welcome_To_JVCS.zh-CN.md @@ -0,0 +1 @@ +# 你好 diff --git a/resources/helpdoc/commands/hexdump.en.md b/resources/helpdoc/commands/hexdump.en.md new file mode 100644 index 0000000..60d5275 --- /dev/null +++ b/resources/helpdoc/commands/hexdump.en.md @@ -0,0 +1,6 @@ +# Hexdump + +> Print binary information + +## Usage +jvn hexdump diff --git a/resources/helpdoc/commands/hexdump.zh-CN.md b/resources/helpdoc/commands/hexdump.zh-CN.md new file mode 100644 index 0000000..9b5b8ad --- /dev/null +++ b/resources/helpdoc/commands/hexdump.zh-CN.md @@ -0,0 +1,6 @@ +# Hexdump + +> 打印二进制信息 + +## 使用 +jvn hexdump diff --git a/resources/helpdoc/commands/sheetdump.en.md b/resources/helpdoc/commands/sheetdump.en.md new file mode 100644 index 0000000..1616f12 --- /dev/null +++ b/resources/helpdoc/commands/sheetdump.en.md @@ -0,0 +1,13 @@ +# Sheetdump + +> Visually output the internal structure of a `Sheet` + +## Usage +jvn sheetdump # Default output +jvn sheetdump --no-sort # No sorting +jvn sheetdump --no-pretty # No prettifying + +## Tip +You can also use `renderer override` to access the internal structure of a `Sheet`, +for example: +jvn sheetdump --renderer json | jq ".mappings" diff --git a/resources/helpdoc/commands/sheetdump.zh-CN.md b/resources/helpdoc/commands/sheetdump.zh-CN.md new file mode 100644 index 0000000..0ae46ef --- /dev/null +++ b/resources/helpdoc/commands/sheetdump.zh-CN.md @@ -0,0 +1,12 @@ +# Sheetdump + +> 可视化地输出 `Sheet` 的内部结构 + +## 使用 +jvn sheetdump # 默认输出 +jvn sheetdump --no-sort # 无排序 +jvn sheetdump --no-pretty # 无美化 + +## 提示 +您也可以使用 `渲染器重载` 来访问 `Sheet` 的内部结构,例如 +jvn sheetdump --renderer json | jq ".mappings" diff --git a/resources/helpdoc/commands/sheetedit.en.md b/resources/helpdoc/commands/sheetedit.en.md new file mode 100644 index 0000000..80f2a7b --- /dev/null +++ b/resources/helpdoc/commands/sheetedit.en.md @@ -0,0 +1,12 @@ +# Sheetedit + +> Edit `Sheet` files using your system editor + +## Usage +jvn sheetedit + +## Note +It reads and uses a command-line editor program in the following priority order: +1. JV\_TEXT\_EDITOR +2. EDITOR +3. If neither exists, it falls back to using `jvii` diff --git a/resources/helpdoc/commands/sheetedit.zh-CN.md b/resources/helpdoc/commands/sheetedit.zh-CN.md new file mode 100644 index 0000000..f30825a --- /dev/null +++ b/resources/helpdoc/commands/sheetedit.zh-CN.md @@ -0,0 +1,12 @@ +# Sheetedit + +> 使用系统编辑器修改 `Sheet` 文件 + +## 使用 +jvn sheetedit + +## 注意 +它读取按照以下优先级寻找命令行编辑器程序: +1. JV\_TEXT\_EDITOR +2. EDITOR +3. 若都不存在,使用 `jvii` 回退 diff --git a/resources/locales/jvn/en.yml b/resources/locales/jvn/en.yml index 6e632f2..7df57fd 100644 --- a/resources/locales/jvn/en.yml +++ b/resources/locales/jvn/en.yml @@ -1,28 +1,25 @@ -help: | - NO - process_error: no_matching_command: | No matching command found! - Use `jv -h` to get help + Use `jvn -h` to get help no_matching_command_but_similar: | No matching command found, but similar commands were found: - jv %{similars} + jvn %{similars} - Use `jv -h` to get help + Use `jvn -h` to get help no_node_found: | Unable to find command `%{node}` This is usually a compilation-phase issue, not a user input error. - Please use `jv -v -C` to get detailed version traceback and contact the developers. + Please use `jvn -v -C` to get detailed version traceback and contact the developers. github: https://github.com/JustEnoughVCS/CommandLine/ parse_error: | An error occurred while parsing your command arguments! - Please use `jv --help` to view help + Please use `jvn --help` to view help renderer_override_but_request_help: | Renderer override mode is enabled, but help output was requested. @@ -38,7 +35,7 @@ process_error: Type conversion failed! This is usually a compilation-phase issue, not a user input error. - Please use `jv -v -C` to get detailed version traceback and contact the developers. + Please use `jvn -v -C` to get detailed version traceback and contact the developers. github: https://github.com/JustEnoughVCS/CommandLine/ @@ -57,19 +54,19 @@ prepare_error: local_config_not_found: | Failed to read workspace config. File may not exist or format mismatch. - Use `jv update` and try again. + Use `jvn update` and try again. latest_info_not_found: | Unable to read latest upstream info! - Use `jv update` and try again. + Use `jvn update` and try again. latest_file_data_not_exist: | Unable to read latest file info for member `%{member_id}`! - Use `jv update` and try again. + Use `jvn update` and try again. cached_sheet_not_found: | Unable to read cached upstream sheet `%{sheet_name}`! - Use `jv update` and try again. + Use `jvn update` and try again. local_sheet_not_found: | Unable to read local sheet `%{sheet_name}` for member `%{member_id}`. @@ -80,7 +77,7 @@ prepare_error: Also, verify that a structure sheet is in use. no_sheet_in_use: | - No sheet in use. Use `jv use `. + No sheet in use. Use `jvn use `. execute_error: io: | @@ -110,7 +107,7 @@ render_error: type_mismatch: | Render type mismatch! This is usually a compilation-phase issue, not a user input error. - Please use `jv -v -C` to get detailed version traceback and contact the developers. + Please use `jvn -v -C` to get detailed version traceback and contact the developers. github: https://github.com/JustEnoughVCS/CommandLine/ diff --git a/resources/locales/jvn/helpdoc_viewer/en.yml b/resources/locales/jvn/helpdoc_viewer/en.yml new file mode 100644 index 0000000..1f5d9d4 --- /dev/null +++ b/resources/locales/jvn/helpdoc_viewer/en.yml @@ -0,0 +1,5 @@ +helpdoc_viewer: + tree_area_hint: | + _List_ | ↑↓: Page | ←→/Space: Switch view | Q: Quit + content_area_hint: | + _Read_ | ↑↓: Page | ←→/Space: Switch view | Q: Quit diff --git a/resources/locales/jvn/helpdoc_viewer/zh-CN.yml b/resources/locales/jvn/helpdoc_viewer/zh-CN.yml new file mode 100644 index 0000000..65825d5 --- /dev/null +++ b/resources/locales/jvn/helpdoc_viewer/zh-CN.yml @@ -0,0 +1,5 @@ +helpdoc_viewer: + tree_area_hint: | + _列表_ | ↑↓: 翻页 | ←→/Space: 切换视图 | Q: 退出 + content_area_hint: | + _阅读_ | ↑↓: 浏览 | ←→/Space: 切换视图 | Q: 退出 diff --git a/resources/locales/jvn/zh-CN.yml b/resources/locales/jvn/zh-CN.yml index f62b53b..ff1b67a 100644 --- a/resources/locales/jvn/zh-CN.yml +++ b/resources/locales/jvn/zh-CN.yml @@ -1,27 +1,24 @@ -help: | - NO - process_error: no_matching_command: | - 无法匹配该命令,使用 `jv -h` 查看帮助 + 无法匹配该命令,使用 `jvn -h` 查看帮助 no_matching_command_but_similar: | 无法找到匹配的命令,但找到相似命令: - jv %{similars} + jvn %{similars} - 使用 `jv -h` 查看帮助 + 使用 `jvn -h` 查看帮助 no_node_found: | 无法找到命令 `%{node}` 这通常是编译期造成的问题,而非用户的错误输入 - 请使用 `jv -v -C` 获得详细的版本追溯,并联系开发人员 + 请使用 `jvn -v -C` 获得详细的版本追溯,并联系开发人员 github: https://github.com/JustEnoughVCS/CommandLine/ parse_error: | 在将您的命令参数进行解析时出现错误! - 请使用 `jv <命令> --help` 查看帮助 + 请使用 `jvn <命令> --help` 查看帮助 renderer_override_but_request_help: | 启用渲染器覆盖模式,但请求输出帮助信息 @@ -37,7 +34,7 @@ process_error: 类型转换失败! 这通常是编译期造成的问题,而非用户的错误输入 - 请使用 `jv -v -C` 获得详细的版本追溯,并联系开发人员 + 请使用 `jvn -v -C` 获得详细的版本追溯,并联系开发人员 github: https://github.com/JustEnoughVCS/CommandLine/ @@ -56,19 +53,19 @@ prepare_error: local_config_not_found: | 读取本地工作区配置文件失败,它可能不存在或格式不匹配 - 请使用 `jv update` 更新工作区信息后再尝试 + 请使用 `jvn update` 更新工作区信息后再尝试 latest_info_not_found: | 无法找到或读取最新上游信息! - 请使用 `jv update` 更新工作区信息后再尝试 + 请使用 `jvn update` 更新工作区信息后再尝试 latest_file_data_not_exist: | 无法找到或读取成员 `%{member_id}` 的最新文件信息! - 请使用 `jv update` 更新工作区信息后再尝试 + 请使用 `jvn update` 更新工作区信息后再尝试 cached_sheet_not_found: | 无法找到或读取上游结构表 `%{sheet_name}` 的缓存信息! - 请使用 `jv update` 更新工作区信息后再尝试 + 请使用 `jvn update` 更新工作区信息后再尝试 local_sheet_not_found: | 无法找到或读取成员 `%{member_id}` 的本地结构表 `%{sheet_name}` @@ -78,7 +75,7 @@ prepare_error: 请检查当前工作区是否正确设置上游,并正在使用结构表 no_sheet_in_use: | - 当前没有在使用表,请使用 `jv use <结构表名称>` 使用一张结构表 + 当前没有在使用表,请使用 `jvn use <结构表名称>` 使用一张结构表 execute_error: io: | @@ -108,7 +105,7 @@ render_error: type_mismatch: | 渲染类型不匹配! 这通常是编译期造成的问题,而非用户的错误输入 - 请使用 `jv -v -C` 获得详细的版本追溯,并联系开发人员 + 请使用 `jvn -v -C` 获得详细的版本追溯,并联系开发人员 github: https://github.com/JustEnoughVCS/CommandLine/ diff --git a/src/bin/jvn.rs b/src/bin/jvn.rs index 3a95a45..0e5a9f6 100644 --- a/src/bin/jvn.rs +++ b/src/bin/jvn.rs @@ -11,6 +11,7 @@ use just_enough_vcs_cli::{ processer::jv_cmd_process, }, debug::verbose_logger::init_verbose_logger, + helpdoc::helpdoc_viewer, }, }; use just_progress::{ @@ -100,7 +101,7 @@ async fn main() { // Handle help when no arguments provided if args.len() < 1 && help { warn!("{}", t!("verbose.no_arguments")); - eprintln!("{}", md(t!("help"))); + helpdoc_viewer::display_with_lang("Welcome_To_JVCS", &lang).await; exit(1); } diff --git a/src/cmds/arg/helpdoc.rs b/src/cmds/arg/helpdoc.rs new file mode 100644 index 0000000..3a34123 --- /dev/null +++ b/src/cmds/arg/helpdoc.rs @@ -0,0 +1,6 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +pub struct JVHelpdocArgument { + pub doc_name: String, +} diff --git a/src/cmds/cmd/helpdoc.rs b/src/cmds/cmd/helpdoc.rs new file mode 100644 index 0000000..8b692ee --- /dev/null +++ b/src/cmds/cmd/helpdoc.rs @@ -0,0 +1,49 @@ +use crate::{ + cmd_output, + cmds::{ + arg::helpdoc::JVHelpdocArgument, collect::empty::JVEmptyCollect, + r#in::helpdoc::JVHelpdocInput, out::none::JVNoneOutput, + }, + systems::{ + cmd::{ + cmd_system::JVCommandContext, + errors::{CmdExecuteError, CmdPrepareError}, + }, + helpdoc::helpdoc_viewer, + }, +}; +use cmd_system_macros::exec; +use std::any::TypeId; + +pub struct JVHelpdocCommand; +type Cmd = JVHelpdocCommand; +type Arg = JVHelpdocArgument; +type In = JVHelpdocInput; +type Collect = JVEmptyCollect; + +async fn help_str() -> String { + helpdoc_viewer::display("Welcome_To_JVCS").await; + String::new() +} + +async fn prepare(args: &Arg, ctx: &JVCommandContext) -> Result { + Ok(JVHelpdocInput { + name: args.doc_name.clone(), + lang: ctx.lang.clone(), + }) +} + +async fn collect(_args: &Arg, _ctx: &JVCommandContext) -> Result { + Ok(JVEmptyCollect) +} + +#[exec] +async fn exec( + input: In, + _collect: Collect, +) -> Result<(Box, TypeId), CmdExecuteError> { + helpdoc_viewer::display_with_lang(&input.name.as_str(), &input.lang.as_str()).await; + cmd_output!(JVNoneOutput => JVNoneOutput) +} + +crate::command_template!(); diff --git a/src/cmds/cmd/hexdump.rs b/src/cmds/cmd/hexdump.rs index 66022ef..928b626 100644 --- a/src/cmds/cmd/hexdump.rs +++ b/src/cmds/cmd/hexdump.rs @@ -6,9 +6,12 @@ use crate::{ arg::single_file::JVSingleFileArgument, collect::single_file::JVSingleFileCollect, r#in::empty::JVEmptyInput, out::hex::JVHexOutput, }, - systems::cmd::{ - cmd_system::JVCommandContext, - errors::{CmdExecuteError, CmdPrepareError}, + systems::{ + cmd::{ + cmd_system::JVCommandContext, + errors::{CmdExecuteError, CmdPrepareError}, + }, + helpdoc::helpdoc_viewer, }, }; use cmd_system_macros::exec; @@ -20,8 +23,9 @@ type Arg = JVSingleFileArgument; type In = JVEmptyInput; type Collect = JVSingleFileCollect; -fn help_str() -> String { - "Hello".to_string() +async fn help_str() -> String { + helpdoc_viewer::display("commands/hexdump").await; + String::new() } async fn prepare(_args: &Arg, _ctx: &JVCommandContext) -> Result { diff --git a/src/cmds/cmd/sheetdump.rs b/src/cmds/cmd/sheetdump.rs index fefa6d4..8140a0d 100644 --- a/src/cmds/cmd/sheetdump.rs +++ b/src/cmds/cmd/sheetdump.rs @@ -8,9 +8,12 @@ use crate::{ r#in::sheetdump::JVSheetdumpInput, out::{mappings::JVMappingsOutput, mappings_pretty::JVMappingsPrettyOutput}, }, - systems::cmd::{ - cmd_system::JVCommandContext, - errors::{CmdExecuteError, CmdPrepareError}, + systems::{ + cmd::{ + cmd_system::JVCommandContext, + errors::{CmdExecuteError, CmdPrepareError}, + }, + helpdoc::helpdoc_viewer, }, }; use cmd_system_macros::exec; @@ -25,8 +28,9 @@ type Arg = JVSheetdumpArgument; type In = JVSheetdumpInput; type Collect = JVSheetdumpCollect; -fn help_str() -> String { - todo!() +async fn help_str() -> String { + helpdoc_viewer::display("commands/sheetdump").await; + String::new() } async fn prepare(args: &Arg, _ctx: &JVCommandContext) -> Result { diff --git a/src/cmds/cmd/sheetedit.rs b/src/cmds/cmd/sheetedit.rs index 2dfbbfb..08918f4 100644 --- a/src/cmds/cmd/sheetedit.rs +++ b/src/cmds/cmd/sheetedit.rs @@ -4,9 +4,12 @@ use crate::{ arg::sheetedit::JVSheeteditArgument, collect::single_file::JVSingleFileCollect, r#in::sheetedit::JVSheeteditInput, out::none::JVNoneOutput, }, - systems::cmd::{ - cmd_system::JVCommandContext, - errors::{CmdExecuteError, CmdPrepareError}, + systems::{ + cmd::{ + cmd_system::JVCommandContext, + errors::{CmdExecuteError, CmdPrepareError}, + }, + helpdoc::helpdoc_viewer, }, }; use cli_utils::{ @@ -26,8 +29,9 @@ type Arg = JVSheeteditArgument; type In = JVSheeteditInput; type Collect = JVSingleFileCollect; -fn help_str() -> String { - todo!() +async fn help_str() -> String { + helpdoc_viewer::display("commands/sheetedit").await; + String::new() } async fn prepare(args: &Arg, _ctx: &JVCommandContext) -> Result { diff --git a/src/cmds/in/helpdoc.rs b/src/cmds/in/helpdoc.rs new file mode 100644 index 0000000..6b72d43 --- /dev/null +++ b/src/cmds/in/helpdoc.rs @@ -0,0 +1,4 @@ +pub struct JVHelpdocInput { + pub name: String, + pub lang: String, +} diff --git a/src/systems.rs b/src/systems.rs index 2ca53be..e0d4491 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -1,3 +1,4 @@ pub mod cmd; pub mod debug; +pub mod helpdoc; pub mod render; diff --git a/src/systems/cmd/cmd_system.rs b/src/systems/cmd/cmd_system.rs index 67f5c7f..3ae4d5e 100644 --- a/src/systems/cmd/cmd_system.rs +++ b/src/systems/cmd/cmd_system.rs @@ -28,7 +28,7 @@ where Collect: Send, { /// Get help string for the command - fn get_help_str() -> String; + fn get_help_str() -> impl Future + Send; /// Run the command and convert the result into type-agnostic serialized information, /// then hand it over to the universal renderer for rendering. @@ -72,7 +72,10 @@ where // skip execution and directly render help information if ctx.help { let mut r = JVRenderResult::default(); - r_println!(r, "{}", Self::get_help_str()); + let help_str = Self::get_help_str().await; + if !help_str.is_empty() { + r_println!(r, "{}", help_str); + } return Ok(r); } @@ -112,7 +115,7 @@ where t = type_name::() ) ); - return Err(CmdProcessError::ParseError(Self::get_help_str())); + return Err(CmdProcessError::ParseError(Self::get_help_str().await)); } }; diff --git a/src/systems/cmd/macros.rs b/src/systems/cmd/macros.rs index 093d178..e9af1ac 100644 --- a/src/systems/cmd/macros.rs +++ b/src/systems/cmd/macros.rs @@ -51,7 +51,7 @@ /// type Collect = JVCustomCollect; /// /// /// Return a string, rendered when the user needs help (command specifies `--help` or syntax error) -/// fn help_str() -> String { +/// async fn help_str() -> String { /// todo!() /// } /// @@ -97,7 +97,7 @@ /// type In = JVCustomInput; /// type Collect = JVCustomCollect; /// -/// fn help_str() -> String { +/// async fn help_str() -> String { /// todo!() /// } /// @@ -121,8 +121,8 @@ macro_rules! command_template { () => { impl $crate::systems::cmd::cmd_system::JVCommand for Cmd { - fn get_help_str() -> String { - help_str() + async fn get_help_str() -> String { + help_str().await } async fn prepare( diff --git a/src/systems/helpdoc.rs b/src/systems/helpdoc.rs new file mode 100644 index 0000000..4e5b2a6 --- /dev/null +++ b/src/systems/helpdoc.rs @@ -0,0 +1,18 @@ +pub mod helpdoc_viewer; + +helpdoc_system_macros::generate_helpdoc_mapping!(); +helpdoc_system_macros::generate_helpdoc_list!(); +helpdoc_system_macros::generate_helpdoc_test!(); + +pub fn get_helpdoc<'a>(doc_name: &'a str, lang: &'a str) -> &'a str { + let doc = get_doc(doc_name, lang); + if doc.is_empty() && lang != "en" { + get_doc(doc_name, "en") + } else { + doc + } +} + +pub fn get_helpdoc_list<'a>() -> Vec<&'a str> { + get_docs_list() +} diff --git a/src/systems/helpdoc/helpdoc_viewer.rs b/src/systems/helpdoc/helpdoc_viewer.rs new file mode 100644 index 0000000..1968775 --- /dev/null +++ b/src/systems/helpdoc/helpdoc_viewer.rs @@ -0,0 +1,636 @@ +use crate::systems::helpdoc::{get_helpdoc, get_helpdoc_list}; +use cli_utils::{display::markdown::Markdown, env::locales::current_locales}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor}, + terminal::{ + Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, + enable_raw_mode, + }, +}; +use rust_i18n::t; +use std::{ + collections::{BTreeMap, HashMap}, + io::{Write, stdout}, +}; + +struct HelpdocViewer { + /// Current language + lang: String, + + /// Document tree structure + doc_tree: DocTree, + + /// Currently selected document path + current_doc: String, + + /// Scroll position history + scroll_history: HashMap, + + /// Current focus area + focus: FocusArea, + + /// Currently selected node index in tree view + tree_selection_index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum FocusArea { + Tree, + Content, +} + +#[derive(Debug, Clone)] +struct DocTreeNode { + /// Node name + name: String, + + /// Full document path + path: String, + + /// Child nodes + children: BTreeMap, + + /// Whether it is a document file + is_document: bool, +} + +#[derive(Debug, Clone)] +struct DocTree { + /// Root node + root: DocTreeNode, + + /// Flattened document list + flat_docs: Vec, +} + +impl HelpdocViewer { + fn new(default_doc: &str, lang: &str) -> Self { + // Build the document tree + let doc_tree = Self::build_doc_tree(); + + // Validate if the default document exists + let current_doc = if doc_tree.contains_doc(default_doc) { + default_doc.to_string() + } else { + // If the default document does not exist, use the first document + doc_tree.flat_docs.first().cloned().unwrap_or_default() + }; + + // Calculate the initial tree selection index + let tree_selection_index = doc_tree + .flat_docs + .iter() + .position(|doc| *doc == current_doc) + .unwrap_or(0); + + Self { + lang: lang.to_string(), + doc_tree, + current_doc, + scroll_history: HashMap::new(), + focus: FocusArea::Content, + tree_selection_index, + } + } + + /// Build document tree + fn build_doc_tree() -> DocTree { + // Get all document list + let doc_list = get_helpdoc_list(); + + // Create root node + let mut root = DocTreeNode { + name: "helpdoc".to_string(), + path: "".to_string(), + children: BTreeMap::new(), + is_document: false, + }; + + // Build tree structure for each document path + for doc_path in doc_list { + Self::add_doc_to_tree(&mut root, doc_path); + } + + // Build flattened document list + let flat_docs = Self::flatten_doc_tree(&root); + + DocTree { root, flat_docs } + } + + /// Add document to tree + fn add_doc_to_tree(root: &mut DocTreeNode, doc_path: &str) { + let parts: Vec<&str> = doc_path.split('/').collect(); + + // Use recursive helper function to avoid borrowing issues + Self::add_doc_to_tree_recursive(root, &parts, 0); + } + + /// Recursively add document to tree + fn add_doc_to_tree_recursive(node: &mut DocTreeNode, parts: &[&str], depth: usize) { + if depth >= parts.len() { + return; + } + + let part = parts[depth]; + let is_document = depth == parts.len() - 1; + let current_path = parts[0..=depth].join("/"); + + // Check if node already exists, create if not + if !node.children.contains_key(part) { + let new_node = DocTreeNode { + name: part.to_string(), + path: current_path.clone(), + children: BTreeMap::new(), + is_document, + }; + node.children.insert(part.to_string(), new_node); + } + + // Get mutable reference to child node + if let Some(child) = node.children.get_mut(part) { + // If this is a document node, ensure it's marked as document + if is_document { + child.is_document = true; + } + // Recursively process next part + Self::add_doc_to_tree_recursive(child, parts, depth + 1); + } + } + + /// Flatten document tree + fn flatten_doc_tree(node: &DocTreeNode) -> Vec { + let mut result = Vec::new(); + + if node.is_document && !node.path.is_empty() { + result.push(node.path.clone()); + } + + // Traverse child nodes in alphabetical order + for child in node.children.values() { + result.extend(Self::flatten_doc_tree(child)); + } + + result + } + + /// Get current document content + fn current_doc_content(&self) -> String { + let content = get_helpdoc(&self.current_doc, &self.lang); + if content.is_empty() { + format!("Document '{}.{}' not found", self.current_doc, self.lang) + } else { + content.to_string() + } + } + + /// Get formatted document content + fn formatted_doc_content(&self) -> Vec { + let content = self.current_doc_content(); + let formatted = content.markdown(); + formatted.lines().map(|s| s.to_string()).collect() + } + + /// Run viewer + async fn run(&mut self) -> std::io::Result<()> { + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen, Hide)?; + + let mut should_exit = false; + + while !should_exit { + self.draw()?; + + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + should_exit = self.handle_key(key); + } + } + } + + execute!(stdout(), Show, LeaveAlternateScreen)?; + disable_raw_mode()?; + + Ok(()) + } + + /// Draw interface + fn draw(&self) -> std::io::Result<()> { + let (width, height) = crossterm::terminal::size()?; + + if height <= 3 { + let content = self.current_doc_content(); + println!("{}", content); + return Ok(()); + } + + execute!(stdout(), Clear(ClearType::All))?; + + let tree_width = 25; + let content_width = width - tree_width - 1; + + // Draw title + execute!( + stdout(), + MoveTo(0, 0), + SetForegroundColor(Color::Cyan), + Print(format!("JVCS Help Docs - {}", self.lang)), + ResetColor + )?; + + // Draw separator line + for y in 1..height { + execute!(stdout(), MoveTo(tree_width, y), Print("│"))?; + } + + // Draw tree view + self.draw_tree(0, 2, tree_width, height - 4)?; + + // Draw content area + self.draw_content(tree_width + 1, 2, content_width, height - 4)?; + + // Draw status bar + self.draw_status_bar(height - 1, width)?; + + stdout().flush()?; + Ok(()) + } + + /// Draw tree view + fn draw_tree(&self, x: u16, y: u16, width: u16, height: u16) -> std::io::Result<()> { + // Draw tree view title + execute!( + stdout(), + MoveTo(x, y - 1), + SetForegroundColor(Color::Yellow), + Print("Documents"), + ResetColor + )?; + + // Recursively draw tree structure + let mut line_counter = 0; + let max_lines = height as usize; + + // Skip root node, start drawing from children + for child in self.doc_tree.root.children.values() { + line_counter = self.draw_tree_node(child, x, y, width, 0, line_counter, max_lines)?; + if line_counter >= max_lines { + break; + } + } + + Ok(()) + } + + /// Recursively draw tree node + fn draw_tree_node( + &self, + node: &DocTreeNode, + x: u16, + start_y: u16, + width: u16, + depth: usize, + mut line_counter: usize, + max_lines: usize, + ) -> std::io::Result { + if line_counter >= max_lines { + return Ok(line_counter); + } + + let line_y = start_y + line_counter as u16; + line_counter += 1; + + // Build indentation and suffix + let indent = " ".repeat(depth); + let suffix = if node.children.is_empty() { "" } else { "/" }; + + // If this is the currently selected document, highlight it (white background, black text) + let is_selected = node.path == self.current_doc; + + if is_selected { + // Highlight with white background and black text + execute!( + stdout(), + MoveTo(x, line_y), + SetForegroundColor(Color::Black), + SetBackgroundColor(Color::White), + Print(" ".repeat(width as usize)), + MoveTo(x, line_y), + SetForegroundColor(Color::Black), + )?; + } else { + // Normal display + execute!( + stdout(), + MoveTo(x, line_y), + SetForegroundColor(Color::White), + SetBackgroundColor(Color::Black), + )?; + } + + // Display node name + let display_text = format!("{} {}{}", indent, node.name, suffix); + execute!(stdout(), Print(display_text))?; + execute!(stdout(), ResetColor, SetBackgroundColor(Color::Black))?; + + // Recursively draw child nodes + if !node.children.is_empty() { + for child in node.children.values() { + line_counter = self.draw_tree_node( + child, + x, + start_y, + width, + depth + 1, + line_counter, + max_lines, + )?; + if line_counter >= max_lines { + break; + } + } + } + + Ok(line_counter) + } + + /// Draw content area + fn draw_content(&self, x: u16, y: u16, width: u16, height: u16) -> std::io::Result<()> { + // Draw content area title + execute!( + stdout(), + MoveTo(x, y - 1), + SetForegroundColor(Color::Yellow), + Print(format!("> {}", self.current_doc)), + ResetColor + )?; + + // Get formatted content + let content_lines = self.formatted_doc_content(); + let scroll_pos = self + .scroll_history + .get(&self.current_doc) + .copied() + .unwrap_or(0); + let start_line = scroll_pos.min(content_lines.len().saturating_sub(1)); + let end_line = (start_line + height as usize).min(content_lines.len()); + + for (i, line) in content_lines + .iter() + .enumerate() + .take(end_line) + .skip(start_line) + { + let line_y = y + i as u16 - start_line as u16; + let display_line = if line.len() > width as usize { + let mut end = width as usize; + while end > 0 && !line.is_char_boundary(end) { + end -= 1; + } + if end == 0 { + // If the first character is multi-byte, display at least one character + let mut end = 1; + while end < line.len() && !line.is_char_boundary(end) { + end += 1; + } + &line[..end] + } else { + &line[..end] + } + } else { + line + }; + + execute!(stdout(), MoveTo(x, line_y), Print(display_line))?; + } + + // Display scroll position indicator + if content_lines.len() > height as usize && content_lines.len() > 0 { + let scroll_percent = if content_lines.len() > 0 { + (scroll_pos * 100) / content_lines.len() + } else { + 0 + }; + execute!( + stdout(), + MoveTo(x + width - 5, y - 1), + SetForegroundColor(Color::DarkGrey), + Print(format!("{:3}%", scroll_percent)), + ResetColor + )?; + } + + Ok(()) + } + + /// Draw status bar + fn draw_status_bar(&self, y: u16, width: u16) -> std::io::Result<()> { + // Draw status bar background + execute!( + stdout(), + MoveTo(0, y), + SetForegroundColor(Color::Black), + Print(" ".repeat(width as usize)), + MoveTo(0, y), + SetForegroundColor(Color::White), + )?; + + let status_text = match self.focus { + FocusArea::Tree => t!("helpdoc_viewer.tree_area_hint").to_string().markdown(), + FocusArea::Content => t!("helpdoc_viewer.content_area_hint") + .to_string() + .markdown(), + } + .to_string(); + + let truncated_text = if status_text.len() > width as usize { + &status_text[..width as usize] + } else { + &status_text + }; + execute!(stdout(), Print(truncated_text))?; + execute!(stdout(), ResetColor)?; + + Ok(()) + } + + /// Handle key input + fn handle_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Char(' ') => self.toggle_focus(), + KeyCode::Left => self.move_left(), + KeyCode::Right => self.move_right(), + KeyCode::Up => self.move_up(), + KeyCode::Down => self.move_down(), + KeyCode::Char('g') if key.modifiers == KeyModifiers::NONE => self.go_to_top(), + KeyCode::Char('G') if key.modifiers == KeyModifiers::SHIFT => self.go_to_bottom(), + KeyCode::Enter => self.select_item(), + _ => {} + } + false + } + + /// Toggle focus area + fn toggle_focus(&mut self) { + self.focus = match self.focus { + FocusArea::Tree => FocusArea::Content, + FocusArea::Content => FocusArea::Tree, + }; + } + + /// Move left + fn move_left(&mut self) { + if self.focus == FocusArea::Content { + self.focus = FocusArea::Tree; + } + } + + /// Move right + fn move_right(&mut self) { + if self.focus == FocusArea::Tree { + self.focus = FocusArea::Content; + } + } + + /// Move up + fn move_up(&mut self) { + match self.focus { + FocusArea::Tree => self.previous_doc(), + FocusArea::Content => self.scroll_up(), + } + } + + /// Move down + fn move_down(&mut self) { + match self.focus { + FocusArea::Tree => self.next_doc(), + FocusArea::Content => self.scroll_down(), + } + } + + /// Scroll to top + fn go_to_top(&mut self) { + match self.focus { + FocusArea::Content => { + self.scroll_history.insert(self.current_doc.clone(), 0); + } + FocusArea::Tree => { + // Select first document + self.tree_selection_index = 0; + if let Some(first_doc) = self.doc_tree.flat_docs.first() { + self.current_doc = first_doc.clone(); + } + } + } + } + + /// Scroll to bottom + fn go_to_bottom(&mut self) { + match self.focus { + FocusArea::Content => { + let content_lines = self.formatted_doc_content(); + if content_lines.len() > 10 { + self.scroll_history + .insert(self.current_doc.clone(), content_lines.len() - 10); + } + } + FocusArea::Tree => { + // Select last document + self.tree_selection_index = self.doc_tree.flat_docs.len().saturating_sub(1); + if let Some(last_doc) = self.doc_tree.flat_docs.last() { + self.current_doc = last_doc.clone(); + } + } + } + } + + /// Select current item + fn select_item(&mut self) { + match self.focus { + FocusArea::Tree => { + // Update current document to the one selected in tree view + if let Some(doc) = self.doc_tree.flat_docs.get(self.tree_selection_index) { + self.current_doc = doc.clone(); + } + // Switch focus to content area + self.focus = FocusArea::Content; + } + _ => {} + } + } + + /// Previous document + fn previous_doc(&mut self) { + if self.tree_selection_index > 0 { + self.tree_selection_index -= 1; + if let Some(doc) = self.doc_tree.flat_docs.get(self.tree_selection_index) { + self.current_doc = doc.clone(); + // Reset scroll position + self.scroll_history.remove(&self.current_doc); + } + } + } + + /// Next document + fn next_doc(&mut self) { + if self.tree_selection_index + 1 < self.doc_tree.flat_docs.len() { + self.tree_selection_index += 1; + if let Some(doc) = self.doc_tree.flat_docs.get(self.tree_selection_index) { + self.current_doc = doc.clone(); + // Reset scroll position + self.scroll_history.remove(&self.current_doc); + } + } + } + + /// Scroll up + fn scroll_up(&mut self) { + let current_scroll = self + .scroll_history + .get(&self.current_doc) + .copied() + .unwrap_or(0); + if current_scroll > 0 { + self.scroll_history + .insert(self.current_doc.clone(), current_scroll - 1); + } + } + + /// Scroll down + fn scroll_down(&mut self) { + let content_lines = self.formatted_doc_content(); + let current_scroll = self + .scroll_history + .get(&self.current_doc) + .copied() + .unwrap_or(0); + if current_scroll + 1 < content_lines.len() { + self.scroll_history + .insert(self.current_doc.clone(), current_scroll + 1); + } + } +} + +impl DocTree { + /// Check if document exists + fn contains_doc(&self, doc_path: &str) -> bool { + self.flat_docs.contains(&doc_path.to_string()) + } +} + +/// Display help document viewer +pub async fn display_with_lang(default_focus_doc: &str, lang: &str) { + let mut viewer = HelpdocViewer::new(default_focus_doc, lang); + + if let Err(e) = viewer.run().await { + eprintln!("Error running helpdoc viewer: {}", e); + } +} + +/// Display help document viewer +pub async fn display(default_focus_doc: &str) { + display_with_lang(default_focus_doc, current_locales().as_str()).await; +} diff --git a/utils/src/display.rs b/utils/src/display.rs index 16c94a9..5b452ce 100644 --- a/utils/src/display.rs +++ b/utils/src/display.rs @@ -1,3 +1,3 @@ -pub mod colorful; +pub mod markdown; pub mod pager; pub mod table; 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