Skip to main content

fslite_command/
parser.rs

1//! Verb table: turns tokenized input (Task 3's [`crate::lexer`]) into a
2//! typed [`Command`] (Task 1). One arm per verb in the coverage table.
3
4use std::collections::HashMap;
5
6use fslite_core::{
7    ContentQuery, CopyOptions, CreateOptions, FindQuery, LinkTarget, MoveOptions, MutationOptions,
8    NodeKind, PageRequest, ReadOptions, RemoveOptions, Revision, StatOptions, TouchOptions,
9    TrashId, TreeOptions, VirtualPath, WriteOptions,
10};
11
12use crate::Command;
13use crate::lexer::{LexError, Token, tokenize};
14
15/// Why a line could not be parsed into a [`Command`].
16#[derive(Debug, Eq, PartialEq)]
17pub enum ParseError {
18    Lex(LexError),
19    UnknownVerb(String),
20    MissingArgument {
21        verb: &'static str,
22        name: &'static str,
23    },
24    InvalidArgument {
25        verb: &'static str,
26        name: &'static str,
27        reason: String,
28    },
29    UnknownFlag {
30        verb: &'static str,
31        flag: String,
32    },
33    /// More positional arguments were given than the verb accepts. Without
34    /// this check, extras are silently discarded by index-based lookup
35    /// (`Args::positional`) — e.g. `rm /a /b` would remove only `/a` and
36    /// report success, with `/b` vanishing with no error or warning. This
37    /// makes that fail loudly instead, matching the parser's design
38    /// principle of never guessing intent (the same principle behind
39    /// `check_known_flags` rejecting unknown flags).
40    TooManyArguments {
41        verb: &'static str,
42        expected: usize,
43        actual: usize,
44    },
45}
46
47impl From<LexError> for ParseError {
48    fn from(err: LexError) -> Self {
49        ParseError::Lex(err)
50    }
51}
52
53struct Args {
54    positionals: Vec<String>,
55    flags: HashMap<String, Option<String>>,
56}
57
58impl Args {
59    fn positional(
60        &self,
61        verb: &'static str,
62        index: usize,
63        name: &'static str,
64    ) -> Result<&str, ParseError> {
65        self.positionals
66            .get(index)
67            .map(String::as_str)
68            .ok_or(ParseError::MissingArgument { verb, name })
69    }
70
71    fn has_flag(&self, name: &str) -> bool {
72        self.flags.contains_key(name)
73    }
74
75    fn flag_value(&self, name: &str) -> Option<&str> {
76        self.flags.get(name).and_then(|v| v.as_deref())
77    }
78
79    fn check_known_flags(&self, verb: &'static str, known: &[&str]) -> Result<(), ParseError> {
80        for flag in self.flags.keys() {
81            if !known.contains(&flag.as_str()) {
82                return Err(ParseError::UnknownFlag {
83                    verb,
84                    flag: flag.clone(),
85                });
86            }
87        }
88        Ok(())
89    }
90
91    /// Rejects more positional arguments than `expected`. Deliberately does
92    /// *not* also reject too *few* — `Args::positional` already fails
93    /// loudly with a specific `MissingArgument { verb, name }` for that case
94    /// (naming exactly which argument is absent), which is more useful than
95    /// a generic count mismatch and is relied on by existing callers/tests.
96    /// This only closes the other gap: extras silently discarded past the
97    /// last index the verb ever reads.
98    fn check_positional_arity(
99        &self,
100        verb: &'static str,
101        expected: usize,
102    ) -> Result<(), ParseError> {
103        if self.positionals.len() > expected {
104            return Err(ParseError::TooManyArguments {
105                verb,
106                expected,
107                actual: self.positionals.len(),
108            });
109        }
110        Ok(())
111    }
112
113    fn expected_revision(&self, verb: &'static str) -> Result<Option<Revision>, ParseError> {
114        match self.flag_value("expected-revision") {
115            None => Ok(None),
116            Some(raw) => {
117                let value: u64 = raw.parse().map_err(|_| ParseError::InvalidArgument {
118                    verb,
119                    name: "expected-revision",
120                    reason: "must be a non-negative integer".into(),
121                })?;
122                Revision::new(value)
123                    .ok_or(ParseError::InvalidArgument {
124                        verb,
125                        name: "expected-revision",
126                        reason: "must be nonzero".into(),
127                    })
128                    .map(Some)
129            }
130        }
131    }
132
133    fn page(&self, verb: &'static str) -> Result<PageRequest, ParseError> {
134        let mut page = PageRequest::default();
135        if let Some(cursor) = self.flag_value("cursor") {
136            page = page.cursor(Some(cursor.to_string()));
137        }
138        if let Some(limit) = self.flag_value("limit") {
139            let limit: u32 = limit.parse().map_err(|_| ParseError::InvalidArgument {
140                verb,
141                name: "limit",
142                reason: "must be a non-negative integer".into(),
143            })?;
144            page = page.limit(limit);
145        }
146        Ok(page)
147    }
148}
149
150fn split(tokens: Vec<Token>) -> (Vec<String>, HashMap<String, Option<String>>) {
151    let mut positionals = Vec::new();
152    let mut flags = HashMap::new();
153    for token in tokens {
154        match token {
155            Token::Word(word) => positionals.push(word),
156            Token::Flag { name, value } => {
157                flags.insert(name, value);
158            }
159        }
160    }
161    (positionals, flags)
162}
163
164fn parse_path(
165    verb: &'static str,
166    name: &'static str,
167    raw: &str,
168) -> Result<VirtualPath, ParseError> {
169    VirtualPath::parse(raw).map_err(|e| ParseError::InvalidArgument {
170        verb,
171        name,
172        reason: e.message().to_string(),
173    })
174}
175
176/// Parses one line of `fslite-command` grammar into a [`Command`].
177pub fn parse(line: &str) -> Result<Command, ParseError> {
178    let tokens = tokenize(line)?;
179    let mut iter = tokens.into_iter();
180    let verb_token = iter.next().ok_or(ParseError::MissingArgument {
181        verb: "<line>",
182        name: "verb",
183    })?;
184    let verb = match verb_token {
185        Token::Word(w) => w,
186        Token::Flag { name, .. } => return Err(ParseError::UnknownVerb(format!("--{name}"))),
187    };
188    let (positionals, flags) = split(iter.collect());
189    let args = Args { positionals, flags };
190
191    match verb.as_str() {
192        "usage" => {
193            args.check_known_flags("usage", &[])?;
194            args.check_positional_arity("usage", 0)?;
195            Ok(Command::WorkspaceUsage)
196        }
197
198        "stat" => {
199            args.check_known_flags("stat", &["no-follow"])?;
200            args.check_positional_arity("stat", 1)?;
201            let path = parse_path("stat", "path", args.positional("stat", 0, "path")?)?;
202            let options = StatOptions::default().follow_symlinks(!args.has_flag("no-follow"));
203            Ok(Command::Stat { path, options })
204        }
205
206        "exists" => {
207            args.check_known_flags("exists", &["no-follow"])?;
208            args.check_positional_arity("exists", 1)?;
209            let path = parse_path("exists", "path", args.positional("exists", 0, "path")?)?;
210            let options = StatOptions::default().follow_symlinks(!args.has_flag("no-follow"));
211            Ok(Command::Exists { path, options })
212        }
213
214        "ls" => {
215            args.check_known_flags("ls", &["cursor", "limit"])?;
216            args.check_positional_arity("ls", 1)?;
217            let path = parse_path("ls", "path", args.positional("ls", 0, "path")?)?;
218            Ok(Command::ReadDir {
219                path,
220                page: args.page("ls")?,
221            })
222        }
223
224        "tree" => {
225            args.check_known_flags("tree", &["max-depth", "follow-symlinks", "cursor", "limit"])?;
226            args.check_positional_arity("tree", 1)?;
227            let path = parse_path("tree", "path", args.positional("tree", 0, "path")?)?;
228            let max_depth = args
229                .flag_value("max-depth")
230                .map(|v| {
231                    v.parse().map_err(|_| ParseError::InvalidArgument {
232                        verb: "tree",
233                        name: "max-depth",
234                        reason: "must be a non-negative integer".into(),
235                    })
236                })
237                .transpose()?;
238            let options = TreeOptions::default()
239                .max_depth(max_depth)
240                .follow_symlinks(args.has_flag("follow-symlinks"));
241            Ok(Command::Tree {
242                path,
243                options,
244                page: args.page("tree")?,
245            })
246        }
247
248        "mkdir" => {
249            args.check_known_flags("mkdir", &["parents", "exist-ok", "expected-revision"])?;
250            args.check_positional_arity("mkdir", 1)?;
251            let path = parse_path("mkdir", "path", args.positional("mkdir", 0, "path")?)?;
252            let options = CreateOptions::default()
253                .parents(args.has_flag("parents"))
254                .exist_ok(args.has_flag("exist-ok"))
255                .expected_revision(args.expected_revision("mkdir")?);
256            Ok(Command::Mkdir { path, options })
257        }
258
259        "cat" => {
260            args.check_known_flags("cat", &["range", "no-follow"])?;
261            args.check_positional_arity("cat", 1)?;
262            let path = parse_path("cat", "path", args.positional("cat", 0, "path")?)?;
263            let range = args
264                .flag_value("range")
265                .map(|raw| {
266                    let (start, end) = raw.split_once('-').ok_or(ParseError::InvalidArgument {
267                        verb: "cat",
268                        name: "range",
269                        reason: "expected START-END".into(),
270                    })?;
271                    let start: u64 = start.parse().map_err(|_| ParseError::InvalidArgument {
272                        verb: "cat",
273                        name: "range",
274                        reason: "invalid start".into(),
275                    })?;
276                    let end: u64 = end.parse().map_err(|_| ParseError::InvalidArgument {
277                        verb: "cat",
278                        name: "range",
279                        reason: "invalid end".into(),
280                    })?;
281                    Ok::<_, ParseError>(fslite_core::ByteRange::new(start, end))
282                })
283                .transpose()?;
284            let options = ReadOptions::default()
285                .range(range)
286                .follow_symlinks(!args.has_flag("no-follow"));
287            Ok(Command::Read { path, options })
288        }
289
290        "write" => {
291            args.check_known_flags("write", &["text", "no-create", "expected-revision"])?;
292            args.check_positional_arity("write", 1)?;
293            let path = parse_path("write", "path", args.positional("write", 0, "path")?)?;
294            let bytes = args
295                .flag_value("text")
296                .map(|s| s.as_bytes().to_vec())
297                .ok_or(ParseError::MissingArgument {
298                    verb: "write",
299                    name: "--text (or another payload source)",
300                })?;
301            let options = WriteOptions::default()
302                .create(!args.has_flag("no-create"))
303                .expected_revision(args.expected_revision("write")?);
304            Ok(Command::Write {
305                path,
306                bytes,
307                options,
308            })
309        }
310
311        "write-at" => {
312            args.check_known_flags(
313                "write-at",
314                &["offset", "text", "no-create", "expected-revision"],
315            )?;
316            args.check_positional_arity("write-at", 1)?;
317            let path = parse_path("write-at", "path", args.positional("write-at", 0, "path")?)?;
318            let offset: u64 = args
319                .flag_value("offset")
320                .ok_or(ParseError::MissingArgument {
321                    verb: "write-at",
322                    name: "--offset",
323                })?
324                .parse()
325                .map_err(|_| ParseError::InvalidArgument {
326                    verb: "write-at",
327                    name: "offset",
328                    reason: "must be a non-negative integer".into(),
329                })?;
330            let bytes = args
331                .flag_value("text")
332                .map(|s| s.as_bytes().to_vec())
333                .ok_or(ParseError::MissingArgument {
334                    verb: "write-at",
335                    name: "--text",
336                })?;
337            let options = WriteOptions::default()
338                .create(!args.has_flag("no-create"))
339                .expected_revision(args.expected_revision("write-at")?);
340            Ok(Command::WriteAt {
341                path,
342                offset,
343                bytes,
344                options,
345            })
346        }
347
348        "append" => {
349            args.check_known_flags("append", &["text", "expected-revision"])?;
350            args.check_positional_arity("append", 1)?;
351            let path = parse_path("append", "path", args.positional("append", 0, "path")?)?;
352            let bytes = args
353                .flag_value("text")
354                .map(|s| s.as_bytes().to_vec())
355                .ok_or(ParseError::MissingArgument {
356                    verb: "append",
357                    name: "--text",
358                })?;
359            let options =
360                WriteOptions::default().expected_revision(args.expected_revision("append")?);
361            Ok(Command::Append {
362                path,
363                bytes,
364                options,
365            })
366        }
367
368        "truncate" => {
369            args.check_known_flags("truncate", &["length", "expected-revision"])?;
370            args.check_positional_arity("truncate", 1)?;
371            let path = parse_path("truncate", "path", args.positional("truncate", 0, "path")?)?;
372            let length: u64 = args
373                .flag_value("length")
374                .ok_or(ParseError::MissingArgument {
375                    verb: "truncate",
376                    name: "--length",
377                })?
378                .parse()
379                .map_err(|_| ParseError::InvalidArgument {
380                    verb: "truncate",
381                    name: "length",
382                    reason: "must be a non-negative integer".into(),
383                })?;
384            let options =
385                MutationOptions::default().expected_revision(args.expected_revision("truncate")?);
386            Ok(Command::Truncate {
387                path,
388                length,
389                options,
390            })
391        }
392
393        "touch" => {
394            args.check_known_flags("touch", &["no-create", "expected-revision"])?;
395            args.check_positional_arity("touch", 1)?;
396            let path = parse_path("touch", "path", args.positional("touch", 0, "path")?)?;
397            let options = TouchOptions::default()
398                .create(!args.has_flag("no-create"))
399                .expected_revision(args.expected_revision("touch")?);
400            Ok(Command::Touch { path, options })
401        }
402
403        "cp" => {
404            args.check_known_flags("cp", &["recursive", "overwrite", "expected-revision"])?;
405            args.check_positional_arity("cp", 2)?;
406            let from = parse_path("cp", "from", args.positional("cp", 0, "from")?)?;
407            let to = parse_path("cp", "to", args.positional("cp", 1, "to")?)?;
408            let options = CopyOptions::default()
409                .recursive(args.has_flag("recursive"))
410                .overwrite(args.has_flag("overwrite"))
411                .expected_revision(args.expected_revision("cp")?);
412            Ok(Command::Copy { from, to, options })
413        }
414
415        "mv" => {
416            args.check_known_flags("mv", &["overwrite", "expected-revision"])?;
417            args.check_positional_arity("mv", 2)?;
418            let from = parse_path("mv", "from", args.positional("mv", 0, "from")?)?;
419            let to = parse_path("mv", "to", args.positional("mv", 1, "to")?)?;
420            let options = MoveOptions::default()
421                .overwrite(args.has_flag("overwrite"))
422                .expected_revision(args.expected_revision("mv")?);
423            Ok(Command::Move { from, to, options })
424        }
425
426        "rm" => {
427            args.check_known_flags("rm", &["recursive", "expected-revision"])?;
428            args.check_positional_arity("rm", 1)?;
429            let path = parse_path("rm", "path", args.positional("rm", 0, "path")?)?;
430            let options = RemoveOptions::default()
431                .recursive(args.has_flag("recursive"))
432                .expected_revision(args.expected_revision("rm")?);
433            Ok(Command::Remove { path, options })
434        }
435
436        "ln" => {
437            args.check_known_flags("ln", &["parents", "exist-ok", "expected-revision"])?;
438            args.check_positional_arity("ln", 2)?;
439            let target_raw = args.positional("ln", 0, "target")?;
440            let link_raw = args.positional("ln", 1, "link")?;
441            let target =
442                LinkTarget::parse(target_raw).map_err(|e| ParseError::InvalidArgument {
443                    verb: "ln",
444                    name: "target",
445                    reason: e.message().to_string(),
446                })?;
447            let link = parse_path("ln", "link", link_raw)?;
448            let options = CreateOptions::default()
449                .parents(args.has_flag("parents"))
450                .exist_ok(args.has_flag("exist-ok"))
451                .expected_revision(args.expected_revision("ln")?);
452            Ok(Command::Symlink {
453                target,
454                link,
455                options,
456            })
457        }
458
459        "readlink" => {
460            args.check_known_flags("readlink", &[])?;
461            args.check_positional_arity("readlink", 1)?;
462            let path = parse_path("readlink", "path", args.positional("readlink", 0, "path")?)?;
463            Ok(Command::ReadLink { path })
464        }
465
466        "trash" => {
467            args.check_known_flags("trash", &["expected-revision"])?;
468            args.check_positional_arity("trash", 1)?;
469            let path = parse_path("trash", "path", args.positional("trash", 0, "path")?)?;
470            let options =
471                MutationOptions::default().expected_revision(args.expected_revision("trash")?);
472            Ok(Command::Trash { path, options })
473        }
474
475        "trash-ls" => {
476            args.check_known_flags("trash-ls", &["cursor", "limit"])?;
477            args.check_positional_arity("trash-ls", 0)?;
478            Ok(Command::ListTrash {
479                page: args.page("trash-ls")?,
480            })
481        }
482
483        "restore" => {
484            args.check_known_flags("restore", &["to", "expected-revision"])?;
485            args.check_positional_arity("restore", 1)?;
486            let raw_id = args.positional("restore", 0, "trash-id")?;
487            let trash = TrashId::parse(raw_id).map_err(|_| ParseError::InvalidArgument {
488                verb: "restore",
489                name: "trash-id",
490                reason: "not a valid id".into(),
491            })?;
492            let destination = args
493                .flag_value("to")
494                .map(|raw| parse_path("restore", "to", raw))
495                .transpose()?;
496            let options =
497                MutationOptions::default().expected_revision(args.expected_revision("restore")?);
498            Ok(Command::Restore {
499                trash,
500                destination,
501                options,
502            })
503        }
504
505        "purge" => {
506            args.check_known_flags("purge", &[])?;
507            args.check_positional_arity("purge", 1)?;
508            let raw_id = args.positional("purge", 0, "trash-id")?;
509            let trash = TrashId::parse(raw_id).map_err(|_| ParseError::InvalidArgument {
510                verb: "purge",
511                name: "trash-id",
512                reason: "not a valid id".into(),
513            })?;
514            Ok(Command::Purge { trash })
515        }
516
517        "setattr" => {
518            args.check_known_flags("setattr", &["value", "expected-revision"])?;
519            args.check_positional_arity("setattr", 2)?;
520            let path = parse_path("setattr", "path", args.positional("setattr", 0, "path")?)?;
521            let key = args.positional("setattr", 1, "key")?.to_string();
522            let value = args
523                .flag_value("value")
524                .map(|s| s.as_bytes().to_vec())
525                .ok_or(ParseError::MissingArgument {
526                    verb: "setattr",
527                    name: "--value",
528                })?;
529            let options =
530                MutationOptions::default().expected_revision(args.expected_revision("setattr")?);
531            Ok(Command::SetAttribute {
532                path,
533                key,
534                value,
535                options,
536            })
537        }
538
539        "rmattr" => {
540            args.check_known_flags("rmattr", &["expected-revision"])?;
541            args.check_positional_arity("rmattr", 2)?;
542            let path = parse_path("rmattr", "path", args.positional("rmattr", 0, "path")?)?;
543            let key = args.positional("rmattr", 1, "key")?.to_string();
544            let options =
545                MutationOptions::default().expected_revision(args.expected_revision("rmattr")?);
546            Ok(Command::RemoveAttribute { path, key, options })
547        }
548
549        "glob" => {
550            args.check_known_flags("glob", &["cursor", "limit"])?;
551            args.check_positional_arity("glob", 1)?;
552            let pattern = args.positional("glob", 0, "pattern")?.to_string();
553            Ok(Command::Glob {
554                pattern,
555                page: args.page("glob")?,
556            })
557        }
558
559        "find" => {
560            args.check_known_flags(
561                "find",
562                &[
563                    "name-contains",
564                    "kind",
565                    "min-size",
566                    "max-size",
567                    "modified-after",
568                    "modified-before",
569                    "cursor",
570                    "limit",
571                ],
572            )?;
573            args.check_positional_arity("find", 1)?;
574            let root = parse_path("find", "root", args.positional("find", 0, "root")?)?;
575            let kind = args
576                .flag_value("kind")
577                .map(|k| match k {
578                    "file" => Ok(NodeKind::File),
579                    "directory" => Ok(NodeKind::Directory),
580                    "symlink" => Ok(NodeKind::Symlink),
581                    other => Err(ParseError::InvalidArgument {
582                        verb: "find",
583                        name: "kind",
584                        reason: format!("unknown kind `{other}`"),
585                    }),
586                })
587                .transpose()?;
588            let min_logical_size = args
589                .flag_value("min-size")
590                .map(|v| {
591                    v.parse().map_err(|_| ParseError::InvalidArgument {
592                        verb: "find",
593                        name: "min-size",
594                        reason: "must be a non-negative integer".into(),
595                    })
596                })
597                .transpose()?;
598            let max_logical_size = args
599                .flag_value("max-size")
600                .map(|v| {
601                    v.parse().map_err(|_| ParseError::InvalidArgument {
602                        verb: "find",
603                        name: "max-size",
604                        reason: "must be a non-negative integer".into(),
605                    })
606                })
607                .transpose()?;
608            let modified_after_ms = args
609                .flag_value("modified-after")
610                .map(|v| {
611                    v.parse().map_err(|_| ParseError::InvalidArgument {
612                        verb: "find",
613                        name: "modified-after",
614                        reason: "must be an integer".into(),
615                    })
616                })
617                .transpose()?;
618            let modified_before_ms = args
619                .flag_value("modified-before")
620                .map(|v| {
621                    v.parse().map_err(|_| ParseError::InvalidArgument {
622                        verb: "find",
623                        name: "modified-before",
624                        reason: "must be an integer".into(),
625                    })
626                })
627                .transpose()?;
628            let query = FindQuery::default()
629                .root(root)
630                .name_contains(args.flag_value("name-contains").map(str::to_string))
631                .kind(kind)
632                .min_logical_size(min_logical_size)
633                .max_logical_size(max_logical_size)
634                .modified_after_ms(modified_after_ms)
635                .modified_before_ms(modified_before_ms);
636            Ok(Command::Find {
637                query,
638                page: args.page("find")?,
639            })
640        }
641
642        "grep" => {
643            args.check_known_flags("grep", &["cursor", "limit"])?;
644            args.check_positional_arity("grep", 2)?;
645            let root = parse_path("grep", "root", args.positional("grep", 0, "root")?)?;
646            let needle = args.positional("grep", 1, "needle")?.as_bytes().to_vec();
647            let query = ContentQuery::default().root(root).needle(needle);
648            Ok(Command::SearchContent {
649                query,
650                page: args.page("grep")?,
651            })
652        }
653
654        "changes" => {
655            args.check_known_flags("changes", &["after", "cursor", "limit"])?;
656            args.check_positional_arity("changes", 0)?;
657            let after = args
658                .flag_value("after")
659                .map(|raw| fslite_core::ChangeCursor::new(raw.to_string()));
660            Ok(Command::Changes {
661                after,
662                page: args.page("changes")?,
663            })
664        }
665
666        "batch" => {
667            args.check_known_flags("batch", &["file"])?;
668            args.check_positional_arity("batch", 0)?;
669            let file = args.flag_value("file").ok_or(ParseError::MissingArgument {
670                verb: "batch",
671                name: "--file",
672            })?;
673            let contents =
674                std::fs::read_to_string(file).map_err(|e| ParseError::InvalidArgument {
675                    verb: "batch",
676                    name: "file",
677                    reason: e.to_string(),
678                })?;
679            let operations: Vec<fslite_core::BatchOperation> = serde_json::from_str(&contents)
680                .map_err(|e| ParseError::InvalidArgument {
681                    verb: "batch",
682                    name: "file",
683                    reason: e.to_string(),
684                })?;
685            Ok(Command::Batch(operations))
686        }
687
688        other => Err(ParseError::UnknownVerb(other.to_string())),
689    }
690}