Module refinery.lib.scripts.js.deobfuscation.interpreter

Mini-interpreter for executing pure JavaScript functions with concrete arguments.

Expand source code Browse git
"""
Mini-interpreter for executing pure JavaScript functions with concrete arguments.
"""
from __future__ import annotations

import math

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping
    from typing import TypeAlias

    from refinery.lib.scripts.js.model import JsArrowFunctionExpression as _Arrow
    from refinery.lib.scripts.js.model import JsFunctionDeclaration as _FuncDecl
    from refinery.lib.scripts.js.model import JsFunctionExpression as _FuncExpr

    Value: TypeAlias = str | int | float | bool | list | dict | _FuncDecl | _FuncExpr | _Arrow | None

from refinery.lib.scripts import Node
from refinery.lib.scripts.js.deobfuscation.helpers import (
    RELATIONAL_OPS,
    _to_int32,
    eval_binary_op,
    js_parse_int,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsBinaryExpression,
    JsBlockStatement,
    JsBooleanLiteral,
    JsBreakStatement,
    JsCallExpression,
    JsConditionalExpression,
    JsContinueStatement,
    JsDoWhileStatement,
    JsExpressionStatement,
    JsForInStatement,
    JsForOfStatement,
    JsForStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsIfStatement,
    JsLogicalExpression,
    JsMemberExpression,
    JsNullLiteral,
    JsNumericLiteral,
    JsObjectExpression,
    JsProperty,
    JsPropertyKind,
    JsReturnStatement,
    JsSequenceExpression,
    JsStringLiteral,
    JsSwitchCase,
    JsSwitchStatement,
    JsTemplateLiteral,
    JsThrowStatement,
    JsTryStatement,
    JsUnaryExpression,
    JsUpdateExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsWhileStatement,
)

MAX_ITERATIONS = 100_000
MAX_STRING_LEN = 1_000_000
_MAX_RECURSION = 10


class InterpreterError(Exception):
    pass


class IrreducibleExpression(Exception):
    def __init__(self, node: Node):
        self.node = node


class _ReturnSignal(Exception):
    def __init__(self, value: Value):
        self.value = value


class _BreakSignal(Exception):
    pass


class _ContinueSignal(Exception):
    pass


def _truthy(value: Value) -> bool:
    if value is None:
        return False
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0 and value == value
    if isinstance(value, str):
        return len(value) > 0
    if isinstance(value, list):
        return True
    if isinstance(value, dict):
        return True
    return False


def to_number(value: Value) -> int | float:
    if isinstance(value, bool):
        return 1 if value else 0
    if isinstance(value, (int, float)):
        return value
    if isinstance(value, str):
        s = value.strip()
        if not s:
            return 0
        try:
            return int(s, 0)
        except ValueError:
            pass
        try:
            return float(s)
        except ValueError:
            return float('nan')
    if value is None:
        return 0
    return float('nan')


def to_string(value: Value) -> str:
    if isinstance(value, str):
        return value
    if value is None:
        return 'undefined'
    if isinstance(value, bool):
        return 'true' if value else 'false'
    if isinstance(value, int):
        return str(value)
    if isinstance(value, float):
        if value != value:
            return 'NaN'
        if value == float('inf'):
            return 'Infinity'
        if value == float('-inf'):
            return '-Infinity'
        if value == int(value):
            return str(int(value))
        return str(value)
    if isinstance(value, list):
        return ','.join(to_string(v) for v in value)
    return '[object Object]'


def _js_typeof(value: Value) -> str:
    if value is None:
        return 'undefined'
    if isinstance(value, bool):
        return 'boolean'
    if isinstance(value, (int, float)):
        return 'number'
    if isinstance(value, str):
        return 'string'
    return 'object'


BUILTIN_REGISTRY: dict[tuple, Callable] = {}


def _register(key: tuple):
    def _decorator(fn: Callable):
        BUILTIN_REGISTRY[key] = fn
        return fn
    return _decorator


@_register((str, 'length'))
def _str_length(s: str, args: list[Value]) -> Value:
    return len(s)


@_register((str, 'charAt'))
def _str_char_at(s: str, args: list[Value]) -> Value:
    idx = int(to_number(args[0])) if args else 0
    if 0 <= idx < len(s):
        return s[idx]
    return ''


@_register((str, 'charCodeAt'))
def _str_char_code_at(s: str, args: list[Value]) -> Value:
    idx = int(to_number(args[0])) if args else 0
    if 0 <= idx < len(s):
        return ord(s[idx])
    return float('nan')


@_register((str, 'indexOf'))
def _str_index_of(s: str, args: list[Value]) -> Value:
    if not args:
        return -1
    search = to_string(args[0])
    start = int(to_number(args[1])) if len(args) > 1 else 0
    return s.find(search, max(0, start))


@_register((str, 'lastIndexOf'))
def _str_last_index_of(s: str, args: list[Value]) -> Value:
    if not args:
        return -1
    search = to_string(args[0])
    end = int(to_number(args[1])) + 1 if len(args) > 1 else len(s)
    return s.rfind(search, 0, end)


@_register((str, 'includes'))
def _str_includes(s: str, args: list[Value]) -> Value:
    if not args:
        return False
    search = to_string(args[0])
    start = int(to_number(args[1])) if len(args) > 1 else 0
    return s.find(search, max(0, start)) != -1


@_register((str, 'startsWith'))
def _str_starts_with(s: str, args: list[Value]) -> Value:
    if not args:
        return False
    prefix = to_string(args[0])
    start = int(to_number(args[1])) if len(args) > 1 else 0
    return s[start:].startswith(prefix)


@_register((str, 'endsWith'))
def _str_ends_with(s: str, args: list[Value]) -> Value:
    if not args:
        return False
    suffix = to_string(args[0])
    end = int(to_number(args[1])) if len(args) > 1 else len(s)
    return s[:end].endswith(suffix)


@_register((str, 'slice'))
def _str_slice(s: str, args: list[Value]) -> Value:
    n = len(s)
    start = int(to_number(args[0])) if args else 0
    end = int(to_number(args[1])) if len(args) > 1 else n
    if start < 0:
        start = max(n + start, 0)
    if end < 0:
        end = max(n + end, 0)
    return s[start:end]


@_register((str, 'substring'))
def _str_substring(s: str, args: list[Value]) -> Value:
    n = len(s)
    start = int(to_number(args[0])) if args else 0
    end = int(to_number(args[1])) if len(args) > 1 else n
    start = max(0, min(start, n))
    end = max(0, min(end, n))
    if start > end:
        start, end = end, start
    return s[start:end]


@_register((str, 'substr'))
def _str_substr(s: str, args: list[Value]) -> Value:
    n = len(s)
    start = int(to_number(args[0])) if args else 0
    length = int(to_number(args[1])) if len(args) > 1 else n
    if start < 0:
        start = max(n + start, 0)
    return s[start:start + max(0, length)]


@_register((str, 'split'))
def _str_split(s: str, args: list[Value]) -> Value:
    if not args:
        return [s]
    sep = to_string(args[0])
    if not sep:
        result = list(s)
    else:
        result = s.split(sep)
    if len(args) > 1:
        limit = int(to_number(args[1]))
        result = result[:limit]
    return result


@_register((str, 'replace'))
def _str_replace(s: str, args: list[Value]) -> Value:
    if len(args) < 2:
        return s
    search = to_string(args[0])
    replacement = to_string(args[1])
    return s.replace(search, replacement, 1)


@_register((str, 'replaceAll'))
def _str_replace_all(s: str, args: list[Value]) -> Value:
    if len(args) < 2:
        return s
    search = to_string(args[0])
    replacement = to_string(args[1])
    return s.replace(search, replacement)


@_register((str, 'toLowerCase'))
def _str_to_lower(s: str, args: list[Value]) -> Value:
    return s.lower()


@_register((str, 'toUpperCase'))
def _str_to_upper(s: str, args: list[Value]) -> Value:
    return s.upper()


@_register((str, 'trim'))
def _str_trim(s: str, args: list[Value]) -> Value:
    return s.strip()


