Skip to content

Commit bc3c31f

Browse files
committed
Merge branch 'master' into ignore_identchars
# Conflicts: # cmd2/parsing.py # tests/test_parsing.py
2 parents 529b783 + bfdb482 commit bc3c31f

File tree

4 files changed

+74
-79
lines changed

4 files changed

+74
-79
lines changed

cmd2/cmd2.py

Lines changed: 8 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1590,12 +1590,9 @@ def complete(self, text, state):
15901590
if begidx > 0:
15911591

15921592
# Parse the command line
1593-
command, args, expanded_line = self.parseline(line)
1594-
1595-
# use these lines instead of the one above
1596-
# statement = self.command_parser.parse_command_only(line)
1597-
# command = statement.command
1598-
# expanded_line = statement.command_and_args
1593+
statement = self.statement_parser.parse_command_only(line)
1594+
command = statement.command
1595+
expanded_line = statement.command_and_args
15991596

16001597
# We overwrote line with a properly formatted but fully stripped version
16011598
# Restore the end spaces since line is only supposed to be lstripped when
@@ -1616,8 +1613,7 @@ def complete(self, text, state):
16161613
tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
16171614

16181615
# Either had a parsing error or are trying to complete the command token
1619-
# The latter can happen if default_to_shell is True and parseline() allowed
1620-
# assumed something like " or ' was a command.
1616+
# The latter can happen if " or ' was entered as the command
16211617
if tokens is None or len(tokens) == 1:
16221618
self.completion_matches = []
16231619
return None
@@ -1937,66 +1933,16 @@ def postparsing_postcmd(self, stop: bool) -> bool:
19371933
def parseline(self, line):
19381934
"""Parse the line into a command name and a string containing the arguments.
19391935
1940-
NOTE: This is an override of a parent class method. It is only used by other parent class methods. But
1941-
we do need to override it here so that the additional shortcuts present in cmd2 get properly expanded for
1942-
purposes of tab completion.
1936+
NOTE: This is an override of a parent class method. It is only used by other parent class methods.
19431937
1944-
Used for command tab completion. Returns a tuple containing (command, args, line).
1945-
'command' and 'args' may be None if the line couldn't be parsed.
1938+
Different from the parent class method, this ignores self.identchars.
19461939
19471940
:param line: str - line read by readline
19481941
:return: (str, str, str) - tuple containing (command, args, line)
19491942
"""
1950-
line = line.strip()
1951-
1952-
if not line:
1953-
# Deal with empty line or all whitespace line
1954-
return None, None, line
1955-
1956-
# Make a copy of aliases so we can edit it
1957-
tmp_aliases = list(self.aliases.keys())
1958-
keep_expanding = len(tmp_aliases) > 0
1959-
1960-
# Expand aliases
1961-
while keep_expanding:
1962-
for cur_alias in tmp_aliases:
1963-
keep_expanding = False
1964-
1965-
if line == cur_alias or line.startswith(cur_alias + ' '):
1966-
line = line.replace(cur_alias, self.aliases[cur_alias], 1)
1967-
1968-
# Do not expand the same alias more than once
1969-
tmp_aliases.remove(cur_alias)
1970-
keep_expanding = len(tmp_aliases) > 0
1971-
break
1972-
1973-
# Expand command shortcut to its full command name
1974-
for (shortcut, expansion) in self.shortcuts:
1975-
if line.startswith(shortcut):
1976-
# If the next character after the shortcut isn't a space, then insert one
1977-
shortcut_len = len(shortcut)
1978-
if len(line) == shortcut_len or line[shortcut_len] != ' ':
1979-
expansion += ' '
1980-
1981-
# Expand the shortcut
1982-
line = line.replace(shortcut, expansion, 1)
1983-
break
1984-
1985-
i, n = 0, len(line)
1986-
1987-
# If we are allowing shell commands, then allow any character in the command
1988-
if self.default_to_shell:
1989-
while i < n and line[i] != ' ':
1990-
i += 1
1991-
1992-
# Otherwise only allow those in identchars
1993-
else:
1994-
while i < n and line[i] in self.identchars:
1995-
i += 1
1996-
1997-
command, arg = line[:i], line[i:].strip()
19981943

1999-
return command, arg, line
1944+
statement = self.statement_parser.parse_command_only(line)
1945+
return statement.command, statement.args, statement.command_and_args
20001946

