Skip to content

Commit aa7d284

Browse files
author
Todd Leonhardt
committed
Tons of tab completion changes
Attempting to emulate Bash shell behavior as closely as possible. Path completion is significantly improved. Shell command completion of commands is also supported now.
1 parent e3fd17d commit aa7d284

File tree

1 file changed

+181
-22
lines changed

1 file changed

+181
-22
lines changed

cmd2.py

Lines changed: 181 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,33 @@ def precmd(self, statement):
929929
"""
930930
return statement
931931

932+
def parseline(self, line):
933+
"""Parse the line into a command name and a string containing the arguments.
934+
935+
Used for command tab completion. Returns a tuple containing (command, args, line).
936+
'command' and 'args' may be None if the line couldn't be parsed.
937+
938+
:param line: str - line read by readline
939+
:return: (str, str, str) - tuple containing (command, args, line)
940+
"""
941+
line = line.strip()
942+
943+
if not line:
944+
# Deal with empty line or all whitespace line
945+
return None, None, line
946+
947+
# Expand command shortcuts to the full command name
948+
for (shortcut, expansion) in self.shortcuts:
949+
if line.startswith(shortcut):
950+
line = line.replace(shortcut, expansion + ' ', 1)
951+
break
952+
953+
i, n = 0, len(line)
954+
while i < n and line[i] in self.identchars:
955+
i = i+1
956+
command, arg = line[:i], line[i:].strip()
957+
return command, arg, line
958+
932959
def onecmd_plus_hooks(self, line):
933960
"""Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
934961
@@ -1327,48 +1354,180 @@ def help_shell(self):
13271354
Usage: shell cmd"""
13281355
self.stdout.write("{}\n".format(help_str))
13291356

1330-
@staticmethod
1331-
def path_complete(line):
1357+
def path_complete(self, text, line, begidx, endidx, dir_exe_only=False):
13321358
"""Method called to complete an input line by local file system path completion.
13331359
1360+
:param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
13341361
:param line: str - the current input line with leading whitespace removed
1362+
:param begidx: int - the beginning indexe of the prefix text
1363+
:param endidx: int - the ending index of the prefix text
1364+
:param dir_exe_only: bool - only return directories and executables, not non-executable files
13351365
:return: List[str] - a list of possible tab completions
13361366
"""
1337-
path = line.split()[-1]
1338-
if not path:
1339-
path = '.'
1367+
# Deal with cases like load command and @ key when path completion is immediately after a shortcut
1368+
for (shortcut, expansion) in self.shortcuts:
1369+
if line.startswith(shortcut):
1370+
# If the next character after the shortcut isn't a space, then insert one and adjust indices
1371+
shortcut_len = len(shortcut)
1372+
if len(line) == shortcut_len or line[shortcut_len] != ' ':
1373+
line = line.replace(shortcut, shortcut + ' ', 1)
1374+
begidx += 1
1375+
endidx += 1
1376+
break
1377+
1378+
# Determine if a trailing separator should be appended to directory completions
1379+
add_trailing_sep_if_dir = False
1380+
if endidx == len(line) or (endidx < len(line) and line[endidx] != os.path.sep):
1381+
add_trailing_sep_if_dir = True
1382+
1383+
add_sep_after_tilde = False
1384+
# If not path and no search text has been entered, then search in the CWD for *
1385+
if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '):
1386+
search_str = os.path.join(os.getcwd(), '*')
1387+
else:
1388+
# Parse out the path being searched
1389+
prev_space_index = line.rfind(' ', 0, begidx)
1390+
dirname = line[prev_space_index + 1:begidx]
1391+
1392+
# Purposely don't match any path containing wildcards - what we are doing is complicated enough!
1393+
wildcards = ['*', '?']
1394+
for wildcard in wildcards:
1395+
if wildcard in dirname or wildcard in text:
1396+
return []
1397+
1398+
if not dirname:
1399+
dirname = os.getcwd()
1400+
elif dirname == '~':
1401+
# If tilde was used without separator, add a separator after the tilde in the completions
1402+
add_sep_after_tilde = True
13401403

1341-
dirname, rest = os.path.split(path)
1342-
real_dir = os.path.expanduser(dirname)
1404+
# Build the search string
1405+
search_str = os.path.join(dirname, text + '*')
1406+
1407+
# Expand "~" to the real user directory
1408+
search_str = os.path.expanduser(search_str)
13431409

13441410
# Find all matching path completions
1345-
path_completions = glob.glob(os.path.join(real_dir, rest) + '*')
1411+
path_completions = glob.glob(search_str)
1412+
1413+
# If we only want directories and executables, filter everything else out first
1414+
if dir_exe_only:
1415+
path_completions = [c for c in path_completions if os.path.isdir(c) or os.access(c, os.X_OK)]
1416+
1417+
# Get the basename of the paths
1418+
completions = []
1419+
for c in path_completions:
1420+
basename = os.path.basename(c)
13461421

1347-
# Strip off everything but the final part of the completion because that's the way readline works
1348-
completions = [os.path.basename(c) for c in path_completions]
1422+
# Add a separator after directories if the next character isn't already a separator
1423+
if os.path.isdir(c) and add_trailing_sep_if_dir:
1424+
basename += os.path.sep
13491425

1350-
# If there is a single completion and it is a directory, add the final separator for convenience
1351-
if len(completions) == 1 and os.path.isdir(path_completions[0]):
1352-
completions[0] += os.path.sep
1426+
completions.append(basename)
1427+
1428+
# If there is a single completion
1429+
if len(completions) == 1:
1430+
# If it is a file and we are at the end of the line, then add a space for convenience
1431+
if os.path.isfile(path_completions[0]) and endidx == len(line):
1432+
completions[0] += ' '
1433+
# If tilde was expanded without a separator, prepend one
1434+
elif os.path.isdir(path_completions[0]) and add_sep_after_tilde:
1435+
completions[0] = os.path.sep + completions[0]
13531436

13541437
return completions
13551438

1439+
# Enable tab completion of paths for relevant commands
1440+
complete_edit = path_complete
1441+
complete_load = path_complete
1442+
complete_save = path_complete
1443+
1444+
@staticmethod
1445+
def _shell_command_complete(search_text):
1446+
"""Method called to complete an input line by environment PATH executable completion.
1447+
1448+
:param search_text: str - the search text used to find a shell command
1449+
:return: List[str] - a list of possible tab completions
1450+
"""
1451+
1452+
# Purposely don't match any executable containing wildcards
1453+
wildcards = ['*', '?']
1454+
for wildcard in wildcards:
1455+
if wildcard in search_text:
1456+
return []
1457+
1458+
# Get a list of every directory in the PATH environment variable and ignore symbolic links
1459+
paths = [p for p in os.getenv('PATH').split(':') if not os.path.islink(p)]
1460+
1461+
# Find every executable file in the PATH that matches the pattern
1462+
exes = []
1463+
for path in paths:
1464+
full_path = os.path.join(path, search_text)
1465+
matches = [f for f in glob.glob(full_path + '*') if os.path.isfile(f) and os.access(f, os.X_OK)]
1466+
1467+
for match in matches:
1468+
exes.append(os.path.basename(match))
1469+
1470+
# If there is a single completion, then add a space at the end for convenience since
1471+
# this will be printed to the command line the user is typing
1472+
if len(exes) == 1:
1473+
exes[0] += ' '
1474+
1475+
return exes
1476+
13561477
# noinspection PyUnusedLocal
13571478
def complete_shell(self, text, line, begidx, endidx):
1358-
"""Handles tab completion of local file system paths.
1479+
"""Handles tab completion of executable commands and local file system paths.
13591480
1360-
:param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
1481+
:param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
13611482
:param line: str - the current input line with leading whitespace removed
1362-
:param begidx: str - the beginning indexe of the prefix text
1363-
:param endidx: str - the ending index of the prefix text
1483+
:param begidx: int - the beginning index of the prefix text
1484+
:param endidx: int - the ending index of the prefix text
13641485
:return: List[str] - a list of possible tab completions
13651486
"""
1366-
return self.path_complete(line)
13671487

