Module refinery.lib.scripts.php.lexer

Expand source code Browse git
from __future__ import annotations

import enum

from dataclasses import dataclass, field
from typing import Generator

from refinery.lib.scripts.php.token import (
    CAST_KEYWORDS,
    KEYWORDS,
    PhpToken,
    PhpTokenKind,
)

_ESCAPE_MAP: dict[str, str] = {
    'n'  : '\n',
    'r'  : '\r',
    't'  : '\t',
    'v'  : '\v',
    'f'  : '\f',
    'e'  : '\x1b',
    '\\' : '\\',
    '$'  : '$',
    '"'  : '"',
}

_HEX = frozenset('0123456789abcdefABCDEF')

_THREE_CHAR_OPS: dict[str, PhpTokenKind] = {
    '===' : PhpTokenKind.IS_IDENTICAL,
    '!==' : PhpTokenKind.IS_NOT_IDENTICAL,
    '<=>' : PhpTokenKind.SPACESHIP,
    '**=' : PhpTokenKind.POW_EQUAL,
    '<<=' : PhpTokenKind.SL_EQUAL,
    '>>=' : PhpTokenKind.SR_EQUAL,
    '??=' : PhpTokenKind.COALESCE_EQUAL,
    '?->' : PhpTokenKind.NULLSAFE_OPERATOR,
    '...' : PhpTokenKind.ELLIPSIS,
}

_TWO_CHAR_OPS: dict[str, PhpTokenKind] = {
    '**' : PhpTokenKind.POW,
    '++' : PhpTokenKind.INC,
    '--' : PhpTokenKind.DEC,
    '->' : PhpTokenKind.OBJECT_OPERATOR,
    '=>' : PhpTokenKind.DOUBLE_ARROW,
    '::' : PhpTokenKind.DOUBLE_COLON,
    '==' : PhpTokenKind.IS_EQUAL,
    '!=' : PhpTokenKind.IS_NOT_EQUAL,
    '<>' : PhpTokenKind.IS_NOT_EQUAL,
    '<=' : PhpTokenKind.IS_SMALLER_OR_EQUAL,
    '>=' : PhpTokenKind.IS_GREATER_OR_EQUAL,
    '+=' : PhpTokenKind.PLUS_EQUAL,
    '-=' : PhpTokenKind.MINUS_EQUAL,
    '*=' : PhpTokenKind.MUL_EQUAL,
    '/=' : PhpTokenKind.DIV_EQUAL,
    '%=' : PhpTokenKind.MOD_EQUAL,
    '.=' : PhpTokenKind.CONCAT_EQUAL,
    '&=' : PhpTokenKind.AND_EQUAL,
    '|=' : PhpTokenKind.OR_EQUAL,
    '^=' : PhpTokenKind.XOR_EQUAL,
    '&&' : PhpTokenKind.BOOLEAN_AND,
    '||' : PhpTokenKind.BOOLEAN_OR,
    '??' : PhpTokenKind.COALESCE,
    '<<' : PhpTokenKind.SL,
    '>>' : PhpTokenKind.SR,
}

_ONE_CHAR_OPS: dict[str, PhpTokenKind] = {
    '+' : PhpTokenKind.PLUS,
    '-' : PhpTokenKind.MINUS,
    '*' : PhpTokenKind.STAR,
    '/' : PhpTokenKind.SLASH,
    '%' : PhpTokenKind.PERCENT,
    '.' : PhpTokenKind.DOT,
    '=' : PhpTokenKind.EQUALS,
    '<' : PhpTokenKind.LT,
    '>' : PhpTokenKind.GT,
    '&' : PhpTokenKind.AMP,
    '|' : PhpTokenKind.PIPE,
    '^' : PhpTokenKind.CARET,
    '~' : PhpTokenKind.TILDE,
    '!' : PhpTokenKind.BANG,
    '@' : PhpTokenKind.AT,
    '?' : PhpTokenKind.QUESTION,
    ':' : PhpTokenKind.COLON,
    '(' : PhpTokenKind.LPAREN,
    ')' : PhpTokenKind.RPAREN,
    '{' : PhpTokenKind.LBRACE,
    '}' : PhpTokenKind.RBRACE,
    '[' : PhpTokenKind.LBRACKET,
    ']' : PhpTokenKind.RBRACKET,
    ';' : PhpTokenKind.SEMICOLON,
    ',' : PhpTokenKind.COMMA,
    '$' : PhpTokenKind.DOLLAR,
    '\\': PhpTokenKind.NS_SEPARATOR,
}


