Module refinery.lib.scripts.ps1.lexer

Expand source code Browse git
from __future__ import annotations

import enum
import re

from dataclasses import dataclass
from typing import Generator

from refinery.lib.scripts.ps1.token import (
    _KEYWORDS,
    _VARIABLE_PATTERN_CORE,
    DASHES,
    DOUBLE_QUOTES,
    SINGLE_QUOTES,
    forces_new_token,
    forces_new_token_after_number,
    is_whitespace,
    Ps1Token,
    Ps1TokenKind,
)


class Ps1LexerMode(enum.Enum):
    EXPRESSION = 'expression'
    ARGUMENT = 'argument'


_TWO_CHAR_OPS: dict[str, Ps1TokenKind] = {
    '+=' : Ps1TokenKind.PLUS_ASSIGN,
    '-=' : Ps1TokenKind.DASH_ASSIGN,
    '*=' : Ps1TokenKind.STAR_ASSIGN,
    '/=' : Ps1TokenKind.SLASH_ASSIGN,
    '%=' : Ps1TokenKind.PERCENT_ASSIGN,
    '++' : Ps1TokenKind.INCREMENT,
    '--' : Ps1TokenKind.DECREMENT,
    '..' : Ps1TokenKind.DOTDOT,
    '::' : Ps1TokenKind.DOUBLE_COLON,
    '&&' : Ps1TokenKind.DOUBLE_AMPERSAND,
    '||' : Ps1TokenKind.DOUBLE_PIPE,
    '@(' : Ps1TokenKind.AT_LPAREN,
    '@{' : Ps1TokenKind.AT_LBRACE,
    '$(' : Ps1TokenKind.DOLLAR_LPAREN,
}

_ONE_CHAR_OPS: dict[str, Ps1TokenKind] = {
    '+' : Ps1TokenKind.PLUS,
    '-' : Ps1TokenKind.DASH,
    '*' : Ps1TokenKind.STAR,
    '/' : Ps1TokenKind.SLASH,
    '%' : Ps1TokenKind.PERCENT,
    '.' : Ps1TokenKind.DOT,
    ',' : Ps1TokenKind.COMMA,
    ';' : Ps1TokenKind.SEMICOLON,
    '!' : Ps1TokenKind.EXCLAIM,
    '(' : Ps1TokenKind.LPAREN,
    ')' : Ps1TokenKind.RPAREN,
    '{' : Ps1TokenKind.LBRACE,
    '}' : Ps1TokenKind.RBRACE,
    '[' : Ps1TokenKind.LBRACKET,
    ']' : Ps1TokenKind.RBRACKET,
    '|' : Ps1TokenKind.PIPE,
    '&' : Ps1TokenKind.AMPERSAND,
    '=' : Ps1TokenKind.EQUALS,
    '<' : Ps1TokenKind.REDIRECT_IN,
}

_DASH_OPERATORS: dict[str, str] = {
    _name: F'-{_name}' for _name in (
        'and',
        'as',
        'band',
        'bnot',
        'bor',
        'bxor',
        'ccontains',
        'ceq',
        'cge',
        'cgt',
        'cin',
        'cle',
        'clike',
        'clt',
        'cmatch',
        'cne',
        'cnotcontains',
        'cnotin',
        'cnotlike',
        'cnotmatch',
        'contains',
        'creplace',
        'csplit',
        'eq',
        'f',
        'ge',
        'gt',
        'icontains',
        'ieq',
        'ige',
        'igt',
        'iin',
        'ile',
        'ilike',
        'ilt',
        'imatch',
        'in',
        'ine',
        'inotcontains',
        'inotin',
        'inotlike',
        'inotmatch',
        'ireplace',
        'is',
        'isnot',
        'isplit',
        'join',
        'le',
        'like',
        'lt',
        'match',
        'ne',
        'not',
        'notcontains',
        'notin',
        'notlike',
        'notmatch',
        'or',
        'replace',
        'shl',
        'shr',
        'split',
        'xor',
    )
}

_VARIABLE_STOPS_NO_RESCAN = frozenset('.[=')

_MEMBER_ACCESS_KINDS: dict[str, Ps1TokenKind] = {
    '.'  : Ps1TokenKind.DOT,
    '::' : Ps1TokenKind.DOUBLE_COLON,
    '['  : Ps1TokenKind.LBRACKET,
}

_REDIRECTION_PATTERN = re.compile(
    r'[1-6*](?:>>|>&[12]|>)'  # explicit stream: 2>&1, 2>>, 2>
    r'|>>|>&1|>',             # bare: >>, >&1, >
)

_DECIMAL_DIGITS = frozenset('0123456789')
_HEX_DIGITS = frozenset('0123456789abcdefABCDEF')
_HEX_MARKER = frozenset('xX')
_EXPONENT_MARKER = frozenset('eE')

#: The letters that name the type a numeral is read as. There are exactly two on 5.1 — an Int64 and
#: a Decimal — which is why every other width has to be spelled with a cast. The rest of the set
#: (`y`, `uy`, `s`, `us`, `u`, `ul`) arrived in 6.2 and `n` in 7.0, and none of them may be read
#: here: `1uy` is the word `1uy` on 5.1, not the byte one.
_TYPE_SUFFIX = frozenset('lLdD')
_DECIMAL_SUFFIX = frozenset('dD')

#: The letters that begin a binary multiplier. Each is a prefix and not the whole of one: the `b`
#: behind it is required, so `1kb` is a number where `1k` is a word.
_MULTIPLIER_START = frozenset('kKmMgGtTpP')
_MULTIPLIER_END = frozenset('bB')

_VARIABLE_PATTERN = re.compile(_VARIABLE_PATTERN_CORE, re.IGNORECASE)

_DASH_WORD = re.compile(r'[a-zA-Z]+')

_PARAMETER_TERMINATORS = frozenset(' \t\r\n{}();,|&.[')

_VARIABLE_START_CHARS = '_?{$^'


