1use std::error::Error as StdError;
8use std::fmt::{Debug, Display, Formatter};
9use unicode_width::UnicodeWidthChar;
10
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
12pub struct SourceOffset(usize);
13
14impl From<usize> for SourceOffset {
15 fn from(value: usize) -> Self {
16 Self(value)
17 }
18}
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct SourceSpan {
22 offset: SourceOffset,
23 len: usize,
24}
25
26impl SourceSpan {
27 pub fn new(offset: SourceOffset, len: usize) -> Self {
28 Self { offset, len }
29 }
30
31 pub fn offset(self) -> usize {
32 self.offset.0
33 }
34
35 pub fn len(self) -> usize {
36 self.len
37 }
38
39 pub fn is_empty(self) -> bool {
40 self.len == 0
41 }
42}
43
44impl From<(usize, usize)> for SourceSpan {
45 fn from((offset, len): (usize, usize)) -> Self {
46 Self::new(offset.into(), len)
47 }
48}
49
50impl From<std::ops::Range<usize>> for SourceSpan {
51 fn from(range: std::ops::Range<usize>) -> Self {
52 Self::new(range.start.into(), range.end.saturating_sub(range.start))
53 }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct NamedSource<S> {
58 name: String,
59 source: S,
60}
61
62impl<S> NamedSource<S> {
63 pub fn new(name: impl Display, source: S) -> Self {
64 Self {
65 name: name.to_string(),
66 source,
67 }
68 }
69
70 pub fn name(&self) -> &str {
71 &self.name
72 }
73
74 pub fn inner(&self) -> &S {
75 &self.source
76 }
77}
78
79#[cfg(feature = "miette")]
80impl ::miette::SourceCode for NamedSource<String> {
81 fn read_span<'a>(
82 &'a self,
83 span: &::miette::SourceSpan,
84 context_lines_before: usize,
85 context_lines_after: usize,
86 ) -> std::result::Result<Box<dyn ::miette::SpanContents<'a> + 'a>, ::miette::MietteError> {
87 let contents = ::miette::SourceCode::read_span(
88 &self.source,
89 span,
90 context_lines_before,
91 context_lines_after,
92 )?;
93 Ok(Box::new(::miette::MietteSpanContents::new_named(
94 self.name.clone(),
95 contents.data(),
96 *contents.span(),
97 contents.line(),
98 contents.column(),
99 contents.line_count(),
100 )))
101 }
102}
103
104#[derive(Debug)]
105pub struct MietteError(String);
106
107impl MietteError {
108 pub fn new(message: impl Into<String>) -> Self {
109 Self(message.into())
110 }
111}
112
113impl Display for MietteError {
114 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
115 f.write_str(&self.0)
116 }
117}
118
119impl StdError for MietteError {}
120
121pub struct Error(Box<dyn StdError + Send + Sync + 'static>);
122pub type Report = Error;
123pub type Result<T, E = Error> = std::result::Result<T, E>;
124
125impl Error {
126 pub fn new(error: impl StdError + Send + Sync + 'static) -> Self {
127 Self(Box::new(error))
128 }
129
130 pub fn msg(message: impl Into<String>) -> Self {
131 Self::new(MietteError::new(message))
132 }
133
134 pub fn downcast_ref<T: StdError + 'static>(&self) -> Option<&T> {
135 self.0.downcast_ref()
136 }
137}
138
139impl Display for Error {
140 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
141 Display::fmt(&self.0, f)
142 }
143}
144
145impl Debug for Error {
146 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
147 let mut rendered = if let Some(error) = self.downcast_ref::<crate::error::UsageErr>() {
148 error.render()
149 } else {
150 self.to_string()
151 };
152 let mut source = self.0.source();
153 if source.is_some() {
154 rendered.push_str("\n\nCaused by:");
155 }
156 let mut index = 1;
157 while let Some(cause) = source {
158 rendered.push_str(&format!("\n {index}: {cause}"));
159 source = cause.source();
160 index += 1;
161 }
162 f.write_str(&rendered)
163 }
164}
165
166impl StdError for Error {
167 fn source(&self) -> Option<&(dyn StdError + 'static)> {
168 self.0.source()
169 }
170}
171
172impl From<crate::error::UsageErr> for Error {
173 fn from(value: crate::error::UsageErr) -> Self {
174 Self::new(value)
175 }
176}
177
178pub trait IntoDiagnostic<T> {
179 fn into_diagnostic(self) -> Result<T>;
180}
181
182impl<T, E> IntoDiagnostic<T> for std::result::Result<T, E>
183where
184 E: StdError + Send + Sync + 'static,
185{
186 fn into_diagnostic(self) -> Result<T> {
187 self.map_err(Error::new)
188 }
189}
190
191pub(crate) fn render_source(
193 title: &str,
194 source_name: &str,
195 source: &str,
196 span: SourceSpan,
197 label: &str,
198 help: Option<&str>,
199) -> String {
200 let offset = source.floor_char_boundary(span.offset().min(source.len()));
201 let lines = line_ranges(source);
202 let line_index = lines
203 .iter()
204 .rposition(|(start, _)| *start <= offset)
205 .unwrap_or(0);
206 let (line_start, line_end) = lines[line_index];
207 let line = expand_tabs(&source[line_start..line_end], 4);
208 let line_no = line_index + 1;
209 let column = display_width(&source[line_start..offset], 0, 4);
210 let requested_end = offset.saturating_add(span.len()).min(line_end);
211 let span_end = source.ceil_char_boundary(requested_end).min(line_end);
212 let marked = display_width(&source[offset..span_end], column, 4).max(1);
213 let width = line_no.to_string().len();
214 let location = if source_name.is_empty() {
215 format!("[{}:{}]", line_no, column + 1)
216 } else {
217 format!("[{source_name}:{}:{}]", line_no, column + 1)
218 };
219 let mut out = format!(
220 " × {title}\n {blank:width$} ╭─{location}\n {line_no:>width$} │ {line}\n {blank:width$} · {blank:column$}{mark:─<marked$}┬\n {blank:width$} · {blank:indent$}╰── {label}\n {blank:width$} ╰────",
221 blank = "",
222 mark = "",
223 width = width,
224 column = column,
225 marked = marked,
226 indent = column + marked,
227 );
228 if let Some(help) = help {
229 out.push_str("\n help: ");
230 out.push_str(help);
231 }
232 out
233}
234
235fn line_ranges(source: &str) -> Vec<(usize, usize)> {
236 let mut ranges = Vec::new();
237 let mut start = 0;
238 let mut chars = source.char_indices().peekable();
239 while let Some((at, ch)) = chars.next() {
240 let is_newline = matches!(
241 ch,
242 '\r' | '\n' | '\u{0085}' | '\u{000B}' | '\u{000C}' | '\u{2028}' | '\u{2029}'
243 );
244 if !is_newline {
245 continue;
246 }
247 ranges.push((start, at));
248 if ch == '\r' && chars.peek().is_some_and(|(_, next)| *next == '\n') {
249 chars.next();
250 }
251 start = chars.peek().map_or(source.len(), |(at, _)| *at);
252 }
253 ranges.push((start, source.len()));
254 ranges
255}
256
257fn display_width(value: &str, starting_column: usize, tab_width: usize) -> usize {
258 let mut column = starting_column;
259 for ch in value.chars() {
260 if ch == '\t' {
261 column += tab_width - (column % tab_width);
262 } else {
263 column += ch.width().unwrap_or(0);
264 }
265 }
266 column - starting_column
267}
268
269fn expand_tabs(value: &str, tab_width: usize) -> String {
270 let mut out = String::with_capacity(value.len());
271 let mut column = 0;
272 for ch in value.chars() {
273 if ch == '\t' {
274 let spaces = tab_width - (column % tab_width);
275 out.extend(std::iter::repeat_n(' ', spaces));
276 column += spaces;
277 } else {
278 out.push(ch);
279 column += ch.width().unwrap_or(0);
280 }
281 }
282 out
283}
284
285#[macro_export]
286macro_rules! __usage_miette {
287 ($fmt:literal $(, $arg:expr)* $(,)?) => {
288 $crate::miette::Error::msg(format!($fmt $(, $arg)*))
289 };
290 ($err:expr $(,)?) => {
291 $crate::miette::Error::msg($err.to_string())
292 };
293}
294
295#[macro_export]
296macro_rules! __usage_bail {
297 ($($arg:tt)*) => {
298 return Err($crate::__usage_miette!($($arg)*))
299 };
300}
301
302pub use crate::__usage_bail as bail;
303pub use crate::__usage_miette as miette;
304
305#[cfg(test)]
306mod tests {
307 use super::{render_source, Error, MietteError, SourceSpan};
308 use crate::error::UsageErr;
309 use crate::Spec;
310 use std::path::Path;
311
312 #[test]
313 fn a_kdl_syntax_error_keeps_its_source_label_and_help() {
314 let source = "name broken\nflag --output {\n arg \"unterminated\n}\n";
315 let error = source.parse::<Spec>().unwrap_err();
316 let rendered = format!("{:?}", Error::from(error));
317
318 assert!(
319 rendered.contains("Unexpected newline in single-line quoted string"),
320 "{rendered}"
321 );
322 assert!(rendered.contains("3 │ arg \"unterminated"), "{rendered}");
323 assert!(rendered.contains("╰── not quoted string"), "{rendered}");
324 assert!(
325 rendered.contains("help: You can make a string multi-line"),
326 "{rendered}"
327 );
328 }
329
330 #[test]
331 fn source_renderer_aligns_wide_gutters_and_utf8_spans() {
332 let source = format!("{}éx", "x\n".repeat(9));
333 let offset = source.find('é').unwrap();
334 let rendered = render_source(
335 "bad value",
336 "",
337 &source,
338 SourceSpan::from(offset..offset + 'é'.len_utf8()),
339 "invalid",
340 None,
341 );
342
343 assert!(rendered.contains(" 10 │ éx\n · ─┬"), "{rendered}");
344
345 let rendered = render_source(
348 "bad value",
349 "",
350 &source,
351 SourceSpan::new((offset + 1).into(), 1),
352 "invalid",
353 None,
354 );
355 assert!(rendered.contains(" 10 │ éx\n · ─┬"), "{rendered}");
356 }
357
358 #[test]
359 fn source_renderer_handles_every_kdl_newline() {
360 for newline in [
361 "\r\n", "\r", "\n", "\u{0085}", "\u{000B}", "\u{000C}", "\u{2028}", "\u{2029}",
362 ] {
363 let source = format!("first{newline}bad");
364 let offset = source.find("bad").unwrap();
365 let rendered = render_source(
366 "bad value",
367 "spec.kdl",
368 &source,
369 SourceSpan::from(offset..offset + 3),
370 "invalid",
371 None,
372 );
373
374 assert!(
375 rendered.contains("[spec.kdl:2:1]"),
376 "{newline:?}: {rendered:?}"
377 );
378 assert!(rendered.contains("2 │ bad"), "{newline:?}: {rendered:?}");
379 }
380 }
381
382 #[test]
383 fn source_renderer_aligns_tabs_and_wide_characters() {
384 let source = "\t界bad";
385 let offset = source.find("bad").unwrap();
386 let rendered = render_source(
387 "bad value",
388 "",
389 source,
390 SourceSpan::from(offset..offset + 3),
391 "invalid",
392 None,
393 );
394
395 assert!(rendered.contains("1 │ 界bad"), "{rendered}");
396 assert!(rendered.contains(" · ───┬"), "{rendered}");
397 assert!(rendered.contains("[1:7]"), "{rendered}");
398 }
399
400 #[test]
401 fn kdl_file_errors_include_the_filename_and_location() {
402 let directory = tempfile::tempdir().unwrap();
403 let path = directory.path().join("broken.usage.kdl");
404 std::fs::write(&path, "name \"ok\"\narg \"unterminated\n").unwrap();
405
406 let error = Spec::parse_file(Path::new(&path)).unwrap_err();
407 let rendered = format!("{:?}", Error::from(error));
408 assert!(
409 rendered.contains(&format!("[{}:2:5]", path.display())),
410 "{rendered}"
411 );
412 }
413
414 #[test]
415 fn reports_include_error_source_chains() {
416 #[derive(Debug, thiserror::Error)]
417 #[error("outer failure")]
418 struct Outer(#[source] MietteError);
419
420 let rendered = format!("{:?}", Error::new(Outer(MietteError::new("root cause"))));
421 assert!(
422 rendered.contains("outer failure\n\nCaused by:\n 1: root cause"),
423 "{rendered}"
424 );
425 }
426
427 #[test]
428 fn plain_errors_keep_their_underlying_message() {
429 let error = UsageErr::from(MietteError::new("specific detail"));
430 assert_eq!(error.to_string(), "Invalid usage config: specific detail");
431
432 let error = "name \"unterminated\n".parse::<Spec>().unwrap_err();
433 assert!(error.to_string().contains("Unexpected newline"), "{error}");
434 }
435
436 #[cfg(feature = "miette")]
437 #[test]
438 fn a_kdl_syntax_error_works_with_a_miette_reporter() {
439 let source = "name broken\nflag --output {\n arg \"unterminated\n}\n";
440 let error = source.parse::<Spec>().unwrap_err();
441 let mut rendered = String::new();
442
443 ::miette::NarratableReportHandler::new()
444 .render_report(&mut rendered, &error)
445 .unwrap();
446
447 assert!(
448 rendered.contains("Unexpected newline in single-line quoted string"),
449 "{rendered}"
450 );
451 assert!(rendered.contains("line 3, columns 7 to 19"), "{rendered}");
452 assert!(rendered.contains("not quoted string"), "{rendered}");
453 assert!(
454 rendered.contains("You can make a string multi-line"),
455 "{rendered}"
456 );
457 }
458}