20011947
def onecmd_plus_hooks(self, line):
20021948
"""Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.

cmd2/parsing.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def command_and_args(self):
8181
return rtn
8282

8383

84-
class StatementParser():
84+
class StatementParser:
8585
"""Parse raw text into command components.
8686
8787
Shortcuts is a list of tuples with each tuple containing the shortcut and the expansion.
@@ -93,7 +93,7 @@ def __init__(
9393
multiline_commands=None,
9494
aliases=None,
9595
shortcuts=None,
96-
):
96+
):
9797
self.allow_redirection = allow_redirection
9898
if terminators is None:
9999
self.terminators = [';']
@@ -169,6 +169,8 @@ def tokenize(self, line: str) -> List[str]:
169169
"""Lex a string into a list of tokens.
170170
171171
Comments are removed, and shortcuts and aliases are expanded.
172+
173+
Raises ValueError if there are unclosed quotation marks.
172174
"""
173175

174176
# strip C-style comments
@@ -190,6 +192,8 @@ def parse(self, rawinput: str) -> Statement:
190192
"""Tokenize the input and parse it into a Statement object, stripping
191193
comments, expanding aliases and shortcuts, and extracting output
192194
redirection directives.
195+
196+
Raises ValueError if there are unclosed quotation marks.
193197
"""
194198

195199
# handle the special case/hardcoded terminator of a blank line
@@ -310,16 +314,40 @@ def parse(self, rawinput: str) -> Statement:
310314
return statement
311315

312316
def parse_command_only(self, rawinput: str) -> Statement:
313-
"""Partially parse input into a Statement object. The command is
314-
identified, and shortcuts and aliases are expanded.
317+
"""Partially parse input into a Statement object.
318+
319+
The command is identified, and shortcuts and aliases are expanded.
315320
Terminators, multiline commands, and output redirection are not
316321
parsed.
322+
323+
This method is used by tab completion code and therefore must not
324+
generate an exception if there are unclosed quotes.
325+
326+
The Statement object returned by this method can at most contained
327+
values in the following attributes:
328+
- raw
329+
- command
330+
- args
331+
332+
Different from parse(), this method does not remove redundant whitespace
333+
within statement.args. It does however, ensure args does not have leading
334+
or trailing whitespace.
317335
"""
318-
# lex the input into a list of tokens
319-
tokens = self.tokenize(rawinput)
336+
# expand shortcuts and aliases
337+
line = self._expand(rawinput)
320338

321-
# parse out the command and everything else
322-
(command, args) = self._command_and_args(tokens)
339+
command = None
340+
args = None
341+
match = self.command_pattern.search(line)
342+
if match:
343+
# we got a match, extract the command
344+
command = match.group(1)
345+
# the command_pattern regex is designed to match the spaces
346+
# between command and args with a second match group. Using
347+
# the end of the second match group ensures that args has
348+
# no leading whitespace. The rstrip() makes sure there is
349+
# no trailing whitespace
350+
args = line[match.end(2):].rstrip()
323351

324352
# build the statement
325353
# string representation of args must be an empty string instead of
@@ -328,7 +356,6 @@ def parse_command_only(self, rawinput: str) -> Statement:
328356
statement.raw = rawinput
329357
statement.command = command
330358
statement.args = args
331-
statement.argv = tokens
332359
return statement
333360

334361
def _expand(self, line: str) -> str:
@@ -355,7 +382,7 @@ def _expand(self, line: str) -> str:
355382

356383
# expand shortcuts
357384
for (shortcut, expansion) in self.shortcuts:
358-
if line.startswith(shortcut):
385+
if line.startswith(shortcut):
359386
# If the next character after the shortcut isn't a space, then insert one
360387
shortcut_len = len(shortcut)
361388
if len(line) == shortcut_len or line[shortcut_len] != ' ':
@@ -383,7 +410,7 @@ def _command_and_args(tokens: List[str]) -> Tuple[str, str]:
383410
if len(tokens) > 1:
384411
args = ' '.join(tokens[1:])
385412

386-
return (command, args)
413+
return command, args
387414

388415
@staticmethod
389416
def _comment_replacer(match):
@@ -400,7 +427,7 @@ def _split_on_punctuation(self, tokens: List[str]) -> List[str]:
400427
# as word breaks when they are in unquoted strings. Each run of punctuation
401428
# characters is treated as a single token.
402429
403-
:param initial_tokens: the tokens as parsed by shlex
430+
:param tokens: the tokens as parsed by shlex
404431
:return: the punctuated tokens
405432
"""
406433
punctuation = []

