Skip to content

Commit 88b739f

Browse files
authored
Merge pull request #496 from python-cmd2/embedded_newlines
Fix #495 by allowing embedded newlines in unclosed quote marks when entering multiline commands
2 parents 46955ec + 3478456 commit 88b739f

File tree

4 files changed

+79
-9
lines changed

4 files changed

+79
-9
lines changed

cmd2/cmd2.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,8 +1858,30 @@ def _complete_statement(self, line: str) -> Statement:
18581858
pipe runs out. We can't refactor it because we need to retain
18591859
backwards compatibility with the standard library version of cmd.
18601860
"""
1861-
statement = self.statement_parser.parse(self.preparse(line))
1862-
while statement.multiline_command and not statement.terminator:
1861+
# preparse() is deprecated, use self.register_postparsing_hook() instead
1862+
line = self.preparse(line)
1863+
1864+
while True:
1865+
try:
1866+
statement = self.statement_parser.parse(line)
1867+
if statement.multiline_command and statement.terminator:
1868+
# we have a completed multiline command, we are done
1869+
break
1870+
if not statement.multiline_command:
1871+
# it's not a multiline command, but we parsed it ok
1872+
# so we are done
1873+
break
1874+
except ValueError:
1875+
# we have unclosed quotation marks, lets parse only the command
1876+
# and see if it's a multiline
1877+
statement = self.statement_parser.parse_command_only(line)
1878+
if not statement.multiline_command:
1879+
# not a multiline command, so raise the exception
1880+
raise
1881+
1882+
# if we get here we must have:
1883+
# - a multiline command with no terminator
1884+
# - a multiline command with unclosed quotation marks
18631885
if not self.quit_on_sigint:
18641886
try:
18651887
newline = self.pseudo_raw_input(self.continuation_prompt)
@@ -1885,7 +1907,6 @@ def _complete_statement(self, line: str) -> Statement:
18851907
newline = '\n'
18861908
self.poutput(newline)
18871909
line = '{}\n{}'.format(statement.raw, newline)
1888-
statement = self.statement_parser.parse(line)
18891910

18901911
if not statement.command:
18911912
raise EmptyStatement()

cmd2/parsing.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,8 @@ def parse_command_only(self, rawinput: str) -> Statement:
407407
"""Partially parse input into a Statement object.
408408
409409
The command is identified, and shortcuts and aliases are expanded.
410-
Terminators, multiline commands, and output redirection are not
411-
parsed.
410+
Multiline commands are identified, but terminators and output
411+
redirection are not parsed.
412412
413413
This method is used by tab completion code and therefore must not
414414
generate an exception if there are unclosed quotes.
@@ -420,8 +420,8 @@ def parse_command_only(self, rawinput: str) -> Statement:
420420
- args
421421
422422
Different from parse(), this method does not remove redundant whitespace
423-
within statement.args. It does however, ensure args does not have leading
424-
or trailing whitespace.
423+
within statement.args. It does however, ensure args does not have
424+
leading or trailing whitespace.
425425
"""
426426
# expand shortcuts and aliases
427427
line = self._expand(rawinput)
@@ -447,13 +447,20 @@ def parse_command_only(self, rawinput: str) -> Statement:
447447
if not command or not args:
448448
args = None
449449

450+
# set multiline
451+
if command in self.multiline_commands:
452+
multiline_command = command
453+
else:
454+
multiline_command = None
455+
450456
# build the statement
451457
# string representation of args must be an empty string instead of
452458
# None for compatibility with standard library cmd
453459
statement = Statement('' if args is None else args,
454460
raw=rawinput,
455461
command=command,
456462
args=args,
463+
multiline_command=multiline_command,
457464
)
458465
return statement
459466

tests/test_cmd2.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1471,7 +1471,8 @@ def test_multiline_complete_empty_statement_raises_exception(multiline_app):
14711471
multiline_app._complete_statement('')
14721472

14731473
def test_multiline_complete_statement_without_terminator(multiline_app):
1474-
# Mock out the input call so we don't actually wait for a user's response on stdin when it looks for more input
1474+
# Mock out the input call so we don't actually wait for a user's response
1475+
# on stdin when it looks for more input
14751476
m = mock.MagicMock(name='input', return_value='\n')
14761477
builtins.input = m
14771478

@@ -1481,6 +1482,20 @@ def test_multiline_complete_statement_without_terminator(multiline_app):
14811482
statement = multiline_app._complete_statement(line)
14821483
assert statement == args
14831484
assert statement.command == command
1485+
assert statement.multiline_command == command
1486+
1487+
def test_multiline_complete_statement_with_unclosed_quotes(multiline_app):
1488+
# Mock out the input call so we don't actually wait for a user's response
1489+
# on stdin when it looks for more input
1490+
m = mock.MagicMock(name='input', side_effect=['quotes', '" now closed;'])
1491+
builtins.input = m
1492+
1493+
line = 'orate hi "partially open'
1494+
statement = multiline_app._complete_statement(line)
1495+
assert statement == 'hi "partially open\nquotes\n" now closed'
1496+
assert statement.command == 'orate'
1497+
assert statement.multiline_command == 'orate'
1498+
assert statement.terminator == ';'
14841499

14851500

14861501
def test_clipboard_failure(base_app, capsys):

tests/test_parsing.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ def test_parse_multiline_with_complete_comment(parser):
376376
assert statement.argv == ['multiline', 'command', 'is', 'done']
377377
assert statement.terminator == ';'
378378

379-
def test_parse_multiline_termninated_by_empty_line(parser):
379+
def test_parse_multiline_terminated_by_empty_line(parser):
380380
line = 'multiline command ends\n\n'
381381
statement = parser.parse(line)
382382
assert statement.multiline_command == 'multiline'
@@ -386,6 +386,23 @@ def test_parse_multiline_termninated_by_empty_line(parser):
386386
assert statement.argv == ['multiline', 'command', 'ends']
387387
assert statement.terminator == '\n'
388388

389+
@pytest.mark.parametrize('line,terminator',[
390+
('multiline command "with\nembedded newline";', ';'),
391+
('multiline command "with\nembedded newline";;;', ';'),
392+
('multiline command "with\nembedded newline";; ;;', ';'),
393+
('multiline command "with\nembedded newline" &', '&'),
394+
('multiline command "with\nembedded newline" & &', '&'),
395+
('multiline command "with\nembedded newline"\n\n', '\n'),
396+
])
397+
def test_parse_multiline_with_embedded_newline(parser, line, terminator):
398+
statement = parser.parse(line)
399+
assert statement.multiline_command == 'multiline'
400+
assert statement.command == 'multiline'
401+
assert statement.args == 'command "with\nembedded newline"'
402+
assert statement == statement.args
403+
assert statement.argv == ['multiline', 'command', 'with\nembedded newline']
404+
assert statement.terminator == terminator
405+
389406
def test_parse_multiline_ignores_terminators_in_comments(parser):
390407
line = 'multiline command "with term; ends" now\n\n'
391408
statement = parser.parse(line)
@@ -584,6 +601,16 @@ def test_parse_command_only_none(parser, line):
584601
assert statement.args is None
585602
assert statement == ''
586603

604+
def test_parse_command_only_multiline(parser):
605+
line = 'multiline with partially "open quotes and no terminator'
606+
statement = parser.parse_command_only(line)
607+
assert statement.command == 'multiline'
608+
assert statement.multiline_command == 'multiline'
609+
assert statement.args == 'with partially "open quotes and no terminator'
610+
assert statement == statement.args
611+
assert statement.command_and_args == line
612+
613+
587614
def test_statement_initialization(parser):
588615
string = 'alias'
589616
statement = cmd2.Statement(string)

0 commit comments

Comments
 (0)