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