@_register((str, 'trimStart'))
def _str_trim_start(s: str, args: list[Value]) -> Value:
    return s.lstrip()


@_register((str, 'trimEnd'))
def _str_trim_end(s: str, args: list[Value]) -> Value:
    return s.rstrip()


@_register((str, 'repeat'))
def _str_repeat(s: str, args: list[Value]) -> Value:
    count = int(to_number(args[0])) if args else 0
    if count < 0:
        raise InterpreterError
    return s * count


def _str_pad(s: str, args: list[Value], prepend: bool) -> Value:
    target_len = int(to_number(args[0])) if args else 0
    fill = to_string(args[1]) if len(args) > 1 else ' '
    needed = target_len - len(s)
    if needed <= 0 or not fill:
        return s
    pad = (fill * (needed // len(fill) + 1))[:needed]
    return pad + s if prepend else s + pad


@_register((str, 'padStart'))
def _str_pad_start(s: str, args: list[Value]) -> Value:
    return _str_pad(s, args, prepend=True)


@_register((str, 'padEnd'))
def _str_pad_end(s: str, args: list[Value]) -> Value:
    return _str_pad(s, args, prepend=False)


@_register((str, 'at'))
def _str_at(s: str, args: list[Value]) -> Value:
    idx = int(to_number(args[0])) if args else 0
    if idx < 0:
        idx += len(s)
    if 0 <= idx < len(s):
        return s[idx]
    return None


@_register(('String', 'fromCharCode'))
def _string_from_char_code(args: list[Value]) -> Value:
    return ''.join(chr(int(to_number(a)) & 0xFFFF) for a in args)


@_register((list, 'length'))
def _arr_length(arr: list, args: list[Value]) -> Value:
    return len(arr)


@_register((list, 'push'))
def _arr_push(arr: list, args: list[Value]) -> Value:
    arr.extend(args)
    return len(arr)


@_register((list, 'pop'))
def _arr_pop(arr: list, args: list[Value]) -> Value:
    if arr:
        return arr.pop()
    return None


@_register((list, 'shift'))
def _arr_shift(arr: list, args: list[Value]) -> Value:
    if arr:
        return arr.pop(0)
    return None


@_register((list, 'unshift'))
def _arr_unshift(arr: list, args: list[Value]) -> Value:
    for i, a in enumerate(args):
        arr.insert(i, a)
    return len(arr)


@_register((list, 'reverse'))
def _arr_reverse(arr: list, args: list[Value]) -> Value:
    arr.reverse()
    return arr


@_register((list, 'concat'))
def _arr_concat(arr: list, args: list[Value]) -> Value:
    result = list(arr)
    for a in args:
        if isinstance(a, list):
            result.extend(a)
        else:
            result.append(a)
    return result


@_register((list, 'slice'))
def _arr_slice(arr: list, args: list[Value]) -> Value:
    n = len(arr)
    start = int(to_number(args[0])) if args else 0
    end = int(to_number(args[1])) if len(args) > 1 else n
    if start < 0:
        start = max(n + start, 0)
    if end < 0:
        end = max(n + end, 0)
    return arr[start:end]


@_register((list, 'splice'))
def _arr_splice(arr: list, args: list[Value]) -> Value:
    if not args:
        return []
    start = int(to_number(args[0]))
    n = len(arr)
    if start < 0:
        start = max(n + start, 0)
    else:
        start = min(start, n)
    delete_count = int(to_number(args[1])) if len(args) > 1 else n - start
    delete_count = max(0, min(delete_count, n - start))
    removed = arr[start:start + delete_count]
    new_items = list(args[2:])
    arr[start:start + delete_count] = new_items
    return removed


@_register((list, 'join'))
def _arr_join(arr: list, args: list[Value]) -> Value:
    sep = to_string(args[0]) if args else ','
    return sep.join(to_string(v) for v in arr)


@_register((list, 'indexOf'))
def _arr_index_of(arr: list, args: list[Value]) -> Value:
    if not args:
        return -1
    target = args[0]
    start = int(to_number(args[1])) if len(args) > 1 else 0
    for i in range(max(0, start), len(arr)):
        if arr[i] == target:
            return i
    return -1


@_register((list, 'includes'))
def _arr_includes(arr: list, args: list[Value]) -> Value:
    if not args:
        return False
    return args[0] in arr


@_register((list, 'flat'))
def _arr_flat(arr: list, args: list[Value]) -> Value:
    depth = int(to_number(args[0])) if args else 1

    def _flatten(lst: list, d: int) -> list:
        result: list = []
        for item in lst:
            if isinstance(item, list) and d > 0:
                result.extend(_flatten(item, d - 1))
            else:
                result.append(item)
        return result
    return _flatten(arr, depth)


@_register((list, 'at'))
def _arr_at(arr: list, args: list[Value]) -> Value:
    idx = int(to_number(args[0])) if args else 0
    if idx < 0:
        idx += len(arr)
    if 0 <= idx < len(arr):
        return arr[idx]
    return None


@_register((list, 'fill'))
def _arr_fill(arr: list, args: list[Value]) -> Value:
    if not args:
        return arr
    value = args[0]
    n = len(arr)
    start = int(to_number(args[1])) if len(args) > 1 else 0
    end = int(to_number(args[2])) if len(args) > 2 else n
    if start < 0:
        start = max(n + start, 0)
    if end < 0:
        end = max(n + end, 0)
    for i in range(start, min(end, n)):
        arr[i] = value
    return arr


_ARRAY_HOF_METHODS = frozenset({
    'every', 'some', 'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex',
})


@_register(('Math', 'floor'))
def _math_floor(args: list[Value]) -> Value:
    return int(math.floor(to_number(args[0]))) if args else 0


@_register(('Math', 'ceil'))
def _math_ceil(args: list[Value]) -> Value:
    return int(math.ceil(to_number(args[0]))) if args else 0


@_register(('Math', 'round'))
def _math_round(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else 0
    return int(math.floor(v + 0.5))


@_register(('Math', 'abs'))
def _math_abs(args: list[Value]) -> Value:
    return abs(to_number(args[0])) if args else 0


@_register(('Math', 'pow'))
def _math_pow(args: list[Value]) -> Value:
    if len(args) < 2:
        return float('nan')
    base = to_number(args[0])
    exp = to_number(args[1])
    try:
        return base ** exp
    except (OverflowError, ValueError):
        return float('nan')


@_register(('Math', 'sqrt'))
def _math_sqrt(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else 0
    if v < 0:
        return float('nan')
    return math.sqrt(v)


@_register(('Math', 'min'))
def _math_min(args: list[Value]) -> Value:
    if not args:
        return float('inf')
    return min(to_number(a) for a in args)


@_register(('Math', 'max'))
def _math_max(args: list[Value]) -> Value:
    if not args:
        return float('-inf')
    return max(to_number(a) for a in args)


@_register(('Math', 'trunc'))
def _math_trunc(args: list[Value]) -> Value:
    return int(math.trunc(to_number(args[0]))) if args else 0


@_register(('Math', 'sign'))
def _math_sign(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else 0
    if v > 0:
        return 1
    if v < 0:
        return -1
    return 0


def _math_log_impl(args: list[Value], fn) -> Value:
    v = to_number(args[0]) if args else 0
    if v <= 0:
        return float('-inf') if v == 0 else float('nan')
    return fn(v)


@_register(('Math', 'log'))
def _math_log(args: list[Value]) -> Value:
    return _math_log_impl(args, math.log)


@_register(('Math', 'log2'))
def _math_log2(args: list[Value]) -> Value:
    return _math_log_impl(args, math.log2)


@_register((None, 'parseInt'))
def _global_parse_int(args: list[Value]) -> Value:
    if not args:
        return float('nan')
    s = to_string(args[0])
    radix = int(to_number(args[1])) if len(args) > 1 else 10
    result = js_parse_int(s, radix)
    if result is None:
        return float('nan')
    return result


@_register((None, 'parseFloat'))
def _global_parse_float(args: list[Value]) -> Value:
    if not args:
        return float('nan')
    s = to_string(args[0]).strip()
    if not s:
        return float('nan')
    digits: list[str] = []
    i = 0
    if i < len(s) and s[i] in '+-':
        digits.append(s[i])
        i += 1
    has_dot = False
    while i < len(s):
        if s[i].isdigit():
            digits.append(s[i])
        elif s[i] == '.' and not has_dot:
            digits.append(s[i])
            has_dot = True
        else:
            break
        i += 1
    if not digits or digits == ['+'] or digits == ['-']:
        return float('nan')
    try:
        return float(''.join(digits))
    except ValueError:
        return float('nan')


@_register((None, 'isNaN'))
def _global_is_nan(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else float('nan')
    return v != v


@_register((None, 'isFinite'))
def _global_is_finite(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else float('nan')
    return math.isfinite(v)


@_register((None, 'Number'))
def _global_number(args: list[Value]) -> Value:
    if not args:
        return 0
    return to_number(args[0])


@_register((None, 'String'))
def _global_string(args: list[Value]) -> Value:
    if not args:
        return ''
    return to_string(args[0])


@_register(('Object', 'keys'))
def _object_keys(args: list[Value]) -> Value:
    if args and isinstance(args[0], dict):
        return list(args[0].keys())
    raise InterpreterError


@_register(('Object', 'values'))
def _object_values(args: list[Value]) -> Value:
    if args and isinstance(args[0], dict):
        return list(args[0].values())
    raise InterpreterError


@_register(('Object', 'entries'))
def _object_entries(args: list[Value]) -> Value:
    if args and isinstance(args[0], dict):
        return [[k, v] for k, v in args[0].items()]
    raise InterpreterError


@_register(('Array', 'from'))
def _array_from(args: list[Value]) -> Value:
    if not args:
        return []
    src = args[0]
    if isinstance(src, (str, list)):
        return list(src)
    raise InterpreterError


@_register(('Array', 'isArray'))
def _array_is_array(args: list[Value]) -> Value:
    return isinstance(args[0], list) if args else False


STATIC_OBJECTS = frozenset({'Math', 'String', 'Object', 'Array', 'Number'})


def is_runtime_name(name: str) -> bool:
    """
    Return True if `name` is a known JavaScript runtime symbol — either a static object namespace
    (e.g. `Math`, `String`) or a global function registered in the builtin registry (e.g.
    `parseInt`, `parseFloat`).
    """
    return name in STATIC_OBJECTS or (None, name) in BUILTIN_REGISTRY


class JsInterpreter:
    """
    Execute a JavaScript function body with concrete argument values. Returns a Python value or
    raises `IrreducibleExpression` when the return value cannot be reduced to a simple value.
    """

    def __init__(
        self, *,
        max_iterations: int = MAX_ITERATIONS,
        max_string_len: int = MAX_STRING_LEN,
        max_recursion: int = _MAX_RECURSION,
        functions: Mapping[str, JsFunctionDeclaration] | None = None,
        depth: int = 0,
    ):
        self.max_iterations = max_iterations
        self.max_string_len = max_string_len
        self.max_recursion = max_recursion
        self._functions: Mapping[str, JsFunctionDeclaration] = functions or {}
        self._env: dict[str, Value] = {}
        self._iterations = 0
        self._depth = depth

    def execute(
        self,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        arguments: list[Value],
    ) -> Value:
        params = func.params
        param_names: list[str] = []
        for p in params:
            if not isinstance(p, JsIdentifier):
                raise InterpreterError
            param_names.append(p.name)
        self._env = {}
        for i, name in enumerate(param_names):
            self._env[name] = arguments[i] if i < len(arguments) else None
        self._iterations = 0
        body = func.body
        if isinstance(body, JsBlockStatement):
            try:
                self._exec_statements(body.body)
            except _ReturnSignal as r:
                return r.value
            return None
        if body is not None:
            return self._eval(body)
        return None

    def _exec_statements(self, stmts: list) -> None:
        for stmt in stmts:
            self._exec_statement(stmt)

    def _exec_statement(self, stmt) -> None:
        if isinstance(stmt, JsVariableDeclaration):
            self._exec_var_decl(stmt)
        elif isinstance(stmt, JsExpressionStatement):
            self._eval(stmt.expression)
        elif isinstance(stmt, JsIfStatement):
            self._exec_if(stmt)
        elif isinstance(stmt, JsSwitchStatement):
            self._exec_switch(stmt)
        elif isinstance(stmt, JsForStatement):
            self._exec_for(stmt)
        elif isinstance(stmt, JsWhileStatement):
            self._exec_while(stmt)
        elif isinstance(stmt, JsDoWhileStatement):
            self._exec_do_while(stmt)
        elif isinstance(stmt, JsForInStatement):
            self._exec_for_in(stmt)
        elif isinstance(stmt, JsForOfStatement):
            self._exec_for_of(stmt)
        elif isinstance(stmt, JsReturnStatement):
            if stmt.argument is None:
                raise _ReturnSignal(None)
            try:
                value = self._eval(stmt.argument)
            except InterpreterError:
                raise IrreducibleExpression(stmt.argument)
            raise _ReturnSignal(value)
        elif isinstance(stmt, JsBreakStatement):
            raise _BreakSignal
        elif isinstance(stmt, JsContinueStatement):
            raise _ContinueSignal
        elif isinstance(stmt, JsBlockStatement):
            self._exec_statements(stmt.body)
        elif isinstance(stmt, JsTryStatement):
            self._exec_try(stmt)
        elif isinstance(stmt, JsThrowStatement):
            raise InterpreterError
        elif isinstance(stmt, JsFunctionDeclaration):
            if isinstance(stmt.id, JsIdentifier):
                self._env[stmt.id.name] = stmt
        else:
            raise InterpreterError

    def _exec_var_decl(self, node: JsVariableDeclaration) -> None:
        for decl in node.declarations:
            if not isinstance(decl, JsVariableDeclarator):
                raise InterpreterError
            if not isinstance(decl.id, JsIdentifier):
                raise InterpreterError
            name = decl.id.name
            value = self._eval(decl.init) if decl.init else None
            self._env[name] = value

    def _exec_if(self, node: JsIfStatement) -> None:
        if _truthy(self._eval(node.test)):
            if node.consequent:
                self._exec_statement(node.consequent)
        elif node.alternate:
            self._exec_statement(node.alternate)

    def _exec_switch(self, node: JsSwitchStatement) -> None:
        discriminant = self._eval(node.discriminant)
        matched = False
        for case in node.cases:
            if not isinstance(case, JsSwitchCase):
                raise InterpreterError
            if not matched:
                matched = case.test is None or self._strict_equal(discriminant, self._eval(case.test))
            if matched:
                try:
                    self._exec_statements(case.body)
                except _BreakSignal:
                    return

    def _exec_loop_body(self, body) -> bool:
        if not body:
            return False
        try:
            self._exec_statement(body)
        except _BreakSignal:
            return True
        except _ContinueSignal:
            pass
        return False

    def _exec_for(self, node: JsForStatement) -> None:
        if node.init:
            if isinstance(node.init, JsVariableDeclaration):
                self._exec_var_decl(node.init)
            else:
                self._eval(node.init)
        while True:
            self._tick()
            if node.test and not _truthy(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break
            if node.update:
                self._eval(node.update)

    def _exec_while(self, node: JsWhileStatement) -> None:
        while True:
            self._tick()
            if not _truthy(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break

    def _exec_do_while(self, node: JsDoWhileStatement) -> None:
        while True:
            self._tick()
            if self._exec_loop_body(node.body):
                break
            if not _truthy(self._eval(node.test)):
                break

    def _exec_for_in(self, node: JsForInStatement) -> None:
        right = self._eval(node.right)
        if isinstance(right, dict):
            keys: list = list(right.keys())
        elif isinstance(right, list):
            keys = [str(i) for i in range(len(right))]
        else:
            raise InterpreterError
        var_name = self._get_loop_var(node.left)
        for key in keys:
            self._tick()
            self._env[var_name] = key
            if self._exec_loop_body(node.body):
                break

    def _exec_for_of(self, node: JsForOfStatement) -> None:
        right = self._eval(node.right)
        if isinstance(right, list):
            items = right
        elif isinstance(right, str):
            items = list(right)
        else:
            raise InterpreterError
        var_name = self._get_loop_var(node.left)
        for item in items:
            self._tick()
            self._env[var_name] = item
            if self._exec_loop_body(node.body):
                break

    def _exec_try(self, node: JsTryStatement) -> None:
        try:
            if node.block:
                self._exec_statements(node.block.body)
        except InterpreterError:
            if node.handler and node.handler.body:
                self._exec_statements(node.handler.body.body)
            else:
                raise

    def _get_loop_var(self, left) -> str:
        if isinstance(left, JsVariableDeclaration):
            if len(left.declarations) == 1:
                decl = left.declarations[0]
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                    return decl.id.name
        if isinstance(left, JsIdentifier):
            return left.name
        raise InterpreterError

    def _tick(self) -> None:
        self._iterations += 1
        if self._iterations > self.max_iterations:
            raise InterpreterError

    def _eval(self, expr) -> Value:
        if expr is None:
            return None
        if isinstance(expr, JsStringLiteral):
            return expr.value
        if isinstance(expr, JsNumericLiteral):
            return expr.value
        if isinstance(expr, JsBooleanLiteral):
            return expr.value
        if isinstance(expr, JsNullLiteral):
            return None
        if isinstance(expr, JsIdentifier):
            return self._eval_identifier(expr)
        if isinstance(expr, JsBinaryExpression):
            return self._eval_binary(expr)
        if isinstance(expr, JsUnaryExpression):
            return self._eval_unary(expr)
        if isinstance(expr, JsUpdateExpression):
            return self._eval_update(expr)
        if isinstance(expr, JsLogicalExpression):
            return self._eval_logical(expr)
        if isinstance(expr, JsAssignmentExpression):
            return self._eval_assignment(expr)
        if isinstance(expr, JsCallExpression):
            return self._eval_call(expr)
        if isinstance(expr, JsMemberExpression):
            return self._eval_member(expr)
        if isinstance(expr, JsConditionalExpression):
            test = self._eval(expr.test)
            return self._eval(expr.consequent) if _truthy(test) else self._eval(expr.alternate)
        if isinstance(expr, JsArrayExpression):
            return [self._eval(e) if e else None for e in expr.elements]
        if isinstance(expr, JsSequenceExpression):
            result: Value = None
            for e in expr.expressions:
                result = self._eval(e)
            return result
        if isinstance(expr, JsTemplateLiteral):
            return self._eval_template(expr)
        if isinstance(expr, JsObjectExpression):
            return self._eval_object(expr)
        if isinstance(expr, (JsFunctionExpression, JsArrowFunctionExpression)):
            return expr
        raise InterpreterError

    def _eval_identifier(self, node: JsIdentifier) -> Value:
        name = node.name
        if name == 'undefined':
            return None
        if name == 'NaN':
            return float('nan')
        if name == 'Infinity':
            return float('inf')
        if name in self._env:
            return self._env[name]
        raise InterpreterError

    def _eval_binary(self, node: JsBinaryExpression) -> Value:
        op = node.operator
        left = self._eval(node.left)
        right = self._eval(node.right)
        if op in ('===', '=='):
            return self._strict_equal(left, right)
        if op in ('!==', '!='):
            return not self._strict_equal(left, right)
        if op == '+':
            if isinstance(left, str) or isinstance(right, str):
                result = to_string(left) + to_string(right)
                if len(result) > self.max_string_len:
                    raise InterpreterError
                return result
            return to_number(left) + to_number(right)
        if op == 'in':
            if isinstance(right, dict):
                return to_string(left) in right
            if isinstance(right, list):
                idx = int(to_number(left))
                return 0 <= idx < len(right)
            raise InterpreterError
        if op == 'instanceof':
            raise InterpreterError
        if op in RELATIONAL_OPS and isinstance(left, str) and isinstance(right, str):
            return RELATIONAL_OPS[op](left, right)
        result = eval_binary_op(op, to_number(left), to_number(right))
        if result is None:
            raise InterpreterError
        return result

    def _eval_unary(self, node: JsUnaryExpression) -> Value:
        op = node.operator
        if op == 'typeof':
            if isinstance(node.operand, JsIdentifier):
                name = node.operand.name
                if name in self._env:
                    return _js_typeof(self._env[name])
                return 'undefined'
            return _js_typeof(self._eval(node.operand))
        if op == 'void':
            self._eval(node.operand)
            return None
        operand = self._eval(node.operand)
        if op == '-':
            v = to_number(operand)
            return -v if v != 0 else (-0.0 if isinstance(v, float) else 0)
        if op == '+':
            return to_number(operand)
        if op == '~':
            return _to_int32(~int(to_number(operand)))
        if op == '!':
            return not _truthy(operand)
        raise InterpreterError

    def _eval_update(self, node: JsUpdateExpression) -> Value:
        if not isinstance(node.argument, JsIdentifier):
            raise InterpreterError
        name = node.argument.name
        if name not in self._env:
            raise InterpreterError
        current = to_number(self._env[name])
        if node.operator == '++':
            new_val = current + 1
        elif node.operator == '--':
            new_val = current - 1
        else:
            raise InterpreterError
        self._env[name] = new_val
        return new_val if node.prefix else current

    def _eval_logical(self, node: JsLogicalExpression) -> Value:
        left = self._eval(node.left)
        if node.operator == '&&':
            return self._eval(node.right) if _truthy(left) else left
        if node.operator == '||':
            return left if _truthy(left) else self._eval(node.right)
        if node.operator == '??':
            return left if left is not None else self._eval(node.right)
        raise InterpreterError

    def _eval_assignment(self, node: JsAssignmentExpression) -> Value:
        if isinstance(node.left, JsMemberExpression):
            return self._eval_member_assignment(node)
        if not isinstance(node.left, JsIdentifier):
            raise InterpreterError
        name = node.left.name
        value = self._eval(node.right)
        op = node.operator
        if op == '=':
            self._env[name] = value
            return value
        current = self._env.get(name)
        if op == '+=':
            if isinstance(current, str) or isinstance(value, str):
                result = to_string(current) + to_string(value)
                if len(result) > self.max_string_len:
                    raise InterpreterError
                self._env[name] = result
            else:
                self._env[name] = to_number(current) + to_number(value)
        elif op == '-=':
            self._env[name] = to_number(current) - to_number(value)
        elif op == '*=':
            self._env[name] = to_number(current) * to_number(value)
        elif op == '/=':
            divisor = to_number(value)
            if divisor == 0:
                raise InterpreterError
            self._env[name] = to_number(current) / divisor
        elif op == '%=':
            divisor = to_number(value)
            if divisor == 0:
                raise InterpreterError
            self._env[name] = math.fmod(to_number(current), divisor)
        elif op == '|=':
            self._env[name] = _to_int32(int(to_number(current)) | int(to_number(value)))
        elif op == '&=':
            self._env[name] = _to_int32(int(to_number(current)) & int(to_number(value)))
        elif op == '^=':
            self._env[name] = _to_int32(int(to_number(current)) ^ int(to_number(value)))
        elif op == '<<=':
            self._env[name] = _to_int32(_to_int32(int(to_number(current))) << (int(to_number(value)) & 0x1F))
        elif op == '>>=':
            self._env[name] = _to_int32(
                _to_int32(int(to_number(current))) >> (int(to_number(value)) & 0x1F)
            )
        else:
            raise InterpreterError
        return self._env[name]

    def _eval_member_assignment(self, node: JsAssignmentExpression) -> Value:
        member = node.left
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        obj = self._eval(member.object)
        key = self._member_key(member)
        value = self._eval(node.right)
        if node.operator != '=':
            old = self._get_property(obj, key)
            if node.operator == '+=':
                if isinstance(old, str) or isinstance(value, str):
                    value = to_string(old) + to_string(value)
                else:
                    value = to_number(old) + to_number(value)
            elif node.operator == '-=':
                value = to_number(old) - to_number(value)
            elif node.operator == '*=':
                value = to_number(old) * to_number(value)
            else:
                raise InterpreterError
        self._set_property(obj, key, value)
        return value

    def _eval_call(self, node: JsCallExpression) -> Value:
        if isinstance(node.callee, JsMemberExpression):
            return self._eval_method_call(node)
        if isinstance(node.callee, JsIdentifier):
            return self._eval_function_call(node)
        if isinstance(node.callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return self._eval_inline_call(node.callee, node.arguments)
        raise InterpreterError

    def _eval_function_call(self, node: JsCallExpression) -> Value:
        callee = node.callee
        if not isinstance(callee, JsIdentifier):
            raise InterpreterError
        name = callee.name
        args = [self._eval(a) for a in node.arguments]
        builtin = BUILTIN_REGISTRY.get((None, name))
        if builtin is not None:
            return builtin(args)
        if name in self._env:
            target = self._env[name]
            if isinstance(target, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
                return self._call_function(target, args)
        func = self._functions.get(name)
        if func is not None:
            return self._call_function(func, args)
        raise InterpreterError

    def _eval_method_call(self, node: JsCallExpression) -> Value:
        member = node.callee
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        if (
            isinstance(member.object, JsIdentifier)
            and member.object.name in STATIC_OBJECTS
        ):
            static_name = member.object.name
            method_name = self._member_key(member)
            args = [self._eval(a) for a in node.arguments]
            builtin = BUILTIN_REGISTRY.get((static_name, method_name))
            if builtin is not None:
                return builtin(args)
            raise InterpreterError
        obj = self._eval(member.object)
        method_name = self._member_key(member)
        args = [self._eval(a) for a in node.arguments]
        obj_type = type(obj)
        builtin = BUILTIN_REGISTRY.get((obj_type, method_name))
        if builtin is not None:
            return builtin(obj, args)
        if isinstance(obj, list) and method_name in _ARRAY_HOF_METHODS:
            return self._eval_array_hof(obj, method_name, args)
        if isinstance(obj, (JsFunctionExpression, JsArrowFunctionExpression)):
            if method_name == 'call':
                return self._call_function(obj, args[1:] if len(args) > 1 else [])
            if method_name == 'apply':
                actual_args = args[1] if len(args) > 1 and isinstance(args[1], list) else []
                return self._call_function(obj, actual_args)
        raise InterpreterError

    def _eval_array_hof(self, arr: list, method: str, args: list[Value]) -> Value:
        if not args:
            raise InterpreterError
        callback = args[0]
        if not isinstance(
            callback,
            (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)
        ):
            raise InterpreterError
        if method == 'every':
            for i, item in enumerate(arr):
                self._tick()
                if not _truthy(self._call_function(callback, [item, i, arr])):
                    return False
            return True
        if method == 'some':
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    return True
            return False
        if method == 'map':
            mapped: list[Value] = []
            for i, item in enumerate(arr):
                self._tick()
                mapped.append(self._call_function(callback, [item, i, arr]))
            return mapped
        if method == 'filter':
            filtered: list[Value] = []
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    filtered.append(item)
            return filtered
        if method == 'find':
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    return item
            return None
        if method == 'findIndex':
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    return i
            return -1
        if method == 'forEach':
            for i, item in enumerate(arr):
                self._tick()
                self._call_function(callback, [item, i, arr])
            return None
        if method == 'reduce':
            if len(arr) == 0 and len(args) < 2:
                raise InterpreterError
            if len(args) >= 2:
                acc: Value = args[1]
                start = 0
            else:
                acc = arr[0]
                start = 1
            for i in range(start, len(arr)):
                self._tick()
                acc = self._call_function(callback, [acc, arr[i], i, arr])
            return acc
        raise InterpreterError

    def _eval_inline_call(self, func, arguments: list) -> Value:
        args = [self._eval(a) for a in arguments]
        return self._call_function(func, args)

    def _call_function(self, func, args: list[Value]) -> Value:
        if self._depth >= self.max_recursion:
            raise InterpreterError
        child = JsInterpreter(
            max_iterations=self.max_iterations - self._iterations,
            max_string_len=self.max_string_len,
            max_recursion=self.max_recursion,
            functions=self._functions,
            depth=self._depth + 1,
        )
        try:
            result = child.execute(func, args)
        finally:
            self._iterations += child._iterations
        return result

    def _eval_member(self, node: JsMemberExpression) -> Value:
        if isinstance(node.object, JsIdentifier) and node.object.name in STATIC_OBJECTS:
            raise InterpreterError
        obj = self._eval(node.object)
        key = self._member_key(node)
        return self._get_property(obj, key)

    def _eval_template(self, node: JsTemplateLiteral) -> Value:
        parts: list[str] = []
        for i, quasi in enumerate(node.quasis):
            parts.append(quasi.value)
            if i < len(node.expressions):
                parts.append(to_string(self._eval(node.expressions[i])))
        result = ''.join(parts)
        if len(result) > self.max_string_len:
            raise InterpreterError
        return result

    def _eval_object(self, node: JsObjectExpression) -> Value:
        result: dict[str, Value] = {}
        for prop in node.properties:
            if not isinstance(prop, JsProperty):
                raise InterpreterError
            if prop.kind != JsPropertyKind.INIT:
                raise InterpreterError
            key: str
            if prop.computed:
                key = to_string(self._eval(prop.key))
            elif isinstance(prop.key, JsIdentifier):
                key = prop.key.name
            elif isinstance(prop.key, JsStringLiteral):
                key = prop.key.value
            elif isinstance(prop.key, JsNumericLiteral):
                key = to_string(prop.key.value)
            else:
                raise InterpreterError
            result[key] = self._eval(prop.value)
        return result

    def _member_key(self, node: JsMemberExpression) -> str:
        if node.computed:
            val = self._eval(node.property)
            return to_string(val)
        if isinstance(node.property, JsIdentifier):
            return node.property.name
        raise InterpreterError

    def _get_property(self, obj: Value, key: str) -> Value:
        if isinstance(obj, dict):
            return obj.get(key)
        if isinstance(obj, list):
            if key == 'length':
                return len(obj)
            try:
                idx = int(key)
                if 0 <= idx < len(obj):
                    return obj[idx]
                return None
            except (ValueError, TypeError):
                pass
            builtin = BUILTIN_REGISTRY.get((list, key))
            if builtin is not None:
                raise InterpreterError
            return None
        if isinstance(obj, str):
            builtin = BUILTIN_REGISTRY.get((str, key))
            if builtin is not None:
                return builtin(obj, [])
            try:
                idx = int(key)
                if 0 <= idx < len(obj):
                    return obj[idx]
                return None
            except (ValueError, TypeError):
                pass
            return None
        raise InterpreterError

    def _set_property(self, obj: Value, key: str, value: Value) -> None:
        if isinstance(obj, dict):
            obj[key] = value
            return
        if isinstance(obj, list):
            if key == 'length':
                new_len = int(to_number(value))
                if new_len < len(obj):
                    del obj[new_len:]
                else:
                    obj.extend([None] * (new_len - len(obj)))
                return
            try:
                idx = int(key)
                if idx < 0:
                    raise InterpreterError
                while len(obj) <= idx:
                    obj.append(None)
                obj[idx] = value
                return
            except (ValueError, TypeError):
                pass
        raise InterpreterError

    @staticmethod
    def _strict_equal(a: Value, b: Value) -> bool:
        if a is None and b is None:
            return True
        if a is None or b is None:
            return False
        if type(a) is not type(b):
            if isinstance(a, (int, float)) and isinstance(b, (int, float)):
                return a == b
            return False
        return a == b

Functions

def to_number(value)
Expand source code Browse git
def to_number(value: Value) -> int | float:
    if isinstance(value, bool):
        return 1 if value else 0
    if isinstance(value, (int, float)):
        return value
    if isinstance(value, str):
        s = value.strip()
        if not s:
            return 0
        try:
            return int(s, 0)
        except ValueError:
            pass
        try:
            return float(s)
        except ValueError:
            return float('nan')
    if value is None:
        return 0
    return float('nan')
def to_string(value)
Expand source code Browse git
def to_string(value: Value) -> str:
    if isinstance(value, str):
        return value
    if value is None:
        return 'undefined'
    if isinstance(value, bool):
        return 'true' if value else 'false'
    if isinstance(value, int):
        return str(value)
    if isinstance(value, float):
        if value != value:
            return 'NaN'
        if value == float('inf'):
            return 'Infinity'
        if value == float('-inf'):
            return '-Infinity'
        if value == int(value):
            return str(int(value))
        return str(value)
    if isinstance(value, list):
        return ','.join(to_string(v) for v in value)
    return '[object Object]'
def is_runtime_name(name)

Return True if name is a known JavaScript runtime symbol — either a static object namespace (e.g. Math, String) or a global function registered in the builtin registry (e.g. parseInt, parseFloat).

Expand source code Browse git
def is_runtime_name(name: str) -> bool:
    """
    Return True if `name` is a known JavaScript runtime symbol — either a static object namespace
    (e.g. `Math`, `String`) or a global function registered in the builtin registry (e.g.
    `parseInt`, `parseFloat`).
    """
    return name in STATIC_OBJECTS or (None, name) in BUILTIN_REGISTRY

Classes

class InterpreterError (*args, **kwargs)

Common base class for all non-exit exceptions.

Expand source code Browse git
class InterpreterError(Exception):
    pass

Ancestors

  • builtins.Exception
  • builtins.BaseException
class IrreducibleExpression (node)

Common base class for all non-exit exceptions.

Expand source code Browse git
class IrreducibleExpression(Exception):
    def __init__(self, node: Node):
        self.node = node

Ancestors

  • builtins.Exception
  • builtins.BaseException
class JsInterpreter (*, max_iterations=100000, max_string_len=1000000, max_recursion=10, functions=None, depth=0)

Execute a JavaScript function body with concrete argument values. Returns a Python value or raises IrreducibleExpression when the return value cannot be reduced to a simple value.

Expand source code Browse git
class JsInterpreter:
    """
    Execute a JavaScript function body with concrete argument values. Returns a Python value or
    raises `IrreducibleExpression` when the return value cannot be reduced to a simple value.
    """

    def __init__(
        self, *,
        max_iterations: int = MAX_ITERATIONS,
        max_string_len: int = MAX_STRING_LEN,
        max_recursion: int = _MAX_RECURSION,
        functions: Mapping[str, JsFunctionDeclaration] | None = None,
        depth: int = 0,
    ):
        self.max_iterations = max_iterations
        self.max_string_len = max_string_len
        self.max_recursion = max_recursion
        self._functions: Mapping[str, JsFunctionDeclaration] = functions or {}
        self._env: dict[str, Value] = {}
        self._iterations = 0
        self._depth = depth

    def execute(
        self,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        arguments: list[Value],
    ) -> Value:
        params = func.params
        param_names: list[str] = []
        for p in params:
            if not isinstance(p, JsIdentifier):
                raise InterpreterError
            param_names.append(p.name)
        self._env = {}
        for i, name in enumerate(param_names):
            self._env[name] = arguments[i] if i < len(arguments) else None
        self._iterations = 0
        body = func.body
        if isinstance(body, JsBlockStatement):
            try:
                self._exec_statements(body.body)
            except _ReturnSignal as r:
                return r.value
            return None
        if body is not None:
            return self._eval(body)
        return None

    def _exec_statements(self, stmts: list) -> None:
        for stmt in stmts:
            self._exec_statement(stmt)

    def _exec_statement(self, stmt) -> None:
        if isinstance(stmt, JsVariableDeclaration):
            self._exec_var_decl(stmt)
        elif isinstance(stmt, JsExpressionStatement):
            self._eval(stmt.expression)
        elif isinstance(stmt, JsIfStatement):
            self._exec_if(stmt)
        elif isinstance(stmt, JsSwitchStatement):
            self._exec_switch(stmt)
        elif isinstance(stmt, JsForStatement):
            self._exec_for(stmt)
        elif isinstance(stmt, JsWhileStatement):
            self._exec_while(stmt)
        elif isinstance(stmt, JsDoWhileStatement):
            self._exec_do_while(stmt)
        elif isinstance(stmt, JsForInStatement):
            self._exec_for_in(stmt)
        elif isinstance(stmt, JsForOfStatement):
            self._exec_for_of(stmt)
        elif isinstance(stmt, JsReturnStatement):
            if stmt.argument is None:
                raise _ReturnSignal(None)
            try:
                value = self._eval(stmt.argument)
            except InterpreterError:
                raise IrreducibleExpression(stmt.argument)
            raise _ReturnSignal(value)
        elif isinstance(stmt, JsBreakStatement):
            raise _BreakSignal
        elif isinstance(stmt, JsContinueStatement):
            raise _ContinueSignal
        elif isinstance(stmt, JsBlockStatement):
            self._exec_statements(stmt.body)
        elif isinstance(stmt, JsTryStatement):
            self._exec_try(stmt)
        elif isinstance(stmt, JsThrowStatement):
            raise InterpreterError
        elif isinstance(stmt, JsFunctionDeclaration):
            if isinstance(stmt.id, JsIdentifier):
                self._env[stmt.id.name] = stmt
        else:
            raise InterpreterError

    def _exec_var_decl(self, node: JsVariableDeclaration) -> None:
        for decl in node.declarations:
            if not isinstance(decl, JsVariableDeclarator):
                raise InterpreterError
            if not isinstance(decl.id, JsIdentifier):
                raise InterpreterError
            name = decl.id.name
            value = self._eval(decl.init) if decl.init else None
            self._env[name] = value

    def _exec_if(self, node: JsIfStatement) -> None:
        if _truthy(self._eval(node.test)):
            if node.consequent:
                self._exec_statement(node.consequent)
        elif node.alternate:
            self._exec_statement(node.alternate)

    def _exec_switch(self, node: JsSwitchStatement) -> None:
        discriminant = self._eval(node.discriminant)
        matched = False
        for case in node.cases:
            if not isinstance(case, JsSwitchCase):
                raise InterpreterError
            if not matched:
                matched = case.test is None or self._strict_equal(discriminant, self._eval(case.test))
            if matched:
                try:
                    self._exec_statements(case.body)
                except _BreakSignal:
                    return

    def _exec_loop_body(self, body) -> bool:
        if not body:
            return False
        try:
            self._exec_statement(body)
        except _BreakSignal:
            return True
        except _ContinueSignal:
            pass
        return False

    def _exec_for(self, node: JsForStatement) -> None:
        if node.init:
            if isinstance(node.init, JsVariableDeclaration):
                self._exec_var_decl(node.init)
            else:
                self._eval(node.init)
        while True:
            self._tick()
            if node.test and not _truthy(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break
            if node.update:
                self._eval(node.update)

    def _exec_while(self, node: JsWhileStatement) -> None:
        while True:
            self._tick()
            if not _truthy(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break

    def _exec_do_while(self, node: JsDoWhileStatement) -> None:
        while True:
            self._tick()
            if self._exec_loop_body(node.body):
                break
            if not _truthy(self._eval(node.test)):
                break

    def _exec_for_in(self, node: JsForInStatement) -> None:
        right = self._eval(node.right)
        if isinstance(right, dict):
            keys: list = list(right.keys())
        elif isinstance(right, list):
            keys = [str(i) for i in range(len(right))]
        else:
            raise InterpreterError
        var_name = self._get_loop_var(node.left)
        for key in keys:
            self._tick()
            self._env[var_name] = key
            if self._exec_loop_body(node.body):
                break

    def _exec_for_of(self, node: JsForOfStatement) -> None:
        right = self._eval(node.right)
        if isinstance(right, list):
            items = right
        elif isinstance(right, str):
            items = list(right)
        else:
            raise InterpreterError
        var_name = self._get_loop_var(node.left)
        for item in items:
            self._tick()
            self._env[var_name] = item
            if self._exec_loop_body(node.body):
                break

    def _exec_try(self, node: JsTryStatement) -> None:
        try:
            if node.block:
                self._exec_statements(node.block.body)
        except InterpreterError:
            if node.handler and node.handler.body:
                self._exec_statements(node.handler.body.body)
            else:
                raise

    def _get_loop_var(self, left) -> str:
        if isinstance(left, JsVariableDeclaration):
            if len(left.declarations) == 1:
                decl = left.declarations[0]
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                    return decl.id.name
        if isinstance(left, JsIdentifier):
            return left.name
        raise InterpreterError

    def _tick(self) -> None:
        self._iterations += 1
        if self._iterations > self.max_iterations:
            raise InterpreterError

    def _eval(self, expr) -> Value:
        if expr is None:
            return None
        if isinstance(expr, JsStringLiteral):
            return expr.value
        if isinstance(expr, JsNumericLiteral):
            return expr.value
        if isinstance(expr, JsBooleanLiteral):
            return expr.value
        if isinstance(expr, JsNullLiteral):
            return None
        if isinstance(expr, JsIdentifier):
            return self._eval_identifier(expr)
        if isinstance(expr, JsBinaryExpression):
            return self._eval_binary(expr)
        if isinstance(expr, JsUnaryExpression):
            return self._eval_unary(expr)
        if isinstance(expr, JsUpdateExpression):
            return self._eval_update(expr)
        if isinstance(expr, JsLogicalExpression):
            return self._eval_logical(expr)
        if isinstance(expr, JsAssignmentExpression):
            return self._eval_assignment(expr)
        if isinstance(expr, JsCallExpression):
            return self._eval_call(expr)
        if isinstance(expr, JsMemberExpression):
            return self._eval_member(expr)
        if isinstance(expr, JsConditionalExpression):
            test = self._eval(expr.test)
            return self._eval(expr.consequent) if _truthy(test) else self._eval(expr.alternate)
        if isinstance(expr, JsArrayExpression):
            return [self._eval(e) if e else None for e in expr.elements]
        if isinstance(expr, JsSequenceExpression):
            result: Value = None
            for e in expr.expressions:
                result = self._eval(e)
            return result
        if isinstance(expr, JsTemplateLiteral):
            return self._eval_template(expr)
        if isinstance(expr, JsObjectExpression):
            return self._eval_object(expr)
        if isinstance(expr, (JsFunctionExpression, JsArrowFunctionExpression)):
            return expr
        raise InterpreterError

    def _eval_identifier(self, node: JsIdentifier) -> Value:
        name = node.name
        if name == 'undefined':
            return None
        if name == 'NaN':
            return float('nan')
        if name == 'Infinity':
            return float('inf')
        if name in self._env:
            return self._env[name]
        raise InterpreterError

    def _eval_binary(self, node: JsBinaryExpression) -> Value:
        op = node.operator
        left = self._eval(node.left)
        right = self._eval(node.right)
        if op in ('===', '=='):
            return self._strict_equal(left, right)
        if op in ('!==', '!='):
            return not self._strict_equal(left, right)
        if op == '+':
            if isinstance(left, str) or isinstance(right, str):
                result = to_string(left) + to_string(right)
                if len(result) > self.max_string_len:
                    raise InterpreterError
                return result
            return to_number(left) + to_number(right)
        if op == 'in':
            if isinstance(right, dict):
                return to_string(left) in right
            if isinstance(right, list):
                idx = int(to_number(left))
                return 0 <= idx < len(right)
            raise InterpreterError
        if op == 'instanceof':
            raise InterpreterError
        if op in RELATIONAL_OPS and isinstance(left, str) and isinstance(right, str):
            return RELATIONAL_OPS[op](left, right)
        result = eval_binary_op(op, to_number(left), to_number(right))
        if result is None:
            raise InterpreterError
        return result

    def _eval_unary(self, node: JsUnaryExpression) -> Value:
        op = node.operator
        if op == 'typeof':
            if isinstance(node.operand, JsIdentifier):
                name = node.operand.name
                if name in self._env:
                    return _js_typeof(self._env[name])
                return 'undefined'
            return _js_typeof(self._eval(node.operand))
        if op == 'void':
            self._eval(node.operand)
            return None
        operand = self._eval(node.operand)
        if op == '-':
            v = to_number(operand)
            return -v if v != 0 else (-0.0 if isinstance(v, float) else 0)
        if op == '+':
            return to_number(operand)
        if op == '~':
            return _to_int32(~int(to_number(operand)))
        if op == '!':
            return not _truthy(operand)
        raise InterpreterError

    def _eval_update(self, node: JsUpdateExpression) -> Value:
        if not isinstance(node.argument, JsIdentifier):
            raise InterpreterError
        name = node.argument.name
        if name not in self._env:
            raise InterpreterError
        current = to_number(self._env[name])
        if node.operator == '++':
            new_val = current + 1
        elif node.operator == '--':
            new_val = current - 1
        else:
            raise InterpreterError
        self._env[name] = new_val
        return new_val if node.prefix else current

    def _eval_logical(self, node: JsLogicalExpression) -> Value:
        left = self._eval(node.left)
        if node.operator == '&&':
            return self._eval(node.right) if _truthy(left) else left
        if node.operator == '||':
            return left if _truthy(left) else self._eval(node.right)
        if node.operator == '??':
            return left if left is not None else self._eval(node.right)
        raise InterpreterError

    def _eval_assignment(self, node: JsAssignmentExpression) -> Value:
        if isinstance(node.left, JsMemberExpression):
            return self._eval_member_assignment(node)
        if not isinstance(node.left, JsIdentifier):
            raise InterpreterError
        name = node.left.name
        value = self._eval(node.right)
        op = node.operator
        if op == '=':
            self._env[name] = value
            return value
        current = self._env.get(name)
        if op == '+=':
            if isinstance(current, str) or isinstance(value, str):
                result = to_string(current) + to_string(value)
                if len(result) > self.max_string_len:
                    raise InterpreterError
                self._env[name] = result
            else:
                self._env[name] = to_number(current) + to_number(value)
        elif op == '-=':
            self._env[name] = to_number(current) - to_number(value)
        elif op == '*=':
            self._env[name] = to_number(current) * to_number(value)
        elif op == '/=':
            divisor = to_number(value)
            if divisor == 0:
                raise InterpreterError
            self._env[name] = to_number(current) / divisor
        elif op == '%=':
            divisor = to_number(value)
            if divisor == 0:
                raise InterpreterError
            self._env[name] = math.fmod(to_number(current), divisor)
        elif op == '|=':
            self._env[name] = _to_int32(int(to_number(current)) | int(to_number(value)))
        elif op == '&=':
            self._env[name] = _to_int32(int(to_number(current)) & int(to_number(value)))
        elif op == '^=':
            self._env[name] = _to_int32(int(to_number(current)) ^ int(to_number(value)))
        elif op == '<<=':
            self._env[name] = _to_int32(_to_int32(int(to_number(current))) << (int(to_number(value)) & 0x1F))
        elif op == '>>=':
            self._env[name] = _to_int32(
                _to_int32(int(to_number(current))) >> (int(to_number(value)) & 0x1F)
            )
        else:
            raise InterpreterError
        return self._env[name]

    def _eval_member_assignment(self, node: JsAssignmentExpression) -> Value:
        member = node.left
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        obj = self._eval(member.object)
        key = self._member_key(member)
        value = self._eval(node.right)
        if node.operator != '=':
            old = self._get_property(obj, key)
            if node.operator == '+=':
                if isinstance(old, str) or isinstance(value, str):
                    value = to_string(old) + to_string(value)
                else:
                    value = to_number(old) + to_number(value)
            elif node.operator == '-=':
                value = to_number(old) - to_number(value)
            elif node.operator == '*=':
                value = to_number(old) * to_number(value)
            else:
                raise InterpreterError
        self._set_property(obj, key, value)
        return value

    def _eval_call(self, node: JsCallExpression) -> Value:
        if isinstance(node.callee, JsMemberExpression):
            return self._eval_method_call(node)
        if isinstance(node.callee, JsIdentifier):
            return self._eval_function_call(node)
        if isinstance(node.callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return self._eval_inline_call(node.callee, node.arguments)
        raise InterpreterError

    def _eval_function_call(self, node: JsCallExpression) -> Value:
        callee = node.callee
        if not isinstance(callee, JsIdentifier):
            raise InterpreterError
        name = callee.name
        args = [self._eval(a) for a in node.arguments]
        builtin = BUILTIN_REGISTRY.get((None, name))
        if builtin is not None:
            return builtin(args)
        if name in self._env:
            target = self._env[name]
            if isinstance(target, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
                return self._call_function(target, args)
        func = self._functions.get(name)
        if func is not None:
            return self._call_function(func, args)
        raise InterpreterError

    def _eval_method_call(self, node: JsCallExpression) -> Value:
        member = node.callee
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        if (
            isinstance(member.object, JsIdentifier)
            and member.object.name in STATIC_OBJECTS
        ):
            static_name = member.object.name
            method_name = self._member_key(member)
            args = [self._eval(a) for a in node.arguments]
            builtin = BUILTIN_REGISTRY.get((static_name, method_name))
            if builtin is not None:
                return builtin(args)
            raise InterpreterError
        obj = self._eval(member.object)
        method_name = self._member_key(member)
        args = [self._eval(a) for a in node.arguments]
        obj_type = type(obj)
        builtin = BUILTIN_REGISTRY.get((obj_type, method_name))
        if builtin is not None:
            return builtin(obj, args)
        if isinstance(obj, list) and method_name in _ARRAY_HOF_METHODS:
            return self._eval_array_hof(obj, method_name, args)
        if isinstance(obj, (JsFunctionExpression, JsArrowFunctionExpression)):
            if method_name == 'call':
                return self._call_function(obj, args[1:] if len(args) > 1 else [])
            if method_name == 'apply':
                actual_args = args[1] if len(args) > 1 and isinstance(args[1], list) else []
                return self._call_function(obj, actual_args)
        raise InterpreterError

    def _eval_array_hof(self, arr: list, method: str, args: list[Value]) -> Value:
        if not args:
            raise InterpreterError
        callback = args[0]
        if not isinstance(
            callback,
            (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)
        ):
            raise InterpreterError
        if method == 'every':
            for i, item in enumerate(arr):
                self._tick()
                if not _truthy(self._call_function(callback, [item, i, arr])):
                    return False
            return True
        if method == 'some':
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    return True
            return False
        if method == 'map':
            mapped: list[Value] = []
            for i, item in enumerate(arr):
                self._tick()
                mapped.append(self._call_function(callback, [item, i, arr]))
            return mapped
        if method == 'filter':
            filtered: list[Value] = []
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    filtered.append(item)
            return filtered
        if method == 'find':
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    return item
            return None
        if method == 'findIndex':
            for i, item in enumerate(arr):
                self._tick()
                if _truthy(self._call_function(callback, [item, i, arr])):
                    return i
            return -1
        if method == 'forEach':
            for i, item in enumerate(arr):
                self._tick()
                self._call_function(callback, [item, i, arr])
            return None
        if method == 'reduce':
            if len(arr) == 0 and len(args) < 2:
                raise InterpreterError
            if len(args) >= 2:
                acc: Value = args[1]
                start = 0
            else:
                acc = arr[0]
                start = 1
            for i in range(start, len(arr)):
                self._tick()
                acc = self._call_function(callback, [acc, arr[i], i, arr])
            return acc
        raise InterpreterError

    def _eval_inline_call(self, func, arguments: list) -> Value:
        args = [self._eval(a) for a in arguments]
        return self._call_function(func, args)

    def _call_function(self, func, args: list[Value]) -> Value:
        if self._depth >= self.max_recursion:
            raise InterpreterError
        child = JsInterpreter(
            max_iterations=self.max_iterations - self._iterations,
            max_string_len=self.max_string_len,
            max_recursion=self.max_recursion,
            functions=self._functions,
            depth=self._depth + 1,
        )
        try:
            result = child.execute(func, args)
        finally:
            self._iterations += child._iterations
        return result

    def _eval_member(self, node: JsMemberExpression) -> Value:
        if isinstance(node.object, JsIdentifier) and node.object.name in STATIC_OBJECTS:
            raise InterpreterError
        obj = self._eval(node.object)
        key = self._member_key(node)
        return self._get_property(obj, key)

    def _eval_template(self, node: JsTemplateLiteral) -> Value:
        parts: list[str] = []
        for i, quasi in enumerate(node.quasis):
            parts.append(quasi.value)
            if i < len(node.expressions):
                parts.append(to_string(self._eval(node.expressions[i])))
        result = ''.join(parts)
        if len(result) > self.max_string_len:
            raise InterpreterError
        return result

    def _eval_object(self, node: JsObjectExpression) -> Value:
        result: dict[str, Value] = {}
        for prop in node.properties:
            if not isinstance(prop, JsProperty):
                raise InterpreterError
            if prop.kind != JsPropertyKind.INIT:
                raise InterpreterError
            key: str
            if prop.computed:
                key = to_string(self._eval(prop.key))
            elif isinstance(prop.key, JsIdentifier):
                key = prop.key.name
            elif isinstance(prop.key, JsStringLiteral):
                key = prop.key.value
            elif isinstance(prop.key, JsNumericLiteral):
                key = to_string(prop.key.value)
            else:
                raise InterpreterError
            result[key] = self._eval(prop.value)
        return result

    def _member_key(self, node: JsMemberExpression) -> str:
        if node.computed:
            val = self._eval(node.property)
            return to_string(val)
        if isinstance(node.property, JsIdentifier):
            return node.property.name
        raise InterpreterError

    def _get_property(self, obj: Value, key: str) -> Value:
        if isinstance(obj, dict):
            return obj.get(key)
        if isinstance(obj, list):
            if key == 'length':
                return len(obj)
            try:
                idx = int(key)
                if 0 <= idx < len(obj):
                    return obj[idx]
                return None
            except (ValueError, TypeError):
                pass
            builtin = BUILTIN_REGISTRY.get((list, key))
            if builtin is not None:
                raise InterpreterError
            return None
        if isinstance(obj, str):
            builtin = BUILTIN_REGISTRY.get((str, key))
            if builtin is not None:
                return builtin(obj, [])
            try:
                idx = int(key)
                if 0 <= idx < len(obj):
                    return obj[idx]
                return None
            except (ValueError, TypeError):
                pass
            return None
        raise InterpreterError

    def _set_property(self, obj: Value, key: str, value: Value) -> None:
        if isinstance(obj, dict):
            obj[key] = value
            return
        if isinstance(obj, list):
            if key == 'length':
                new_len = int(to_number(value))
                if new_len < len(obj):
                    del obj[new_len:]
                else:
                    obj.extend([None] * (new_len - len(obj)))
                return
            try:
                idx = int(key)
                if idx < 0:
                    raise InterpreterError
                while len(obj) <= idx:
                    obj.append(None)
                obj[idx] = value
                return
            except (ValueError, TypeError):
                pass
        raise InterpreterError

    @staticmethod
    def _strict_equal(a: Value, b: Value) -> bool:
        if a is None and b is None:
            return True
        if a is None or b is None:
            return False
        if type(a) is not type(b):
            if isinstance(a, (int, float)) and isinstance(b, (int, float)):
                return a == b
            return False
        return a == b

Methods

def execute(self, func, arguments)
Expand source code Browse git
def execute(
    self,
    func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
    arguments: list[Value],
) -> Value:
    params = func.params
    param_names: list[str] = []
    for p in params:
        if not isinstance(p, JsIdentifier):
            raise InterpreterError
        param_names.append(p.name)
    self._env = {}
    for i, name in enumerate(param_names):
        self._env[name] = arguments[i] if i < len(arguments) else None
    self._iterations = 0
    body = func.body
    if isinstance(body, JsBlockStatement):
        try:
            self._exec_statements(body.body)
        except _ReturnSignal as r:
            return r.value
        return None
    if body is not None:
        return self._eval(body)
    return None