diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..42f5b52d --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +PYTHON ?= python3 +# CURDIR is reliable with spaces; lastword(MAKEFILE_LIST) breaks on "Web Browser/..." +ROOT := $(CURDIR) + +export PYTHONPATH := $(ROOT) + +.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-esnext test-language test-all + +help: + @echo "Js2Py test targets:" + @echo " make test Run quick integration tests (default)" + @echo " make test-simple Run simple_test.py (ES5 + ES6 smoke tests)" + @echo " make test-es_ Run all tests/test_es*.py tests" + @echo " make test-es6 Run tests/test_es6.py" + @echo " make test-es7 Run tests/test_es7.py" + @echo " make test-es8 Run tests/test_es8.py" + @echo " make test-es9 Run tests/test_es9.py" + @echo " make test-es10 Run tests/test_es10.py" + @echo " make test-es11 Run tests/test_es11.py" + @echo " make test-es12 Run tests/test_es12.py" + @echo " make test-es13 Run tests/test_es13.py" + @echo " make test-es14 Run tests/test_es14.py" + @echo " make test-es15 Run tests/test_es15.py" + @echo " make test-es16 Run tests/test_es16.py" + @echo " make test-esnext Run tests/test_esnext.py" + @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" + @echo " make test-all Run quick tests and the language suite" + +test: test-simple test-es_ + @: + +test-simple: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" + +test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-esnext + +test-es6: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" + +test-es7: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es7.py" + +test-es8: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es8.py" + +test-es9: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es9.py" + +test-es10: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es10.py" + +test-es11: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es11.py" + +test-es12: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es12.py" + +test-es13: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es13.py" + +test-es14: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es14.py" + +test-es15: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es15.py" + +test-es16: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es16.py" + +test-esnext: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_esnext.py" + +test-language: + @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" + cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py + +test-all: test test-language diff --git a/js2py/__init__.py b/js2py/__init__.py index a7fe5979..5ba42d48 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -65,7 +65,13 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'parse_js', 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', - 'translate_js6', 'PyJsException', 'get_file_contents', + 'translate_js6', 'eval_js7', 'translate_js7', 'eval_js8', 'translate_js8', + 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', + 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', + 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', + 'eval_js15', 'translate_js15', 'eval_js16', 'translate_js16', + 'eval_jsnext', 'translate_jsnext', 'drain_event_loop', + 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/base.py b/js2py/base.py index f4ee721c..d7413bbd 100644 --- a/js2py/base.py +++ b/js2py/base.py @@ -1150,7 +1150,10 @@ def get(self, prop, throw=True): # fast local scope cand = self.own.get(prop) if cand is None: - return self.prototype.get(prop, throw) + parent = self.prototype + if isinstance(parent, Scope): + return parent.get(prop, throw) + return parent.get(prop) return cand # slow, global scope if prop not in self.own: @@ -1369,15 +1372,35 @@ def __repr__(self): ObjectPrototype = PyJsObject() +def _js_argcount(fcode): + """Number of JavaScript parameters (excluding this, arguments, and var).""" + names = fcode.co_varnames[:fcode.co_argcount] + if (len(names) >= 3 and names[-1] == 'var' and names[-3] == 'this' and + names[-2] == 'arguments'): + return len(names) - 3 + if len(names) >= 2 and names[-2] == 'this' and names[-1] == 'arguments': + return len(names) - 2 + return fcode.co_argcount - 2 + + #Function class PyJsFunction(PyJs): Class = 'Function' def __init__(self, func, prototype=None, extensible=True, source=None): cand = fix_js_args(func) - has_scope = cand is func func = cand - self.argcount = six.get_function_code(func).co_argcount - 2 - has_scope + fcode = six.get_function_code(func) + fargs = fcode.co_varnames[fcode.co_argcount - 2:fcode.co_argcount] + if fargs == ('this', 'arguments') or fargs == ('arguments', 'var'): + self._js_global_this = False + self.argcount = _js_argcount(fcode) + elif cand is func: + self._js_global_this = True + self.argcount = fcode.co_argcount + else: + self._js_global_this = False + self.argcount = fcode.co_argcount - 2 self.code = func self.source = source if source else '{ [python code] }' self.func_name = func.__name__ if not func.__name__.startswith( @@ -1459,6 +1482,21 @@ def call(self, this, args=()): args = args[0:arglen] elif len(args) < arglen: args += (undefined, ) * (arglen - len(args)) + if self._js_global_this: + g = self.code.__globals__ + saved = {} + for name, val in (('this', this), ('arguments', arguments)): + if name in g: + saved[name] = g[name] + g[name] = val + try: + return Js(self.code(*args)) + finally: + for name, val in six.iteritems(saved): + g[name] = val + for name in ('this', 'arguments'): + if name not in saved: + g.pop(name, None) args += this, arguments #append extra params to the arg list try: return Js(self.code(*args)) @@ -3006,6 +3044,21 @@ def TypeError(message): for e in ERROR_NAMES: define_error_type(e + 'Error') + +@Js +def error_is_error(value): + if not value.is_object(): + return False + return value.instanceof(Error).to_boolean().value + + +Error.define_own_property('isError', { + 'value': error_is_error, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) + ############################################################################## # Import and fill prototypes here. diff --git a/js2py/constructors/jsiterator.py b/js2py/constructors/jsiterator.py new file mode 100644 index 00000000..ce9d1d82 --- /dev/null +++ b/js2py/constructors/jsiterator.py @@ -0,0 +1,58 @@ +from ..base import * + + +def _iterable_kind(iterable): + obj = iterable.to_object() + next_fn = obj.get('next') + if next_fn.is_callable(): + return 'iterator', obj + length = obj.get('length') + if length.TYPE == 'Number': + return 'array', obj + raise MakeError('TypeError', 'Iterator.concat requires iterables') + + +@Js +def iterator_concat(): + specs = [] + for i in range(len(arguments)): + specs.append(_iterable_kind(arguments[i])) + state = {'spec_idx': 0, 'index': 0} + + @Js + def next_method(): + while state['spec_idx'] < len(specs): + kind, data = specs[state['spec_idx']] + if kind == 'array': + idx = state['index'] + length = data.get('length').to_uint32() + if idx < length: + if data.has_property(str(idx)): + val = data.get(str(idx)) + else: + val = undefined + state['index'] += 1 + result = PyJsObject(prototype=ObjectPrototype) + result.put('value', val) + result.put('done', false) + return result + state['spec_idx'] += 1 + state['index'] = 0 + continue + result = data.callprop('next') + if result.get('done').to_boolean().value: + state['spec_idx'] += 1 + continue + return result + result = PyJsObject(prototype=ObjectPrototype) + result.put('value', undefined) + result.put('done', true) + return result + + iterator = PyJsObject(prototype=ObjectPrototype) + iterator.put('next', next_method) + return iterator + + +Iterator = PyJsObject(prototype=ObjectPrototype) +Iterator.put('concat', Js(iterator_concat)) diff --git a/js2py/constructors/jsmath.py b/js2py/constructors/jsmath.py index 06751a3b..1d652144 100644 --- a/js2py/constructors/jsmath.py +++ b/js2py/constructors/jsmath.py @@ -153,5 +153,17 @@ def max(): def random(): return random.random() + def sumPrecise(): + import math + values = [] + for i in range(len(arguments)): + n = arguments[i] + if n.TYPE != 'Number': + raise MakeError('TypeError', 'Math.sumPrecise requires numbers') + values.append(n.to_number().value) + if not values: + return +0.0 + return math.fsum(values) + fill_prototype(Math, MathFunctions, default_attrs) diff --git a/js2py/constructors/jsobject.py b/js2py/constructors/jsobject.py index c4e0ada3..f4f69071 100644 --- a/js2py/constructors/jsobject.py +++ b/js2py/constructors/jsobject.py @@ -145,6 +145,85 @@ def keys(obj): raise MakeError('TypeError', 'Object.keys called on non-object') return [e for e, d in six.iteritems(obj.own) if d.get('enumerable')] + def values(obj): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.values called on non-object') + return [obj.get(k) for k, d in six.iteritems(obj.own) + if d.get('enumerable')] + + def entries(obj): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.entries called on non-object') + return [[Js(k), obj.get(k)] for k, d in six.iteritems(obj.own) + if d.get('enumerable')] + + def assign(target): + if not target.is_object(): + raise MakeError('TypeError', 'Object.assign target must be an object') + obj = target.to_object() + for i in range(1, len(arguments)): + src = arguments[i] + if src.is_null() or src.is_undefined(): + continue + src_obj = src.to_object() + for name, desc in six.iteritems(src_obj.own): + if desc.get('enumerable'): + obj.put(name, src_obj.get(name)) + return obj + + def fromEntries(iterable): + obj = PyJsObject(prototype=ObjectPrototype) + items = iterable.to_object() + length = items.get('length') + if length.TYPE == 'Number': + count = length.to_uint32() + for i in range(count): + entry = items.get(str(i)) + if entry.TYPE != 'Object': + raise MakeError('TypeError', 'Invalid entry in fromEntries') + key = entry.get('0') + val = entry.get('1') + obj.put(key.to_string().value, val) + return obj + raise MakeError('TypeError', 'Object.fromEntries requires an iterable') + + def hasOwn(obj, prop): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.hasOwn called on non-object') + return prop.to_string().value in obj.own + + def getOwnPropertyDescriptors(obj): + if not obj.is_object(): + raise MakeError('TypeError', + 'Object.getOwnPropertyDescriptors called on non-object') + result = PyJsObject(prototype=ObjectPrototype) + for name, desc in six.iteritems(obj.own): + desc_obj = PyJsObject(prototype=ObjectPrototype) + for key, val in six.iteritems(desc): + desc_obj.put(key, val) + result.put(name, desc_obj) + return result + + def groupBy(items, callbackfn): + if not callbackfn.is_callable(): + raise MakeError('TypeError', 'callbackfn must be a function') + items_obj = items.to_object() + length_val = items_obj.get('length') + if length_val.TYPE != 'Number': + raise MakeError('TypeError', 'Object.groupBy requires an iterable') + length = length_val.to_uint32() + groups = {} + for k in range(length): + if items_obj.has_property(str(k)): + value = items_obj.get(str(k)) + key = callbackfn.call(undefined, (value, Js(k), items_obj)) + key_str = key.to_string().value + groups.setdefault(key_str, []).append(value) + result = PyJsObject(prototype=null) + for key_str, values in six.iteritems(groups): + result.put(key_str, PyJsArray(values, ArrayPrototype)) + return result + # add methods attached to Object constructor fill_prototype(Object, ObjectMethods, default_attrs) diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py new file mode 100644 index 00000000..12d942f5 --- /dev/null +++ b/js2py/constructors/jspromise.py @@ -0,0 +1,406 @@ +from ..base import * + +PROMISE_STATES = {} + + +def _state(promise): + return PROMISE_STATES[id(promise)] + + +def _unwrap_promise(value): + if isinstance(value, PyJsObject) and getattr(value, 'Class', None) == 'Promise': + return value + return None + + +def _enqueue_reaction(reaction): + from ..event_loop import queue_microtask + queue_microtask(lambda: _run_reaction(reaction)) + + +PromisePrototype = PyJsObject(prototype=ObjectPrototype) + + +def _create_promise(executor): + promise = PyJsObject(prototype=PromisePrototype) + promise.Class = 'Promise' + PROMISE_STATES[id(promise)] = { + 'state': 'pending', + 'value': undefined, + 'reason': undefined, + 'reactions': [], + } + if executor is not None and executor.is_callable(): + resolving = {'done': False} + + @Js + def resolve(value): + if resolving['done']: + return undefined + resolving['done'] = True + _resolve_promise(promise, value) + return undefined + + @Js + def reject(reason): + if resolving['done']: + return undefined + resolving['done'] = True + _reject_promise(promise, reason) + return undefined + + try: + executor.call(undefined, (resolve, reject)) + except PyJsException as exc: + reject.call(undefined, (PyExceptionToJs(exc),)) + except Exception as exc: + reject.call(undefined, (PyExceptionToJs(exc),)) + return promise + + +def _resolve_promise(promise, value): + state = _state(promise) + if state['state'] != 'pending': + return + nested = _unwrap_promise(value) + if nested is not None: + if nested is promise: + _reject_promise(promise, MakeError( + 'TypeError', 'Cannot resolve promise with itself')) + return + + @Js + def on_fulfilled(val): + _resolve_promise(promise, val) + return undefined + + @Js + def on_rejected(reason): + _reject_promise(promise, reason) + return undefined + + nested.callprop('then', on_fulfilled, on_rejected) + return + state['state'] = 'fulfilled' + state['value'] = value + reactions = state['reactions'] + state['reactions'] = [] + for reaction in reactions: + _enqueue_reaction(reaction) + + +def _reject_promise(promise, reason): + state = _state(promise) + if state['state'] != 'pending': + return + state['state'] = 'rejected' + state['reason'] = reason + reactions = state['reactions'] + state['reactions'] = [] + for reaction in reactions: + _enqueue_reaction(reaction) + + +def _run_reaction(reaction): + parent, onFulfilled, onRejected, child = reaction + parent_state = _state(parent) + try: + if parent_state['state'] == 'fulfilled': + if onFulfilled.is_callable(): + val = onFulfilled.call(undefined, (parent_state['value'],)) + else: + val = parent_state['value'] + else: + if onRejected.is_callable(): + val = onRejected.call(undefined, (parent_state['reason'],)) + else: + _reject_promise(child, parent_state['reason']) + return + nested = _unwrap_promise(val) + if nested is not None: + + @Js + def on_fulfilled(v): + _resolve_promise(child, v) + return undefined + + @Js + def on_rejected(r): + _reject_promise(child, r) + return undefined + + nested.callprop('then', on_fulfilled, on_rejected) + else: + _resolve_promise(child, val) + except PyJsException as exc: + _reject_promise(child, PyExceptionToJs(exc)) + except Exception as exc: + _reject_promise(child, PyExceptionToJs(exc)) + + +def _chain_promise(promise, onFulfilled, onRejected): + result = _create_promise(None) + parent_state = _state(promise) + reaction = (promise, onFulfilled, onRejected, result) + if parent_state['state'] == 'pending': + parent_state['reactions'].append(reaction) + else: + _enqueue_reaction(reaction) + return result + + +class PromiseProtoMethods: + def then(onFulfilled, onRejected): + if len(arguments) < 2 or onRejected.is_undefined(): + onRejected = undefined + return _chain_promise(this, onFulfilled, onRejected) + + def catch(onRejected): + return this.callprop('then', undefined, onRejected) + + def finally_(onFinally): + if not onFinally.is_callable(): + onFinally = undefined + + @Js + def on_fulfilled(value): + if onFinally.is_callable(): + cleanup = onFinally.call(undefined, ()) + nested = _unwrap_promise(cleanup) + if nested is not None: + + @Js + def cont(): + return value + + return nested.callprop('then', cont) + return value + + @Js + def on_rejected(reason): + if onFinally.is_callable(): + cleanup = onFinally.call(undefined, ()) + nested = _unwrap_promise(cleanup) + if nested is not None: + + @Js + def cont(): + raise JsToPyException(reason) + + return nested.callprop('then', cont) + raise JsToPyException(reason) + + return this.callprop('then', on_fulfilled, on_rejected) + + +@Js +def promise_constructor(executor): + if len(arguments) and not executor.is_callable(): + raise MakeError('TypeError', 'Promise resolver is not a function') + return _create_promise(executor if len(arguments) else None) + + +Promise = promise_constructor +Promise.create = promise_constructor + + +@Js +def promise_resolve(value): + nested = _unwrap_promise(value) + if nested is not None: + return nested + promise = _create_promise(None) + _resolve_promise(promise, value) + return promise + + +@Js +def promise_reject(reason): + promise = _create_promise(None) + _reject_promise(promise, reason) + return promise + + +@Js +def promise_all_settled(iterable): + arr = iterable.to_object() + length = arr.get('length').to_uint32() + result_promise = _create_promise(None) + if length == 0: + _resolve_promise(result_promise, []) + return result_promise + results = [undefined] * length + remaining = {'count': length} + + def settle(index, status, payload): + entry = PyJsObject(prototype=ObjectPrototype) + entry.put('status', Js(status)) + if status == 'fulfilled': + entry.put('value', payload) + else: + entry.put('reason', payload) + results[index] = entry + remaining['count'] -= 1 + if remaining['count'] == 0: + out = Js([]) + for i, item in enumerate(results): + out.put(str(i), item) + _resolve_promise(result_promise, out) + + for i in range(length): + p = promise_resolve(arr.get(str(i))) + + def make_handlers(idx): + @Js + def on_fulfilled(value): + settle(idx, 'fulfilled', value) + return undefined + + @Js + def on_rejected(reason): + settle(idx, 'rejected', reason) + return undefined + + return on_fulfilled, on_rejected + + on_fulfilled, on_rejected = make_handlers(i) + p.callprop('then', on_fulfilled, on_rejected) + return result_promise + + +@Js +def promise_any(iterable): + arr = iterable.to_object() + length = arr.get('length').to_uint32() + result_promise = _create_promise(None) + if length == 0: + _reject_promise(result_promise, MakeError( + 'Error', 'All promises were rejected')) + return result_promise + remaining = {'count': length} + + def on_any_reject(): + remaining['count'] -= 1 + if remaining['count'] == 0: + state = _state(result_promise) + if state['state'] == 'pending': + _reject_promise(result_promise, MakeError( + 'Error', 'All promises were rejected')) + + for i in range(length): + p = promise_resolve(arr.get(str(i))) + + def make_handlers(idx): + @Js + def on_fulfilled(value): + state = _state(result_promise) + if state['state'] == 'pending': + _resolve_promise(result_promise, value) + return undefined + + @Js + def on_rejected(reason): + on_any_reject() + return undefined + + return on_fulfilled, on_rejected + + on_fulfilled, on_rejected = make_handlers(i) + p.callprop('then', on_fulfilled, on_rejected) + return result_promise + + +@Js +def promise_with_resolvers(): + out = PyJsObject(prototype=ObjectPrototype) + holder = {'promise': None} + + @Js + def resolve(value): + _resolve_promise(holder['promise'], value) + return undefined + + @Js + def reject(reason): + _reject_promise(holder['promise'], reason) + return undefined + + promise = _create_promise(None) + holder['promise'] = promise + out.put('promise', promise) + out.put('resolve', resolve) + out.put('reject', reject) + return out + + +@Js +def promise_try(callback): + args = arguments.to_list()[1:] if len(arguments) > 1 else [] + if not callback.is_callable(): + raise MakeError('TypeError', 'Promise.try callback must be a function') + try: + result = callback.call(undefined, tuple(args)) + except PyJsException as exc: + return promise_reject(PyExceptionToJs(exc)) + except Exception as exc: + return promise_reject(PyExceptionToJs(exc)) + nested = _unwrap_promise(result) + if nested is not None: + return nested + return promise_resolve(result) + + +Promise.define_own_property('resolve', { + 'value': promise_resolve, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property('reject', { + 'value': promise_reject, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property('allSettled', { + 'value': promise_all_settled, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property('any', { + 'value': promise_any, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property('withResolvers', { + 'value': promise_with_resolvers, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property('try', { + 'value': promise_try, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property( + 'prototype', { + 'value': PromisePrototype, + 'enumerable': False, + 'writable': False, + 'configurable': False + }) + +PromisePrototype.define_own_property('constructor', { + 'value': Promise, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) + +fill_prototype(PromisePrototype, PromiseProtoMethods, default_attrs) +PromisePrototype.define_own_property( + 'finally', PromisePrototype.own['finally_']) diff --git a/js2py/constructors/jsregexp.py b/js2py/constructors/jsregexp.py index 2dfc25cb..7e63b7e1 100644 --- a/js2py/constructors/jsregexp.py +++ b/js2py/constructors/jsregexp.py @@ -1,5 +1,33 @@ from ..base import * +_REGEXP_ESCAPE_SYNTAX = set('^$\\.*+?()[]{}|') + + +@Js +def regexp_escape(string): + s = string.to_string().value + out = [] + for i, ch in enumerate(s): + if ch in _REGEXP_ESCAPE_SYNTAX: + out.append('\\' + ch) + elif i == 0: + o = ord(ch) + if (48 <= o <= 57) or (65 <= o <= 90) or (97 <= o <= 122): + out.append('\\x%02x' % o) + else: + out.append(ch) + else: + out.append(ch) + return Js(''.join(out)) + + +RegExp.define_own_property('escape', { + 'value': regexp_escape, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) + RegExpPrototype.define_own_property('constructor', { 'value': RegExp, 'enumerable': False, diff --git a/js2py/es10/__init__.py b/js2py/es10/__init__.py new file mode 100644 index 00000000..831a272c --- /dev/null +++ b/js2py/es10/__init__.py @@ -0,0 +1,41 @@ +"""ES10 (ES2019) support: optional catch binding and syntax detection.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES10_SYNTAX_RE = re.compile( + r'(?:' + r'\.flat\s*\(|' + r'\.flatMap\s*\(|' + r'\.trimStart\s*\(|' + r'\.trimEnd\s*\(|' + r'\bcatch\s*\{' + r')', + re.MULTILINE) + +_OPTIONAL_CATCH_RE = re.compile(r'\bcatch\s*\{') + + +def looks_like_es10(code): + """Return True if source likely contains ES10 syntax or APIs.""" + return bool(_ES10_SYNTAX_RE.search(code)) + + +def prepare_es10(code): + """Apply ES2019 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _OPTIONAL_CATCH_RE.sub('catch (__PyJsOptionalCatch) {', masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked diff --git a/js2py/es11/__init__.py b/js2py/es11/__init__.py new file mode 100644 index 00000000..642f1e24 --- /dev/null +++ b/js2py/es11/__init__.py @@ -0,0 +1,349 @@ +"""ES11 (ES2020) support: optional chaining, nullish coalescing, globalThis.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES11_SYNTAX_RE = re.compile( + r'(?:' + r'\?\?|' + r'\?\.|' + r'\bglobalThis\b|' + r'\bPromise\.allSettled\s*\(' + r')', + re.MULTILINE) + +_IDENT_RE = re.compile(r'[\w$]+') + + +def looks_like_es11(code): + """Return True if source likely contains ES11 syntax or APIs.""" + return bool(_ES11_SYNTAX_RE.search(code)) + + +def prepare_es11(code): + """Apply ES2020 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _transform_nullish_coalescing(masked) + masked = _transform_optional_chaining(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _is_ident_char(ch): + return ch.isalnum() or ch in ('_', '$') + + +def _skip_ws(code, i, direction=1): + if direction >= 0: + while i < len(code) and code[i].isspace(): + i += 1 + return i + while i >= 0 and code[i].isspace(): + i -= 1 + return i + + +def _find_matching(code, start, open_ch, close_ch): + depth = 0 + in_str = None + i = start + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + if depth == 0: + return i + i += 1 + return None + + +def _parse_optional_access(code, pos): + pos = _skip_ws(code, pos, 1) + if pos >= len(code): + return None, pos + if code[pos] == '[': + end = _find_matching(code, pos, '[', ']') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + if code[pos] == '(': + end = _find_matching(code, pos, '(', ')') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + m = _IDENT_RE.match(code, pos) + if m: + return '.' + m.group(0), m.end() + return None, pos + + +def _parse_member_access(code, pos): + pos = _skip_ws(code, pos, 1) + if pos >= len(code): + return None, pos + if code[pos] == '.': + pos += 1 + m = _IDENT_RE.match(code, pos) + if not m: + return None, pos + return '.' + m.group(0), m.end() + if code[pos] == '[': + end = _find_matching(code, pos, '[', ']') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + if code[pos] == '(': + end = _find_matching(code, pos, '(', ')') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + return None, pos + + +def _parse_base_backwards(code, end): + end = _skip_ws(code, end - 1, -1) + 1 + if end <= 0: + return None, None + i = end - 1 + if code[i] == ')': + start = _find_matching(code, i, '(', ')') + if start is None: + return None, None + return start, end + if code[i] == ']': + start = _find_matching(code, i, '[', ']') + if start is None: + return None, None + return start, end + if _is_ident_char(code[i]): + while i >= 0 and _is_ident_char(code[i]): + i -= 1 + if i >= 0 and code[i] == '.': + while i >= 0: + if _is_ident_char(code[i]) or code[i] == '.': + i -= 1 + elif code[i] == ')': + start = _find_matching(code, i, '(', ')') + if start is None: + break + i = start - 1 + else: + break + return i + 1, end + return None, None + + +def _parse_optional_chain(code, qmark_pos): + if code[qmark_pos:qmark_pos + 2] != '?.': + return None + base_start, base_end = _parse_base_backwards(code, qmark_pos) + if base_start is None: + return None + base = code[base_start:base_end].strip() + pos = qmark_pos + 2 + segments = [] + access, pos = _parse_optional_access(code, pos) + if access is None: + return None + segments.append(('opt', access)) + while True: + pos = _skip_ws(code, pos, 1) + if pos >= len(code): + break + if code[pos:pos + 2] == '?.': + pos += 2 + access, pos = _parse_optional_access(code, pos) + if access is None: + break + segments.append(('opt', access)) + continue + if code[pos] == '.': + access, pos = _parse_member_access(code, pos) + if access is None: + break + segments.append(('req', access)) + continue + break + chain_end = pos + return base_start, chain_end, base, segments + + +def _apply_access(var, access): + if access.startswith('.'): + return '%s%s' % (var, access) + if access.startswith('['): + return '%s%s' % (var, access) + if access.startswith('('): + return '%s%s' % (var, access) + return var + + +def _emit_optional_chain(base, segments): + lines = ['var _r = %s;' % base] + for kind, access in segments: + if kind == 'opt': + lines.append('if (_r == null) return undefined;') + if access.startswith('('): + lines.append('_r = _r%s;' % access) + else: + lines.append('_r = _r%s;' % access) + else: + lines.append('_r = _r%s;' % access) + lines.append('return _r;') + body = ' '.join(lines) + return '(function() { %s })()' % body + + +def _transform_optional_chaining(code): + changed = True + while changed: + changed = False + idx = 0 + while idx < len(code) - 1: + if code[idx:idx + 2] != '?.': + idx += 1 + continue + parsed = _parse_optional_chain(code, idx) + if parsed is None: + idx += 1 + continue + start, end, base, segments = parsed + replacement = _emit_optional_chain(base, segments) + code = code[:start] + replacement + code[end:] + changed = True + idx = start + len(replacement) + return code + + +def _scan_nullish_operand(code, start, direction): + """Scan one nullish-coalescing operand; start is first char of operand.""" + if direction < 0: + i = _skip_ws(code, start, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if i > 0 and code[i - 1:i + 1] == '??': + break + if code[i:i + 2] == '||' or code[i:i + 2] == '&&': + break + if ch in ',;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start_idx = i + 1 + while start_idx < end and code[start_idx].isspace(): + start_idx += 1 + return start_idx, end + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if code[i:i + 2] == '??': + break + if code[i:i + 2] == '||' or code[i:i + 2] == '&&': + break + if ch in ',;:{}': + break + if ch == ')': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _parse_nullish_operand(code, op_pos, side): + if side == 'left': + return _scan_nullish_operand(code, op_pos - 1, -1) + return _scan_nullish_operand(code, op_pos + 2, 1) + + +def _transform_nullish_coalescing(code): + while True: + op_pos = -1 + for i in range(len(code) - 1): + if code[i:i + 2] == '??': + op_pos = i + break + if op_pos < 0: + break + left_start, left_end = _parse_nullish_operand(code, op_pos, 'left') + right_start, right_end = _parse_nullish_operand(code, op_pos, 'right') + left = code[left_start:left_end].strip() + right = code[right_start:right_end].strip() + replacement = ( + '(%s !== null && %s !== undefined ? %s : %s)' % (left, left, left, right)) + code = code[:left_start] + replacement + code[right_end:] + return code diff --git a/js2py/es12/__init__.py b/js2py/es12/__init__.py new file mode 100644 index 00000000..27facb8f --- /dev/null +++ b/js2py/es12/__init__.py @@ -0,0 +1,165 @@ +"""ES12 (ES2021) support: logical assignment, numeric separators, replaceAll.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES12_SYNTAX_RE = re.compile( + r'(?:' + r'\?\?=|&&=|\|\|=|' + r'[0-9]+_[0-9]|' + r'\.replaceAll\s*\(|' + r'\bPromise\.any\s*\(' + r')', + re.MULTILINE) + +_LOGICAL_ASSIGN_OPS = ('??=', '&&=', '||=') +_NUMERIC_LITERAL_RE = re.compile( + r'(?= 0: + while i < len(code) and code[i].isspace(): + i += 1 + return i + while i >= 0 and code[i].isspace(): + i -= 1 + return i + + +def _scan_lhs(code, op_start): + i = _skip_ws(code, op_start - 1, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if ch in '=,;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start = i + 1 + while start < end and code[start].isspace(): + start += 1 + return start, end + + +def _scan_rhs(code, start): + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if ch in ',;': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _transform_logical_assignment(code): + while True: + op_pos = -1 + op = None + for candidate in _LOGICAL_ASSIGN_OPS: + idx = code.find(candidate) + if idx >= 0 and (op_pos < 0 or idx < op_pos): + op_pos = idx + op = candidate + if op_pos < 0: + break + lhs_start, lhs_end = _scan_lhs(code, op_pos) + rhs_start, rhs_end = _scan_rhs(code, op_pos + len(op)) + lhs = code[lhs_start:lhs_end].strip() + rhs = code[rhs_start:rhs_end].strip() + if op == '&&=': + repl = '%s = (%s && (%s))' % (lhs, lhs, rhs) + elif op == '||=': + repl = '%s = (%s || (%s))' % (lhs, lhs, rhs) + else: + repl = ('%s = (%s !== null && %s !== undefined ? %s : %s)' % + (lhs, lhs, lhs, lhs, rhs)) + code = code[:lhs_start] + repl + code[rhs_end:] + return code + + +def _strip_numeric_underscores(match): + return match.group(1).replace('_', '') + + +def _transform_numeric_separators(code): + return _NUMERIC_LITERAL_RE.sub(_strip_numeric_underscores, code) diff --git a/js2py/es13/__init__.py b/js2py/es13/__init__.py new file mode 100644 index 00000000..c5a14217 --- /dev/null +++ b/js2py/es13/__init__.py @@ -0,0 +1,20 @@ +"""ES13 (ES2022) support: at() and Object.hasOwn detection.""" + +import re + +_ES13_SYNTAX_RE = re.compile( + r'(?:' + r'\.at\s*\(|' + r'\bObject\.hasOwn\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es13(code): + """Return True if source likely contains ES13 syntax or APIs.""" + return bool(_ES13_SYNTAX_RE.search(code)) + + +def prepare_es13(code): + """Apply ES2022 source transforms before translation.""" + return code diff --git a/js2py/es14/__init__.py b/js2py/es14/__init__.py new file mode 100644 index 00000000..53cef8b7 --- /dev/null +++ b/js2py/es14/__init__.py @@ -0,0 +1,29 @@ +"""ES14 (ES2023) support: hashbang comments and findLast APIs.""" + +import re + +_ES14_SYNTAX_RE = re.compile( + r'(?:' + r'^#!|' + r'\.findLast\s*\(|' + r'\.findLastIndex\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es14(code): + """Return True if source likely contains ES14 syntax or APIs.""" + if code.startswith('#!'): + return True + return bool(_ES14_SYNTAX_RE.search(code)) + + +def prepare_es14(code): + """Apply ES2023 source transforms before translation.""" + if code.startswith('#!'): + newline = code.find('\n') + if newline >= 0: + code = code[newline + 1:] + else: + code = '' + return code diff --git a/js2py/es15/__init__.py b/js2py/es15/__init__.py new file mode 100644 index 00000000..78eac88d --- /dev/null +++ b/js2py/es15/__init__.py @@ -0,0 +1,23 @@ +"""ES15 (ES2024) support: groupBy, withResolvers, well-formed strings, sumPrecise.""" + +import re + +_ES15_SYNTAX_RE = re.compile( + r'(?:' + r'\bObject\.groupBy\s*\(|' + r'\bPromise\.withResolvers\s*\(|' + r'\.isWellFormed\s*\(|' + r'\.toWellFormed\s*\(|' + r'\bMath\.sumPrecise\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es15(code): + """Return True if source likely contains ES15 syntax or APIs.""" + return bool(_ES15_SYNTAX_RE.search(code)) + + +def prepare_es15(code): + """Apply ES2024 source transforms before translation.""" + return code diff --git a/js2py/es16/__init__.py b/js2py/es16/__init__.py new file mode 100644 index 00000000..61af1a87 --- /dev/null +++ b/js2py/es16/__init__.py @@ -0,0 +1,22 @@ +"""ES16 (ES2025) support: RegExp.escape, Promise.try, JSON rawJSON.""" + +import re + +_ES16_SYNTAX_RE = re.compile( + r'(?:' + r'\bRegExp\.escape\s*\(|' + r'\bPromise\.try\s*\(|' + r'\bJSON\.rawJSON\s*\(|' + r'\bJSON\.isRawJSON\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es16(code): + """Return True if source likely contains ES16 syntax or APIs.""" + return bool(_ES16_SYNTAX_RE.search(code)) + + +def prepare_es16(code): + """Apply ES2025 source transforms before translation.""" + return code diff --git a/js2py/es6/__init__.py b/js2py/es6/__init__.py index 4a58d3f0..569ef47c 100644 --- a/js2py/es6/__init__.py +++ b/js2py/es6/__init__.py @@ -2,6 +2,27 @@ babel = None babelPresetEs2015 = None +import re + +# Patterns for ES6+ syntax that pyjsparser rejects or that needs Babel downleveling. +_ES6_SYNTAX_RE = re.compile( + r'(?:' + r'=>|' # arrow functions + r'\bclass\b|' # classes + r'`|' # template literals + r'\.\.\.|' # spread/rest + r'\bfor\s*\([^)]*\bof\b|' # for...of + r'\bimport\b|\bexport\b|' # modules + r'\basync\b|\bawait\b|' # async/await + r'\bfunction\s*\*' # generators + r')', + re.MULTILINE) + + +def looks_like_es6(code): + """Return True if source likely contains ES6+ syntax needing transpilation.""" + return bool(_ES6_SYNTAX_RE.search(code)) + def js6_to_js5(code): global INITIALISED, babel, babelPresetEs2015 diff --git a/js2py/es7/__init__.py b/js2py/es7/__init__.py new file mode 100644 index 00000000..3fdcd4ed --- /dev/null +++ b/js2py/es7/__init__.py @@ -0,0 +1,254 @@ +"""ES7 (ES2016) support: exponentiation operator and includes().""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES7_SYNTAX_RE = re.compile( + r'(?:' + r'\*\*=|\*\*|' + r'\.includes\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es7(code): + """Return True if source likely contains ES7 syntax or APIs.""" + return bool(_ES7_SYNTAX_RE.search(code)) + + +def prepare_es7(code): + """Apply ES2016 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _transform_exponentiation_assignment(masked) + masked = _transform_exponentiation(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _skip_ws(code, i, direction=1): + if direction >= 0: + while i < len(code) and code[i].isspace(): + i += 1 + return i + while i >= 0 and code[i].isspace(): + i -= 1 + return i + + +def _scan_lhs(code, op_start): + i = _skip_ws(code, op_start - 1, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if ch in '=,;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start = i + 1 + while start < end and code[start].isspace(): + start += 1 + return start, end + + +def _scan_rhs(code, start): + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_str: + if ch == '\\': + escape = True + i += 1 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if ch in ',;': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _transform_exponentiation_assignment(code): + while True: + op_pos = code.find('**=') + if op_pos < 0: + break + lhs_start, lhs_end = _scan_lhs(code, op_pos) + rhs_start, rhs_end = _scan_rhs(code, op_pos + 3) + lhs = code[lhs_start:lhs_end].strip() + rhs = code[rhs_start:rhs_end].strip() + repl = '%s = Math.pow(%s, %s)' % (lhs, lhs, rhs) + code = code[:lhs_start] + repl + code[rhs_end:] + return code + + +def _find_exponentiation(code): + for i in range(len(code) - 1, 0, -1): + if code[i - 1:i + 1] == '**' and (i + 1 >= len(code) or code[i + 1] != '='): + return i - 1 + return -1 + + +def _scan_exp_lhs(code, op_start): + i = _skip_ws(code, op_start - 1, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if ch == '*' and i > 0 and code[i - 1] == '*' and (i - 1) != op_start: + break + if ch in '=,;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start = i + 1 + while start < end and code[start].isspace(): + start += 1 + return start, end + + +def _scan_exp_rhs(code, start): + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_str: + if ch == '\\': + escape = True + i += 1 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if ch == '*' and i + 1 < len(code) and code[i + 1] == '*': + break + if ch in ',;': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _transform_exponentiation(code): + while True: + op_pos = _find_exponentiation(code) + if op_pos < 0: + break + lhs_start, lhs_end = _scan_exp_lhs(code, op_pos) + rhs_start, rhs_end = _scan_exp_rhs(code, op_pos + 2) + lhs = code[lhs_start:lhs_end].strip() + rhs = code[rhs_start:rhs_end].strip() + repl = 'Math.pow(%s, %s)' % (lhs, rhs) + code = code[:lhs_start] + repl + code[rhs_end:] + return code diff --git a/js2py/es8/__init__.py b/js2py/es8/__init__.py new file mode 100644 index 00000000..ca62ab15 --- /dev/null +++ b/js2py/es8/__init__.py @@ -0,0 +1,23 @@ +"""ES8 (ES2017) support: padStart/padEnd, Object.values/entries, getOwnPropertyDescriptors.""" + +import re + +_ES8_SYNTAX_RE = re.compile( + r'(?:' + r'\.padStart\s*\(|' + r'\.padEnd\s*\(|' + r'\bObject\.values\s*\(|' + r'\bObject\.entries\s*\(|' + r'\bObject\.getOwnPropertyDescriptors\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es8(code): + """Return True if source likely contains ES8 syntax or APIs.""" + return bool(_ES8_SYNTAX_RE.search(code)) + + +def prepare_es8(code): + """Apply ES2017 source transforms before translation.""" + return code diff --git a/js2py/es9/__init__.py b/js2py/es9/__init__.py new file mode 100644 index 00000000..1437e3d4 --- /dev/null +++ b/js2py/es9/__init__.py @@ -0,0 +1,212 @@ +"""ES9 (ES2018) support: object spread/rest and Promise.finally.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES9_SYNTAX_RE = re.compile( + r'(?:' + r'\{\s*\.\.\.|' + r',\s*\.\.\.|' + r'\{\s*[^}]*,\s*\.\.\.[\w$]|' + r'\.finally\s*\(|' + r'\bObject\.assign\s*\(|' + r'\bObject\.fromEntries\s*\(' + r')', + re.MULTILINE) + +_REST_DECL_RE = re.compile( + r'\b(var|let|const)\s+\{([^}]+)\}\s*=\s*([^;\n]+)\s*;?', + re.MULTILINE) + + +def looks_like_es9(code): + """Return True if source likely contains ES9 syntax or APIs.""" + return bool(_ES9_SYNTAX_RE.search(code)) + + +def prepare_es9(code): + """Apply ES2018 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _transform_object_rest_declarations(masked) + masked = _transform_object_spreads(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _contains_spread_property(inner): + return bool(re.search(r'(?:^\s*,?\s*|\,\s*)\.\.\.', inner)) + + +def _find_matching_brace(code, start): + if start >= len(code) or code[start] != '{': + raise ValueError('expected {') + depth = 0 + in_str = None + i = start + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + return i + i += 1 + return None + + +def _split_object_properties(inner): + parts = [] + buf = [] + depth = 0 + in_str = None + for ch in inner: + if in_str: + buf.append(ch) + if ch == '\\': + continue + if ch == in_str: + in_str = None + continue + if ch in ('"', "'"): + in_str = ch + buf.append(ch) + continue + if ch in '({[': + depth += 1 + elif ch in ')}]': + depth -= 1 + if ch == ',' and depth == 0: + part = ''.join(buf).strip() + if part: + parts.append(part) + buf = [] + continue + buf.append(ch) + part = ''.join(buf).strip() + if part: + parts.append(part) + return parts + + +def _is_destructuring_brace(code, brace_pos): + before = code[:brace_pos].rstrip() + if re.search(r'\b(var|let|const|for)\s*$', before): + return True + if re.search(r'(?:function\s+\w+\s*|\()\s*$', before): + return True + return False + + +def _desugar_spread_object(literal): + inner = literal[1:-1] + props = _split_object_properties(inner) + lines = ['var __o = {};'] + for prop in props: + prop = prop.strip() + if prop.startswith('...'): + expr = prop[3:].strip() + lines.append( + 'var __t = %s; if (__t != null) { for (var __k in __t) { ' + 'if (__t.hasOwnProperty(__k)) __o[__k] = __t[__k]; } }' % expr) + elif re.match(r'^[\w$]+\s*:', prop): + key, val = prop.split(':', 1) + key = key.strip() + val = val.strip() + if re.match(r'^[\w$]+$', key): + lines.append('__o.%s = %s;' % (key, val)) + else: + lines.append('__o[%s] = %s;' % (key, val)) + elif re.match(r'^[\w$]+$', prop): + lines.append('__o.%s = %s;' % (prop, prop)) + else: + lines.append('__o[%s] = %s;' % (prop, prop)) + lines.append('return __o;') + return '(function() { %s })()' % ' '.join(lines) + + +def _transform_object_spreads(code): + changed = True + while changed: + changed = False + i = 0 + while i < len(code): + if code[i] != '{': + i += 1 + continue + if _is_destructuring_brace(code, i): + i += 1 + continue + end = _find_matching_brace(code, i) + if end is None: + i += 1 + continue + literal = code[i:end + 1] + if not _contains_spread_property(literal[1:-1]): + i = end + 1 + continue + replacement = _desugar_spread_object(literal) + code = code[:i] + replacement + code[end + 1:] + changed = True + i += len(replacement) + return code + + +def _transform_object_rest_declarations(code): + def replacer(match): + kind, inner, src = match.group(1), match.group(2), match.group(3).strip() + props = [p.strip() for p in _split_object_properties(inner) if p.strip()] + if not any(p.startswith('...') for p in props): + return match.group(0) + fixed = [] + rest_name = None + excluded = [] + for prop in props: + if prop.startswith('...'): + rest_name = prop[3:].strip() + continue + if ':' in prop: + key, val = prop.split(':', 1) + key = key.strip().strip('"').strip("'") + val = val.strip() + else: + key = prop.strip() + val = '__src.' + key + excluded.append(key) + if re.match(r'^[\w$]+$', key): + fixed.append('var %s = __src.%s;' % (key, key)) + else: + fixed.append('var %s = __src[%s];' % (val, repr(key))) + if rest_name is None: + return match.group(0) + cond = 'true' + for key in excluded: + piece = '(__k !== "%s")' % key + cond = piece if cond == 'true' else cond + ' && ' + piece + fixed.append('var %s = {};' % rest_name) + fixed.append( + 'for (var __k in __src) { if (__src.hasOwnProperty(__k) && %s) ' + '%s[__k] = __src[__k]; }' % (cond, rest_name)) + return 'var __src = %s; %s' % (src, ' '.join(fixed)) + + return _REST_DECL_RE.sub(replacer, code) diff --git a/js2py/esnext/__init__.py b/js2py/esnext/__init__.py new file mode 100644 index 00000000..f632ddcc --- /dev/null +++ b/js2py/esnext/__init__.py @@ -0,0 +1,200 @@ +"""ES.Next support: staging features beyond ES16 (Error.isError, Iterator.concat, using).""" + +import re + +_ESNEXT_SYNTAX_RE = re.compile( + r'(?:' + r'\bError\.isError\s*\(|' + r'\bIterator\.concat\s*\(|' + r'\busing\s+[A-Za-z_$][\w$]*\s*=' + r')', + re.MULTILINE) + +_USING_STMT_RE = re.compile( + r'^\s*using\s+([A-Za-z_$][\w$]*)\s*=\s*', + re.MULTILINE) + + +def _scan_expression_until_semicolon(code, start): + i = start + depth_paren = depth_bracket = depth_brace = 0 + in_single = in_double = False + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_single or in_double: + if ch == '\\': + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + i += 1 + continue + if ch == "'": + in_single = True + i += 1 + continue + if ch == '"': + in_double = True + i += 1 + continue + if ch == '(': + depth_paren += 1 + elif ch == ')': + depth_paren = max(0, depth_paren - 1) + elif ch == '[': + depth_bracket += 1 + elif ch == ']': + depth_bracket = max(0, depth_bracket - 1) + elif ch == '{': + depth_brace += 1 + elif ch == '}': + depth_brace = max(0, depth_brace - 1) + elif (depth_paren == 0 and depth_bracket == 0 and depth_brace == 0 + and ch == ';'): + return code[start:i].strip(), i + 1 + i += 1 + return code[start:].strip(), len(code) + + +def looks_like_esnext(code): + """Return True if source likely contains ES.Next syntax or APIs.""" + return bool(_ESNEXT_SYNTAX_RE.search(code)) + + +def _desugar_block_using(inner): + pos = 0 + usings = [] + while pos < len(inner): + match = _USING_STMT_RE.match(inner, pos) + if not match: + break + name = match.group(1) + init, end = _scan_expression_until_semicolon(inner, match.end()) + usings.append((name, init)) + pos = end + while pos < len(inner) and inner[pos] in ' \t\r\n': + pos += 1 + if not usings: + return inner + body = inner[pos:] + declarations = ';\n'.join('var %s = (%s)' % (name, init) for name, init in usings) + disposals = [] + for name, _ in reversed(usings): + disposals.append( + 'if (%s != null && %s !== undefined) {' + ' var __PyJsDispose = %s.dispose;' + ' if (typeof __PyJsDispose === "function") __PyJsDispose.call(%s);' + ' }' % (name, name, name, name)) + return ( + declarations + ';\n' + 'try {\n' + body + '\n' + '} finally {\n' + '\n'.join(disposals) + '\n' + '}') + + +def _find_matching_brace(code, start): + i = start + 1 + depth = 1 + in_single = in_double = in_template = False + escape = False + while i < len(code) and depth: + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_single or in_double or in_template: + if ch == '\\': + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + elif in_template and ch == '`': + in_template = False + i += 1 + continue + if ch == "'": + in_single = True + i += 1 + continue + if ch == '"': + in_double = True + i += 1 + continue + if ch == '`': + in_template = True + i += 1 + continue + if ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + i += 1 + return i - 1 + + +def _transform_using_blocks(code): + if 'using ' not in code: + return code + out = [] + i = 0 + in_single = in_double = in_template = False + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + out.append(ch) + i += 1 + continue + if in_single or in_double or in_template: + if ch == '\\': + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + elif in_template and ch == '`': + in_template = False + out.append(ch) + i += 1 + continue + if ch == "'": + in_single = True + out.append(ch) + i += 1 + continue + if ch == '"': + in_double = True + out.append(ch) + i += 1 + continue + if ch == '`': + in_template = True + out.append(ch) + i += 1 + continue + if ch == '{': + end = _find_matching_brace(code, i) + inner = code[i + 1:end] + inner = _transform_using_blocks(inner) + if re.search(r'\busing\s+', inner): + inner = _desugar_block_using(inner) + out.append('{' + inner + '}') + i = end + 1 + continue + out.append(ch) + i += 1 + return ''.join(out) + + +def prepare_esnext(code): + """Apply ES.Next source transforms before translation.""" + return _transform_using_blocks(code) diff --git a/js2py/evaljs.py b/js2py/evaljs.py index f4649c4d..868498c4 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -1,6 +1,7 @@ # coding=utf-8 from .translators import translate_js, DEFAULT_HEADER -from .es6 import js6_to_js5 +from .translators.translator import _prepare_js_source +from .event_loop import drain_event_loop import sys import time import json @@ -11,12 +12,97 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', - 'eval_js6', 'translate_js6', 'run_file', 'disable_pyimport', + 'eval_js6', 'translate_js6', 'eval_js7', 'translate_js7', + 'eval_js8', 'translate_js8', 'eval_js9', 'translate_js9', + 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', + 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', + 'eval_js14', 'translate_js14', 'eval_js15', 'translate_js15', + 'eval_js16', 'translate_js16', 'eval_jsnext', 'translate_jsnext', + 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] DEBUG = False +def _strip_leading_use_strict(code): + code = code.lstrip('\ufeff') + while True: + stripped = code.lstrip() + for quote in ('"', "'"): + directive = quote + 'use strict' + quote + if stripped.startswith(directive): + rest = stripped[len(directive):].lstrip() + if rest.startswith(';'): + code = rest[1:].lstrip() + break + return code + else: + return code + + +def _split_top_level_statements(code): + parts = [] + current = [] + depth = 0 + in_single = in_double = in_template = False + escape = False + for char in code: + if escape: + escape = False + current.append(char) + continue + if char == '\\' and (in_single or in_double): + escape = True + current.append(char) + continue + if not in_single and not in_double and not in_template: + if char == "'": + in_single = True + elif char == '"': + in_double = True + elif char == '`': + in_template = True + elif char in '({[': + depth += 1 + elif char in ')}]': + depth = max(0, depth - 1) + elif char == ';' and depth == 0: + part = ''.join(current).strip() + if part: + parts.append(part) + current = [] + continue + elif char == '\n' and depth == 0: + part = ''.join(current).strip() + if part: + parts.append(part) + current = [] + continue + elif in_single and char == "'": + in_single = False + elif in_double and char == '"': + in_double = False + elif in_template and char == '`': + in_template = False + current.append(char) + remainder = ''.join(current).strip() + if remainder: + parts.append(remainder) + return parts + + +def _wrap_js_for_eval(prepared): + """Wrap prepared JS so the last statement's value becomes PyJsEvalResult.""" + prepared = _strip_leading_use_strict(prepared).strip() + if not prepared: + return 'PyJsEvalResult = undefined' + parts = _split_top_level_statements(prepared) + if len(parts) == 1: + return 'PyJsEvalResult = (%s)' % parts[0] + body = ';\n'.join(parts[:-1]) + return '%s;\nPyJsEvalResult = %s' % (body, parts[-1]) + + def disable_pyimport(): import pyjsparser.parser pyjsparser.parser.ENABLE_PYIMPORT = False @@ -57,11 +143,25 @@ def write_file_contents(path_or_file, contents): f.write(contents) -def translate_file(input_path, output_path): +def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9=False, es10=False, + es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. + es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + es7: False, True, or 'auto' — enable ES2016 features (**, includes). + es8: False, True, or 'auto' — enable ES2017 features (padStart, Object.values). + es9: False, True, or 'auto' — enable ES2018 features (object spread/rest, etc.). + es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). + es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). + es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). + es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). + es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). + es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). + es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try). + esnext: False, True, or 'auto' — enable ES.Next staging features (isError, using). + For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -72,7 +172,8 @@ def translate_file(input_path, output_path): ''' js = get_file_contents(input_path) - py_code = translate_js(js) + py_code = translate_js(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, + es12=es12, es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -92,11 +193,25 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js): +def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, + es13=False, es14=False, es15=False, es16=False, esnext=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code + es6: False, True, or 'auto' — see translate_js. + es7: False, True, or 'auto' — enable ES2016 features. + es8: False, True, or 'auto' — enable ES2017 features. + es9: False, True, or 'auto' — enable ES2018 features. + es10: False, True, or 'auto' — enable ES2019 features. + es11: False, True, or 'auto' — enable ES2020 features. + es12: False, True, or 'auto' — enable ES2021 features. + es13: False, True, or 'auto' — enable ES2022 features. + es14: False, True, or 'auto' — enable ES2023 features. + es15: False, True, or 'auto' — enable ES2024 features. + es16: False, True, or 'auto' — enable ES2025 features. + esnext: False, True, or 'auto' — enable ES.Next staging features. + EXAMPLE: >>> import js2py >>> add = js2py.eval_js('function add(a, b) {return a + b}') @@ -112,17 +227,130 @@ def eval_js(js): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - return e.eval(js) + result = e.eval(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) + drain_event_loop() + return result + + +def eval_jsnext(js): + """Like eval_js with ES.Next staging support enabled.""" + return eval_js(js, esnext=True) + + +def translate_jsnext(js): + """Like translate_js with ES.Next staging support enabled.""" + return translate_js(js, esnext=True) + + +def eval_js16(js): + """Like eval_js with ES2025 support enabled.""" + return eval_js(js, es16=True) + + +def translate_js16(js): + """Like translate_js with ES2025 support enabled.""" + return translate_js(js, es16=True) + + +def eval_js15(js): + """Like eval_js with ES2024 support enabled.""" + return eval_js(js, es15=True) + + +def translate_js15(js): + """Like translate_js with ES2024 support enabled.""" + return translate_js(js, es15=True) + + +def eval_js14(js): + """Like eval_js with ES2023 support enabled.""" + return eval_js(js, es14=True) + + +def translate_js14(js): + """Like translate_js with ES2023 support enabled.""" + return translate_js(js, es14=True) + + +def eval_js13(js): + """Like eval_js with ES2022 support enabled.""" + return eval_js(js, es13=True) + + +def translate_js13(js): + """Like translate_js with ES2022 support enabled.""" + return translate_js(js, es13=True) + + +def eval_js12(js): + """Like eval_js with ES2021 support enabled.""" + return eval_js(js, es12=True) + + +def translate_js12(js): + """Like translate_js with ES2021 support enabled.""" + return translate_js(js, es12=True) + + +def eval_js11(js): + """Like eval_js with ES2020 support enabled.""" + return eval_js(js, es11=True) + + +def translate_js11(js): + """Like translate_js with ES2020 support enabled.""" + return translate_js(js, es11=True) + + +def eval_js10(js): + """Like eval_js with ES2019 support enabled.""" + return eval_js(js, es10=True) + + +def translate_js10(js): + """Like translate_js with ES2019 support enabled.""" + return translate_js(js, es10=True) + + +def eval_js9(js): + """Like eval_js with ES2018 support enabled.""" + return eval_js(js, es9=True) + + +def translate_js9(js): + """Like translate_js with ES2018 support enabled.""" + return translate_js(js, es9=True) def eval_js6(js): """Just like eval_js but with experimental support for js6 via babel.""" - return eval_js(js6_to_js5(js)) + return eval_js(js, es6=True) def translate_js6(js): """Just like translate_js but with experimental support for js6 via babel.""" - return translate_js(js6_to_js5(js)) + return translate_js(js, es6=True) + + +def eval_js7(js): + """Like eval_js with ES2016 support enabled.""" + return eval_js(js, es7=True) + + +def translate_js7(js): + """Like translate_js with ES2016 support enabled.""" + return translate_js(js, es7=True) + + +def eval_js8(js): + """Like eval_js with ES2017 support enabled.""" + return eval_js(js, es8=True) + + +def translate_js8(js): + """Like translate_js with ES2017 support enabled.""" + return translate_js(js, es8=True) class EvalJs(object): @@ -171,9 +399,23 @@ def _js_require_impl(npm_module_name): for k, v in six.iteritems(context): setattr(self._var, k, v) - def execute(self, js=None, use_compilation_plan=False): + def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): """executes javascript js in current context + es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + es7: False, True, or 'auto' — enable ES2016 features. + es8: False, True, or 'auto' — enable ES2017 features. + es9: False, True, or 'auto' — enable ES2018 features. + es10: False, True, or 'auto' — enable ES2019 features. + es11: False, True, or 'auto' — enable ES2020 features. + es12: False, True, or 'auto' — enable ES2021 features. + es13: False, True, or 'auto' — enable ES2022 features. + es14: False, True, or 'auto' — enable ES2023 features. + es15: False, True, or 'auto' — enable ES2024 features. + es16: False, True, or 'auto' — enable ES2025 features. + esnext: False, True, or 'auto' — enable ES.Next staging features. + During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the js code to python code. @@ -188,20 +430,30 @@ def execute(self, js=None, use_compilation_plan=False): cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - hashkey = hashlib.md5(js.encode('utf-8')).digest() + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es7, es8, es9, es10, + es11, es12, es13, es14, es15, es16, esnext) try: - compiled = cache[hashkey] + compiled = cache[cache_key] except KeyError: code = translate_js( - js, '', use_compilation_plan=use_compilation_plan) - compiled = cache[hashkey] = compile(code, '', + js, '', use_compilation_plan=use_compilation_plan, + es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) + compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) + drain_event_loop() - def eval(self, expression, use_compilation_plan=False): + def eval(self, expression, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): """evaluates expression in current context and returns its value""" - code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) - self.execute(code, use_compilation_plan=use_compilation_plan) + expression = _prepare_js_source( + expression, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) + code = _wrap_js_for_eval(expression) + self.execute(code, use_compilation_plan=use_compilation_plan, + es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/event_loop.py b/js2py/event_loop.py new file mode 100644 index 00000000..26a9aacb --- /dev/null +++ b/js2py/event_loop.py @@ -0,0 +1,93 @@ +"""JavaScript-style event loop: microtasks (Promises) and macrotasks (timers).""" + +import heapq +import time + +_microtasks = [] +_macrotasks = [] +_macrotask_seq = 0 +_timers = {} +_next_timer_id = 1 +_MAX_DRAIN_ITERATIONS = 100000 + + +def queue_microtask(callback): + """Schedule a microtask (Promise reaction, etc.).""" + _microtasks.append(callback) + + +def _schedule_macrotask(callback, delay_ms): + due = time.monotonic() + max(0.0, float(delay_ms)) / 1000.0 + global _macrotask_seq + _macrotask_seq += 1 + heapq.heappush(_macrotasks, (due, _macrotask_seq, callback)) + + +def schedule_timer(delay_ms, callback, repeat_ms=None): + """Schedule setTimeout/setInterval; returns timer handle id.""" + global _next_timer_id + timer_id = _next_timer_id + _next_timer_id += 1 + + _timers[timer_id] = { + 'cancelled': False, + 'repeat_ms': repeat_ms, + } + + def fire(): + info = _timers.get(timer_id) + if not info or info['cancelled']: + return + callback() + if info['cancelled']: + return + repeat = info.get('repeat_ms') + if repeat is not None: + _schedule_macrotask(fire, repeat) + + _schedule_macrotask(fire, delay_ms) + return timer_id + + +def clear_timer(timer_id): + info = _timers.pop(int(timer_id), None) + if info is not None: + info['cancelled'] = True + + +def drain_event_loop(timeout=None): + """Run microtasks and due macrotasks until idle or timeout (seconds).""" + deadline = None if timeout is None else time.monotonic() + timeout + iterations = 0 + while iterations < _MAX_DRAIN_ITERATIONS: + iterations += 1 + if _microtasks: + batch = _microtasks[:] + _microtasks.clear() + for callback in batch: + callback() + continue + if not _macrotasks: + return + now = time.monotonic() + if _macrotasks[0][0] > now: + if deadline is not None and now >= deadline: + return + wait = _macrotasks[0][0] - now + if deadline is not None: + wait = min(wait, max(0.0, deadline - now)) + if wait > 0: + time.sleep(wait) + continue + _, _, callback = heapq.heappop(_macrotasks) + callback() + if deadline is not None and time.monotonic() >= deadline: + return + raise RuntimeError('Event loop drain exceeded maximum iterations') + + +def reset_event_loop(): + """Clear all queued tasks (mainly for tests).""" + _microtasks[:] = [] + _macrotasks[:] = [] + _timers.clear() diff --git a/js2py/host/jseval.py b/js2py/host/jseval.py index 13140d2d..b3f36cae 100644 --- a/js2py/host/jseval.py +++ b/js2py/host/jseval.py @@ -8,7 +8,13 @@ @Js def Eval(code): - local_scope = inspect.stack()[3][0].f_locals['var'] + local_scope = None + for frame_info in inspect.stack(): + if 'var' in frame_info.frame.f_locals: + local_scope = frame_info.frame.f_locals['var'] + break + if local_scope is None: + raise MakeError('ReferenceError', 'eval scope not found') global_scope = this.GlobalObject # todo fix scope - we have to behave differently if called through variable other than eval # we will use local scope (default) diff --git a/js2py/prototypes/jsarray.py b/js2py/prototypes/jsarray.py index d02e62b2..ffa70a21 100644 --- a/js2py/prototypes/jsarray.py +++ b/js2py/prototypes/jsarray.py @@ -304,6 +304,27 @@ def lastIndexOf(searchElement): k -= 1 return -1 + def includes(searchElement): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if len(arguments) > 1: + fromIndex = arguments[1].to_int() + else: + fromIndex = 0 + k = fromIndex + if k < 0: + k = arr_len + k + if k < 0: + k = 0 + while k < arr_len: + if array.has_property(str(k)): + elementK = array.get(str(k)) + if (searchElement.is_nan() and elementK.is_nan()) or ( + searchElement.strict_equality_comparison(elementK).value): + return True + k += 1 + return False + def every(callbackfn): array = this.to_object() arr_len = array.get('length').to_uint32() @@ -389,6 +410,38 @@ def filter(callbackfn): k += 1 return res # converted to js array automatically + def findLast(callbackfn): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if not callbackfn.is_callable(): + raise this.MakeError('TypeError', 'callbackfn must be a function') + T = arguments[1] if len(arguments) > 1 else this.undefined + k = arr_len - 1 + while k >= 0: + if array.has_property(str(k)): + kValue = array.get(str(k)) + if callbackfn.call( + T, (kValue, this.Js(k), array)).to_boolean().value: + return kValue + k -= 1 + return this.undefined + + def findLastIndex(callbackfn): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if not callbackfn.is_callable(): + raise this.MakeError('TypeError', 'callbackfn must be a function') + T = arguments[1] if len(arguments) > 1 else this.undefined + k = arr_len - 1 + while k >= 0: + if array.has_property(str(k)): + kValue = array.get(str(k)) + if callbackfn.call( + T, (kValue, this.Js(k), array)).to_boolean().value: + return k + k -= 1 + return -1 + def reduce(callbackfn): array = this.to_object() arr_len = array.get('length').to_uint32() @@ -447,6 +500,51 @@ def reduceRight(callbackfn): k -= 1 return accumulator + def flat(depth): + array = this.to_object() + depth_num = 1 if depth.is_undefined() else depth.to_int() + if depth_num < 0: + depth_num = 0 + result = this.Js([]) + n = [0] + + def flatten(source, current_depth): + length = source.get('length').to_uint32() + for i in xrange(length): + key = str(i) + if not source.has_property(key): + continue + element = source.get(key) + if current_depth > 0 and element.Class == 'Array': + flatten(element, current_depth - 1) + else: + result.put(str(n[0]), element) + n[0] += 1 + + flatten(array, depth_num) + result.put('length', this.Js(n[0])) + return result + + def flatMap(callbackfn): + if not callbackfn.is_callable(): + raise this.MakeError('TypeError', 'callbackfn must be a function') + mapped = this.callprop('map', callbackfn, + arguments[1] if len(arguments) > 1 else this.undefined) + return mapped.callprop('flat', this.Js(1)) + + def at(index): + array = this.to_object() + length = array.get('length').to_uint32() + k = index.to_int() + if k < 0: + k = length + k + if k < 0 or k >= length: + return this.undefined + key = str(k) + if not array.has_property(key): + return this.undefined + return array.get(key) + def sort_compare(a, b, comp): if a is None: diff --git a/js2py/prototypes/jsjson.py b/js2py/prototypes/jsjson.py index 9f7ccbb0..e1d92f7c 100644 --- a/js2py/prototypes/jsjson.py +++ b/js2py/prototypes/jsjson.py @@ -1,6 +1,7 @@ import json from ..base import Js indent = '' +RAW_JSON_MARKER = '__PyJsRawJSON__' # python 3 support import six if six.PY3: @@ -68,6 +69,10 @@ def stringify(value, replacer, space): def Str(key, holder, replacer_function, property_list, gap, stack, space): value = holder[key] + if value.is_object(): + raw = value.get(RAW_JSON_MARKER) + if not raw.is_undefined(): + return raw.to_string().value if value.is_object(): to_json = value.get('toJSON') if to_json.is_callable(): @@ -200,6 +205,23 @@ def walk(holder, name, reviver): return reviver.call(holder, (name, val)) +def rawJSON(text): + s = text.to_string().value + try: + json.loads(s) + except Exception: + raise this.MakeError('SyntaxError', 'Invalid raw JSON text') + obj = this.Js({}) + obj.put(RAW_JSON_MARKER, this.Js(s)) + return obj + + +def isRawJSON(value): + if not value.is_object(): + return False + return not value.get(RAW_JSON_MARKER).is_undefined() + + JSON = Js({}) JSON.define_own_property( @@ -217,3 +239,19 @@ def walk(holder, name, reviver): 'writable': True, 'configurable': True }) + +JSON.define_own_property( + 'rawJSON', { + 'value': Js(rawJSON), + 'enumerable': False, + 'writable': True, + 'configurable': True + }) + +JSON.define_own_property( + 'isRawJSON', { + 'value': Js(isRawJSON), + 'enumerable': False, + 'writable': True, + 'configurable': True + }) diff --git a/js2py/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index a313bfb9..9967c5f2 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -201,6 +201,57 @@ def replace(searchValue, replaceValue): res += s[span[1]:] return res + def replaceAll(searchValue, replaceValue): + this.cok() + string = this.to_string() + s = string.value + if not replaceValue.is_callable(): + replaceValue = replaceValue.to_string().value + func = False + else: + func = True + if searchValue.Class == 'RegExp': + if not searchValue.glob: + raise this.MakeError( + 'TypeError', + 'replaceAll must be called with a global RegExp') + res = '' + last = 0 + for e in re.finditer(searchValue.pat, s): + res += s[last:e.span()[0]] + if func: + args = (e.group(), ) + e.groups() + (e.span()[1], string) + args = map(this.Js, args) + res += replaceValue(*args).to_string().value + else: + res += replacement_template(replaceValue, s, e.span(), + e.groups()) + last = e.span()[1] + res += s[last:] + return this.Js(res) + match = searchValue.to_string().value + if not match: + raise this.MakeError( + 'TypeError', 'replaceAll must not replace with empty string') + if func: + res = '' + last = 0 + ind = 0 + while True: + pos = s.find(match, last) + if pos == -1: + break + span = (pos, pos + len(match)) + res += s[last:span[0]] + args = (match, ) + () + (span[1], string) + args = tuple(this.Js(x) for x in args) + res += replaceValue(*args).to_string().value + last = span[1] + ind += 1 + res += s[last:] + return this.Js(res) + return this.Js(s.replace(match, replaceValue)) + def search(regexp): this.cok() string = this.to_string() @@ -299,6 +350,114 @@ def trim(): this.cok() return this.Js(this.to_string().value.strip(WHITE)) + def trimStart(): + this.cok() + s = this.to_string().value + i = 0 + while i < len(s) and s[i] in WHITE: + i += 1 + return this.Js(s[i:]) + + def trimEnd(): + this.cok() + s = this.to_string().value + i = len(s) + while i > 0 and s[i - 1] in WHITE: + i -= 1 + return this.Js(s[:i]) + + def trimLeft(): + return this.callprop('trimStart') + + def trimRight(): + return this.callprop('trimEnd') + + def padStart(maxLength): + this.cok() + s = this.to_string().value + target = maxLength.to_int() + fill = ' ' + if len(arguments) > 1 and not arguments[1].is_undefined(): + fill = arguments[1].to_string().value + if not fill: + fill = ' ' + if len(s) >= target: + return this.Js(s) + pad_len = target - len(s) + repeated = (fill * ((pad_len // len(fill)) + 1))[:pad_len] + return this.Js(repeated + s) + + def padEnd(maxLength): + this.cok() + s = this.to_string().value + target = maxLength.to_int() + fill = ' ' + if len(arguments) > 1 and not arguments[1].is_undefined(): + fill = arguments[1].to_string().value + if not fill: + fill = ' ' + if len(s) >= target: + return this.Js(s) + pad_len = target - len(s) + repeated = (fill * ((pad_len // len(fill)) + 1))[:pad_len] + return this.Js(s + repeated) + + def isWellFormed(): + this.cok() + s = this.to_string().value + i = 0 + n = len(s) + while i < n: + c = ord(s[i]) + if 0xD800 <= c <= 0xDBFF: + if i + 1 >= n: + return False + c2 = ord(s[i + 1]) + if not (0xDC00 <= c2 <= 0xDFFF): + return False + i += 2 + elif 0xDC00 <= c <= 0xDFFF: + return False + else: + i += 1 + return True + + def toWellFormed(): + this.cok() + s = this.to_string().value + out = [] + i = 0 + n = len(s) + while i < n: + c = ord(s[i]) + if 0xD800 <= c <= 0xDBFF: + if i + 1 < n: + c2 = ord(s[i + 1]) + if 0xDC00 <= c2 <= 0xDFFF: + out.append(s[i:i + 2]) + i += 2 + continue + out.append('\uFFFD') + i += 1 + elif 0xDC00 <= c <= 0xDFFF: + out.append('\uFFFD') + i += 1 + else: + out.append(s[i]) + i += 1 + return this.Js(''.join(out)) + + def at(index): + this.cok() + s = this.to_string().value + length = len(s) + k = index.to_int() + if k < 0: + k = length + k + if k < 0 or k >= length: + return this.undefined + return this.Js(s[k]) + def SplitMatch(s, q, R): # s is Py String to match, q is the py int match start and R is Js RegExp or String. diff --git a/js2py/pyjs.py b/js2py/pyjs.py index 98e106a0..0d0d9d94 100644 --- a/js2py/pyjs.py +++ b/js2py/pyjs.py @@ -8,6 +8,8 @@ from .constructors.jsboolean import Boolean from .constructors.jsregexp import RegExp from .constructors.jsarray import Array +from .constructors.jsiterator import Iterator +from .constructors.jspromise import Promise from .constructors.jsarraybuffer import ArrayBuffer from .constructors.jsint8array import Int8Array from .constructors.jsuint8array import Uint8Array @@ -50,6 +52,8 @@ 'Object', 'Function', 'Array', + 'Iterator', + 'Promise', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', @@ -88,6 +92,8 @@ def set_global_object(obj): # also add window and set it to be a global object for compatibility obj.register('window') obj.put('window', this) + obj.register('globalThis') + obj.put('globalThis', this) scope = dict(zip(builtins, [globals()[e] for e in builtins])) diff --git a/js2py/translators/translating_nodes.py b/js2py/translators/translating_nodes.py index 4e2b5760..d4215036 100644 --- a/js2py/translators/translating_nodes.py +++ b/js2py/translators/translating_nodes.py @@ -239,9 +239,14 @@ def ObjectExpression(type, properties): name = None elems = [] after = '' + dynamic_inits = [] for p in properties: if p['kind'] == 'init': - elems.append('%s:%s' % Property(**p)) + key, val = Property(**p) + if key is None: + dynamic_inits.append(val) + else: + elems.append('%s:%s' % (key, val)) else: if name is None: name = inline_stack.require('Object') @@ -257,6 +262,18 @@ def ObjectExpression(type, properties): name, k, getter) else: raise RuntimeError('Unexpected object propery kind') + if dynamic_inits: + if name is None: + name = inline_stack.require('Object') + definition = 'Js({%s})' % ','.join(elems) if elems else 'Js({})' + body = '%s = %s\n' % (name, definition) + for init in dynamic_inits: + body += init.replace('__OBJ__', name) + '\n' + body += after + body += 'return %s\n' % name + code = 'def %s():\n%s' % (name, indent(body)) + inline_stack.define(name, code) + return name + '()' definition = 'Js({%s})' % ','.join(elems) if name is None: return definition @@ -269,9 +286,15 @@ def ObjectExpression(type, properties): def Property(type, kind, key, computed, value, method, shorthand): - if shorthand or computed: - raise NotImplementedError( - 'Shorthand and Computed properties not implemented!') + if shorthand: + k = to_key(key) + if k is None: + raise SyntaxError('Invalid key in dictionary! Or bug in Js2Py') + return repr(k), 'var.get(%s)' % repr(k) + if computed: + k_expr = trans(key) + v = trans(value) + return None, '%s.put(%s, %s)' % ('__OBJ__', k_expr, v) k = to_key(key) if k is None: raise SyntaxError('Invalid key in dictionary! Or bug in Js2Py') @@ -543,7 +566,7 @@ def TryStatement(type, block, handler, handlers, guardedHandlers, finalizer): if handler: identifier = handler['param']['name'] holder = 'PyJsHolder_%s_%d' % (to_hex(identifier), - random.randrange(1e8)) + random.randrange(int(1e8))) identifier = repr(identifier) result += 'except PyJsException as PyJsTempException:\n' # fill in except ( catch ) block and remember to recover holder variable to its previous state @@ -603,12 +626,27 @@ def Program(type, body): # ======== FUNCTIONS ============ +def _default_params_init(params, defaults, used_vars): + """Emit ES6 default-parameter initialization at function entry.""" + if not defaults: + return '' + code = '' + for i, (param, py_name) in enumerate(zip(params, used_vars)): + default = defaults[i] if i < len(defaults) else None + if default is None: + continue + js_name = param['name'] + default_val = trans(default) + code += 'if %s.is_undefined():\n' % py_name + code += ' %s = %s\n' % (py_name, default_val) + code += ' var.put(%s, %s)\n' % (repr(js_name), py_name) + return code + + def FunctionDeclaration(type, id, params, defaults, body, generator, expression): if generator: raise NotImplementedError('Generators not supported') - if defaults: - raise NotImplementedError('Defaults not supported') if not id: return FunctionExpression(type, id, params, defaults, body, generator, expression) + '\n' @@ -643,10 +681,11 @@ def FunctionDeclaration(type, id, params, defaults, body, generator, arg_map.update({'this': 'this', 'arguments': 'arguments'}) arg_conv = 'var = Scope({%s}, var)\n' % ', '.join( repr(k) + ':' + v for k, v in six.iteritems(arg_map)) + default_init = _default_params_init(params, defaults or [], used_vars) # and finally set the name of the function to its real name: footer = '%s.func_name = %s\n' % (PyName, repr(JsName)) footer += 'var.put(%s, %s)\n' % (repr(JsName), PyName) - whole_code = header + indent(arg_conv + code) + footer + whole_code = header + indent(arg_conv + default_init + code) + footer # restore context Context = previous_context # define in upper context @@ -658,8 +697,6 @@ def FunctionExpression(type, id, params, defaults, body, generator, expression): if generator: raise NotImplementedError('Generators not supported') - if defaults: - raise NotImplementedError('Defaults not supported') JsName = id['name'] if id else 'anonymous' if not is_valid_py_name(JsName): ScriptName = 'InlineNonPyName' @@ -698,9 +735,10 @@ def FunctionExpression(type, id, params, defaults, body, generator, arg_map[id['name']] = PyName arg_conv = 'var = Scope({%s}, var)\n' % ', '.join( repr(k) + ':' + v for k, v in six.iteritems(arg_map)) + default_init = _default_params_init(params, defaults or [], used_vars) # and finally set the name of the function to its real name: footer = '%s._set_name(%s)\n' % (PyName, repr(JsName)) - whole_code = header + indent(arg_conv + code) + footer + whole_code = header + indent(arg_conv + default_init + code) + footer # restore context Context = previous_context # define in upper context diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 5ec47002..01d74dfc 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -5,6 +5,78 @@ import hashlib import re +try: + from ..es6 import js6_to_js5, looks_like_es6 +except ImportError: + js6_to_js5 = None + looks_like_es6 = None + +try: + from ..es8 import looks_like_es8, prepare_es8 +except ImportError: + looks_like_es8 = None + prepare_es8 = None + +try: + from ..es7 import looks_like_es7, prepare_es7 +except ImportError: + looks_like_es7 = None + prepare_es7 = None + +try: + from ..es9 import looks_like_es9, prepare_es9 +except ImportError: + looks_like_es9 = None + prepare_es9 = None + +try: + from ..es10 import looks_like_es10, prepare_es10 +except ImportError: + looks_like_es10 = None + prepare_es10 = None + +try: + from ..es11 import looks_like_es11, prepare_es11 +except ImportError: + looks_like_es11 = None + prepare_es11 = None + +try: + from ..es12 import looks_like_es12, prepare_es12 +except ImportError: + looks_like_es12 = None + prepare_es12 = None + +try: + from ..es13 import looks_like_es13, prepare_es13 +except ImportError: + looks_like_es13 = None + prepare_es13 = None + +try: + from ..esnext import looks_like_esnext, prepare_esnext +except ImportError: + looks_like_esnext = None + prepare_esnext = None + +try: + from ..es16 import looks_like_es16, prepare_es16 +except ImportError: + looks_like_es16 = None + prepare_es16 = None + +try: + from ..es15 import looks_like_es15, prepare_es15 +except ImportError: + looks_like_es15 = None + prepare_es15 = None + +try: + from ..es14 import looks_like_es14, prepare_es14 +except ImportError: + looks_like_es14 = None + prepare_es14 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -61,9 +133,120 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn): +def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, + es13=False, es14=False, es15=False, es16=False, esnext=False): + """Optionally downlevel ES6/ES7–ES16/ES.Next source before translation.""" + if esnext == 'auto': + if looks_like_esnext and looks_like_esnext(js): + esnext = True + else: + esnext = False + if es16 == 'auto': + if looks_like_es16 and looks_like_es16(js): + es16 = True + else: + es16 = False + if es15 == 'auto': + if looks_like_es15 and looks_like_es15(js): + es15 = True + else: + es15 = False + if es14 == 'auto': + if looks_like_es14 and looks_like_es14(js): + es14 = True + else: + es14 = False + if es13 == 'auto': + if looks_like_es13 and looks_like_es13(js): + es13 = True + else: + es13 = False + if es12 == 'auto': + if looks_like_es12 and looks_like_es12(js): + es12 = True + else: + es12 = False + if es11 == 'auto': + if looks_like_es11 and looks_like_es11(js): + es11 = True + else: + es11 = False + if es10 == 'auto': + if looks_like_es10 and looks_like_es10(js): + es10 = True + else: + es10 = False + if es9 == 'auto': + if looks_like_es9 and looks_like_es9(js): + es9 = True + else: + es9 = False + if es8 == 'auto': + if looks_like_es8 and looks_like_es8(js): + es8 = True + else: + es8 = False + if es7 == 'auto': + if looks_like_es7 and looks_like_es7(js): + es7 = True + else: + es7 = False + if es6 == 'auto': + if looks_like_es6 and looks_like_es6(js): + es6 = True + else: + es6 = False + if esnext and prepare_esnext: + js = prepare_esnext(js) + if es16 and prepare_es16: + js = prepare_es16(js) + if es15 and prepare_es15: + js = prepare_es15(js) + if es14 and prepare_es14: + js = prepare_es14(js) + if es13 and prepare_es13: + js = prepare_es13(js) + if es12 and prepare_es12: + js = prepare_es12(js) + if es11 and prepare_es11: + js = prepare_es11(js) + if es10 and prepare_es10: + js = prepare_es10(js) + if es9 and prepare_es9: + js = prepare_es9(js) + if es8 and prepare_es8: + js = prepare_es8(js) + if es7 and prepare_es7: + js = prepare_es7(js) + if es6: + if js6_to_js5 is None: + raise RuntimeError('ES6 support is not available') + return js6_to_js5(js) + return js + + +def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, + parse_fn=pyjsparser_parse_fn, es6=False, es7=False, es8=False, es9=False, + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): """js has to be a javascript source code. - returns equivalent python code.""" + returns equivalent python code. + + es6: False (ES5 only), True (always transpile via Babel), or 'auto' + (transpile when ES6 syntax is detected). + es7: False, True, or 'auto' — enable ES2016 features (**, includes). + es8: False, True, or 'auto' — enable ES2017 features (padStart, Object.values). + es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.). + es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). + es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). + es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). + es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). + es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). + es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). + es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try). + esnext: False, True, or 'auto' — enable ES.Next staging features (isError, using).""" + js = _prepare_js_source(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, + es12=es12, es13=es13, es14=es14, es15=es15, es16=es16, + esnext=esnext) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/js2py/utils/injector.py b/js2py/utils/injector.py index 88e0d93e..977ab0d7 100644 --- a/js2py/utils/injector.py +++ b/js2py/utils/injector.py @@ -24,7 +24,12 @@ def fix_js_args(func): fargs = fcode.co_varnames[fcode.co_argcount - 2:fcode.co_argcount] if fargs == ('this', 'arguments') or fargs == ('arguments', 'var'): return func - code = append_arguments(six.get_function_code(func), ('this', 'arguments')) + # Python 3.11+ bytecode layouts (especially 3.14 inline caches) are too + # fragile to rewrite safely. PyJsFunction.call injects this/arguments + # into the function globals instead. + if sys.version_info >= (3, 11): + return func + code = append_arguments(fcode, ('this', 'arguments')) result = types.FunctionType( code, @@ -34,6 +39,98 @@ def fix_js_args(func): return result +def _instruction_bytes(code_obj, inst, insts, index): + old_code = code_obj.co_code + end = len(old_code) + if index + 1 < len(insts): + end = insts[index + 1].offset + return old_code[inst.offset:end] + + +def _instruction_span_len(code_obj, insts, index): + inst = insts[index] + if index + 1 < len(insts): + return insts[index + 1].offset - inst.offset + return len(code_obj.co_code) - inst.offset + + +def _write_padded_instruction(op, arg, span_len): + """Write an instruction, padding with NOPs to fill span_len bytes.""" + nop = dis.opmap['NOP'] + out = bytearray(write_instruction(op, arg)) + while len(out) < span_len: + out.extend(write_instruction(nop, 0)) + return bytes(out[:span_len]) + + +_PACKED_FAST_OPS = frozenset( + name for name in opcode.opmap + if ('LOAD_FAST' in name or 'STORE_FAST' in name) and name.count('FAST') >= 2) + + +def _remap_local_arg(inst, translations): + """Remap a local slot index, including packed two-slot operands.""" + if inst.opname in _PACKED_FAST_OPS: + first = inst.arg & 0xF + second = (inst.arg >> 4) & 0xF + first = translations.get(first, first) + second = translations.get(second, second) + return (second << 4) | first + if inst.arg in translations: + return translations[inst.arg] + return inst.arg + + +def _patch_span_arg(span, new_arg): + """Patch the arg byte of a 2-byte wordcode instruction.""" + chunk = bytearray(span) + chunk[1] = new_arg & 0xFF + return bytes(chunk) + + +def _extend_js_args_modern(code_obj, new_locals): + """Extend a code object with this/arguments on Python 3.11+.""" + co_varnames = code_obj.co_varnames + co_argcount = code_obj.co_argcount + new_locals_len = len(new_locals) + varnames = ( + co_varnames[:co_argcount] + new_locals + co_varnames[co_argcount:]) + names_to_idx = {name: varnames.index(name) for name in new_locals} + varname_translations = dict((i, i) for i in range(co_argcount)) + varname_translations.update( + (i, i + new_locals_len) for i in range(co_argcount, len(co_varnames))) + insts = list(instructions(code_obj, show_cache=False)) + load_fast = opcode.opmap.get('LOAD_FAST_BORROW', LOAD_FAST) + modified = bytearray() + for index, inst in enumerate(insts): + span = _instruction_bytes(code_obj, inst, insts, index) + if (inst.opname == 'LOAD_GLOBAL' and + getattr(inst, 'argval', None) in names_to_idx): + modified.extend(_write_padded_instruction( + load_fast, names_to_idx[inst.argval], len(span))) + continue + if inst.opcode in opcode.haslocal: + new_arg = _remap_local_arg(inst, varname_translations) + if new_arg != inst.arg: + modified.extend(_patch_span_arg(span, new_arg)) + continue + if (sys.version_info >= (3, 11) and inst.opcode in opcode.hasfree and + getattr(inst, 'argval', None) not in + code_obj.co_varnames[:code_obj.co_argcount]): + new_arg = inst.arg + new_locals_len + if new_arg != inst.arg: + modified.extend(_patch_span_arg(span, new_arg)) + continue + modified.extend(span) + new_code = bytes(modified) + return code_obj.replace( + co_argcount=co_argcount + new_locals_len, + co_nlocals=max(code_obj.co_nlocals, len(varnames)), + co_varnames=varnames, + co_code=new_code, + ) + + def append_arguments(code_obj, new_locals): co_varnames = code_obj.co_varnames # Old locals co_names = code_obj.co_names # Old globals @@ -261,7 +358,9 @@ def signature(func): code_obj.co_filename, code_obj.co_freevars, code_obj.co_cellvars) -check(six.get_function_code(check)) +# Bytecode layoutText changed in 3.13+ (inline caches); skip round-trip check there. +if sys.version_info < (3, 13): + check(six.get_function_code(check)) diff --git a/tests/test_es10.py b/tests/test_es10.py new file mode 100644 index 00000000..ce1b927b --- /dev/null +++ b/tests/test_es10.py @@ -0,0 +1,83 @@ +"""Tests for ES10 (ES2019) JavaScript support.""" +import js2py +from js2py.es10 import looks_like_es10, prepare_es10 + + +def test_looks_like_es10(): + assert looks_like_es10('[1, [2]].flat()') + assert looks_like_es10('" x".trimStart()') + assert looks_like_es10('try {} catch {}') + assert not looks_like_es10('var a = [1, 2]; a.join()') + + +def test_prepare_es10_optional_catch(): + out = prepare_es10('try { x(); } catch { y = 1; }') + assert '__PyJsOptionalCatch' in out + + +def test_array_flat(): + ctx = js2py.EvalJs() + ctx.execute('var a = [1, [2, 3], [[4]]]; var b = a.flat();', es10=True) + assert list(ctx.b) == [1, 2, 3, [4]] + + +def test_array_flat_depth(): + ctx = js2py.EvalJs() + ctx.execute('var a = [1, [2, [3]]]; var b = a.flat(2);', es10=True) + assert list(ctx.b) == [1, 2, 3] + + +def test_array_flat_map(): + ctx = js2py.EvalJs() + ctx.execute( + 'var a = [1, 2, 3]; var b = a.flatMap(function(x) { return [x, x * 10]; });', + es10=True) + assert list(ctx.b) == [1, 10, 2, 20, 3, 30] + + +def test_array_flat_auto(): + ctx = js2py.EvalJs() + ctx.execute('var out = [0, [1]].flat();', es10='auto') + assert list(ctx.out) == [0, 1] + + +def test_string_trim_start(): + assert js2py.eval_js('" hello".trimStart()', es10=True) == 'hello' + assert js2py.eval_js('" hello".trimLeft()', es10=True) == 'hello' + + +def test_string_trim_end(): + assert js2py.eval_js('"hello ".trimEnd()', es10=True) == 'hello' + assert js2py.eval_js('"hello ".trimRight()', es10=True) == 'hello' + + +def test_optional_catch_binding(): + ctx = js2py.EvalJs() + ctx.execute(''' + var ok = false; + try { throw "boom"; } catch { ok = true; } + ''', es10=True) + assert ctx.ok is True + + +def test_eval_js10(): + assert js2py.eval_js10('[1, [2]].flat().length') == 2 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es11.py b/tests/test_es11.py new file mode 100644 index 00000000..0d4b6e0d --- /dev/null +++ b/tests/test_es11.py @@ -0,0 +1,99 @@ +"""Tests for ES11 (ES2020) JavaScript support.""" +import js2py +from js2py.es11 import looks_like_es11, prepare_es11 +from js2py.event_loop import reset_event_loop + + +def test_looks_like_es11(): + assert looks_like_es11('a?.b') + assert looks_like_es11('x ?? y') + assert looks_like_es11('globalThis') + assert looks_like_es11('Promise.allSettled([])') + assert not looks_like_es11('var a = 1; a.b') + + +def test_prepare_es11_nullish(): + out = prepare_es11('a ?? b') + assert '!== null' in out and '!== undefined' in out + + +def test_prepare_es11_optional_chain(): + out = prepare_es11('obj?.prop') + assert '_r' in out and '== null' in out + + +def test_nullish_coalescing(): + assert js2py.eval_js('0 ?? 1', es11=True) == 0 + assert js2py.eval_js('null ?? 2', es11=True) == 2 + assert js2py.eval_js('undefined ?? 3', es11=True) == 3 + + +def test_nullish_coalescing_chain(): + assert js2py.eval_js('null ?? null ?? 4', es11=True) == 4 + + +def test_optional_chaining_property(): + ctx = js2py.EvalJs({'obj': {'x': 9}}) + ctx.execute('var v = obj?.x;', es11=True) + assert ctx.v == 9 + + +def test_optional_chaining_null(): + assert js2py.eval_js('null?.missing', es11=True) is None + + +def test_optional_chaining_nested(): + ctx = js2py.EvalJs({'obj': {'inner': {'y': 5}}}) + ctx.execute('var v = obj?.inner?.y;', es11=True) + assert ctx.v == 5 + + +def test_optional_chaining_auto(): + ctx = js2py.EvalJs({'a': {'b': 7}}) + ctx.execute('var v = a?.b;', es11='auto') + assert ctx.v == 7 + + +def test_global_this(): + ctx = js2py.EvalJs() + ctx.execute('var same = (globalThis === window);', es11=True) + assert ctx.same is True + + +def test_promise_all_settled(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.allSettled([ + Promise.resolve(1), + Promise.reject("no") + ]).then(function(r) { out = r; }); + ''', es11=True) + assert list(ctx.out)[0]['status'] == 'fulfilled' + assert list(ctx.out)[0]['value'] == 1 + assert list(ctx.out)[1]['status'] == 'rejected' + assert list(ctx.out)[1]['reason'] == 'no' + + +def test_eval_js11(): + assert js2py.eval_js11('(null ?? 5) + 1') == 6 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es12.py b/tests/test_es12.py new file mode 100644 index 00000000..6a004ba2 --- /dev/null +++ b/tests/test_es12.py @@ -0,0 +1,99 @@ +"""Tests for ES12 (ES2021) JavaScript support.""" +import js2py +from js2py.es12 import looks_like_es12, prepare_es12 +from js2py.event_loop import reset_event_loop + + +def test_looks_like_es12(): + assert looks_like_es12('a &&= 1') + assert looks_like_es12('1_000') + assert looks_like_es12('"a".replaceAll("a","b")') + assert looks_like_es12('Promise.any([])') + assert not looks_like_es12('var x = 1; x + 2') + + +def test_prepare_es12_logical_assign(): + out = prepare_es12('count &&= 1') + assert 'count = (count &&' in out + assert '1' in out + + +def test_prepare_es12_numeric_separator(): + assert prepare_es12('var n = 1_000_000;') == 'var n = 1000000;' + + +def test_numeric_separator(): + assert js2py.eval_js('1_000 + 2', es12=True) == 1002 + + +def test_logical_and_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = 1; x &&= 2; var y = x;', es12=True) + assert ctx.y == 2 + + +def test_logical_or_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = 0; x ||= 5; var y = x;', es12=True) + assert ctx.y == 5 + + +def test_nullish_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = null; x ??= 7; var y = x;', es12=True) + assert ctx.y == 7 + ctx.execute('var z = 0; z ??= 9;', es12=True) + assert ctx.z == 0 + + +def test_string_replace_all(): + assert js2py.eval_js('"a-a-a".replaceAll("-", "+")', es12=True) == 'a+a+a' + + +def test_promise_any_fulfilled(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.any([ + Promise.reject("nope"), + Promise.resolve(42) + ]).then(function(v) { out = v; }); + ''', es12=True) + assert ctx.out == 42 + + +def test_promise_any_all_rejected(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var err = null; + Promise.any([ + Promise.reject("a"), + Promise.reject("b") + ]).catch(function(e) { err = e; }); + ''', es12=True) + assert ctx.err is not None + + +def test_eval_js12(): + assert js2py.eval_js12('1_00 * 2') == 200 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es13.py b/tests/test_es13.py new file mode 100644 index 00000000..a05c1cf2 --- /dev/null +++ b/tests/test_es13.py @@ -0,0 +1,70 @@ +"""Tests for ES13 (ES2022) JavaScript support.""" +import js2py +from js2py.es13 import looks_like_es13 + + +def test_looks_like_es13(): + assert looks_like_es13('[1, 2, 3].at(-1)') + assert looks_like_es13('Object.hasOwn({}, "x")') + assert not looks_like_es13('var a = [1, 2]; a[0]') + + +def test_array_at_positive(): + assert js2py.eval_js('[10, 20, 30].at(1)', es13=True) == 20 + + +def test_array_at_negative(): + assert js2py.eval_js('[10, 20, 30].at(-1)', es13=True) == 30 + + +def test_array_at_out_of_range(): + assert js2py.eval_js('[1].at(5)', es13=True) is None + + +def test_array_at_auto(): + ctx = js2py.EvalJs() + ctx.execute('var a = [5, 6]; var v = a.at(-2);', es13='auto') + assert ctx.v == 5 + + +def test_string_at(): + assert js2py.eval_js('"hello".at(-1)', es13=True) == 'o' + assert js2py.eval_js('"hi".at(5)', es13=True) is None + + +def test_object_has_own_true(): + assert js2py.eval_js('Object.hasOwn({a: 1}, "a")', es13=True) is True + + +def test_object_has_own_false(): + assert js2py.eval_js('Object.hasOwn({a: 1}, "toString")', es13=True) is False + + +def test_object_has_own_inherited(): + ctx = js2py.EvalJs() + ctx.execute('var o = Object.create({x: 1}); var v = Object.hasOwn(o, "x");', + es13=True) + assert ctx.v is False + + +def test_eval_js13(): + assert js2py.eval_js13('Object.hasOwn({k: 9}, "k")') is True + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es14.py b/tests/test_es14.py new file mode 100644 index 00000000..a667ebc5 --- /dev/null +++ b/tests/test_es14.py @@ -0,0 +1,82 @@ +"""Tests for ES14 (ES2023) JavaScript support.""" +import js2py +from js2py.es14 import looks_like_es14 + + +def test_looks_like_es14(): + assert looks_like_es14('[1, 2, 3].findLast(function(x) { return x > 1; })') + assert looks_like_es14('[1, 2, 3].findLastIndex(function(x) { return x > 1; })') + assert looks_like_es14('#!/usr/bin/env node\nvar x = 1;') + assert not looks_like_es14('var a = [1, 2]; a[0]') + + +def test_array_find_last(): + assert js2py.eval_js( + '[1, 2, 3, 4].findLast(function(x) { return x % 2 === 0; })', + es14=True) == 4 + + +def test_array_find_last_none(): + assert js2py.eval_js( + '[1, 3, 5].findLast(function(x) { return x % 2 === 0; })', + es14=True) is None + + +def test_array_find_last_index(): + assert js2py.eval_js( + '[1, 2, 3, 4].findLastIndex(function(x) { return x % 2 === 0; })', + es14=True) == 3 + + +def test_array_find_last_index_missing(): + assert js2py.eval_js( + '[1, 3, 5].findLastIndex(function(x) { return x % 2 === 0; })', + es14=True) == -1 + + +def test_array_find_last_sparse(): + assert js2py.eval_js( + '[1, , 3, 4].findLast(function(x) { return x > 2; })', + es14=True) == 4 + + +def test_array_find_last_auto(): + ctx = js2py.EvalJs() + ctx.execute( + 'var a = [10, 20, 30]; var v = a.findLast(function(x) { return x < 25; });', + es14='auto') + assert ctx.v == 20 + + +def test_hashbang_execute(): + ctx = js2py.EvalJs() + ctx.execute('#!/usr/bin/env node\nvar x = 42;', es14=True) + assert ctx.x == 42 + + +def test_hashbang_eval(): + assert js2py.eval_js('#!/usr/bin/env node\n1 + 2', es14=True) == 3 + + +def test_eval_js14(): + assert js2py.eval_js14( + '[5, 6, 7].findLastIndex(function(x) { return x < 7; })') == 1 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es15.py b/tests/test_es15.py new file mode 100644 index 00000000..ab8890b4 --- /dev/null +++ b/tests/test_es15.py @@ -0,0 +1,98 @@ +"""Tests for ES15 (ES2024) JavaScript support.""" +import js2py +from js2py.es15 import looks_like_es15 +from js2py.event_loop import drain_event_loop + + +def test_looks_like_es15(): + assert looks_like_es15('Object.groupBy([], function() {})') + assert looks_like_es15('Promise.withResolvers()') + assert looks_like_es15('"x".isWellFormed()') + assert looks_like_es15('Math.sumPrecise(1, 2)') + assert not looks_like_es15('Object.keys({})') + + +def test_object_group_by(): + js = ''' + Object.groupBy([1, 2, 3, 4, 5, 6], function(x) { + return x % 2 === 0 ? "even" : "odd"; + }).even.length + ''' + assert js2py.eval_js(js, es15=True) == 3 + js2 = ''' + Object.groupBy([1, 2, 3, 4, 5, 6], function(x) { + return x % 2 === 0 ? "even" : "odd"; + }).even[0] + ''' + assert js2py.eval_js(js2, es15=True) == 2 + + +def test_object_group_by_auto(): + assert js2py.eval_js( + 'Object.groupBy(["a", "b", "aa"], function(s) { return s.length; })["1"].length', + es15='auto') == 2 + assert js2py.eval_js( + 'Object.groupBy(["a", "b", "aa"], function(s) { return s.length; })["2"][0]', + es15='auto') == 'aa' + + +def test_promise_with_resolvers(): + ctx = js2py.EvalJs() + ctx.execute('var r = Promise.withResolvers(); r.resolve(42);', es15=True) + drain_event_loop() + assert ctx.r.promise is not None + + +def test_promise_with_resolvers_value(): + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + var r = Promise.withResolvers(); + r.promise.then(function(v) { out = v; }); + r.resolve(99); + ''', es15=True) + drain_event_loop() + assert ctx.out == 99 + + +def test_string_is_well_formed_true(): + assert js2py.eval_js('"hello".isWellFormed()', es15=True) is True + + +def test_string_is_well_formed_false(): + assert js2py.eval_js('"\\uD800".isWellFormed()', es15=True) is False + + +def test_string_to_well_formed(): + assert js2py.eval_js('"\\uD800".toWellFormed()', es15=True) == '\ufffd' + + +def test_math_sum_precise(): + assert js2py.eval_js('Math.sumPrecise(1, 2, 3)', es15=True) == 6 + + +def test_math_sum_precise_floats(): + assert js2py.eval_js('Math.sumPrecise(1e20, 1, -1e20)', es15=True) == 1 + + +def test_eval_js15(): + assert js2py.eval_js15('Math.sumPrecise(10, 20)') == 30 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es16.py b/tests/test_es16.py new file mode 100644 index 00000000..03c13dbe --- /dev/null +++ b/tests/test_es16.py @@ -0,0 +1,97 @@ +"""Tests for ES16 (ES2025) JavaScript support.""" +import js2py +from js2py.es16 import looks_like_es16 +from js2py.event_loop import drain_event_loop + + +def test_looks_like_es16(): + assert looks_like_es16('RegExp.escape("x")') + assert looks_like_es16('Promise.try(function() {})') + assert looks_like_es16('JSON.rawJSON("1")') + assert looks_like_es16('JSON.isRawJSON({})') + assert not looks_like_es16('JSON.parse("{}")') + + +def test_regexp_escape_literal(): + assert js2py.eval_js('RegExp.escape("hello")', es16=True) == '\\x68ello' + + +def test_regexp_escape_syntax(): + assert js2py.eval_js('RegExp.escape("(a|b)")', es16=True) == '\\(a\\|b\\)' + + +def test_regexp_escape_match(): + assert js2py.eval_js( + 'new RegExp(RegExp.escape("a*b")).test("a*b")', es16=True) is True + + +def test_regexp_escape_auto(): + assert js2py.eval_js('RegExp.escape("x.y").indexOf("\\\\.") >= 0', es16='auto') is True + + +def test_promise_try_success(): + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.try(function() { return 7; }).then(function(v) { out = v; }); + ''', es16=True) + drain_event_loop() + assert ctx.out == 7 + + +def test_promise_try_with_args(): + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.try(function(a, b) { return a + b; }, 3, 4).then(function(v) { out = v; }); + ''', es16=True) + drain_event_loop() + assert ctx.out == 7 + + +def test_promise_try_reject(): + ctx = js2py.EvalJs() + ctx.execute(''' + var err; + Promise.try(function() { throw "boom"; }).catch(function(e) { err = e; }); + ''', es16=True) + drain_event_loop() + assert ctx.err == 'boom' + + +def test_json_raw_json_stringify(): + assert js2py.eval_js('JSON.stringify(JSON.rawJSON("1"))', es16=True) == '1' + assert js2py.eval_js('JSON.stringify(JSON.rawJSON("\\"hi\\""))', es16=True) == '"hi"' + + +def test_json_raw_json_embedded(): + assert js2py.eval_js( + 'JSON.stringify({x: JSON.rawJSON("\\"hi\\"")})', es16=True) == '{"x":"hi"}' + + +def test_json_is_raw_json(): + assert js2py.eval_js('JSON.isRawJSON(JSON.rawJSON("1"))', es16=True) is True + assert js2py.eval_js('JSON.isRawJSON(1)', es16=True) is False + + +def test_eval_js16(): + assert js2py.eval_js16('RegExp.escape("test")') == '\\x74est' + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es6.py b/tests/test_es6.py new file mode 100644 index 00000000..a844d216 --- /dev/null +++ b/tests/test_es6.py @@ -0,0 +1,87 @@ +"""Tests for ES6 JavaScript translation support.""" +import js2py +from js2py.es6 import looks_like_es6 + + +def test_looks_like_es6(): + assert looks_like_es6('let a = () => 1') + assert looks_like_es6('class Foo {}') + assert not looks_like_es6('var a = 1; function f() { return a; }') + + +def test_native_default_parameters(): + ctx = js2py.EvalJs() + ctx.execute('function f(x, y) { y = (y === undefined) ? 2 : y; return x + y; }') + assert ctx.f(1) == 3 + ctx2 = js2py.EvalJs() + ctx2.execute('function f(x, y=2) { return x + y; }') + assert ctx2.f(1) == 3 + assert ctx2.f(1, 5) == 6 + + +def test_native_object_shorthand(): + assert js2py.eval_js('var x = 10; ({x}).x') == 10 + + +def test_native_computed_property(): + assert js2py.eval_js('var k = "foo"; ({[k]: 42}).foo') == 42 + + +def test_eval_js_auto_es6(): + assert js2py.eval_js('(() => 11)()', es6=True) == 11 + + +def test_eval_js6_arrow_this(): + result = js2py.eval_js6(''' +const v = 11; +obj = {value: v}; +obj.x = function() { + return () => this +}; +obj.x()() +''') + assert result.value == 11 + + +def test_eval_js6_for_of(): + assert js2py.eval_js6(''' +var x; +for (let a of [1,2,3]) { + x = a +} +typeof a === 'undefined' && x === 3 +''') + + +def test_eval_js6_class(): + shape = js2py.eval_js6(''' +class Shape { + constructor (id, x, y) { + this.id = id + this.move(x, y) + } + move (x, y) { + this.x = x + this.y = y + } +}; +new Shape(1,2,3) +''') + assert shape.x == 2 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + sys.exit(1 if failed else 0) diff --git a/tests/test_es7.py b/tests/test_es7.py new file mode 100644 index 00000000..9955b33e --- /dev/null +++ b/tests/test_es7.py @@ -0,0 +1,80 @@ +"""Tests for ES7 (ES2016) JavaScript support.""" +import js2py +from js2py.es7 import looks_like_es7, prepare_es7 + + +def test_looks_like_es7(): + assert looks_like_es7('[1, 2, 3].includes(2)') + assert looks_like_es7('2 ** 3') + assert looks_like_es7('x **= 2') + assert not looks_like_es7('var a = [1, 2]; a.indexOf(1)') + + +def test_prepare_es7_exponentiation(): + out = prepare_es7('2 ** 3 ** 2') + assert 'Math.pow' in out + assert '**' not in out + + +def test_prepare_es7_exponentiation_assign(): + out = prepare_es7('x **= 3') + assert out == 'x = Math.pow(x, 3)' + + +def test_exponentiation(): + assert js2py.eval_js('2 ** 10', es7=True) == 1024 + + +def test_exponentiation_right_associative(): + assert js2py.eval_js('2 ** 3 ** 2', es7=True) == 512 + + +def test_exponentiation_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = 2; x **= 3;', es7=True) + assert ctx.x == 8 + + +def test_array_includes_true(): + assert js2py.eval_js('[1, 2, 3].includes(2)', es7=True) is True + + +def test_array_includes_false(): + assert js2py.eval_js('[1, 2, 3].includes(4)', es7=True) is False + + +def test_array_includes_nan(): + assert js2py.eval_js('[NaN].includes(NaN)', es7=True) is True + + +def test_array_includes_from_index(): + assert js2py.eval_js('[1, 2, 3].includes(2, 2)', es7=True) is False + + +def test_array_includes_auto(): + ctx = js2py.EvalJs() + ctx.execute('var ok = [10, 20].includes(20);', es7='auto') + assert ctx.ok is True + + +def test_eval_js7(): + assert js2py.eval_js7('3 ** 4') == 81 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es8.py b/tests/test_es8.py new file mode 100644 index 00000000..c380ef0d --- /dev/null +++ b/tests/test_es8.py @@ -0,0 +1,74 @@ +"""Tests for ES8 (ES2017) JavaScript support.""" +import js2py +from js2py.es8 import looks_like_es8 + + +def test_looks_like_es8(): + assert looks_like_es8('"x".padStart(4)') + assert looks_like_es8('Object.values({a: 1})') + assert looks_like_es8('Object.getOwnPropertyDescriptors({})') + assert not looks_like_es8('Object.keys({a: 1})') + + +def test_object_values(): + assert list(js2py.eval_js('Object.values({a: 1, b: 2})', es8=True)) == [1, 2] + + +def test_object_entries(): + ctx = js2py.EvalJs() + ctx.execute('var e = Object.entries({x: 9, y: 8});', es8=True) + assert list(ctx.e[0]) == ['x', 9] + assert list(ctx.e[1]) == ['y', 8] + + +def test_string_pad_start(): + assert js2py.eval_js('"9".padStart(4, "0")', es8=True) == '0009' + assert js2py.eval_js('"abc".padStart(3)', es8=True) == 'abc' + assert js2py.eval_js('"abc".padStart(6, "123")', es8=True) == '123abc' + + +def test_string_pad_end(): + assert js2py.eval_js('"9".padEnd(4, "0")', es8=True) == '9000' + assert js2py.eval_js('"abc".padEnd(3)', es8=True) == 'abc' + assert js2py.eval_js('"abc".padEnd(6, "123")', es8=True) == 'abc123' + + +def test_pad_auto(): + ctx = js2py.EvalJs() + ctx.execute('var s = "hi".padEnd(5, "-");', es8='auto') + assert ctx.s == 'hi---' + + +def test_get_own_property_descriptors(): + ctx = js2py.EvalJs() + ctx.execute(''' + var o = {a: 1, b: 2}; + var d = Object.getOwnPropertyDescriptors(o); + var keys = Object.keys(d).sort().join(","); + ''', es8=True) + assert ctx.keys == 'a,b' + assert ctx.d.a.value == 1 + assert ctx.d.b.value == 2 + + +def test_eval_js8(): + assert list(js2py.eval_js8('Object.values({k: 5})')) == [5] + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es9.py b/tests/test_es9.py new file mode 100644 index 00000000..8e4d5a7b --- /dev/null +++ b/tests/test_es9.py @@ -0,0 +1,113 @@ +"""Tests for ES9 (ES2018) JavaScript support.""" +import js2py +from js2py.es9 import looks_like_es9, prepare_es9 +from js2py.event_loop import reset_event_loop + + +def test_looks_like_es9(): + assert looks_like_es9('var x = {...a};') + assert looks_like_es9('Promise.resolve(1).finally(function() {})') + assert looks_like_es9('Object.fromEntries([])') + assert not looks_like_es9('var a = 1; Object.keys({})') + + +def test_prepare_es9_object_spread(): + out = prepare_es9('var x = {...a, b: 2};') + assert '__o' in out + assert 'b = 2' in out or 'b=2' in out.replace(' ', '') + + +def test_object_assign(): + ctx = js2py.EvalJs() + ctx.execute('var a = {x: 1}; var b = {y: 2}; var c = Object.assign({}, a, b);') + assert ctx.c.x == 1 + assert ctx.c.y == 2 + + +def test_object_spread_literal(): + ctx = js2py.EvalJs({'src': {'a': 1, 'b': 2}}) + ctx.execute('var merged = {...src, c: 3};', es9=True) + assert ctx.merged.a == 1 + assert ctx.merged.b == 2 + assert ctx.merged.c == 3 + + +def test_object_spread_auto(): + ctx = js2py.EvalJs({'base': {'x': 9}}) + ctx.execute('var out = {...base, y: 1};', es9='auto') + assert ctx.out.x == 9 + assert ctx.out.y == 1 + + +def test_object_rest_destructuring(): + ctx = js2py.EvalJs() + ctx.execute( + 'var obj = {a: 1, b: 2, c: 3}; var {a, ...rest} = obj;', es9=True) + assert ctx.a == 1 + assert ctx.rest.b == 2 + assert ctx.rest.c == 3 + assert ctx.rest.a is None or str(getattr(ctx.rest, 'a', None)) in ('undefined', 'None') + + +def test_object_from_entries(): + ctx = js2py.EvalJs() + ctx.execute( + 'var o = Object.fromEntries([["a", 1], ["b", 2]]);', es9=True) + assert ctx.o.a == 1 + assert ctx.o.b == 2 + + +def test_object_entries_roundtrip(): + ctx = js2py.EvalJs() + ctx.execute( + 'var src = {p: 4, q: 5}; var copy = Object.fromEntries(Object.entries(src));', + es9=True) + assert ctx.copy.p == 4 + assert ctx.copy.q == 5 + + +def test_promise_finally_fulfilled(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var log = []; + Promise.resolve(10).finally(function() { log.push("done"); }).then(function(v) { + log.push(v); + }); + ''', es9=True) + assert list(ctx.log) == ['done', 10] + + +def test_promise_finally_rejected(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var log = []; + Promise.reject("err").finally(function() { log.push("cleanup"); }).catch(function(e) { + log.push(e); + }); + ''', es9=True) + assert list(ctx.log) == ['cleanup', 'err'] + + +def test_eval_js9(): + assert js2py.eval_js9('Object.assign({a:1}, {b:2}).b') == 2 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_esnext.py b/tests/test_esnext.py new file mode 100644 index 00000000..cb010ca6 --- /dev/null +++ b/tests/test_esnext.py @@ -0,0 +1,100 @@ +"""Tests for ES.Next (staging) JavaScript support.""" +import js2py +from js2py.esnext import looks_like_esnext, prepare_esnext + + +def test_looks_like_esnext(): + assert looks_like_esnext('Error.isError(new Error("x"))') + assert looks_like_esnext('Iterator.concat([[1]])') + assert looks_like_esnext('{ using x = foo(); }') + assert not looks_like_esnext('Error("x")') + + +def test_prepare_esnext_using(): + out = prepare_esnext('{ using r = get(); x = 1; }') + assert 'try {' in out + assert 'finally {' in out + assert 'r.dispose' in out + assert 'using ' not in out + + +def test_error_is_error_true(): + assert js2py.eval_js('Error.isError(new Error("x"))', esnext=True) is True + assert js2py.eval_js('Error.isError(new TypeError("x"))', esnext=True) is True + + +def test_error_is_error_false(): + assert js2py.eval_js('Error.isError({})', esnext=True) is False + assert js2py.eval_js('Error.isError("err")', esnext=True) is False + + +def test_iterator_concat_arrays(): + js = ''' + var it = Iterator.concat([1, 2], [3]); + var out = []; + var step; + while (!(step = it.next()).done) { out.push(step.value); } + out.length + ''' + assert js2py.eval_js(js, esnext=True) == 3 + + +def test_iterator_concat_values(): + assert js2py.eval_js( + 'Iterator.concat([10, 20]).next().value', esnext=True) == 10 + + +def test_iterator_concat_auto(): + assert js2py.eval_js( + 'Iterator.concat([5]).next().value', esnext='auto') == 5 + + +def test_using_dispose(): + ctx = js2py.EvalJs() + ctx.execute(''' + var disposed = false; + var result; + { + using r = { dispose: function() { disposed = true; } }; + result = 42; + } + ''', esnext=True) + assert ctx.result == 42 + assert ctx.disposed is True + + +def test_using_dispose_on_throw(): + ctx = js2py.EvalJs() + ctx.execute(''' + var disposed = false; + try { + { + using r = { dispose: function() { disposed = true; } }; + throw "fail"; + } + } catch (e) {} + ''', esnext=True) + assert ctx.disposed is True + + +def test_eval_jsnext(): + assert js2py.eval_jsnext('Error.isError(new Error(1))') is True + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0)