Skip to main content

dynamic_cli/parser/
repl_parser.rs

1//! REPL line parser
2//!
3//! This module provides the [`ReplParser`] which parses interactive REPL
4//! command lines. It works with the [`CommandRegistry`] to resolve command
5//! names and aliases, then delegates to [`CliParser`] for argument parsing.
6//!
7//! # Example
8//!
9//! ```
10//! use dynamic_cli::parser::repl_parser::ReplParser;
11//! use dynamic_cli::registry::CommandRegistry;
12//! use dynamic_cli::config::schema::{CommandDefinition, ArgumentType};
13//! use dynamic_cli::executor::CommandHandler;
14//! use dynamic_cli::context::ExecutionContext;
15//! use std::collections::HashMap;
16//!
17//! // Create registry
18//! let mut registry = CommandRegistry::new();
19//!
20//! // Register a command
21//! let definition = CommandDefinition {
22//!     name: "hello".to_string(),
23//!     aliases: vec!["hi".to_string()],
24//!     description: "Say hello".to_string(),
25//!     required: false,
26//!     arguments: vec![],
27//!     options: vec![],
28//!     implementation: "handler".to_string(),
29//! };
30//!
31//! // Dummy handler for example
32//! struct DummyHandler;
33//! impl CommandHandler for DummyHandler {
34//!     fn execute(
35//!         &self,
36//!         _context: &mut dyn ExecutionContext,
37//!         _args: &HashMap<String, String>,
38//!     ) -> dynamic_cli::error::Result<()> {
39//!         Ok(())
40//!     }
41//! }
42//!
43//! registry.register(definition, Box::new(DummyHandler)).unwrap();
44//!
45//! // Parse a REPL line
46//! let parser = ReplParser::new(&registry);
47//! let parsed = parser.parse_line("hi").unwrap();
48//! assert_eq!(parsed.command_name, "hello");
49//! ```
50
51use crate::error::{ParseError, Result};
52use crate::parser::cli_parser::CliParser;
53use crate::registry::CommandRegistry;
54use std::collections::HashMap;
55
56/// REPL line parser
57///
58/// Parses interactive command lines in REPL mode. The parser:
59/// 1. Splits the line into command name and arguments
60/// 2. Resolves the command name (including aliases) via the registry
61/// 3. Delegates to [`CliParser`] for argument parsing
62///
63/// # Lifetime
64///
65/// Holds a reference to a [`CommandRegistry`] and therefore has a
66/// lifetime parameter `'a`.
67///
68/// # Example
69///
70/// ```no_run
71/// use dynamic_cli::parser::repl_parser::ReplParser;
72/// use dynamic_cli::registry::CommandRegistry;
73///
74/// let registry = CommandRegistry::new();
75/// let parser = ReplParser::new(&registry);
76///
77/// // Parse various command formats
78/// let parsed = parser.parse_line("command arg1 arg2").unwrap();
79/// let parsed = parser.parse_line("cmd --option value").unwrap();
80/// let parsed = parser.parse_line("alias -v").unwrap();
81/// ```
82pub struct ReplParser<'a> {
83    /// Reference to the command registry for name resolution
84    registry: &'a CommandRegistry,
85}
86
87/// Parsed REPL command
88///
89/// Contains the resolved command name and parsed arguments.
90/// This structure is the output of [`ReplParser::parse_line`].
91///
92/// # Fields
93///
94/// - `command_name`: The canonical command name (aliases are resolved)
95/// - `arguments`: HashMap of argument/option names to their string values
96///
97/// # Example
98///
99/// ```
100/// use dynamic_cli::parser::repl_parser::ParsedCommand;
101/// use std::collections::HashMap;
102///
103/// let mut args = HashMap::new();
104/// args.insert("input".to_string(), "file.txt".to_string());
105///
106/// let parsed = ParsedCommand {
107///     command_name: "process".to_string(),
108///     arguments: args,
109/// };
110///
111/// assert_eq!(parsed.command_name, "process");
112/// assert_eq!(parsed.arguments.get("input"), Some(&"file.txt".to_string()));
113/// ```
114#[derive(Debug, Clone, PartialEq)]
115pub struct ParsedCommand {
116    /// The canonical command name (after alias resolution)
117    pub command_name: String,
118
119    /// Parsed arguments and options
120    pub arguments: HashMap<String, String>,
121}
122
123impl<'a> ReplParser<'a> {
124    /// Create a new REPL parser with the given registry
125    ///
126    /// # Arguments
127    ///
128    /// * `registry` - The command registry for resolving command names
129    ///
130    /// # Example
131    ///
132    /// ```
133    /// use dynamic_cli::parser::repl_parser::ReplParser;
134    /// use dynamic_cli::registry::CommandRegistry;
135    ///
136    /// let registry = CommandRegistry::new();
137    /// let parser = ReplParser::new(&registry);
138    /// ```
139    pub fn new(registry: &'a CommandRegistry) -> Self {
140        Self { registry }
141    }
142
143    /// Parse a REPL command line
144    ///
145    /// Parses a complete command line as entered in the REPL.
146    /// The line is split into tokens, the first token is resolved as
147    /// a command name (or alias), and remaining tokens are parsed
148    /// as arguments and options.
149    ///
150    /// # Arguments
151    ///
152    /// * `line` - The command line to parse
153    ///
154    /// # Returns
155    ///
156    /// A [`ParsedCommand`] containing the command name and parsed arguments
157    ///
158    /// # Errors
159    ///
160    /// - [`ParseError::UnknownCommand`] if the command is not registered
161    /// - [`ParseError::InvalidSyntax`] if the line is empty or malformed
162    /// - Any errors from [`CliParser`] during argument parsing
163    ///
164    /// # Example
165    ///
166    /// ```no_run
167    /// # use dynamic_cli::parser::repl_parser::ReplParser;
168    /// # use dynamic_cli::registry::CommandRegistry;
169    /// # let registry = CommandRegistry::new();
170    /// let parser = ReplParser::new(&registry);
171    ///
172    /// // Simple command
173    /// let parsed = parser.parse_line("help").unwrap();
174    ///
175    /// // Command with arguments
176    /// let parsed = parser.parse_line("process input.txt output.txt").unwrap();
177    ///
178    /// // Command with options
179    /// let parsed = parser.parse_line("run --verbose --count=10").unwrap();
180    /// ```
181    pub fn parse_line(&self, line: &str) -> Result<ParsedCommand> {
182        // Tokenize the line (respecting quotes)
183        let tokens = self.tokenize(line)?;
184
185        if tokens.is_empty() {
186            return Err(ParseError::InvalidSyntax {
187                details: "Empty command line".to_string(),
188                hint: Some("Type a command or 'help' for available commands".to_string()),
189            }
190            .into());
191        }
192
193        // First token is the command name
194        let input_name = &tokens[0];
195
196        // Resolve command name (handles aliases)
197        let command_name = self
198            .registry
199            .resolve_name(input_name)
200            .ok_or_else(|| {
201                // Get list of all available commands for suggestions
202                let available: Vec<String> = self
203                    .registry
204                    .list_commands()
205                    .iter()
206                    .flat_map(|cmd| {
207                        let mut names = vec![cmd.name.clone()];
208                        names.extend(cmd.aliases.clone());
209                        names
210                    })
211                    .collect();
212
213                ParseError::unknown_command_with_suggestions(input_name, &available)
214            })?
215            .to_string();
216
217        // Get command definition for argument parsing
218        let definition = self
219            .registry
220            .get_definition(&command_name)
221            .expect("Command definition must exist after resolution");
222
223        // Parse arguments using CliParser
224        let remaining_args: Vec<String> = tokens[1..].to_vec();
225        let cli_parser = CliParser::new(definition);
226        let arguments = cli_parser.parse(&remaining_args)?;
227
228        Ok(ParsedCommand {
229            command_name,
230            arguments,
231        })
232    }
233
234    /// Tokenize a command line into arguments
235    ///
236    /// This function performs simple tokenization by splitting on whitespace
237    /// while respecting quoted strings. It handles:
238    /// - Single quotes: `'quoted string'`
239    /// - Double quotes: `"quoted string"`
240    /// - Escaped quotes within quotes: `"say \"hello\""`
241    ///
242    /// # Arguments
243    ///
244    /// * `line` - The line to tokenize
245    ///
246    /// # Returns
247    ///
248    /// Vector of token strings
249    ///
250    /// # Errors
251    ///
252    /// Returns [`ParseError::InvalidSyntax`] if quotes are unbalanced
253    ///
254    /// # Example
255    ///
256    /// ```
257    /// # use dynamic_cli::parser::repl_parser::ReplParser;
258    /// # use dynamic_cli::registry::CommandRegistry;
259    /// # let registry = CommandRegistry::new();
260    /// # let parser = ReplParser::new(&registry);
261    /// // Simple tokens
262    /// let tokens = parser.tokenize("cmd arg1 arg2").unwrap();
263    /// assert_eq!(tokens, vec!["cmd", "arg1", "arg2"]);
264    ///
265    /// // Quoted strings
266    /// let tokens = parser.tokenize(r#"cmd "hello world""#).unwrap();
267    /// assert_eq!(tokens, vec!["cmd", "hello world"]);
268    /// ```
269    pub fn tokenize(&self, line: &str) -> Result<Vec<String>> {
270        let mut tokens = Vec::new();
271        let mut current_token = String::new();
272        let mut in_quotes = false;
273        let mut quote_char = ' ';
274        let mut chars = line.chars().peekable();
275
276        while let Some(ch) = chars.next() {
277            match ch {
278                // Handle quotes
279                '"' | '\'' => {
280                    if in_quotes && ch == quote_char {
281                        // End of quoted string
282                        in_quotes = false;
283                        quote_char = ' ';
284                    } else if !in_quotes {
285                        // Start of quoted string
286                        in_quotes = true;
287                        quote_char = ch;
288                    } else {
289                        // Quote char inside different quotes
290                        current_token.push(ch);
291                    }
292                }
293
294                // Handle whitespace
295                ' ' | '\t' => {
296                    if in_quotes {
297                        current_token.push(ch);
298                    } else if !current_token.is_empty() {
299                        tokens.push(current_token.clone());
300                        current_token.clear();
301                    }
302                }
303
304                // Handle escape sequences
305                '\\' => {
306                    if let Some(&next_ch) = chars.peek() {
307                        if in_quotes && (next_ch == quote_char || next_ch == '\\') {
308                            chars.next(); // Consume the escaped character
309                            current_token.push(next_ch);
310                        } else {
311                            current_token.push(ch);
312                        }
313                    } else {
314                        current_token.push(ch);
315                    }
316                }
317
318                // Regular character
319                _ => {
320                    current_token.push(ch);
321                }
322            }
323        }
324
325        // Check for unbalanced quotes
326        if in_quotes {
327            return Err(ParseError::InvalidSyntax {
328                details: format!("Unbalanced quote: {}", quote_char),
329                hint: Some("Make sure all quotes are properly closed".to_string()),
330            }
331            .into());
332        }
333
334        // Add last token if any
335        if !current_token.is_empty() {
336            tokens.push(current_token);
337        }
338
339        Ok(tokens)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::config::schema::{
347        ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
348    };
349    use crate::context::ExecutionContext;
350    use crate::executor::CommandHandler;
351
352    // Dummy handler for tests
353    struct TestHandler;
354
355    impl CommandHandler for TestHandler {
356        fn execute(
357            &self,
358            _context: &mut dyn ExecutionContext,
359            _args: &HashMap<String, String>,
360        ) -> crate::error::Result<()> {
361            Ok(())
362        }
363    }
364
365    /// Helper to create a test registry with some commands
366    fn create_test_registry() -> CommandRegistry {
367        let mut registry = CommandRegistry::new();
368
369        // Register "hello" command with "hi" alias
370        let hello_def = CommandDefinition {
371            name: "hello".to_string(),
372            aliases: vec!["hi".to_string(), "greet".to_string()],
373            description: "Say hello".to_string(),
374            required: false,
375            arguments: vec![ArgumentDefinition {
376                name: "name".to_string(),
377                arg_type: ArgumentType::String,
378                required: false,
379                description: "Name to greet".to_string(),
380                validation: vec![],
381                secure: false,
382            }],
383            options: vec![OptionDefinition {
384                name: "loud".to_string(),
385                short: Some("l".to_string()),
386                long: Some("loud".to_string()),
387                option_type: ArgumentType::Bool,
388                required: false,
389                default: Some("false".to_string()),
390                description: "Loud greeting".to_string(),
391                choices: vec![],
392            }],
393            implementation: "hello_handler".to_string(),
394        };
395
396        registry.register(hello_def, Box::new(TestHandler)).unwrap();
397
398        // Register "process" command
399        let process_def = CommandDefinition {
400            name: "process".to_string(),
401            aliases: vec!["proc".to_string()],
402            description: "Process files".to_string(),
403            required: false,
404            arguments: vec![
405                ArgumentDefinition {
406                    name: "input".to_string(),
407                    arg_type: ArgumentType::Path,
408                    required: true,
409                    description: "Input file".to_string(),
410                    validation: vec![],
411                    secure: false,
412                },
413                ArgumentDefinition {
414                    name: "output".to_string(),
415                    arg_type: ArgumentType::Path,
416                    required: false,
417                    description: "Output file".to_string(),
418                    validation: vec![],
419                    secure: false,
420                },
421            ],
422            options: vec![OptionDefinition {
423                name: "verbose".to_string(),
424                short: Some("v".to_string()),
425                long: Some("verbose".to_string()),
426                option_type: ArgumentType::Bool,
427                required: false,
428                default: Some("false".to_string()),
429                description: "Verbose output".to_string(),
430                choices: vec![],
431            }],
432            implementation: "process_handler".to_string(),
433        };
434
435        registry
436            .register(process_def, Box::new(TestHandler))
437            .unwrap();
438
439        registry
440    }
441
442    // ========================================================================
443    // Tokenization tests
444    // ========================================================================
445
446    #[test]
447    fn test_tokenize_simple() {
448        let registry = create_test_registry();
449        let parser = ReplParser::new(&registry);
450
451        let tokens = parser.tokenize("hello world").unwrap();
452        assert_eq!(tokens, vec!["hello", "world"]);
453    }
454
455    #[test]
456    fn test_tokenize_multiple_spaces() {
457        let registry = create_test_registry();
458        let parser = ReplParser::new(&registry);
459
460        let tokens = parser.tokenize("hello    world   test").unwrap();
461        assert_eq!(tokens, vec!["hello", "world", "test"]);
462    }
463
464    #[test]
465    fn test_tokenize_double_quotes() {
466        let registry = create_test_registry();
467        let parser = ReplParser::new(&registry);
468
469        let tokens = parser.tokenize(r#"hello "world test""#).unwrap();
470        assert_eq!(tokens, vec!["hello", "world test"]);
471    }
472
473    #[test]
474    fn test_tokenize_single_quotes() {
475        let registry = create_test_registry();
476        let parser = ReplParser::new(&registry);
477
478        let tokens = parser.tokenize("hello 'world test'").unwrap();
479        assert_eq!(tokens, vec!["hello", "world test"]);
480    }
481
482    #[test]
483    fn test_tokenize_escaped_quotes() {
484        let registry = create_test_registry();
485        let parser = ReplParser::new(&registry);
486
487        let tokens = parser.tokenize(r#"hello "say \"hi\"""#).unwrap();
488        assert_eq!(tokens, vec!["hello", r#"say "hi""#]);
489    }
490
491    #[test]
492    fn test_tokenize_unbalanced_quotes() {
493        let registry = create_test_registry();
494        let parser = ReplParser::new(&registry);
495
496        let result = parser.tokenize(r#"hello "world"#);
497        assert!(result.is_err());
498    }
499
500    #[test]
501    fn test_tokenize_empty_line() {
502        let registry = create_test_registry();
503        let parser = ReplParser::new(&registry);
504
505        let tokens = parser.tokenize("").unwrap();
506        assert!(tokens.is_empty());
507    }
508
509    #[test]
510    fn test_tokenize_only_spaces() {
511        let registry = create_test_registry();
512        let parser = ReplParser::new(&registry);
513
514        let tokens = parser.tokenize("    ").unwrap();
515        assert!(tokens.is_empty());
516    }
517
518    // ========================================================================
519    // Command name resolution tests
520    // ========================================================================
521
522    #[test]
523    fn test_parse_command_by_name() {
524        let registry = create_test_registry();
525        let parser = ReplParser::new(&registry);
526
527        let parsed = parser.parse_line("hello").unwrap();
528        assert_eq!(parsed.command_name, "hello");
529    }
530
531    #[test]
532    fn test_parse_command_by_alias() {
533        let registry = create_test_registry();
534        let parser = ReplParser::new(&registry);
535
536        let parsed = parser.parse_line("hi").unwrap();
537        assert_eq!(parsed.command_name, "hello");
538
539        let parsed = parser.parse_line("greet").unwrap();
540        assert_eq!(parsed.command_name, "hello");
541    }
542
543    #[test]
544    fn test_parse_unknown_command() {
545        let registry = create_test_registry();
546        let parser = ReplParser::new(&registry);
547
548        let result = parser.parse_line("unknown");
549        assert!(result.is_err());
550
551        match result.unwrap_err() {
552            crate::error::DynamicCliError::Parse(ParseError::UnknownCommand {
553                command, ..
554            }) => {
555                assert_eq!(command, "unknown");
556            }
557            other => panic!("Expected UnknownCommand error, got {:?}", other),
558        }
559    }
560
561    #[test]
562    fn test_parse_empty_line() {
563        let registry = create_test_registry();
564        let parser = ReplParser::new(&registry);
565
566        let result = parser.parse_line("");
567        assert!(result.is_err());
568    }
569
570    // ========================================================================
571    // Argument parsing tests
572    // ========================================================================
573
574    #[test]
575    fn test_parse_command_with_arguments() {
576        let registry = create_test_registry();
577        let parser = ReplParser::new(&registry);
578
579        let parsed = parser.parse_line("hello Alice").unwrap();
580        assert_eq!(parsed.command_name, "hello");
581        assert_eq!(parsed.arguments.get("name"), Some(&"Alice".to_string()));
582    }
583
584    #[test]
585    fn test_parse_command_with_options() {
586        let registry = create_test_registry();
587        let parser = ReplParser::new(&registry);
588
589        let parsed = parser.parse_line("hello --loud").unwrap();
590        assert_eq!(parsed.command_name, "hello");
591        assert_eq!(parsed.arguments.get("loud"), Some(&"true".to_string()));
592    }
593
594    #[test]
595    fn test_parse_command_with_short_option() {
596        let registry = create_test_registry();
597        let parser = ReplParser::new(&registry);
598
599        let parsed = parser.parse_line("hello -l").unwrap();
600        assert_eq!(parsed.command_name, "hello");
601        assert_eq!(parsed.arguments.get("loud"), Some(&"true".to_string()));
602    }
603
604    #[test]
605    fn test_parse_command_with_multiple_arguments_and_options() {
606        let registry = create_test_registry();
607        let parser = ReplParser::new(&registry);
608
609        let parsed = parser
610            .parse_line("process input.txt output.txt --verbose")
611            .unwrap();
612        assert_eq!(parsed.command_name, "process");
613        assert_eq!(
614            parsed.arguments.get("input"),
615            Some(&"input.txt".to_string())
616        );
617        assert_eq!(
618            parsed.arguments.get("output"),
619            Some(&"output.txt".to_string())
620        );
621        assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
622    }
623
624    #[test]
625    fn test_parse_alias_with_arguments() {
626        let registry = create_test_registry();
627        let parser = ReplParser::new(&registry);
628
629        let parsed = parser.parse_line("proc input.txt -v").unwrap();
630        assert_eq!(parsed.command_name, "process");
631        assert_eq!(
632            parsed.arguments.get("input"),
633            Some(&"input.txt".to_string())
634        );
635        assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
636    }
637
638    // ========================================================================
639    // Quoted argument tests
640    // ========================================================================
641
642    #[test]
643    fn test_parse_quoted_arguments() {
644        let registry = create_test_registry();
645        let parser = ReplParser::new(&registry);
646
647        let parsed = parser.parse_line(r#"hello "Alice Bob""#).unwrap();
648        assert_eq!(parsed.command_name, "hello");
649        assert_eq!(parsed.arguments.get("name"), Some(&"Alice Bob".to_string()));
650    }
651
652    #[test]
653    fn test_parse_quoted_paths() {
654        let registry = create_test_registry();
655        let parser = ReplParser::new(&registry);
656
657        let parsed = parser
658            .parse_line(r#"process "/path/with spaces/file.txt""#)
659            .unwrap();
660        assert_eq!(parsed.command_name, "process");
661        assert_eq!(
662            parsed.arguments.get("input"),
663            Some(&"/path/with spaces/file.txt".to_string())
664        );
665    }
666
667    // ========================================================================
668    // Integration tests
669    // ========================================================================
670
671    #[test]
672    fn test_parse_complex_command_line() {
673        let registry = create_test_registry();
674        let parser = ReplParser::new(&registry);
675
676        let parsed = parser
677            .parse_line(r#"proc "input file.txt" "output file.txt" -v"#)
678            .unwrap();
679
680        assert_eq!(parsed.command_name, "process");
681        assert_eq!(
682            parsed.arguments.get("input"),
683            Some(&"input file.txt".to_string())
684        );
685        assert_eq!(
686            parsed.arguments.get("output"),
687            Some(&"output file.txt".to_string())
688        );
689        assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
690    }
691
692    #[test]
693    fn test_parsed_command_debug() {
694        let mut args = HashMap::new();
695        args.insert("test".to_string(), "value".to_string());
696
697        let parsed = ParsedCommand {
698            command_name: "test".to_string(),
699            arguments: args,
700        };
701
702        // Verify Debug trait works
703        let debug_str = format!("{:?}", parsed);
704        assert!(debug_str.contains("test"));
705    }
706
707    #[test]
708    fn test_parsed_command_clone() {
709        let mut args = HashMap::new();
710        args.insert("test".to_string(), "value".to_string());
711
712        let parsed = ParsedCommand {
713            command_name: "test".to_string(),
714            arguments: args,
715        };
716
717        let cloned = parsed.clone();
718        assert_eq!(parsed, cloned);
719    }
720}