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