|
| 1 | +"""Parser implementations for text and shell commands.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import shlex |
| 6 | + |
| 7 | +from cli_patterns.ui.parser.types import Context, ParseError, ParseResult |
| 8 | + |
| 9 | + |
| 10 | +class TextParser: |
| 11 | + """Parser for standard text-based commands with flags and options. |
| 12 | +
|
| 13 | + Handles parsing of commands with arguments, short/long flags, and key-value options. |
| 14 | + Supports proper quote handling using shlex for shell-like parsing. |
| 15 | + """ |
| 16 | + |
| 17 | + def can_parse(self, input: str, context: Context) -> bool: |
| 18 | + """Check if input can be parsed by this text parser. |
| 19 | +
|
| 20 | + Args: |
| 21 | + input: Input string to check |
| 22 | + context: Parsing context |
| 23 | +
|
| 24 | + Returns: |
| 25 | + True if input is non-empty text that doesn't start with shell prefix |
| 26 | + """ |
| 27 | + if not input or not input.strip(): |
| 28 | + return False |
| 29 | + |
| 30 | + # Don't handle shell commands (those start with !) |
| 31 | + if input.lstrip().startswith("!"): |
| 32 | + return False |
| 33 | + |
| 34 | + return True |
| 35 | + |
| 36 | + def parse(self, input: str, context: Context) -> ParseResult: |
| 37 | + """Parse text input into structured command result. |
| 38 | +
|
| 39 | + Args: |
| 40 | + input: Input string to parse |
| 41 | + context: Parsing context |
| 42 | +
|
| 43 | + Returns: |
| 44 | + ParseResult with parsed command, args, flags, and options |
| 45 | +
|
| 46 | + Raises: |
| 47 | + ParseError: If parsing fails (e.g., unmatched quotes, empty input) |
| 48 | + """ |
| 49 | + if not self.can_parse(input, context): |
| 50 | + if not input.strip(): |
| 51 | + raise ParseError( |
| 52 | + error_type="EMPTY_INPUT", |
| 53 | + message="Empty input cannot be parsed", |
| 54 | + suggestions=["Enter a command to execute"], |
| 55 | + ) |
| 56 | + else: |
| 57 | + raise ParseError( |
| 58 | + error_type="INVALID_INPUT", |
| 59 | + message="Input cannot be parsed by text parser", |
| 60 | + suggestions=["Check command format"], |
| 61 | + ) |
| 62 | + |
| 63 | + try: |
| 64 | + # Use shlex for proper quote handling |
| 65 | + tokens = shlex.split(input.strip()) |
| 66 | + except ValueError as e: |
| 67 | + # Handle shlex errors (e.g., unmatched quotes) |
| 68 | + error_msg = str(e).replace("quotation", "quote") |
| 69 | + raise ParseError( |
| 70 | + error_type="QUOTE_MISMATCH", |
| 71 | + message=f"Syntax error in command: {error_msg}", |
| 72 | + suggestions=["Check quote pairing", "Escape special characters"], |
| 73 | + ) from e |
| 74 | + |
| 75 | + if not tokens: |
| 76 | + raise ParseError( |
| 77 | + error_type="EMPTY_INPUT", |
| 78 | + message="No command found after parsing", |
| 79 | + suggestions=["Enter a valid command"], |
| 80 | + ) |
| 81 | + |
| 82 | + # First token is the command |
| 83 | + command = tokens[0] |
| 84 | + |
| 85 | + # Parse remaining tokens into args, flags, and options |
| 86 | + args = [] |
| 87 | + flags = set() |
| 88 | + options = {} |
| 89 | + |
| 90 | + i = 1 |
| 91 | + while i < len(tokens): |
| 92 | + token = tokens[i] |
| 93 | + |
| 94 | + if token.startswith("--"): |
| 95 | + # Long option handling |
| 96 | + if "=" in token: |
| 97 | + # Format: --key=value |
| 98 | + key_value = token[2:] # Remove -- |
| 99 | + if "=" in key_value: |
| 100 | + key, value = key_value.split("=", 1) |
| 101 | + options[key] = value |
| 102 | + else: |
| 103 | + # Format: --key value (next token is value) |
| 104 | + key = token[2:] # Remove -- |
| 105 | + if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): |
| 106 | + options[key] = tokens[i + 1] |
| 107 | + i += 1 # Skip the value token |
| 108 | + else: |
| 109 | + # Treat as flag if no value follows |
| 110 | + flags.add(key) |
| 111 | + |
| 112 | + elif token.startswith("-") and len(token) > 1: |
| 113 | + # Short flag(s) handling |
| 114 | + flag_chars = token[1:] # Remove - |
| 115 | + for char in flag_chars: |
| 116 | + flags.add(char) |
| 117 | + |
| 118 | + else: |
| 119 | + # Regular argument |
| 120 | + args.append(token) |
| 121 | + |
| 122 | + i += 1 |
| 123 | + |
| 124 | + return ParseResult( |
| 125 | + command=command, args=args, flags=flags, options=options, raw_input=input |
| 126 | + ) |
| 127 | + |
| 128 | + def get_suggestions(self, partial: str) -> list[str]: |
| 129 | + """Get completion suggestions for partial input. |
| 130 | +
|
| 131 | + Args: |
| 132 | + partial: Partial input to complete |
| 133 | +
|
| 134 | + Returns: |
| 135 | + List of completion suggestions (empty for base implementation) |
| 136 | + """ |
| 137 | + # Base implementation returns no suggestions |
| 138 | + # This could be extended to provide command suggestions |
| 139 | + return [] |
| 140 | + |
| 141 | + |
| 142 | +class ShellParser: |
| 143 | + """Parser for shell commands prefixed with '!'. |
| 144 | +
|
| 145 | + Handles commands that should be executed directly in the shell, |
| 146 | + preserving the full command after the '!' prefix. |
| 147 | + """ |
| 148 | + |
| 149 | + def can_parse(self, input: str, context: Context) -> bool: |
| 150 | + """Check if input is a shell command. |
| 151 | +
|
| 152 | + Args: |
| 153 | + input: Input string to check |
| 154 | + context: Parsing context |
| 155 | +
|
| 156 | + Returns: |
| 157 | + True if input starts with '!' and has content after it |
| 158 | + """ |
| 159 | + if not input or not input.strip(): |
| 160 | + return False |
| 161 | + |
| 162 | + stripped = input.strip() |
| 163 | + |
| 164 | + # Must start with ! and have content after it |
| 165 | + if not stripped.startswith("!"): |
| 166 | + return False |
| 167 | + |
| 168 | + # Must have content after the ! |
| 169 | + shell_content = stripped[1:].strip() |
| 170 | + return len(shell_content) > 0 |
| 171 | + |
| 172 | + def parse(self, input: str, context: Context) -> ParseResult: |
| 173 | + """Parse shell command input. |
| 174 | +
|
| 175 | + Args: |
| 176 | + input: Input string starting with '!' |
| 177 | + context: Parsing context |
| 178 | +
|
| 179 | + Returns: |
| 180 | + ParseResult with '!' as command and shell command preserved |
| 181 | +
|
| 182 | + Raises: |
| 183 | + ParseError: If input is not a valid shell command |
| 184 | + """ |
| 185 | + if not self.can_parse(input, context): |
| 186 | + if not input.strip(): |
| 187 | + raise ParseError( |
| 188 | + error_type="EMPTY_INPUT", |
| 189 | + message="Empty input cannot be parsed", |
| 190 | + suggestions=["Enter a shell command prefixed with '!'"], |
| 191 | + ) |
| 192 | + elif not input.strip().startswith("!"): |
| 193 | + raise ParseError( |
| 194 | + error_type="NOT_SHELL_COMMAND", |
| 195 | + message="Not a shell command (must start with '!')", |
| 196 | + suggestions=["Use '!' prefix for shell commands"], |
| 197 | + ) |
| 198 | + else: |
| 199 | + raise ParseError( |
| 200 | + error_type="EMPTY_SHELL_COMMAND", |
| 201 | + message="Shell command prefix found but no command specified", |
| 202 | + suggestions=["Add a command after the '!' prefix"], |
| 203 | + ) |
| 204 | + |
| 205 | + stripped = input.strip() |
| 206 | + shell_command = stripped[1:].strip() # Remove ! prefix |
| 207 | + |
| 208 | + return ParseResult( |
| 209 | + command="!", |
| 210 | + args=[], # Shell parser doesn't break down the shell command |
| 211 | + flags=set(), |
| 212 | + options={}, |
| 213 | + raw_input=input, |
| 214 | + shell_command=shell_command, |
| 215 | + ) |
| 216 | + |
| 217 | + def get_suggestions(self, partial: str) -> list[str]: |
| 218 | + """Get completion suggestions for partial shell input. |
| 219 | +
|
| 220 | + Args: |
| 221 | + partial: Partial input to complete |
| 222 | +
|
| 223 | + Returns: |
| 224 | + List of shell command suggestions |
| 225 | + """ |
| 226 | + # Base implementation for shell commands |
| 227 | + if not partial.startswith("!"): |
| 228 | + # For empty or non-shell input, suggest shell prefix |
| 229 | + return ["!ls", "!pwd", "!ps", "!grep", "!find"] |
| 230 | + |
| 231 | + # Could suggest common shell commands |
| 232 | + shell_partial = partial[1:].strip() |
| 233 | + if not shell_partial: |
| 234 | + return ["!ls", "!pwd", "!ps", "!grep", "!find"] |
| 235 | + |
| 236 | + common_commands = [ |
| 237 | + "ls", |
| 238 | + "pwd", |
| 239 | + "ps", |
| 240 | + "grep", |
| 241 | + "find", |
| 242 | + "cat", |
| 243 | + "less", |
| 244 | + "head", |
| 245 | + "tail", |
| 246 | + ] |
| 247 | + suggestions = [] |
| 248 | + |
| 249 | + for cmd in common_commands: |
| 250 | + if cmd.startswith(shell_partial): |
| 251 | + suggestions.append(f"!{cmd}") |
| 252 | + |
| 253 | + return suggestions |
0 commit comments