-
Notifications
You must be signed in to change notification settings - Fork 0
Add token processing helper #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,41 +9,31 @@ use std::sync::LazyLock; | |||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| use regex::Regex; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| use crate::wrap::{Token, tokenize_markdown}; | ||||||||||||||||||||||||||||||||||||||||||||||||
| use crate::{textproc::process_tokens, wrap::Token}; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| static DOT_RE: LazyLock<Regex> = lazy_regex!(r"\.{3,}", "ellipsis pattern regex should compile"); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /// Replace `...` with `…` outside code spans and fences. | ||||||||||||||||||||||||||||||||||||||||||||||||
| #[must_use] | ||||||||||||||||||||||||||||||||||||||||||||||||
| pub fn replace_ellipsis(lines: &[String]) -> Vec<String> { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if lines.is_empty() { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return Vec::new(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| let joined = lines.join("\n"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let mut out = String::new(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| for token in tokenize_markdown(&joined) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| match token { | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Text(t) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let replaced = DOT_RE.replace_all(t, |caps: ®ex::Captures<'_>| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let len = caps[0].len(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let ellipses = "…".repeat(len / 3); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let leftover = ".".repeat(len % 3); | ||||||||||||||||||||||||||||||||||||||||||||||||
| format!("{ellipses}{leftover}") | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push_str(&replaced); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Code(c) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push('`'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push_str(c); | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push('`'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Fence(f) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push_str(f); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Newline => out.push('\n'), | ||||||||||||||||||||||||||||||||||||||||||||||||
| process_tokens(lines, |token, out| match token { | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Text(t) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let replaced = DOT_RE.replace_all(t, |caps: ®ex::Captures<'_>| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let len = caps[0].len(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let ellipses = "…".repeat(len / 3); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let leftover = ".".repeat(len % 3); | ||||||||||||||||||||||||||||||||||||||||||||||||
| format!("{ellipses}{leftover}") | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push_str(&replaced); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+19
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Avoid unnecessary allocation when no replacement is needed.
Token::Text(t) => {
- let replaced = DOT_RE.replace_all(t, |caps: ®ex::Captures<'_>| {
+ if !DOT_RE.is_match(t) {
+ out.push_str(t);
+ return;
+ }
+
+ let replaced = DOT_RE.replace_all(t, |caps: ®ex::Captures<'_>| {
let len = caps[0].len();
let ellipses = "…".repeat(len / 3);
let leftover = ".".repeat(len % 3);
format!("{ellipses}{leftover}")
});
out.push_str(&replaced);
}The early return keeps the hot path allocation-free and silences a forthcoming clippy 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.split('\n').map(str::to_string).collect() | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Code(c) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push('`'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push_str(c); | ||||||||||||||||||||||||||||||||||||||||||||||||
| out.push('`'); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Fence(f) => out.push_str(f), | ||||||||||||||||||||||||||||||||||||||||||||||||
| Token::Newline => out.push('\n'), | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Push a newline after fences to preserve line structure.
- Token::Fence(f) => out.push_str(f),
+ Token::Fence(f) => {
+ out.push_str(f);
+ out.push('\n');
+ },Verify the existing tests still pass and add one that checks for exact line preservation around fenced blocks. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| #[cfg(test)] | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| //! Provides helpers for token-based transformations of Markdown lines. | ||
| //! | ||
| //! This module reuses the tokenizer from the [`wrap`] module and offers | ||
| //! a streaming API for rewriting Markdown. Each helper tokenizes lines | ||
| //! on the fly, feeds the resulting tokens to caller-provided logic, and | ||
| //! then reconstructs the lines. Trailing blank lines roundtrip | ||
| //! correctly. | ||
|
|
||
| use crate::wrap::{Token, is_fence}; | ||
|
|
||
| /// Apply a transformation to a sequence of [`Token`]s. | ||
| /// | ||
| /// The `lines` slice is tokenized in order, preserving fence context. | ||
| /// Each token is passed to `f` along with the output accumulator. The | ||
| /// final string is split on newline characters and returned as a | ||
| /// vector of lines. | ||
| /// | ||
| /// # Examples | ||
| /// | ||
| /// ```ignore | ||
| /// use mdtablefix::{ | ||
| /// textproc::process_tokens, | ||
| /// wrap::Token, | ||
| /// }; | ||
| /// | ||
| /// let lines = vec!["code".to_string()]; | ||
| /// let out = process_tokens(&lines, |tok, out| match tok { | ||
| /// Token::Text(t) => out.push_str(t), | ||
| /// Token::Code(c) => { | ||
| /// out.push('`'); | ||
| /// out.push_str(c); | ||
| /// out.push('`'); | ||
| /// } | ||
| /// Token::Fence(f) => out.push_str(f), | ||
| /// Token::Newline => out.push('\n'), | ||
| /// }); | ||
| /// assert_eq!(out, lines); | ||
| /// ``` | ||
| #[must_use] | ||
| pub(crate) fn process_tokens<F>(lines: &[String], mut f: F) -> Vec<String> | ||
| where | ||
| F: FnMut(Token<'_>, &mut String), | ||
| { | ||
| if lines.is_empty() { | ||
| return Vec::new(); | ||
| } | ||
|
|
||
| let trailing_blanks = lines.iter().rev().take_while(|l| l.is_empty()).count(); | ||
| if trailing_blanks == lines.len() { | ||
| return vec![String::new(); lines.len()]; | ||
| } | ||
|
|
||
| let mut out = String::new(); | ||
| let mut in_fence = false; | ||
| let last_idx = lines.len() - 1; | ||
| for (i, line) in lines.iter().enumerate() { | ||
| let trimmed = line.as_str(); | ||
| if is_fence(trimmed) { | ||
| f(Token::Fence(trimmed), &mut out); | ||
| if i < last_idx { | ||
| f(Token::Newline, &mut out); | ||
| } | ||
| in_fence = !in_fence; | ||
| continue; | ||
| } | ||
| if in_fence { | ||
| f(Token::Fence(trimmed), &mut out); | ||
| if i < last_idx { | ||
| f(Token::Newline, &mut out); | ||
| } | ||
| continue; | ||
| } | ||
| let mut rest = trimmed; | ||
| while let Some(pos) = rest.find('`') { | ||
| if pos > 0 { | ||
| f(Token::Text(&rest[..pos]), &mut out); | ||
| } | ||
| if let Some(end) = rest[pos + 1..].find('`') { | ||
| f(Token::Code(&rest[pos + 1..pos + 1 + end]), &mut out); | ||
| rest = &rest[pos + end + 2..]; | ||
| } else { | ||
| f(Token::Text(&rest[pos..]), &mut out); | ||
| rest = ""; | ||
| break; | ||
| } | ||
| } | ||
| if !rest.is_empty() { | ||
| f(Token::Text(rest), &mut out); | ||
| } | ||
| if i < last_idx { | ||
| f(Token::Newline, &mut out); | ||
| } | ||
| } | ||
|
|
||
| if out.is_empty() { | ||
| return Vec::new(); | ||
| } | ||
|
|
||
| let mut result: Vec<String> = out.split('\n').map(str::to_string).collect(); | ||
| let out_blanks = result.iter().rev().take_while(|l| l.is_empty()).count(); | ||
| for _ in out_blanks..trailing_blanks { | ||
| result.push(String::new()); | ||
| } | ||
| result | ||
| } | ||
|
Comment on lines
+47
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainToken processing logic is sound but verify fence state handling. The implementation correctly handles empty input, preserves trailing blank lines, and processes tokens appropriately. However, verify that the fence state tracking correctly handles nested or malformed fence sequences. 🏁 Script executed: #!/bin/bash
# Description: Search for fence-related logic in tests to ensure comprehensive coverage
# Expected: Find tests that verify fence state handling edge cases
echo "Searching for fence-related test cases:"
rg -A 10 -B 2 "fence" src/textproc.rs
echo -e "\nSearching for fence validation logic in wrap module:"
rg -A 5 -B 2 "is_fence" src/wrap.rsLength of output: 1703 Add fence state handling tests No existing tests verify transitions of in_fence or malformed fence sequences. Add unit tests covering:
Target locations:
Write tests in tests/textproc.rs (or equivalent) to confirm correct token emission and fence state tracking. 🤖 Prompt for AI Agents |
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn identity_transformation_returns_input() { | ||
| let lines = vec!["a `b`".to_string()]; | ||
| let out = process_tokens(&lines, |tok, buf| match tok { | ||
| Token::Text(t) => buf.push_str(t), | ||
| Token::Code(c) => { | ||
| buf.push('`'); | ||
| buf.push_str(c); | ||
| buf.push('`'); | ||
| } | ||
| Token::Fence(f) => buf.push_str(f), | ||
| Token::Newline => buf.push('\n'), | ||
| }); | ||
| assert_eq!(out, lines); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_input_returns_empty_vector() { | ||
| let lines: Vec<String> = Vec::new(); | ||
| let out = process_tokens(&lines, |_tok, _out| unreachable!()); | ||
| assert!(out.is_empty()); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| #[test] | ||
| fn transformation_can_remove_all_content() { | ||
| let lines = vec!["data".to_string()]; | ||
| let out = process_tokens(&lines, |_tok, _out| {}); | ||
| assert!(out.is_empty()); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| #[test] | ||
| fn preserves_trailing_blank_lines() { | ||
| let lines = vec!["a".to_string(), String::new(), String::new()]; | ||
| let out = process_tokens(&lines, |tok, buf| match tok { | ||
| Token::Text(t) => buf.push_str(t), | ||
| Token::Code(c) => { | ||
| buf.push('`'); | ||
| buf.push_str(c); | ||
| buf.push('`'); | ||
| } | ||
| Token::Fence(f) => buf.push_str(f), | ||
| Token::Newline => buf.push('\n'), | ||
| }); | ||
| assert_eq!(out, lines); | ||
| } | ||
|
|
||
| #[test] | ||
| fn blanks_only_are_preserved() { | ||
| let lines = vec![String::new(), String::new()]; | ||
| let out = process_tokens(&lines, |_tok, _buf| {}); | ||
| assert_eq!(out, lines); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick (assertive)
Reduce coupling to
wrapby re-exportingTokenviatextproc.Importing
Tokendirectly fromwrapkeepsellipsistied to an implementation detail that the newtextprochelper is trying to abstract away. Re-exportTokenfrom thetextprocmodule (e.g.pub use crate::wrap::Token;) and import it here viacrate::textproc::Tokeninstead. This makes future refactors (or a replacement tokenizer) localised totextproc.🤖 Prompt for AI Agents