@dataclass
class Ps1Lexer:
    source: str
    pos: int = 0
    mode: Ps1LexerMode = Ps1LexerMode.ARGUMENT

    def _at_end(self) -> bool:
        return self.pos >= len(self.source)

    def _peek(self, ahead: int = 0) -> str:
        """
        The character `ahead` positions past `Ps1Lexer.pos`, or the empty string where the source
        has ended. Every rule that asks what comes next has to answer for the end of the source
        too, and this is the one place that answer is given: the character classes in
        `refinery.lib.scripts.ps1.token` read the empty string as the end and judge it accordingly.
        """
        return self.source[self.pos + ahead:self.pos + ahead + 1]

    def _skip_whitespace(self) -> bool:
        start = self.pos
        src = self.source
        length = len(src)
        while self.pos < length:
            c = src[self.pos]
            if is_whitespace(c):
                self.pos += 1
            elif c == '`' and self.pos + 1 < length and src[self.pos + 1] == '\n':
                self.pos += 2
            elif c == '`' and self.pos + 2 < length and src[self.pos + 1:self.pos + 3] == '\r\n':
                self.pos += 3
            else:
                break
        return self.pos > start

    def _read_line_comment(self) -> None:
        src = self.source
        length = len(src)
        while self.pos < length and src[self.pos] != '\n':
            self.pos += 1

    def _read_block_comment(self) -> None:
        src = self.source
        length = len(src)
        self.pos += 2
        while self.pos < length - 1:
            if src[self.pos] == '#' and src[self.pos + 1] == '>':
                self.pos += 2
                return
            self.pos += 1
        self.pos = length

    def _try_skip_expandable(self) -> bool:
        src = self.source
        length = len(src)
        c = src[self.pos]
        if c == '`' and self.pos + 1 < length:
            self.pos += 2
            return True
        if c == '$' and self.pos + 1 < length and src[self.pos + 1] == '(':
            self.pos += 2
            self._skip_subexpression_content()
            return True
        return False

    def _read_string(self, quote_set: frozenset[str], expandable: bool = False) -> str:
        start = self.pos
        src = self.source
        length = len(src)
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if expandable and self._try_skip_expandable():
                continue
            if c in quote_set:
                self.pos += 1
                if self.pos < length and src[self.pos] in quote_set:
                    self.pos += 1
                    continue
                return src[start:self.pos]
            self.pos += 1
        return src[start:self.pos]

    def _skip_subexpression_content(self):
        src = self.source
        length = len(src)
        depth = 1
        while self.pos < length:
            c = src[self.pos]
            if c == '`' and self.pos + 1 < length:
                self.pos += 2
                continue
            if c == '(':
                depth += 1
                self.pos += 1
                continue
            if c == ')':
                depth -= 1
                self.pos += 1
                if depth == 0:
                    return
                continue
            if c == '@' and self.pos + 1 < length:
                nc = src[self.pos + 1]
                if self.pos + 2 < length and src[self.pos + 2] in '\r\n':
                    if nc in SINGLE_QUOTES:
                        self._read_here_string(SINGLE_QUOTES)
                        continue
                    if nc in DOUBLE_QUOTES:
                        self._read_here_string(DOUBLE_QUOTES, expandable=True)
                        continue
            if c in SINGLE_QUOTES:
                self._read_string(SINGLE_QUOTES)
                continue
            if c in DOUBLE_QUOTES:
                self._read_string(DOUBLE_QUOTES, expandable=True)
                continue
            self.pos += 1

    def _skip_here_string_header(self):
        src = self.source
        length = len(src)
        self.pos += 2
        if self.pos < length and src[self.pos] == '\r':
            self.pos += 1
        if self.pos < length and src[self.pos] == '\n':
            self.pos += 1

    def _check_here_string_terminator(self, quote_set: frozenset[str]) -> bool:
        src = self.source
        length = len(src)
        if src[self.pos] == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n':
            nl_end = self.pos + 2
        else:
            nl_end = self.pos + 1
        if nl_end + 1 < length and src[nl_end] in quote_set and src[nl_end + 1] == '@':
            self.pos = nl_end + 2
            return True
        return False

    def _read_here_string(self, quote_set: frozenset[str], expandable: bool = False) -> str:
        start = self.pos
        src = self.source
        length = len(src)
        self._skip_here_string_header()
        while self.pos < length:
            c = src[self.pos]
            if c in '\r\n':
                if self._check_here_string_terminator(quote_set):
                    return src[start:self.pos]
            if expandable and self._try_skip_expandable():
                continue
            self.pos += 1
        return src[start:self.pos]

    def _read_variable(self, prefix: str) -> Ps1Token:
        start = self.pos
        self.pos += len(prefix)
        m = _VARIABLE_PATTERN.match(self.source, self.pos)
        if m:
            self.pos = m.end()
        kind = Ps1TokenKind.SPLAT_VARIABLE if prefix == '@' else Ps1TokenKind.VARIABLE
        return Ps1Token(kind, self.source[start:self.pos], start)

    def _read_digits(self, digits: frozenset[str]) -> int:
        start = self.pos
        while self._peek() in digits:
            self.pos += 1
        return self.pos - start

    def _read_exponent(self) -> bool:
        """
        Read the power an `e` was written for, and say whether one was there. A sign may stand
        between the two, and digits must: `1e3` is a thousand and `1e` is the word `1e`.
        """
        if self._peek() in ('+', '-'):
            self.pos += 1
        return self._read_digits(_DECIMAL_DIGITS) > 0

    def _read_fraction(self) -> bool:
        """
        Read what follows a numeral's dot: the digits after it, and the power they may be raised to.
        No digit is required — `1.` is the number one and `1.e1` is ten — because the dot has
        already settled that a numeral is being read.
        """
        self._read_digits(_DECIMAL_DIGITS)
        if self._peek() not in _EXPONENT_MARKER:
            return True
        self.pos += 1
        return self._read_exponent()

    def _read_number(self) -> Ps1Token | None:
        """
        The numeral written at `Ps1Lexer.pos`, or `None` where what stands there only looks like
        one. A digit is written there, or a dot and then a digit; `Ps1Lexer.scan` is what knows
        that, and nothing else calls this.

        The parts come in 5.1's order and are not alternatives to one another: a base, then a
        fraction or a power, then a type suffix, then a multiplier. Reading a suffix *or* a
        multiplier loses `1dkb`, which is a Decimal kilobyte, and `1.5L`, which is an Int64.

        A trailing dot belongs to the numeral — `3.` is the number three — and is given back only to
        the range operator, so that `3..5` counts from three. A dot after a power belongs to neither:
        only the plain-decimal branch ever eats one, so `1e3` ends where the dot begins and 5.1 reads
        what follows as a member.

        Three spellings end here rather than in `Ps1Lexer.scan`, because it is the numeral itself
        that cannot be one: `0x` names no digits, `1e` raises nothing to a power, and a multiplier
        with no `b` behind it is part of the word around it, so `1k` is a word where `1kb` is a
        number. Everything else the numeral did not swallow is `Ps1Lexer.scan`'s to judge.

        The kind reported is which literal the parser builds and not what the numeral is worth:
        `1kb` is a real to spell and an `Int32` to read, and `refinery.lib.scripts.ps1.analysis
        .values.read` is what decides the second.
        """
        start = self.pos
        real = False
        if self._peek() == '.':
            self.pos += 1
            if not self._read_fraction():
                self.pos = start
                return None
            real = True
        elif self._peek() == '0' and self._peek(1) in _HEX_MARKER:
            self.pos += 2
            if not self._read_digits(_HEX_DIGITS):
                self.pos = start
                return None
        else:
            self._read_digits(_DECIMAL_DIGITS)
            if self._peek() == '.' and self._peek(1) != '.':
                self.pos += 1
                if not self._read_fraction():
                    self.pos = start
                    return None
                real = True
            elif self._peek() in _EXPONENT_MARKER:
                self.pos += 1
                if not self._read_exponent():
                    self.pos = start
                    return None
                real = True
        suffix = self._peek()
        if suffix in _TYPE_SUFFIX:
            self.pos += 1
            real = real or suffix in _DECIMAL_SUFFIX
        if self._peek() in _MULTIPLIER_START:
            self.pos += 1
            if self._peek() not in _MULTIPLIER_END:
                self.pos = start
                return None
            self.pos += 1
            real = True
        kind = Ps1TokenKind.REAL if real else Ps1TokenKind.INTEGER
        return Ps1Token(kind, self.source[start:self.pos], start)

    def _numeral_ends_here(self) -> bool:
        """
        Whether the numeral just read really ends where it stopped. Which characters can end one
        depends on the slot: where an expression is read a numeral ends on an operator too, so `1+2`
        is three tokens, and where a bare word is a value only a token terminator will do, so `f 1+2`
        passes the single word `1+2`.
        """
        c = self._peek()
        if forces_new_token(c):
            return True
        return self.mode is Ps1LexerMode.EXPRESSION and forces_new_token_after_number(c)

    def _try_dash_operator(self) -> Ps1Token | None:
        src = self.source
        start = self.pos
        self.pos += 1
        m = _DASH_WORD.match(src, self.pos)
        if not m:
            self.pos = start
            return None
        word = m.group().lower()
        if word in _DASH_OPERATORS:
            self.pos = m.end()
            return Ps1Token(Ps1TokenKind.OPERATOR, _DASH_OPERATORS[word], start)
        self.pos = start
        return None

    def _try_parameter(self) -> Ps1Token | None:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 1
        if self.pos >= length:
            self.pos = start
            return None
        c = src[self.pos]
        if not (c.isalpha() or c == '_' or c == '?'):
            self.pos = start
            return None
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c in _PARAMETER_TERMINATORS or c.isspace():
                break
            if c in SINGLE_QUOTES or c in DOUBLE_QUOTES:
                self.pos = start
                return self._read_generic_token()
            if c == ':':
                self.pos += 1
                break
            self.pos += 1
        return Ps1Token(Ps1TokenKind.PARAMETER, src[start:self.pos], start)

    def _read_argument_dash(self) -> Ps1Token:
        """
        The token a dash begins in argument mode. A dash touching a letter, `_` or `?` names a
        parameter; a dash touching anything else is part of the word around it, so `f -1` passes the
        string `-1` rather than the number, and `f -` passes the word `-`.

        No binder is consulted: 5.1 lets a sign join a numeral only where its expression rule asked
        for one, which it never does for an argument, so the dash falls to the same generic scan
        every other unrecognized character does.
        """
        return self._try_parameter() or self._read_generic_token()

    def _try_redirection(self) -> Ps1Token | None:
        m = _REDIRECTION_PATTERN.match(self.source, self.pos)
        if m:
            start = self.pos
            self.pos = m.end()
            return Ps1Token(Ps1TokenKind.REDIRECTION, m.group(), start)
        return None

    def _read_identifier(self) -> Ps1Token:
        src = self.source
        length = len(src)
        start = self.pos
        c = src[self.pos]
        if c == '`' and self.pos + 1 < length:
            self.pos += 2
        else:
            self.pos += 1
        while self.pos < length:
            ch = src[self.pos]
            if ch == '`' and self.pos + 1 < length:
                self.pos += 2
            elif ch.isalnum() or ch == '_':
                self.pos += 1
            elif ch in DASHES and self.mode != Ps1LexerMode.EXPRESSION:
                self.pos += 1
            else:
                break
        return Ps1Token(Ps1TokenKind.GENERIC_TOKEN, src[start:self.pos], start)

    def _read_generic_token(self) -> Ps1Token:
        start = self.pos
        src = self.source
        length = len(src)
        has_expansion = False
        while self.pos < length:
            c = src[self.pos]
            if c == '`' and self.pos + 1 < length:
                self.pos += 2
                continue
            if c in SINGLE_QUOTES:
                self._read_string(SINGLE_QUOTES)
                continue
            if c in DOUBLE_QUOTES:
                self._read_string(DOUBLE_QUOTES, expandable=True)
                continue
            if c == '$' and self.pos + 1 < length:
                nc = src[self.pos + 1]
                if nc == '(':
                    has_expansion = True
                    self.pos += 2
                    self._skip_subexpression_content()
                    continue
                if nc.isalnum() or nc in _VARIABLE_START_CHARS:
                    m = _VARIABLE_PATTERN.match(src, self.pos + 1)
                    if m:
                        has_expansion = True
                        self.pos = m.end()
                        continue
                self.pos += 1
                continue
            if forces_new_token(c):
                break
            self.pos += 1
        kind = Ps1TokenKind.GENERIC_EXPAND if has_expansion else Ps1TokenKind.GENERIC_TOKEN
        return Ps1Token(kind, src[start:self.pos], start)

    def scan_member_access(self) -> Ps1Token | None:
        """
        The member access operator written at `Ps1Lexer.pos`, or `None` where none is. A reader that
        has just taken a value asks this instead of asking for a token, because one character has
        two answers: a `.` before a digit begins a number where a value may start and names a member
        where one has just ended, so `$x.5` reads the property `5` and `$x = .5` reads a half. 5.1
        draws the line in the same place and by the same means — the operator has a scan of its own
        there, and that scan never reaches the number scanner.

        Whether anything binds here at all is the caller's question and not this one's: nothing is
        passed over, so an operator that does not touch what precedes it is simply read at the wrong
        position.

        Two spellings are refused rather than read. A second dot is the range operator, so `1..5`
        counts from one rather than reading a member of it. And where a bare word is a value, an
        access with nothing behind it belongs to the word: `f $x.` passes `$x.` and asks for no
        member.
        """
        spelling = self._member_access_spelling()
        if spelling is None:
            return None
        offset = self.pos
        self.pos += len(spelling)
        if spelling != '[' and self.mode is Ps1LexerMode.ARGUMENT and self._nothing_to_reach():
            self.pos = offset
            return None
        return Ps1Token(_MEMBER_ACCESS_KINDS[spelling], spelling, offset)

    def _nothing_to_reach(self) -> bool:
        """
        Whether the source at `Ps1Lexer.pos` holds nothing an access could reach. A line ends the
        statement rather than the token, so it is named here beside the whitespace and the end of
        the source that `forces_new_token` already answers for.
        """
        c = self._peek()
        return not c or c in '\r\n' or is_whitespace(c)

    def _member_access_spelling(self) -> str | None:
        if self._peek() == '[':
            return '['
        if self._peek() == ':' and self._peek(1) == ':':
            return '::'
        if self._peek() == '.' and self._peek(1) != '.':
            return '.'
        return None

    def _keyword_or_token(self, token: Ps1Token) -> Ps1Token:
        kw = _KEYWORDS.get(token.value.lower())
        if kw is None:
            return token
        return Ps1Token(kw, token.value, token.offset)

    def tokenize(self) -> Generator[Ps1Token, None, None]:
        while True:
            token = self.scan()
            yield token
            if token.kind == Ps1TokenKind.EOF:
                return

    def scan(self) -> Ps1Token:
        """
        Read one token at `Ps1Lexer.pos` in the current `Ps1Lexer.mode` and advance past it. The
        result depends on nothing but the source, the position and the mode, so restoring a position
        and scanning again re-reads the same text faithfully. That is what allows a caller to hold a
        single token of lookahead and discard it when the mode changes.
        """
        src = self.source
        length = len(src)

        while True:
            self._skip_whitespace()
            if self._at_end():
                return Ps1Token(Ps1TokenKind.EOF, '', self.pos)

            start = self.pos
            c = src[self.pos]

            if c == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n':
                self.pos += 2
                return Ps1Token(Ps1TokenKind.NEWLINE, '\r\n', start)
            if c == '\n':
                self.pos += 1
                return Ps1Token(Ps1TokenKind.NEWLINE, '\n', start)

            c2 = src[self.pos:self.pos + 2]
            if len(c2) == 2:
                d0 = '-' if c2[0] in DASHES else c2[0]
                d1 = '-' if c2[1] in DASHES else c2[1]
                c2 = d0 + d1

            if c2 == '<#':
                self._read_block_comment()
                continue
            if c == '#':
                self._read_line_comment()
                continue

            if c == '@' and self.pos + 1 < length:
                nc = src[self.pos + 1]
                if nc in SINGLE_QUOTES:
                    text = self._read_here_string(SINGLE_QUOTES)
                    return Ps1Token(Ps1TokenKind.HSTRING_VERBATIM, text, start)
                if nc in DOUBLE_QUOTES:
                    text = self._read_here_string(DOUBLE_QUOTES, expandable=True)
                    return Ps1Token(Ps1TokenKind.HSTRING_EXPAND, text, start)

            if c2 in ('..', '--', '++', '::', '+=', '-=', '*=', '/=', '%=') and self.mode == Ps1LexerMode.ARGUMENT:
                if not forces_new_token(self._peek(2)):
                    token = self._read_generic_token()
                    if token.value:
                        return token

            if c2 in _TWO_CHAR_OPS:
                self.pos += 2
                kind = _TWO_CHAR_OPS[c2]
                return Ps1Token(kind, c2, start)

            if c == ':' and self.pos + 1 < length and (src[self.pos + 1].isalpha() or src[self.pos + 1] == '_'):
                self.pos += 1
                while self.pos < length and (src[self.pos].isalnum() or src[self.pos] == '_'):
                    self.pos += 1
                return Ps1Token(Ps1TokenKind.LABEL, src[start:self.pos], start)

            if c == '$' or (c == '@' and self.pos + 1 < length and src[self.pos + 1] not in '({'):
                nc = src[self.pos + 1] if self.pos + 1 < length else ''
                if nc and (nc.isalnum() or nc in _VARIABLE_START_CHARS):
                    token = self._read_variable(c)
                    if self.mode == Ps1LexerMode.ARGUMENT:
                        fc = self._peek()
                        if not forces_new_token(fc) and fc not in _VARIABLE_STOPS_NO_RESCAN:
                            self.pos = start
                            token = self._read_generic_token()
                    return token

            if c in SINGLE_QUOTES:
                text = self._read_string(SINGLE_QUOTES)
                return Ps1Token(Ps1TokenKind.STRING_VERBATIM, text, start)
            if c in DOUBLE_QUOTES:
                text = self._read_string(DOUBLE_QUOTES, expandable=True)
                return Ps1Token(Ps1TokenKind.STRING_EXPAND, text, start)

            if c in '123456' and self.pos + 1 < length and src[self.pos + 1] == '>':
                if self.mode == Ps1LexerMode.ARGUMENT:
                    redir = self._try_redirection()
                    if redir:
                        return redir

            if c in _DECIMAL_DIGITS or (c == '.' and self._peek(1) in _DECIMAL_DIGITS):
                token = self._read_number()
                if token is not None and self._numeral_ends_here():
                    return token
                self.pos = start
                return self._read_generic_token()

            if c in DASHES:
                if self.mode == Ps1LexerMode.EXPRESSION:
                    op = self._try_dash_operator()
                    if op:
                        return op
                elif self.mode == Ps1LexerMode.ARGUMENT:
                    return self._read_argument_dash()

            redir = self._try_redirection()
            if redir:
                return redir

            if self.mode == Ps1LexerMode.ARGUMENT:
                if c == '.':
                    nc = self._peek(1)
                    if (
                        not forces_new_token(nc)
                        and nc != '$'
                        and nc not in SINGLE_QUOTES
                        and nc not in DOUBLE_QUOTES
                    ):
                        token = self._read_generic_token()
                        if token.value:
                            return token

            if self.mode == Ps1LexerMode.ARGUMENT and c in '*/%=!+?[':
                if not forces_new_token(self._peek(1)):
                    token = self._read_generic_token()
                    if token.value:
                        return token

            if c in _ONE_CHAR_OPS or c in DASHES:
                self.pos += 1
                kind = _ONE_CHAR_OPS.get(c) or Ps1TokenKind.DASH
                return Ps1Token(kind, c, start)

            if self.mode == Ps1LexerMode.ARGUMENT:
                if c.isalpha() or c == '_' or c == '\\' or c == '`':
                    token = self._read_generic_token()
                    if token.value:
                        return self._keyword_or_token(token)

            if c.isalpha() or c == '_' or c == '`':
                token = self._read_identifier()
                if not forces_new_token(self._peek()):
                    if self.mode == Ps1LexerMode.ARGUMENT or (
                        src[self.pos] in SINGLE_QUOTES
                        or src[self.pos] in DOUBLE_QUOTES
                        or src[self.pos] == '$'
                    ):
                        self.pos = start
                        token = self._read_generic_token()
                if token.value:
                    return self._keyword_or_token(token)

            self.pos += 1
            return Ps1Token(Ps1TokenKind.GENERIC_TOKEN, c, start)