class PhpLexerMode(enum.Enum):
    INLINE_HTML = 'inline-html'
    SCRIPTING = 'scripting'


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


@dataclass
class PhpLexer:
    source: str
    pos: int = 0
    mode_stack: list[PhpLexerMode] = field(
        default_factory=lambda: [PhpLexerMode.INLINE_HTML])

    @property
    def mode(self) -> PhpLexerMode:
        return self.mode_stack[-1]

    @mode.setter
    def mode(self, value: PhpLexerMode):
        self.mode_stack[-1] = value

    def push_mode(self, mode: PhpLexerMode):
        self.mode_stack.append(mode)

    def pop_mode(self):
        if len(self.mode_stack) > 1:
            self.mode_stack.pop()

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

    def _read_inline_html(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        while self.pos < length:
            if src[self.pos] == '<' and src[self.pos + 1:self.pos + 2] == '?':
                break
            self.pos += 1
        return PhpToken(PhpTokenKind.INLINE_HTML, src[start:self.pos], start)

    def _read_open_tag(self) -> PhpToken:
        src = self.source
        start = self.pos
        if src[self.pos:self.pos + 3] == '<?=':
            self.pos += 3
            return PhpToken(PhpTokenKind.OPEN_TAG_ECHO, '<?=', start)
        if src[self.pos:self.pos + 5].lower() == '<?php':
            after = src[self.pos + 5:self.pos + 6]
            if after == '' or after.isspace():
                self.pos += 5
                return PhpToken(PhpTokenKind.OPEN_TAG, src[start:self.pos], start)
        self.pos += 2
        return PhpToken(PhpTokenKind.OPEN_TAG, '<?', start)

    def _read_line_comment(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        while self.pos < length and src[self.pos] not in '\r\n':
            if src[self.pos] == '?' and src[self.pos + 1:self.pos + 2] == '>':
                break
            self.pos += 1
        return PhpToken(PhpTokenKind.COMMENT, src[start:self.pos], start)

    def _read_block_comment(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        kind = PhpTokenKind.COMMENT
        if src[self.pos:self.pos + 3] == '/**' and src[self.pos + 3:self.pos + 4] != '/':
            kind = PhpTokenKind.DOC_COMMENT
        self.pos += 2
        while self.pos < length - 1:
            if src[self.pos] == '*' and src[self.pos + 1] == '/':
                self.pos += 2
                return PhpToken(kind, src[start:self.pos], start)
            self.pos += 1
        self.pos = length
        return PhpToken(kind, src[start:self.pos], start)

    def _skip_string_interpolation(self):
        """
        Advance the cursor over a balanced `{ ... }` interpolation region inside a double-quoted
        string or heredoc body. The cursor must point at the opening brace. Nested braces and nested
        quoted strings are skipped as units so that a closing brace or quote appearing inside the
        interpolation does not terminate the enclosing string prematurely.
        """
        src = self.source
        length = len(src)
        depth = 0
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            if c in '\'"':
                self._skip_nested_string(c)
                continue
            if c == '{':
                depth += 1
            elif c == '}':
                depth -= 1
                self.pos += 1
                if depth == 0:
                    return
                continue
            self.pos += 1

    def _skip_nested_string(self, quote: str):
        src = self.source
        length = len(src)
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            self.pos += 1
            if c == quote:
                return

    def _read_single_quoted(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            self.pos += 1
            if c == '\'':
                break
        return PhpToken(PhpTokenKind.STRING_SINGLE, src[start:self.pos], start)

    def _read_interpolated(self, quote: str, kind: PhpTokenKind) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            if c == quote:
                self.pos += 1
                break
            if c == '{' and src[self.pos + 1:self.pos + 2] == '$':
                self._skip_string_interpolation()
                continue
            if c == '$' and src[self.pos + 1:self.pos + 2] == '{':
                self.pos += 1
                self._skip_string_interpolation()
                continue
            self.pos += 1
        return PhpToken(kind, src[start:self.pos], start)

    def _read_heredoc(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 3
        while self.pos < length and src[self.pos] in ' \t':
            self.pos += 1
        nowdoc = False
        quote = ''
        if self.pos < length and src[self.pos] in '\'"':
            quote = src[self.pos]
            nowdoc = quote == '\''
            self.pos += 1
        label_start = self.pos
        while self.pos < length and _is_ident_part(src[self.pos]):
            self.pos += 1
        label = src[label_start:self.pos]
        if quote:
            self.pos += 1
        while self.pos < length and src[self.pos] not in '\r\n':
            self.pos += 1
        kind = PhpTokenKind.NOWDOC if nowdoc else PhpTokenKind.HEREDOC
        if not label:
            return PhpToken(PhpTokenKind.ERROR, src[start:self.pos], start)
        while self.pos < length:
            line_start = self.pos
            while self.pos < length and src[self.pos] in '\r\n':
                self.pos += 1
            content_start = self.pos
            while self.pos < length and src[self.pos] in ' \t':
                self.pos += 1
            if src[self.pos:self.pos + len(label)] == label:
                after_start = self.pos + len(label)
                after = src[after_start:after_start + 1]
                if not (after and _is_ident_part(after)):
                    self.pos = after_start
                    return PhpToken(kind, src[start:self.pos], start)
            self.pos = content_start
            if self.pos == line_start:
                self.pos += 1
            while self.pos < length and src[self.pos] not in '\r\n':
                if (
                    not nowdoc
                    and src[self.pos] == '\\'
                    and src[self.pos + 1:self.pos + 2] not in ('', '\r', '\n')
                ):
                    self.pos += 2
                    continue
                self.pos += 1
        return PhpToken(kind, src[start:self.pos], start)

    def _read_number(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        if src[self.pos] == '0' and self.pos + 1 < length:
            nc = src[self.pos + 1]
            if nc in 'xX':
                self.pos += 2
                return self._read_prefixed_int('0123456789abcdefABCDEF_', start)
            if nc in 'oO':
                self.pos += 2
                return self._read_prefixed_int('01234567_', start)
            if nc in 'bB':
                self.pos += 2
                return self._read_prefixed_int('01_', start)
        while self.pos < length and (src[self.pos].isdigit() or src[self.pos] == '_'):
            self.pos += 1
        is_float = False
        if self.pos < length and src[self.pos] == '.' and src[self.pos + 1:self.pos + 2] != '.':
            is_float = True
            self.pos += 1
            while self.pos < length and (src[self.pos].isdigit() or src[self.pos] == '_'):
                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() or src[self.pos] == '_'):
                    self.pos += 1
        kind = PhpTokenKind.FLOAT if is_float else PhpTokenKind.INTEGER
        return PhpToken(kind, src[start:self.pos], start)

    def _read_prefixed_int(self, valid_digits: str, start: int) -> PhpToken:
        src = self.source
        length = len(src)
        if self.pos >= length or src[self.pos] not in valid_digits or src[self.pos] == '_':
            return PhpToken(PhpTokenKind.ERROR, src[start:self.pos], start)
        while self.pos < length and src[self.pos] in valid_digits:
            self.pos += 1
        if src[self.pos - 1] == '_':
            return PhpToken(PhpTokenKind.ERROR, src[start:self.pos], start)
        return PhpToken(PhpTokenKind.INTEGER, src[start:self.pos], start)

    def _read_variable(self) -> PhpToken:
        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 PhpToken(PhpTokenKind.VARIABLE, src[start:self.pos], start)

    def _read_identifier(self) -> PhpToken:
        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 PhpToken(kind, word, start)
        return PhpToken(PhpTokenKind.IDENTIFIER, word, start)

    def _try_read_cast(self) -> PhpToken | None:
        src = self.source
        length = len(src)
        start = self.pos
        pos = self.pos + 1
        while pos < length and src[pos] in ' \t':
            pos += 1
        word_start = pos
        while pos < length and src[pos].isalpha():
            pos += 1
        word = src[word_start:pos]
        if not word:
            return None
        while pos < length and src[pos] in ' \t':
            pos += 1
        if pos >= length or src[pos] != ')':
            return None
        kind = CAST_KEYWORDS.get(word.lower())
        if kind is None:
            return None
        self.pos = pos + 1
        return PhpToken(kind, src[start:self.pos], start)

    def tokenize(self) -> Generator[PhpToken, None, None]:
        src = self.source
        length = len(src)
        while True:
            if self.mode is PhpLexerMode.INLINE_HTML:
                if self._at_end():
                    yield PhpToken(PhpTokenKind.EOF, '', self.pos)
                    return
                if src[self.pos] == '<' and src[self.pos + 1:self.pos + 2] == '?':
                    tok = self._read_open_tag()
                    self.mode = PhpLexerMode.SCRIPTING
                    yield tok
                    continue
                html = self._read_inline_html()
                if html.value:
                    yield html
                continue

            while self.pos < length and src[self.pos] in ' \t\r\n\f\v':
                self.pos += 1
            if self._at_end():
                yield PhpToken(PhpTokenKind.EOF, '', self.pos)
                return

            start = self.pos
            c = src[self.pos]
            c2 = src[self.pos:self.pos + 2]

            if c2 == '?>':
                self.pos += 2
                if self.pos < length and src[self.pos] == '\n':
                    self.pos += 1
                elif src[self.pos:self.pos + 2] == '\r\n':
                    self.pos += 2
                self.mode = PhpLexerMode.INLINE_HTML
                yield PhpToken(PhpTokenKind.CLOSE_TAG, '?>', start)
                continue

            if c2 == '//' or c == '#' and c2 != '#[':
                yield self._read_line_comment()
                continue
            if c2 == '/*':
                yield self._read_block_comment()
                continue
            if c2 == '#[':
                self.pos += 2
                yield PhpToken(PhpTokenKind.ATTRIBUTE, '#[', start)
                continue

            if c == '\'':
                yield self._read_single_quoted()
                continue
            if c == '"':
                yield self._read_interpolated('"', PhpTokenKind.STRING_DOUBLE)
                continue
            if c == '`':
                yield self._read_interpolated('`', PhpTokenKind.SHELL_EXEC)
                continue
            if c2 == '<<' and src[self.pos:self.pos + 3] == '<<<':
                yield self._read_heredoc()
                continue

            if c == '$' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]):
                yield self._read_variable()
                continue

            if c.isdigit() or (
                c == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit()
            ):
                yield self._read_number()
                continue

            if _is_ident_start(c):
                yield self._read_identifier()
                continue

            if c == '(':
                cast = self._try_read_cast()
                if cast is not None:
                    yield cast
                    continue

            c3 = src[self.pos:self.pos + 3]
            if c3 in _THREE_CHAR_OPS:
                self.pos += 3
                yield PhpToken(_THREE_CHAR_OPS[c3], c3, start)
                continue
            if c2 in _TWO_CHAR_OPS:
                self.pos += 2
                yield PhpToken(_TWO_CHAR_OPS[c2], c2, start)
                continue
            if c in _ONE_CHAR_OPS:
                self.pos += 1
                yield PhpToken(_ONE_CHAR_OPS[c], c, start)
                continue

            self.pos += 1
            yield PhpToken(PhpTokenKind.ERROR, c, start)


def decode_php_single_quoted(text: str) -> str:
    """
    Decode the body of a single-quoted PHP string literal. Only `\\'` and `\\\\` are recognized as
    escape sequences; every other backslash is literal.
    """
    if '\\' not in text:
        return text
    parts: list[str] = []
    i = 0
    length = len(text)
    while i < length:
        c = text[i]
        if c == '\\' and i + 1 < length and text[i + 1] in '\\\'':
            parts.append(text[i + 1])
            i += 2
            continue
        parts.append(c)
        i += 1
    return ''.join(parts)


def decode_php_double_quoted(text: str) -> str:
    """
    Decode the escape sequences of a double-quoted PHP string body. Interpolation is not resolved;
    embedded variable references are left verbatim. Recognizes the standard C-style escapes as well
    as `\\xHH`, `\\u{...}`, and octal `\\NNN`.
    """
    if '\\' not in text:
        return text
    parts: list[str] = []
    i = 0
    length = len(text)
    while i < length:
        c = text[i]
        if c != '\\' or i + 1 >= length:
            parts.append(c)
            i += 1
            continue
        n = text[i + 1]
        mapped = _ESCAPE_MAP.get(n)
        if mapped is not None:
            parts.append(mapped)
            i += 2
            continue
        if n == 'x' and i + 2 < length and text[i + 2] in _HEX:
            j = i + 2
            while j < length and j < i + 4 and text[j] in _HEX:
                j += 1
            parts.append(chr(int(text[i + 2:j], 16)))
            i = j
            continue
        if n == 'u' and text[i + 2:i + 3] == '{':
            end = text.find('}', i + 3)
            if end != -1 and all(h in _HEX for h in text[i + 3:end]) and end > i + 3:
                code_point = int(text[i + 3:end], 16)
                if code_point <= 0x10FFFF:
                    parts.append(chr(code_point))
                    i = end + 1
                    continue
                parts.append(text[i:end + 1])
                i = end + 1
                continue
        if n in '01234567':
            j = i + 1
            while j < length and j < i + 4 and text[j] in '01234567':
                j += 1
            parts.append(chr(int(text[i + 1:j], 8) & 0xFF))
            i = j
            continue
        parts.append(c)
        i += 1
    return ''.join(parts)

Functions

def decode_php_single_quoted(text)

Decode the body of a single-quoted PHP string literal. Only \' and \\ are recognized as escape sequences; every other backslash is literal.

Expand source code Browse git
def decode_php_single_quoted(text: str) -> str:
    """
    Decode the body of a single-quoted PHP string literal. Only `\\'` and `\\\\` are recognized as
    escape sequences; every other backslash is literal.
    """
    if '\\' not in text:
        return text
    parts: list[str] = []
    i = 0
    length = len(text)
    while i < length:
        c = text[i]
        if c == '\\' and i + 1 < length and text[i + 1] in '\\\'':
            parts.append(text[i + 1])
            i += 2
            continue
        parts.append(c)
        i += 1
    return ''.join(parts)
def decode_php_double_quoted(text)

Decode the escape sequences of a double-quoted PHP string body. Interpolation is not resolved; embedded variable references are left verbatim. Recognizes the standard C-style escapes as well as \xHH, \u{...}, and octal \NNN.

Expand source code Browse git
def decode_php_double_quoted(text: str) -> str:
    """
    Decode the escape sequences of a double-quoted PHP string body. Interpolation is not resolved;
    embedded variable references are left verbatim. Recognizes the standard C-style escapes as well
    as `\\xHH`, `\\u{...}`, and octal `\\NNN`.
    """
    if '\\' not in text:
        return text
    parts: list[str] = []
    i = 0
    length = len(text)
    while i < length:
        c = text[i]
        if c != '\\' or i + 1 >= length:
            parts.append(c)
            i += 1
            continue
        n = text[i + 1]
        mapped = _ESCAPE_MAP.get(n)
        if mapped is not None:
            parts.append(mapped)
            i += 2
            continue
        if n == 'x' and i + 2 < length and text[i + 2] in _HEX:
            j = i + 2
            while j < length and j < i + 4 and text[j] in _HEX:
                j += 1
            parts.append(chr(int(text[i + 2:j], 16)))
            i = j
            continue
        if n == 'u' and text[i + 2:i + 3] == '{':
            end = text.find('}', i + 3)
            if end != -1 and all(h in _HEX for h in text[i + 3:end]) and end > i + 3:
                code_point = int(text[i + 3:end], 16)
                if code_point <= 0x10FFFF:
                    parts.append(chr(code_point))
                    i = end + 1
                    continue
                parts.append(text[i:end + 1])
                i = end + 1
                continue
        if n in '01234567':
            j = i + 1
            while j < length and j < i + 4 and text[j] in '01234567':
                j += 1
            parts.append(chr(int(text[i + 1:j], 8) & 0xFF))
            i = j
            continue
        parts.append(c)
        i += 1
    return ''.join(parts)

Classes

class PhpLexerMode (*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 PhpLexerMode(enum.Enum):
    INLINE_HTML = 'inline-html'
    SCRIPTING = 'scripting'

Ancestors

  • enum.Enum

Class variables

var INLINE_HTML

The type of the None singleton.

var SCRIPTING

The type of the None singleton.

class PhpLexer (source, pos=0, mode_stack=<factory>)

PhpLexer(source: 'str', pos: 'int' = 0, mode_stack: 'list[PhpLexerMode]' = )

Expand source code Browse git
@dataclass
class PhpLexer:
    source: str
    pos: int = 0
    mode_stack: list[PhpLexerMode] = field(
        default_factory=lambda: [PhpLexerMode.INLINE_HTML])

    @property
    def mode(self) -> PhpLexerMode:
        return self.mode_stack[-1]

    @mode.setter
    def mode(self, value: PhpLexerMode):
        self.mode_stack[-1] = value

    def push_mode(self, mode: PhpLexerMode):
        self.mode_stack.append(mode)

    def pop_mode(self):
        if len(self.mode_stack) > 1:
            self.mode_stack.pop()

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

    def _read_inline_html(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        while self.pos < length:
            if src[self.pos] == '<' and src[self.pos + 1:self.pos + 2] == '?':
                break
            self.pos += 1
        return PhpToken(PhpTokenKind.INLINE_HTML, src[start:self.pos], start)

    def _read_open_tag(self) -> PhpToken:
        src = self.source
        start = self.pos
        if src[self.pos:self.pos + 3] == '<?=':
            self.pos += 3
            return PhpToken(PhpTokenKind.OPEN_TAG_ECHO, '<?=', start)
        if src[self.pos:self.pos + 5].lower() == '<?php':
            after = src[self.pos + 5:self.pos + 6]
            if after == '' or after.isspace():
                self.pos += 5
                return PhpToken(PhpTokenKind.OPEN_TAG, src[start:self.pos], start)
        self.pos += 2
        return PhpToken(PhpTokenKind.OPEN_TAG, '<?', start)

    def _read_line_comment(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        while self.pos < length and src[self.pos] not in '\r\n':
            if src[self.pos] == '?' and src[self.pos + 1:self.pos + 2] == '>':
                break
            self.pos += 1
        return PhpToken(PhpTokenKind.COMMENT, src[start:self.pos], start)

    def _read_block_comment(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        kind = PhpTokenKind.COMMENT
        if src[self.pos:self.pos + 3] == '/**' and src[self.pos + 3:self.pos + 4] != '/':
            kind = PhpTokenKind.DOC_COMMENT
        self.pos += 2
        while self.pos < length - 1:
            if src[self.pos] == '*' and src[self.pos + 1] == '/':
                self.pos += 2
                return PhpToken(kind, src[start:self.pos], start)
            self.pos += 1
        self.pos = length
        return PhpToken(kind, src[start:self.pos], start)

    def _skip_string_interpolation(self):
        """
        Advance the cursor over a balanced `{ ... }` interpolation region inside a double-quoted
        string or heredoc body. The cursor must point at the opening brace. Nested braces and nested
        quoted strings are skipped as units so that a closing brace or quote appearing inside the
        interpolation does not terminate the enclosing string prematurely.
        """
        src = self.source
        length = len(src)
        depth = 0
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            if c in '\'"':
                self._skip_nested_string(c)
                continue
            if c == '{':
                depth += 1
            elif c == '}':
                depth -= 1
                self.pos += 1
                if depth == 0:
                    return
                continue
            self.pos += 1

    def _skip_nested_string(self, quote: str):
        src = self.source
        length = len(src)
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            self.pos += 1
            if c == quote:
                return

    def _read_single_quoted(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            self.pos += 1
            if c == '\'':
                break
        return PhpToken(PhpTokenKind.STRING_SINGLE, src[start:self.pos], start)

    def _read_interpolated(self, quote: str, kind: PhpTokenKind) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 1
        while self.pos < length:
            c = src[self.pos]
            if c == '\\':
                self.pos += 2
                continue
            if c == quote:
                self.pos += 1
                break
            if c == '{' and src[self.pos + 1:self.pos + 2] == '$':
                self._skip_string_interpolation()
                continue
            if c == '$' and src[self.pos + 1:self.pos + 2] == '{':
                self.pos += 1
                self._skip_string_interpolation()
                continue
            self.pos += 1
        return PhpToken(kind, src[start:self.pos], start)

    def _read_heredoc(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        self.pos += 3
        while self.pos < length and src[self.pos] in ' \t':
            self.pos += 1
        nowdoc = False
        quote = ''
        if self.pos < length and src[self.pos] in '\'"':
            quote = src[self.pos]
            nowdoc = quote == '\''
            self.pos += 1
        label_start = self.pos
        while self.pos < length and _is_ident_part(src[self.pos]):
            self.pos += 1
        label = src[label_start:self.pos]
        if quote:
            self.pos += 1
        while self.pos < length and src[self.pos] not in '\r\n':
            self.pos += 1
        kind = PhpTokenKind.NOWDOC if nowdoc else PhpTokenKind.HEREDOC
        if not label:
            return PhpToken(PhpTokenKind.ERROR, src[start:self.pos], start)
        while self.pos < length:
            line_start = self.pos
            while self.pos < length and src[self.pos] in '\r\n':
                self.pos += 1
            content_start = self.pos
            while self.pos < length and src[self.pos] in ' \t':
                self.pos += 1
            if src[self.pos:self.pos + len(label)] == label:
                after_start = self.pos + len(label)
                after = src[after_start:after_start + 1]
                if not (after and _is_ident_part(after)):
                    self.pos = after_start
                    return PhpToken(kind, src[start:self.pos], start)
            self.pos = content_start
            if self.pos == line_start:
                self.pos += 1
            while self.pos < length and src[self.pos] not in '\r\n':
                if (
                    not nowdoc
                    and src[self.pos] == '\\'
                    and src[self.pos + 1:self.pos + 2] not in ('', '\r', '\n')
                ):
                    self.pos += 2
                    continue
                self.pos += 1
        return PhpToken(kind, src[start:self.pos], start)

    def _read_number(self) -> PhpToken:
        src = self.source
        length = len(src)
        start = self.pos
        if src[self.pos] == '0' and self.pos + 1 < length:
            nc = src[self.pos + 1]
            if nc in 'xX':
                self.pos += 2
                return self._read_prefixed_int('0123456789abcdefABCDEF_', start)
            if nc in 'oO':
                self.pos += 2
                return self._read_prefixed_int('01234567_', start)
            if nc in 'bB':
                self.pos += 2
                return self._read_prefixed_int('01_', start)
        while self.pos < length and (src[self.pos].isdigit() or src[self.pos] == '_'):
            self.pos += 1
        is_float = False
        if self.pos < length and src[self.pos] == '.' and src[self.pos + 1:self.pos + 2] != '.':
            is_float = True
            self.pos += 1
            while self.pos < length and (src[self.pos].isdigit() or src[self.pos] == '_'):
                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() or src[self.pos] == '_'):
                    self.pos += 1
        kind = PhpTokenKind.FLOAT if is_float else PhpTokenKind.INTEGER
        return PhpToken(kind, src[start:self.pos], start)

    def _read_prefixed_int(self, valid_digits: str, start: int) -> PhpToken:
        src = self.source
        length = len(src)
        if self.pos >= length or src[self.pos] not in valid_digits or src[self.pos] == '_':
            return PhpToken(PhpTokenKind.ERROR, src[start:self.pos], start)
        while self.pos < length and src[self.pos] in valid_digits:
            self.pos += 1
        if src[self.pos - 1] == '_':
            return PhpToken(PhpTokenKind.ERROR, src[start:self.pos], start)
        return PhpToken(PhpTokenKind.INTEGER, src[start:self.pos], start)

    def _read_variable(self) -> PhpToken:
        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 PhpToken(PhpTokenKind.VARIABLE, src[start:self.pos], start)

    def _read_identifier(self) -> PhpToken:
        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 PhpToken(kind, word, start)
        return PhpToken(PhpTokenKind.IDENTIFIER, word, start)

    def _try_read_cast(self) -> PhpToken | None:
        src = self.source
        length = len(src)
        start = self.pos
        pos = self.pos + 1
        while pos < length and src[pos] in ' \t':
            pos += 1
        word_start = pos
        while pos < length and src[pos].isalpha():
            pos += 1
        word = src[word_start:pos]
        if not word:
            return None
        while pos < length and src[pos] in ' \t':
            pos += 1
        if pos >= length or src[pos] != ')':
            return None
        kind = CAST_KEYWORDS.get(word.lower())
        if kind is None:
            return None
        self.pos = pos + 1
        return PhpToken(kind, src[start:self.pos], start)

    def tokenize(self) -> Generator[PhpToken, None, None]:
        src = self.source
        length = len(src)
        while True:
            if self.mode is PhpLexerMode.INLINE_HTML:
                if self._at_end():
                    yield PhpToken(PhpTokenKind.EOF, '', self.pos)
                    return
                if src[self.pos] == '<' and src[self.pos + 1:self.pos + 2] == '?':
                    tok = self._read_open_tag()
                    self.mode = PhpLexerMode.SCRIPTING
                    yield tok
                    continue
                html = self._read_inline_html()
                if html.value:
                    yield html
                continue

            while self.pos < length and src[self.pos] in ' \t\r\n\f\v':
                self.pos += 1
            if self._at_end():
                yield PhpToken(PhpTokenKind.EOF, '', self.pos)
                return

            start = self.pos
            c = src[self.pos]
            c2 = src[self.pos:self.pos + 2]

            if c2 == '?>':
                self.pos += 2
                if self.pos < length and src[self.pos] == '\n':
                    self.pos += 1
                elif src[self.pos:self.pos + 2] == '\r\n':
                    self.pos += 2
                self.mode = PhpLexerMode.INLINE_HTML
                yield PhpToken(PhpTokenKind.CLOSE_TAG, '?>', start)
                continue

            if c2 == '//' or c == '#' and c2 != '#[':
                yield self._read_line_comment()
                continue
            if c2 == '/*':
                yield self._read_block_comment()
                continue
            if c2 == '#[':
                self.pos += 2
                yield PhpToken(PhpTokenKind.ATTRIBUTE, '#[', start)
                continue

            if c == '\'':
                yield self._read_single_quoted()
                continue
            if c == '"':
                yield self._read_interpolated('"', PhpTokenKind.STRING_DOUBLE)
                continue
            if c == '`':
                yield self._read_interpolated('`', PhpTokenKind.SHELL_EXEC)
                continue
            if c2 == '<<' and src[self.pos:self.pos + 3] == '<<<':
                yield self._read_heredoc()
                continue

            if c == '$' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]):
                yield self._read_variable()
                continue

            if c.isdigit() or (
                c == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit()
            ):
                yield self._read_number()
                continue

            if _is_ident_start(c):
                yield self._read_identifier()
                continue

            if c == '(':
                cast = self._try_read_cast()
                if cast is not None:
                    yield cast
                    continue

            c3 = src[self.pos:self.pos + 3]
            if c3 in _THREE_CHAR_OPS:
                self.pos += 3
                yield PhpToken(_THREE_CHAR_OPS[c3], c3, start)
                continue
            if c2 in _TWO_CHAR_OPS:
                self.pos += 2
                yield PhpToken(_TWO_CHAR_OPS[c2], c2, start)
                continue
            if c in _ONE_CHAR_OPS:
                self.pos += 1
                yield PhpToken(_ONE_CHAR_OPS[c], c, start)
                continue

            self.pos += 1
            yield PhpToken(PhpTokenKind.ERROR, c, start)

Instance variables

var source

The type of the None singleton.

var mode_stack

The type of the None singleton.

var pos

The type of the None singleton.

var mode
Expand source code Browse git
@property
def mode(self) -> PhpLexerMode:
    return self.mode_stack[-1]

Methods

def push_mode(self, mode)
Expand source code Browse git
def push_mode(self, mode: PhpLexerMode):
    self.mode_stack.append(mode)
def pop_mode(self)
Expand source code Browse git
def pop_mode(self):
    if len(self.mode_stack) > 1:
        self.mode_stack.pop()
def tokenize(self)
Expand source code Browse git
def tokenize(self) -> Generator[PhpToken, None, None]:
    src = self.source
    length = len(src)
    while True:
        if self.mode is PhpLexerMode.INLINE_HTML:
            if self._at_end():
                yield PhpToken(PhpTokenKind.EOF, '', self.pos)
                return
            if src[self.pos] == '<' and src[self.pos + 1:self.pos + 2] == '?':
                tok = self._read_open_tag()
                self.mode = PhpLexerMode.SCRIPTING
                yield tok
                continue
            html = self._read_inline_html()
            if html.value:
                yield html
            continue

        while self.pos < length and src[self.pos] in ' \t\r\n\f\v':
            self.pos += 1
        if self._at_end():
            yield PhpToken(PhpTokenKind.EOF, '', self.pos)
            return

        start = self.pos
        c = src[self.pos]
        c2 = src[self.pos:self.pos + 2]

        if c2 == '?>':
            self.pos += 2
            if self.pos < length and src[self.pos] == '\n':
                self.pos += 1
            elif src[self.pos:self.pos + 2] == '\r\n':
                self.pos += 2
            self.mode = PhpLexerMode.INLINE_HTML
            yield PhpToken(PhpTokenKind.CLOSE_TAG, '?>', start)
            continue

        if c2 == '//' or c == '#' and c2 != '#[':
            yield self._read_line_comment()
            continue
        if c2 == '/*':
            yield self._read_block_comment()
            continue
        if c2 == '#[':
            self.pos += 2
            yield PhpToken(PhpTokenKind.ATTRIBUTE, '#[', start)
            continue

        if c == '\'':
            yield self._read_single_quoted()
            continue
        if c == '"':
            yield self._read_interpolated('"', PhpTokenKind.STRING_DOUBLE)
            continue
        if c == '`':
            yield self._read_interpolated('`', PhpTokenKind.SHELL_EXEC)
            continue
        if c2 == '<<' and src[self.pos:self.pos + 3] == '<<<':
            yield self._read_heredoc()
            continue

        if c == '$' and self.pos + 1 < length and _is_ident_start(src[self.pos + 1]):
            yield self._read_variable()
            continue

        if c.isdigit() or (
            c == '.' and self.pos + 1 < length and src[self.pos + 1].isdigit()
        ):
            yield self._read_number()
            continue

        if _is_ident_start(c):
            yield self._read_identifier()
            continue

        if c == '(':
            cast = self._try_read_cast()
            if cast is not None:
                yield cast
                continue

        c3 = src[self.pos:self.pos + 3]
        if c3 in _THREE_CHAR_OPS:
            self.pos += 3
            yield PhpToken(_THREE_CHAR_OPS[c3], c3, start)
            continue
        if c2 in _TWO_CHAR_OPS:
            self.pos += 2
            yield PhpToken(_TWO_CHAR_OPS[c2], c2, start)
            continue
        if c in _ONE_CHAR_OPS:
            self.pos += 1
            yield PhpToken(_ONE_CHAR_OPS[c], c, start)
            continue

        self.pos += 1
        yield PhpToken(PhpTokenKind.ERROR, c, start)