facet_args/
help.rs

1//! Help text generation for command-line interfaces.
2//!
3//! This module provides utilities to generate help text from Facet type metadata,
4//! including doc comments, field names, and attribute information.
5
6use alloc::string::String;
7use alloc::vec::Vec;
8use facet_core::{Def, Facet, Field, Shape, StructKind, Type, UserType, Variant};
9use heck::ToKebabCase;
10use owo_colors::OwoColorize;
11
12/// Configuration for help text generation.
13#[derive(Debug, Clone)]
14pub struct HelpConfig {
15    /// Program name (defaults to executable name)
16    pub program_name: Option<String>,
17    /// Program version
18    pub version: Option<String>,
19    /// Additional description to show after the auto-generated one
20    pub description: Option<String>,
21    /// Width for wrapping text (0 = no wrapping)
22    pub width: usize,
23}
24
25impl Default for HelpConfig {
26    fn default() -> Self {
27        Self {
28            program_name: None,
29            version: None,
30            description: None,
31            width: 80,
32        }
33    }
34}
35
36/// Generate help text for a Facet type.
37pub fn generate_help<T: facet_core::Facet<'static>>(config: &HelpConfig) -> String {
38    generate_help_for_shape(T::SHAPE, config)
39}
40
41/// Generate help text for a shape.
42pub fn generate_help_for_shape(shape: &'static Shape, config: &HelpConfig) -> String {
43    let mut out = String::new();
44
45    // Program name and version
46    let program_name = config
47        .program_name
48        .clone()
49        .or_else(|| std::env::args().next())
50        .unwrap_or_else(|| "program".to_string());
51
52    if let Some(version) = &config.version {
53        out.push_str(&format!("{program_name} {version}\n"));
54    } else {
55        out.push_str(&format!("{program_name}\n"));
56    }
57
58    // Type doc comment
59    if !shape.doc.is_empty() {
60        out.push('\n');
61        for line in shape.doc {
62            out.push_str(line.trim());
63            out.push('\n');
64        }
65    }
66
67    // Additional description
68    if let Some(desc) = &config.description {
69        out.push('\n');
70        out.push_str(desc);
71        out.push('\n');
72    }
73
74    out.push('\n');
75
76    // Generate based on type
77    match &shape.ty {
78        Type::User(UserType::Struct(struct_type)) => {
79            generate_struct_help(&mut out, &program_name, struct_type.fields);
80        }
81        Type::User(UserType::Enum(enum_type)) => {
82            generate_enum_help(&mut out, &program_name, enum_type.variants);
83        }
84        _ => {
85            out.push_str("(No help available for this type)\n");
86        }
87    }
88
89    out
90}
91
92fn generate_struct_help(out: &mut String, program_name: &str, fields: &'static [Field]) {
93    // Collect flags, positionals, and subcommand
94    let mut flags: Vec<&Field> = Vec::new();
95    let mut positionals: Vec<&Field> = Vec::new();
96    let mut subcommand: Option<&Field> = None;
97
98    for field in fields {
99        if field.has_attr(Some("args"), "subcommand") {
100            subcommand = Some(field);
101        } else if field.has_attr(Some("args"), "positional") {
102            positionals.push(field);
103        } else {
104            flags.push(field);
105        }
106    }
107
108    // Usage line
109    out.push_str(&format!("{}:\n    ", "USAGE".yellow().bold()));
110    out.push_str(program_name);
111
112    if !flags.is_empty() {
113        out.push_str(" [OPTIONS]");
114    }
115
116    for pos in &positionals {
117        let name = pos.name.to_kebab_case().to_uppercase();
118        let is_optional = matches!(pos.shape().def, Def::Option(_)) || pos.has_default();
119        if is_optional {
120            out.push_str(&format!(" [{name}]"));
121        } else {
122            out.push_str(&format!(" <{name}>"));
123        }
124    }
125
126    if let Some(sub) = subcommand {
127        let is_optional = matches!(sub.shape().def, Def::Option(_));
128        if is_optional {
129            out.push_str(" [COMMAND]");
130        } else {
131            out.push_str(" <COMMAND>");
132        }
133    }
134
135    out.push_str("\n\n");
136
137    // Positional arguments
138    if !positionals.is_empty() {
139        out.push_str(&format!("{}:\n", "ARGUMENTS".yellow().bold()));
140        for field in &positionals {
141            write_field_help(out, field, true);
142        }
143        out.push('\n');
144    }
145
146    // Options
147    if !flags.is_empty() {
148        out.push_str(&format!("{}:\n", "OPTIONS".yellow().bold()));
149        for field in &flags {
150            write_field_help(out, field, false);
151        }
152        out.push('\n');
153    }
154
155    // Subcommands
156    if let Some(sub_field) = subcommand {
157        let sub_shape = sub_field.shape();
158        // Handle Option<Enum> or direct Enum
159        let enum_shape = if let Def::Option(opt) = sub_shape.def {
160            opt.t
161        } else {
162            sub_shape
163        };
164
165        if let Type::User(UserType::Enum(enum_type)) = enum_shape.ty {
166            out.push_str(&format!("{}:\n", "COMMANDS".yellow().bold()));
167            for variant in enum_type.variants {
168                write_variant_help(out, variant);
169            }
170            out.push('\n');
171        }
172    }
173}
174
175fn generate_enum_help(out: &mut String, program_name: &str, variants: &'static [Variant]) {
176    // For top-level enum, show subcommands
177    out.push_str(&format!("{}:\n    ", "USAGE".yellow().bold()));
178    out.push_str(program_name);
179    out.push_str(" <COMMAND>\n\n");
180
181    out.push_str(&format!("{}:\n", "COMMANDS".yellow().bold()));
182    for variant in variants {
183        write_variant_help(out, variant);
184    }
185    out.push('\n');
186}
187
188fn write_field_help(out: &mut String, field: &Field, is_positional: bool) {
189    out.push_str("    ");
190
191    // Short flag
192    let short = get_short_flag(field);
193    if let Some(c) = short {
194        out.push_str(&format!("{}, ", format!("-{c}").green()));
195    } else {
196        out.push_str("    ");
197    }
198
199    // Long flag or positional name
200    let kebab_name = field.name.to_kebab_case();
201    if is_positional {
202        out.push_str(&format!(
203            "{}",
204            format!("<{}>", kebab_name.to_uppercase()).green()
205        ));
206    } else {
207        out.push_str(&format!("{}", format!("--{kebab_name}").green()));
208
209        // Show value placeholder for non-bool types
210        let shape = field.shape();
211        if !shape.is_shape(bool::SHAPE) {
212            out.push_str(&format!(" <{}>", shape.type_identifier.to_uppercase()));
213        }
214    }
215
216    // Doc comment
217    if let Some(doc) = field.doc.first() {
218        out.push_str("\n            ");
219        out.push_str(doc.trim());
220    }
221
222    out.push('\n');
223}
224
225fn write_variant_help(out: &mut String, variant: &Variant) {
226    out.push_str("    ");
227
228    // Variant name (check for rename)
229    let name = variant
230        .get_builtin_attr("rename")
231        .and_then(|attr| attr.get_as::<&str>())
232        .map(|s| (*s).to_string())
233        .unwrap_or_else(|| variant.name.to_kebab_case());
234
235    out.push_str(&format!("{}", name.green()));
236
237    // Doc comment
238    if let Some(doc) = variant.doc.first() {
239        out.push_str("\n            ");
240        out.push_str(doc.trim());
241    }
242
243    out.push('\n');
244}
245
246/// Get the short flag character for a field, if any
247fn get_short_flag(field: &Field) -> Option<char> {
248    field
249        .get_attr(Some("args"), "short")
250        .and_then(|attr| attr.get_as::<crate::Attr>())
251        .and_then(|attr| {
252            if let crate::Attr::Short(c) = attr {
253                // If explicit char provided, use it; otherwise use first char of field name
254                c.or_else(|| field.name.chars().next())
255            } else {
256                None
257            }
258        })
259}
260
261/// Generate help for a specific subcommand variant.
262pub fn generate_subcommand_help(
263    variant: &'static Variant,
264    parent_program: &str,
265    config: &HelpConfig,
266) -> String {
267    let mut out = String::new();
268
269    let variant_name = variant
270        .get_builtin_attr("rename")
271        .and_then(|attr| attr.get_as::<&str>())
272        .map(|s| (*s).to_string())
273        .unwrap_or_else(|| variant.name.to_kebab_case());
274
275    let full_name = format!("{parent_program} {variant_name}");
276
277    // Header
278    if let Some(version) = &config.version {
279        out.push_str(&format!("{full_name} {version}\n"));
280    } else {
281        out.push_str(&format!("{full_name}\n"));
282    }
283
284    // Variant doc comment
285    if !variant.doc.is_empty() {
286        out.push('\n');
287        for line in variant.doc {
288            out.push_str(line.trim());
289            out.push('\n');
290        }
291    }
292
293    out.push('\n');
294
295    // Generate help for variant fields
296    // Handle tuple variant with single struct field (newtype pattern)
297    // e.g., `Build(BuildArgs)` should flatten BuildArgs fields
298    // This matches clap's behavior: "automatically flattened with a tuple-variant"
299    let fields = variant.data.fields;
300    if variant.data.kind == StructKind::TupleStruct && fields.len() == 1 {
301        let inner_shape = fields[0].shape();
302        if let Type::User(UserType::Struct(struct_type)) = inner_shape.ty {
303            // Use the inner struct's fields instead of the tuple field
304            generate_struct_help(&mut out, &full_name, struct_type.fields);
305            return out;
306        }
307    }
308
309    generate_struct_help(&mut out, &full_name, fields);
310
311    out
312}