def reads_as_one_numeral(spelling: str, following: str, mode: Ps1LexerMode) -> bool:
    """
    Whether a numeral written as `spelling` is still read as that numeral once `following` is
    written straight against it. A numeral ends where the character after it says it does, so the
    same digits are a number in one place and part of a word in another: `3` before `.ToString` is
    the word `3.ToString`, `0xFF` before it is the number and a member read, and `3` before `[0]`
    or `::MaxValue` is a word again because neither bracket nor colon ends a numeral.

    The question is put to the lexer rather than restated here, so that a slot deciding whether the
    value it holds needs a bracket and the reader that would read it back cannot come apart. That
    also makes it the one thing to fix when the lexer is wrong: a repair reaches the writer for
    free.

    A sign is the one thing asked around the lexer rather than of it. Where an expression is read, a
    `+` or `-` written straight against a numeral is part of it — 5.1 raises `AllowSignedNumbers`
    for exactly that position — and the rest of the rule then applies to the whole of it:
    measured, `-1kb.GetType()` reads the member of minus one kilobyte while `-1.GetType` is one
    word. Where a bare word is a value no sign is ever taken, so the spelling is asked about as
    it was written.
    """
    signed = mode is Ps1LexerMode.EXPRESSION and spelling[:1] in ('+', '-')
    numeral = spelling[1:] if signed else spelling
    lexer = Ps1Lexer(F'{numeral}{following}', mode=mode)
    token = lexer.scan()
    if token.kind not in (Ps1TokenKind.INTEGER, Ps1TokenKind.REAL):
        return False
    return lexer.pos == len(numeral)

