miette/handlers/graphical/
report.rs1use std::fmt;
11
12use owo_colors::OwoColorize;
13
14use super::handler::{GraphicalReportHandler, LinkStyle};
15use crate::{Diagnostic, Severity};
16
17impl GraphicalReportHandler {
18 pub fn render_report(
24 &self,
25 f: &mut impl fmt::Write,
26 diagnostic: &dyn Diagnostic,
27 ) -> fmt::Result {
28 writeln!(f)?;
29 self.render_title(f, diagnostic)?;
30 let src = diagnostic.source_code();
31 self.render_snippets(f, diagnostic, src)?;
32 self.render_footer(f, diagnostic)?;
33 Ok(())
34 }
35
36 fn render_title(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
37 let (severity_style, severity_icon) = match diagnostic.severity() {
38 Some(Severity::Error) | None => (self.theme.styles.error, &self.theme.characters.error),
39 Some(Severity::Warning) => (self.theme.styles.warning, &self.theme.characters.warning),
40 Some(Severity::Advice) => (self.theme.styles.advice, &self.theme.characters.advice),
41 };
42
43 let width = self.termwidth.saturating_sub(2);
44
45 let title = match (self.links, diagnostic.url(), diagnostic.code()) {
46 (LinkStyle::Link, Some(url), Some(code)) => {
47 const CTL: &str = "\u{1b}]8;;";
49 const END: &str = "\u{1b}]8;;\u{1b}\\";
50 let code = code.style(severity_style);
51 let title = diagnostic.style(severity_style);
52 format!("{CTL}{url}\u{1b}\\{code}{END}: {title}")
53 }
54 (_, _, Some(code)) if severity_style.is_plain() => format!("{code}: {diagnostic}"),
55 (_, _, Some(code)) => {
56 format!("{}", format_args!("{code}: {diagnostic}").style(severity_style))
57 }
58 _ if severity_style.is_plain() => diagnostic.to_string(),
59 _ => format!("{}", diagnostic.style(severity_style)),
60 };
61 if !title.contains('\n')
62 && severity_icon.len().saturating_add(title.len()).saturating_add(3) <= width
63 {
64 f.write_str(" ")?;
65 if severity_style.is_plain() {
66 f.write_str(severity_icon)?;
67 } else {
68 write!(f, "{}", severity_icon.style(severity_style))?;
69 }
70 f.write_char(' ')?;
71 f.write_str(title.trim_end_matches(' '))?;
72 } else {
73 let (initial_indent, rest_indent) = if severity_style.is_plain() {
75 (format!(" {severity_icon} "), format!(" {} ", self.theme.characters.vbar))
76 } else {
77 (
78 format!(" {} ", severity_icon.style(severity_style)),
79 format!(" {} ", self.theme.characters.vbar.style(severity_style)),
80 )
81 };
82 let opts = Self::wrap_options(width, &initial_indent, &rest_indent);
83 Self::write_fill(f, &title, opts)?;
84 }
85 f.write_char('\n')?;
86
87 Ok(())
88 }
89
90 fn render_footer(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
91 if let Some(help) = diagnostic.help() {
92 const PREFIX: &str = " help: ";
93 let width = self.termwidth.saturating_sub(4);
94 if memchr::memchr(b'\n', help.as_bytes()).is_none()
95 && PREFIX.len().saturating_add(help.len()) <= width
96 {
97 if self.theme.styles.help.is_plain() {
98 f.write_str(PREFIX)?;
99 } else {
100 write!(f, "{}", PREFIX.style(self.theme.styles.help))?;
101 }
102 f.write_str(help.trim_end_matches(' '))?;
103 } else {
104 let initial_indent = PREFIX.style(self.theme.styles.help).to_string();
105 let opts = Self::wrap_options(width, &initial_indent, " ");
106 Self::write_fill(f, &help, opts)?;
107 }
108 f.write_char('\n')?;
109 }
110 if let Some(note) = diagnostic.note() {
111 let width = self.termwidth.saturating_sub(4);
114 let initial_indent = " note: ".style(self.theme.styles.note).to_string();
115 let opts = Self::wrap_options(width, &initial_indent, " ");
116 Self::write_fill(f, ¬e, opts)?;
117 f.write_char('\n')?;
118 }
119 Ok(())
120 }
121
122 fn wrap_options<'a>(
124 width: usize,
125 initial_indent: &'a str,
126 subsequent_indent: &'a str,
127 ) -> textwrap::Options<'a> {
128 textwrap::Options::new(width)
129 .initial_indent(initial_indent)
130 .subsequent_indent(subsequent_indent)
131 }
132
133 fn write_fill(f: &mut impl fmt::Write, text: &str, opts: textwrap::Options<'_>) -> fmt::Result {
134 if Self::fits_on_line(text, &opts) {
135 f.write_str(opts.initial_indent)?;
136 f.write_str(text.trim_end_matches(' '))
137 } else {
138 f.write_str(&textwrap::fill(text, opts))
139 }
140 }
141
142 #[cfg(test)]
146 fn fill(text: &str, opts: textwrap::Options<'_>) -> String {
147 if Self::fits_on_line(text, &opts) {
148 let text = text.trim_end_matches(' ');
149 let mut result = String::with_capacity(opts.initial_indent.len() + text.len());
150 result.push_str(opts.initial_indent);
151 result.push_str(text);
152 return result;
153 }
154 textwrap::fill(text, opts)
155 }
156
157 fn fits_on_line(text: &str, opts: &textwrap::Options<'_>) -> bool {
158 if memchr::memchr(b'\n', text.as_bytes()).is_some() {
159 return false;
160 }
161
162 opts.initial_indent.len().saturating_add(text.len()) <= opts.width || {
166 let available = opts.width.saturating_sub(Self::display_width(opts.initial_indent));
167 Self::display_width(text) <= available
168 }
169 }
170
171 fn display_width(text: &str) -> usize {
175 if !text.is_ascii() {
176 return textwrap::core::display_width(text);
177 }
178
179 let bytes = text.as_bytes();
180 let mut width = 0;
181 let mut i = 0;
182 while i < bytes.len() {
183 if bytes[i] != b'\x1b' {
184 width += usize::from((b' '..=b'~').contains(&bytes[i]));
185 i += 1;
186 continue;
187 }
188
189 i += 1;
190 let Some(&kind) = bytes.get(i) else { break };
191 i += 1;
192 match kind {
193 b'[' => {
194 while i < bytes.len() {
195 let byte = bytes[i];
196 i += 1;
197 if (b'@'..=b'~').contains(&byte) {
198 break;
199 }
200 }
201 }
202 b']' => {
203 while i < bytes.len() {
204 if bytes[i] == b'\x07' {
205 i += 1;
206 break;
207 }
208 if bytes[i] == b'\x1b' && bytes.get(i + 1) == Some(&b'\\') {
209 i += 2;
210 break;
211 }
212 i += 1;
213 }
214 }
215 _ => {}
216 }
217 }
218 width
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 #[cfg_attr(
228 miri,
229 ignore = "exhaustive equivalence check over safe text wrapping code; interpreting every \
230 textwrap case under Miri takes more than 16 minutes"
231 )]
232 fn fill_fast_path_matches_textwrap() {
233 let texts = [
234 "",
235 "short diagnostic",
236 "trailing spaces ",
237 " leading spaces",
238 "two inner spaces",
239 "Café 火",
240 "combining e\u{301}",
241 "emoji 🐂",
242 "\u{1b}[31mstyled text\u{1b}[0m",
243 "\u{1b}]8;;https://example.com\u{1b}\\linked\u{1b}]8;;\u{1b}\\",
244 "\u{1b}]0;title\u{7}visible",
245 "control\tcharacters\u{7}",
246 "incomplete \u{1b}[31",
247 "first\nsecond",
248 ];
249 for width in 0..32 {
250 for initial_indent in ["", " ", " help: ", "\u{1b}[31m × \u{1b}[0m"] {
251 for text in texts {
252 let opts = textwrap::Options::new(width)
253 .initial_indent(initial_indent)
254 .subsequent_indent(" ");
255 assert_eq!(
256 GraphicalReportHandler::fill(text, opts.clone()),
257 textwrap::fill(text, opts.clone()),
258 "width={width}, indent={initial_indent:?}, text={text:?}"
259 );
260 let mut output = String::new();
261 GraphicalReportHandler::write_fill(&mut output, text, opts.clone()).unwrap();
262 assert_eq!(
263 output,
264 textwrap::fill(text, opts),
265 "streaming: width={width}, indent={initial_indent:?}, text={text:?}"
266 );
267 }
268 }
269 }
270 }
271}