I have written a string parser that is designed to split a string by spaces, excluding spaces wrapped in strings.
Here is some example inputs and outputs:
| Rule | Input | Output |
|------|-------------------------------|-----------------------------------|
| 1 | 'foo' | ['foo'] |
| 1 | 'foo bar' | ['foo', 'bar'] |
| 1 | 'foo bar \'abc xyz\'' | ['foo', 'bar', "'abc xyz'"] |
| 1 | 'foo bar "abc xyz"' | ['foo', 'bar', '"abc xyz"'] |
| 3 | 'foo bar "abc xyz"\\' | ['foo', 'bar', '"abc xyz"'] |
| 2 | 'foo bar "abc \\"def\\" xyz"' | ['foo', 'bar', '"abc "def" xyz"'] |
And the rules my parser follows:
The input string is split by spaces, counting any text between quotes (either single or double) as a single "unit" of text (i.e. not split by spaces).
Any text wrapped in double quotes can be escaped using a backslash. For example:
'hello "\\"world\\""'
becomes['hello', '""world""']
.Any trailing backslashes are stripped.
Here is the code I have written to parse the strings:
import re as _re
import enum as _enum
def is_space(text):
"""Returns whether or not the provided string is a single space."""
return _re.match(r'^\s*$', text) is not None
class State(_enum.Enum):
"""A state for the parser.
space: The last character was a space.
word: The last character was part of a word.
quote: The last character was part of a quoted string.
"""
space = 0
word = 1
quote = 2
class ParserState(object):
"""A string parser that splits a string into words.
This parser splits a string into words, counting quoted strings as a single
word.
Example:
input: 'hello world "inner string"'
output: ['hello', 'world', '"inner string"']
"""
def __init__(self, text):
"""Constructs a new parser for the provided text."""
self._text = text
self._index = 0
self._state = State.space
self._quote = ''
self._word = ''
self._words = []
@property
def text(self):
"""The source text that the parser is parsing."""
return self._text
@property
def index(self):
"""The current index in the source text of the parser."""
return self._index
@index.setter
def index(self, value):
"""The current index in the source text of the parser.
If the value is not an integer then the index is not set.
"""
if isinstance(value, int):
self._index = value
@property
def character(self):
"""The current character in the source text of the parser.
This is the same as text[index]
"""
return self._text[self.index]
@property
def state(self):
"""The current state of the parser."""
return self._state
@state.setter
def state(self, value):
"""The current state of the parser.
If the value is not a valid state then the state is not set.
"""
if value in State:
self._state = value
@property
def quote(self):
"""The current quoting used by the parser.
This is set to a quote (either ' or ") to denote the closing quote for
the current section.
"""
return self._quote
@quote.setter
def quote(self, value):
"""The current quoting used by the parser.
If the provided value is not ' or " then the quote is not set.
"""
if value in ('\'', '"'):
self._quote = value
@property
def word(self):
"""The current word that the parser is building."""
return self._word
@word.setter
def word(self, value):
"""The current word that the parser is building.
If the value is not a string then the word is not set.
"""
if isinstance(value, basestring):
self._word = value
@property
def words(self):
"""All of the words found by the parser."""
return self._words
def push_word(self, allow_empty=False, clear_word=False):
"""Push the current word onto the words array.
If the word is empty and allow_empty is False then the word will not
be added to the words array.
If clear_word is True then word is emptied after pushing it.
"""
if allow_empty or len(self.word) > 0:
self.words.append(self.word)
if clear_word:
self.word = ''
def push_character(self, increment_index=0):
"""Append the current character onto the word.
If increment_index is not 0 then index is incremented by it's value.
"""
self.word += self.character
if increment_index != 0:
self.index += increment_index
def parse(text):
"""Splits the provided string into words.
This method counts quoted strings as a single word.
Example:
input: 'hello world "inner string"'
output: ['hello', 'world', '"inner string"']
"""
state = ParserState(text)
# Iterate each character in the provided string.
while state.index < len(state.text):
if is_space(state.character):
# If we're currently in a word then we push it the words array and
# clear it's value.
if state.state == State.word:
state.push_word(clear_word=True)
# If we're not in a quoted string then skip the space. By doing
# this we ignore all consecutive spaces.
if state.state != State.quote:
state.index += 1
continue
elif state.state == State.space:
state.state = State.word
if state.state == State.word:
# If the current character is a quote then store the type of quote
# and change the state to quote.
if state.character in ('\'', '"'):
state.quote = state.character
state.state = State.quote
# If the current character is a backslash then push it onto the
# current word and increment the index. By doing this, we can now
# access the character that is being escaped.
if state.character == '\\':
# If the current character is the last character of the string
# then increment the index and skip the rest of the iteration.
# This skips the backslash character, effectively removing it
# from the end of the string.
if state.index + 1 == len(state.text):
state.index += 1
continue
state.push_character(increment_index=1)
# Push the current character onto the current word and skip the
# rest of the iteration.
state.push_character(increment_index=1)
continue
if state.state == State.quote:
# If the current character is the same as the opening quote then
# change the state to word.
if state.character == state.quote:
state.state = State.word
# If the current character is a backslash and we're not in a single
# quoted string.
if state.character == '\\' and state.quote != '\'':
# If the current character is the last character in the string
# then increment the index and skip the rest of the iteration.
# This skips the backslash character, effectively removing it
# from the end of the string.
if state.index + 1 == len(state.text):
state.index += 1
continue
# Increment the index, effectively skipping the backslash.
state.index += 1
state.push_character()
state.index += 1
state.push_word()
return state.words
The main issue I have with my code is that I feel it's a bit long and complex for what it is accomplishing; is there a simpler way to do it?
I'm not sure how relevant this is but this code is part of a Dockerfile parser. You can find the Go implementation here.
I know the Go implementation is much shorter, however I tried implementing their design and it ended up rather large and "non-pythonic". I am actually using the original logic from the Go parser but with a custom state enum and parser state object.
shlex.split
? \$\endgroup\$shlex.split
, as it seems to follow pretty much the same rules my code does (except it removes the outer quotes from strings). \$\endgroup\$