diff options
30 files changed, 1071 insertions, 56 deletions
@@ -1027,6 +1027,15 @@ 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", @@ -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<proc_macro2::TokenStream> { + 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 <FILE> 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 <FILE> 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 <SHEET_FILE> # Default output +jvn sheetdump <SHEET_FILE> --no-sort # No sorting +jvn sheetdump <SHEET_FILE> --no-pretty # No prettifying + +## Tip +You can also use `renderer override` to access the internal structure of a `Sheet`, +for example: +jvn sheetdump <SHEET_FILE> --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 <SHEET_FILE> # 默认输出 +jvn sheetdump <SHEET_FILE> --no-sort # 无排序 +jvn sheetdump <SHEET_FILE> --no-pretty # 无美化 + +## 提示 +您也可以使用 `渲染器重载` 来访问 `Sheet` 的内部结构,例如 +jvn sheetdump <SHEET_FILE> --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 <FILE> + +## 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 <FILE> + +## 注意 +它读取按照以下优先级寻找命令行编辑器程序: +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 <command> --help` to view help + Please use `jvn <command> --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 <structure_sheet_name>`. + No sheet in use. Use `jvn use <structure_sheet_name>`. 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<In, CmdPrepareError> { + Ok(JVHelpdocInput { + name: args.doc_name.clone(), + lang: ctx.lang.clone(), + }) +} + +async fn collect(_args: &Arg, _ctx: &JVCommandContext) -> Result<Collect, CmdPrepareError> { + Ok(JVEmptyCollect) +} + +#[exec] +async fn exec( + input: In, + _collect: Collect, +) -> Result<(Box<dyn std::any::Any + Send + 'static>, 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<In, CmdPrepareError> { 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<In, CmdPrepareError> { 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<In, CmdPrepareError> { 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<Output = String> + 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::<Argument>() ) ); - 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<Arg, In, Collect> 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<String, usize>, + + /// 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<String, DocTreeNode>, + + /// Whether it is a document file + is_document: bool, +} + +#[derive(Debug, Clone)] +struct DocTree { + /// Root node + root: DocTreeNode, + + /// Flattened document list + flat_docs: Vec<String>, +} + +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<String> { + 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<String> { + 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<usize> { + 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/markdown.rs index 40f83bf..40f83bf 100644 --- a/utils/src/display/colorful.rs +++ b/utils/src/display/markdown.rs |
