summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd2/cmd2.py131
-rw-r--r--cmd2/parsing.py46
-rw-r--r--tests/test_cmd2.py2
3 files changed, 99 insertions, 80 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index a7b60b1a..2c0103ee 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -1845,14 +1845,22 @@ class Cmd(cmd.Cmd):
# necessary/desired here.
return stop
- def _complete_statement(self, line: str) -> Statement:
+ def _complete_statement(self, line: str, used_macros: Optional[List[str]] = None) -> Statement:
"""Keep accepting lines of input until the command is complete.
There is some pretty hacky code here to handle some quirks of
self.pseudo_raw_input(). It returns a literal 'eof' if the input
pipe runs out. We can't refactor it because we need to retain
backwards compatibility with the standard library version of cmd.
+
+ :param line: the line being parsed
+ :param used_macros: a list of macros that have already been resolved during parsing.
+ this should be None for the first call.
+ :return: the completed Statement
"""
+ if used_macros is None:
+ used_macros = []
+
while True:
try:
statement = self.statement_parser.parse(line)
@@ -1897,8 +1905,63 @@ class Cmd(cmd.Cmd):
if not statement.command:
raise EmptyStatement()
+
+ # Check if this command is a macro and wasn't already processed to avoid an infinite loop
+ if statement.command in self.macros.keys() and statement.command not in used_macros:
+ line = self._resolve_macro(statement)
+ if line is None:
+ raise EmptyStatement()
+ used_macros.append(statement.command)
+
+ # Parse the resolved macro
+ statement = self._complete_statement(line, used_macros)
+
return statement
+ def _resolve_macro(self, statement: Statement) -> Optional[str]:
+ """
+ Resolve a macro and return the resulting string
+
+ :param statement: the parsed statement from the command line
+ :return: the resolved macro or None on error
+ """
+ from itertools import islice
+
+ if statement.command not in self.macros.keys():
+ raise KeyError('{} is not a macro'.format(statement.command))
+
+ macro = self.macros[statement.command]
+
+ # Make sure enough arguments were passed in
+ if len(statement.arg_list) < macro.minimum_arg_count:
+ self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command,
+ macro.minimum_arg_count),
+ traceback_war=False)
+ return None
+
+ # Resolve the arguments in reverse and read their values from statement.argv since those
+ # are unquoted. Macro args should have been quoted when the macro was created.
+ resolved = macro.value
+ reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
+
+ for arg in reverse_arg_list:
+ if arg.is_escaped:
+ to_replace = '{{' + arg.number_str + '}}'
+ replacement = '{' + arg.number_str + '}'
+ else:
+ to_replace = '{' + arg.number_str + '}'
+ replacement = statement.argv[int(arg.number_str)]
+
+ parts = resolved.rsplit(to_replace, maxsplit=1)
+ resolved = parts[0] + replacement + parts[1]
+
+ # Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
+ for arg in islice(statement.arg_list, macro.minimum_arg_count, None):
+ resolved += ' ' + arg
+
+ # Restore any terminator, suffix, redirection, etc.
+ return resolved + statement.post_command
+
def _redirect_output(self, statement: Statement) -> Tuple[bool, utils.RedirectionSavedState]:
"""Handles output redirection for >, >>, and |.
@@ -2047,71 +2110,23 @@ class Cmd(cmd.Cmd):
if not isinstance(statement, Statement):
statement = self._complete_statement(statement)
- # Check if this is a macro
- if statement.command in self.macros:
- stop = self._run_macro(statement)
- else:
- func = self.cmd_func(statement.command)
- if func:
- # Check to see if this command should be stored in history
- if statement.command not in self.exclude_from_history \
- and statement.command not in self.disabled_commands:
- self.history.append(statement)
+ func = self.cmd_func(statement.command)
+ if func:
+ # Check to see if this command should be stored in history
+ if statement.command not in self.exclude_from_history \
+ and statement.command not in self.disabled_commands:
+ self.history.append(statement)
- stop = func(statement)
+ stop = func(statement)
- else:
- stop = self.default(statement)
+ else:
+ stop = self.default(statement)
if stop is None:
stop = False
return stop
- def _run_macro(self, statement: Statement) -> bool:
- """
- Resolve a macro and run the resulting string
-
- :param statement: the parsed statement from the command line
- :return: a flag indicating whether the interpretation of commands should stop
- """
- from itertools import islice
-
- if statement.command not in self.macros.keys():
- raise KeyError('{} is not a macro'.format(statement.command))
-
- macro = self.macros[statement.command]
-
- # Make sure enough arguments were passed in
- if len(statement.arg_list) < macro.minimum_arg_count:
- self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command,
- macro.minimum_arg_count),
- traceback_war=False)
- return False
-
- # Resolve the arguments in reverse and read their values from statement.argv since those
- # are unquoted. Macro args should have been quoted when the macro was created.
- resolved = macro.value
- reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
-
- for arg in reverse_arg_list:
- if arg.is_escaped:
- to_replace = '{{' + arg.number_str + '}}'
- replacement = '{' + arg.number_str + '}'
- else:
- to_replace = '{' + arg.number_str + '}'
- replacement = statement.argv[int(arg.number_str)]
-
- parts = resolved.rsplit(to_replace, maxsplit=1)
- resolved = parts[0] + replacement + parts[1]
-
- # Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
- for arg in islice(statement.arg_list, macro.minimum_arg_count, None):
- resolved += ' ' + arg
-
- # Run the resolved command
- return self.onecmd_plus_hooks(resolved)
-
def default(self, statement: Statement) -> Optional[bool]:
"""Executed when the command given isn't a recognized command implemented by a do_* method.
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index 2af8a207..41bb6b15 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -198,9 +198,9 @@ class Statement(str):
return rtn
@property
- def expanded_command_line(self) -> str:
- """Contains command_and_args plus any ending terminator, suffix, and redirection chars"""
- rtn = self.command_and_args
+ def post_command(self) -> str:
+ """A string containing any ending terminator, suffix, and redirection chars"""
+ rtn = ''
if self.multiline_command:
rtn += constants.MULTILINE_TERMINATOR
elif self.terminator:
@@ -220,6 +220,11 @@ class Statement(str):
return rtn
@property
+ def expanded_command_line(self) -> str:
+ """Combines command_and_args and post_command"""
+ return self.command_and_args + self.post_command
+
+ @property
def argv(self) -> List[str]:
"""a list of arguments a la sys.argv.
@@ -621,25 +626,24 @@ class StatementParser:
def _expand(self, line: str) -> str:
"""Expand shortcuts and aliases"""
-
# expand aliases
- # make a copy of aliases so we can edit it
- tmp_aliases = list(self.aliases.keys())
- keep_expanding = bool(tmp_aliases)
- while keep_expanding:
- for cur_alias in tmp_aliases:
- keep_expanding = False
- # apply our regex to line
- match = self._command_pattern.search(line)
- if match:
- # we got a match, extract the command
- command = match.group(1)
- if command and command == cur_alias:
- # rebuild line with the expanded alias
- line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):]
- tmp_aliases.remove(cur_alias)
- keep_expanding = bool(tmp_aliases)
- break
+ used_aliases = []
+ while True:
+ # apply our regex to line
+ match = self._command_pattern.search(line)
+ if match:
+ # we got a match, extract the command
+ command = match.group(1)
+
+ # Check if this command matches an alias and wasn't already processed to avoid an infinite loop
+ if command in self.aliases and command not in used_aliases:
+ # rebuild line with the expanded alias
+ line = self.aliases[command] + match.group(2) + line[match.end(2):]
+ used_aliases.append(command)
+ else:
+ break
+ else:
+ break
# expand shortcuts
for (shortcut, expansion) in self.shortcuts:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 300e3ed9..22f250ac 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -1830,7 +1830,7 @@ def test_nonexistent_macro(base_app):
exception = None
try:
- base_app._run_macro(StatementParser().parse('fake'))
+ base_app._resolve_macro(StatementParser().parse('fake'))
except KeyError as e:
exception = e