Skip to main content

facet_args/
error.rs

1extern crate alloc;
2
3use crate::span::Span;
4use core::fmt;
5use facet_core::{Field, Shape, Type, UserType, Variant};
6use facet_reflect::ReflectError;
7use heck::ToKebabCase;
8
9/// An args parsing error, with input info, so that it can be formatted nicely
10#[derive(Debug)]
11pub struct ArgsErrorWithInput {
12    /// The inner error
13    pub(crate) inner: ArgsError,
14
15    /// All CLI arguments joined by a space
16    #[allow(unused)]
17    pub(crate) flattened_args: String,
18}
19
20impl ArgsErrorWithInput {
21    /// Returns true if this is a help request (not a real error)
22    pub const fn is_help_request(&self) -> bool {
23        self.inner.kind.is_help_request()
24    }
25
26    /// If this is a help request, returns the help text
27    pub fn help_text(&self) -> Option<&str> {
28        self.inner.kind.help_text()
29    }
30}
31
32impl core::fmt::Display for ArgsErrorWithInput {
33    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
34        // For help requests, just display the help text directly
35        if let Some(help) = self.help_text() {
36            return write!(f, "{}", help);
37        }
38
39        // Write the main error message
40        write!(f, "error: {}", self.inner.kind.label())?;
41
42        // If we have help text, add it
43        if let Some(help) = self.inner.kind.help() {
44            write!(f, "\n\n{help}")?;
45        }
46
47        Ok(())
48    }
49}
50
51impl core::error::Error for ArgsErrorWithInput {}
52
53/// An args parsing error (without input info)
54#[derive(Debug)]
55pub struct ArgsError {
56    /// Where the error occurred
57    #[allow(unused)]
58    pub span: Span,
59
60    /// The specific error that occurred while parsing arguments.
61    pub kind: ArgsErrorKind,
62}
63
64/// An error kind for argument parsing.
65///
66/// Stores references to static shape/field/variant info for lazy formatting.
67#[derive(Debug, Clone)]
68#[non_exhaustive]
69pub enum ArgsErrorKind {
70    /// Help was requested via -h, --help, -help, or /?
71    ///
72    /// This is not really an "error" but uses the error path to return
73    /// help text when the user explicitly requests it.
74    HelpRequested {
75        /// The generated help text
76        help_text: alloc::string::String,
77    },
78
79    /// Did not expect a positional argument at this position
80    UnexpectedPositionalArgument {
81        /// Fields of the struct/variant being parsed (for help text)
82        fields: &'static [Field],
83    },
84
85    /// Wanted to look up a field, for example `--something` in a struct,
86    /// but the current shape was not a struct.
87    NoFields {
88        /// The shape that was being parsed
89        shape: &'static Shape,
90    },
91
92    /// Found an enum field without the args::subcommand attribute.
93    /// Enums can only be used as subcommands when explicitly marked.
94    EnumWithoutSubcommandAttribute {
95        /// The field that has the enum type
96        field: &'static Field,
97    },
98
99    /// Passed `--something` (see span), no such long flag
100    UnknownLongFlag {
101        /// The flag that was passed
102        flag: String,
103        /// Fields of the struct/variant being parsed
104        fields: &'static [Field],
105    },
106
107    /// Passed `-j` (see span), no such short flag
108    UnknownShortFlag {
109        /// The flag that was passed
110        flag: String,
111        /// Fields of the struct/variant being parsed
112        fields: &'static [Field],
113        /// Precise span for the invalid flag (used for chained short flags like `-axc` where `x` is invalid)
114        precise_span: Option<Span>,
115    },
116
117    /// Struct/type expected a certain argument to be passed and it wasn't
118    MissingArgument {
119        /// The field that was missing
120        field: &'static Field,
121    },
122
123    /// Expected a value of type shape, got EOF
124    ExpectedValueGotEof {
125        /// The type that was expected
126        shape: &'static Shape,
127    },
128
129    /// Unknown subcommand name
130    UnknownSubcommand {
131        /// The subcommand that was provided
132        provided: String,
133        /// Variants of the enum (subcommands)
134        variants: &'static [Variant],
135    },
136
137    /// Required subcommand was not provided
138    MissingSubcommand {
139        /// Variants of the enum (available subcommands)
140        variants: &'static [Variant],
141    },
142
143    /// Generic reflection error: something went wrong
144    ReflectError(ReflectError),
145}
146
147impl ArgsErrorKind {
148    /// Returns a precise span override if the error kind has one.
149    /// This is used for errors like `UnknownShortFlag` in chained flags
150    /// where we want to highlight just the invalid character, not the whole arg.
151    pub const fn precise_span(&self) -> Option<Span> {
152        match self {
153            ArgsErrorKind::UnknownShortFlag { precise_span, .. } => *precise_span,
154            _ => None,
155        }
156    }
157
158    /// Returns an error code for this error kind.
159    pub const fn code(&self) -> &'static str {
160        match self {
161            ArgsErrorKind::HelpRequested { .. } => "args::help",
162            ArgsErrorKind::UnexpectedPositionalArgument { .. } => "args::unexpected_positional",
163            ArgsErrorKind::NoFields { .. } => "args::no_fields",
164            ArgsErrorKind::EnumWithoutSubcommandAttribute { .. } => {
165                "args::enum_without_subcommand_attribute"
166            }
167            ArgsErrorKind::UnknownLongFlag { .. } => "args::unknown_long_flag",
168            ArgsErrorKind::UnknownShortFlag { .. } => "args::unknown_short_flag",
169            ArgsErrorKind::MissingArgument { .. } => "args::missing_argument",
170            ArgsErrorKind::ExpectedValueGotEof { .. } => "args::expected_value",
171            ArgsErrorKind::UnknownSubcommand { .. } => "args::unknown_subcommand",
172            ArgsErrorKind::MissingSubcommand { .. } => "args::missing_subcommand",
173            ArgsErrorKind::ReflectError(_) => "args::reflect_error",
174        }
175    }
176
177    /// Returns a short label for the error (shown inline in the source)
178    pub fn label(&self) -> String {
179        match self {
180            ArgsErrorKind::HelpRequested { .. } => "help requested".to_string(),
181            ArgsErrorKind::UnexpectedPositionalArgument { .. } => {
182                "unexpected positional argument".to_string()
183            }
184            ArgsErrorKind::NoFields { shape } => {
185                format!("cannot parse arguments into `{}`", shape.type_identifier)
186            }
187            ArgsErrorKind::EnumWithoutSubcommandAttribute { field } => {
188                format!(
189                    "enum field `{}` must be marked with `#[facet(args::subcommand)]` to be used as subcommands",
190                    field.name
191                )
192            }
193            ArgsErrorKind::UnknownLongFlag { flag, .. } => {
194                format!("unknown flag `--{flag}`")
195            }
196            ArgsErrorKind::UnknownShortFlag { flag, .. } => {
197                format!("unknown flag `-{flag}`")
198            }
199            ArgsErrorKind::ExpectedValueGotEof { shape } => {
200                // Unwrap Option to show the inner type
201                let inner_type = unwrap_option_type(shape);
202                format!("expected `{inner_type}` value")
203            }
204            ArgsErrorKind::ReflectError(err) => format_reflect_error(err),
205            ArgsErrorKind::MissingArgument { field } => {
206                let doc_hint = field
207                    .doc
208                    .first()
209                    .map(|d| format!(" ({})", d.trim()))
210                    .unwrap_or_default();
211                let positional = field.has_attr(Some("args"), "positional");
212                let arg_name = if positional {
213                    format!("<{}>", field.name.to_kebab_case())
214                } else {
215                    format!("--{}", field.name.to_kebab_case())
216                };
217                format!("missing required argument `{arg_name}`{doc_hint}")
218            }
219            ArgsErrorKind::UnknownSubcommand { provided, .. } => {
220                format!("unknown subcommand `{provided}`")
221            }
222            ArgsErrorKind::MissingSubcommand { .. } => "expected a subcommand".to_string(),
223        }
224    }
225
226    /// Returns help text for this error
227    pub fn help(&self) -> Option<Box<dyn core::fmt::Display + '_>> {
228        match self {
229            ArgsErrorKind::UnexpectedPositionalArgument { fields } => {
230                if fields.is_empty() {
231                    return Some(Box::new(
232                        "this command does not accept positional arguments",
233                    ));
234                }
235
236                // Check if any of the fields are enums without subcommand attributes
237                if let Some(enum_field) = fields.iter().find(|f| {
238                    matches!(f.shape().ty, Type::User(UserType::Enum(_)))
239                        && !f.has_attr(Some("args"), "subcommand")
240                }) {
241                    return Some(Box::new(format!(
242                        "available options:\n{}\n\nnote: field `{}` is an enum but missing `#[facet(args::subcommand)]` attribute. Enums must be marked as subcommands to accept positional arguments.",
243                        format_available_flags(fields),
244                        enum_field.name
245                    )));
246                }
247
248                let flags = format_available_flags(fields);
249                Some(Box::new(format!("available options:\n{flags}")))
250            }
251            ArgsErrorKind::UnknownLongFlag { flag, fields } => {
252                // Try to find a similar flag
253                if let Some(suggestion) = find_similar_flag(flag, fields) {
254                    return Some(Box::new(format!("did you mean `--{suggestion}`?")));
255                }
256                if fields.is_empty() {
257                    return None;
258                }
259                let flags = format_available_flags(fields);
260                Some(Box::new(format!("available options:\n{flags}")))
261            }
262            ArgsErrorKind::UnknownShortFlag { flag, fields, .. } => {
263                // Try to find what flag the user might have meant
264                let short_char = flag.chars().next();
265                if let Some(field) = fields.iter().find(|f| get_short_flag(f) == short_char) {
266                    return Some(Box::new(format!(
267                        "`-{}` is `--{}`",
268                        flag,
269                        field.name.to_kebab_case()
270                    )));
271                }
272                if fields.is_empty() {
273                    return None;
274                }
275                let flags = format_available_flags(fields);
276                Some(Box::new(format!("available options:\n{flags}")))
277            }
278            ArgsErrorKind::MissingArgument { field } => {
279                let kebab = field.name.to_kebab_case();
280                let type_name = field.shape().type_identifier;
281                let positional = field.has_attr(Some("args"), "positional");
282                if positional {
283                    Some(Box::new(format!("provide a value for `<{kebab}>`")))
284                } else {
285                    Some(Box::new(format!(
286                        "provide a value with `--{kebab} <{type_name}>`"
287                    )))
288                }
289            }
290            ArgsErrorKind::UnknownSubcommand { provided, variants } => {
291                if variants.is_empty() {
292                    return None;
293                }
294                // Try to find a similar subcommand
295                if let Some(suggestion) = find_similar_subcommand(provided, variants) {
296                    return Some(Box::new(format!("did you mean `{suggestion}`?")));
297                }
298                let cmds = format_available_subcommands(variants);
299                Some(Box::new(format!("available subcommands:\n{cmds}")))
300            }
301            ArgsErrorKind::MissingSubcommand { variants } => {
302                if variants.is_empty() {
303                    return None;
304                }
305                let cmds = format_available_subcommands(variants);
306                Some(Box::new(format!("available subcommands:\n{cmds}")))
307            }
308            ArgsErrorKind::ExpectedValueGotEof { .. } => {
309                Some(Box::new("provide a value after the flag"))
310            }
311            ArgsErrorKind::HelpRequested { .. }
312            | ArgsErrorKind::NoFields { .. }
313            | ArgsErrorKind::EnumWithoutSubcommandAttribute { .. }
314            | ArgsErrorKind::ReflectError(_) => None,
315        }
316    }
317
318    /// Returns true if this is a help request (not a real error)
319    pub const fn is_help_request(&self) -> bool {
320        matches!(self, ArgsErrorKind::HelpRequested { .. })
321    }
322
323    /// If this is a help request, returns the help text
324    pub fn help_text(&self) -> Option<&str> {
325        match self {
326            ArgsErrorKind::HelpRequested { help_text } => Some(help_text),
327            _ => None,
328        }
329    }
330}
331
332/// Format a two-column list with aligned descriptions
333fn format_two_column_list(
334    items: impl IntoIterator<Item = (String, Option<&'static str>)>,
335) -> String {
336    use core::fmt::Write;
337
338    let items: Vec<_> = items.into_iter().collect();
339
340    // Find max width for alignment
341    let max_width = items.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
342
343    let mut lines = Vec::new();
344    for (name, doc) in items {
345        let mut line = String::new();
346        write!(line, "  {name}").unwrap();
347
348        // Pad to alignment
349        let padding = max_width.saturating_sub(name.len());
350        for _ in 0..padding {
351            line.push(' ');
352        }
353
354        if let Some(doc) = doc {
355            write!(line, "  {}", doc.trim()).unwrap();
356        }
357
358        lines.push(line);
359    }
360    lines.join("\n")
361}
362
363/// Format available flags for help text (from static field info)
364fn format_available_flags(fields: &'static [Field]) -> String {
365    let items = fields.iter().filter_map(|field| {
366        if field.has_attr(Some("args"), "subcommand") {
367            return None;
368        }
369
370        let short = get_short_flag(field);
371        let positional = field.has_attr(Some("args"), "positional");
372        let kebab = field.name.to_kebab_case();
373
374        let name = if positional {
375            match short {
376                Some(s) => format!("-{s}, <{kebab}>"),
377                None => format!("    <{kebab}>"),
378            }
379        } else {
380            match short {
381                Some(s) => format!("-{s}, --{kebab}"),
382                None => format!("    --{kebab}"),
383            }
384        };
385
386        Some((name, field.doc.first().copied()))
387    });
388
389    format_two_column_list(items)
390}
391
392/// Format available subcommands for help text (from static variant info)
393fn format_available_subcommands(variants: &'static [Variant]) -> String {
394    let items = variants.iter().map(|variant| {
395        let name = variant
396            .get_builtin_attr("rename")
397            .and_then(|attr| attr.get_as::<&str>())
398            .map(|s| (*s).to_string())
399            .unwrap_or_else(|| variant.name.to_kebab_case());
400
401        (name, variant.doc.first().copied())
402    });
403
404    format_two_column_list(items)
405}
406
407/// Get the short flag character for a field, if any
408fn get_short_flag(field: &Field) -> Option<char> {
409    field
410        .get_attr(Some("args"), "short")
411        .and_then(|attr| attr.get_as::<crate::Attr>())
412        .and_then(|attr| {
413            if let crate::Attr::Short(c) = attr {
414                // If explicit char provided, use it; otherwise use first char of field name
415                c.or_else(|| field.name.chars().next())
416            } else {
417                None
418            }
419        })
420}
421
422/// Find a similar flag name using simple heuristics
423fn find_similar_flag(input: &str, fields: &'static [Field]) -> Option<String> {
424    for field in fields {
425        let kebab = field.name.to_kebab_case();
426        if is_similar(input, &kebab) {
427            return Some(kebab);
428        }
429    }
430    None
431}
432
433/// Find a similar subcommand name using simple heuristics
434fn find_similar_subcommand(input: &str, variants: &'static [Variant]) -> Option<String> {
435    for variant in variants {
436        // Check for rename attribute first
437        let name = variant
438            .get_builtin_attr("rename")
439            .and_then(|attr| attr.get_as::<&str>())
440            .map(|s| (*s).to_string())
441            .unwrap_or_else(|| variant.name.to_kebab_case());
442        if is_similar(input, &name) {
443            return Some(name);
444        }
445    }
446    None
447}
448
449/// Check if two strings are similar (differ by at most 2 edits)
450fn is_similar(a: &str, b: &str) -> bool {
451    if a == b {
452        return true;
453    }
454    let len_diff = (a.len() as isize - b.len() as isize).abs();
455    if len_diff > 2 {
456        return false;
457    }
458
459    // Simple check: count character differences
460    let mut diffs = 0;
461    let a_chars: Vec<char> = a.chars().collect();
462    let b_chars: Vec<char> = b.chars().collect();
463
464    for (ac, bc) in a_chars.iter().zip(b_chars.iter()) {
465        if ac != bc {
466            diffs += 1;
467        }
468    }
469    diffs += len_diff as usize;
470    diffs <= 2
471}
472
473/// Get the inner type identifier, unwrapping Option if present
474const fn unwrap_option_type(shape: &'static Shape) -> &'static str {
475    match shape.def {
476        facet_core::Def::Option(opt_def) => opt_def.t.type_identifier,
477        _ => shape.type_identifier,
478    }
479}
480
481/// Format a ReflectError into a user-friendly message
482fn format_reflect_error(err: &ReflectError) -> String {
483    use facet_reflect::ReflectError::*;
484    match err {
485        ParseFailed { shape, .. } => {
486            // Use the same nice message format as OperationFailed with "Failed to parse"
487            let inner_type = unwrap_option_type(shape);
488            format!("invalid value for `{inner_type}`")
489        }
490        OperationFailed { shape, operation } => {
491            // Improve common operation failure messages
492            // Unwrap Option to show the inner type
493            let inner_type = unwrap_option_type(shape);
494
495            // Check for subcommand-specific error message
496            if operation.starts_with("Subcommands must be provided") {
497                return operation.to_string();
498            }
499
500            match *operation {
501                "Type does not support parsing from string" => {
502                    format!("`{inner_type}` cannot be parsed from a string value")
503                }
504                "Failed to parse string value" => {
505                    format!("invalid value for `{inner_type}`")
506                }
507                _ => format!("`{inner_type}`: {operation}"),
508            }
509        }
510        UninitializedField { shape, field_name } => {
511            format!(
512                "field `{}` of `{}` was not provided",
513                field_name, shape.type_identifier
514            )
515        }
516        WrongShape { expected, actual } => {
517            format!(
518                "expected `{}`, got `{}`",
519                expected.type_identifier, actual.type_identifier
520            )
521        }
522        _ => format!("{err}"),
523    }
524}
525
526impl core::fmt::Display for ArgsErrorKind {
527    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
528        write!(f, "{}", self.label())
529    }
530}
531
532impl From<ReflectError> for ArgsErrorKind {
533    fn from(error: ReflectError) -> Self {
534        ArgsErrorKind::ReflectError(error)
535    }
536}
537
538impl ArgsError {
539    /// Creates a new args error
540    pub const fn new(kind: ArgsErrorKind, span: Span) -> Self {
541        Self { span, kind }
542    }
543}
544
545impl fmt::Display for ArgsError {
546    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
547        fmt::Debug::fmt(self, f)
548    }
549}
550
551/// Extract variants from a shape (if it's an enum)
552pub(crate) const fn get_variants_from_shape(shape: &'static Shape) -> &'static [Variant] {
553    if let Type::User(UserType::Enum(enum_type)) = shape.ty {
554        enum_type.variants
555    } else {
556        &[]
557    }
558}
559
560#[cfg(feature = "ariadne")]
561mod ariadne_impl {
562    use super::*;
563    use ariadne::{Color, Label, Report, ReportKind, Source};
564
565    impl ArgsErrorWithInput {
566        /// Returns an Ariadne report builder for this error.
567        ///
568        /// The report uses `std::ops::Range<usize>` as the span type, suitable for
569        /// use with `ariadne::Source::from(&self.flattened_args)`.
570        pub fn to_ariadne_report(&self) -> Report<'static, core::ops::Range<usize>> {
571            // Skip help requests - they're not real errors
572            if self.is_help_request() {
573                return Report::build(ReportKind::Custom("Help", Color::Cyan), 0..0)
574                    .with_message(self.help_text().unwrap_or(""))
575                    .finish();
576            }
577
578            // Use precise_span if available (e.g., for chained short flags)
579            let span = self.inner.kind.precise_span().unwrap_or(self.inner.span);
580            let range = span.start..(span.start + span.len);
581
582            let mut builder = Report::build(ReportKind::Error, range.clone())
583                .with_code(self.inner.kind.code())
584                .with_message(self.inner.kind.label());
585
586            // Add the primary label
587            builder = builder.with_label(
588                Label::new(range)
589                    .with_message(self.inner.kind.label())
590                    .with_color(Color::Red),
591            );
592
593            // Add help text as a note if available
594            if let Some(help) = self.inner.kind.help() {
595                builder = builder.with_help(help.to_string());
596            }
597
598            builder.finish()
599        }
600
601        /// Writes the error as a pretty-printed Ariadne diagnostic to the given writer.
602        ///
603        /// This creates a source from the flattened CLI arguments and renders the
604        /// error report with source context.
605        pub fn write_ariadne(&self, writer: impl std::io::Write) -> std::io::Result<()> {
606            let source = Source::from(&self.flattened_args);
607            self.to_ariadne_report().write(source, writer)
608        }
609
610        /// Returns the error as a pretty-printed Ariadne diagnostic string.
611        ///
612        /// This is a convenience method that calls [`write_ariadne`](Self::write_ariadne)
613        /// with an in-memory buffer.
614        pub fn to_ariadne_string(&self) -> String {
615            let mut buf = Vec::new();
616            // write_ariadne only fails on IO errors, which won't happen with Vec
617            self.write_ariadne(&mut buf).expect("write to Vec failed");
618            String::from_utf8(buf).expect("ariadne output is valid UTF-8")
619        }
620    }
621}