From f8d96d07be8c440be0e8e177c936c8ef1f0f3467 Mon Sep 17 00:00:00 2001 From: jinlong Date: Fri, 12 Sep 2025 16:39:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=87=AA=E5=8A=A8=E6=8D=A2=E8=A1=8C=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持智能文本自动换行,确保AI生成的提交消息和分支名在终端中正确显示 - 实现代码块保护、链接保留、行内代码处理等高级文本处理功能 - 添加CLI参数 --no-wrap 和 --wrap-width 控制文本换行行为 - 支持配置文件控制文本换行行为 - 更新文档说明新功能的使用方法 Signed-off-by: jinlong --- Cargo.lock | 23 +- Cargo.toml | 4 +- README.md | 18 +- README_CN.md | 17 +- docs/TEXT_WRAPPING.md | 423 +++++++++++++++++++++++++++++ src/cli.rs | 9 + src/config.rs | 51 ++++ src/constants.rs | 3 +- src/main.rs | 50 +++- src/text_wrapper/hybrid_wrapper.rs | 309 +++++++++++++++++++++ src/text_wrapper/mod.rs | 165 +++++++++++ src/text_wrapper/text_wrapper.rs | 126 +++++++++ src/text_wrapper/types.rs | 68 +++++ 13 files changed, 1257 insertions(+), 9 deletions(-) create mode 100644 docs/TEXT_WRAPPING.md create mode 100644 src/text_wrapper/hybrid_wrapper.rs create mode 100644 src/text_wrapper/mod.rs create mode 100644 src/text_wrapper/text_wrapper.rs create mode 100644 src/text_wrapper/types.rs diff --git a/Cargo.lock b/Cargo.lock index 1a010ca..0feca44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,7 +359,7 @@ dependencies = [ [[package]] name = "fastcommit" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "chrono", @@ -375,8 +375,10 @@ dependencies = [ "reqwest", "serde", "serde_json", + "terminal_size", "tokio", "toml", + "unicode-width", ] [[package]] @@ -1555,6 +1557,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "2.0.12" @@ -2025,6 +2037,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index d40d4cc..1929da2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastcommit" -version = "0.4.0" +version = "0.5.0" description = "AI-based command line tool to quickly generate standardized commit messages." edition = "2021" authors = ["longjin "] @@ -25,3 +25,5 @@ tokio = { version = "1.43.0", features = ["full"] } rand = "0.8.5" indicatif = "0.17.8" toml = "0.8.20" +unicode-width = "0.2.0" +terminal_size = "0.4.0" diff --git a/README.md b/README.md index 954bce3..f71938f 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ You can install `fastcommit` using the following method: ```bash # Install using cargo -cargo install --git https://github.com/fslongjin/fastcommit --tag v0.4.0 +cargo install --git https://github.com/fslongjin/fastcommit --tag v0.5.0 ``` + ## Usage ### Basic Usage @@ -35,6 +36,8 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` - `-v, --verbosity `: Set the detail level of the commit message. Acceptable values are `verbose` (detailed), `normal`, or `quiet` (concise). The default is `quiet`. - `-p, --prompt `: Additional prompt to help AI understand the commit context. - `-r, --range `: Specify diff range for generating commit message (e.g. HEAD~1, abc123..def456). +- `--no-wrap`: Disable text wrapping for long lines. +- `--wrap-width `: Set custom line width for text wrapping (default: config file setting or 80). - `-h, --help`: Print help information. - `-V, --version`: Print version information. @@ -86,6 +89,19 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` fastcommit -r abc123..def456 ``` +8. Control text wrapping behavior: + + ```bash + # Disable text wrapping + fastcommit --no-wrap + + # Set custom line width + fastcommit --wrap-width 60 + + # Combine with other options + fastcommit -b -m --wrap-width 100 + ``` + ## Contributing Contributions of code or suggestions are welcome! Please read the [Contributing Guide](CONTRIBUTING.md) first. diff --git a/README_CN.md b/README_CN.md index aaf8102..06665d6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -8,7 +8,7 @@ ```bash # 使用 cargo 安装 -cargo install --git https://github.com/fslongjin/fastcommit --tag v0.4.0 +cargo install --git https://github.com/fslongjin/fastcommit --tag v0.5.0 ``` ## 使用 @@ -33,6 +33,8 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` - `-v, --verbosity `: 设置提交信息的详细级别。可选值为 `verbose`(详细)、`normal`(正常)或 `quiet`(简洁)。 默认为 `quiet`。 - `-p, --prompt `: 额外的提示信息,帮助 AI 理解提交上下文。 - `-r, --range `: 指定差异范围以生成提交信息(例如:HEAD~1, abc123..def456)。 +- `--no-wrap`: 禁用长行文本换行。 +- `--wrap-width `: 设置文本换行的自定义行宽度(默认:配置文件设置或 80)。 - `-h, --help`: 打印帮助信息。 - `-V, --version`: 打印版本信息。 @@ -84,6 +86,19 @@ NOTE: All common config can be configured via `~/.fastcommit/config.toml` fastcommit -r abc123..def456 ``` +8. 控制文本换行行为: + + ```bash + # 禁用文本换行 + fastcommit --no-wrap + + # 设置自定义行宽度 + fastcommit --wrap-width 60 + + # 与其他选项组合使用 + fastcommit -b -m --wrap-width 100 + ``` + ## 贡献 欢迎贡献代码或提出建议!请先阅读 [贡献指南](CONTRIBUTING.md)。 diff --git a/docs/TEXT_WRAPPING.md b/docs/TEXT_WRAPPING.md new file mode 100644 index 0000000..4161d6e --- /dev/null +++ b/docs/TEXT_WRAPPING.md @@ -0,0 +1,423 @@ +# Text Auto-Wrapping Feature + +## Overview + +`fastcommit` now supports intelligent text auto-wrapping functionality, ensuring AI-generated commit messages and branch names are displayed properly in the terminal, enhancing user experience. This feature includes rich text processing capabilities such as code block protection, link preservation, inline code handling, and other advanced features. + +## Feature Highlights + +### 🎯 Smart Wrapping Strategy +- **Word boundary protection**: Automatically wraps at word boundaries to avoid breaking word integrity +- **Chinese-English mixed support**: Intelligently recognizes Chinese characters and handles mixed text correctly +- **Long word handling**: Optionally force-wrap at appropriate positions for extremely long words +- **Special text protection**: Intelligently recognizes and protects special formats like code blocks and links + +### 🏗️ Text Segmentation Processing +- **Multi-layer parsing architecture**: Uses layered parsing to handle complex nested text structures +- **Intelligent segmentation recognition**: Automatically identifies different text types like code blocks, links, and inline code +- **Priority processing**: Handles different text segment types in order of importance + +### 🔗 Code Block Processing +- **Code block detection**: Automatically recognizes ```code``` format code blocks +- **Format protection**: Code blocks maintain original format without being broken by word wrapping +- **Smart wrapping**: Automatically adds line breaks before and after code blocks to ensure readability +- **Configuration control**: Can be enabled or disabled through configuration + +### 🔗 Link Preservation +- **Multi-format support**: Supports direct URLs and Markdown link formats +- **Smart conversion**: Automatically converts URLs to Markdown format to maintain readability +- **Link protection**: Ensures URL integrity, avoiding being split by line breaks +- **Text extraction**: Optionally display only link text, hiding full URLs + +### ⚡ Inline Code Processing +- **Format recognition**: Automatically recognizes `code` format inline code +- **Protection mechanism**: Inline code maintains original format without being split by line breaks +- **Display optimization**: Ensures inline code readability when wrapping + +### ⚙️ Flexible Configuration +- **Configuration file control**: Control default behavior via `~/.fastcommit/config.toml` +- **Command line arguments**: Override configuration at runtime via CLI arguments +- **Terminal adaptation**: Automatically detects terminal width or uses user-specified width +- **Priority management**: CLI arguments have higher priority than configuration file settings + +## Configuration Options + +### Configuration File Settings + +Add the following configuration to `~/.fastcommit/config.toml`: + +```toml +[text_wrap] +# Enable text wrapping (default: true) +enabled = true +# Default wrapping width (default: 80) +default_width = 80 +# Protect word boundaries (default: true) +preserve_words = true +# Break long words when necessary (default: true) +break_long_words = true +# Handle code blocks (default: true) +handle_code_blocks = true +# Preserve link integrity (default: true) +preserve_links = true +``` + +### Configuration Option Details + +#### `enabled` - Master Switch +- **Type**: `bool` +- **Default**: `true` +- **Description**: Controls whether text wrapping functionality is enabled. When set to `false`, all text wrapping processing is disabled. + +#### `default_width` - Default Width +- **Type**: `usize` +- **Default**: `80` +- **Description**: Sets the default width for text wrapping (number of characters). Used when `--wrap-width` is not specified. + +#### `preserve_words` - Word Protection +- **Type**: `bool` +- **Default**: `true` +- **Description**: Whether to wrap at word boundaries. When `true`, wrapping won't occur in the middle of words; when `false`, wrapping can occur at any position. + +#### `break_long_words` - Long Word Handling +- **Type**: `bool` +- **Default**: `true` +- **Description**: Whether to force-break long words at appropriate positions when word length exceeds wrapping width. Only effective when `preserve_words = true`. + +#### `handle_code_blocks` - Code Block Processing +- **Type**: `bool` +- **Default**: `true` +- **Description**: Whether to specially handle code blocks. When enabled, code blocks are separated and line breaks are added before and after them. + +#### `preserve_links` - Link Preservation +- **Type**: `bool` +- **Default**: `true` +- **Description**: Whether to preserve complete link format. When enabled, URLs are converted to Markdown format; when disabled, only link text is displayed. + +### Command Line Arguments + +```bash +# Disable text wrapping +fastcommit --no-wrap + +# Specify wrapping width +fastcommit --wrap-width 60 + +# Force enable wrapping (overrides disabled settings in config file) +fastcommit --force-wrap + +# Combined usage +fastcommit --wrap-width 100 --force-wrap +``` + +### Priority Explanation + +Parameter priority (from highest to lowest): +1. Command line `--force-wrap` parameter (force enable) +2. Command line `--no-wrap` parameter (force disable) +3. Command line `--wrap-width` parameter +4. `default_width` setting in configuration file +5. Auto-detected terminal width +6. Default value 80 + +## Usage Examples + +### Basic Usage + +```bash +# Use default configuration text wrapping +fastcommit + +# Long commit messages will automatically wrap when generated +``` + +### Custom Width + +```bash +# Set narrower display width +fastcommit --wrap-width 60 + +# Set wider display width +fastcommit --wrap-width 120 +``` + +### Disable Wrapping + +```bash +# Completely disable text wrapping +fastcommit --no-wrap + +# Only disables for current execution, configuration file settings remain unchanged +``` + +### Generate Branch Names + +```bash +# Generate branch name and apply wrapping +fastcommit -b + +# Generate branch name and commit message simultaneously +fastcommit -b -m +``` + +### Complex Scenario Examples + +```bash +# Handle commit messages containing code blocks +fastcommit -m "Fixed database connection issues and added retry mechanism + +```sql +SELECT * FROM users WHERE status = 'active' +``` + +Also optimized API response time" + +# Handle technical documentation containing links +fastcommit -m "Updated README documentation, see https://github.com/example/project for more info, and refactored code following [Best Practices Guide](https://docs.example.com/best-practices)" + +# Handle mixed content technical commits +fastcommit -m "Added caching mechanism in `UserService`, using Redis for data storage, see ```redis_client.rs``` file for details" +``` + +### Configuration File Example + +```toml +# ~/.fastcommit/config.toml +[text_wrap] +enabled = true +default_width = 80 +preserve_words = true +break_long_words = true +handle_code_blocks = true +preserve_links = true +``` + +## Technical Implementation + +### Core Algorithms + +1. **Hybrid Wrapping Strategy**: + - Prioritize wrapping at word boundaries + - For Chinese text, wrap at punctuation marks + - Force wrap at character boundaries when necessary + +2. **Unicode Width Calculation**: + - Correctly handle display width of Chinese characters + - Support CJK character width calculation + - Compatible with various terminal fonts + +3. **Special Text Recognition**: + - Code block recognition: ```code``` + - Link recognition: http://... and [text](url) + - Inline code: `code` + +### Architecture Design + +``` +TextWrapper (High-level Interface) +├── WrapConfig (Configuration Management) +├── HybridWrapper (Hybrid Strategy Implementation) +├── WordBoundaryWrapper (Word Boundary Strategy) +├── SemanticWrapper (Semantic Awareness Strategy) +├── TextSegmentProcessor (Text Segment Processing) +└── OutputRenderer (Output Rendering) +``` + +### Performance Optimization + +- **Lazy initialization**: Regular expressions compiled on demand +- **Memory efficient**: Avoid unnecessary string copying +- **Fast algorithm**: Linear time complexity wrapping algorithm + +### Code Structure + +```rust +// Core type definitions +pub struct WrapConfig { + pub max_width: usize, + pub preserve_words: bool, + pub break_long_words: bool, + pub handle_code_blocks: bool, + pub preserve_links: bool, + pub strategy: WrapStrategy, + pub indent: String, + pub hanging_indent: String, +} + +// Text segment types +pub enum TextSegment { + PlainText(String), + CodeBlock(String), + Link(String, String), + InlineCode(String), +} + +// Wrapper trait +pub trait WordWrapper { + fn wrap_text(&self, text: &str, config: &WrapConfig) -> String; + fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String; +} +``` + +## Troubleshooting + +### Common Issues + +**Q: Why isn't my text automatically wrapping?** +A: Please check: +1. Whether `text_wrap.enabled` in configuration file is `true` +2. Whether `--no-wrap` parameter is used +3. Whether terminal width is sufficient to accommodate text +4. Whether there are conflicting `--force-wrap` parameters + +**Q: Chinese characters displaying incorrectly?** +A: Ensure: +1. Terminal supports Unicode characters +2. Font settings are correct +3. `preserve_words` option is not disabled +4. Using terminal font that supports CJK characters + +**Q: Code blocks being unexpectedly wrapped?** +A: Check: +1. Whether `handle_code_blocks` setting is correct +2. Whether code block markers are complete (three backticks) +3. Whether code block content contains special characters + +**Q: Links being split across multiple lines?** +A: Check: +1. Whether `preserve_links` is `true` +2. Whether link length exceeds `max_width` +3. Whether `break_long_words` settings are affecting it + +**Q: Long words or URLs not being handled correctly?** +A: Check: +1. Whether `break_long_words` is `true` +2. Whether `preserve_words` setting is appropriate +3. Whether `max_width` setting is too small + +### Debugging Methods + +Enable verbose logging: + +```bash +RUST_LOG=debug fastcommit +``` + +View current configuration: + +```bash +cat ~/.fastcommit/config.toml +``` + +Test different configurations: + +```bash +# Test different widths +fastcommit --wrap-width 60 +fastcommit --wrap-width 100 + +# Test disabling wrapping +fastcommit --no-wrap + +# Test force enabling +fastcommit --force-wrap +``` + +Check terminal support: + +```bash +echo $COLUMNS +echo $TERM +``` + +## Best Practices + +### Recommended Configuration + +For most users, the following configuration is recommended: + +```toml +[text_wrap] +enabled = true +default_width = 80 +preserve_words = true +break_long_words = true +handle_code_blocks = true +preserve_links = true +``` + +### Adjustments for Different Scenarios + +**Narrow terminals (mobile devices)**: +```toml +[text_wrap] +enabled = true +default_width = 50 +preserve_words = true +break_long_words = false +``` + +**Wide screens (desktop development)**: +```toml +[text_wrap] +enabled = true +default_width = 100 +preserve_words = true +break_long_words = true +``` + +**Code repositories (with lots of technical details)**: +```toml +[text_wrap] +enabled = true +default_width = 80 +handle_code_blocks = true +preserve_links = true +preserve_words = true +``` + +**Plain text repositories (minimal formatting)**: +```toml +[text_wrap] +enabled = true +default_width = 80 +handle_code_blocks = false +preserve_links = false +``` + +### Performance Optimization Tips + +1. **Set reasonable width**: Avoid setting too small `max_width`, this causes frequent wrapping affecting performance +2. **Disable unnecessary features**: If code block or link processing is not needed, disable corresponding options +3. **Use appropriate strategies**: Choose suitable wrapping strategies based on text type +4. **Avoid frequent configuration changes**: Configuration changes trigger re-initialization, affecting performance + +### Maintenance Tips + +1. **Regularly update configuration**: Adjust configuration promptly as terminal environment changes +2. **Monitor performance**: If performance issues are found, check for unnecessary enabled features +3. **Backup configuration**: Important configuration files should have backups +4. **Test new features**: Verify new features in test environment before using in production + +## Future Improvements + +### Planned Features + +- [ ] **Semantic-aware wrapping**: More intelligent wrapping based on sentence structure +- [ ] **Multi-language support**: Enhanced support for Japanese, Korean and other languages +- [ ] **Color themes**: Support for different color theme outputs +- [ ] **Real-time preview**: Interactive wrapping preview functionality +- [ ] **Configuration validation**: Configuration file syntax checking and validation + +### Contribution Guidelines + +Contributions are welcome! Please refer to: + +1. Code style: Follow project's existing Rust code style +2. Test coverage: New features need corresponding unit tests +3. Documentation updates: Update related documentation and examples +4. Performance considerations: Ensure new features don't significantly impact performance + +## License + +This feature follows the project's MIT license. \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 4e31ae5..8af73cb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -59,4 +59,13 @@ pub struct Args { help = "Temporarily disable sensitive info sanitizer for this run" )] pub no_sanitize: bool, + + #[clap(long = "no-wrap", help = "Disable text wrapping for long lines")] + pub no_wrap: bool, + + #[clap( + long = "wrap-width", + help = "Set custom line width for text wrapping (default: terminal width)" + )] + pub wrap_width: Option, } diff --git a/src/config.rs b/src/config.rs index 979ac9a..c3e3b5a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,53 @@ fn default_true() -> bool { true } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TextWrapConfig { + /// Enable text wrapping for long lines + #[serde(default = "default_true")] + pub enabled: bool, + /// Default line width for text wrapping + #[serde(default = "default_wrap_width")] + pub default_width: usize, + /// Preserve word boundaries when wrapping + #[serde(default = "default_true")] + pub preserve_words: bool, + /// Break long words when necessary + #[serde(default = "default_true")] + pub break_long_words: bool, + /// Handle code blocks specially + #[serde(default = "default_true")] + pub handle_code_blocks: bool, + /// Preserve links in text + #[serde(default = "default_true")] + pub preserve_links: bool, + /// Hanging indent for wrapped lines (empty string for no indent) + #[serde(default = "default_hanging_indent")] + pub hanging_indent: String, +} + +fn default_wrap_width() -> usize { + 80 +} + +fn default_hanging_indent() -> String { + String::new() // 默认无悬挂缩进 +} + +impl Default for TextWrapConfig { + fn default() -> Self { + Self { + enabled: true, + default_width: 80, + preserve_words: true, + break_long_words: true, + handle_code_blocks: true, + preserve_links: true, + hanging_indent: String::new(), + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CustomSanitizePattern { /// A short name/identifier for the pattern. e.g. "INTERNAL_URL" @@ -34,6 +81,9 @@ pub struct Config { /// User defined extra regex patterns for sanitizer. #[serde(default)] pub custom_sanitize_patterns: Vec, + /// Text wrapping configuration + #[serde(default)] + pub text_wrap: TextWrapConfig, } impl Config { @@ -124,6 +174,7 @@ impl Default for Config { branch_prefix: None, sanitize_secrets: true, custom_sanitize_patterns: Vec::new(), + text_wrap: TextWrapConfig::default(), } } } diff --git a/src/constants.rs b/src/constants.rs index dcc85d8..9bc1b4d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -60,7 +60,8 @@ message内容要使用标签包裹,例如: (这里是commit标题) -(这里是commit message内容) +- (这里是commit message内容) +- (这里是commit message内容) diff --git a/src/main.rs b/src/main.rs index 712b62e..579fdca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use clap::Parser; use log::error; +use text_wrapper::{TextWrapper, WrapConfig}; mod animation; mod cli; @@ -8,6 +9,7 @@ mod constants; mod generate; mod sanitizer; mod template_engine; +mod text_wrapper; mod update_checker; #[tokio::main] @@ -36,6 +38,18 @@ async fn main() -> anyhow::Result<()> { config.sanitize_secrets = false; } + // 确定是否启用文本包装 (CLI 参数优先级高于配置) + let enable_wrapping = !args.no_wrap && config.text_wrap.enabled; + + // 预创建统一的包装配置和包装器 (如果需要) + let wrapper = if enable_wrapping { + let wrap_config = + WrapConfig::from_config_and_args(&config.text_wrap, args.wrap_width, false); + Some(TextWrapper::new(wrap_config)) + } else { + None + }; + run_update_checker().await; // 根据参数决定生成内容: @@ -47,23 +61,51 @@ async fn main() -> anyhow::Result<()> { let (branch_name, msg) = generate::generate_both(&args, &config).await?; // 停止spinner动画 spinner.finish(); - println!("Generated branch name: {}", branch_name); - println!("{}", msg); + + print_wrapped_content(&wrapper, &branch_name, Some("Generated branch name:")); + print_wrapped_content(&wrapper, &msg, None); } else if args.generate_branch { let branch_name = generate::generate_branch(&args, &config).await?; // 停止spinner动画 spinner.finish(); - println!("Generated branch name: {}", branch_name); + + print_wrapped_content(&wrapper, &branch_name, Some("Generated branch name:")); } else { // 包括:无参数 或 仅 --m let msg = generate::generate(&args, &config).await?; // 停止spinner动画 spinner.finish(); - println!("{}", msg); + + // 对于提交消息,需要启用段落保留 + let final_wrapper = if enable_wrapping { + let wrap_config = + WrapConfig::from_config_and_args(&config.text_wrap, args.wrap_width, true); + Some(TextWrapper::new(wrap_config)) + } else { + None + }; + + print_wrapped_content(&final_wrapper, &msg, None); } Ok(()) } +fn print_wrapped_content(wrapper: &Option, content: &str, prefix: Option<&str>) { + if let Some(wrapper) = wrapper { + if let Some(p) = prefix { + println!("{} {}", p, wrapper.wrap(content)); + } else { + println!("{}", wrapper.wrap(content)); + } + } else { + if let Some(p) = prefix { + println!("{} {}", p, content); + } else { + println!("{}", content); + } + } +} + async fn run_update_checker() { match update_checker::check_for_updates().await { Ok(Some(update_info)) => { diff --git a/src/text_wrapper/hybrid_wrapper.rs b/src/text_wrapper/hybrid_wrapper.rs new file mode 100644 index 0000000..49c01d8 --- /dev/null +++ b/src/text_wrapper/hybrid_wrapper.rs @@ -0,0 +1,309 @@ +use super::types::*; +use regex::Regex; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub struct HybridWrapper { + code_block_regex: Regex, + link_regex: Regex, + inline_code_regex: Regex, +} + +impl HybridWrapper { + pub fn new() -> Self { + Self { + code_block_regex: Regex::new(r"```[\s\S]*?```").unwrap(), + link_regex: Regex::new(r"https?://[^\s]+|\[([^\]]+)\]\(([^)]+)\)").unwrap(), + inline_code_regex: Regex::new(r"`[^`]+`").unwrap(), + } + } +} + +impl WordWrapper for HybridWrapper { + fn wrap_text(&self, text: &str, config: &WrapConfig) -> String { + if text.is_empty() { + return String::new(); + } + + // 解析文本段 + let segments = self.parse_segments(text); + + // 处理分段文本 + self.wrap_segments(&segments, config) + } + + fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String { + let mut result = String::new(); + let mut current_line = String::new(); + let mut current_width = config.indent.width(); + + for segment in segments { + let processed = self.process_segment(segment, config); + + if current_width + processed.width() <= config.max_width { + current_line.push_str(&processed); + current_width += processed.width(); + } else { + // 当前行放不下,需要换行 + if !current_line.is_empty() { + result.push_str(¤t_line); + result.push('\n'); + } + + // 新行处理 + current_line = config.indent.clone(); + if !current_line.is_empty() { + current_line.push_str(&config.hanging_indent); + } + current_line.push_str(&processed); + current_width = current_line.width(); + } + } + + if !current_line.is_empty() { + result.push_str(¤t_line); + } + + result + } +} + +impl HybridWrapper { + fn parse_segments(&self, text: &str) -> Vec { + let mut segments = Vec::new(); + let mut remaining = text.to_string(); + + // 处理代码块 + while let Some(mat) = self.code_block_regex.find(&remaining) { + let before = &remaining[..mat.start()]; + if !before.is_empty() { + let sub_segments = self.parse_links_and_code(before); + segments.extend(sub_segments); + } + + segments.push(TextSegment::CodeBlock(mat.as_str().to_string())); + remaining = remaining[mat.end()..].to_string(); + } + + // 处理剩余文本中的链接和行内代码 + let final_segments = self.parse_links_and_code(&remaining); + segments.extend(final_segments); + + segments + } + + fn parse_links_and_code(&self, text: &str) -> Vec { + let mut segments = Vec::new(); + let mut remaining = text.to_string(); + + // 处理链接 + while let Some(mat) = self.link_regex.find(&remaining) { + let before = &remaining[..mat.start()]; + if !before.is_empty() { + let code_segments = self.parse_inline_code(before); + segments.extend(code_segments); + } + + let link_text = mat.as_str(); + if link_text.starts_with('[') { + // Markdown 链接 [text](url) + if let Some(caps) = self.link_regex.captures(link_text) { + let text = caps.get(1).unwrap().as_str(); + let url = caps.get(2).unwrap().as_str(); + segments.push(TextSegment::Link(url.to_string(), text.to_string())); + } + } else { + // 直接 URL + segments.push(TextSegment::Link( + link_text.to_string(), + link_text.to_string(), + )); + } + + remaining = remaining[mat.end()..].to_string(); + } + + // 处理剩余文本中的行内代码 + let code_segments = self.parse_inline_code(&remaining); + segments.extend(code_segments); + + segments + } + + fn parse_inline_code(&self, text: &str) -> Vec { + let mut segments = Vec::new(); + let mut remaining = text.to_string(); + + // 处理行内代码 + while let Some(mat) = self.inline_code_regex.find(&remaining) { + let before = &remaining[..mat.start()]; + if !before.is_empty() { + segments.push(TextSegment::PlainText(before.to_string())); + } + + segments.push(TextSegment::InlineCode(mat.as_str().to_string())); + remaining = remaining[mat.end()..].to_string(); + } + + if !remaining.is_empty() { + segments.push(TextSegment::PlainText(remaining)); + } + + segments + } + + fn process_segment(&self, segment: &TextSegment, config: &WrapConfig) -> String { + match segment { + TextSegment::PlainText(text) => { + if config.handle_code_blocks { + self.wrap_plain_text(text, config) + } else { + text.clone() + } + } + TextSegment::CodeBlock(code) => { + if config.handle_code_blocks { + format!("\n{}\n", code) + } else { + code.clone() + } + } + TextSegment::Link(url, text) => { + if config.preserve_links { + format!("[{}]({})", text, url) + } else { + text.clone() + } + } + TextSegment::InlineCode(code) => { + format!("`{}`", code) + } + } + } + + fn wrap_plain_text(&self, text: &str, config: &WrapConfig) -> String { + if config.preserve_paragraphs { + // 保留段落格式的处理方式 + self.wrap_with_paragraphs(text, config) + } else { + // 原有的处理方式 + self.wrap_without_paragraphs(text, config) + } + } + + fn wrap_with_paragraphs(&self, text: &str, config: &WrapConfig) -> String { + let mut result = String::new(); + let paragraphs: Vec<&str> = text.split("\n\n").collect(); + + for (i, paragraph) in paragraphs.iter().enumerate() { + if i > 0 { + result.push_str("\n\n"); // 段落之间保留空行 + } + + // 检查段落内是否有换行符,如果有则保留 + if paragraph.contains('\n') { + let lines: Vec<&str> = paragraph.lines().collect(); + for (j, line) in lines.iter().enumerate() { + if j > 0 { + result.push('\n'); + } + if !line.trim().is_empty() { + let wrapped_line = self.wrap_without_paragraphs(line.trim(), config); + result.push_str(&wrapped_line); + } else { + result.push('\n'); + } + } + } else { + let wrapped_paragraph = self.wrap_without_paragraphs(paragraph.trim(), config); + result.push_str(&wrapped_paragraph); + } + } + + result + } + + fn wrap_without_paragraphs(&self, text: &str, config: &WrapConfig) -> String { + let words: Vec<&str> = if config.preserve_words { + text.split_whitespace().collect() + } else { + text.split(' ').collect() + }; + + let mut lines = Vec::new(); + let mut current_line = String::new(); + let mut current_width = config.indent.width(); + + for word in words { + let word_width = word.width(); + let separator_width = if current_line.is_empty() { 0 } else { 1 }; + + if current_width + separator_width + word_width <= config.max_width { + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(word); + current_width += separator_width + word_width; + } else { + // 当前单词放不下,需要换行 + if config.break_long_words && word_width > config.max_width { + // 长单词强制换行 + if !current_line.is_empty() { + lines.push(current_line); + current_line = String::new(); + current_width = config.indent.width(); + } + + let mut remaining = word; + while !remaining.is_empty() { + let available = config.max_width - current_width; + let (part, rest) = if remaining.width() <= available { + (remaining, "") + } else { + self.break_word_at_width(remaining, available) + }; + + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(part); + current_width = current_line.width(); + + if !rest.is_empty() { + lines.push(current_line); + current_line = config.hanging_indent.clone(); + current_width = current_line.width(); + remaining = rest; + } else { + break; + } + } + } else { + // 普通换行 + if !current_line.is_empty() { + lines.push(current_line); + } + current_line = config.hanging_indent.clone(); + current_line.push_str(word); + current_width = current_line.width(); + } + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines.join("\n") + } + + fn break_word_at_width<'a>(&self, word: &'a str, max_width: usize) -> (&'a str, &'a str) { + let mut current_width = 0; + for (i, ch) in word.char_indices() { + current_width += ch.width_cjk().unwrap_or(1); + if current_width > max_width { + return (&word[..i], &word[i..]); + } + } + (word, "") + } +} diff --git a/src/text_wrapper/mod.rs b/src/text_wrapper/mod.rs new file mode 100644 index 0000000..2a84a7b --- /dev/null +++ b/src/text_wrapper/mod.rs @@ -0,0 +1,165 @@ +pub mod hybrid_wrapper; +pub mod text_wrapper; +pub mod types; + +pub use text_wrapper::*; +pub use types::*; + +#[cfg(test)] +mod tests { + use super::*; + use unicode_width::UnicodeWidthStr; + + #[test] + fn test_basic_wrapping() { + let mut config = WrapConfig::default(); + config.max_width = 20; + let wrapper = TextWrapper::new(config); + let text = "This is a long line of text that should be wrapped"; + let result = wrapper.wrap(text); + + let lines: Vec<&str> = result.lines().collect(); + assert!(lines.len() > 1); + + for line in lines { + assert!(line.width() <= 20); + } + } + + #[test] + fn test_long_word_handling() { + let mut config = WrapConfig::default(); + config.max_width = 10; + config.break_long_words = true; + let wrapper = TextWrapper::new(config); + let text = "Thisisaverylongwordthatneedstobebroken"; + let result = wrapper.wrap(text); + + let lines: Vec<&str> = result.lines().collect(); + assert!(lines.len() > 1); + } + + #[test] + fn test_code_block_preservation() { + let mut config = WrapConfig::default(); + config.max_width = 20; + config.handle_code_blocks = true; + let wrapper = TextWrapper::new(config); + let text = "Here is some text ```code block``` and more text"; + let result = wrapper.wrap(text); + + assert!(result.contains("code block")); + assert!(result.contains('\n')); + } + + #[test] + fn test_code_block_disabled() { + let mut config = WrapConfig::default(); + config.max_width = 20; + config.handle_code_blocks = false; + let wrapper = TextWrapper::new(config); + let text = "Here is some text ```code block``` and more text"; + let result = wrapper.wrap(text); + + // 代码块不应该被特殊处理 + assert!(!result.contains("\ncode block\n")); + } + + #[test] + fn test_link_preservation() { + let mut config = WrapConfig::default(); + config.max_width = 30; + config.preserve_links = true; + let wrapper = TextWrapper::new(config); + let text = "Check out this link: https://example.com/some/long/url for more info"; + let result = wrapper.wrap(text); + + assert!(result.contains("https://example.com")); + assert!(result.contains("[")); + assert!(result.contains("]")); + } + + #[test] + fn test_link_disabled() { + let mut config = WrapConfig::default(); + config.max_width = 30; + config.preserve_links = false; + let wrapper = TextWrapper::new(config); + let text = "Check out this link: https://example.com/some/long/url for more info"; + let result = wrapper.wrap(text); + + // 链接不应该被转换为 markdown 格式 + assert!(!result.contains("](")); + } + + #[test] + fn test_markdown_links() { + let mut config = WrapConfig::default(); + config.max_width = 30; + config.preserve_links = true; + let wrapper = TextWrapper::new(config); + let text = "See [Example](https://example.com) for details"; + let result = wrapper.wrap(text); + + assert!(result.contains("[Example](https://example.com)")); + } + + #[test] + fn test_inline_code() { + let mut config = WrapConfig::default(); + config.max_width = 20; + let wrapper = TextWrapper::new(config); + let text = "Use the `command` to run it"; + let result = wrapper.wrap(text); + + assert!(result.contains("`command`")); + } + + #[test] + fn test_mixed_content() { + let mut config = WrapConfig::default(); + config.max_width = 25; + config.handle_code_blocks = true; + config.preserve_links = true; + let wrapper = TextWrapper::new(config); + let text = + "Here is a link: https://example.com and some ```code block``` with `inline code`"; + let result = wrapper.wrap(text); + + assert!(result.contains("https://example.com")); + assert!(result.contains("code block")); + assert!(result.contains("`inline code`")); + } + + #[test] + fn test_complex_scenario() { + let mut config = WrapConfig::default(); + config.max_width = 80; // 更合理的宽度 + config.handle_code_blocks = true; + config.preserve_links = true; + config.preserve_words = true; + let wrapper = TextWrapper::new(config); + let text = "This is a long commit message that contains https://example.com and some ```rust\nfn main() {\n println!(\"Hello\");\n}\n``` plus `inline_code` for reference."; + let result = wrapper.wrap(text); + + // 验证各种元素都被正确处理 + assert!(result.contains("https://example.com")); + assert!(result.contains("```rust")); + assert!(result.contains("`inline_code`")); + + // 验证换行 + let lines: Vec<&str> = result.lines().collect(); + assert!(lines.len() > 1); + + // 验证每行不超过指定宽度 + for line in lines { + // 跳过代码块行,因为它们可能很长 + if !line.trim().starts_with("```") + && !line.trim().starts_with("fn") + && !line.trim().starts_with("println") + { + assert!(line.width() <= 80, "Line '{}' exceeds width 80", line); + } + } + } +} diff --git a/src/text_wrapper/text_wrapper.rs b/src/text_wrapper/text_wrapper.rs new file mode 100644 index 0000000..5a00a97 --- /dev/null +++ b/src/text_wrapper/text_wrapper.rs @@ -0,0 +1,126 @@ +use super::hybrid_wrapper::HybridWrapper; +use super::types::*; +use unicode_width::UnicodeWidthStr; + +pub struct TextWrapper { + config: WrapConfig, + wrapper: Box, +} + +impl TextWrapper { + pub fn new(config: WrapConfig) -> Self { + let wrapper: Box = match config.strategy { + WrapStrategy::WordBoundary => Box::new(WordBoundaryWrapper::new()), + WrapStrategy::Hybrid => Box::new(HybridWrapper::new()), + WrapStrategy::Semantic => Box::new(SemanticWrapper::new()), + }; + + Self { config, wrapper } + } + + pub fn wrap(&self, text: &str) -> String { + self.wrapper.wrap_text(text, &self.config) + } +} + +impl Default for TextWrapper { + fn default() -> Self { + Self::new(WrapConfig::default()) + } +} + +// 单词边界包装器 +pub struct WordBoundaryWrapper { + inner: HybridWrapper, +} + +impl WordBoundaryWrapper { + pub fn new() -> Self { + Self { + inner: HybridWrapper::new(), + } + } +} + +impl WordWrapper for WordBoundaryWrapper { + fn wrap_text(&self, text: &str, config: &WrapConfig) -> String { + let mut word_config = config.clone(); + word_config.strategy = WrapStrategy::WordBoundary; + self.inner.wrap_text(text, &word_config) + } + + fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String { + let mut word_config = config.clone(); + word_config.strategy = WrapStrategy::WordBoundary; + self.inner.wrap_segments(segments, &word_config) + } +} + +// 字符包装器 +pub struct CharacterWrapper; + +impl WordWrapper for CharacterWrapper { + fn wrap_text(&self, text: &str, config: &WrapConfig) -> String { + let max_width = config.max_width; + let indent = &config.indent; + let hanging = &config.hanging_indent; + + let mut lines = Vec::new(); + let mut current_line = indent.clone(); + + for (_i, ch) in text.chars().enumerate() { + if current_line.width() >= max_width && !current_line.trim().is_empty() { + lines.push(current_line); + current_line = hanging.clone(); + } + current_line.push(ch); + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines.join("\n") + } + + fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String { + let text: String = segments + .iter() + .map(|seg| match seg { + TextSegment::PlainText(s) => s.clone(), + TextSegment::CodeBlock(s) => s.clone(), + TextSegment::Link(_, text) => text.clone(), + TextSegment::InlineCode(s) => s.clone(), + }) + .collect(); + + self.wrap_text(&text, config) + } +} + +// 语义包装器 +pub struct SemanticWrapper { + inner: HybridWrapper, +} + +impl SemanticWrapper { + pub fn new() -> Self { + Self { + inner: HybridWrapper::new(), + } + } +} + +impl WordWrapper for SemanticWrapper { + fn wrap_text(&self, text: &str, config: &WrapConfig) -> String { + let mut semantic_config = config.clone(); + semantic_config.strategy = WrapStrategy::Semantic; + self.inner.wrap_text(text, &semantic_config) + } + + fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String { + let mut semantic_config = config.clone(); + semantic_config.strategy = WrapStrategy::Semantic; + self.inner.wrap_segments(segments, &semantic_config) + } +} diff --git a/src/text_wrapper/types.rs b/src/text_wrapper/types.rs new file mode 100644 index 0000000..cdd7673 --- /dev/null +++ b/src/text_wrapper/types.rs @@ -0,0 +1,68 @@ +#[derive(Debug, Clone, PartialEq)] +pub enum WrapStrategy { + WordBoundary, // 单词边界换行 + Hybrid, // 混合策略 + Semantic, // 语义感知换行 +} + +#[derive(Debug, Clone)] +pub struct WrapConfig { + pub max_width: usize, + pub preserve_words: bool, + pub break_long_words: bool, + pub handle_code_blocks: bool, + pub preserve_links: bool, + pub preserve_paragraphs: bool, + pub strategy: WrapStrategy, + pub indent: String, + pub hanging_indent: String, +} + +impl WrapConfig { + pub fn from_config_and_args( + text_wrap_config: &crate::config::TextWrapConfig, + wrap_width: Option, + preserve_paragraphs: bool, + ) -> Self { + Self { + max_width: wrap_width.unwrap_or(text_wrap_config.default_width), + preserve_words: text_wrap_config.preserve_words, + break_long_words: text_wrap_config.break_long_words, + handle_code_blocks: text_wrap_config.handle_code_blocks, + preserve_links: text_wrap_config.preserve_links, + preserve_paragraphs, + strategy: WrapStrategy::Hybrid, + indent: String::new(), + hanging_indent: text_wrap_config.hanging_indent.clone(), + } + } +} + +impl Default for WrapConfig { + fn default() -> Self { + Self { + max_width: 80, + preserve_words: true, + break_long_words: true, + handle_code_blocks: true, + preserve_links: true, + preserve_paragraphs: false, + strategy: WrapStrategy::Hybrid, + indent: String::new(), + hanging_indent: String::new(), // 默认无悬挂缩进,更适合大多数场景 + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TextSegment { + PlainText(String), + CodeBlock(String), + Link(String, String), // (url, text) + InlineCode(String), +} + +pub trait WordWrapper { + fn wrap_text(&self, text: &str, config: &WrapConfig) -> String; + fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String; +}