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