1use std::fmt::Write as _;
4
5use comfy_table::presets::{NOTHING, UTF8_FULL_CONDENSED};
6use comfy_table::{Cell, ContentArrangement, Table as EngineTable};
7use unicode_bidi::format_chars::{ALM, FSI, LRE, LRI, LRM, LRO, PDF, PDI, RLE, RLI, RLM, RLO};
8use unicode_general_category::{GeneralCategory, get_general_category};
9use unicode_width::UnicodeWidthStr;
10
11use crate::color::ColorMode;
12use crate::document::{Block, Document, Fields, Notice, NoticeLevel, Role, Section, Table, Text};
13use crate::style::{ERROR, HEADING, MUTED, OPTION, SUCCESS, VALUE, WARNING, styled};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct RenderOptions {
18 color: ColorMode,
19 width: Option<u16>,
20}
21
22impl RenderOptions {
23 #[must_use]
25 pub const fn new(color: ColorMode) -> Self {
26 Self { color, width: None }
27 }
28
29 #[must_use]
31 pub const fn width(mut self, width: u16) -> Self {
32 self.width = Some(width);
33 self
34 }
35
36 #[must_use]
38 pub const fn color(self) -> ColorMode {
39 self.color
40 }
41
42 #[must_use]
44 pub const fn explicit_width(self) -> Option<u16> {
45 self.width
46 }
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct Renderer {
52 options: RenderOptions,
53}
54
55impl Renderer {
56 #[must_use]
58 pub const fn new(options: RenderOptions) -> Self {
59 Self { options }
60 }
61
62 #[must_use]
64 pub fn render(self, document: &Document) -> String {
65 let mut rendered = document
66 .blocks()
67 .iter()
68 .filter_map(|block| {
69 let rendered = self.render_block(block);
70 (!rendered.is_empty()).then_some(rendered)
71 })
72 .collect::<Vec<_>>()
73 .join("\n\n");
74 if !rendered.is_empty() {
75 rendered.push('\n');
76 }
77 rendered
78 }
79
80 fn render_block(self, block: &Block) -> String {
81 match block {
82 Block::Heading(text) => self.text_with_default(text, Role::Heading),
83 Block::Paragraph(text) => self.wrap(&self.text(text), 0),
84 Block::Verbatim(value) => sanitize_verbatim(value),
85 Block::Fields(fields) => self.fields(fields),
86 Block::Table(table) => self.table(table),
87 Block::Section(section) => self.section(section),
88 Block::Notice(notice) => self.notice(notice),
89 Block::Rule(rule) => {
90 let width = usize::from(self.width().unwrap_or(40));
91 rule.title().map_or_else(
92 || "─".repeat(width),
93 |title| {
94 let title_width = title
95 .spans()
96 .iter()
97 .map(|span| UnicodeWidthStr::width(span.value()))
98 .sum::<usize>();
99 let title = self.text_with_default(title, Role::Heading);
100 format!(
101 "── {title} {}",
102 "─".repeat(width.saturating_sub(title_width + 4))
103 )
104 },
105 )
106 }
107 }
108 }
109
110 fn section(self, section: &Section) -> String {
111 let heading = self.text_with_default(section.title(), Role::Heading);
112 let body = self.render(section.body());
113 if body.is_empty() {
114 heading
115 } else {
116 format!("{heading}\n{}", body.trim_end())
117 }
118 }
119
120 fn fields(self, fields: &Fields) -> String {
121 let mut table = self.engine_table();
122 for (label, value) in fields.rows() {
123 table.add_row([self.text(label), self.text(value)]);
124 }
125 format!("{table}")
126 }
127
128 fn table(self, table: &Table) -> String {
129 if self.should_stack(table) {
130 return self.stacked(table);
131 }
132 let mut engine = self.engine_table();
133 if !table.headers().is_empty() {
134 engine.set_header(
135 table
136 .headers()
137 .iter()
138 .map(|header| Cell::new(self.text_with_default(header, Role::Heading))),
139 );
140 }
141 for row in table.rows() {
142 engine.add_row(row.iter().enumerate().map(|(index, cell)| {
143 let value = if table.token_column_index() == Some(index) {
144 self.text_with_default(cell, Role::Token)
145 } else {
146 self.text(cell)
147 };
148 Cell::new(value)
149 }));
150 }
151 format!("{engine}")
152 }
153
154 fn should_stack(self, table: &Table) -> bool {
155 let Some(stacked) = table.stacked() else {
156 return false;
157 };
158 self.width().is_some_and(|width| width < stacked.width())
159 }
160
161 fn stacked(self, table: &Table) -> String {
162 let Some(policy) = table.stacked() else {
163 return String::new();
164 };
165 let mut output = String::new();
166 for row in table.rows() {
167 let labels = row
168 .iter()
169 .take(policy.label_columns())
170 .filter(|value| !value.is_empty())
171 .map(|value| self.text_with_default(value, Role::Token))
172 .collect::<Vec<_>>()
173 .join(" ");
174 let description = row
175 .iter()
176 .skip(policy.label_columns())
177 .filter(|value| !value.is_empty())
178 .map(|value| self.text(value))
179 .collect::<Vec<_>>()
180 .join(" ");
181 for line in self.wrap(&labels, 2).lines() {
182 let _ = writeln!(output, " {line}");
183 }
184 if !description.is_empty() {
185 let wrapped = self.wrap(&description, 4);
186 for line in wrapped.lines() {
187 let _ = writeln!(output, " {line}");
188 }
189 }
190 }
191 output.trim_end().to_owned()
192 }
193
194 fn notice(self, notice: &Notice) -> String {
195 let (label, role) = match notice.level() {
196 NoticeLevel::Success => ("success", Role::Success),
197 NoticeLevel::Warning => ("warning", Role::Warning),
198 NoticeLevel::Error => ("error", Role::Error),
199 };
200 let mut line = self.paint(role, label);
201 if let Some(code) = notice.code_value() {
202 let _ = write!(line, " · {}", self.paint(Role::Muted, code));
203 }
204 let _ = write!(line, " · {}", self.text(notice.message()));
205 self.wrap(&line, 0)
206 }
207
208 fn engine_table(self) -> EngineTable {
209 let mut table = EngineTable::new();
210 table
211 .load_style(UTF8_FULL_CONDENSED)
212 .set_content_arrangement(ContentArrangement::Dynamic);
213 if let Some(width) = self.width() {
214 table.set_width(width);
215 }
216 table
217 }
218
219 fn wrap(self, value: &str, indentation: u16) -> String {
220 let Some(width) = self.width() else {
221 return value.to_owned();
222 };
223 let mut table = EngineTable::new();
224 table
225 .load_style(NOTHING)
226 .set_content_arrangement(ContentArrangement::Dynamic)
227 .set_width(width.saturating_sub(indentation))
228 .add_row([value]);
229 if let Some(column) = table.column_mut(0) {
230 column.set_padding((0, 0));
231 }
232 table
233 .to_string()
234 .lines()
235 .map(str::trim_end)
236 .collect::<Vec<_>>()
237 .join("\n")
238 }
239
240 fn width(self) -> Option<u16> {
241 self.options
242 .explicit_width()
243 .or_else(crate::layout::terminal_width)
244 }
245
246 fn text(self, text: &Text) -> String {
247 text.spans()
248 .iter()
249 .map(|span| self.paint(span.role(), span.value()))
250 .collect()
251 }
252
253 fn text_with_default(self, text: &Text, default: Role) -> String {
254 text.spans()
255 .iter()
256 .map(|span| {
257 let role = if span.role() == Role::Plain {
258 default
259 } else {
260 span.role()
261 };
262 self.paint(role, span.value())
263 })
264 .collect()
265 }
266
267 fn paint(self, role: Role, value: &str) -> String {
268 if self.options.color() == ColorMode::Never || role == Role::Plain {
269 return value.to_owned();
270 }
271 let style = match role {
272 Role::Plain => return value.to_owned(),
273 Role::Heading => HEADING,
274 Role::Success => SUCCESS,
275 Role::Warning => WARNING,
276 Role::Error => ERROR,
277 Role::Value => VALUE,
278 Role::Muted => MUTED,
279 Role::Token => OPTION,
280 };
281 styled(style, value)
282 }
283}
284
285impl Document {
286 #[must_use]
288 pub fn render(&self, options: RenderOptions) -> String {
289 Renderer::new(options).render(self)
290 }
291}
292
293fn sanitize_verbatim(value: &str) -> String {
294 value
295 .chars()
296 .filter(|character| {
297 *character == '\n' || *character == '\t' || !is_unsafe_verbatim(*character)
298 })
299 .collect::<String>()
300 .trim_end_matches('\n')
301 .to_owned()
302}
303
304fn is_unsafe_verbatim(character: char) -> bool {
305 character.is_control()
306 || matches!(
307 character,
308 ALM | FSI | LRE | LRI | LRM | LRO | PDF | PDI | RLE | RLI | RLM | RLO
309 )
310 || matches!(
311 get_general_category(character),
312 GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator
313 )
314}
315
316#[cfg(test)]
317mod tests {
318 use indoc::{formatdoc, indoc};
319
320 use super::RenderOptions;
321 use crate::color::ColorMode;
322 use crate::document::{Document, Fields, Notice, NoticeLevel, Table, Text};
323
324 #[test]
325 fn colorless_document_is_deterministic() {
326 let document = Document::new()
327 .heading("status")
328 .fields(Fields::new().row("pending", Text::plain("2")))
329 .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
330 let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
331 let expected = indoc! {"
332 status
333
334 ┌─────────┬───┐
335 │ pending ┆ 2 │
336 └─────────┴───┘
337
338 warning · one stale row
339 "};
340 assert_eq!(rendered, expected);
341 assert!(!rendered.contains('\u{1b}'));
342 }
343
344 #[test]
345 fn colored_document_has_ansi() {
346 let document = Document::new().heading("status");
347 let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
348 assert!(rendered.contains('\u{1b}'));
349 }
350
351 #[test]
352 fn narrow_table_stacks() {
353 let table =
354 Table::plain()
355 .stacked_below(64, 2)
356 .row(["-f", "--format", "Output representation"]);
357 let rendered = Document::new()
358 .table(table)
359 .render(RenderOptions::new(ColorMode::Never).width(40));
360 assert_eq!(rendered, " -f --format\n Output representation\n");
361 }
362
363 #[test]
364 fn verbatim_text_ignores_explicit_width() {
365 let source = indoc! {"
366 ```text
367 this line stays longer than five
368 ```
369 "};
370 let rendered = Document::new()
371 .verbatim(source)
372 .render(RenderOptions::new(ColorMode::Never).width(5));
373 assert_eq!(rendered, source);
374 }
375
376 #[test]
377 fn verbatim_text_removes_terminal_bidi_and_line_controls() {
378 let rendered = Document::new()
379 .verbatim("\u{1b}]52;clipboard\u{7}\u{202e}\u{2028}\u{2029}\tvalue")
380 .render(RenderOptions::new(ColorMode::Never).width(5));
381 assert_eq!(rendered, "]52;clipboard\tvalue\n");
382 }
383
384 #[test]
385 fn verbatim_text_preserves_other_unicode_formatting() {
386 let source = "\u{600}\u{6dd}\u{70f}\u{110bd}\u{200b}\u{2060}\u{feff}\u{fff9}\u{1d173}\u{e0001}\u{200c}\u{200d}\u{ad}";
387 let rendered = Document::new()
388 .verbatim(source)
389 .render(RenderOptions::new(ColorMode::Never).width(5));
390 assert_eq!(rendered, format!("{source}\n"));
391 }
392
393 #[test]
394 fn wrapped_stacked_labels_keep_indentation() {
395 let table = Table::plain()
396 .stacked_below(64, 1)
397 .row(["one two three four five", "description"]);
398 let rendered = Document::new()
399 .table(table)
400 .render(RenderOptions::new(ColorMode::Never).width(14));
401 let expected = formatdoc! {"
402 {label}one two
403 {label}three four
404 {label}five
405 {description}descriptio
406 {description}n
407 ",
408 label = " ",
409 description = " ",
410 };
411 assert_eq!(rendered, expected);
412 }
413}