Skip to content

Commit 685825c

Browse files
committed
add support for positional arguments
1 parent d109a32 commit 685825c

22 files changed

+587
-34
lines changed

REFERENCE_derive_conf.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The `#[conf(...)]` attributes conform to [Rust’s structured attribute conventi
2121
* [Parameter](#parameter)
2222
* [short](#parameter-short)
2323
* [long](#parameter-long)
24+
* [pos](#parameter-pos)
2425
* [env](#parameter-env)
2526
* [aliases](#parameter-aliases)
2627
* [env_aliases](#parameter-env-aliases)
@@ -238,6 +239,81 @@ A parameter represents a single value that can be parsed from a string.
238239

239240
*Note*: This behavior is the same as in `clap-derive`.
240241

242+
* <a name="parameter-pos"></a> `pos` (no arguments)
243+
244+
Specifies that this parameter is a positional argument.
245+
Positional arguments are identified by their position in the command line, not by a flag name.
246+
The order of positional arguments is determined by the order of fields in the struct.
247+
248+
example: `#[arg(pos)]`
249+
250+
example command-line: `./my_prog input.txt output.txt` where the first positional is assigned to the first `pos` field, the second to the second `pos` field, etc.
251+
252+
**Positional argument filling**:
253+
- Positional arguments are filled left-to-right based on their declaration order in the struct
254+
- You cannot skip a positional argument to provide a later one
255+
- If you have multiple optional positionals and provide fewer arguments, they fill from left to right
256+
257+
**Optional positionals**:
258+
- A positional parameter can be optional by using `Option<T>` as the field type
259+
- All optional positional arguments must come after all required positional arguments
260+
- The parser will panic at construction time if a required positional follows an optional positional
261+
262+
**Compatibility**:
263+
- `pos` is mutually exclusive with `short` and `long` (you cannot have a positional argument with flag names)
264+
- `pos` is compatible with `env` (the value can be provided via environment variable or positional argument)
265+
- `pos` is supported in regular `flatten` and in subcommands
266+
- `pos` is NOT supported in `flatten` with `Option<T>` (flatten optional) - this will produce an error
267+
268+
**Examples**:
269+
270+
Valid configuration:
271+
```rust
272+
#[derive(Conf)]
273+
struct MyConfig {
274+
/// Input file (required positional)
275+
#[conf(pos)]
276+
input: String,
277+
278+
/// Output file (required positional)
279+
#[conf(pos)]
280+
output: String,
281+
282+
/// Optional log file (optional positional - OK because it's at the end)
283+
#[conf(pos)]
284+
log_file: Option<String>,
285+
}
286+
```
287+
288+
Command-line usage: `./my_prog input.txt output.txt` or `./my_prog input.txt output.txt debug.log`
289+
290+
Invalid configuration (will panic):
291+
```rust
292+
#[derive(Conf)]
293+
struct BadConfig {
294+
#[conf(pos)]
295+
first: String,
296+
297+
#[conf(pos)]
298+
second: Option<String>, // optional
299+
300+
#[conf(pos)]
301+
third: String, // ERROR: required after optional!
302+
}
303+
```
304+
305+
Positional with environment variable fallback:
306+
```rust
307+
#[derive(Conf)]
308+
struct MyConfig {
309+
/// Can be provided as first positional arg or via INPUT env var
310+
#[conf(pos, env)]
311+
input: String,
312+
}
313+
```
314+
315+
Command-line usage: `./my_prog input.txt` or `INPUT=input.txt ./my_prog`
316+
241317
* <a name="parameter-env"></a> `env` (optional string argument)
242318

243319
Specifies an environment variable associated to this parameter.

conf_derive/src/proc_macro_options/field_item/flag_item.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ impl FlagItem {
212212
is_required: false,
213213
allow_hyphen_values: false,
214214
secret: Some(false),
215+
is_positional: false,
215216
});
216217
})
217218
}

conf_derive/src/proc_macro_options/field_item/flatten_item.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,29 @@ impl FlattenItem {
245245

246246
// The initializer simply gets all program options, modifies as needed,
247247
// and then checks for a skip-short error.
248+
let positional_check = if self.is_optional_type.is_some() {
249+
quote! {
250+
// Check for positional args in flatten optional
251+
for opt in __inner_options__ {
252+
if opt.is_positional {
253+
return Err(::conf::Error::positional_in_flatten_optional(
254+
#field_name,
255+
<#inner_type as ::conf::Conf>::get_name(),
256+
&opt.id
257+
));
258+
}
259+
}
260+
}
261+
} else {
262+
quote! {}
263+
};
264+
248265
let push_expr = quote! {
249266
let mut #was_skipped_ident = [false; #skip_short_len];
267+
let __inner_options__ = <#inner_type as ::conf::Conf>::get_program_options()?;
268+
#positional_check
250269
#program_options_ident.extend(
251-
<#inner_type as ::conf::Conf>::get_program_options()?.iter().cloned().map(
270+
__inner_options__.iter().cloned().map(
252271
|program_option|
253272
program_option
254273
#modify_program_option

conf_derive/src/proc_macro_options/field_item/parameter_item.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub struct ParameterItem {
7171
value_parser: Option<Expr>,
7272
serde: Option<ParameterSerdeItem>,
7373
doc_string: Option<String>,
74+
is_positional: bool,
7475
}
7576

7677
impl ParameterItem {
@@ -99,6 +100,7 @@ impl ParameterItem {
99100
value_parser: None,
100101
serde: None,
101102
doc_string: None,
103+
is_positional: false,
102104
};
103105

104106
for attr in &field.attrs {
@@ -168,25 +170,45 @@ impl ParameterItem {
168170
&mut result.serde,
169171
Some(ParameterSerdeItem::new(meta)?),
170172
)
173+
} else if path.is_ident("pos") {
174+
result.is_positional = true;
175+
Ok(())
171176
} else {
172177
Err(meta.error("unrecognized conf parameter option"))
173178
}
174179
})?;
175180
}
176181
}
177182

183+
// Validate positional argument constraints
184+
if result.is_positional {
185+
if result.short_switch.is_some() {
186+
return Err(Error::new(
187+
field.span(),
188+
"#[conf(pos)] cannot be used with #[conf(short)]",
189+
));
190+
}
191+
if result.long_switch.is_some() {
192+
return Err(Error::new(
193+
field.span(),
194+
"#[conf(pos)] cannot be used with #[conf(long)]",
195+
));
196+
}
197+
}
198+
178199
if result.is_optional_type.is_none()
179200
&& result.short_switch.is_none()
180201
&& result.long_switch.is_none()
181202
&& result.env_name.is_none()
182203
&& result.default_value.is_none()
204+
&& !result.is_positional
183205
&& struct_item.serde.is_none()
184206
{
185207
return Err(Error::new(
186208
field.span(),
187209
"There is no way for the user to give this parameter a value. \
188-
Trying using #[arg(short)], #[arg(long)], or #[arg(env)] to specify a switch \
189-
or an env associated to this value, or specify a default value.",
210+
Trying using #[arg(short)], #[arg(long)], #[arg(env)], or #[arg(pos)] to specify a switch, \
211+
positional argument, or an env associated to this value, or specify a default value.",
190212
));
191213
}
192214

@@ -276,6 +298,7 @@ impl ParameterItem {
276298
let default_value = quote_opt_into(&self.default_value);
277299
let allow_hyphen_values = self.allow_hyphen_values;
278300
let secret = quote_opt(&self.secret);
301+
let is_positional = self.is_positional;
279302

280303
Ok(quote! {
281304
#program_options_ident.push(::conf::ProgramOption {
@@ -291,6 +314,7 @@ impl ParameterItem {
291314
is_required: #is_required,
292315
allow_hyphen_values: #allow_hyphen_values,
293316
secret: #secret,
317+
is_positional: #is_positional,
294318
});
295319
})
296320
}

conf_derive/src/proc_macro_options/field_item/repeat_item.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ impl RepeatItem {
293293
is_required: false,
294294
allow_hyphen_values: #allow_hyphen_values,
295295
secret: #secret,
296+
is_positional: false,
296297
});
297298
})
298299
}