1368-
# Enable tab completion of paths for other commands in an identical fashion
1369-
complete_edit = complete_shell
1370-
complete_load = complete_shell
1371-
complete_save = complete_shell
1488+
# First we strip off the shell command or shortcut key
1489+
if line.startswith('!'):
1490+
stripped_line = line.lstrip('!')
1491+
initial_length = len('!')
1492+
else:
1493+
stripped_line = line[len('shell'):]
1494+
initial_length = len('shell')
1495+
1496+
line_parts = stripped_line.split()
1497+
1498+
# Don't tab complete anything if user only typed shell or !
1499+
if not line_parts:
1500+
return []
1501+
1502+
# Find the start index of the first thing after the shell or !
1503+
cmd_start = line.find(line_parts[0], initial_length)
1504+
cmd_end = cmd_start + len(line_parts[0])
1505+
1506+
# Check if we are in the command token
1507+
if cmd_start <= begidx <= cmd_end:
1508+
1509+
# See if text is part of a path
1510+
possible_path = line[cmd_start:begidx]
1511+
1512+
# There is nothing to search
1513+
if len(possible_path) == 0 and not text:
1514+
return []
1515+
1516+
if os.path.sep not in possible_path:
1517+
# The text before the search text is not a directory path.
1518+
# It is OK to try shell command completion.
1519+
command_completions = self._shell_command_complete(text)
1520+
1521+
if command_completions:
1522+
return command_completions
1523+
1524+
# If we have no results, try path completion
1525+
return self.path_complete(text, line, begidx, endidx, dir_exe_only=True)
1526+
1527+
# Past command token
1528+
else:
1529+
# Do path completion
1530+
return self.path_complete(text, line, begidx, endidx)
13721531

13731532
def do_py(self, arg):
13741533
"""

0 commit comments

Comments
 (0)