use ratatui::{
style::{Color, Modifier, Style, Stylize},
text::Span,
};
use unicode_width::UnicodeWidthStr;
use crate::{
config::Symbols,
note_editor::{
ast::{self, SourceRange},
rich_text::RichText,
text_wrap::wrap_preserve_trailing,
virtual_document::{
content_span, empty_virtual_line, synthetic_span, virtual_line, VirtualBlock,
VirtualLine, VirtualSpan,
},
},
stylized_text::stylize,
};
trait SpanExt {
fn merge(self, other: Span) -> Span;
}
impl SpanExt for &Span<'_> {
fn merge(self, other: Span) -> Span {
Span::styled(
format!("{}{}", self.content, other.content),
self.style.patch(other.style),
)
}
}
const CODE_BG: Color = Color::Black;
#[derive(Clone, PartialEq, Debug)]
pub enum RenderStyle {
Raw,
Visual,
Reader,
}
impl RenderStyle {
pub fn is_styled(&self) -> bool {
matches!(self, Self::Visual | Self::Reader)
}
}
#[allow(clippy::too_many_arguments)]
fn text_wrap_internal<'a>(
text_content: &str,
text_style: Style,
prefix: Span<'static>,
source_range: &SourceRange<usize>,
width: usize,
marker: Option<Span<'static>>,
option: &RenderStyle,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let wrap_marker = &symbols.wrap_marker;
let wrapped_lines = wrap_preserve_trailing(text_content, width, wrap_marker.width() + 1);
let mut current_range_start = source_range.start;
wrapped_lines
.iter()
.enumerate()
.map(|(i, line)| {
let line_byte_len = line.len();
let line_source_range =
current_range_start..(current_range_start + line_byte_len).min(source_range.end);
current_range_start += line_byte_len;
let first_line = i == 0;
let content_span = Span::styled(line.to_string(), text_style);
match (&marker, first_line) {
(Some(marker), true) if option.is_styled() => virtual_line!([
synthetic_span!(prefix),
synthetic_span!(marker),
content_span!(content_span, line_source_range)
]),
(_, true) => virtual_line!([
synthetic_span!(prefix),
content_span!(content_span, line_source_range)
]),
_ => {
let marker_padding = marker.as_ref().map(|m| m.width()).unwrap_or(0);
virtual_line!([
synthetic_span!(prefix),
synthetic_span!(Span::styled(" ".repeat(marker_padding), prefix.style)),
synthetic_span!(Span::styled(wrap_marker.clone(), Style::new().black())),
content_span!(content_span, line_source_range)
])
}
}
})
.collect()
}
fn render_raw_line<'a>(
line: &str,
prefix: Span<'static>,
source_range: &SourceRange<usize>,
max_width: usize,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
text_wrap_internal(
line,
Style::default(),
prefix,
source_range,
max_width,
None,
&RenderStyle::Raw,
symbols,
)
}
pub fn edit_lines<'a>(
content: &str,
base: usize,
cursor_offset: usize,
max_width: usize,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let mut lines = Vec::new();
let mut start = base;
let mut in_code = false;
for line in content.split_inclusive('\n') {
let line_range = start..start + line.len();
start = line_range.end;
let text = line.strip_suffix('\n').unwrap_or(line);
let fence = is_code_fence(text);
if in_code || fence {
lines.push(code_line(text, &line_range, max_width));
in_code ^= fence;
} else {
lines.extend(edit_line(
text,
&line_range,
line_range.contains(&cursor_offset),
max_width,
symbols,
));
}
}
lines
}
fn edit_line<'a>(
text: &str,
line_range: &SourceRange<usize>,
is_cursor: bool,
max_width: usize,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
if text.is_empty() {
return vec![virtual_line!([content_span!(
"".to_string(),
line_range.clone()
)])];
}
let indent_len = text.len() - text.trim_start().len();
let rest = &text[indent_len..];
if let Some(level) = heading_level(rest) {
return heading_lines(text, line_range, indent_len, level, max_width, symbols);
}
if is_cursor {
if let Some((prefix_len, _)) = quote_prefix(rest) {
return vec![raw_quote_line(text, line_range, indent_len, prefix_len)];
}
return render_raw_line(text, Span::default(), line_range, max_width, symbols);
}
decorate_line(text, line_range, max_width, symbols)
}
fn is_code_fence(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
fn code_line<'a>(text: &str, line_range: &SourceRange<usize>, max_width: usize) -> VirtualLine<'a> {
let code_bg = Style::new().bg(CODE_BG);
let pad = max_width.saturating_sub(text.chars().count() + 1);
virtual_line!([
synthetic_span!(Span::styled(" ", code_bg)),
content_span!(Span::raw(text.to_string()).bg(CODE_BG), line_range.clone()),
synthetic_span!(Span::styled(" ".repeat(pad), code_bg))
])
}
fn heading_lines<'a>(
text: &str,
line_range: &SourceRange<usize>,
indent_len: usize,
level: usize,
max_width: usize,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let marker_end = (indent_len + level + 1).min(text.len());
let title = &text[marker_end..];
let start = line_range.start;
let mut lines = vec![virtual_line!([
content_span!(text[..indent_len].to_string(), start..start + indent_len),
content_span!(
text[indent_len..marker_end].to_string().dark_gray(),
(start + indent_len)..(start + marker_end)
),
content_span!(
heading_span(title, level),
(start + marker_end)..line_range.end
)
])];
match level {
1 => lines.push(virtual_line!([synthetic_span!(Span::raw(
symbols.h1_underline.repeat(max_width)
))])),
2 => lines.push(virtual_line!([synthetic_span!(symbols
.h2_underline
.repeat(max_width)
.yellow())])),
_ => {}
}
lines
}
fn raw_quote_line<'a>(
text: &str,
line_range: &SourceRange<usize>,
indent_len: usize,
prefix_len: usize,
) -> VirtualLine<'a> {
let start = line_range.start;
let marker_end = indent_len + prefix_len;
virtual_line!([
content_span!(text[..indent_len].to_string(), start..start + indent_len),
content_span!(
text[indent_len..marker_end].to_string().magenta(),
(start + indent_len)..(start + marker_end)
),
content_span!(
text[marker_end..].to_string(),
(start + marker_end)..line_range.end
)
])
}
fn decorate_line<'a>(
text: &str,
line_range: &SourceRange<usize>,
max_width: usize,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let indent_len = text.len() - text.trim_start().len();
let rest = &text[indent_len..];
let indent = text[..indent_len].replace('\t', " ");
let depth = indent.chars().count() / 2;
let prefix = Span::raw(indent);
let render = |marker: Option<Span<'static>>, content_start: usize, content: Span<'a>| {
let content_range = (line_range.start + content_start)..line_range.end;
text_wrap(
&content,
prefix.clone(),
&content_range,
max_width,
marker,
&RenderStyle::Visual,
symbols,
)
};
if let Some((checked, marker_len)) = task_marker(rest) {
let content_start = indent_len + marker_len;
let (icon, content) = if checked {
(
format!("{} ", symbols.task_checked).magenta(),
Span::raw(text[content_start..].to_string())
.dark_gray()
.add_modifier(Modifier::CROSSED_OUT),
)
} else {
(
format!("{} ", symbols.task_unchecked).dark_gray(),
Span::raw(text[content_start..].to_string()),
)
};
return render(Some(icon), content_start, content);
}
if let Some(marker_len) = unordered_marker(rest) {
let bullet = if symbols.list_markers.is_empty() {
"-"
} else {
&symbols.list_markers[depth % symbols.list_markers.len()]
};
let content_start = indent_len + marker_len;
return render(
Some(format!("{bullet} ").dark_gray()),
content_start,
Span::raw(text[content_start..].to_string()),
);
}
if let Some(marker_len) = ordered_marker(rest) {
let content_start = indent_len + marker_len;
return render(
Some(rest[..marker_len].to_string().dark_gray()),
content_start,
Span::raw(text[content_start..].to_string()),
);
}
if let Some((prefix_len, levels)) = quote_prefix(rest) {
let content_start = indent_len + prefix_len;
return render(
Some(Span::raw("┃ ".repeat(levels)).magenta()),
content_start,
Span::raw(text[content_start..].to_string()),
);
}
render(None, indent_len, Span::raw(rest.to_string()))
}
fn heading_level(rest: &str) -> Option<usize> {
let hashes = rest.chars().take_while(|c| *c == '#').count();
((1..=6).contains(&hashes) && rest.as_bytes().get(hashes) == Some(&b' ')).then_some(hashes)
}
fn task_marker(rest: &str) -> Option<(bool, usize)> {
let bytes = rest.as_bytes();
let valid = matches!(bytes.first(), Some(b'-' | b'*' | b'+'))
&& bytes.get(1) == Some(&b' ')
&& bytes.get(2) == Some(&b'[')
&& bytes.get(4) == Some(&b']')
&& bytes.get(5) == Some(&b' ');
valid.then(|| (matches!(bytes.get(3), Some(b'x' | b'X')), 6))
}
fn unordered_marker(rest: &str) -> Option<usize> {
let bytes = rest.as_bytes();
(matches!(bytes.first(), Some(b'-' | b'*' | b'+')) && bytes.get(1) == Some(&b' ')).then_some(2)
}
fn ordered_marker(rest: &str) -> Option<usize> {
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
let bytes = rest.as_bytes();
(digits > 0
&& matches!(bytes.get(digits), Some(b'.' | b')'))
&& bytes.get(digits + 1) == Some(&b' '))
.then_some(digits + 2)
}
fn quote_prefix(rest: &str) -> Option<(usize, usize)> {
let bytes = rest.as_bytes();
let mut len = 0;
let mut levels = 0;
while bytes.get(len) == Some(&b'>') {
len += 1;
levels += 1;
if bytes.get(len) == Some(&b' ') {
len += 1;
}
}
(levels > 0).then_some((len, levels))
}
fn heading_span<'a>(text: &str, level: usize) -> Span<'a> {
let span = Span::raw(text.to_string()).bold();
match level {
2 => span.yellow(),
3 => span.cyan(),
4 => span.magenta(),
_ => span,
}
}
pub fn text_wrap<'a>(
text: &Span<'a>,
prefix: Span<'static>,
source_range: &SourceRange<usize>,
width: usize,
marker: Option<Span<'static>>,
option: &RenderStyle,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
text_wrap_internal(
&text.content,
text.style,
prefix,
source_range,
width,
marker,
option,
symbols,
)
}
#[allow(clippy::too_many_arguments)]
pub fn heading<'a>(
level: ast::HeadingLevel,
content: &str,
prefix: Span<'static>,
text: &RichText,
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
) -> VirtualBlock<'a> {
use ast::HeadingLevel::*;
let (text, heading_source_range, remaining) = match option {
RenderStyle::Visual | RenderStyle::Reader => (text.to_string(), source_range.clone(), None),
RenderStyle::Raw => {
let node_content = content.get(source_range.clone()).unwrap_or(content);
match node_content.split_once('\n') {
Some((first, rest)) => {
let end = (source_range.start + first.len()).min(source_range.end);
(
first.to_string(),
source_range.start..end,
Some(!rest.is_empty()),
)
}
None => (node_content.to_string(), source_range.clone(), None),
}
}
};
let prefix_width = prefix.width();
let h = |marker: Span<'static>, content: Span<'a>| {
let mut wrapped_heading = text_wrap(
&content,
prefix.clone(),
&heading_source_range,
max_width,
Some(marker),
option,
symbols,
);
if option.is_styled() {
wrapped_heading.push(empty_virtual_line!());
}
wrapped_heading
};
let h_with_underline = |content: Span<'a>, underline: Span<'static>| {
let mut wrapped_heading = text_wrap(
&content,
prefix.clone(),
&heading_source_range,
max_width,
None,
option,
symbols,
);
wrapped_heading.push(virtual_line!([synthetic_span!(underline)]));
wrapped_heading
};
let mut lines = match level {
H1 => h_with_underline(
if option.is_styled() {
text.to_uppercase().bold()
} else {
text.bold()
},
symbols
.h1_underline
.repeat(max_width.saturating_sub(prefix_width))
.into(),
),
H2 => h_with_underline(
text.bold().yellow(),
symbols
.h2_underline
.repeat(max_width.saturating_sub(prefix_width))
.yellow(),
),
H3 => h(format!("{} ", symbols.h3_marker).cyan(), text.bold().cyan()),
H4 => h(
format!("{} ", symbols.h4_marker).magenta(),
text.bold().magenta(),
),
H5 => h(
format!("{} ", symbols.h5_marker).into(),
match symbols.h5_font_style {
Some(style) => stylize(&text, style).into(),
None => text.into(),
},
),
H6 => h(
format!("{} ", symbols.h6_marker).into(),
match symbols.h6_font_style {
Some(style) => stylize(&text, style).into(),
None => text.into(),
},
),
};
if remaining == Some(true) {
let remaining_start = (heading_source_range.end + 1).min(source_range.end);
let remaining_range = remaining_start..source_range.end;
lines.extend(render_raw(
content,
&remaining_range,
max_width,
prefix.clone(),
symbols,
));
}
VirtualBlock::new(&lines, source_range)
}
pub fn render_raw<'a>(
content: &str,
source_range: &SourceRange<usize>,
max_width: usize,
prefix: Span<'static>,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let content = content.get(source_range.clone()).unwrap_or(content);
let mut current_range_start = source_range.start;
let mut lines = content
.lines()
.flat_map(|line| {
let line_range = line_range(current_range_start, line.len(), true);
current_range_start = line_range.end;
if line.is_empty() {
vec![virtual_line!([
synthetic_span!(prefix.clone()),
content_span!("".to_string(), line_range)
])]
} else {
render_raw_line(line, prefix.clone(), &line_range, max_width, symbols)
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
lines.push(virtual_line!([
synthetic_span!(prefix.clone()),
content_span!("".to_string(), source_range)
]));
}
lines.push(empty_virtual_line!());
lines
}
pub fn paragraph<'a>(
content: &str,
prefix: Span<'static>,
text: &RichText,
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual | RenderStyle::Reader => {
let text = text.to_string();
let mut current_range_start = source_range.start;
let mut lines = text
.to_string()
.lines()
.flat_map(|line| {
let line_range = line_range(current_range_start, line.len(), true);
current_range_start = line_range.end;
text_wrap(
&line.to_string().into(),
prefix.clone(),
&line_range,
max_width,
None,
option,
symbols,
)
})
.collect::<Vec<_>>();
if prefix.to_string().is_empty() {
lines.extend([empty_virtual_line!()]);
}
lines
}
};
VirtualBlock::new(&lines, source_range)
}
pub fn code_block<'a>(
content: &str,
prefix: Span<'static>,
_lang: &Option<String>,
text: &RichText,
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => {
let content = content.get(source_range.clone()).unwrap_or(content);
let mut current_range_start = source_range.start;
let mut lines = content
.lines()
.map(|line| {
let line_range = line_range(current_range_start, line.len(), true);
current_range_start = line_range.end;
virtual_line!([
synthetic_span!(prefix.clone()),
synthetic_span!(Span::styled(" ", Style::new().bg(CODE_BG))),
content_span!(line.to_string().bg(CODE_BG), line_range),
synthetic_span!(" "
.repeat(
max_width
.saturating_sub(prefix.width() + line.chars().count())
.saturating_sub(1)
)
.bg(CODE_BG)),
])
})
.collect::<Vec<_>>();
lines.push(empty_virtual_line!());
lines
}
RenderStyle::Visual | RenderStyle::Reader => {
let text = text.to_string();
let padding_line = virtual_line!([
synthetic_span!(prefix.clone()),
synthetic_span!(" "
.repeat(max_width.saturating_sub(prefix.width()))
.bg(CODE_BG))
]);
let mut current_range_start = source_range.start;
let mut lines = vec![padding_line.clone()];
lines.extend(text.lines().map(|line| {
let source_range = line_range(current_range_start, line.len(), true);
current_range_start = source_range.end;
virtual_line!([
synthetic_span!(prefix.clone()),
synthetic_span!(Span::styled(" ", Style::new().bg(CODE_BG))),
content_span!(line.to_string().bg(CODE_BG), source_range),
synthetic_span!(" "
.repeat(
max_width
.saturating_sub(prefix.width() + line.chars().count())
.saturating_sub(1)
)
.bg(CODE_BG)),
])
}));
lines.extend([padding_line]);
lines.extend([empty_virtual_line!()]);
lines
}
};
VirtualBlock::new(&lines, source_range)
}
#[allow(clippy::too_many_arguments)]
pub fn list<'a>(
content: &str,
prefix: Span<'static>,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual | RenderStyle::Reader => {
let preserve_empty_lines = matches!(option, RenderStyle::Visual);
let mut lines: Vec<VirtualLine<'a>> = nodes
.iter()
.enumerate()
.flat_map(|(i, node)| {
let mut lines = Vec::new();
if preserve_empty_lines && i > 0 {
let prev_slice = content
.get(nodes[i - 1].source_range().clone())
.unwrap_or("");
let empties = trailing_empty_lines(prev_slice);
lines.extend(
(0..empties).map(|_| virtual_line!([synthetic_span!(prefix.clone())])),
);
}
lines.extend(
render_node(
content.to_string(),
node,
max_width,
prefix.clone(),
option,
symbols,
list_depth,
)
.lines,
);
lines
})
.collect();
if prefix.to_string().is_empty() {
lines.extend([empty_virtual_line!()]);
}
lines
}
};
VirtualBlock::new(&lines, source_range)
}
pub(crate) fn trailing_empty_lines(slice: &str) -> usize {
slice
.lines()
.rev()
.take_while(|line| line.trim().is_empty())
.count()
}
#[allow(clippy::too_many_arguments)]
pub fn task<'a>(
content: &str,
prefix: Span<'static>,
kind: &ast::TaskKind,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual | RenderStyle::Reader => {
let Some((text, rest)) = nodes.split_first().and_then(|(first, rest)| {
let text = first.rich_text()?;
Some((text, rest))
}) else {
return VirtualBlock::new(&[], source_range);
};
let text = text.to_string();
let (marker, text) = match kind {
ast::TaskKind::Unchecked => (
format!("{} ", symbols.task_unchecked).dark_gray(),
text.into(),
),
ast::TaskKind::LooselyChecked => (
format!("{} ", symbols.task_checked).magenta(),
text.dark_gray(),
),
ast::TaskKind::Checked => (
format!("{} ", symbols.task_checked).magenta(),
text.dark_gray().add_modifier(Modifier::CROSSED_OUT),
),
};
let mut lines = text_wrap(
&text,
prefix.clone(),
source_range,
max_width,
Some(marker),
option,
symbols,
);
lines.extend(rest.iter().flat_map(|node| {
render_node(
content.to_string(),
node,
max_width,
prefix.merge(" ".into()),
option,
symbols,
list_depth + 1,
)
.lines
}));
lines
}
};
VirtualBlock::new(&lines, source_range)
}
#[allow(clippy::too_many_arguments)]
pub fn item<'a>(
content: &str,
prefix: Span<'static>,
kind: &ast::ItemKind,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual | RenderStyle::Reader => {
let Some((text, rest)) = nodes.split_first().and_then(|(first, rest)| {
let text = first.rich_text()?;
Some((text, rest))
}) else {
return VirtualBlock::new(&[], source_range);
};
let text = text.to_string();
let marker = match kind {
ast::ItemKind::Ordered(i) => format!("{i}. ").dark_gray(),
ast::ItemKind::Unordered => {
let marker = if symbols.list_markers.is_empty() {
"-"
} else {
&symbols.list_markers[list_depth % symbols.list_markers.len()]
};
format!("{marker} ").dark_gray()
}
};
let mut lines = text_wrap(
&text.into(),
prefix.clone(),
source_range,
max_width,
Some(marker),
option,
symbols,
);
lines.extend(rest.iter().flat_map(|node| {
render_node(
content.to_string(),
node,
max_width,
prefix.merge(" ".into()),
option,
symbols,
list_depth + 1,
)
.lines
}));
lines
}
};
VirtualBlock::new(&lines, source_range)
}
pub fn line_range(start: usize, line_width: usize, newline: bool) -> SourceRange<usize> {
let end = start + line_width + if newline { 1 } else { 0 };
start..end
}
#[allow(clippy::too_many_arguments)]
pub fn block_quote<'a>(
content: &str,
prefix: Span<'static>,
_kind: &Option<ast::BlockQuoteKind>,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual | RenderStyle::Reader => nodes
.iter()
.enumerate()
.flat_map(|(i, node)| {
let mut lines = render_node(
content.to_string(),
node,
max_width,
prefix.merge(Span::raw("┃ ").magenta()),
option,
symbols,
0,
)
.lines;
if prefix.to_string().is_empty() && i != nodes.len().saturating_sub(1) {
lines.extend([virtual_line!([synthetic_span!(Span::raw("┃ ").magenta())])]);
}
if prefix.to_string().is_empty() && i == nodes.len().saturating_sub(1) {
lines.extend([empty_virtual_line!()]);
}
lines
})
.collect::<Vec<_>>(),
};
VirtualBlock::new(&lines, source_range)
}
#[allow(clippy::too_many_arguments)]
pub fn render_node<'a>(
content: String,
node: &ast::Node,
max_width: usize,
prefix: Span<'static>,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
use ast::Node::*;
match node {
Heading {
level,
text,
source_range,
} => heading(
*level,
&content,
prefix,
text,
source_range,
max_width,
option,
symbols,
),
Paragraph { text, source_range } => paragraph(
&content,
prefix,
text,
source_range,
max_width,
option,
symbols,
),
CodeBlock {
lang,
text,
source_range,
} => code_block(
&content,
prefix,
lang,
text,
source_range,
max_width,
option,
),
List {
nodes,
source_range,
} => list(
&content,
prefix,
nodes,
source_range,
max_width,
option,
symbols,
list_depth,
),
Item {
kind,
nodes,
source_range,
} => item(
&content,
prefix,
kind,
nodes,
source_range,
max_width,
option,
symbols,
list_depth,
),
Task {
kind,
nodes,
source_range,
} => task(
&content,
prefix,
kind,
nodes,
source_range,
max_width,
option,
symbols,
list_depth,
),
BlockQuote {
kind,
nodes,
source_range,
} => block_quote(
&content,
prefix,
kind,
nodes,
source_range,
max_width,
option,
symbols,
),
}
}