Skip to content

Commit 9c7e400

Browse files
committed
Macro resolution now occurs during parsing
1 parent b22c0bd commit 9c7e400

File tree

3 files changed

+99
-80
lines changed

3 files changed

+99
-80
lines changed

cmd2/cmd2.py

Lines changed: 73 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,14 +1845,22 @@ def runcmds_plus_hooks(self, cmds: List[str]) -> bool:
18451845
# necessary/desired here.
18461846
return stop
18471847

1848-
def _complete_statement(self, line: str) -> Statement:
1848+
def _complete_statement(self, line: str, used_macros: Optional[List[str]] = None) -> Statement:
18491849
"""Keep accepting lines of input until the command is complete.
18501850
18511851
There is some pretty hacky code here to handle some quirks of
18521852
self.pseudo_raw_input(). It returns a literal 'eof' if the input
18531853
pipe runs out. We can't refactor it because we need to retain
18541854
backwards compatibility with the standard library version of cmd.
1855+
1856+
:param line: the line being parsed
1857+
:param used_macros: a list of macros that have already been resolved during parsing.
1858+
this should be None for the first call.
1859+
:return: the completed Statement
18551860
"""
1861+
if used_macros is None:
1862+
used_macros = []
1863+
18561864
while True:
18571865
try:
18581866
statement = self.statement_parser.parse(line)
@@ -1897,8 +1905,63 @@ def _complete_statement(self, line: str) -> Statement:
18971905

18981906
if not statement.command:
18991907
raise EmptyStatement()
1908+
1909+
# Check if this command is a macro and wasn't already processed to avoid an infinite loop
1910+
if statement.command in self.macros.keys() and statement.command not in used_macros:
1911+
line = self._resolve_macro(statement)
1912+
if line is None:
1913+
raise EmptyStatement()
1914+
used_macros.append(statement.command)
1915+
1916+
# Parse the resolved macro
1917+
statement = self._complete_statement(line, used_macros)
1918+
19001919
return statement
19011920

1921+
def _resolve_macro(self, statement: Statement) -> Optional[str]:
1922+
"""
1923+
Resolve a macro and return the resulting string
1924+
1925+
:param statement: the parsed statement from the command line
1926+
:return: the resolved macro or None on error
1927+
"""
1928+
from itertools import islice
1929+
1930+
if statement.command not in self.macros.keys():
1931+
raise KeyError('{} is not a macro'.format(statement.command))
1932+
1933+
macro = self.macros[statement.command]
1934+
1935+
# Make sure enough arguments were passed in
1936+
if len(statement.arg_list) < macro.minimum_arg_count:
1937+
self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command,
1938+
macro.minimum_arg_count),
1939+
traceback_war=False)
1940+
return None
1941+
1942+
# Resolve the arguments in reverse and read their values from statement.argv since those
1943+
# are unquoted. Macro args should have been quoted when the macro was created.
1944+
resolved = macro.value
1945+
reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
1946+
1947+
for arg in reverse_arg_list:
1948+
if arg.is_escaped:
1949+
to_replace = '{{' + arg.number_str + '}}'
1950+
replacement = '{' + arg.number_str + '}'
1951+
else:
1952+
to_replace = '{' + arg.number_str + '}'
1953+
replacement = statement.argv[int(arg.number_str)]
1954+
1955+
parts = resolved.rsplit(to_replace, maxsplit=1)
1956+
resolved = parts[0] + replacement + parts[1]
1957+
1958+
# Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
1959+
for arg in islice(statement.arg_list, macro.minimum_arg_count, None):
1960+
resolved += ' ' + arg
1961+
1962+
# Restore any terminator, suffix, redirection, etc.
1963+
return resolved + statement.post_command
1964+
19021965
def _redirect_output(self, statement: Statement) -> Tuple[bool, utils.RedirectionSavedState]:
19031966
"""Handles output redirection for >, >>, and |.
19041967
@@ -2047,71 +2110,23 @@ def onecmd(self, statement: Union[Statement, str]) -> bool:
20472110
if not isinstance(statement, Statement):
20482111
statement = self._complete_statement(statement)
20492112

2050-
# Check if this is a macro
2051-
if statement.command in self.macros:
2052-
stop = self._run_macro(statement)
2053-
else:
2054-
func = self.cmd_func(statement.command)
2055-
if func:
2056-
# Check to see if this command should be stored in history
2057-
if statement.command not in self.exclude_from_history \
2058-
and statement.command not in self.disabled_commands:
2059-
self.history.append(statement)
2113+
func = self.cmd_func(statement.command)
2114+
if func:
2115+
# Check to see if this command should be stored in history
2116+
if statement.command not in self.exclude_from_history \
2117+
and statement.command not in self.disabled_commands:
2118+
self.history.append(statement)
20602119

2061-
stop = func(statement)
2120+
stop = func(statement)
20622121

2063-
else:
2064-
stop = self.default(statement)
2122+
else:
2123+
stop = self.default(statement)
20652124

20662125
if stop is None:
20672126
stop = False
20682127

20692128
return stop
20702129

2071-
def _run_macro(self, statement: Statement) -> bool:
2072-
"""
2073-
Resolve a macro and run the resulting string
2074-
2075-
:param statement: the parsed statement from the command line
2076-
:return: a flag indicating whether the interpretation of commands should stop
2077-
"""
2078-
from itertools import islice
2079-
2080-
if statement.command not in self.macros.keys():
2081-
raise KeyError('{} is not a macro'.format(statement.command))
2082-
2083-
macro = self.macros[statement.command]
2084-
2085-
# Make sure enough arguments were passed in
2086-
if len(statement.arg_list) < macro.minimum_arg_count:
2087-
self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command,
2088-
macro.minimum_arg_count),
2089-
traceback_war=False)
2090-
return False
2091-
2092-
# Resolve the arguments in reverse and read their values from statement.argv since those
2093-
# are unquoted. Macro args should have been quoted when the macro was created.
2094-
resolved = macro.value
2095-
reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
2096-
2097-
for arg in reverse_arg_list:
2098-
if arg.is_escaped:
2099-
to_replace = '{{' + arg.number_str + '}}'
2100-
replacement = '{' + arg.number_str + '}'
2101-
else:
2102-
to_replace = '{' + arg.number_str + '}'
2103-
replacement = statement.argv[int(arg.number_str)]
2104-
2105-
parts = resolved.rsplit(to_replace, maxsplit=1)
2106-
resolved = parts[0] + replacement + parts[1]
2107-
2108-
# Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
2109-
for arg in islice(statement.arg_list, macro.minimum_arg_count, None):
2110-
resolved += ' ' + arg
2111-
2112-
# Run the resolved command
2113-
return self.onecmd_plus_hooks(resolved)
2114-
21152130
def default(self, statement: Statement) -> Optional[bool]:
21162131
"""Executed when the command given isn't a recognized command implemented by a do_* method.
21172132

