mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
941 lines
31 KiB
Rust
941 lines
31 KiB
Rust
use crate::render::highlight::highlight_code_to_lines;
|
||
use crate::render::line_utils::line_to_static;
|
||
use crate::wrapping::RtOptions;
|
||
use crate::wrapping::adaptive_wrap_line;
|
||
use codex_utils_string::normalize_markdown_hash_location_suffix;
|
||
use pulldown_cmark::CodeBlockKind;
|
||
use pulldown_cmark::CowStr;
|
||
use pulldown_cmark::Event;
|
||
use pulldown_cmark::HeadingLevel;
|
||
use pulldown_cmark::Options;
|
||
use pulldown_cmark::Parser;
|
||
use pulldown_cmark::Tag;
|
||
use pulldown_cmark::TagEnd;
|
||
use ratatui::style::Style;
|
||
use ratatui::text::Line;
|
||
use ratatui::text::Span;
|
||
use ratatui::text::Text;
|
||
use regex_lite::Regex;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::LazyLock;
|
||
use url::Url;
|
||
use urlencoding::decode;
|
||
|
||
struct MarkdownStyles {
|
||
h1: Style,
|
||
h2: Style,
|
||
h3: Style,
|
||
h4: Style,
|
||
h5: Style,
|
||
h6: Style,
|
||
code: Style,
|
||
emphasis: Style,
|
||
strong: Style,
|
||
strikethrough: Style,
|
||
ordered_list_marker: Style,
|
||
unordered_list_marker: Style,
|
||
link: Style,
|
||
blockquote: Style,
|
||
}
|
||
|
||
impl Default for MarkdownStyles {
|
||
fn default() -> Self {
|
||
use ratatui::style::Stylize;
|
||
|
||
Self {
|
||
h1: Style::new().bold().underlined(),
|
||
h2: Style::new().bold(),
|
||
h3: Style::new().bold().italic(),
|
||
h4: Style::new().italic(),
|
||
h5: Style::new().italic(),
|
||
h6: Style::new().italic(),
|
||
code: Style::new().cyan(),
|
||
emphasis: Style::new().italic(),
|
||
strong: Style::new().bold(),
|
||
strikethrough: Style::new().crossed_out(),
|
||
ordered_list_marker: Style::new().light_blue(),
|
||
unordered_list_marker: Style::new(),
|
||
link: Style::new().cyan().underlined(),
|
||
blockquote: Style::new().green(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct IndentContext {
|
||
prefix: Vec<Span<'static>>,
|
||
marker: Option<Vec<Span<'static>>>,
|
||
is_list: bool,
|
||
}
|
||
|
||
impl IndentContext {
|
||
fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, is_list: bool) -> Self {
|
||
Self {
|
||
prefix,
|
||
marker,
|
||
is_list,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn render_markdown_text(input: &str) -> Text<'static> {
|
||
render_markdown_text_with_width(input, None)
|
||
}
|
||
|
||
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
|
||
render_markdown_text_with_width_and_cwd(input, width, None)
|
||
}
|
||
|
||
pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||
input: &str,
|
||
width: Option<usize>,
|
||
cwd: Option<&Path>,
|
||
) -> Text<'static> {
|
||
let mut options = Options::empty();
|
||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||
let parser = Parser::new_ext(input, options);
|
||
let mut w = Writer::new(parser, width, cwd);
|
||
w.run();
|
||
w.text
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct LinkState {
|
||
destination: String,
|
||
show_destination: bool,
|
||
local_display_text: Option<String>,
|
||
}
|
||
|
||
fn should_render_link_destination(dest_url: &str) -> bool {
|
||
!is_local_path_like_link(dest_url)
|
||
}
|
||
|
||
static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
|
||
LazyLock::new(
|
||
|| match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") {
|
||
Ok(regex) => regex,
|
||
Err(error) => panic!("invalid location suffix regex: {error}"),
|
||
},
|
||
);
|
||
|
||
// Covered by load_location_suffix_regexes.
|
||
static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
|
||
LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") {
|
||
Ok(regex) => regex,
|
||
Err(error) => panic!("invalid hash location regex: {error}"),
|
||
});
|
||
|
||
fn is_local_path_like_link(dest_url: &str) -> bool {
|
||
dest_url.starts_with("file://")
|
||
|| dest_url.starts_with('/')
|
||
|| dest_url.starts_with("~/")
|
||
|| dest_url.starts_with("./")
|
||
|| dest_url.starts_with("../")
|
||
|| dest_url.starts_with("\\\\")
|
||
|| matches!(
|
||
dest_url.as_bytes(),
|
||
[drive, b':', separator, ..]
|
||
if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
|
||
)
|
||
}
|
||
|
||
fn local_link_display_text(dest_url: &str, cwd: Option<&Path>) -> Option<String> {
|
||
if !is_local_path_like_link(dest_url) {
|
||
return None;
|
||
}
|
||
let (path, location_suffix) = split_local_link_destination(dest_url)?;
|
||
let display_path = cwd.map_or_else(
|
||
|| path.display().to_string(),
|
||
|cwd| match path.strip_prefix(cwd) {
|
||
Ok(stripped) => stripped.display().to_string(),
|
||
Err(_) => path.display().to_string(),
|
||
},
|
||
);
|
||
Some(format!("{display_path}{location_suffix}"))
|
||
}
|
||
|
||
fn split_local_link_destination(dest_url: &str) -> Option<(PathBuf, String)> {
|
||
if dest_url.starts_with("file://") {
|
||
let url = Url::parse(dest_url).ok()?;
|
||
let path = url.to_file_path().ok().or_else(|| {
|
||
let decoded_path = decode(url.path()).ok()?.into_owned();
|
||
if decoded_path.is_empty() {
|
||
return None;
|
||
}
|
||
let file_path = match url.host_str() {
|
||
Some(host) if !host.is_empty() && host != "localhost" => {
|
||
format!("//{host}{decoded_path}")
|
||
}
|
||
_ => decoded_path,
|
||
};
|
||
Some(PathBuf::from(file_path))
|
||
})?;
|
||
let location_suffix = url
|
||
.fragment()
|
||
.map(|fragment| format!("#{fragment}"))
|
||
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
|
||
.unwrap_or_default();
|
||
return Some((path, location_suffix));
|
||
}
|
||
|
||
if let Some((path_part, fragment)) = dest_url.rsplit_once('#')
|
||
&& HASH_LOCATION_SUFFIX_RE.is_match(fragment)
|
||
{
|
||
let location_suffix =
|
||
normalize_markdown_hash_location_suffix(&format!("#{fragment}")).unwrap_or_default();
|
||
return Some((PathBuf::from(path_part), location_suffix));
|
||
}
|
||
|
||
if let Some(location_match) = COLON_LOCATION_SUFFIX_RE.find(dest_url) {
|
||
let path_part = &dest_url[..location_match.start()];
|
||
return Some((
|
||
PathBuf::from(path_part),
|
||
location_match.as_str().to_string(),
|
||
));
|
||
}
|
||
|
||
Some((PathBuf::from(dest_url), String::new()))
|
||
}
|
||
|
||
struct Writer<'a, I>
|
||
where
|
||
I: Iterator<Item = Event<'a>>,
|
||
{
|
||
iter: I,
|
||
text: Text<'static>,
|
||
styles: MarkdownStyles,
|
||
inline_styles: Vec<Style>,
|
||
indent_stack: Vec<IndentContext>,
|
||
list_indices: Vec<Option<u64>>,
|
||
link: Option<LinkState>,
|
||
needs_newline: bool,
|
||
pending_marker_line: bool,
|
||
in_paragraph: bool,
|
||
in_code_block: bool,
|
||
code_block_lang: Option<String>,
|
||
code_block_buffer: String,
|
||
wrap_width: Option<usize>,
|
||
current_line_content: Option<Line<'static>>,
|
||
current_initial_indent: Vec<Span<'static>>,
|
||
current_subsequent_indent: Vec<Span<'static>>,
|
||
current_line_style: Style,
|
||
current_line_in_code_block: bool,
|
||
cwd: Option<PathBuf>,
|
||
}
|
||
|
||
impl<'a, I> Writer<'a, I>
|
||
where
|
||
I: Iterator<Item = Event<'a>>,
|
||
{
|
||
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
|
||
Self {
|
||
iter,
|
||
text: Text::default(),
|
||
styles: MarkdownStyles::default(),
|
||
inline_styles: Vec::new(),
|
||
indent_stack: Vec::new(),
|
||
list_indices: Vec::new(),
|
||
link: None,
|
||
needs_newline: false,
|
||
pending_marker_line: false,
|
||
in_paragraph: false,
|
||
in_code_block: false,
|
||
code_block_lang: None,
|
||
code_block_buffer: String::new(),
|
||
wrap_width,
|
||
current_line_content: None,
|
||
current_initial_indent: Vec::new(),
|
||
current_subsequent_indent: Vec::new(),
|
||
current_line_style: Style::default(),
|
||
current_line_in_code_block: false,
|
||
cwd: cwd.map(Path::to_path_buf),
|
||
}
|
||
}
|
||
|
||
fn suppress_local_link_label(&self) -> bool {
|
||
self.link
|
||
.as_ref()
|
||
.and_then(|link| link.local_display_text.as_ref())
|
||
.is_some()
|
||
}
|
||
|
||
fn run(&mut self) {
|
||
while let Some(ev) = self.iter.next() {
|
||
self.handle_event(ev);
|
||
}
|
||
self.flush_current_line();
|
||
}
|
||
|
||
fn handle_event(&mut self, event: Event<'a>) {
|
||
match event {
|
||
Event::Start(tag) => self.start_tag(tag),
|
||
Event::End(tag) => self.end_tag(tag),
|
||
Event::Text(text) => {
|
||
if !self.suppress_local_link_label() {
|
||
self.text(text);
|
||
}
|
||
}
|
||
Event::Code(code) => {
|
||
if !self.suppress_local_link_label() {
|
||
self.code(code);
|
||
}
|
||
}
|
||
Event::SoftBreak => {
|
||
if !self.suppress_local_link_label() {
|
||
self.soft_break();
|
||
}
|
||
}
|
||
Event::HardBreak => {
|
||
if !self.suppress_local_link_label() {
|
||
self.hard_break();
|
||
}
|
||
}
|
||
Event::Rule => {
|
||
self.flush_current_line();
|
||
if !self.text.lines.is_empty() {
|
||
self.push_blank_line();
|
||
}
|
||
self.push_line(Line::from("———"));
|
||
self.needs_newline = true;
|
||
}
|
||
Event::Html(html) => {
|
||
if !self.suppress_local_link_label() {
|
||
self.html(html, false);
|
||
}
|
||
}
|
||
Event::InlineHtml(html) => {
|
||
if !self.suppress_local_link_label() {
|
||
self.html(html, true);
|
||
}
|
||
}
|
||
Event::FootnoteReference(_) => {}
|
||
Event::TaskListMarker(_) => {}
|
||
}
|
||
}
|
||
|
||
fn start_tag(&mut self, tag: Tag<'a>) {
|
||
match tag {
|
||
Tag::Paragraph => self.start_paragraph(),
|
||
Tag::Heading { level, .. } => self.start_heading(level),
|
||
Tag::BlockQuote => self.start_blockquote(),
|
||
Tag::CodeBlock(kind) => {
|
||
let indent = match kind {
|
||
CodeBlockKind::Fenced(_) => None,
|
||
CodeBlockKind::Indented => Some(Span::from(" ".repeat(4))),
|
||
};
|
||
let lang = match kind {
|
||
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
|
||
CodeBlockKind::Indented => None,
|
||
};
|
||
self.start_codeblock(lang, indent)
|
||
}
|
||
Tag::List(start) => self.start_list(start),
|
||
Tag::Item => self.start_item(),
|
||
Tag::Emphasis => {
|
||
if !self.suppress_local_link_label() {
|
||
self.push_inline_style(self.styles.emphasis);
|
||
}
|
||
}
|
||
Tag::Strong => {
|
||
if !self.suppress_local_link_label() {
|
||
self.push_inline_style(self.styles.strong);
|
||
}
|
||
}
|
||
Tag::Strikethrough => {
|
||
if !self.suppress_local_link_label() {
|
||
self.push_inline_style(self.styles.strikethrough);
|
||
}
|
||
}
|
||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||
Tag::HtmlBlock
|
||
| Tag::FootnoteDefinition(_)
|
||
| Tag::Table(_)
|
||
| Tag::TableHead
|
||
| Tag::TableRow
|
||
| Tag::TableCell
|
||
| Tag::Image { .. }
|
||
| Tag::MetadataBlock(_) => {}
|
||
}
|
||
}
|
||
|
||
fn end_tag(&mut self, tag: TagEnd) {
|
||
match tag {
|
||
TagEnd::Paragraph => self.end_paragraph(),
|
||
TagEnd::Heading(_) => self.end_heading(),
|
||
TagEnd::BlockQuote => self.end_blockquote(),
|
||
TagEnd::CodeBlock => self.end_codeblock(),
|
||
TagEnd::List(_) => self.end_list(),
|
||
TagEnd::Item => {
|
||
self.indent_stack.pop();
|
||
self.pending_marker_line = false;
|
||
}
|
||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
|
||
if !self.suppress_local_link_label() {
|
||
self.pop_inline_style();
|
||
}
|
||
}
|
||
TagEnd::Link => self.pop_link(),
|
||
TagEnd::HtmlBlock
|
||
| TagEnd::FootnoteDefinition
|
||
| TagEnd::Table
|
||
| TagEnd::TableHead
|
||
| TagEnd::TableRow
|
||
| TagEnd::TableCell
|
||
| TagEnd::Image
|
||
| TagEnd::MetadataBlock(_) => {}
|
||
}
|
||
}
|
||
|
||
fn start_paragraph(&mut self) {
|
||
if self.needs_newline {
|
||
self.push_blank_line();
|
||
}
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
self.in_paragraph = true;
|
||
}
|
||
|
||
fn end_paragraph(&mut self) {
|
||
self.needs_newline = true;
|
||
self.in_paragraph = false;
|
||
self.pending_marker_line = false;
|
||
}
|
||
|
||
fn start_heading(&mut self, level: HeadingLevel) {
|
||
if self.needs_newline {
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
}
|
||
let heading_style = match level {
|
||
HeadingLevel::H1 => self.styles.h1,
|
||
HeadingLevel::H2 => self.styles.h2,
|
||
HeadingLevel::H3 => self.styles.h3,
|
||
HeadingLevel::H4 => self.styles.h4,
|
||
HeadingLevel::H5 => self.styles.h5,
|
||
HeadingLevel::H6 => self.styles.h6,
|
||
};
|
||
let content = format!("{} ", "#".repeat(level as usize));
|
||
self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
|
||
self.push_inline_style(heading_style);
|
||
self.needs_newline = false;
|
||
}
|
||
|
||
fn end_heading(&mut self) {
|
||
self.needs_newline = true;
|
||
self.pop_inline_style();
|
||
}
|
||
|
||
fn start_blockquote(&mut self) {
|
||
if self.needs_newline {
|
||
self.push_blank_line();
|
||
self.needs_newline = false;
|
||
}
|
||
self.indent_stack
|
||
.push(IndentContext::new(vec![Span::from("> ")], None, false));
|
||
}
|
||
|
||
fn end_blockquote(&mut self) {
|
||
self.indent_stack.pop();
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn text(&mut self, text: CowStr<'a>) {
|
||
if self.pending_marker_line {
|
||
self.push_line(Line::default());
|
||
}
|
||
self.pending_marker_line = false;
|
||
|
||
// When inside a fenced code block with a known language, accumulate
|
||
// text into the buffer for batch highlighting in end_codeblock().
|
||
// Append verbatim — pulldown-cmark text events already contain the
|
||
// original line breaks, so inserting separators would double them.
|
||
if self.in_code_block && self.code_block_lang.is_some() {
|
||
self.code_block_buffer.push_str(&text);
|
||
return;
|
||
}
|
||
|
||
if self.in_code_block && !self.needs_newline {
|
||
let has_content = self
|
||
.current_line_content
|
||
.as_ref()
|
||
.map(|line| !line.spans.is_empty())
|
||
.unwrap_or_else(|| {
|
||
self.text
|
||
.lines
|
||
.last()
|
||
.map(|line| !line.spans.is_empty())
|
||
.unwrap_or(false)
|
||
});
|
||
if has_content {
|
||
self.push_line(Line::default());
|
||
}
|
||
}
|
||
for (i, line) in text.lines().enumerate() {
|
||
if self.needs_newline {
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
}
|
||
if i > 0 {
|
||
self.push_line(Line::default());
|
||
}
|
||
let content = line.to_string();
|
||
let span = Span::styled(
|
||
content,
|
||
self.inline_styles.last().copied().unwrap_or_default(),
|
||
);
|
||
self.push_span(span);
|
||
}
|
||
self.needs_newline = false;
|
||
}
|
||
|
||
fn code(&mut self, code: CowStr<'a>) {
|
||
if self.pending_marker_line {
|
||
self.push_line(Line::default());
|
||
self.pending_marker_line = false;
|
||
}
|
||
let span = Span::from(code.into_string()).style(self.styles.code);
|
||
self.push_span(span);
|
||
}
|
||
|
||
fn html(&mut self, html: CowStr<'a>, inline: bool) {
|
||
self.pending_marker_line = false;
|
||
for (i, line) in html.lines().enumerate() {
|
||
if self.needs_newline {
|
||
self.push_line(Line::default());
|
||
self.needs_newline = false;
|
||
}
|
||
if i > 0 {
|
||
self.push_line(Line::default());
|
||
}
|
||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||
self.push_span(Span::styled(line.to_string(), style));
|
||
}
|
||
self.needs_newline = !inline;
|
||
}
|
||
|
||
fn hard_break(&mut self) {
|
||
self.push_line(Line::default());
|
||
}
|
||
|
||
fn soft_break(&mut self) {
|
||
self.push_line(Line::default());
|
||
}
|
||
|
||
fn start_list(&mut self, index: Option<u64>) {
|
||
if self.list_indices.is_empty() && self.needs_newline {
|
||
self.push_line(Line::default());
|
||
}
|
||
self.list_indices.push(index);
|
||
}
|
||
|
||
fn end_list(&mut self) {
|
||
self.list_indices.pop();
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn start_item(&mut self) {
|
||
self.pending_marker_line = true;
|
||
let depth = self.list_indices.len();
|
||
let is_ordered = self
|
||
.list_indices
|
||
.last()
|
||
.map(Option::is_some)
|
||
.unwrap_or(false);
|
||
let width = depth * 4 - 3;
|
||
let marker = if let Some(last_index) = self.list_indices.last_mut() {
|
||
match last_index {
|
||
None => Some(vec![Span::styled(
|
||
" ".repeat(width - 1) + "- ",
|
||
self.styles.unordered_list_marker,
|
||
)]),
|
||
Some(index) => {
|
||
*index += 1;
|
||
Some(vec![Span::styled(
|
||
format!("{:width$}. ", *index - 1),
|
||
self.styles.ordered_list_marker,
|
||
)])
|
||
}
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
let indent_prefix = if depth == 0 {
|
||
Vec::new()
|
||
} else {
|
||
let indent_len = if is_ordered { width + 2 } else { width + 1 };
|
||
vec![Span::from(" ".repeat(indent_len))]
|
||
};
|
||
self.indent_stack
|
||
.push(IndentContext::new(indent_prefix, marker, true));
|
||
self.needs_newline = false;
|
||
}
|
||
|
||
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
|
||
self.flush_current_line();
|
||
if !self.text.lines.is_empty() {
|
||
self.push_blank_line();
|
||
}
|
||
self.in_code_block = true;
|
||
|
||
// Extract the language token from the info string. CommonMark info
|
||
// strings can contain metadata after the language, separated by commas,
|
||
// spaces, or other delimiters (e.g. "rust,no_run", "rust title=demo").
|
||
// Take only the first token so the syntax lookup succeeds.
|
||
let lang = lang
|
||
.as_deref()
|
||
.and_then(|s| s.split([',', ' ', '\t']).next())
|
||
.filter(|s| !s.is_empty())
|
||
.map(std::string::ToString::to_string);
|
||
self.code_block_lang = lang;
|
||
self.code_block_buffer.clear();
|
||
|
||
self.indent_stack.push(IndentContext::new(
|
||
vec![indent.unwrap_or_default()],
|
||
None,
|
||
false,
|
||
));
|
||
self.needs_newline = true;
|
||
}
|
||
|
||
fn end_codeblock(&mut self) {
|
||
// If we buffered code for a known language, syntax-highlight it now.
|
||
if let Some(lang) = self.code_block_lang.take() {
|
||
let code = std::mem::take(&mut self.code_block_buffer);
|
||
if !code.is_empty() {
|
||
let highlighted = highlight_code_to_lines(&code, &lang);
|
||
for hl_line in highlighted {
|
||
self.push_line(Line::default());
|
||
for span in hl_line.spans {
|
||
self.push_span(span);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
self.needs_newline = true;
|
||
self.in_code_block = false;
|
||
self.indent_stack.pop();
|
||
}
|
||
|
||
fn push_inline_style(&mut self, style: Style) {
|
||
let current = self.inline_styles.last().copied().unwrap_or_default();
|
||
let merged = current.patch(style);
|
||
self.inline_styles.push(merged);
|
||
}
|
||
|
||
fn pop_inline_style(&mut self) {
|
||
self.inline_styles.pop();
|
||
}
|
||
|
||
fn push_link(&mut self, dest_url: String) {
|
||
let show_destination = should_render_link_destination(&dest_url);
|
||
self.link = Some(LinkState {
|
||
show_destination,
|
||
local_display_text: local_link_display_text(&dest_url, self.cwd.as_deref()),
|
||
destination: dest_url,
|
||
});
|
||
}
|
||
|
||
fn pop_link(&mut self) {
|
||
if let Some(link) = self.link.take() {
|
||
if link.show_destination {
|
||
self.push_span(" (".into());
|
||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||
self.push_span(")".into());
|
||
} else if let Some(local_display_text) = link.local_display_text {
|
||
self.push_span(Span::styled(local_display_text, self.styles.code));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn flush_current_line(&mut self) {
|
||
if let Some(line) = self.current_line_content.take() {
|
||
let style = self.current_line_style;
|
||
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
|
||
if !self.current_line_in_code_block
|
||
&& let Some(width) = self.wrap_width
|
||
{
|
||
let opts = RtOptions::new(width)
|
||
.initial_indent(self.current_initial_indent.clone().into())
|
||
.subsequent_indent(self.current_subsequent_indent.clone().into());
|
||
for wrapped in adaptive_wrap_line(&line, opts) {
|
||
let owned = line_to_static(&wrapped).style(style);
|
||
self.text.lines.push(owned);
|
||
}
|
||
} else {
|
||
let mut spans = self.current_initial_indent.clone();
|
||
let mut line = line;
|
||
spans.append(&mut line.spans);
|
||
self.text.lines.push(Line::from_iter(spans).style(style));
|
||
}
|
||
self.current_initial_indent.clear();
|
||
self.current_subsequent_indent.clear();
|
||
self.current_line_in_code_block = false;
|
||
}
|
||
}
|
||
|
||
fn push_line(&mut self, line: Line<'static>) {
|
||
self.flush_current_line();
|
||
let blockquote_active = self
|
||
.indent_stack
|
||
.iter()
|
||
.any(|ctx| ctx.prefix.iter().any(|s| s.content.contains('>')));
|
||
let style = if blockquote_active {
|
||
self.styles.blockquote
|
||
} else {
|
||
line.style
|
||
};
|
||
let was_pending = self.pending_marker_line;
|
||
|
||
self.current_initial_indent = self.prefix_spans(was_pending);
|
||
self.current_subsequent_indent = self.prefix_spans(false);
|
||
self.current_line_style = style;
|
||
self.current_line_content = Some(line);
|
||
self.current_line_in_code_block = self.in_code_block;
|
||
|
||
self.pending_marker_line = false;
|
||
}
|
||
|
||
fn push_span(&mut self, span: Span<'static>) {
|
||
if let Some(line) = self.current_line_content.as_mut() {
|
||
line.push_span(span);
|
||
} else {
|
||
self.push_line(Line::from(vec![span]));
|
||
}
|
||
}
|
||
|
||
fn push_blank_line(&mut self) {
|
||
self.flush_current_line();
|
||
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
|
||
self.text.lines.push(Line::default());
|
||
} else {
|
||
self.push_line(Line::default());
|
||
self.flush_current_line();
|
||
}
|
||
}
|
||
|
||
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
|
||
let mut prefix: Vec<Span<'static>> = Vec::new();
|
||
let last_marker_index = if pending_marker_line {
|
||
self.indent_stack
|
||
.iter()
|
||
.enumerate()
|
||
.rev()
|
||
.find_map(|(i, ctx)| if ctx.marker.is_some() { Some(i) } else { None })
|
||
} else {
|
||
None
|
||
};
|
||
let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list);
|
||
|
||
for (i, ctx) in self.indent_stack.iter().enumerate() {
|
||
if pending_marker_line {
|
||
if Some(i) == last_marker_index
|
||
&& let Some(marker) = &ctx.marker
|
||
{
|
||
prefix.extend(marker.iter().cloned());
|
||
continue;
|
||
}
|
||
if ctx.is_list && last_marker_index.is_some_and(|idx| idx > i) {
|
||
continue;
|
||
}
|
||
} else if ctx.is_list && Some(i) != last_list_index {
|
||
continue;
|
||
}
|
||
prefix.extend(ctx.prefix.iter().cloned());
|
||
}
|
||
|
||
prefix
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod markdown_render_tests {
|
||
include!("markdown_render_tests.rs");
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pretty_assertions::assert_eq;
|
||
use ratatui::text::Text;
|
||
|
||
fn lines_to_strings(text: &Text<'_>) -> Vec<String> {
|
||
text.lines
|
||
.iter()
|
||
.map(|l| {
|
||
l.spans
|
||
.iter()
|
||
.map(|s| s.content.clone())
|
||
.collect::<String>()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_plain_text_when_width_provided() {
|
||
let markdown = "This is a simple sentence that should wrap.";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(16));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"This is a simple".to_string(),
|
||
"sentence that".to_string(),
|
||
"should wrap.".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_list_items_preserving_indent() {
|
||
let markdown = "- first second third fourth";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(14));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec!["- first second".to_string(), " third fourth".to_string(),]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_nested_lists() {
|
||
let markdown =
|
||
"- outer item with several words to wrap\n - inner item that also needs wrapping";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(20));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"- outer item with".to_string(),
|
||
" several words to".to_string(),
|
||
" wrap".to_string(),
|
||
" - inner item".to_string(),
|
||
" that also".to_string(),
|
||
" needs wrapping".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_ordered_lists() {
|
||
let markdown = "1. ordered item contains many words for wrapping";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(18));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"1. ordered item".to_string(),
|
||
" contains many".to_string(),
|
||
" words for".to_string(),
|
||
" wrapping".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_blockquotes() {
|
||
let markdown = "> block quote with content that should wrap nicely";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(22));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"> block quote with".to_string(),
|
||
"> content that should".to_string(),
|
||
"> wrap nicely".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_blockquotes_inside_lists() {
|
||
let markdown = "- list item\n > block quote inside list that wraps";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(24));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"- list item".to_string(),
|
||
" > block quote inside".to_string(),
|
||
" > list that wraps".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_list_items_containing_blockquotes() {
|
||
let markdown = "1. item with quote\n > quoted text that should wrap";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(24));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec![
|
||
"1. item with quote".to_string(),
|
||
" > quoted text that".to_string(),
|
||
" > should wrap".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_wrap_code_blocks() {
|
||
let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````";
|
||
let rendered = render_markdown_text_with_width(markdown, Some(10));
|
||
let lines = lines_to_strings(&rendered);
|
||
assert_eq!(
|
||
lines,
|
||
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_split_long_url_like_token_without_scheme() {
|
||
let url_like =
|
||
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
|
||
let rendered = render_markdown_text_with_width(url_like, Some(24));
|
||
let lines = lines_to_strings(&rendered);
|
||
|
||
assert_eq!(
|
||
lines.iter().filter(|line| line.contains(url_like)).count(),
|
||
1,
|
||
"expected full URL-like token in one rendered line, got: {lines:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fenced_code_info_string_with_metadata_highlights() {
|
||
// CommonMark info strings like "rust,no_run" or "rust title=demo"
|
||
// contain metadata after the language token. The language must be
|
||
// extracted (first word / comma-separated token) so highlighting works.
|
||
for info in &["rust,no_run", "rust no_run", "rust title=\"demo\""] {
|
||
let markdown = format!("```{info}\nfn main() {{}}\n```\n");
|
||
let rendered = render_markdown_text(&markdown);
|
||
let has_rgb = rendered.lines.iter().any(|line| {
|
||
line.spans
|
||
.iter()
|
||
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
|
||
});
|
||
assert!(
|
||
has_rgb,
|
||
"info string \"{info}\" should still produce syntax highlighting"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn crlf_code_block_no_extra_blank_lines() {
|
||
// pulldown-cmark can split CRLF code blocks into multiple Text events.
|
||
// The buffer must concatenate them verbatim — no inserted separators.
|
||
let markdown = "```rust\r\nfn main() {}\r\n line2\r\n```\r\n";
|
||
let rendered = render_markdown_text(markdown);
|
||
let lines = lines_to_strings(&rendered);
|
||
// Should be exactly two code lines; no spurious blank line between them.
|
||
assert_eq!(
|
||
lines,
|
||
vec!["fn main() {}".to_string(), " line2".to_string()],
|
||
"CRLF code block should not produce extra blank lines: {lines:?}"
|
||
);
|
||
}
|
||
}
|