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
15pub const DEFAULT_COLUMN_BUFFER: u16 = 1;
17pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
19pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
21pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = 20;
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct RenderOptions {
27 color: ColorMode,
28 width: Option<u16>,
29 automatic_width_buffer: Option<u16>,
30 automatic_width_buffer_envs: &'static [&'static str],
31 minimum_automatic_width: u16,
32}
33
34impl RenderOptions {
35 #[must_use]
38 pub const fn new(color: ColorMode) -> Self {
39 Self {
40 color,
41 width: None,
42 automatic_width_buffer: None,
43 automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
44 minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
45 }
46 }
47
48 #[must_use]
50 pub const fn width(mut self, width: u16) -> Self {
51 self.width = Some(width);
52 self
53 }
54
55 #[must_use]
58 pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
59 self.automatic_width_buffer = Some(columns);
60 self
61 }
62
63 #[must_use]
66 pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
67 self.automatic_width_buffer_envs = names;
68 self
69 }
70
71 #[must_use]
73 pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
74 self.minimum_automatic_width = columns;
75 self
76 }
77
78 #[must_use]
80 pub const fn color(self) -> ColorMode {
81 self.color
82 }
83
84 #[must_use]
86 pub const fn explicit_width(self) -> Option<u16> {
87 self.width
88 }
89
90 #[must_use]
92 pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
93 self.automatic_width_buffer
94 }
95
96 #[must_use]
98 pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
99 self.automatic_width_buffer_envs
100 }
101
102 #[must_use]
104 pub const fn automatic_width_minimum(self) -> u16 {
105 self.minimum_automatic_width
106 }
107
108 fn resolved_automatic_width_buffer(self) -> u16 {
109 self.automatic_width_buffer
110 .or_else(|| crate::layout::column_buffer(self.automatic_width_buffer_envs))
111 .unwrap_or(DEFAULT_COLUMN_BUFFER)
112 }
113}
114
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub struct Renderer {
118 options: RenderOptions,
119}
120
121impl Renderer {
122 #[must_use]
124 pub const fn new(options: RenderOptions) -> Self {
125 Self { options }
126 }
127
128 #[must_use]
130 pub fn render(self, document: &Document) -> String {
131 let mut rendered = document
132 .blocks()
133 .iter()
134 .filter_map(|block| {
135 let rendered = self.render_block(block);
136 (!rendered.is_empty()).then_some(rendered)
137 })
138 .collect::<Vec<_>>()
139 .join("\n\n");
140 if !rendered.is_empty() {
141 rendered.push('\n');
142 }
143 rendered
144 }
145
146 fn render_block(self, block: &Block) -> String {
147 match block {
148 Block::Heading(text) => self.text_with_default(text, Role::Heading),
149 Block::Paragraph(text) => self.wrap(&self.text(text), 0),
150 Block::Verbatim(value) => sanitize_verbatim(value),
151 Block::Fields(fields) => self.fields(fields),
152 Block::Table(table) => self.table(table),
153 Block::Section(section) => self.section(section),
154 Block::Notice(notice) => self.notice(notice),
155 Block::Rule(rule) => {
156 let width = usize::from(self.width().unwrap_or(40));
157 rule.title().map_or_else(
158 || "─".repeat(width),
159 |title| {
160 let title_width = title
161 .spans()
162 .iter()
163 .map(|span| UnicodeWidthStr::width(span.value()))
164 .sum::<usize>();
165 let title = self.text_with_default(title, Role::Heading);
166 format!(
167 "── {title} {}",
168 "─".repeat(width.saturating_sub(title_width + 4))
169 )
170 },
171 )
172 }
173 }
174 }
175
176 fn section(self, section: &Section) -> String {
177 let heading = self.text_with_default(section.title(), Role::Heading);
178 let body = self.render(section.body());
179 if body.is_empty() {
180 heading
181 } else {
182 format!("{heading}\n{}", body.trim_end())
183 }
184 }
185
186 fn fields(self, fields: &Fields) -> String {
187 let mut table = self.engine_table();
188 for (label, value) in fields.rows() {
189 table.add_row([self.text(label), self.text(value)]);
190 }
191 format!("{table}")
192 }
193
194 fn table(self, table: &Table) -> String {
195 if self.should_stack(table) {
196 return self.stacked(table);
197 }
198 let mut engine = self.engine_table();
199 if !table.headers().is_empty() {
200 engine.set_header(
201 table
202 .headers()
203 .iter()
204 .map(|header| Cell::new(self.text_with_default(header, Role::Heading))),
205 );
206 }
207 for row in table.rows() {
208 engine.add_row(row.iter().enumerate().map(|(index, cell)| {
209 let value = if table.token_column_index() == Some(index) {
210 self.text_with_default(cell, Role::Token)
211 } else {
212 self.text(cell)
213 };
214 Cell::new(value)
215 }));
216 }
217 format!("{engine}")
218 }
219
220 fn should_stack(self, table: &Table) -> bool {
221 let Some(stacked) = table.stacked() else {
222 return false;
223 };
224 self.width().is_some_and(|width| width < stacked.width())
225 }
226
227 fn stacked(self, table: &Table) -> String {
228 let Some(policy) = table.stacked() else {
229 return String::new();
230 };
231 let mut output = String::new();
232 for row in table.rows() {
233 let labels = row
234 .iter()
235 .take(policy.label_columns())
236 .filter(|value| !value.is_empty())
237 .map(|value| self.text_with_default(value, Role::Token))
238 .collect::<Vec<_>>()
239 .join(" ");
240 let description = row
241 .iter()
242 .skip(policy.label_columns())
243 .filter(|value| !value.is_empty())
244 .map(|value| self.text(value))
245 .collect::<Vec<_>>()
246 .join(" ");
247 for line in self.wrap(&labels, 2).lines() {
248 let _ = writeln!(output, " {line}");
249 }
250 if !description.is_empty() {
251 let wrapped = self.wrap(&description, 4);
252 for line in wrapped.lines() {
253 let _ = writeln!(output, " {line}");
254 }
255 }
256 }
257 output.trim_end().to_owned()
258 }
259
260 fn notice(self, notice: &Notice) -> String {
261 let (label, role) = match notice.level() {
262 NoticeLevel::Success => ("success", Role::Success),
263 NoticeLevel::Warning => ("warning", Role::Warning),
264 NoticeLevel::Error => ("error", Role::Error),
265 };
266 let mut line = self.paint(role, label);
267 if let Some(code) = notice.code_value() {
268 let _ = write!(line, " · {}", self.paint(Role::Muted, code));
269 }
270 let _ = write!(line, " · {}", self.text(notice.message()));
271 self.wrap(&line, 0)
272 }
273
274 fn engine_table(self) -> EngineTable {
275 let mut table = EngineTable::new();
276 table
277 .load_style(UTF8_FULL_CONDENSED)
278 .set_content_arrangement(ContentArrangement::Dynamic);
279 if let Some(width) = self.width() {
280 table.set_width(width);
281 }
282 table
283 }
284
285 fn wrap(self, value: &str, indentation: u16) -> String {
286 let Some(width) = self.width() else {
287 return value.to_owned();
288 };
289 let mut table = EngineTable::new();
290 table
291 .load_style(NOTHING)
292 .set_content_arrangement(ContentArrangement::Dynamic)
293 .set_width(width.saturating_sub(indentation))
294 .add_row([value]);
295 if let Some(column) = table.column_mut(0) {
296 column.set_padding((0, 0));
297 }
298 table
299 .to_string()
300 .lines()
301 .map(str::trim_end)
302 .collect::<Vec<_>>()
303 .join("\n")
304 }
305
306 fn width(self) -> Option<u16> {
307 self.options.explicit_width().or_else(|| {
308 crate::layout::terminal_width(
309 self.options.resolved_automatic_width_buffer(),
310 self.options.automatic_width_minimum(),
311 )
312 })
313 }
314
315 fn text(self, text: &Text) -> String {
316 text.spans()
317 .iter()
318 .map(|span| self.paint(span.role(), span.value()))
319 .collect()
320 }
321
322 fn text_with_default(self, text: &Text, default: Role) -> String {
323 text.spans()
324 .iter()
325 .map(|span| {
326 let role = if span.role() == Role::Plain {
327 default
328 } else {
329 span.role()
330 };
331 self.paint(role, span.value())
332 })
333 .collect()
334 }
335
336 fn paint(self, role: Role, value: &str) -> String {
337 if self.options.color() == ColorMode::Never || role == Role::Plain {
338 return value.to_owned();
339 }
340 let style = match role {
341 Role::Plain => return value.to_owned(),
342 Role::Heading => HEADING,
343 Role::Success => SUCCESS,
344 Role::Warning => WARNING,
345 Role::Error => ERROR,
346 Role::Value => VALUE,
347 Role::Muted => MUTED,
348 Role::Token => OPTION,
349 };
350 styled(style, value)
351 }
352}
353
354impl Document {
355 #[must_use]
357 pub fn render(&self, options: RenderOptions) -> String {
358 Renderer::new(options).render(self)
359 }
360}
361
362fn sanitize_verbatim(value: &str) -> String {
363 value
364 .chars()
365 .filter(|character| {
366 *character == '\n' || *character == '\t' || !is_unsafe_verbatim(*character)
367 })
368 .collect::<String>()
369 .trim_end_matches('\n')
370 .to_owned()
371}
372
373fn is_unsafe_verbatim(character: char) -> bool {
374 character.is_control()
375 || matches!(
376 character,
377 ALM | FSI | LRE | LRI | LRM | LRO | PDF | PDI | RLE | RLI | RLM | RLO
378 )
379 || matches!(
380 get_general_category(character),
381 GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator
382 )
383}
384
385#[cfg(test)]
386mod tests {
387 use indoc::{formatdoc, indoc};
388
389 use super::RenderOptions;
390 use crate::color::ColorMode;
391 use crate::document::{Document, Fields, Notice, NoticeLevel, Table, Text};
392
393 #[test]
394 fn automatic_width_policy_is_explicit_and_overridable() {
395 let defaults = RenderOptions::new(ColorMode::Never);
396 assert_eq!(defaults.explicit_automatic_width_buffer(), None);
397 assert_eq!(
398 defaults.automatic_width_minimum(),
399 super::DEFAULT_MINIMUM_AUTOMATIC_WIDTH
400 );
401
402 let configured = defaults
403 .automatic_width_buffer(0)
404 .automatic_width_buffer_envs(&["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"])
405 .minimum_automatic_width(8);
406 assert_eq!(configured.explicit_automatic_width_buffer(), Some(0));
407 assert_eq!(
408 configured.automatic_width_buffer_env_names(),
409 ["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"]
410 );
411 assert_eq!(configured.automatic_width_minimum(), 8);
412 }
413
414 #[test]
415 fn explicit_width_ignores_the_automatic_buffer() {
416 let document = Document::new().paragraph("one two three four five six");
417 let exact = document.render(RenderOptions::new(ColorMode::Never).width(16));
418 let buffered = document.render(
419 RenderOptions::new(ColorMode::Never)
420 .width(16)
421 .automatic_width_buffer(8),
422 );
423 assert_eq!(buffered, exact);
424 }
425
426 #[test]
427 fn colorless_document_is_deterministic() {
428 let document = Document::new()
429 .heading("status")
430 .fields(Fields::new().row("pending", Text::plain("2")))
431 .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
432 let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
433 let expected = indoc! {"
434 status
435
436 ┌─────────┬───┐
437 │ pending ┆ 2 │
438 └─────────┴───┘
439
440 warning · one stale row
441 "};
442 assert_eq!(rendered, expected);
443 assert!(!rendered.contains('\u{1b}'));
444 }
445
446 #[test]
447 fn colored_document_has_ansi() {
448 let document = Document::new().heading("status");
449 let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
450 assert!(rendered.contains('\u{1b}'));
451 }
452
453 #[test]
454 fn narrow_table_stacks() {
455 let table =
456 Table::plain()
457 .stacked_below(64, 2)
458 .row(["-f", "--format", "Output representation"]);
459 let rendered = Document::new()
460 .table(table)
461 .render(RenderOptions::new(ColorMode::Never).width(40));
462 assert_eq!(rendered, " -f --format\n Output representation\n");
463 }
464
465 #[test]
466 fn verbatim_text_ignores_explicit_width() {
467 let source = indoc! {"
468 ```text
469 this line stays longer than five
470 ```
471 "};
472 let rendered = Document::new()
473 .verbatim(source)
474 .render(RenderOptions::new(ColorMode::Never).width(5));
475 assert_eq!(rendered, source);
476 }
477
478 #[test]
479 fn verbatim_text_removes_terminal_bidi_and_line_controls() {
480 let rendered = Document::new()
481 .verbatim("\u{1b}]52;clipboard\u{7}\u{202e}\u{2028}\u{2029}\tvalue")
482 .render(RenderOptions::new(ColorMode::Never).width(5));
483 assert_eq!(rendered, "]52;clipboard\tvalue\n");
484 }
485
486 #[test]
487 fn verbatim_text_preserves_other_unicode_formatting() {
488 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}";
489 let rendered = Document::new()
490 .verbatim(source)
491 .render(RenderOptions::new(ColorMode::Never).width(5));
492 assert_eq!(rendered, format!("{source}\n"));
493 }
494
495 #[test]
496 fn wrapped_stacked_labels_keep_indentation() {
497 let table = Table::plain()
498 .stacked_below(64, 1)
499 .row(["one two three four five", "description"]);
500 let rendered = Document::new()
501 .table(table)
502 .render(RenderOptions::new(ColorMode::Never).width(14));
503 let expected = formatdoc! {"
504 {label}one two
505 {label}three four
506 {label}five
507 {description}descriptio
508 {description}n
509 ",
510 label = " ",
511 description = " ",
512 };
513 assert_eq!(rendered, expected);
514 }
515}