diff options
-rwxr-xr-x | cmd2/cmd2.py | 32 | ||||
-rw-r--r-- | cmd2/parsing.py | 62 | ||||
-rw-r--r-- | tests/test_cmd2.py | 3 | ||||
-rw-r--r-- | tests/test_parsing.py | 24 |
4 files changed, 86 insertions, 35 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 764f3ce7..4bec394b 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -730,22 +730,6 @@ class Cmd(cmd.Cmd): # If this string is non-empty, then this warning message will print if a broken pipe error occurs while printing self.broken_pipe_warning = '' - # regular expression to test for invalid characters in aliases - # we will construct it dynamically, because some of the components - # like terminator characters, can change - invalid_items = [] - invalid_items.extend(constants.REDIRECTION_CHARS) - invalid_items.extend(self.terminators) - # escape each item so it will for sure get treated as a literal - invalid_items = [re.escape(x) for x in invalid_items] - # don't allow whitespace - invalid_items.append(r'\s') - # join them up with a pipe to form a regular expression - # that looks something like r';|>|\||\s' - expr = '|'.join(invalid_items) - # and compile it into a pattern - self.invalid_alias_pattern = re.compile(expr) - # If a startup script is provided, then add it in the queue to load if startup_script is not None: startup_script = os.path.expanduser(startup_script) @@ -2396,15 +2380,17 @@ Usage: Usage: alias [name] | [<name> <value>] name = arglist[0] value = ' '.join(arglist[1:]) - # Validate the alias to ensure it doesn't include wierd characters + # Validate the alias to ensure it doesn't include weird characters # like terminators, output redirection, or whitespace - if self.invalid_alias_pattern.search(name): - self.perror('Alias names can not contain special characters.', traceback_war=False) - return + valid, invalidchars = self.statement_parser.is_valid_command(name) + if valid: + # Set the alias + self.aliases[name] = value + self.poutput("Alias {!r} created".format(name)) + else: + errmsg = "Aliases can not contain: {}".format(invalidchars) + self.perror(errmsg, traceback_war=False) - # Set the alias - self.aliases[name] = value - self.poutput("Alias {!r} created".format(name)) def complete_alias(self, text, line, begidx, endidx): """ Tab completion for alias """ diff --git a/cmd2/parsing.py b/cmd2/parsing.py index d7feeb48..3a9b390b 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -141,7 +141,7 @@ class StatementParser: re.DOTALL | re.MULTILINE ) - # aliases have to be a word, so make a regular expression + # commands have to be a word, so make a regular expression # that matches the first word in the line. This regex has three # parts: # - the '\A\s*' matches the beginning of the string (even @@ -157,19 +157,51 @@ class StatementParser: # REDIRECTION_CHARS, one of the terminators, or the end of # the string (\Z matches the end of the string even if it # contains multiple lines) - second_group_items = [] - second_group_items.extend(constants.REDIRECTION_CHARS) - second_group_items.extend(terminators) + # + invalid_command_chars = [] + invalid_command_chars.extend(constants.QUOTES) + invalid_command_chars.extend(constants.REDIRECTION_CHARS) + invalid_command_chars.extend(terminators) # escape each item so it will for sure get treated as a literal - second_group_items = [re.escape(x) for x in second_group_items] + second_group_items = [re.escape(x) for x in invalid_command_chars] # add the whitespace and end of string, not escaped because they # are not literals second_group_items.extend([r'\s', r'\Z']) # join them up with a pipe second_group = '|'.join(second_group_items) # build the regular expression - expr = r'\A\s*(\S+?)({})'.format(second_group) - self.command_pattern = re.compile(expr) + expr = r'\A\s*(\S*?)({})'.format(second_group) + self._command_pattern = re.compile(expr) + + def is_valid_command(self, word: str) -> Tuple[bool, str]: + """Determine whether a word is a valid alias. + + Aliases can not include redirection characters, whitespace, + or termination characters. + + If word is not a valid command, return False and a comma + separated string of characters that can not appear in a command. + This string is suitable for inclusion in an error message of your + choice: + + valid, invalidchars = statement_parser.is_valid_command('>') + if not valid: + errmsg = "Aliases can not contain: {}".format(invalidchars) + """ + valid = False + + errmsg = 'whitespace, quotes, ' + errchars = [] + errchars.extend(constants.REDIRECTION_CHARS) + errchars.extend(self.terminators) + errmsg += ', '.join([shlex.quote(x) for x in errchars]) + + match = self._command_pattern.search(word) + if match: + if word == match.group(1): + valid = True + errmsg = None + return valid, errmsg def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. @@ -344,16 +376,24 @@ class StatementParser: command = None args = None - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - # the command_pattern regex is designed to match the spaces + # the match could be an empty string, if so, turn it into none + if not command: + command = None + # the _command_pattern regex is designed to match the spaces # between command and args with a second match group. Using # the end of the second match group ensures that args has # no leading whitespace. The rstrip() makes sure there is # no trailing whitespace args = line[match.end(2):].rstrip() + # if the command is none that means the input was either empty + # or something wierd like '>'. args should be None if we couldn't + # parse a command + if not command or not args: + args = None # build the statement # string representation of args must be an empty string instead of @@ -375,11 +415,11 @@ class StatementParser: for cur_alias in tmp_aliases: keep_expanding = False # apply our regex to line - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - if command == cur_alias: + if command and command == cur_alias: # rebuild line with the expanded alias line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):] tmp_aliases.remove(cur_alias) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index c3c9a29f..bc76505f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1707,10 +1707,11 @@ def test_unalias_non_existing(base_app, capsys): @pytest.mark.parametrize('alias_name', [ '">"', - '"no>pe"' + '"no>pe"', '"no spaces"', '"nopipe|"', '"noterm;"', + 'noembedded"quotes', ]) def test_create_invalid_alias(base_app, alias_name, capsys): run_cmd(base_app, 'alias {} help'.format(alias_name)) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index ad4d31cd..bfb55b23 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -439,3 +439,27 @@ def test_parse_command_only_quoted_args(parser): assert statement.command == 'shell' assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' assert statement.command_and_args == line.replace('l', 'shell ls -al') + +@pytest.mark.parametrize('line', [ + 'helpalias > out.txt', + 'helpalias>out.txt', + 'helpalias >> out.txt', + 'helpalias>>out.txt', + 'help|less', + 'helpalias;', +]) +def test_parse_command_only_specialchars(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == 'help' + +@pytest.mark.parametrize('line', [ + ';', + '>', + "'", + '"', + '|', +]) +def test_parse_command_only_none(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == None + assert statement.args == None |