Module refinery.lib.scripts.au3.lexer
Expand source code Browse git
from __future__ import annotations
import string
from dataclasses import dataclass
from typing import Generator
from refinery.lib.scripts.au3.token import KEYWORDS, Au3Token, Au3TokenKind
_HEX = frozenset('0123456789abcdefABCDEF')
_TWO_CHAR_OPS: dict[str, Au3TokenKind] = {
'==' : Au3TokenKind.EQ_STRICT,
'<>' : Au3TokenKind.NE,
'<=' : Au3TokenKind.LE,
'>=' : Au3TokenKind.GE,
'+=' : Au3TokenKind.PLUS_ASSIGN,
'-=' : Au3TokenKind.MINUS_ASSIGN,
'*=' : Au3TokenKind.MUL_ASSIGN,
'/=' : Au3TokenKind.DIV_ASSIGN,
'&=' : Au3TokenKind.CONCAT_ASSIGN,
}
_ONE_CHAR_OPS: dict[str, Au3TokenKind] = {
'=' : Au3TokenKind.ASSIGN_OR_EQ,
'+' : Au3TokenKind.PLUS,
'-' : Au3TokenKind.MINUS,
'*' : Au3TokenKind.STAR,
'/' : Au3TokenKind.SLASH,
'^' : Au3TokenKind.CARET,
'&' : Au3TokenKind.AMP,
'<' : Au3TokenKind.LT,
'>' : Au3TokenKind.GT,
'?' : Au3TokenKind.QUESTION,
':' : Au3TokenKind.COLON,
'(' : Au3TokenKind.LPAREN,
')' : Au3TokenKind.RPAREN,
'[' : Au3TokenKind.LBRACKET,
']' : Au3TokenKind.RBRACKET,
',' : Au3TokenKind.COMMA,
'.' : Au3TokenKind.DOT,
}
#: Every character the lexer ends a line on, and with it a comment, a directive, or a string
#: literal: `refinery.lib.scripts.au3.analysis.values` reads the set to refuse a value no
#: literal can spell.
LINE_BREAKS = frozenset('\r\n\u2028\u2029')
_ASCII_LOWER = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
_BLOCK_COMMENT_START = ('#cs', '#comments-start')
_BLOCK_COMMENT_END = ('#ce', '#comments-end')
def _is_ident_start(c: str) -> bool:
return c.isalpha() or c == '_' or ord(c) >= 0x80
def _is_ident_part(c: str) -> bool:
return c.isalnum() or c == '_' or ord(c) >= 0x80
def is_au3_identifier(text: str) -> bool:
"""
Whether *text* is a shape the lexer reads as exactly one name: a first character a name
starts with and only characters a name continues with, so a caller that prints it back can
rely on the lexer reading the same name again.
"""
return bool(text) and _is_ident_start(text[0]) and all(
_is_ident_part(c) for c in text[1:]
)
def ascii_lower(text: str) -> str:
"""
The lowercase spelling of *text* with ASCII letters only. Python's `str.lower` also folds
characters outside ASCII into ASCII letters — U+212A KELVIN SIGN becomes `k` — and whether
AutoIt folds them too is unpinned, so a caller that may only rewrite a name it knows the
engine matches compares through this instead.
"""
return text.translate(_ASCII_LOWER)
def decode_au3_string(text: str) -> str:
"""
Decode an AutoIt string literal, including its delimiting quotes. A doubled quote is the
only escape sequence; both quote styles delimit strings. An unterminated literal decodes
to its body.
"""
quote = text[0]
body = text[1:-1] if len(text) > 1 and text[-1] == quote else text[1:]
return body.replace(quote * 2, quote)
def encode_au3_string(value: str, quote: str = '"') -> str:
"""
Encode a string as an AutoIt string literal with the given quote character.
"""
return quote + value.replace(quote, quote * 2) + quote
@dataclass
class Au3Lexer:
source: str
pos: int = 0
def _at_end(self) -> bool:
return self.pos >= len(self.source)
def _read_line_comment(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
while self.pos < length and src[self.pos] not in LINE_BREAKS:
self.pos += 1
return Au3Token(Au3TokenKind.COMMENT, src[start:self.pos], start)
def _match_word(self, words: tuple[str, ...]) -> int | None:
"""
Return the length of the first of `words` that starts at the cursor and is not the
prefix of a longer identifier, or `None`. Matching is case-insensitive.
"""
src = self.source
for word in words:
end = self.pos + len(word)
if src[self.pos:end].lower() == word:
after = src[end:end + 1]
if not after or not _is_ident_part(after):
return len(word)
return None
def _read_block_comment(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
depth = 0
while self.pos < length:
if (n := self._match_word(_BLOCK_COMMENT_END)) is not None:
self.pos += n
depth -= 1
elif (n := self._match_word(_BLOCK_COMMENT_START)) is not None:
self.pos += n
depth += 1
else:
self.pos += 1
continue
if depth == 0:
break
end = self.pos
if depth != 0 and end > start and src[end - 1] in LINE_BREAKS:
# A comment the file never closes runs to its end, and the break that ends that last
# line is the one the synthesizer writes after every statement: leaving it out of the
# comment is what makes printing one read back as itself. A terminated comment already
# stops at its closing word, so only the unterminated one needs this.
if end - 1 > start and src[end - 1] == '\n' and src[end - 2] == '\r':
end -= 2
else:
end -= 1
self.pos = end
return Au3Token(Au3TokenKind.COMMENT, src[start:end], start)
def _read_directive(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
while self.pos < length and src[self.pos] not in LINE_BREAKS:
self.pos += 1
return Au3Token(Au3TokenKind.DIRECTIVE, src[start:self.pos], start)
def _read_string(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
quote = src[self.pos]
self.pos += 1
while self.pos < length:
c = src[self.pos]
if c in LINE_BREAKS:
break
if c == quote:
self.pos += 1
if self.pos < length and src[self.pos] == quote:
self.pos += 1
continue
break
self.pos += 1
return Au3Token(Au3TokenKind.STRING, src[start:self.pos], start)
def _read_number(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
if src[self.pos] == '0' and src[self.pos + 1:self.pos + 2] in ('x', 'X'):
if src[self.pos + 2:self.pos + 3] and src[self.pos + 2] in _HEX:
self.pos += 2
while self.pos < length and src[self.pos] in _HEX:
self.pos += 1
return Au3Token(Au3TokenKind.INTEGER, src[start:self.pos], start)
is_float = False
while self.pos < length and src[self.pos].isdigit():
self.pos += 1
if (
self.pos < length
and src[self.pos] == '.'
and self.pos + 1 < length
and src[self.pos + 1].isdigit()
):
is_float = True
self.pos += 1
while self.pos < length and src[self.pos].isdigit():
self.pos += 1
if self.pos < length and src[self.pos] in 'eE':
peek = self.pos + 1
if peek < length and src[peek] in '+-':
peek += 1
if peek < length and src[peek].isdigit():
is_float = True
self.pos = peek
while self.pos < length and src[self.pos].isdigit():
self.pos += 1
kind = Au3TokenKind.FLOAT if is_float else Au3TokenKind.INTEGER
return Au3Token(kind, src[start:self.pos], start)
def _read_prefixed_name(self, kind: Au3TokenKind) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
self.pos += 1
while self.pos < length and _is_ident_part(src[self.pos]):
self.pos += 1
return Au3Token(kind, src[start:self.pos], start)
def _read_identifier(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
while self.pos < length and _is_ident_part(src[self.pos]):
self.pos += 1
word = src[start:self.pos]
kind = KEYWORDS.get(word.lower())
if kind is not None:
return Au3Token(kind, word, start)
return Au3Token(Au3TokenKind.IDENTIFIER, word, start)
def _consume_continuation(self):
"""
The cursor is on a line-continuation underscore: consume it, the rest of its line, and
the line break itself, so the logical line continues on the next physical one. Blank
lines after the break are consumed as well.
"""
src = self.source
length = len(src)
while self.pos < length and src[self.pos] not in LINE_BREAKS:
self.pos += 1
while self.pos < length and src[self.pos] in LINE_BREAKS:
self.pos += 1
while self.pos < length and src[self.pos] in ' \t':
self.pos += 1
if self.pos < length and src[self.pos] not in LINE_BREAKS:
return
def _is_blank_rest_of_line(self) -> bool:
"""
Check whether everything from just after the cursor's underscore to the end of the
line is whitespace or a comment.
"""
src = self.source
length = len(src)
pos = self.pos + 1
while pos < length and src[pos] in ' \t':
pos += 1
if pos < length and src[pos] == ';':
while pos < length and src[pos] not in LINE_BREAKS:
pos += 1
return pos >= length or src[pos] in LINE_BREAKS
def _read_newline(self) -> Au3Token:
src = self.source
length = len(src)
start = self.pos
while self.pos < length:
c = src[self.pos]
if c in LINE_BREAKS:
self.pos += 1
elif c in ' \t':
self.pos += 1
else:
break
return Au3Token(Au3TokenKind.NEWLINE, '', start)
def tokenize(self) -> Generator[Au3Token, None, None]:
src = self.source
length = len(src)
while True:
while self.pos < length and src[self.pos] in ' \t\f\v':
self.pos += 1
if self._at_end():
yield Au3Token(Au3TokenKind.EOF, '', self.pos)
return
start = self.pos
c = src[self.pos]
c2 = src[self.pos:self.pos + 2]
if c in LINE_BREAKS:
yield self._read_newline()
continue
if c == ';':
yield self._read_line_comment()
continue
if c == '#':
if self._match_word(_BLOCK_COMMENT_START) is not None:
yield self._read_block_comment()
continue
yield self._read_directive()
continue
if c in '"\'':
yield self._read_string()
continue
if c == '$' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]):
yield self._read_prefixed_name(Au3TokenKind.VARIABLE)
continue
if c == '@' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]):
yield self._read_prefixed_name(Au3TokenKind.MACRO)
continue
if c.isdigit() or c == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit():
yield self._read_number()
continue
if c == '_' and (self.pos + 1 >= length or not _is_ident_part(src[self.pos + 1])):
if self._is_blank_rest_of_line():
self._consume_continuation()
continue
self.pos += 1
yield Au3Token(Au3TokenKind.ERROR, '_', start)
continue
if _is_ident_start(c):
yield self._read_identifier()
continue
if c2 in _TWO_CHAR_OPS:
self.pos += 2
yield Au3Token(_TWO_CHAR_OPS[c2], c2, start)
continue
if c in _ONE_CHAR_OPS:
self.pos += 1
yield Au3Token(_ONE_CHAR_OPS[c], c, start)
continue
self.pos += 1
yield Au3Token(Au3TokenKind.ERROR, c, start)
Global variables
var LINE_BREAKS-
Every character the lexer ends a line on, and with it a comment, a directive, or a string literal:
refinery.lib.scripts.au3.analysis.valuesreads the set to refuse a value no literal can spell.
Functions
def is_au3_identifier(text)-
Whether text is a shape the lexer reads as exactly one name: a first character a name starts with and only characters a name continues with, so a caller that prints it back can rely on the lexer reading the same name again.
Expand source code Browse git
def is_au3_identifier(text: str) -> bool: """ Whether *text* is a shape the lexer reads as exactly one name: a first character a name starts with and only characters a name continues with, so a caller that prints it back can rely on the lexer reading the same name again. """ return bool(text) and _is_ident_start(text[0]) and all( _is_ident_part(c) for c in text[1:] ) def ascii_lower(text)-
The lowercase spelling of text with ASCII letters only. Python's
str.loweralso folds characters outside ASCII into ASCII letters — U+212A KELVIN SIGN becomesk— and whether AutoIt folds them too is unpinned, so a caller that may only rewrite a name it knows the engine matches compares through this instead.Expand source code Browse git
def ascii_lower(text: str) -> str: """ The lowercase spelling of *text* with ASCII letters only. Python's `str.lower` also folds characters outside ASCII into ASCII letters — U+212A KELVIN SIGN becomes `k` — and whether AutoIt folds them too is unpinned, so a caller that may only rewrite a name it knows the engine matches compares through this instead. """ return text.translate(_ASCII_LOWER) def decode_au3_string(text)-
Decode an AutoIt string literal, including its delimiting quotes. A doubled quote is the only escape sequence; both quote styles delimit strings. An unterminated literal decodes to its body.
Expand source code Browse git
def decode_au3_string(text: str) -> str: """ Decode an AutoIt string literal, including its delimiting quotes. A doubled quote is the only escape sequence; both quote styles delimit strings. An unterminated literal decodes to its body. """ quote = text[0] body = text[1:-1] if len(text) > 1 and text[-1] == quote else text[1:] return body.replace(quote * 2, quote) def encode_au3_string(value, quote='"')-
Encode a string as an AutoIt string literal with the given quote character.
Expand source code Browse git
def encode_au3_string(value: str, quote: str = '"') -> str: """ Encode a string as an AutoIt string literal with the given quote character. """ return quote + value.replace(quote, quote * 2) + quote
Classes
class Au3Lexer (source, pos=0)-
Au3Lexer(source: 'str', pos: 'int' = 0)
Expand source code Browse git
@dataclass class Au3Lexer: source: str pos: int = 0 def _at_end(self) -> bool: return self.pos >= len(self.source) def _read_line_comment(self) -> Au3Token: src = self.source length = len(src) start = self.pos while self.pos < length and src[self.pos] not in LINE_BREAKS: self.pos += 1 return Au3Token(Au3TokenKind.COMMENT, src[start:self.pos], start) def _match_word(self, words: tuple[str, ...]) -> int | None: """ Return the length of the first of `words` that starts at the cursor and is not the prefix of a longer identifier, or `None`. Matching is case-insensitive. """ src = self.source for word in words: end = self.pos + len(word) if src[self.pos:end].lower() == word: after = src[end:end + 1] if not after or not _is_ident_part(after): return len(word) return None def _read_block_comment(self) -> Au3Token: src = self.source length = len(src) start = self.pos depth = 0 while self.pos < length: if (n := self._match_word(_BLOCK_COMMENT_END)) is not None: self.pos += n depth -= 1 elif (n := self._match_word(_BLOCK_COMMENT_START)) is not None: self.pos += n depth += 1 else: self.pos += 1 continue if depth == 0: break end = self.pos if depth != 0 and end > start and src[end - 1] in LINE_BREAKS: # A comment the file never closes runs to its end, and the break that ends that last # line is the one the synthesizer writes after every statement: leaving it out of the # comment is what makes printing one read back as itself. A terminated comment already # stops at its closing word, so only the unterminated one needs this. if end - 1 > start and src[end - 1] == '\n' and src[end - 2] == '\r': end -= 2 else: end -= 1 self.pos = end return Au3Token(Au3TokenKind.COMMENT, src[start:end], start) def _read_directive(self) -> Au3Token: src = self.source length = len(src) start = self.pos while self.pos < length and src[self.pos] not in LINE_BREAKS: self.pos += 1 return Au3Token(Au3TokenKind.DIRECTIVE, src[start:self.pos], start) def _read_string(self) -> Au3Token: src = self.source length = len(src) start = self.pos quote = src[self.pos] self.pos += 1 while self.pos < length: c = src[self.pos] if c in LINE_BREAKS: break if c == quote: self.pos += 1 if self.pos < length and src[self.pos] == quote: self.pos += 1 continue break self.pos += 1 return Au3Token(Au3TokenKind.STRING, src[start:self.pos], start) def _read_number(self) -> Au3Token: src = self.source length = len(src) start = self.pos if src[self.pos] == '0' and src[self.pos + 1:self.pos + 2] in ('x', 'X'): if src[self.pos + 2:self.pos + 3] and src[self.pos + 2] in _HEX: self.pos += 2 while self.pos < length and src[self.pos] in _HEX: self.pos += 1 return Au3Token(Au3TokenKind.INTEGER, src[start:self.pos], start) is_float = False while self.pos < length and src[self.pos].isdigit(): self.pos += 1 if ( self.pos < length and src[self.pos] == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit() ): is_float = True self.pos += 1 while self.pos < length and src[self.pos].isdigit(): self.pos += 1 if self.pos < length and src[self.pos] in 'eE': peek = self.pos + 1 if peek < length and src[peek] in '+-': peek += 1 if peek < length and src[peek].isdigit(): is_float = True self.pos = peek while self.pos < length and src[self.pos].isdigit(): self.pos += 1 kind = Au3TokenKind.FLOAT if is_float else Au3TokenKind.INTEGER return Au3Token(kind, src[start:self.pos], start) def _read_prefixed_name(self, kind: Au3TokenKind) -> Au3Token: src = self.source length = len(src) start = self.pos self.pos += 1 while self.pos < length and _is_ident_part(src[self.pos]): self.pos += 1 return Au3Token(kind, src[start:self.pos], start) def _read_identifier(self) -> Au3Token: src = self.source length = len(src) start = self.pos while self.pos < length and _is_ident_part(src[self.pos]): self.pos += 1 word = src[start:self.pos] kind = KEYWORDS.get(word.lower()) if kind is not None: return Au3Token(kind, word, start) return Au3Token(Au3TokenKind.IDENTIFIER, word, start) def _consume_continuation(self): """ The cursor is on a line-continuation underscore: consume it, the rest of its line, and the line break itself, so the logical line continues on the next physical one. Blank lines after the break are consumed as well. """ src = self.source length = len(src) while self.pos < length and src[self.pos] not in LINE_BREAKS: self.pos += 1 while self.pos < length and src[self.pos] in LINE_BREAKS: self.pos += 1 while self.pos < length and src[self.pos] in ' \t': self.pos += 1 if self.pos < length and src[self.pos] not in LINE_BREAKS: return def _is_blank_rest_of_line(self) -> bool: """ Check whether everything from just after the cursor's underscore to the end of the line is whitespace or a comment. """ src = self.source length = len(src) pos = self.pos + 1 while pos < length and src[pos] in ' \t': pos += 1 if pos < length and src[pos] == ';': while pos < length and src[pos] not in LINE_BREAKS: pos += 1 return pos >= length or src[pos] in LINE_BREAKS def _read_newline(self) -> Au3Token: src = self.source length = len(src) start = self.pos while self.pos < length: c = src[self.pos] if c in LINE_BREAKS: self.pos += 1 elif c in ' \t': self.pos += 1 else: break return Au3Token(Au3TokenKind.NEWLINE, '', start) def tokenize(self) -> Generator[Au3Token, None, None]: src = self.source length = len(src) while True: while self.pos < length and src[self.pos] in ' \t\f\v': self.pos += 1 if self._at_end(): yield Au3Token(Au3TokenKind.EOF, '', self.pos) return start = self.pos c = src[self.pos] c2 = src[self.pos:self.pos + 2] if c in LINE_BREAKS: yield self._read_newline() continue if c == ';': yield self._read_line_comment() continue if c == '#': if self._match_word(_BLOCK_COMMENT_START) is not None: yield self._read_block_comment() continue yield self._read_directive() continue if c in '"\'': yield self._read_string() continue if c == '$' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]): yield self._read_prefixed_name(Au3TokenKind.VARIABLE) continue if c == '@' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]): yield self._read_prefixed_name(Au3TokenKind.MACRO) continue if c.isdigit() or c == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit(): yield self._read_number() continue if c == '_' and (self.pos + 1 >= length or not _is_ident_part(src[self.pos + 1])): if self._is_blank_rest_of_line(): self._consume_continuation() continue self.pos += 1 yield Au3Token(Au3TokenKind.ERROR, '_', start) continue if _is_ident_start(c): yield self._read_identifier() continue if c2 in _TWO_CHAR_OPS: self.pos += 2 yield Au3Token(_TWO_CHAR_OPS[c2], c2, start) continue if c in _ONE_CHAR_OPS: self.pos += 1 yield Au3Token(_ONE_CHAR_OPS[c], c, start) continue self.pos += 1 yield Au3Token(Au3TokenKind.ERROR, c, start)Instance variables
var source-
The type of the None singleton.
var pos-
The type of the None singleton.
Methods
def tokenize(self)-
Expand source code Browse git
def tokenize(self) -> Generator[Au3Token, None, None]: src = self.source length = len(src) while True: while self.pos < length and src[self.pos] in ' \t\f\v': self.pos += 1 if self._at_end(): yield Au3Token(Au3TokenKind.EOF, '', self.pos) return start = self.pos c = src[self.pos] c2 = src[self.pos:self.pos + 2] if c in LINE_BREAKS: yield self._read_newline() continue if c == ';': yield self._read_line_comment() continue if c == '#': if self._match_word(_BLOCK_COMMENT_START) is not None: yield self._read_block_comment() continue yield self._read_directive() continue if c in '"\'': yield self._read_string() continue if c == '$' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]): yield self._read_prefixed_name(Au3TokenKind.VARIABLE) continue if c == '@' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]): yield self._read_prefixed_name(Au3TokenKind.MACRO) continue if c.isdigit() or c == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit(): yield self._read_number() continue if c == '_' and (self.pos + 1 >= length or not _is_ident_part(src[self.pos + 1])): if self._is_blank_rest_of_line(): self._consume_continuation() continue self.pos += 1 yield Au3Token(Au3TokenKind.ERROR, '_', start) continue if _is_ident_start(c): yield self._read_identifier() continue if c2 in _TWO_CHAR_OPS: self.pos += 2 yield Au3Token(_TWO_CHAR_OPS[c2], c2, start) continue if c in _ONE_CHAR_OPS: self.pos += 1 yield Au3Token(_ONE_CHAR_OPS[c], c, start) continue self.pos += 1 yield Au3Token(Au3TokenKind.ERROR, c, start)