cmd2/parsing.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ def command_and_args(self) -> str:
198198
return rtn
199199

200200
@property
201-
def expanded_command_line(self) -> str:
202-
"""Contains command_and_args plus any ending terminator, suffix, and redirection chars"""
203-
rtn = self.command_and_args
201+
def post_command(self) -> str:
202+
"""A string containing any ending terminator, suffix, and redirection chars"""
203+
rtn = ''
204204
if self.multiline_command:
205205
rtn += constants.MULTILINE_TERMINATOR
206206
elif self.terminator:
@@ -219,6 +219,11 @@ def expanded_command_line(self) -> str:
219219

220220
return rtn
221221

222+
@property
223+
def expanded_command_line(self) -> str:
224+
"""Combines command_and_args and post_command"""
225+
return self.command_and_args + self.post_command
226+
222227
@property
223228
def argv(self) -> List[str]:
224229
"""a list of arguments a la sys.argv.
@@ -621,25 +626,24 @@ def get_command_arg_list(self, command_name: str, to_parse: Union[Statement, str
621626

622627
def _expand(self, line: str) -> str:
623628
"""Expand shortcuts and aliases"""
624-
625629
# expand aliases
626-
# make a copy of aliases so we can edit it
627-
tmp_aliases = list(self.aliases.keys())
628-
keep_expanding = bool(tmp_aliases)
629-
while keep_expanding:
630-
for cur_alias in tmp_aliases:
631-
keep_expanding = False
632-
# apply our regex to line
633-
match = self._command_pattern.search(line)
634-
if match:
635-
# we got a match, extract the command
636-
command = match.group(1)
637-
if command and command == cur_alias:
638-
# rebuild line with the expanded alias
639-
line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):]
640-
tmp_aliases.remove(cur_alias)
641-
keep_expanding = bool(tmp_aliases)
642-
break
630+
used_aliases = []
631+
while True:
632+
# apply our regex to line
633+
match = self._command_pattern.search(line)
634+
if match:
635+
# we got a match, extract the command
636+
command = match.group(1)
637+
638+
# Check if this command matches an alias and wasn't already processed to avoid an infinite loop
639+
if command in self.aliases and command not in used_aliases:
640+
# rebuild line with the expanded alias
641+
line = self.aliases[command] + match.group(2) + line[match.end(2):]
642+
used_aliases.append(command)
643+
else:
644+
break
645+
else:
646+
break
643647

644648
# expand shortcuts
645649
for (shortcut, expansion) in self.shortcuts:

tests/test_cmd2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1830,7 +1830,7 @@ def test_nonexistent_macro(base_app):
18301830
exception = None
18311831

18321832
try:
1833-
base_app._run_macro(StatementParser().parse('fake'))
1833+
base_app._resolve_macro(StatementParser().parse('fake'))
18341834
except KeyError as e:
18351835
exception = e
18361836

0 commit comments

Comments
 (0)