tests/test_cmd2.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,3 +1723,21 @@ def test_ppaged(base_app):
17231723
base_app.ppaged(msg)
17241724
out = base_app.stdout.buffer
17251725
assert out == msg + end
1726+
1727+
# we override cmd.parseline() so we always get consistent
1728+
# command parsing by parent methods we don't override
1729+
# don't need to test all the parsing logic here, because
1730+
# parseline just calls StatementParser.parse_command_only()
1731+
def test_parseline_empty(base_app):
1732+
statement = ''
1733+
command, args, line = base_app.parseline(statement)
1734+
assert not command
1735+
assert not args
1736+
assert not line
1737+
1738+
def test_parseline(base_app):
1739+
statement = " command with 'partially completed quotes "
1740+
command, args, line = base_app.parseline(statement)
1741+
assert command == 'command'
1742+
assert args == "with 'partially completed quotes"
1743+
assert line == statement.strip()

tests/test_parsing.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ def test_tokenize(parser, line, tokens):
4444
tokens_to_test = parser.tokenize(line)
4545
assert tokens_to_test == tokens
4646

47+
def test_tokenize_unclosed_quotes(parser):
48+
with pytest.raises(ValueError):
49+
tokens = parser.tokenize('command with "unclosed quotes')
50+
4751
@pytest.mark.parametrize('tokens,command,args', [
4852
([], None, None),
4953
(['command'], 'command', None),
@@ -219,7 +223,7 @@ def test_parse_output_to_paste_buffer(parser):
219223
assert statement.argv == ['output', 'to', 'paste', 'buffer']
220224
assert statement.output == '>>'
221225

222-
def test_has_redirect_inside_terminator(parser):
226+
def test_parse_redirect_inside_terminator(parser):
223227
"""The terminator designates the end of the commmand/arguments portion. If a redirector
224228
occurs before a terminator, then it will be treated as part of the arguments and not as a redirector."""
225229
line = 'has > inside;'
@@ -307,6 +311,10 @@ def test_parse_redirect_to_unicode_filename(parser):
307311
assert statement.output == '>'
308312
assert statement.output_to == 'café'
309313

314+
def test_parse_unclosed_quotes(parser):
315+
with pytest.raises(ValueError):
316+
tokens = parser.tokenize("command with 'unclosed quotes")
317+
310318
def test_empty_statement_raises_exception():
311319
app = cmd2.Cmd()
312320
with pytest.raises(cmd2.EmptyStatement):
@@ -372,7 +380,6 @@ def test_parse_command_only_command_and_args(parser):
372380
statement = parser.parse_command_only(line)
373381
assert statement.command == 'help'
374382
assert statement.args == 'history'
375-
assert statement.argv == ['help', 'history']
376383
assert statement.command_and_args == line
377384

378385
def test_parse_command_only_emptyline(parser):
@@ -392,22 +399,19 @@ def test_parse_command_only_strips_line(parser):
392399
statement = parser.parse_command_only(line)
393400
assert statement.command == 'help'
394401
assert statement.args == 'history'
395-
assert statement.argv == ['help', 'history']
396402
assert statement.command_and_args == line.strip()
397403

398404
def test_parse_command_only_expands_alias(parser):
399405
line = 'fake foobar.py'
400406
statement = parser.parse_command_only(line)
401407
assert statement.command == 'pyscript'
402408
assert statement.args == 'foobar.py'
403-
assert statement.argv == ['pyscript', 'foobar.py']
404409

405410
def test_parse_command_only_expands_shortcuts(parser):
406411
line = '!cat foobar.txt'
407412
statement = parser.parse_command_only(line)
408413
assert statement.command == 'shell'
409414
assert statement.args == 'cat foobar.txt'
410-
assert statement.argv == ['shell', 'cat', 'foobar.txt']
411415
assert statement.command_and_args == 'shell cat foobar.txt'
412416

413417
def test_parse_command_only_quoted_args(parser):

0 commit comments

Comments
 (0)