From 8b00ae4969b5343e280e241b54638047416158cb Mon Sep 17 00:00:00 2001 From: jinlong Date: Mon, 15 Dec 2025 12:19:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(hybrid=5Fwrapper):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=8D=A2=E8=A1=8C=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A1=8C=E5=86=85=E4=BB=A3=E7=A0=81=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增段落保留功能,支持按段落处理文本 - 改进行内代码正则表达式,支持单反引号和双反引号 - 修复行内代码重复添加反引号的问题 - 优化列表项换行处理,避免列表标记单独成行 - 添加行内代码换行测试用例 Signed-off-by: jinlong chore: 升级版本号至0.5.2 Signed-off-by: jinlong --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/text_wrapper/hybrid_wrapper.rs | 162 +++++++++++++++++++++---- src/text_wrapper/mod.rs | 183 +++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b51fffc..0c49e24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,7 +359,7 @@ dependencies = [ [[package]] name = "fastcommit" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 6c5c5d0..c7d1806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastcommit" -version = "0.5.1" +version = "0.5.2" description = "AI-based command line tool to quickly generate standardized commit messages." edition = "2021" authors = ["longjin "] diff --git a/src/text_wrapper/hybrid_wrapper.rs b/src/text_wrapper/hybrid_wrapper.rs index 49c01d8..ce1411d 100644 --- a/src/text_wrapper/hybrid_wrapper.rs +++ b/src/text_wrapper/hybrid_wrapper.rs @@ -13,7 +13,8 @@ impl HybridWrapper { 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(), + // 支持单反引号和双反引号的行内代码(如 `code` 或 ``code``) + inline_code_regex: Regex::new(r"`{1,3}[^`]+?`{1,3}").unwrap(), } } } @@ -24,11 +25,53 @@ impl WordWrapper for HybridWrapper { return String::new(); } - // 解析文本段 - let segments = self.parse_segments(text); + // 如果 preserve_paragraphs 为 true,需要先处理段落,然后再处理段 + if config.preserve_paragraphs { + 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() { + // 对每一行单独处理,但不在段级别使用 preserve_paragraphs + let mut line_config = config.clone(); + line_config.preserve_paragraphs = false; + let segments = self.parse_segments(line.trim()); + let wrapped_line = self.wrap_segments(&segments, &line_config); + result.push_str(&wrapped_line); + } else { + result.push('\n'); + } + } + } else { + // 段落内没有换行符,直接处理整个段落 + // 但不在段级别使用 preserve_paragraphs,因为段落处理已经在更高层级完成 + let mut para_config = config.clone(); + para_config.preserve_paragraphs = false; + let segments = self.parse_segments(paragraph.trim()); + let wrapped_paragraph = self.wrap_segments(&segments, ¶_config); + result.push_str(&wrapped_paragraph); + } + } - // 处理分段文本 - self.wrap_segments(&segments, config) + result + } else { + // 解析文本段 + let segments = self.parse_segments(text); + + // 处理分段文本 + self.wrap_segments(&segments, config) + } } fn wrap_segments(&self, segments: &[TextSegment], config: &WrapConfig) -> String { @@ -36,26 +79,66 @@ impl WordWrapper for HybridWrapper { let mut current_line = String::new(); let mut current_width = config.indent.width(); - for segment in segments { - let processed = self.process_segment(segment, config); + // 检查是否是列表项(第一个段是 PlainText 且以 "- " 开头) + let is_list_item = segments + .first() + .and_then(|s| { + if let TextSegment::PlainText(text) = s { + Some(text.trim_start().starts_with("-")) + } else { + None + } + }) + .unwrap_or(false); + + for (idx, segment) in segments.iter().enumerate() { + // 对于行内代码,需要特殊处理:作为不可分割的单元 + // 行内代码已经包含反引号(从正则匹配中),不需要通过process_segment重复添加 + let processed = match segment { + TextSegment::InlineCode(code) => { + // 行内代码已经包含反引号,直接使用,作为不可分割的单元 + code.clone() + } + _ => self.process_segment(segment, config), + }; - if current_width + processed.width() <= config.max_width { + let processed_width = processed.width(); + + // 检查当前行是否能放下这个段 + // 对于行内代码,如果当前行已经有内容且放不下,需要整体换行 + // 对于普通文本,可以继续处理 + // 特殊处理:如果是列表项,且当前行只包含列表标记("-"),不要换行 + let is_list_marker_only = is_list_item && idx == 0 && current_line.trim() == "-"; + let should_wrap = + current_width + processed_width > config.max_width && !is_list_marker_only; + + if !should_wrap { current_line.push_str(&processed); - current_width += processed.width(); + current_width += processed_width; } else { // 当前行放不下,需要换行 - if !current_line.is_empty() { + // 特殊处理:如果是列表项,且当前行只包含列表标记("-"),不要换行 + if !current_line.is_empty() && !is_list_marker_only { 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); + if is_list_marker_only { + // 当前行只有 "-",不要换行,继续在当前行处理 + if !current_line.ends_with(' ') { + current_line.push(' '); + } + current_line.push_str(&processed); + current_width = current_line.width(); + } else { + 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(); } - current_line.push_str(&processed); - current_width = current_line.width(); } } @@ -155,7 +238,12 @@ impl HybridWrapper { match segment { TextSegment::PlainText(text) => { if config.handle_code_blocks { - self.wrap_plain_text(text, config) + // 在 wrap_segments 中,PlainText 段不应该使用 wrap_with_paragraphs + // 因为段落处理应该在更高层级(wrap_text)进行 + // 这里只处理单词级别的换行,不处理段落 + let mut no_paragraph_config = config.clone(); + no_paragraph_config.preserve_paragraphs = false; + self.wrap_plain_text(text, &no_paragraph_config) } else { text.clone() } @@ -175,7 +263,9 @@ impl HybridWrapper { } } TextSegment::InlineCode(code) => { - format!("`{}`", code) + // InlineCode段已经包含反引号(从正则匹配中),直接返回 + // 行内代码应该作为不可分割的单元,不进行额外的包装处理 + code.clone() } } } @@ -207,7 +297,8 @@ impl HybridWrapper { result.push('\n'); } if !line.trim().is_empty() { - let wrapped_line = self.wrap_without_paragraphs(line.trim(), config); + let trimmed = line.trim(); + let wrapped_line = self.wrap_without_paragraphs(trimmed, config); result.push_str(&wrapped_line); } else { result.push('\n'); @@ -237,6 +328,9 @@ impl HybridWrapper { let word_width = word.width(); let separator_width = if current_line.is_empty() { 0 } else { 1 }; + // 特殊处理:如果当前行只包含 "-"(列表项标记),不要换行 + let is_list_marker_only = current_line.trim() == "-"; + if current_width + separator_width + word_width <= config.max_width { if !current_line.is_empty() { current_line.push(' '); @@ -247,10 +341,17 @@ impl HybridWrapper { // 当前单词放不下,需要换行 if config.break_long_words && word_width > config.max_width { // 长单词强制换行 - if !current_line.is_empty() { + // 特殊处理:如果当前行只包含 "-",不要换行,而是继续在当前行处理 + if !is_list_marker_only && !current_line.is_empty() { lines.push(current_line); current_line = String::new(); current_width = config.indent.width(); + } else if is_list_marker_only { + // 当前行只有 "-",不要换行,继续在当前行处理长单词 + if !current_line.ends_with(' ') { + current_line.push(' '); + current_width += 1; + } } let mut remaining = word; @@ -262,7 +363,7 @@ impl HybridWrapper { self.break_word_at_width(remaining, available) }; - if !current_line.is_empty() { + if !current_line.is_empty() && !current_line.ends_with(' ') { current_line.push(' '); } current_line.push_str(part); @@ -279,12 +380,23 @@ impl HybridWrapper { } } else { // 普通换行 - if !current_line.is_empty() { - lines.push(current_line); + // 特殊处理:如果当前行只包含 "-",不要换行,而是继续在当前行处理 + if is_list_marker_only { + // 当前行只有 "-",不要换行,继续在当前行处理 + if !current_line.ends_with(' ') { + current_line.push(' '); + } + current_line.push_str(word); + current_width = current_line.width(); + } 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(); } - current_line = config.hanging_indent.clone(); - current_line.push_str(word); - current_width = current_line.width(); } } } diff --git a/src/text_wrapper/mod.rs b/src/text_wrapper/mod.rs index 2a84a7b..aab8e38 100644 --- a/src/text_wrapper/mod.rs +++ b/src/text_wrapper/mod.rs @@ -115,6 +115,47 @@ mod tests { assert!(result.contains("`command`")); } + #[test] + fn test_inline_code_no_double_backticks() { + // 测试修复:行内代码不应该重复添加反引号 + let mut config = WrapConfig::default(); + config.max_width = 80; + let wrapper = TextWrapper::new(config); + let text = "Change the default path from ``config.json`` to ``settings.json``"; + let result = wrapper.wrap(text); + + // 验证没有重复的反引号(不应该出现`` `code` ``这样的格式) + assert!(!result.contains("`` `")); + assert!(!result.contains("` ``")); + // 验证行内代码格式正确 + assert!(result.contains("`config.json`")); + assert!(result.contains("`settings.json`")); + } + + #[test] + fn test_inline_code_wrapping() { + // 测试行内代码的换行处理:应该作为整体,不应该被拆分 + let mut config = WrapConfig::default(); + config.max_width = 30; // 设置较小的宽度 + let wrapper = TextWrapper::new(config); + let text = "Change the default path from ``config.json`` to ``settings.json``"; + let result = wrapper.wrap(text); + + // 验证行内代码没有被拆分(每个行内代码应该完整出现) + let lines: Vec<&str> = result.lines().collect(); + // 检查每一行,确保行内代码是完整的 + for line in &lines { + // 如果一行包含反引号,应该成对出现 + let backtick_count = line.matches('`').count(); + assert_eq!( + backtick_count % 2, + 0, + "行内代码的反引号应该成对出现: {}", + line + ); + } + } + #[test] fn test_mixed_content() { let mut config = WrapConfig::default(); @@ -162,4 +203,146 @@ mod tests { } } } + + #[test] + fn test_list_item_no_wrap_after_dash() { + // 测试列表项在 `-` 后不换行 + let mut config = WrapConfig::default(); + config.max_width = 80; + config.preserve_paragraphs = true; + let wrapper = TextWrapper::new(config); + + // 测试用例:列表项以 `-` 开头,后续文本很长 + let text = "- This is a very long list item that should wrap properly without breaking after the dash"; + let result = wrapper.wrap(text); + + // 验证 `-` 不在单独一行 + let lines: Vec<&str> = result.lines().collect(); + // 第一行应该包含 `-` 和后续文本,不应该只有 `-` + assert!(!lines[0].trim().eq("-"), "列表项不应该在 `-` 后立即换行"); + assert!(lines[0].contains("-"), "第一行应该包含 `-`"); + assert!(lines[0].len() > 1, "第一行不应该只有 `-`"); + } + + #[test] + fn test_list_item_with_inline_code_as_whole() { + // 测试列表项包含行内代码时作为整体处理 + let mut config = WrapConfig::default(); + config.max_width = 80; + config.preserve_paragraphs = true; + let wrapper = TextWrapper::new(config); + + // 测试用例:列表项包含行内代码,不应该在行内代码前后换行 + let text = "- Use the `Config` class to initialize the system and then process the data"; + let result = wrapper.wrap(text); + + let lines: Vec<&str> = result.lines().collect(); + // 验证第一行包含 `-`、行内代码和后续文本 + assert!(lines[0].contains("-"), "第一行应该包含 `-`"); + assert!( + lines[0].contains("`Config`"), + "第一行应该包含行内代码 `Config`" + ); + assert!(lines[0].contains("class"), "第一行应该包含后续文本"); + + // 验证行内代码前后不换行(即第一行应该包含 `-` 和 `Use` 以及 `Config`) + // 注意:由于文本处理,可能 `-` 和 `Use` 之间有空格,所以检查它们都在第一行 + assert!( + lines[0].contains("-") && lines[0].contains("Use") && lines[0].contains("`Config`"), + "列表项应该在行内代码前后保持整体,第一行: '{}'", + lines[0] + ); + } + + #[test] + fn test_list_item_with_multiple_inline_codes() { + // 测试列表项包含多个行内代码时作为整体处理 + let mut config = WrapConfig::default(); + config.max_width = 80; + config.preserve_paragraphs = true; + let wrapper = TextWrapper::new(config); + + // 测试用例:列表项包含多个行内代码 + let text = "- Add `getUser` method to `UserService` class for retrieving user information"; + let result = wrapper.wrap(text); + + let lines: Vec<&str> = result.lines().collect(); + // 验证第一行包含 `-` 和第一个行内代码 + assert!(lines[0].contains("-"), "第一行应该包含 `-`"); + assert!( + lines[0].contains("`getUser`"), + "第一行应该包含第一个行内代码" + ); + + // 验证列表项作为整体处理,行内代码前后不换行 + // 注意:由于文本处理,可能 `-` 和 `Add` 之间有空格,所以检查它们都在第一行 + assert!( + lines[0].contains("-") && lines[0].contains("Add") && lines[0].contains("`getUser`"), + "列表项应该在第一个行内代码前后保持整体,第一行: '{}'", + lines[0] + ); + } + + #[test] + fn test_list_item_preserve_paragraphs() { + // 测试列表项在保留段落格式时的换行行为 + let mut config = WrapConfig::default(); + config.max_width = 80; + config.preserve_paragraphs = true; + let wrapper = TextWrapper::new(config); + + // 测试用例:包含多个列表项的段落 + let text = "Feature: Add new functionality\n\n- Implement `FeatureA` class with `method1` and `method2`\n- Add `FeatureB` module to handle data processing\n- Create unit tests for all new components"; + let result = wrapper.wrap(text); + + let lines: Vec<&str> = result.lines().collect(); + + // 验证每个列表项都不应该在 `-` 后立即换行 + let mut found_list_items = 0; + for line in &lines { + if line.trim().starts_with("-") { + found_list_items += 1; + // 验证列表项的第一行不应该只有 `-` + assert!( + line.trim().len() > 1, + "列表项不应该在 `-` 后立即换行: {}", + line + ); + } + } + assert!(found_list_items >= 3, "应该找到至少3个列表项"); + } + + #[test] + fn test_list_item_long_text_wrapping() { + // 测试列表项文本很长时的换行行为 + let mut config = WrapConfig::default(); + config.max_width = 50; // 设置较小的宽度以触发换行 + config.preserve_paragraphs = true; + let wrapper = TextWrapper::new(config); + + // 测试用例:列表项文本很长,应该换行,但 `-` 不应该单独一行 + let text = "- This is a very long list item that contains multiple words and should wrap to multiple lines when the width is limited"; + let result = wrapper.wrap(text); + + let lines: Vec<&str> = result.lines().collect(); + // 验证第一行包含 `-` 和部分文本 + assert!(lines[0].contains("-"), "第一行应该包含 `-`"); + assert!(!lines[0].trim().eq("-"), "第一行不应该只有 `-`"); + assert!(lines[0].len() > 1, "第一行应该包含文本内容"); + + // 验证后续行使用悬挂缩进(如果有) + if lines.len() > 1 { + // 后续行不应该以 `-` 开头(除非是新的列表项) + for line in lines.iter().skip(1) { + if !line.trim().is_empty() { + assert!( + !line.trim().starts_with("-") || line.trim().starts_with("- "), + "后续行不应该意外包含列表标记: {}", + line + ); + } + } + } + } }