Skip to main content

oxdock_parser/
strip_flags.rs

1use crate::ast::Arg;
2use crate::command::{CommandMeta, FlagValueType};
3use crate::error::{ParseError, SpanContext};
4
5/// Result of flag stripping: (extracted_flags, remaining_positional_args).
6pub type StrippedArgs = (Vec<(String, Arg)>, Vec<Arg>);
7
8/// Strip flags from raw arguments, returning (flags, positional_args).
9///
10/// Handles:
11/// - POSIX `--` terminator (stops flag scanning)
12/// - `--flag val` and `--flag=val` forms
13/// - Unknown flags are rejected when the command has registered flags
14/// - Unrecognized `--` on flag-less commands falls through as positional
15pub fn strip_flags(args: Vec<Arg>, meta: &CommandMeta) -> Result<StrippedArgs, ParseError> {
16    if meta.flags.is_empty() {
17        return Ok((Vec::new(), args));
18    }
19
20    let mut flags = Vec::new();
21    let mut positional = Vec::new();
22    let mut iter = args.into_iter();
23    while let Some(arg) = iter.next() {
24        match &arg {
25            // Quoted arguments are always positional, even if they start with `--`
26            Arg::String(_, true) => {
27                positional.push(arg);
28            }
29            // Expressions and mixed values are opaque to flag parsing.
30            Arg::Expr(_) | Arg::Parts(_) => {
31                positional.push(arg);
32            }
33            Arg::String(s, false) if s == "--" => {
34                positional.extend(iter);
35                break;
36            }
37            Arg::String(s, false) if s.starts_with("--") => {
38                let matched = meta.flags.iter().find(|f| {
39                    s == f.long
40                        || s.starts_with(f.long) && s.as_bytes().get(f.long.len()) == Some(&b'=')
41                });
42                match matched {
43                    Some(flag_meta) => {
44                        let value = if let Some(eq_pos) = s.find('=') {
45                            Arg::String(s[eq_pos + 1..].to_string(), false)
46                        } else if matches!(flag_meta.value_type, FlagValueType::Flag) {
47                            Arg::String("true".into(), false)
48                        } else {
49                            iter.next().ok_or_else(|| {
50                                ParseError::validation(
51                                    meta.name,
52                                    format!(
53                                        "invalid syntax for command {}: {} requires a value",
54                                        meta.name, flag_meta.long
55                                    ),
56                                    &SpanContext::line_only(0),
57                                )
58                            })?
59                        };
60                        flags.push((flag_meta.name.to_string(), value));
61                    }
62                    None => {
63                        return Err(ParseError::validation(
64                            meta.name,
65                            format!(
66                                "invalid syntax for command {}: unknown flag {}",
67                                meta.name, s
68                            ),
69                            &SpanContext::line_only(0),
70                        ));
71                    }
72                }
73            }
74            _ => positional.push(arg),
75        }
76    }
77    Ok((flags, positional))
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::command::FlagValueType;
84
85    fn test_meta(flags: &'static [crate::command::FlagSpec]) -> CommandMeta {
86        CommandMeta {
87            name: "TEST_CMD",
88            syntax: "TEST_CMD",
89            summary: "test",
90            description: "test",
91            args: &[],
92            flags,
93            default_output: None,
94            examples: &[],
95        }
96    }
97
98    #[test]
99    fn no_flags_passes_through() {
100        let meta = test_meta(&[]);
101        let args = vec![
102            Arg::String("hello".into(), false),
103            Arg::String("world".into(), false),
104        ];
105        let (flags, pos) = strip_flags(args, &meta).unwrap();
106        assert!(flags.is_empty());
107        assert_eq!(pos.len(), 2);
108    }
109
110    #[test]
111    fn posix_terminator_stops_scanning() {
112        let meta = test_meta(&[crate::command::FlagSpec {
113            name: "hash",
114            long: "--hash",
115            value_type: FlagValueType::String,
116            required: false,
117            description: "",
118        }]);
119        let args = vec![
120            Arg::String("--".into(), false),
121            Arg::String("--hash".into(), false),
122            Arg::String("abc123".into(), false),
123        ];
124        let (flags, pos) = strip_flags(args, &meta).unwrap();
125        assert!(flags.is_empty());
126        assert_eq!(pos.len(), 2);
127        assert_eq!(pos[0].as_str(), "--hash");
128        assert_eq!(pos[1].as_str(), "abc123");
129    }
130
131    #[test]
132    fn flag_with_separate_value() {
133        let meta = test_meta(&[crate::command::FlagSpec {
134            name: "hash",
135            long: "--hash",
136            value_type: FlagValueType::String,
137            required: false,
138            description: "",
139        }]);
140        let args = vec![
141            Arg::String("--hash".into(), false),
142            Arg::String("abc123".into(), false),
143            Arg::String("path.txt".into(), false),
144        ];
145        let (flags, pos) = strip_flags(args, &meta).unwrap();
146        assert_eq!(flags.len(), 1);
147        assert_eq!(flags[0].0, "hash");
148        assert_eq!(flags[0].1.as_str(), "abc123");
149        assert_eq!(pos.len(), 1);
150        assert_eq!(pos[0].as_str(), "path.txt");
151    }
152
153    #[test]
154    fn flag_with_attached_value() {
155        let meta = test_meta(&[crate::command::FlagSpec {
156            name: "hash",
157            long: "--hash",
158            value_type: FlagValueType::String,
159            required: false,
160            description: "",
161        }]);
162        let args = vec![
163            Arg::String("--hash=abc123".into(), false),
164            Arg::String("path.txt".into(), false),
165        ];
166        let (flags, pos) = strip_flags(args, &meta).unwrap();
167        assert_eq!(flags.len(), 1);
168        assert_eq!(flags[0].0, "hash");
169        assert_eq!(flags[0].1.as_str(), "abc123");
170        assert_eq!(pos.len(), 1);
171    }
172
173    #[test]
174    fn boolean_flag() {
175        let meta = test_meta(&[crate::command::FlagSpec {
176            name: "dirty",
177            long: "--include-dirty",
178            value_type: FlagValueType::Flag,
179            required: false,
180            description: "",
181        }]);
182        let args = vec![
183            Arg::String("--include-dirty".into(), false),
184            Arg::String("rev".into(), false),
185        ];
186        let (flags, pos) = strip_flags(args, &meta).unwrap();
187        assert_eq!(flags.len(), 1);
188        assert_eq!(flags[0].0, "dirty");
189        assert_eq!(flags[0].1.as_str(), "true");
190        assert_eq!(pos.len(), 1);
191    }
192
193    #[test]
194    fn unknown_flag_rejected() {
195        let meta = test_meta(&[crate::command::FlagSpec {
196            name: "hash",
197            long: "--hash",
198            value_type: FlagValueType::String,
199            required: false,
200            description: "",
201        }]);
202        let args = vec![
203            Arg::String("--hsh".into(), false),
204            Arg::String("val".into(), false),
205        ];
206        let result = strip_flags(args, &meta);
207        assert!(result.is_err());
208        assert!(result.unwrap_err().to_string().contains("unknown flag"));
209    }
210
211    #[test]
212    fn flag_missing_value_is_error() {
213        let meta = test_meta(&[crate::command::FlagSpec {
214            name: "hash",
215            long: "--hash",
216            value_type: FlagValueType::String,
217            required: false,
218            description: "",
219        }]);
220        let args = vec![Arg::String("--hash".into(), false)];
221        let result = strip_flags(args, &meta);
222        assert!(result.is_err());
223    }
224}