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_sync(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
397            .register_sync(hello_def, Box::new(TestHandler))
398            .unwrap();
399
400        // Register "process" command
401        let process_def = CommandDefinition {
402            name: "process".to_string(),
403            aliases: vec!["proc".to_string()],
404            description: "Process files".to_string(),
405            required: false,
406            arguments: vec![
407                ArgumentDefinition {
408                    name: "input".to_string(),
409                    arg_type: ArgumentType::Path,
410                    required: true,
411                    description: "Input file".to_string(),
412                    validation: vec![],
413                    secure: false,
414                },
415                ArgumentDefinition {
416                    name: "output".to_string(),
417                    arg_type: ArgumentType::Path,
418                    required: false,
419                    description: "Output file".to_string(),
420                    validation: vec![],
421                    secure: false,
422                },
423            ],
424            options: vec![OptionDefinition {
425                name: "verbose".to_string(),
426                short: Some("v".to_string()),
427                long: Some("verbose".to_string()),
428                option_type: ArgumentType::Bool,
429                required: false,
430                default: Some("false".to_string()),
431                description: "Verbose output".to_string(),
432                choices: vec![],
433            }],
434            implementation: "process_handler".to_string(),
435        };
436
437        registry
438            .register_sync(process_def, Box::new(TestHandler))
439            .unwrap();
440
441        registry
442    }
443
444    // ========================================================================
445    // Tokenization tests
446    // ========================================================================
447
448    #[test]
449    fn test_tokenize_simple() {
450        let registry = create_test_registry();
451        let parser = ReplParser::new(&registry);
452
453        let tokens = parser.tokenize("hello world").unwrap();
454        assert_eq!(tokens, vec!["hello", "world"]);
455    }
456
457    #[test]
458    fn test_tokenize_multiple_spaces() {
459        let registry = create_test_registry();
460        let parser = ReplParser::new(&registry);
461
462        let tokens = parser.tokenize("hello    world   test").unwrap();
463        assert_eq!(tokens, vec!["hello", "world", "test"]);
464    }
465
466    #[test]
467    fn test_tokenize_double_quotes() {
468        let registry = create_test_registry();
469        let parser = ReplParser::new(&registry);
470
471        let tokens = parser.tokenize(r#"hello "world test""#).unwrap();
472        assert_eq!(tokens, vec!["hello", "world test"]);
473    }
474
475    #[test]
476    fn test_tokenize_single_quotes() {
477        let registry = create_test_registry();
478        let parser = ReplParser::new(&registry);
479
480        let tokens = parser.tokenize("hello 'world test'").unwrap();
481        assert_eq!(tokens, vec!["hello", "world test"]);
482    }
483
484    #[test]
485    fn test_tokenize_escaped_quotes() {
486        let registry = create_test_registry();
487        let parser = ReplParser::new(&registry);
488
489        let tokens = parser.tokenize(r#"hello "say \"hi\"""#).unwrap();
490        assert_eq!(tokens, vec!["hello", r#"say "hi""#]);
491    }
492
493    #[test]
494    fn test_tokenize_unbalanced_quotes() {
495        let registry = create_test_registry();
496        let parser = ReplParser::new(&registry);
497
498        let result = parser.tokenize(r#"hello "world"#);
499        assert!(result.is_err());
500    }
501
502    #[test]
503    fn test_tokenize_empty_line() {
504        let registry = create_test_registry();
505        let parser = ReplParser::new(&registry);
506
507        let tokens = parser.tokenize("").unwrap();
508        assert!(tokens.is_empty());
509    }
510
511    #[test]
512    fn test_tokenize_only_spaces() {
513        let registry = create_test_registry();
514        let parser = ReplParser::new(&registry);
515
516        let tokens = parser.tokenize("    ").unwrap();
517        assert!(tokens.is_empty());
518    }
519
520    // ========================================================================
521    // Command name resolution tests
522    // ========================================================================
523
524    #[test]
525    fn test_parse_command_by_name() {
526        let registry = create_test_registry();
527        let parser = ReplParser::new(&registry);
528
529        let parsed = parser.parse_line("hello").unwrap();
530        assert_eq!(parsed.command_name, "hello");
531    }
532
533    #[test]
534    fn test_parse_command_by_alias() {
535        let registry = create_test_registry();
536        let parser = ReplParser::new(&registry);
537
538        let parsed = parser.parse_line("hi").unwrap();
539        assert_eq!(parsed.command_name, "hello");
540
541        let parsed = parser.parse_line("greet").unwrap();
542        assert_eq!(parsed.command_name, "hello");
543    }
544
545    #[test]
546    fn test_parse_unknown_command() {
547        let registry = create_test_registry();
548        let parser = ReplParser::new(&registry);
549
550        let result = parser.parse_line("unknown");
551        assert!(result.is_err());
552
553        match result.unwrap_err() {
554            crate::error::DynamicCliError::Parse(ParseError::UnknownCommand {
555                command, ..
556            }) => {
557                assert_eq!(command, "unknown");
558            }
559            other => panic!("Expected UnknownCommand error, got {:?}", other),
560        }
561    }
562
563    #[test]
564    fn test_parse_empty_line() {
565        let registry = create_test_registry();
566        let parser = ReplParser::new(&registry);
567
568        let result = parser.parse_line("");
569        assert!(result.is_err());
570    }
571
572    // ========================================================================
573    // Argument parsing tests
574    // ========================================================================
575
576    #[test]
577    fn test_parse_command_with_arguments() {
578        let registry = create_test_registry();
579        let parser = ReplParser::new(&registry);
580
581        let parsed = parser.parse_line("hello Alice").unwrap();
582        assert_eq!(parsed.command_name, "hello");
583        assert_eq!(parsed.arguments.get("name"), Some(&"Alice".to_string()));
584    }
585
586    #[test]
587    fn test_parse_command_with_options() {
588        let registry = create_test_registry();
589        let parser = ReplParser::new(&registry);
590
591        let parsed = parser.parse_line("hello --loud").unwrap();
592        assert_eq!(parsed.command_name, "hello");
593        assert_eq!(parsed.arguments.get("loud"), Some(&"true".to_string()));
594    }
595
596    #[test]
597    fn test_parse_command_with_short_option() {
598        let registry = create_test_registry();
599        let parser = ReplParser::new(&registry);
600
601        let parsed = parser.parse_line("hello -l").unwrap();
602        assert_eq!(parsed.command_name, "hello");
603        assert_eq!(parsed.arguments.get("loud"), Some(&"true".to_string()));
604    }
605
606    #[test]
607    fn test_parse_command_with_multiple_arguments_and_options() {
608        let registry = create_test_registry();
609        let parser = ReplParser::new(&registry);
610
611        let parsed = parser
612            .parse_line("process input.txt output.txt --verbose")
613            .unwrap();
614        assert_eq!(parsed.command_name, "process");
615        assert_eq!(
616            parsed.arguments.get("input"),
617            Some(&"input.txt".to_string())
618        );
619        assert_eq!(
620            parsed.arguments.get("output"),
621            Some(&"output.txt".to_string())
622        );
623        assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
624    }
625
626    #[test]
627    fn test_parse_alias_with_arguments() {
628        let registry = create_test_registry();
629        let parser = ReplParser::new(&registry);
630
631        let parsed = parser.parse_line("proc input.txt -v").unwrap();
632        assert_eq!(parsed.command_name, "process");
633        assert_eq!(
634            parsed.arguments.get("input"),
635            Some(&"input.txt".to_string())
636        );
637        assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
638    }
639
640    // ========================================================================
641    // Quoted argument tests
642    // ========================================================================
643
644    #[test]
645    fn test_parse_quoted_arguments() {
646        let registry = create_test_registry();
647        let parser = ReplParser::new(&registry);
648
649        let parsed = parser.parse_line(r#"hello "Alice Bob""#).unwrap();
650        assert_eq!(parsed.command_name, "hello");
651        assert_eq!(parsed.arguments.get("name"), Some(&"Alice Bob".to_string()));
652    }
653
654    #[test]
655    fn test_parse_quoted_paths() {
656        let registry = create_test_registry();
657        let parser = ReplParser::new(&registry);
658
659        let parsed = parser
660            .parse_line(r#"process "/path/with spaces/file.txt""#)
661            .unwrap();
662        assert_eq!(parsed.command_name, "process");
663        assert_eq!(
664            parsed.arguments.get("input"),
665            Some(&"/path/with spaces/file.txt".to_string())
666        );
667    }
668
669    // ========================================================================
670    // Integration tests
671    // ========================================================================
672
673    #[test]
674    fn test_parse_complex_command_line() {
675        let registry = create_test_registry();
676        let parser = ReplParser::new(&registry);
677
678        let parsed = parser
679            .parse_line(r#"proc "input file.txt" "output file.txt" -v"#)
680            .unwrap();
681
682        assert_eq!(parsed.command_name, "process");
683        assert_eq!(
684            parsed.arguments.get("input"),
685            Some(&"input file.txt".to_string())
686        );
687        assert_eq!(
688            parsed.arguments.get("output"),
689            Some(&"output file.txt".to_string())
690        );
691        assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
692    }
693
694    #[test]
695    fn test_parsed_command_debug() {
696        let mut args = HashMap::new();
697        args.insert("test".to_string(), "value".to_string());
698
699        let parsed = ParsedCommand {
700            command_name: "test".to_string(),
701            arguments: args,
702        };
703
704        // Verify Debug trait works
705        let debug_str = format!("{:?}", parsed);
706        assert!(debug_str.contains("test"));
707    }
708
709    #[test]
710    fn test_parsed_command_clone() {
711        let mut args = HashMap::new();
712        args.insert("test".to_string(), "value".to_string());
713
714        let parsed = ParsedCommand {
715            command_name: "test".to_string(),
716            arguments: args,
717        };
718
719        let cloned = parsed.clone();
720        assert_eq!(parsed, cloned);
721    }
722}