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