Functions

def reads_as_one_numeral(spelling, following, mode)

Whether a numeral written as spelling is still read as that numeral once following is written straight against it. A numeral ends where the character after it says it does, so the same digits are a number in one place and part of a word in another: 3 before .ToString is the word 3.ToString, 0xFF before it is the number and a member read, and 3 before [0] or ::MaxValue is a word again because neither bracket nor colon ends a numeral.

The question is put to the lexer rather than restated here, so that a slot deciding whether the value it holds needs a bracket and the reader that would read it back cannot come apart. That also makes it the one thing to fix when the lexer is wrong: a repair reaches the writer for free.

A sign is the one thing asked around the lexer rather than of it. Where an expression is read, a + or - written straight against a numeral is part of it — 5.1 raises AllowSignedNumbers for exactly that position — and the rest of the rule then applies to the whole of it: measured, -1kb.GetType() reads the member of minus one kilobyte while -1.GetType is one word. Where a bare word is a value no sign is ever taken, so the spelling is asked about as it was written.

Expand source code Browse git
def reads_as_one_numeral(spelling: str, following: str, mode: Ps1LexerMode) -> bool:
    """
    Whether a numeral written as `spelling` is still read as that numeral once `following` is
    written straight against it. A numeral ends where the character after it says it does, so the
    same digits are a number in one place and part of a word in another: `3` before `.ToString` is
    the word `3.ToString`, `0xFF` before it is the number and a member read, and `3` before `[0]`
    or `::MaxValue` is a word again because neither bracket nor colon ends a numeral.

    The question is put to the lexer rather than restated here, so that a slot deciding whether the
    value it holds needs a bracket and the reader that would read it back cannot come apart. That
    also makes it the one thing to fix when the lexer is wrong: a repair reaches the writer for
    free.

    A sign is the one thing asked around the lexer rather than of it. Where an expression is read, a
    `+` or `-` written straight against a numeral is part of it — 5.1 raises `AllowSignedNumbers`
    for exactly that position — and the rest of the rule then applies to the whole of it:
    measured, `-1kb.GetType()` reads the member of minus one kilobyte while `-1.GetType` is one
    word. Where a bare word is a value no sign is ever taken, so the spelling is asked about as
    it was written.
    """
    signed = mode is Ps1LexerMode.EXPRESSION and spelling[:1] in ('+', '-')
    numeral = spelling[1:] if signed else spelling
    lexer = Ps1Lexer(F'{numeral}{following}', mode=mode)
    token = lexer.scan()
    if token.kind not in (Ps1TokenKind.INTEGER, Ps1TokenKind.REAL):
        return False
    return lexer.pos == len(numeral)

