# pyshlex.py - PLY compatible lexer for pysh. # # Copyright 2007 Patrick Mezard # # This software may be used and distributed according to the terms # of the GNU General Public License, incorporated herein by reference. # TODO: # - review all "char in 'abc'" snippets: the empty string can be matched # - test line continuations within quoted/expansion strings # - eof is buggy wrt sublexers # - the lexer cannot really work in pull mode as it would be required to run # PLY in pull mode. It was designed to work incrementally and it would not be # that hard to enable pull mode. import re try: s = set() del s except NameError: from Set import Set as set from ply import lex from sherrors import * class NeedMore(Exception): pass def is_blank(c): return c in (' ', '\t') _RE_DIGITS = re.compile(r'^\d+$') def are_digits(s): return _RE_DIGITS.search(s) is not None _OPERATORS = dict([ ('&&', 'AND_IF'), ('||', 'OR_IF'), (';;', 'DSEMI'), ('<<', 'DLESS'), ('>>', 'DGREAT'), ('<&', 'LESSAND'), ('>&', 'GREATAND'), ('<>', 'LESSGREAT'), ('<<-', 'DLESSDASH'), ('>|', 'CLOBBER'), ('&', 'AMP'), (';', 'COMMA'), ('<', 'LESS'), ('>', 'GREATER'), ('(', 'LPARENS'), (')', 'RPARENS'), ]) #Make a function to silence pychecker "Local variable shadows global" def make_partial_ops(): partials = {} for k in _OPERATORS: for i in range(1, len(k)+1): partials[k[:i]] = None return partials _PARTIAL_OPERATORS = make_partial_ops() def is_partial_op(s): """Return True if s matches a non-empty subpart of an operator starting at its first character. """ return s in _PARTIAL_OPERATORS def is_op(s): """If s matches an operator, returns the operator identifier. Return None otherwise. """ return _OPERATORS.get(s) _RESERVEDS = dict([ ('if', 'If'), ('then', 'Then'), ('else', 'Else'), ('elif', 'Elif'), ('fi', 'Fi'), ('do', 'Do'), ('done', 'Done'), ('case', 'Case'), ('esac', 'Esac'), ('while', 'While'), ('until', 'Until'), ('for', 'For'), ('{', 'Lbrace'), ('}', 'Rbrace'), ('!', 'Bang'), ('in', 'In'), ('|', 'PIPE'), ]) def get_reserved(s): return _RESERVEDS.get(s) _RE_NAME = re.compile(r'^[0-9a-zA-Z_]+$') def is_name(s): return _RE_NAME.search(s) is not None def find_chars(seq, chars): for i,v in enumerate(seq): if v in chars: return i,v return -1, None class WordLexer: """WordLexer parse quoted or expansion expressions and return an expression tree. The input string can be any well formed sequence beginning with quoting or expansion character. Embedded expressions are handled recursively. The resulting tree is made of lists and strings. Lists represent quoted or expansion expressions. Each list first element is the opening separator, the last one the closing separator. In-between can be any number of strings or lists for sub-expressions. Non quoted/expansion expression can written as strings or as lists with empty strings as starting and ending delimiters. """ NAME_CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_' NAME_CHARSET = dict(zip(NAME_CHARSET, NAME_CHARSET)) SPECIAL_CHARSET = '@*#?-$!0' #Characters which can be escaped depends on the current delimiters ESCAPABLE = { '`': set(['$', '\\', '`']), '"': set(['$', '\\', '`', '"']), "'": set(), } def __init__(self, heredoc = False): # _buffer is the unprocessed input characters buffer self._buffer = [] # _stack is empty or contains a quoted list being processed # (this is the DFS path to the quoted expression being evaluated). self._stack = [] self._escapable = None # True when parsing unquoted here documents self._heredoc = heredoc def add(self, data, eof=False): """Feed the lexer with more data. If the quoted expression can be delimited, return a tuple (expr, remaining) containing the expression tree and the unconsumed data. Otherwise, raise NeedMore. """ self._buffer += list(data) self._parse(eof) result = self._stack[0] remaining = ''.join(self._buffer) self._stack = [] self._buffer = [] return result, remaining def _is_escapable(self, c, delim=None): if delim is None: if self._heredoc: # Backslashes works as if they were double quoted in unquoted # here-documents delim = '"' else: if len(self._stack)<=1: return True delim = self._stack[-2][0] escapables = self.ESCAPABLE.get(delim, None) return escapables is None or c in escapables def _parse_squote(self, buf, result, eof): if not buf: raise NeedMore() try: pos = buf.index("'") except ValueError: raise NeedMore() result[-1] += ''.join(buf[:pos]) result += ["'"] return pos+1, True def _parse_bquote(self, buf, result, eof): if not buf: raise NeedMore() if buf[0]=='\n': #Remove line continuations result[:] = ['', '', ''] elif self._is_escapable(buf[0]): result[-1] += buf[0] result += [''] else: #Keep as such result[:] = ['', '\\'+buf[0], ''] return 1, True def _parse_dquote(self, buf, result, eof): if not buf: raise NeedMore() pos, sep = find_chars(buf, '$\\`"') if pos==-1: raise NeedMore() result[-1] += ''.join(buf[:pos]) if sep=='"': result += ['"'] return pos+1, True else: #Keep everything until the separator and defer processing return pos, False def _parse_command(self, buf, result, eof): if not buf: raise NeedMore() chars = '$\\`"\'' if result[0] == '$(': chars += ')' pos, sep = find_chars(buf, chars) if pos == -1: raise NeedMore() result[-1] += ''.join(buf[:pos]) if (result[0]=='$(' and sep==')') or (result[0]=='`' and sep=='`'): result += [sep] return pos+1, True else: return pos, False def _parse_parameter(self, buf, result, eof): if not buf: raise NeedMore() pos, sep = find_chars(buf, '$\\`"\'}') if pos==-1: raise NeedMore() result[-1] += ''.join(buf[:pos]) if sep=='}': result += [sep] return pos+1, True else: return pos, False def _parse_dollar(self, buf, result, eof): sep = result[0] if sep=='$': if not buf: #TODO: handle empty $ raise NeedMore() if buf[0]=='(': if len(buf)==1: raise NeedMore() if buf[1]=='(': result[0] = '$((' buf[:2] = [] else: result[0] = '$(' buf[:1] = [] elif buf[0]=='{': result[0] = '${' buf[:1] = [] else: if buf[0] in self.SPECIAL_CHARSET: result[-1] = buf[0] read = 1 else: for read,c in enumerate(buf): if c not in self.NAME_CHARSET: break else: if not eof: raise NeedMore() read += 1 result[-1] += ''.join(buf[0:read]) if not result[-1]: result[:] = ['', result[0], ''] else: result += [''] return read,True sep = result[0] if sep=='$(': parsefunc = self._parse_command elif sep=='${': parsefunc = self._parse_parameter else: raise NotImplementedError() pos, closed = parsefunc(buf, result, eof) return pos, closed def _parse(self, eof): buf = self._buffer stack = self._stack recurse = False while 1: if not stack or recurse: if not buf: raise NeedMore() if buf[0] not in ('"\\`$\''): raise ShellSyntaxError('Invalid quoted string sequence') stack.append([buf[0], '']) buf[:1] = [] recurse = False result = stack[-1] if result[0]=="'": parsefunc = self._parse_squote elif result[0]=='\\': parsefunc = self._parse_bquote elif result[0]=='"': parsefunc = self._parse_dquote elif result[0]=='`': parsefunc = self._parse_command elif result[0][0]=='$': parsefunc = self._parse_dollar else: raise NotImplementedError() read, closed = parsefunc(buf, result, eof) buf[:read] = [] if closed: if len(stack)>1: #Merge in parent expression parsed = stack.pop() stack[-1] += [parsed] stack[-1] += [''] else: break else: recurse = True def normalize_wordtree(wtree): """Fold back every literal sequence (delimited with empty strings) into parent sequence. """ def normalize(wtree): result = [] for part in wtree[1:-1]: if isinstance(part, list): part = normalize(part) if part[0]=='': #Move the part content back at current level result += part[1:-1] continue elif not part: #Remove empty strings continue result.append(part) if not result: result = [''] return [wtree[0]] + result + [wtree[-1]] return normalize(wtree) def make_wordtree(token, here_document=False): """Parse a delimited token and return a tree similar to the ones returned by WordLexer. token may contain any combinations of expansion/quoted fields and non-ones. """ tree = [''] remaining = token delimiters = '\\$`' if not here_document: delimiters += '\'"' while 1: pos, sep = find_chars(remaining, delimiters) if pos==-1: tree += [remaining, ''] return normalize_wordtree(tree) tree.append(remaining[:pos]) remaining = remaining[pos:] try: result, remaining = WordLexer(heredoc = here_document).add(remaining, True) except NeedMore: raise ShellSyntaxError('Invalid token "%s"') tree.append(result) def wordtree_as_string(wtree): """Rewrite an expression tree generated by make_wordtree as string.""" def visit(node, output): for child in node: if isinstance(child, list): visit(child, output) else: output.append(child) output = [] visit(wtree, output) return ''.join(output) def unquote_wordtree(wtree): """Fold the word tree while removing quotes everywhere. Other expansion sequences are joined as such. """ def unquote(wtree): unquoted = [] if wtree[0] in ('', "'", '"', '\\'): wtree = wtree[1:-1] for part in wtree: if isinstance(part, list): part = unquote(part) unquoted.append(part) return ''.join(unquoted) return unquote(wtree) class HereDocLexer: """HereDocLexer delimits whatever comes from the here-document starting newline not included to the closing delimiter line included. """ def __init__(self, op, delim): assert op in ('<<', '<<-') if not delim: raise ShellSyntaxError('invalid here document delimiter %s' % str(delim)) self._op = op self._delim = delim self._buffer = [] self._token = [] def add(self, data, eof): """If the here-document was delimited, return a tuple (content, remaining). Raise NeedMore() otherwise. """ self._buffer += list(data) self._parse(eof) token = ''.join(self._token) remaining = ''.join(self._buffer) self._token, self._remaining = [], [] return token, remaining def _parse(self, eof): while 1: #Look for first unescaped newline. Quotes may be ignored escaped = False for i,c in enumerate(self._buffer): if escaped: escaped = False elif c=='\\': escaped = True elif c=='\n': break else: i = -1 if i==-1 or self._buffer[i]!='\n': if not eof: raise NeedMore() #No more data, maybe the last line is closing delimiter line = ''.join(self._buffer) eol = '' self._buffer[:] = [] else: line = ''.join(self._buffer[:i]) eol = self._buffer[i] self._buffer[:i+1] = [] if self._op=='<<-': line = line.lstrip('\t') if line==self._delim: break self._token += [line, eol] if i==-1: break class Token: #TODO: check this is still in use OPERATOR = 'OPERATOR' WORD = 'WORD' def __init__(self): self.value = '' self.type = None def __getitem__(self, key): #Behave like a two elements tuple if key==0: return self.type if key==1: return self.value raise IndexError(key) class HereDoc: def __init__(self, op, name=None): self.op = op self.name = name self.pendings = [] TK_COMMA = 'COMMA' TK_AMPERSAND = 'AMP' TK_OP = 'OP' TK_TOKEN = 'TOKEN' TK_COMMENT = 'COMMENT' TK_NEWLINE = 'NEWLINE' TK_IONUMBER = 'IO_NUMBER' TK_ASSIGNMENT = 'ASSIGNMENT_WORD' TK_HERENAME = 'HERENAME' class Lexer: """Main lexer. Call add() until the script AST is returned. """ # Here-document handling makes the whole thing more complex because they basically # force tokens to be reordered: here-content must come right after the operator # and the here-document name, while some other tokens might be following the # here-document expression on the same line. # # So, here-doc states are basically: # *self._state==ST_NORMAL # - self._heredoc.op is None: no here-document # - self._heredoc.op is not None but name is: here-document operator matched, # waiting for the document name/delimiter # - self._heredoc.op and name are not None: here-document is ready, following # tokens are being stored and will be pushed again when the document is # completely parsed. # *self._state==ST_HEREDOC # - The here-document is being delimited by self._herelexer. Once it is done # the content is pushed in front of the pending token list then all these # tokens are pushed once again. ST_NORMAL = 'ST_NORMAL' ST_OP = 'ST_OP' ST_BACKSLASH = 'ST_BACKSLASH' ST_QUOTED = 'ST_QUOTED' ST_COMMENT = 'ST_COMMENT' ST_HEREDOC = 'ST_HEREDOC' #Match end of backquote strings RE_BACKQUOTE_END = re.compile(r'(?=len(self._input): if not eof: raise NeedMore() elif self._state not in (self.ST_OP, self.ST_QUOTED, self.ST_HEREDOC): #Delimit the current token and leave cleanly self._push_token('') break else: #Let the sublexer handle the eof themselves pass if self._state==self.ST_NORMAL: self._parse_normal() elif self._state==self.ST_COMMENT: self._parse_comment() elif self._state==self.ST_OP: self._parse_op(eof) elif self._state==self.ST_QUOTED: self._parse_quoted(eof) elif self._state==self.ST_HEREDOC: self._parse_heredoc(eof) else: assert False, "Unknown state " + str(self._state) if self._heredoc.op is not None: raise ShellSyntaxError('missing here-document delimiter') def _parse_normal(self): c = self._input[self._pos] if c=='\n': self._push_token(c) self._token = c self._type = TK_NEWLINE self._push_token('') self._pos += 1 elif c in ('\\', '\'', '"', '`', '$'): self._state = self.ST_QUOTED elif is_partial_op(c): self._push_token(c) self._type = TK_OP self._token += c self._pos += 1 self._state = self.ST_OP elif is_blank(c): self._push_token(c) #Discard blanks self._pos += 1 elif self._token: self._token += c self._pos += 1 elif c=='#': self._state = self.ST_COMMENT self._type = TK_COMMENT self._pos += 1 else: self._pos += 1 self._token += c def _parse_op(self, eof): assert self._token while 1: if self._pos>=len(self._input): if not eof: raise NeedMore() c = '' else: c = self._input[self._pos] op = self._token + c if c and is_partial_op(op): #Still parsing an operator self._token = op self._pos += 1 else: #End of operator self._push_token(c) self._state = self.ST_NORMAL break def _parse_comment(self): while 1: if self._pos>=len(self._input): raise NeedMore() c = self._input[self._pos] if c=='\n': #End of comment, do not consume the end of line self._state = self.ST_NORMAL break else: self._token += c self._pos += 1 def _parse_quoted(self, eof): """Precondition: the starting backquote/dollar is still in the input queue.""" if not self._wordlexer: self._wordlexer = WordLexer() if self._pos'): #Detect IO_NUMBER self._type = TK_IONUMBER elif self._token==';': self._type = TK_COMMA elif self._token=='&': self._type = TK_AMPERSAND elif self._type==TK_COMMENT: #Comments are not part of sh grammar, ignore them self._token = '' self._type = TK_TOKEN return 0 if self._for_count is not None: #Track token count in 'For' expression to detect 'In' reserved words. #Can only be in third position, no need to go beyond self._for_count += 1 if self._for_count==3: self._for_count = None self.on_token((self._token, self._type)) self._token = '' self._type = TK_TOKEN return 1 def on_token(self, token): raise NotImplementedError tokens = [ TK_TOKEN, # To silence yacc unused token warnings # TK_COMMENT, TK_NEWLINE, TK_IONUMBER, TK_ASSIGNMENT, TK_HERENAME, ] #Add specific operators tokens += _OPERATORS.values() #Add reserved words tokens += _RESERVEDS.values() class PLYLexer(Lexer): """Bridge Lexer and PLY lexer interface.""" def __init__(self): Lexer.__init__(self) self._tokens = [] self._current = 0 self.lineno = 0 def on_token(self, token): value, type = token self.lineno = 0 t = lex.LexToken() t.value = value t.type = type t.lexer = self t.lexpos = 0 t.lineno = 0 self._tokens.append(t) def is_empty(self): return not bool(self._tokens) #PLY compliant interface def token(self): if self._current>=len(self._tokens): return None t = self._tokens[self._current] self._current += 1 return t def get_tokens(s): """Parse the input string and return a tuple (tokens, unprocessed) where tokens is a list of parsed tokens and unprocessed is the part of the input string left untouched by the lexer. """ lexer = PLYLexer() untouched = lexer.add(s, True) tokens = [] while 1: token = lexer.token() if token is None: break tokens.append(token) tokens = [(t.value, t.type) for t in tokens] return tokens, untouched