examples/serde/figment.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use conf::Conf;
22
use figment::{
3+
Figment,
34
providers::{Format, Json, Toml},
45
value::Value,
5-
Figment,
66
};
77
use std::env;
88

src/builder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{parse_env, Conf, ConfContext, Error, InnerError, ParsedArgs, ParsedEnv};
1+
use crate::{Conf, ConfContext, Error, InnerError, ParsedArgs, ParsedEnv, parse_env};
22
use std::{ffi::OsString, marker::PhantomData};
33

44
/// A builder which collects config value sources for the parse.

src/conf_context.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{str_to_bool, InnerError, ParseType, ParsedArgs, ParsedEnv, ProgramOption};
1+
use crate::{InnerError, ParseType, ParsedArgs, ParsedEnv, ProgramOption, str_to_bool};
22
use clap::parser::ValueSource;
33
use core::fmt::Debug;
44

@@ -154,7 +154,7 @@ impl<'a> ConfContext<'a> {
154154
self.args.id_to_option()
155155
)
156156
});
157-
if opt.short_form.is_some() || opt.long_form.is_some() {
157+
if opt.short_form.is_some() || opt.long_form.is_some() || opt.is_positional {
158158
if let Some(val) = self.args.arg_matches.get_one::<String>(&id) {
159159
let value_source = self
160160
.args

src/error.rs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{ConfValueSource, FlattenedOptionalDebugInfo, ProgramOption};
2-
use clap::{builder::Styles, error::ErrorKind, Command, Error as ClapError};
2+
use clap::{Command, Error as ClapError, builder::Styles, error::ErrorKind};
33
use std::{ffi::OsString, fmt, fmt::Write};
44

55
/// An error which occurs when a `Conf::parse` function is called.
@@ -42,9 +42,24 @@ impl Error {
4242
field_name: &'static str,
4343
field_type_name: &'static str,
4444
) -> Self {
45-
let buf = format!("Internal error (invalid skip short)\n When flattening {field_type_name} at {field_name}, these short options were not found: {not_found_chars:?}\n To fix this error, remove them from the skip_short attribute list.");
45+
let buf = format!(
46+
"Internal error (invalid skip short)\n When flattening {field_type_name} at {field_name}, these short options were not found: {not_found_chars:?}\n To fix this error, remove them from the skip_short attribute list."
47+
);
4648
ClapError::raw(ErrorKind::UnknownArgument, buf).into()
4749
}
50+
51+
// An error reported when positional arguments are used in flatten optional
52+
#[doc(hidden)]
53+
pub fn positional_in_flatten_optional(
54+
field_name: &str,
55+
field_type_name: &str,
56+
option_id: &str,
57+
) -> Self {
58+
let buf = format!(
59+
"Cannot use flatten optional with struct '{field_type_name}' at field '{field_name}' because it contains positional argument '{option_id}'. Positional arguments are not supported in flatten optional structs (but are supported in regular flatten)."
60+
);
61+
ClapError::raw(ErrorKind::ArgumentConflict, buf).into()
62+
}
4863
}
4964

5065
impl From<ClapError> for Error {
@@ -378,7 +393,10 @@ impl InnerError {
378393
instance_id_prefix.insert_str(0, " @ .");
379394
remove_trailing_dot(&mut instance_id_prefix);
380395
}
381-
writeln!(stream, " One of these must be provided: (constraint on {struct_name}{instance_id_prefix}): ")?;
396+
writeln!(
397+
stream,
398+
" One of these must be provided: (constraint on {struct_name}{instance_id_prefix}): "
399+
)?;
382400
for opt in single_opts {
383401
write!(stream, " ")?;
384402
print_opt_requirements(stream, opt, "")?;
@@ -402,7 +420,10 @@ impl InnerError {
402420
instance_id_prefix.insert_str(0, " @ .");
403421
remove_trailing_dot(&mut instance_id_prefix);
404422
}
405-
writeln!(stream, " Too many arguments, provide at most one of these: (constraint on {struct_name}{instance_id_prefix}): ")?;
423+
writeln!(
424+
stream,
425+
" Too many arguments, provide at most one of these: (constraint on {struct_name}{instance_id_prefix}): "
426+
)?;
406427
for (opt, source) in single_opts {
407428
let provided_opt = render_provided_opt(opt, source);
408429
writeln!(stream, " {provided_opt}")?;
@@ -474,6 +495,23 @@ fn print_opt_requirements(
474495
opt: &ProgramOption,
475496
trailing_text: &str,
476497
) -> fmt::Result {
498+
// Handle positional arguments
499+
if opt.is_positional {
500+
let pos_name = format!("<{}>", opt.id);
501+
match opt.env_form.as_deref() {
502+
Some(name) => {
503+
let trailing_text = if trailing_text.is_empty() {
504+
"".to_owned()
505+
} else {
506+
", ".to_owned() + trailing_text
507+
};
508+
writeln!(stream, " env '{name}', or '{pos_name}'{trailing_text}")?
509+
}
510+
None => writeln!(stream, " '{pos_name}' {trailing_text}")?,
511+
}
512+
return Ok(());
513+
}
514+
477515
let maybe_switch = render_help_switch(opt);
478516
match (maybe_switch, opt.env_form.as_deref()) {
479517
(Some(switch), Some(name)) => {
@@ -487,7 +525,10 @@ fn print_opt_requirements(
487525
(Some(switch), None) => writeln!(stream, " '{switch}' {trailing_text}")?,
488526
(None, Some(name)) => writeln!(stream, " env '{name}' {trailing_text}")?,
489527
(None, None) => {
490-
debug_assert!(false, "This should be unreachable, we should not be printing opt requirements for an option with no way to specify it");
528+
debug_assert!(
529+
false,
530+
"This should be unreachable, we should not be printing opt requirements for an option with no way to specify it"
531+
);
491532
writeln!(
492533
stream,
493534
" There is no way to provide this value, this is an internal error ({id})",

0 commit comments

Comments
 (0)