use pretty_assertions::assert_eq; use ratatui::style::Modifier; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; 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_lines_with_width_and_cwd; 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_debug_snapshot; use insta::assert_snapshot; fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> { render_markdown_text_with_width_and_cwd(input, /*width*/ None, Some(cwd)) } fn plain_lines(text: &Text<'_>) -> Vec { text.lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.clone()) .collect::() }) .collect() } #[test] fn bare_url_with_tilde_keeps_complete_hyperlink() { let destination = "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf"; let lines = render_markdown_lines_with_width_and_cwd( destination, /*width*/ Some(80), /*cwd*/ None, ); let rendered = lines .iter() .map(|line| { let text = line .line .spans .iter() .map(|span| span.content.as_ref()) .collect::(); let hyperlinks = line .hyperlinks .iter() .map(|link| (link.columns.clone(), link.destination.as_str())) .collect::>(); (text, hyperlinks) }) .collect::>(); assert_debug_snapshot!(rendered); } #[test] fn table_url_with_tilde_keeps_complete_hyperlink() { let destination = "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf"; let markdown = format!("| URL |\n| --- |\n| {destination} |\n"); let lines = render_markdown_lines_with_width_and_cwd( &markdown, /*width*/ Some(32), /*cwd*/ None, ); let destinations = lines .iter() .flat_map(|line| line.hyperlinks.iter()) .map(|link| link.destination.as_str()) .collect::>(); assert!(!destinations.is_empty()); assert_eq!(destinations, vec![destination; destinations.len()]); } #[test] fn merged_text_events_preserve_entity_decoding() { let source = "https://example.com/a&b~c"; let destination = "https://example.com/a&b~c"; let lines = render_markdown_lines_with_width_and_cwd( source, /*width*/ Some(80), /*cwd*/ None, ); let rendered = lines .iter() .map(|line| { let text = line .line .spans .iter() .map(|span| span.content.as_ref()) .collect::(); let hyperlinks = line .hyperlinks .iter() .map(|link| (link.columns.clone(), link.destination.as_str())) .collect::>(); (text, hyperlinks) }) .collect::>(); assert_eq!( rendered, vec![( destination.to_string(), vec![(0..destination.len(), destination)], )], ); } #[test] fn empty() { assert_eq!(render_markdown_text(""), Text::default()); } #[test] fn paragraph_single() { assert_eq!( render_markdown_text("Hello, world!"), Text::from("Hello, world!") ); } #[test] fn paragraph_soft_break() { assert_eq!( render_markdown_text("Hello\nWorld"), Text::from_iter(["Hello", "World"]) ); } #[test] fn paragraph_multiple() { assert_eq!( render_markdown_text("Paragraph 1\n\nParagraph 2"), Text::from_iter(["Paragraph 1", "", "Paragraph 2"]) ); } #[test] fn headings() { let md = "# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n##### Heading 5\n###### Heading 6\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["# ".bold().underlined(), "Heading 1".bold().underlined()]), Line::default(), Line::from_iter(["## ".bold(), "Heading 2".bold()]), Line::default(), Line::from_iter(["### ".bold().italic(), "Heading 3".bold().italic()]), Line::default(), Line::from_iter(["#### ".italic(), "Heading 4".italic()]), Line::default(), Line::from_iter(["##### ".italic(), "Heading 5".italic()]), Line::default(), Line::from_iter(["###### ".italic(), "Heading 6".italic()]), ]); assert_eq!(text, expected); } #[test] fn blockquote_single() { let text = render_markdown_text("> Blockquote"); let expected = Text::from(Line::from_iter(["> ", "Blockquote"]).green()); assert_eq!(text, expected); } #[test] fn blockquote_soft_break() { // Soft break via lazy continuation should render as a new line in blockquotes. let text = render_markdown_text("> This is a blockquote\nwith a soft break\n"); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "> This is a blockquote".to_string(), "> with a soft break".to_string() ] ); } #[test] fn blockquote_multiple_with_break() { let text = render_markdown_text("> Blockquote 1\n\n> Blockquote 2\n"); let expected = Text::from_iter([ Line::from_iter(["> ", "Blockquote 1"]).green(), Line::default(), Line::from_iter(["> ", "Blockquote 2"]).green(), ]); assert_eq!(text, expected); } #[test] fn blockquote_three_paragraphs_short_lines() { let md = "> one\n>\n> two\n>\n> three\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["> ", "one"]).green(), Line::from_iter(["> "]).green(), Line::from_iter(["> ", "two"]).green(), Line::from_iter(["> "]).green(), Line::from_iter(["> ", "three"]).green(), ]); assert_eq!(text, expected); } #[test] fn blockquote_nested_two_levels() { let md = "> Level 1\n>> Level 2\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["> ", "Level 1"]).green(), Line::from_iter(["> "]).green(), Line::from_iter(["> ", "> ", "Level 2"]).green(), ]); assert_eq!(text, expected); } #[test] fn blockquote_with_list_items() { let md = "> - item 1\n> - item 2\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["> ", "- ", "item 1"]).green(), Line::from_iter(["> ", "- ", "item 2"]).green(), ]); assert_eq!(text, expected); } #[test] fn blockquote_with_ordered_list() { let md = "> 1. first\n> 2. second\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(vec![ Span::from("> "), "1. ".light_blue(), Span::from("first"), ]) .green(), Line::from_iter(vec![ Span::from("> "), "2. ".light_blue(), Span::from("second"), ]) .green(), ]); assert_eq!(text, expected); } #[test] fn blockquote_list_then_nested_blockquote() { let md = "> - parent\n> > child\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["> ", "- ", "parent"]).green(), Line::from_iter(["> ", " ", "> ", "child"]).green(), ]); assert_eq!(text, expected); } #[test] fn list_item_with_inline_blockquote_on_same_line() { let md = "1. > quoted\n"; let text = render_markdown_text(md); let mut lines = text.lines.iter(); let first = lines.next().expect("one line"); // Expect content to include the ordered marker, a space, "> ", and the text let s: String = first.spans.iter().map(|sp| sp.content.clone()).collect(); assert_eq!(s, "1. > quoted"); } #[test] fn blockquote_surrounded_by_blank_lines() { let md = "foo\n\n> bar\n\nbaz\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "foo".to_string(), "".to_string(), "> bar".to_string(), "".to_string(), "baz".to_string(), ] ); } #[test] fn blockquote_in_ordered_list_on_next_line() { // Blockquote begins on a new line within an ordered list item; it should // render inline on the same marker line. let md = "1.\n > quoted\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["1. > quoted".to_string()]); } #[test] fn blockquote_in_unordered_list_on_next_line() { // Blockquote begins on a new line within an unordered list item; it should // render inline on the same marker line. let md = "-\n > quoted\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["- > quoted".to_string()]); } #[test] fn blockquote_two_paragraphs_inside_ordered_list_has_blank_line() { // Two blockquote paragraphs inside a list item should be separated by a blank line. let md = "1.\n > para 1\n >\n > para 2\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "1. > para 1".to_string(), " > ".to_string(), " > para 2".to_string(), ], "expected blockquote content to stay aligned after list marker" ); } #[test] fn blockquote_inside_nested_list() { let md = "1. A\n - B\n > inner\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["1. A", " - B", " > inner"]); } #[test] fn list_item_text_then_blockquote() { let md = "1. before\n > quoted\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["1. before", " > quoted"]); } #[test] fn list_item_blockquote_then_text() { let md = "1.\n > quoted\n after\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["1. > quoted", " > after"]); } #[test] fn list_item_text_blockquote_text() { let md = "1. before\n > quoted\n after\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["1. before", " > quoted", " > after"]); } #[test] fn blockquote_with_heading_and_paragraph() { let md = "> # Heading\n> paragraph text\n"; let text = render_markdown_text(md); // Validate on content shape; styling is handled elsewhere let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "> # Heading".to_string(), "> ".to_string(), "> paragraph text".to_string(), ] ); } #[test] fn blockquote_heading_inherits_heading_style() { let text = render_markdown_text("> # test header\n> in blockquote\n"); assert_eq!( text.lines, [ Line::from_iter([ "> ".into(), "# ".bold().underlined(), "test header".bold().underlined(), ]) .green(), Line::from_iter(["> "]).green(), Line::from_iter(["> ", "in blockquote"]).green(), ] ); } #[test] fn blockquote_with_code_block() { let md = "> ```\n> code\n> ```\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["> code".to_string()]); } #[test] fn blockquote_with_multiline_code_block() { let md = "> ```\n> first\n> second\n> ```\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["> first", "> second"]); } #[test] fn nested_blockquote_with_inline_and_fenced_code() { /* let md = \"> Nested quote with code:\n\ > > Inner quote and `inline code`\n\ > >\n\ > > ```\n\ > > # fenced code inside a quote\n\ > > echo \"hello from a quote\"\n\ > > ```\n"; */ let md = r#"> Nested quote with code: > > Inner quote and `inline code` > > > > ``` > > # fenced code inside a quote > > echo "hello from a quote" > > ``` "#; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "> Nested quote with code:".to_string(), "> ".to_string(), "> > Inner quote and inline code".to_string(), "> > ".to_string(), "> > # fenced code inside a quote".to_string(), "> > echo \"hello from a quote\"".to_string(), ] ); } #[test] fn list_unordered_single() { let text = render_markdown_text("- List item 1\n"); let expected = Text::from_iter([Line::from_iter(["- ", "List item 1"])]); assert_eq!(text, expected); } #[test] fn list_unordered_multiple() { let text = render_markdown_text("- List item 1\n- List item 2\n"); let expected = Text::from_iter([ Line::from_iter(["- ", "List item 1"]), Line::from_iter(["- ", "List item 2"]), ]); assert_eq!(text, expected); } #[test] fn list_ordered() { let text = render_markdown_text("1. List item 1\n2. List item 2\n"); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "List item 1".into()]), Line::from_iter(["2. ".light_blue(), "List item 2".into()]), ]); assert_eq!(text, expected); } #[test] fn list_nested() { let text = render_markdown_text("- List item 1\n - Nested list item 1\n"); let expected = Text::from_iter([ Line::from_iter(["- ", "List item 1"]), Line::from_iter([" - ", "Nested list item 1"]), ]); assert_eq!(text, expected); } #[test] fn list_ordered_custom_start() { let text = render_markdown_text("3. First\n4. Second\n"); let expected = Text::from_iter([ Line::from_iter(["3. ".light_blue(), "First".into()]), Line::from_iter(["4. ".light_blue(), "Second".into()]), ]); assert_eq!(text, expected); } #[test] fn nested_unordered_in_ordered() { let md = "1. Outer\n - Inner A\n - Inner B\n2. Next\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "Outer".into()]), Line::from_iter([" - ", "Inner A"]), Line::from_iter([" - ", "Inner B"]), Line::default(), Line::from_iter(["2. ".light_blue(), "Next".into()]), ]); assert_eq!(text, expected); } #[test] fn nested_ordered_in_unordered() { let md = "- Outer\n 1. One\n 2. Two\n- Last\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["- ", "Outer"]), Line::from_iter([" 1. ".light_blue(), "One".into()]), Line::from_iter([" 2. ".light_blue(), "Two".into()]), Line::default(), Line::from_iter(["- ", "Last"]), ]); assert_eq!(text, expected); } #[test] fn loose_list_item_multiple_paragraphs() { let md = "1. First paragraph\n\n Second paragraph of same item\n\n2. Next item\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "First paragraph".into()]), Line::default(), Line::from_iter([" ", "Second paragraph of same item"]), Line::default(), Line::from_iter(["2. ".light_blue(), "Next item".into()]), ]); assert_eq!(text, expected); } #[test] fn tight_item_with_soft_break() { let md = "- item line1\n item line2\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["- ", "item line1"]), Line::from_iter([" ", "item line2"]), ]); assert_eq!(text, expected); } #[test] fn deeply_nested_mixed_three_levels() { let md = "1. A\n - B\n 1. C\n2. D\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "A".into()]), Line::from_iter([" - ", "B"]), Line::from_iter([" 1. ".light_blue(), "C".into()]), Line::default(), Line::from_iter(["2. ".light_blue(), "D".into()]), ]); assert_eq!(text, expected); } #[test] fn loose_items_due_to_blank_line_between_items() { let md = "1. First\n\n2. Second\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "First".into()]), Line::from_iter(["2. ".light_blue(), "Second".into()]), ]); assert_eq!(text, expected); } #[test] fn mixed_tight_then_loose_in_one_list() { let md = "1. Tight\n\n2.\n Loose\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "Tight".into()]), Line::from_iter(["2. ".light_blue(), "Loose".into()]), ]); assert_eq!(text, expected); } #[test] fn ordered_item_with_indented_continuation_is_tight() { let md = "1. Foo\n Bar\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "Foo".into()]), Line::from_iter([" ", "Bar"]), ]); assert_eq!(text, expected); } #[test] fn inline_code() { let text = render_markdown_text("Example of `Inline code`"); let expected = Line::from_iter(["Example of ".into(), "Inline code".cyan()]).into(); assert_eq!(text, expected); } #[test] fn strong() { assert_eq!( render_markdown_text("**Strong**"), Text::from(Line::from("Strong".bold())) ); } #[test] fn emphasis() { assert_eq!( render_markdown_text("*Emphasis*"), Text::from(Line::from("Emphasis".italic())) ); } #[test] fn strikethrough() { assert_eq!( render_markdown_text("~~Strikethrough~~"), Text::from(Line::from("Strikethrough".crossed_out())) ); } #[test] fn strong_emphasis() { let text = render_markdown_text("**Strong *emphasis***"); let expected = Text::from(Line::from_iter([ "Strong ".bold(), "emphasis".bold().italic(), ])); assert_eq!(text, expected); } #[test] fn link() { let text = render_markdown_text("[Link](https://example.com)"); let expected = Text::from(Line::from_iter([ "Link".into(), " (".into(), "https://example.com".cyan().underlined(), ")".into(), ])); assert_eq!(text, expected); } #[test] fn load_location_suffix_regexes() { let _colon = &*COLON_LOCATION_SUFFIX_RE; let _hash = &*HASH_LOCATION_SUFFIX_RE; } #[test] fn file_link_hides_destination() { let text = render_markdown_text_for_cwd( "[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs".cyan()])); assert_eq!(text, expected); } #[test] fn file_link_decodes_percent_encoded_bare_path_destination() { let text = render_markdown_text_for_cwd( "[report](/Users/example/code/codex/Example%20Folder/R%C3%A9sum%C3%A9/report.md)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "Example Folder/Résumé/report.md".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_appends_line_number_when_label_lacks_it() { let text = render_markdown_text_for_cwd( "[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_keeps_absolute_paths_outside_cwd() { let text = render_markdown_text_for_cwd( "[README.md:74](/Users/example/code/codex/README.md:74)", Path::new("/Users/example/code/codex/codex-rs/tui"), ); let expected = Text::from(Line::from_iter(["/Users/example/code/codex/README.md:74".cyan()])); assert_eq!(text, expected); } #[test] fn file_link_appends_hash_anchor_when_label_lacks_it() { let text = render_markdown_text_for_cwd( "[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74:3".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_uses_target_path_for_hash_anchor() { let text = render_markdown_text_for_cwd( "[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74:3".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_appends_range_when_label_lacks_it() { let text = render_markdown_text_for_cwd( "[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_uses_target_path_for_range() { let text = render_markdown_text_for_cwd( "[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_appends_hash_range_when_label_lacks_it() { let text = render_markdown_text_for_cwd( "[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), ])); assert_eq!(text, expected); } #[test] fn multiline_file_link_label_after_styled_prefix_does_not_panic() { let text = render_markdown_text_for_cwd( "**bold** plain [foo\nbar](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "bold".bold(), " plain ".into(), "codex-rs/tui/src/markdown_render.rs:74:3".cyan(), ])); assert_eq!(text, expected); } #[test] fn file_link_uses_target_path_for_hash_range() { let text = render_markdown_text_for_cwd( "[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)", Path::new("/Users/example/code/codex"), ); let expected = Text::from(Line::from_iter([ "codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(), ])); assert_eq!(text, expected); } #[test] fn url_link_shows_destination() { let text = render_markdown_text("[docs](https://example.com/docs)"); let expected = Text::from(Line::from_iter([ "docs".into(), " (".into(), "https://example.com/docs".cyan().underlined(), ")".into(), ])); assert_eq!(text, expected); } #[test] fn markdown_render_file_link_snapshot() { let text = render_markdown_text_for_cwd( "See [markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74).", Path::new("/Users/example/code/codex"), ); let rendered = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect::>() .join("\n"); assert_snapshot!(rendered); } #[test] fn unordered_list_local_file_link_stays_inline_with_following_text() { let text = render_markdown_text_with_width_and_cwd( "- [binary](/Users/example/code/codex/codex-rs/README.md:93): core is the agent/business logic, tui is the terminal UI, exec is the headless automation surface, and cli is the top-level multitool binary.", Some(72), Some(Path::new("/Users/example/code/codex")), ); let rendered = text .lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .collect::>(); assert_eq!( rendered, vec![ "- codex-rs/README.md:93: core is the agent/business logic, tui is the", " terminal UI, exec is the headless automation surface, and cli is the", " top-level multitool binary.", ] ); } #[test] fn unordered_list_local_file_link_soft_break_before_colon_stays_inline() { let text = render_markdown_text_with_width_and_cwd( "- [binary](/Users/example/code/codex/codex-rs/README.md:93)\n : core is the agent/business logic.", Some(72), Some(Path::new("/Users/example/code/codex")), ); let rendered = text .lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .collect::>(); assert_eq!( rendered, vec!["- codex-rs/README.md:93: core is the agent/business logic.",] ); } #[test] fn consecutive_unordered_list_local_file_links_do_not_detach_paths() { let text = render_markdown_text_with_width_and_cwd( "- [binary](/Users/example/code/codex/codex-rs/README.md:93)\n : cli is the top-level multitool binary.\n- [expectations](/Users/example/code/codex/codex-rs/core/README.md:1)\n : codex-core owns the real runtime behavior.", Some(72), Some(Path::new("/Users/example/code/codex")), ); let rendered = text .lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .collect::>(); assert_eq!( rendered, vec![ "- codex-rs/README.md:93: cli is the top-level multitool binary.", "- codex-rs/core/README.md:1: codex-core owns the real runtime behavior.", ] ); } #[test] fn code_block_known_lang_has_syntax_colors() { let text = render_markdown_text("```rust\nfn main() {}\n```\n"); let content: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); // Content should be preserved; ignore trailing empty line from highlighting. let content: Vec<&str> = content .iter() .map(std::string::String::as_str) .filter(|s| !s.is_empty()) .collect(); assert_eq!(content, vec!["fn main() {}"]); // At least one span should have non-default style (syntax highlighting). let has_colored_span = text .lines .iter() .flat_map(|l| l.spans.iter()) .any(|sp| sp.style.fg.is_some()); assert!(has_colored_span, "expected syntax-highlighted spans with color"); } #[test] fn code_block_unknown_lang_plain() { let text = render_markdown_text("```xyzlang\nhello world\n```\n"); let content: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); let content: Vec<&str> = content .iter() .map(std::string::String::as_str) .filter(|s| !s.is_empty()) .collect(); assert_eq!(content, vec!["hello world"]); // No syntax coloring for unknown language — all spans have default style. let has_colored_span = text .lines .iter() .flat_map(|l| l.spans.iter()) .any(|sp| sp.style.fg.is_some()); assert!(!has_colored_span, "expected no syntax coloring for unknown lang"); } #[test] fn code_block_no_lang_plain() { let text = render_markdown_text("```\nno lang specified\n```\n"); let content: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); let content: Vec<&str> = content .iter() .map(std::string::String::as_str) .filter(|s| !s.is_empty()) .collect(); assert_eq!(content, vec!["no lang specified"]); } #[test] fn code_block_multiple_lines_root() { let md = "```\nfirst\nsecond\n```\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["", "first"]), Line::from_iter(["", "second"]), ]); assert_eq!(text, expected); } #[test] fn code_block_indented() { let md = " function greet() {\n console.log(\"Hi\");\n }\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter([" ", "function greet() {"]), Line::from_iter([" ", " console.log(\"Hi\");"]), Line::from_iter([" ", "}"]), ]); assert_eq!(text, expected); } #[test] fn horizontal_rule_renders_em_dashes() { let md = "Before\n\n---\n\nAfter\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["Before", "", "———", "", "After"]); } #[test] fn code_block_with_inner_triple_backticks_outer_four() { let md = r#"````text Here is a code block that shows another fenced block: ```md # Inside fence - bullet - `inline code` ``` ```` "#; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); // Filter empty trailing lines for stability; the code block may or may // not emit a trailing blank depending on the highlighting path. let trimmed: Vec<&str> = { let mut v: Vec<&str> = lines.iter().map(std::string::String::as_str).collect(); while v.last() == Some(&"") { v.pop(); } v }; assert_eq!( trimmed, vec![ "Here is a code block that shows another fenced block:", "", "```md", "# Inside fence", "- bullet", "- `inline code`", "```", ] ); } #[test] fn code_block_inside_unordered_list_item_is_indented() { let md = "- Item\n\n ```\n code line\n ```\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["- Item", "", " code line"]); } #[test] fn code_block_multiple_lines_inside_unordered_list() { let md = "- Item\n\n ```\n first\n second\n ```\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["- Item", "", " first", " second"]); } #[test] fn code_block_inside_unordered_list_item_multiple_lines() { let md = "- Item\n\n ```\n first\n second\n ```\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); assert_eq!(lines, vec!["- Item", "", " first", " second"]); } #[test] fn list_item_after_code_block_keeps_blank_separator() { let md = "1. First:\n\n ```rust\n fn first() {}\n ```\n\n2. Second:\n"; let text = render_markdown_text(md); let lines = plain_lines(&text); assert_eq!( lines, vec!["1. First:", "", " fn first() {}", "", "2. Second:"] ); assert_snapshot!( "list_item_after_code_block_keeps_blank_separator", lines.join("\n") ); } #[test] fn outer_list_item_after_nested_code_block_keeps_blank_separator() { let md = "1. First:\n - Nested:\n\n ```rust\n fn first() {}\n ```\n\n2. Second:\n"; let text = render_markdown_text(md); let lines = plain_lines(&text); assert_eq!( lines, vec![ "1. First:", " - Nested:", "", " fn first() {}", "", "2. Second:", ] ); } #[test] fn list_item_after_simple_item_stays_compact() { let md = "1. First\n\n2. Second\n"; let text = render_markdown_text(md); assert_eq!(plain_lines(&text), vec!["1. First", "2. Second"]); } #[test] fn multiline_finding_items_are_separated_snapshot() { let md = r#"**Findings** 1. **Correctness issue: server tool-search completions are always rejected.** In `next_prompt_suggestion.rs`, the output is ignored, suppressing suggestions after completed searches. Minimal correction: count matching outputs and suppress only missing ones. 2. **High-confidence simplification: remove the unused error channel.** The implementation resolves failures to `None`, so its contract can be narrower. 3. **High-confidence churn reduction: consolidate table-driven filter tests.** "#; let text = render_markdown_text(md); assert_snapshot!(plain_lines(&text).join("\n")); } #[test] fn wrapped_list_item_is_separated_from_next_sibling() { let md = "1. This item wraps onto another visible rendered line\n2. Next item\n"; let text = render_markdown_text_with_width(md, Some(/*width*/ 24)); assert_eq!( plain_lines(&text), vec![ "1. This item wraps onto", " another visible", " rendered line", "", "2. Next item", ] ); } #[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 Intro paragraph with bold **text**, italic *text*, and inline code `x=1`. Combined bold-italic ***both*** and escaped asterisks \*literal\*. Auto-link: and reference link [ref][r1]. Link with title: [hover me](https://example.com "Example") and mailto . Image: ![alt text](https://example.com/img.png "Title") > Blockquote level 1 >> Blockquote level 2 with `inline code` - Unordered list item 1 - Nested bullet with italics _inner_ - Unordered list item 2 with ~~strikethrough~~ 1. Ordered item one 2. Ordered item two with sublist: 1) Alt-numbered subitem - [ ] Task: unchecked - [x] Task: checked with link [home](https://example.org) --- Table below (alignment test): | Left | Center | Right | |:-----|:------:|------:| | a | b | c | Inline HTML: sup and sub. HTML block:
inline block
Escapes: \_underscores\_, backslash \\, ticks ``code with `backtick` inside``. Emoji shortcodes: :sparkles: :tada: (if supported). Hard break test (line ends with two spaces) Next line should be close to previous. Footnote reference here[^1] and another[^longnote]. Horizontal rule with asterisks: *** Fenced code block (JSON): ```json { "a": 1, "b": [true, false] } ``` Fenced code with tildes and triple backticks inside: ~~~markdown To close ``` you need tildes. ~~~ Indented code block: for i in range(3): print(i) Definition-like list: Term : Definition with `code`. Character entities: & < > " ' [^1]: This is the first footnote. [^longnote]: A longer footnote with a link to [Rust](https://www.rust-lang.org/). Escaped pipe in text: a \| b \| c. URL with parentheses: [link](https://example.com/path_(with)_parens). [r1]: https://example.com/ref "Reference link title" "#; let text = render_markdown_text(md); // Convert to plain text lines for snapshot (ignore styles) let rendered = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect::>() .join("\n"); assert_snapshot!(rendered); } #[test] fn ordered_item_with_code_block_and_nested_bullet() { let md = "1. **item 1**\n\n2. **item 2**\n ```\n code\n ```\n - `PROCESS_START` (a `OnceLock`) keeps the start time for the entire process.\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "1. item 1".to_string(), "2. item 2".to_string(), String::new(), " code".to_string(), " - PROCESS_START (a OnceLock) keeps the start time for the entire process.".to_string(), ] ); } #[test] fn nested_five_levels_mixed_lists() { let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "First".into()]), Line::from_iter([" - ", "Second level"]), Line::from_iter([" 1. ".light_blue(), "Third level (ordered)".into()]), Line::from_iter([" - ", "Fourth level (bullet)"]), Line::from_iter([ " - ", "Fifth level to test indent consistency", ]), ]); assert_eq!(text, expected); } #[test] fn html_inline_is_verbatim() { let md = "Hello world!"; let text = render_markdown_text(md); let expected: Text = Line::from_iter(["Hello ", "", "world", "", "!"]).into(); assert_eq!(text, expected); } #[test] fn html_block_is_verbatim_multiline() { let md = "
\n hi\n
\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["
"]), Line::from_iter([" hi"]), Line::from_iter(["
"]), ]); assert_eq!(text, expected); } #[test] fn html_in_tight_ordered_item_soft_breaks_with_space() { let md = "1. Foo\n Bar\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "Foo".into()]), Line::from_iter([" ", "", "Bar", ""]), ]); assert_eq!(text, expected); } #[test] fn html_continuation_paragraph_in_unordered_item_indented() { let md = "- Item\n\n continued\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["- ", "Item"]), Line::default(), Line::from_iter([" ", "", "continued", ""]), ]); assert_eq!(text, expected); } #[test] fn unordered_item_continuation_paragraph_is_indented() { let md = "- Intro\n\n Continuation paragraph line 1\n Continuation paragraph line 2\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.clone()) .collect::() }) .collect(); assert_eq!( lines, vec![ "- Intro".to_string(), String::new(), " Continuation paragraph line 1".to_string(), " Continuation paragraph line 2".to_string(), ] ); } #[test] fn ordered_item_continuation_paragraph_is_indented() { let md = "1. Intro\n\n More details about intro\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "Intro".into()]), Line::default(), Line::from_iter([" ", "More details about intro"]), ]); assert_eq!(text, expected); } #[test] fn nested_item_continuation_paragraph_is_indented() { let md = "1. A\n - B\n\n Continuation for B\n2. C\n"; let text = render_markdown_text(md); let expected = Text::from_iter([ Line::from_iter(["1. ".light_blue(), "A".into()]), Line::from_iter([" - ", "B"]), Line::default(), Line::from_iter([" ", "Continuation for B"]), Line::default(), Line::from_iter(["2. ".light_blue(), "C".into()]), ]); assert_eq!(text, expected); } #[test] fn code_block_preserves_trailing_blank_lines() { // A fenced code block with an intentional trailing blank line must keep it. let md = "```rust\nfn main() {}\n\n```\n"; let text = render_markdown_text(md); let content: Vec = text .lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.clone()) .collect::() }) .collect(); // Should have: "fn main() {}" then "" (the blank line). // Filter only to content lines (skip leading/trailing empty from rendering). assert!( content.iter().any(|c| c == "fn main() {}"), "expected code line, got {content:?}" ); // The trailing blank line inside the fence should be preserved. let code_start = content.iter().position(|c| c == "fn main() {}").unwrap(); assert!( content.len() > code_start + 1, "expected a line after 'fn main() {{}}' but content ends: {content:?}" ); assert_eq!( content[code_start + 1], "", "trailing blank line inside code fence was lost: {content:?}" ); } #[test] fn table_renders_app_style_rows_with_themed_bold_header() { let md = "| A | B |\n|---|---|\n| 1 | 2 |\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); assert_eq!( lines, vec![ " A B".to_string(), "━━━━━ ━━━━━".to_string(), " 1 2".to_string(), ] ); assert!( text.lines[0] .style .add_modifier .contains(Modifier::BOLD) ); assert!( text.lines[0].style.fg.is_some(), "expected the syntax theme to provide a table header accent" ); assert!( text.lines[1].spans[0] .style .add_modifier .contains(Modifier::DIM) ); assert!( !text.lines[2] .style .add_modifier .contains(Modifier::BOLD) ); } #[test] fn table_alignment_respects_markers() { let md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); assert_eq!(lines[0], " Left Center Right"); assert_eq!(lines[2], " a b c"); } #[test] fn table_separates_logical_rows_after_wrapped_content() { let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n| -q | Quiet output |\n"; let text = crate::markdown_render::render_markdown_text_with_width(md, Some(30)); let lines: Vec = text .lines .iter() .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); assert!( lines .iter() .any(|line| line.contains("Enable very verbose")) && lines.iter().any(|line| line.contains("logging output")), "expected wrapped row content: {lines:?}" ); let separator_indices: Vec = lines .iter() .enumerate() .filter_map(|(idx, line)| { ((line.contains('━') || line.contains('─')) && line.chars().all(|ch| matches!(ch, '━' | '─' | ' '))) .then_some(idx) }) .collect(); let wrapped_row_end = lines .iter() .position(|line| line.contains("logging output")) .expect("expected final wrapped line"); assert_eq!(separator_indices.len(), 2); assert!(separator_indices[1] > wrapped_row_end); assert!( !lines .last() .is_some_and(|line| line.contains('━') || line.contains('─')) ); } #[test] fn table_wraps_file_paths_before_collapsing_narrative_columns_snapshot() { let md = r#"| Unit | Files | Adds | Removes | What It Adds | |---|---:|---:|---:|---| | Suggestion engine and unit coverage | [next_prompt_suggestion.rs](/Users/example/code/codex/codex-rs/core/src/next_prompt_suggestion.rs:1), [next_prompt_suggestion_tests.rs](/Users/example/code/codex/codex-rs/core/src/next_prompt_suggestion_tests.rs:1) | 704 | 0 | Sampling workflow, stable-history checks, tool-flow suppression, fast reasoning profile, filtering rules, cancellation and timeout. | | Model instruction fragment and contextual isolation | [next_prompt_suggestion.rs](/Users/example/code/codex/codex-rs/core/src/context/next_prompt_suggestion.rs:1), [contextual_user_message_tests.rs](/Users/example/code/codex/codex-rs/core/src/context/contextual_user_message_tests.rs:1) | 54 | 0 | Synthetic suggestion prompt and an isolation test for ordinary user text. | "#; let text = render_markdown_text_with_width_and_cwd( md, Some(/*width*/ 120), Some(Path::new("/Users/example/code/codex")), ); assert_snapshot!(plain_lines(&text).join("\n")); } #[test] fn table_renders_stacked_key_value_records_when_path_column_becomes_too_narrow_snapshot() { let md = r#"| Session | Why useful | Detected table blocks | | --- | --- | --- | | [2026-05-25 current gallery](/Users/felipe.coury/.codex/sessions/2026/05/25/rollout-2026-05-25T18-13-09-019e60fc-0518-7c21-9596-980fe97225ba.jsonl) | The large gallery from this thread: emojis, links, emphasis, code, alignment, paragraphs, and a 30+ row table | 7 | | [2026-05-14 renderer testing](/Users/felipe.coury/.codex/sessions/2026/05/14/rollout-2026-05-14T12-57-18-019e2734-e500-7011-8278-975c94d06000.jsonl) | Explicit "markdown tables for testing" session with several successive assistant samples | 16 | | [2026-05-14 five-table test](/Users/felipe.coury/.codex/sessions/2026/05/14/rollout-2026-05-14T12-27-57-019e271a-064c-78c3-a5cd-a6f20a0c1ad5.jsonl) | Explicit request for five tables containing emojis, code, italics, and varied cell content | 10 | "#; let text = render_markdown_text_with_width(md, Some(/*width*/ 42)); assert_snapshot!(plain_lines(&text).join("\n")); } #[test] fn table_renders_records_when_multiple_prose_columns_are_starved_snapshot() { let md = r#"| Issue | Activity | Complexity | Why start | | --- | ---: | ---: | --- | | [#24485: newline shortcut fails in PyCharm terminal on Windows](https://github.com/openai/codex/issues/24485) | `+1` 0, substantive comments 0 | Low | New, deterministic regression range; localized composer/keymap path. | | [#23926: Vim composer `e` stalls at word end](https://github.com/openai/codex/issues/23926) | `+1` 0, comments 0 | Low | Standing best quick win; deterministic motion bug. | | [#23651: Zellij scrollback misses Codex transcript over SSH](https://github.com/openai/codex/issues/23651) | `+1` 3, human comments 2 | Medium | Clear regression and strong scrollback evidence. | | [#23740: raw ANSI/control sequences in Windows Terminal](https://github.com/openai/codex/issues/23740) | `+1` 7, human comments 7 | Medium | Highest activity; established Windows rendering regression family. | | [#24527: typing lag increases with session length](https://github.com/openai/codex/issues/24527) | `+1` 0, substantive comments 0 | Medium | New TUI-visible performance report; needs profiling before implementation. | "#; let text = render_markdown_text_with_width(md, Some(/*width*/ 76)); assert_snapshot!(plain_lines(&text).join("\n")); } #[test] fn table_keeps_grid_when_only_one_compact_record_fragments_snapshot() { let md = r#"| Key | Date | State | | --- | --- | --- | | short | 2025-01-01 | Ready | | verylongidentifier | 2025-02-02 | Ready | | final | 2025-03-03 | Done | "#; let text = render_markdown_text_with_width(md, Some(/*width*/ 40)); assert_snapshot!(plain_lines(&text).join("\n")); } #[test] fn table_renders_key_value_records_when_compact_fragmentation_is_systemic_snapshot() { let md = r#"| Key | Notes | | --- | --- | | firstlongid | A readable explanatory sentence for this row. | | secondlongid | Another readable explanatory sentence for this row. | | short | A final readable explanatory sentence for this row. | "#; let text = render_markdown_text_with_width(md, Some(/*width*/ 17)); assert_snapshot!(plain_lines(&text).join("\n")); } #[test] fn table_inside_blockquote_has_quote_prefix() { let md = "> | A | B |\n> |---|---|\n> | 1 | 2 |\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); assert!(lines.iter().all(|line| line.starts_with("> "))); assert!(lines.iter().any(|line| line.contains("━━━━━ ━━━━━"))); } #[test] fn escaped_pipes_render_in_table_cells() { let md = "| Col |\n| --- |\n| a \\| b |\n"; let text = render_markdown_text(md); let lines: Vec = text .lines .iter() .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); assert!(lines.iter().any(|line| line.contains("a | b"))); } #[test] fn table_falls_back_to_key_value_records_if_grid_cannot_fit() { let md = "| c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10 |\n|---|---|---|---|---|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |\n"; let text = crate::markdown_render::render_markdown_text_with_width(md, Some(/*width*/ 20)); let lines: Vec = text .lines .iter() .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); assert!(lines.first().is_some_and(|line| line.contains("c1"))); assert!(lines.iter().any(|line| line.contains("c10") && line.contains("10"))); assert!( !lines .iter() .any(|line| line.starts_with('|') || line.contains('━') || line.contains('─')) ); } #[test] fn table_key_value_fallback_preserves_rich_values_and_themed_labels() { let md = "| Key | Content | Extra | More |\n|---|---|---|---|\n| item | [link](https://example.com) | **bold** | `code` |\n"; let text = crate::markdown_render::render_markdown_text_with_width(md, Some(/*width*/ 16)); let lines = plain_lines(&text); assert!(lines.iter().any(|line| line.contains("Key"))); assert!(lines.iter().any(|line| line.contains("item"))); assert!(lines.iter().any(|line| line.contains("link"))); assert!(lines.iter().any(|line| line.contains("bold"))); assert!(lines.iter().any(|line| line.contains("code"))); assert!( text.lines[0] .spans .iter() .any(|span| span.content.contains("Key") && span.style.add_modifier.contains(Modifier::BOLD) && span.style.fg.is_some()) ); assert!(text.lines.iter().any(|line| { line.spans .iter() .any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED)) })); }