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