Skip to content

Commit 60a66c1

Browse files
authored
Fixed multiline command highlighting in Cmd2Lexer (#1591)
Fixed multiline command highlighting in Cmd2Lexer
1 parent 41cf712 commit 60a66c1

File tree

2 files changed

+116
-65
lines changed

2 files changed

+116
-65
lines changed

cmd2/pt_utils.py

Lines changed: 73 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -219,77 +219,86 @@ def __init__(
219219

220220
def lex_document(self, document: Document) -> Callable[[int], Any]:
221221
"""Lex the document."""
222+
# Get redirection tokens and terminators to avoid highlighting them as values
223+
exclude_tokens = set(constants.REDIRECTION_TOKENS)
224+
exclude_tokens.update(self.cmd_app.statement_parser.terminators)
225+
arg_pattern = re.compile(r'(\s+)|(--?[^\s\'"]+)|("[^"]*"?|\'[^\']*\'?)|([^\s\'"]+)')
226+
227+
def highlight_args(text: str, tokens: list[tuple[str, str]]) -> None:
228+
"""Highlight arguments in a string."""
229+
for m in arg_pattern.finditer(text):
230+
space, flag, quoted, word = m.groups()
231+
match_text = m.group(0)
232+
233+
if space:
234+
tokens.append(('', match_text))
235+
elif flag:
236+
tokens.append((self.flag_color, match_text))
237+
elif (quoted or word) and match_text not in exclude_tokens:
238+
tokens.append((self.argument_color, match_text))
239+
else:
240+
tokens.append(('', match_text))
222241

223242
def get_line(lineno: int) -> list[tuple[str, str]]:
224243
"""Return the tokens for the given line number."""
225244
line = document.lines[lineno]
226245
tokens: list[tuple[str, str]] = []
227246

228-
# Use cmd2's command pattern to find the first word (the command)
229-
if ru.ALLOW_STYLE != ru.AllowStyle.NEVER and (
230-
match := self.cmd_app.statement_parser._command_pattern.search(line)
231-
):
232-
# Group 1 is the command, Group 2 is the character(s) that terminated the command match
233-
command = match.group(1)
234-
cmd_start = match.start(1)
235-
cmd_end = match.end(1)
236-
237-
# Add any leading whitespace
238-
if cmd_start > 0:
239-
tokens.append(('', line[:cmd_start]))
240-
241-
if command:
242-
# Determine the style for the command
243-
shortcut_found = False
244-
for shortcut, _ in self.cmd_app.statement_parser.shortcuts:
245-
if command.startswith(shortcut):
246-
# Add the shortcut with the command style
247-
tokens.append((self.command_color, shortcut))
248-
249-
# If there's more in the command word, it's an argument
250-
if len(command) > len(shortcut):
251-
tokens.append((self.argument_color, command[len(shortcut) :]))
252-
253-
shortcut_found = True
254-
break
255-
256-
if not shortcut_found:
257-
style = ''
258-
if command in self.cmd_app.get_all_commands():
259-
style = self.command_color
260-
elif command in self.cmd_app.aliases:
261-
style = self.alias_color
262-
elif command in self.cmd_app.macros:
263-
style = self.macro_color
264-
265-
# Add the command with the determined style
266-
tokens.append((style, command))
267-
268-
# Add the rest of the line
269-
if cmd_end < len(line):
270-
rest = line[cmd_end:]
271-
# Regex to match whitespace, flags, quoted strings, or other words
272-
arg_pattern = re.compile(r'(\s+)|(--?[^\s\'"]+)|("[^"]*"?|\'[^\']*\'?)|([^\s\'"]+)')
273-
274-
# Get redirection tokens and terminators to avoid highlighting them as values
275-
exclude_tokens = set(constants.REDIRECTION_TOKENS)
276-
exclude_tokens.update(self.cmd_app.statement_parser.terminators)
277-
278-
for m in arg_pattern.finditer(rest):
279-
space, flag, quoted, word = m.groups()
280-
text = m.group(0)
281-
282-
if space:
283-
tokens.append(('', text))
284-
elif flag:
285-
tokens.append((self.flag_color, text))
286-
elif (quoted or word) and text not in exclude_tokens:
287-
tokens.append((self.argument_color, text))
288-
else:
289-
tokens.append(('', text))
290-
elif line:
291-
# No command match found or colors aren't allowed, add the entire line unstyled
247+
# No syntax highlighting if styles are disallowed
248+
if ru.ALLOW_STYLE == ru.AllowStyle.NEVER:
292249
tokens.append(('', line))
250+
return tokens
251+
252+
# Only attempt to match a command on the first line
253+
if lineno == 0:
254+
# Use cmd2's command pattern to find the first word (the command)
255+
match = self.cmd_app.statement_parser._command_pattern.search(line)
256+
if match:
257+
# Group 1 is the command, Group 2 is the character(s) that terminated the command match
258+
command = match.group(1)
259+
cmd_start = match.start(1)
260+
cmd_end = match.end(1)
261+
262+
# Add any leading whitespace
263+
if cmd_start > 0:
264+
tokens.append(('', line[:cmd_start]))
265+
266+
if command:
267+
# Determine the style for the command
268+
shortcut_found = False
269+
for shortcut, _ in self.cmd_app.statement_parser.shortcuts:
270+
if command.startswith(shortcut):
271+
# Add the shortcut with the command style
272+
tokens.append((self.command_color, shortcut))
273+
274+
# If there's more in the command word, it's an argument
275+
if len(command) > len(shortcut):
276+
tokens.append((self.argument_color, command[len(shortcut) :]))
277+
278+
shortcut_found = True
279+
break
280+
281+
if not shortcut_found:
282+
style = ''
283+
if command in self.cmd_app.get_all_commands():
284+
style = self.command_color
285+
elif command in self.cmd_app.aliases:
286+
style = self.alias_color
287+
elif command in self.cmd_app.macros:
288+
style = self.macro_color
289+
290+
# Add the command with the determined style
291+
tokens.append((style, command))
292+
293+
# Add the rest of the line as arguments
294+
if cmd_end < len(line):
295+
highlight_args(line[cmd_end:], tokens)
296+
else:
297+
# No command match found on the first line
298+
tokens.append(('', line))
299+
else:
300+
# All other lines are treated as arguments
301+
highlight_args(line, tokens)
293302

294303
return tokens
295304

tests/test_pt_utils.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ def test_pt_filter_style_never() -> None:
9292

9393

9494
class TestCmd2Lexer:
95+
@with_ansi_style(ru.AllowStyle.NEVER)
96+
def test_lex_document_no_style(self, mock_cmd_app):
97+
"""Test lexing when styles are disallowed."""
98+
lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
99+
100+
line = "help something"
101+
document = Document(line)
102+
get_line = lexer.lex_document(document)
103+
tokens = get_line(0)
104+
105+
assert tokens == [('', line)]
106+
95107
def test_lex_document_command(self, mock_cmd_app):
96108
"""Test lexing a command name."""
97109
mock_cmd_app.all_commands = ["help"]
@@ -162,6 +174,19 @@ def test_lex_document_no_command(self, mock_cmd_app):
162174

163175
assert tokens == [('', ' ')]
164176

177+
def test_lex_document_no_match(self, mock_cmd_app):
178+
"""Test lexing when command pattern fails to match."""
179+
# Force the pattern to not match anything
180+
mock_cmd_app.statement_parser._command_pattern = re.compile(r'something_impossible')
181+
lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
182+
183+
line = "test command"
184+
document = Document(line)
185+
get_line = lexer.lex_document(document)
186+
tokens = get_line(0)
187+
188+
assert tokens == [('', line)]
189+
165190
def test_lex_document_arguments(self, mock_cmd_app):
166191
"""Test lexing a command with flags and values."""
167192
mock_cmd_app.all_commands = ["help"]
@@ -210,13 +235,30 @@ def test_lex_document_shortcut(self, mock_cmd_app):
210235
tokens = get_line(0)
211236
assert tokens == [('ansigreen', '!'), ('ansiyellow', 'ls')]
212237

213-
# Case 2: Shortcut with space
214238
line = "! ls"
215239
document = Document(line)
216240
get_line = lexer.lex_document(document)
217241
tokens = get_line(0)
218242
assert tokens == [('ansigreen', '!'), ('', ' '), ('ansiyellow', 'ls')]
219243

244+
def test_lex_document_multiline(self, mock_cmd_app):
245+
"""Test lexing a multiline command."""
246+
mock_cmd_app.all_commands = ["orate"]
247+
lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
248+
249+
# Command on first line, argument on second line that looks like a command
250+
line = "orate\nhelp"
251+
document = Document(line)
252+
get_line = lexer.lex_document(document)
253+
254+
# First line should have command
255+
tokens0 = get_line(0)
256+
assert tokens0 == [('ansigreen', 'orate')]
257+
258+
# Second line should have argument (not command)
259+
tokens1 = get_line(1)
260+
assert tokens1 == [('ansiyellow', 'help')]
261+
220262

221263
class TestCmd2Completer:
222264
def test_get_completions(self, mock_cmd_app: MockCmd, monkeypatch) -> None:

0 commit comments

Comments
 (0)