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