summaryrefslogtreecommitdiff
path: root/cmd2.py
diff options
context:
space:
mode:
authorTodd Leonhardt <tleonhardt@gmail.com>2017-05-17 19:21:26 -0400
committerTodd Leonhardt <tleonhardt@gmail.com>2017-05-17 19:21:26 -0400
commitaa7d2841190ee642c3dd2493d19897f638a989d6 (patch)
treed17f31b24e62e7385beaf9314d7d5aa77d9547a6 /cmd2.py
parente3fd17d3e29b196628ffd8301341b0dbf9916f80 (diff)
downloadcmd2-git-aa7d2841190ee642c3dd2493d19897f638a989d6.tar.gz
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.
Diffstat (limited to 'cmd2.py')
-rwxr-xr-xcmd2.py203
1 files changed, 181 insertions, 22 deletions
diff --git a/cmd2.py b/cmd2.py
index 37dd4695..8c45536d 100755
--- a/cmd2.py
+++ b/cmd2.py
@@ -929,6 +929,33 @@ class Cmd(cmd.Cmd):
"""
return statement
+ def parseline(self, line):
+ """Parse the line into a command name and a string containing the arguments.
+
+ Used for command tab completion. Returns a tuple containing (command, args, line).
+ 'command' and 'args' may be None if the line couldn't be parsed.
+
+ :param line: str - line read by readline
+ :return: (str, str, str) - tuple containing (command, args, line)
+ """
+ line = line.strip()
+
+ if not line:
+ # Deal with empty line or all whitespace line
+ return None, None, line
+
+ # Expand command shortcuts to the full command name
+ for (shortcut, expansion) in self.shortcuts:
+ if line.startswith(shortcut):
+ line = line.replace(shortcut, expansion + ' ', 1)
+ break
+
+ i, n = 0, len(line)
+ while i < n and line[i] in self.identchars:
+ i = i+1
+ command, arg = line[:i], line[i:].strip()
+ return command, arg, line
+
def onecmd_plus_hooks(self, line):
"""Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
@@ -1327,48 +1354,180 @@ class Cmd(cmd.Cmd):
Usage: shell cmd"""
self.stdout.write("{}\n".format(help_str))
- @staticmethod
- def path_complete(line):
+ def path_complete(self, text, line, begidx, endidx, dir_exe_only=False):
"""Method called to complete an input line by local file system path completion.
+ :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
:param line: str - the current input line with leading whitespace removed
+ :param begidx: int - the beginning indexe of the prefix text
+ :param endidx: int - the ending index of the prefix text
+ :param dir_exe_only: bool - only return directories and executables, not non-executable files
:return: List[str] - a list of possible tab completions
"""
- path = line.split()[-1]
- if not path:
- path = '.'
+ # Deal with cases like load command and @ key when path completion is immediately after a shortcut
+ for (shortcut, expansion) in self.shortcuts:
+ if line.startswith(shortcut):
+ # If the next character after the shortcut isn't a space, then insert one and adjust indices
+ shortcut_len = len(shortcut)
+ if len(line) == shortcut_len or line[shortcut_len] != ' ':
+ line = line.replace(shortcut, shortcut + ' ', 1)
+ begidx += 1
+ endidx += 1
+ break
+
+ # Determine if a trailing separator should be appended to directory completions
+ add_trailing_sep_if_dir = False
+ if endidx == len(line) or (endidx < len(line) and line[endidx] != os.path.sep):
+ add_trailing_sep_if_dir = True
+
+ add_sep_after_tilde = False
+ # If not path and no search text has been entered, then search in the CWD for *
+ if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '):
+ search_str = os.path.join(os.getcwd(), '*')
+ else:
+ # Parse out the path being searched
+ prev_space_index = line.rfind(' ', 0, begidx)
+ dirname = line[prev_space_index + 1:begidx]
+
+ # Purposely don't match any path containing wildcards - what we are doing is complicated enough!
+ wildcards = ['*', '?']
+ for wildcard in wildcards:
+ if wildcard in dirname or wildcard in text:
+ return []
+
+ if not dirname:
+ dirname = os.getcwd()
+ elif dirname == '~':
+ # If tilde was used without separator, add a separator after the tilde in the completions
+ add_sep_after_tilde = True
- dirname, rest = os.path.split(path)
- real_dir = os.path.expanduser(dirname)
+ # Build the search string
+ search_str = os.path.join(dirname, text + '*')
+
+ # Expand "~" to the real user directory
+ search_str = os.path.expanduser(search_str)
# Find all matching path completions
- path_completions = glob.glob(os.path.join(real_dir, rest) + '*')
+ path_completions = glob.glob(search_str)
+
+ # If we only want directories and executables, filter everything else out first
+ if dir_exe_only:
+ path_completions = [c for c in path_completions if os.path.isdir(c) or os.access(c, os.X_OK)]
+
+ # Get the basename of the paths
+ completions = []
+ for c in path_completions:
+ basename = os.path.basename(c)
- # Strip off everything but the final part of the completion because that's the way readline works
- completions = [os.path.basename(c) for c in path_completions]
+ # Add a separator after directories if the next character isn't already a separator
+ if os.path.isdir(c) and add_trailing_sep_if_dir:
+ basename += os.path.sep
- # If there is a single completion and it is a directory, add the final separator for convenience
- if len(completions) == 1 and os.path.isdir(path_completions[0]):
- completions[0] += os.path.sep
+ completions.append(basename)
+
+ # If there is a single completion
+ if len(completions) == 1:
+ # If it is a file and we are at the end of the line, then add a space for convenience
+ if os.path.isfile(path_completions[0]) and endidx == len(line):
+ completions[0] += ' '
+ # If tilde was expanded without a separator, prepend one
+ elif os.path.isdir(path_completions[0]) and add_sep_after_tilde:
+ completions[0] = os.path.sep + completions[0]
return completions
+ # Enable tab completion of paths for relevant commands
+ complete_edit = path_complete
+ complete_load = path_complete
+ complete_save = path_complete
+
+ @staticmethod
+ def _shell_command_complete(search_text):
+ """Method called to complete an input line by environment PATH executable completion.
+
+ :param search_text: str - the search text used to find a shell command
+ :return: List[str] - a list of possible tab completions
+ """
+
+ # Purposely don't match any executable containing wildcards
+ wildcards = ['*', '?']
+ for wildcard in wildcards:
+ if wildcard in search_text:
+ return []
+
+ # Get a list of every directory in the PATH environment variable and ignore symbolic links
+ paths = [p for p in os.getenv('PATH').split(':') if not os.path.islink(p)]
+
+ # Find every executable file in the PATH that matches the pattern
+ exes = []
+ for path in paths:
+ full_path = os.path.join(path, search_text)
+ matches = [f for f in glob.glob(full_path + '*') if os.path.isfile(f) and os.access(f, os.X_OK)]
+
+ for match in matches:
+ exes.append(os.path.basename(match))
+
+ # If there is a single completion, then add a space at the end for convenience since
+ # this will be printed to the command line the user is typing
+ if len(exes) == 1:
+ exes[0] += ' '
+
+ return exes
+
# noinspection PyUnusedLocal
def complete_shell(self, text, line, begidx, endidx):
- """Handles tab completion of local file system paths.
+ """Handles tab completion of executable commands and local file system paths.
- :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
+ :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
:param line: str - the current input line with leading whitespace removed
- :param begidx: str - the beginning indexe of the prefix text
- :param endidx: str - the ending index of the prefix text
+ :param begidx: int - the beginning index of the prefix text
+ :param endidx: int - the ending index of the prefix text
:return: List[str] - a list of possible tab completions
"""
- return self.path_complete(line)
- # Enable tab completion of paths for other commands in an identical fashion
- complete_edit = complete_shell
- complete_load = complete_shell
- complete_save = complete_shell
+ # First we strip off the shell command or shortcut key
+ if line.startswith('!'):
+ stripped_line = line.lstrip('!')
+ initial_length = len('!')
+ else:
+ stripped_line = line[len('shell'):]
+ initial_length = len('shell')
+
+ line_parts = stripped_line.split()
+
+ # Don't tab complete anything if user only typed shell or !
+ if not line_parts:
+ return []
+
+ # Find the start index of the first thing after the shell or !
+ cmd_start = line.find(line_parts[0], initial_length)
+ cmd_end = cmd_start + len(line_parts[0])
+
+ # Check if we are in the command token
+ if cmd_start <= begidx <= cmd_end:
+
+ # See if text is part of a path
+ possible_path = line[cmd_start:begidx]
+
+ # There is nothing to search
+ if len(possible_path) == 0 and not text:
+ return []
+
+ if os.path.sep not in possible_path:
+ # The text before the search text is not a directory path.
+ # It is OK to try shell command completion.
+ command_completions = self._shell_command_complete(text)
+
+ if command_completions:
+ return command_completions
+
+ # If we have no results, try path completion
+ return self.path_complete(text, line, begidx, endidx, dir_exe_only=True)
+
+ # Past command token
+ else:
+ # Do path completion
+ return self.path_complete(text, line, begidx, endidx)
def do_py(self, arg):
"""