Classes

class Ps1LexerMode (*args, **kwds)

Create a collection of name/value pairs.

Example enumeration:

>>> class Color(Enum):
...     RED = 1
...     BLUE = 2
...     GREEN = 3

Access them by:

  • attribute access:

Color.RED

  • value lookup:

Color(1)

  • name lookup:

Color['RED']

Enumerations can be iterated over, and know how many members they have:

>>> len(Color)
3
>>> list(Color)
[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Expand source code Browse git
class Ps1LexerMode(enum.Enum):
    EXPRESSION = 'expression'
    ARGUMENT = 'argument'

Ancestors

  • enum.Enum

Class variables

var EXPRESSION

The type of the None singleton.

var ARGUMENT

The type of the None singleton.

class Ps1Lexer (source, pos=0, mode=Ps1LexerMode.ARGUMENT)

Ps1Lexer(source: 'str', pos: 'int' = 0, mode: 'Ps1LexerMode' = )

Expand source code Browse git
@dataclass
class Ps1Lexer:
    source: str
    pos: int = 0
    mode: Ps1LexerMode = Ps1LexerMode.ARGUMENT

    def _at_end(self) -> bool:
        return self.pos >= len(self.source)

    def _peek(self, ahead: int = 0) -> str:
        """
        The character `ahead` positions past `Ps1Lexer.pos`, or the empty string where the source
        has ended. Every rule that asks what comes next has to answer for the end of the source
        too, and this is the one place that answer is given: the character classes in
        `refinery.lib.scripts.ps1.token` read the empty string as the end and judge it accordingly.
        """
        return self.source[self.pos + ahead:self.pos + ahead + 1]

    def _skip_whitespace(self) -> bool:
        start = self.pos
        src = self.source
        length = len(src)
        while self.pos < length:
            c = src[self.pos]
            if is_whitespace(c):
                self.pos += 1
            elif c == '`' and self.pos + 1 < length and src[self.pos + 1] == '\n':
                self.pos += 2
            elif c == '`' and self.pos + 2 < length and src[self.pos + 1:self.pos + 3] == '\r\n':
                self.pos += 3
            else:
                break
        return self.pos > start

    def _read_line_comment(self) -> None:
        src = self.source
        length = len(src)
        while self.pos < length and src[self.pos] != '\n':
            self.pos += 1

    def _read_block_comment(self) -> None:
        src = self.source
        length = len(src)
        self.pos += 2
        while self.pos < length - 1:
            if src[self.pos] == '#' and src[self.pos + 1] == '>':
                self.pos += 2
                return
            self.pos += 1
        self.pos = length

    def _try_skip_expandable(self) -> bool:
        src = self.source
        length = len(src)
        c = src[self.pos]
        if c == '`' and self.pos + 1 < length:
            self.pos += 2
            return True
        if c == '$' and self.pos + 1 < length and src[self.pos + 1] == '(':
            self.pos += 2
            self._skip_subexpression_content()
            return True
        return False

    def _read_string(self, quote_set: frozenset[str], expandable: bool = False) -> str:
        start = self.pos
        src = self.source
        length = len(src)
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if expandable and self._try_skip_expandable():
                continue
            if c in quote_set:
                self.pos += 1
                if self.pos < length and src[self.pos] in quote_set:
                    self.pos += 1
                    continue
                return src[start:self.pos]
            self.pos += 1
        return src[start:self.pos]

    def _skip_subexpression_content(self):
        src = self.source
        length = len(src)
        depth = 1
        while self.pos < length:
            c = src[self.pos]
            if c == '`' and self.pos + 1 < length:
                self.pos += 2
                continue
            if c == '(':
                depth += 1
                self.pos += 1
                continue
            if c == ')':
                depth -= 1
                self.pos += 1
                if depth == 0:
                    return
                continue
            if c == '@' and self.pos + 1 < length:
                nc = src[self.pos + 1]
                if self.pos + 2 < length and src[self.pos + 2] in '\r\n':
                    if nc in SINGLE_QUOTES:
                        self._read_here_string(SINGLE_QUOTES)
                        continue
                    if nc in DOUBLE_QUOTES:
                        self._read_here_string(DOUBLE_QUOTES, expandable=True)
                        continue
            if c in SINGLE_QUOTES:
                self._read_string(SINGLE_QUOTES)
                continue
            if c in DOUBLE_QUOTES:
                self._read_string(DOUBLE_QUOTES, expandable=True)
                continue
            self.pos += 1

    def _skip_here_string_header(self):
        src = self.source
        length = len(src)
        self.pos += 2
        if self.pos < length and src[self.pos] == '\r':
            self.pos += 1
        if self.pos < length and src[self.pos] == '\n':
            self.pos += 1

    def _check_here_string_terminator(self, quote_set: frozenset[str]) -> bool:
        src = self.source
        length = len(src)
        if src[self.pos] == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n':
            nl_end = self.pos + 2
        else:
            nl_end = self.pos + 1
        if nl_end + 1 < length and src[nl_end] in quote_set and src[nl_end + 1] == '@':
            self.pos = nl_end + 2
            return True
        return False

    def _read_here_string(self, quote_set: frozenset[str], expandable: bool = False) -> str:
        start = self.pos
        src = self.source
        length = len(src)
        self._skip_here_string_header()
        while self.pos < length:
            c = src[self.pos]
            if c in '\r\n':
                if self._check_here_string_terminator(quote_set):
                    return src[start:self.pos]
            if expandable and self._try_skip_expandable():
                continue
            self.pos += 1
        return src[start:self.pos]

    def _read_variable(self, prefix: str) -> Ps1Token:
        start = self.pos
        self.pos += len(prefix)
        m = _VARIABLE_PATTERN.match(self.source, self.pos)
        if m:
            self.pos = m.end()
        kind = Ps1TokenKind.SPLAT_VARIABLE if prefix == '@' else Ps1TokenKind.VARIABLE
        return Ps1Token(kind, self.source[start:self.pos], start)

    def _read_digits(self, digits: frozenset[str]) -> int:
        start = self.pos
        while self._peek() in digits:
            self.pos += 1
        return self.pos - start

    def _read_exponent(self) -> bool:
        """
        Read the power an `e` was written for, and say whether one was there. A sign may stand
        between the two, and digits must: `1e3` is a thousand and `1e` is the word `1e`.
        """
        if self._peek() in ('+', '-'):
            self.pos += 1
        return self._read_digits(_DECIMAL_DIGITS) > 0

    def _read_fraction(self) -> bool:
        """
        Read what follows a numeral's dot: the digits after it, and the power they may be raised to.
        No digit is required — `1.` is the number one and `1.e1` is ten — because the dot has
        already settled that a numeral is being read.
        """
        self._read_digits(_DECIMAL_DIGITS)
        if self._peek() not in _EXPONENT_MARKER:
            return True
        self.pos += 1
        return self._read_exponent()

    def _read_number(self) -> Ps1Token | None:
        """
        The numeral written at `Ps1Lexer.pos`, or `None` where what stands there only looks like
        one. A digit is written there, or a dot and then a digit; `Ps1Lexer.scan` is what knows
        that, and nothing else calls this.

        The parts come in 5.1's order and are not alternatives to one another: a base, then a
        fraction or a power, then a type suffix, then a multiplier. Reading a suffix *or* a
        multiplier loses `1dkb`, which is a Decimal kilobyte, and `1.5L`, which is an Int64.

        A trailing dot belongs to the numeral — `3.` is the number three — and is given back only to
        the range operator, so that `3..5` counts from three. A dot after a power belongs to neither:
        only the plain-decimal branch ever eats one, so `1e3` ends where the dot begins and 5.1 reads
        what follows as a member.

        Three spellings end here rather than in `Ps1Lexer.scan`, because it is the numeral itself
        that cannot be one: `0x` names no digits, `1e` raises nothing to a power, and a multiplier
        with no `b` behind it is part of the word around it, so `1k` is a word where `1kb` is a
        number. Everything else the numeral did not swallow is `Ps1Lexer.scan`'s to judge.

        The kind reported is which literal the parser builds and not what the numeral is worth:
        `1kb` is a real to spell and an `Int32` to read, and `refinery.lib.scripts.ps1.analysis
        .values.read` is what decides the second.
        """
        start = self.pos
        real = False
        if self._peek() == '.':
            self.pos += 1
            if not self._read_fraction():
                self.pos = start
                return None
            real = True
        elif self._peek() == '0' and self._peek(1) in _HEX_MARKER:
            self.pos += 2
            if not self._read_digits(_HEX_DIGITS):
                self.pos = start
                return None
        else:
            self._read_digits(_DECIMAL_DIGITS)
            if self._peek() == '.' and self._peek(1) != '.':
                self.pos += 1
                if not self._read_fraction():
                    self.pos = start
                    return None
                real = True
            elif self._peek() in _EXPONENT_MARKER:
                self.pos += 1
                if not self._read_exponent():
                    self.pos = start
                    return None
                real = True
        suffix = self._peek()
        if suffix in _TYPE_SUFFIX:
            self.pos += 1
            real = real or suffix in _DECIMAL_SUFFIX
        if self._peek() in _MULTIPLIER_START:
            self.pos += 1
            if self._peek() not in _MULTIPLIER_END:
                self.pos = start
                return None
            self.pos += 1
            real = True
        kind = Ps1TokenKind.REAL if real else Ps1TokenKind.INTEGER
        return Ps1Token(kind, self.source[start:self.pos], start)

    def _numeral_ends_here(self) -> bool:
        """
        Whether the numeral just read really ends where it stopped. Which characters can end one
        depends on the slot: where an expression is read a numeral ends on an operator too, so `1+2`
        is three tokens, and where a bare word is a value only a token terminator will do, so `f 1+2`
        passes the single word `1+2`.
        """
        c = self._peek()
        if forces_new_token(c):
            return True
        return self.mode is Ps1LexerMode.EXPRESSION and forces_new_token_after_number(c)

    def _try_dash_operator(self) -> Ps1Token | None:
        src = self.source
        start = self.pos
        self.pos += 1
        m = _DASH_WORD.match(src, self.pos)
        if not m:
            self.pos = start
            return None
        word = m.group().lower()
        if word in _DASH_OPERATORS:
            self.pos = m.end()
            return Ps1Token(Ps1TokenKind.OPERATOR, _DASH_OPERATORS[word], start)
        self.pos = start
        return None

    def _try_parameter(self) -> Ps1Token | None:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 1
        if self.pos >= length:
            self.pos = start
            return None
        c = src[self.pos]
        if not (c.isalpha() or c == '_' or c == '?'):
            self.pos = start
            return None
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c in _PARAMETER_TERMINATORS or c.isspace():
                break
            if c in SINGLE_QUOTES or c in DOUBLE_QUOTES:
                self.pos = start
                return self._read_generic_token()
            if c == ':':
                self.pos += 1
                break
            self.pos += 1
        return Ps1Token(Ps1TokenKind.PARAMETER, src[start:self.pos], start)

    def _read_argument_dash(self) -> Ps1Token:
        """
        The token a dash begins in argument mode. A dash touching a letter, `_` or `?` names a
        parameter; a dash touching anything else is part of the word around it, so `f -1` passes the
        string `-1` rather than the number, and `f -` passes the word `-`.

        No binder is consulted: 5.1 lets a sign join a numeral only where its expression rule asked
        for one, which it never does for an argument, so the dash falls to the same generic scan
        every other unrecognized character does.
        """
        return self._try_parameter() or self._read_generic_token()

    def _try_redirection(self) -> Ps1Token | None:
        m = _REDIRECTION_PATTERN.match(self.source, self.pos)
        if m:
            start = self.pos
            self.pos = m.end()
            return Ps1Token(Ps1TokenKind.REDIRECTION, m.group(), start)
        return None

    def _read_identifier(self) -> Ps1Token:
        src = self.source
        length = len(src)
        start = self.pos
        c = src[self.pos]
        if c == '`' and self.pos + 1 < length:
            self.pos += 2
        else:
            self.pos += 1
        while self.pos < length:
            ch = src[self.pos]
            if ch == '`' and self.pos + 1 < length:
                self.pos += 2
            elif ch.isalnum() or ch == '_':
                self.pos += 1
            elif ch in DASHES and self.mode != Ps1LexerMode.EXPRESSION:
                self.pos += 1
            else:
                break
        return Ps1Token(Ps1TokenKind.GENERIC_TOKEN, src[start:self.pos], start)

    def _read_generic_token(self) -> Ps1Token:
        start = self.pos
        src = self.source
        length = len(src)
        has_expansion = False
        while self.pos < length:
            c = src[self.pos]
            if c == '`' and self.pos + 1 < length:
                self.pos += 2
                continue
            if c in SINGLE_QUOTES:
                self._read_string(SINGLE_QUOTES)
                continue
            if c in DOUBLE_QUOTES:
                self._read_string(DOUBLE_QUOTES, expandable=True)
                continue
            if c == '$' and self.pos + 1 < length:
                nc = src[self.pos + 1]
                if nc == '(':
                    has_expansion = True
                    self.pos += 2
                    self._skip_subexpression_content()
                    continue
                if nc.isalnum() or nc in _VARIABLE_START_CHARS:
                    m = _VARIABLE_PATTERN.match(src, self.pos + 1)
                    if m:
                        has_expansion = True
                        self.pos = m.end()
                        continue
                self.pos += 1
                continue
            if forces_new_token(c):
                break
            self.pos += 1
        kind = Ps1TokenKind.GENERIC_EXPAND if has_expansion else Ps1TokenKind.GENERIC_TOKEN
        return Ps1Token(kind, src[start:self.pos], start)

    def scan_member_access(self) -> Ps1Token | None:
        """
        The member access operator written at `Ps1Lexer.pos`, or `None` where none is. A reader that
        has just taken a value asks this instead of asking for a token, because one character has
        two answers: a `.` before a digit begins a number where a value may start and names a member
        where one has just ended, so `$x.5` reads the property `5` and `$x = .5` reads a half. 5.1
        draws the line in the same place and by the same means — the operator has a scan of its own
        there, and that scan never reaches the number scanner.

        Whether anything binds here at all is the caller's question and not this one's: nothing is
        passed over, so an operator that does not touch what precedes it is simply read at the wrong
        position.

        Two spellings are refused rather than read. A second dot is the range operator, so `1..5`
        counts from one rather than reading a member of it. And where a bare word is a value, an
        access with nothing behind it belongs to the word: `f $x.` passes `$x.` and asks for no
        member.
        """
        spelling = self._member_access_spelling()
        if spelling is None:
            return None
        offset = self.pos
        self.pos += len(spelling)
        if spelling != '[' and self.mode is Ps1LexerMode.ARGUMENT and self._nothing_to_reach():
            self.pos = offset
            return None
        return Ps1Token(_MEMBER_ACCESS_KINDS[spelling], spelling, offset)

    def _nothing_to_reach(self) -> bool:
        """
        Whether the source at `Ps1Lexer.pos` holds nothing an access could reach. A line ends the
        statement rather than the token, so it is named here beside the whitespace and the end of
        the source that `forces_new_token` already answers for.
        """
        c = self._peek()
        return not c or c in '\r\n' or is_whitespace(c)

    def _member_access_spelling(self) -> str | None:
        if self._peek() == '[':
            return '['
        if self._peek() == ':' and self._peek(1) == ':':
            return '::'
        if self._peek() == '.' and self._peek(1) != '.':
            return '.'
        return None

    def _keyword_or_token(self, token: Ps1Token) -> Ps1Token:
        kw = _KEYWORDS.get(token.value.lower())
        if kw is None:
            return token
        return Ps1Token(kw, token.value, token.offset)

    def tokenize(self) -> Generator[Ps1Token, None, None]:
        while True:
            token = self.scan()
            yield token
            if token.kind == Ps1TokenKind.EOF:
                return

    def scan(self) -> Ps1Token:
        """
        Read one token at `Ps1Lexer.pos` in the current `Ps1Lexer.mode` and advance past it. The
        result depends on nothing but the source, the position and the mode, so restoring a position
        and scanning again re-reads the same text faithfully. That is what allows a caller to hold a
        single token of lookahead and discard it when the mode changes.
        """
        src = self.source
        length = len(src)

        while True:
            self._skip_whitespace()
            if self._at_end():
                return Ps1Token(Ps1TokenKind.EOF, '', self.pos)

            start = self.pos
            c = src[self.pos]

            if c == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n':
                self.pos += 2
                return Ps1Token(Ps1TokenKind.NEWLINE, '\r\n', start)
            if c == '\n':
                self.pos += 1
                return Ps1Token(Ps1TokenKind.NEWLINE, '\n', start)

            c2 = src[self.pos:self.pos + 2]
            if len(c2) == 2:
                d0 = '-' if c2[0] in DASHES else c2[0]
                d1 = '-' if c2[1] in DASHES else c2[1]
                c2 = d0 + d1

            if c2 == '<#':
                self._read_block_comment()
                continue
            if c == '#':
                self._read_line_comment()
                continue

            if c == '@' and self.pos + 1 < length:
                nc = src[self.pos + 1]
                if nc in SINGLE_QUOTES:
                    text = self._read_here_string(SINGLE_QUOTES)
                    return Ps1Token(Ps1TokenKind.HSTRING_VERBATIM, text, start)
                if nc in DOUBLE_QUOTES:
                    text = self._read_here_string(DOUBLE_QUOTES, expandable=True)
                    return Ps1Token(Ps1TokenKind.HSTRING_EXPAND, text, start)

            if c2 in ('..', '--', '++', '::', '+=', '-=', '*=', '/=', '%=') and self.mode == Ps1LexerMode.ARGUMENT:
                if not forces_new_token(self._peek(2)):
                    token = self._read_generic_token()
                    if token.value:
                        return token

            if c2 in _TWO_CHAR_OPS:
                self.pos += 2
                kind = _TWO_CHAR_OPS[c2]
                return Ps1Token(kind, c2, start)

            if c == ':' and self.pos + 1 < length and (src[self.pos + 1].isalpha() or src[self.pos + 1] == '_'):
                self.pos += 1
                while self.pos < length and (src[self.pos].isalnum() or src[self.pos] == '_'):
                    self.pos += 1
                return Ps1Token(Ps1TokenKind.LABEL, src[start:self.pos], start)

            if c == '$' or (c == '@' and self.pos + 1 < length and src[self.pos + 1] not in '({'):
                nc = src[self.pos + 1] if self.pos + 1 < length else ''
                if nc and (nc.isalnum() or nc in _VARIABLE_START_CHARS):
                    token = self._read_variable(c)
                    if self.mode == Ps1LexerMode.ARGUMENT:
                        fc = self._peek()
                        if not forces_new_token(fc) and fc not in _VARIABLE_STOPS_NO_RESCAN:
                            self.pos = start
                            token = self._read_generic_token()
                    return token

            if c in SINGLE_QUOTES:
                text = self._read_string(SINGLE_QUOTES)
                return Ps1Token(Ps1TokenKind.STRING_VERBATIM, text, start)
            if c in DOUBLE_QUOTES:
                text = self._read_string(DOUBLE_QUOTES, expandable=True)
                return Ps1Token(Ps1TokenKind.STRING_EXPAND, text, start)

            if c in '123456' and self.pos + 1 < length and src[self.pos + 1] == '>':
                if self.mode == Ps1LexerMode.ARGUMENT:
                    redir = self._try_redirection()
                    if redir:
                        return redir

            if c in _DECIMAL_DIGITS or (c == '.' and self._peek(1) in _DECIMAL_DIGITS):
                token = self._read_number()
                if token is not None and self._numeral_ends_here():
                    return token
                self.pos = start
                return self._read_generic_token()

            if c in DASHES:
                if self.mode == Ps1LexerMode.EXPRESSION:
                    op = self._try_dash_operator()
                    if op:
                        return op
                elif self.mode == Ps1LexerMode.ARGUMENT:
                    return self._read_argument_dash()

            redir = self._try_redirection()
            if redir:
                return redir

            if self.mode == Ps1LexerMode.ARGUMENT:
                if c == '.':
                    nc = self._peek(1)
                    if (
                        not forces_new_token(nc)
                        and nc != '$'
                        and nc not in SINGLE_QUOTES
                        and nc not in DOUBLE_QUOTES
                    ):
                        token = self._read_generic_token()
                        if token.value:
                            return token

            if self.mode == Ps1LexerMode.ARGUMENT and c in '*/%=!+?[':
                if not forces_new_token(self._peek(1)):
                    token = self._read_generic_token()
                    if token.value:
                        return token

            if c in _ONE_CHAR_OPS or c in DASHES:
                self.pos += 1
                kind = _ONE_CHAR_OPS.get(c) or Ps1TokenKind.DASH
                return Ps1Token(kind, c, start)

            if self.mode == Ps1LexerMode.ARGUMENT:
                if c.isalpha() or c == '_' or c == '\\' or c == '`':
                    token = self._read_generic_token()
                    if token.value:
                        return self._keyword_or_token(token)

            if c.isalpha() or c == '_' or c == '`':
                token = self._read_identifier()
                if not forces_new_token(self._peek()):
                    if self.mode == Ps1LexerMode.ARGUMENT or (
                        src[self.pos] in SINGLE_QUOTES
                        or src[self.pos] in DOUBLE_QUOTES
                        or src[self.pos] == '$'
                    ):
                        self.pos = start
                        token = self._read_generic_token()
                if token.value:
                    return self._keyword_or_token(token)

            self.pos += 1
            return Ps1Token(Ps1TokenKind.GENERIC_TOKEN, c, start)

Instance variables

var source

The type of the None singleton.

var pos

The type of the None singleton.

var mode

The type of the None singleton.

Methods

def scan_member_access(self)

The member access operator written at Ps1Lexer.pos, or None where none is. A reader that has just taken a value asks this instead of asking for a token, because one character has two answers: a . before a digit begins a number where a value may start and names a member where one has just ended, so $x.5 reads the property 5 and $x = .5 reads a half. 5.1 draws the line in the same place and by the same means — the operator has a scan of its own there, and that scan never reaches the number scanner.

Whether anything binds here at all is the caller's question and not this one's: nothing is passed over, so an operator that does not touch what precedes it is simply read at the wrong position.

Two spellings are refused rather than read. A second dot is the range operator, so 1..5 counts from one rather than reading a member of it. And where a bare word is a value, an access with nothing behind it belongs to the word: f $x. passes $x. and asks for no member.

Expand source code Browse git
def scan_member_access(self) -> Ps1Token | None:
    """
    The member access operator written at `Ps1Lexer.pos`, or `None` where none is. A reader that
    has just taken a value asks this instead of asking for a token, because one character has
    two answers: a `.` before a digit begins a number where a value may start and names a member
    where one has just ended, so `$x.5` reads the property `5` and `$x = .5` reads a half. 5.1
    draws the line in the same place and by the same means — the operator has a scan of its own
    there, and that scan never reaches the number scanner.

    Whether anything binds here at all is the caller's question and not this one's: nothing is
    passed over, so an operator that does not touch what precedes it is simply read at the wrong
    position.

    Two spellings are refused rather than read. A second dot is the range operator, so `1..5`
    counts from one rather than reading a member of it. And where a bare word is a value, an
    access with nothing behind it belongs to the word: `f $x.` passes `$x.` and asks for no
    member.
    """
    spelling = self._member_access_spelling()
    if spelling is None:
        return None
    offset = self.pos
    self.pos += len(spelling)
    if spelling != '[' and self.mode is Ps1LexerMode.ARGUMENT and self._nothing_to_reach():
        self.pos = offset
        return None
    return Ps1Token(_MEMBER_ACCESS_KINDS[spelling], spelling, offset)
def tokenize(self)
Expand source code Browse git
def tokenize(self) -> Generator[Ps1Token, None, None]:
    while True:
        token = self.scan()
        yield token
        if token.kind == Ps1TokenKind.EOF:
            return
def scan(self)

Read one token at Ps1Lexer.pos in the current Ps1Lexer.mode and advance past it. The result depends on nothing but the source, the position and the mode, so restoring a position and scanning again re-reads the same text faithfully. That is what allows a caller to hold a single token of lookahead and discard it when the mode changes.

Expand source code Browse git
def scan(self) -> Ps1Token:
    """
    Read one token at `Ps1Lexer.pos` in the current `Ps1Lexer.mode` and advance past it. The
    result depends on nothing but the source, the position and the mode, so restoring a position
    and scanning again re-reads the same text faithfully. That is what allows a caller to hold a
    single token of lookahead and discard it when the mode changes.
    """
    src = self.source
    length = len(src)

    while True:
        self._skip_whitespace()
        if self._at_end():
            return Ps1Token(Ps1TokenKind.EOF, '', self.pos)

        start = self.pos
        c = src[self.pos]

        if c == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n':
            self.pos += 2
            return Ps1Token(Ps1TokenKind.NEWLINE, '\r\n', start)
        if c == '\n':
            self.pos += 1
            return Ps1Token(Ps1TokenKind.NEWLINE, '\n', start)

        c2 = src[self.pos:self.pos + 2]
        if len(c2) == 2:
            d0 = '-' if c2[0] in DASHES else c2[0]
            d1 = '-' if c2[1] in DASHES else c2[1]
            c2 = d0 + d1

        if c2 == '<#':
            self._read_block_comment()
            continue
        if c == '#':
            self._read_line_comment()
            continue

        if c == '@' and self.pos + 1 < length:
            nc = src[self.pos + 1]
            if nc in SINGLE_QUOTES:
                text = self._read_here_string(SINGLE_QUOTES)
                return Ps1Token(Ps1TokenKind.HSTRING_VERBATIM, text, start)
            if nc in DOUBLE_QUOTES:
                text = self._read_here_string(DOUBLE_QUOTES, expandable=True)
                return Ps1Token(Ps1TokenKind.HSTRING_EXPAND, text, start)

        if c2 in ('..', '--', '++', '::', '+=', '-=', '*=', '/=', '%=') and self.mode == Ps1LexerMode.ARGUMENT:
            if not forces_new_token(self._peek(2)):
                token = self._read_generic_token()
                if token.value:
                    return token

        if c2 in _TWO_CHAR_OPS:
            self.pos += 2
            kind = _TWO_CHAR_OPS[c2]
            return Ps1Token(kind, c2, start)

        if c == ':' and self.pos + 1 < length and (src[self.pos + 1].isalpha() or src[self.pos + 1] == '_'):
            self.pos += 1
            while self.pos < length and (src[self.pos].isalnum() or src[self.pos] == '_'):
                self.pos += 1
            return Ps1Token(Ps1TokenKind.LABEL, src[start:self.pos], start)

        if c == '$' or (c == '@' and self.pos + 1 < length and src[self.pos + 1] not in '({'):
            nc = src[self.pos + 1] if self.pos + 1 < length else ''
            if nc and (nc.isalnum() or nc in _VARIABLE_START_CHARS):
                token = self._read_variable(c)
                if self.mode == Ps1LexerMode.ARGUMENT:
                    fc = self._peek()
                    if not forces_new_token(fc) and fc not in _VARIABLE_STOPS_NO_RESCAN:
                        self.pos = start
                        token = self._read_generic_token()
                return token

        if c in SINGLE_QUOTES:
            text = self._read_string(SINGLE_QUOTES)
            return Ps1Token(Ps1TokenKind.STRING_VERBATIM, text, start)
        if c in DOUBLE_QUOTES:
            text = self._read_string(DOUBLE_QUOTES, expandable=True)
            return Ps1Token(Ps1TokenKind.STRING_EXPAND, text, start)

        if c in '123456' and self.pos + 1 < length and src[self.pos + 1] == '>':
            if self.mode == Ps1LexerMode.ARGUMENT:
                redir = self._try_redirection()
                if redir:
                    return redir

        if c in _DECIMAL_DIGITS or (c == '.' and self._peek(1) in _DECIMAL_DIGITS):
            token = self._read_number()
            if token is not None and self._numeral_ends_here():
                return token
            self.pos = start
            return self._read_generic_token()

        if c in DASHES:
            if self.mode == Ps1LexerMode.EXPRESSION:
                op = self._try_dash_operator()
                if op:
                    return op
            elif self.mode == Ps1LexerMode.ARGUMENT:
                return self._read_argument_dash()

        redir = self._try_redirection()
        if redir:
            return redir

        if self.mode == Ps1LexerMode.ARGUMENT:
            if c == '.':
                nc = self._peek(1)
                if (
                    not forces_new_token(nc)
                    and nc != '$'
                    and nc not in SINGLE_QUOTES
                    and nc not in DOUBLE_QUOTES
                ):
                    token = self._read_generic_token()
                    if token.value:
                        return token

        if self.mode == Ps1LexerMode.ARGUMENT and c in '*/%=!+?[':
            if not forces_new_token(self._peek(1)):
                token = self._read_generic_token()
                if token.value:
                    return token

        if c in _ONE_CHAR_OPS or c in DASHES:
            self.pos += 1
            kind = _ONE_CHAR_OPS.get(c) or Ps1TokenKind.DASH
            return Ps1Token(kind, c, start)

        if self.mode == Ps1LexerMode.ARGUMENT:
            if c.isalpha() or c == '_' or c == '\\' or c == '`':
                token = self._read_generic_token()
                if token.value:
                    return self._keyword_or_token(token)

        if c.isalpha() or c == '_' or c == '`':
            token = self._read_identifier()
            if not forces_new_token(self._peek()):
                if self.mode == Ps1LexerMode.ARGUMENT or (
                    src[self.pos] in SINGLE_QUOTES
                    or src[self.pos] in DOUBLE_QUOTES
                    or src[self.pos] == '$'
                ):
                    self.pos = start
                    token = self._read_generic_token()
            if token.value:
                return self._keyword_or_token(token)

        self.pos += 1
        return Ps1Token(Ps1TokenKind.GENERIC_TOKEN, c, start)