diff --git a/AGENTS.md b/AGENTS.md index 8301947..f3122ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,9 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence - Use kebab-case for file names. - Use `#private` for state fields only, and spell out `public` / `protected` / `private` on all other members. -- Use [Conventional Commits](https://www.conventionalcommits.org) with a scope: the package, such as `maple`, or `*` for - the whole repository. The version and changelog come from them, so never edit `version` in +- Use [Conventional Commits](https://www.conventionalcommits.org) with a scope naming the area the commit changes, such + as `evaluator`, `json`, `playground` or `parity`, or `*` for the whole repository. Older commits name the package + (`maple`); new ones name the area. The version and changelog come from them, so never edit `version` in `packages/maple/package.json` or `CHANGELOG.md` by hand. - Don't add attribution to commits or pull requests: no `Co-Authored-By:` or other trailers, no "Generated with" lines and no session links. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a9a2cd..2c24606 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,8 @@ It runs `test` on Node 22, 24 and 26: the minimum from the library's `engines` f ## Commits & releases Commits must follow [Conventional Commits](https://www.conventionalcommits.org), which commitlint enforces in the `commit-msg` hook. -Every commit needs a scope: the package it changes, such as `maple`, `playground` or `parity`, or `*` when it spans the repository. +Every commit needs a scope: the area it changes, such as `evaluator`, `boundary`, `builtins` or `json` in the library, `playground` or `parity`, or `*` when it spans the repository. +Older commits name the library's package, `maple`, instead; new ones name the area, so the changelog says what changed. Pull requests target `increment`, the default branch, and are merged with a merge commit or rebased, never squashed, so their commits reach `master` as they are. A release is a merge of `increment` into `master`, with a merge commit as well. diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index a69af50..6b93003 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -53,6 +53,7 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `with`, implicit globals, `arguments.callee` | Every script runs in strict mode, where these are errors. js-interpreter runs sloppy code and has no `callee`. | | A method called without its receiver: `const p = [].push; p(1)`, `(0, a.push)(1)`, `arr.map("".trim)` | As in strict mode, a plain call has no receiver, so it is a `TypeError`. js-interpreter passes the global object instead. | +| `this` at the top level and in a plain call: `typeof this` is `'undefined'` | As in strict mode, there is no global object to stand in. js-interpreter in sloppy mode passes its global object. | | `window`, `self`, `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` | Host APIs, not part of JavaScript. A run is synchronous and ends with its result, so nothing can be scheduled either. | | `String(Math)`, `"" + JSON`: Maple gives `[object Math]`, js-interpreter `[object Object]` | A namespace converts with its own tag, as in JavaScript; js-interpreter tags every object as a plain one. | | `Object.isExtensible(5)`: Maple gives `false`, js-interpreter `true` | A primitive is never extensible since ES2015; js-interpreter follows ES5. | @@ -66,6 +67,7 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | An error named by an array that holds it: Maple gives `m: m` | `join` cuts the cycle at the array, as in JavaScript; js-interpreter gives `...: m`. | | `f.bind(null, 1)`: Maple names it `bound f` with the rest of `f`'s length, js-interpreter `fBound`, 0 | A bound function's `name` and `length` are ES2015; js-interpreter follows ES5. | | `f.apply(null, 1)`, `f.apply(null, 'ab')` | A primitive is no list of arguments, so it is a `TypeError`. js-interpreter ignores a number and spreads a string. | +| `JSON.parse(text, reviver)`: Maple applies the reviver, with the holder as `this` | As in JavaScript; js-interpreter ignores the reviver and returns the parsed value as it is. | ### Maple departs from JavaScript @@ -73,14 +75,14 @@ On purpose: these are what the sandbox leaves out. | Divergence | Why | | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -| `this`, `new` on a script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes or receivers. An object's behaviour comes from its type. | +| `new` on a script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes. An object's behaviour comes from its type. | | `new` on a bound function: `new (Error.bind(null, 'x'))()` | Only the listed built-in constructors can be constructed, and a bound function is not one of them. | | A built-in method called on a receiver of another type: `''.trim.call({})` | A method's behaviour comes from its receiver's type, so it refuses another type with a `TypeError`. | | `Object.create`, `Object.getPrototypeOf`, `isPrototypeOf` | The same object model: there is no prototype chain to create or inspect. | | Getters and setters | They would run script code on a plain property read or write, host conversion included. | | `new String`, `new Number`, `new Boolean`, `Object(primitive)`, `instanceof String` / `Number` / `Boolean` | There are no primitive wrapper objects. They are a well-known trap and would add a value kind. | | `eval`, the `Function` constructor and its members (`Function.toString`, `Function.valueOf`, `Function.prototype`) | Creating code from a string is the escape the sandbox exists to prevent, so the global is not reachable at all. | -| The global `this`, the global `constructor` | There is no global object: globals are read-only bindings, and the host decides what exists. | +| The global `constructor` | There is no global object: globals are read-only bindings, and the host decides what exists. | ### Not supported yet diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 67b02d1..f91740f 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -455,6 +455,80 @@ describe('method calls', () => { }); }); +describe('syntax:ThisExpression', () => { + it('is the object a function is called on', () => { + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return o.f();')).toBe(1); + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return o["f"]();')).toBe(1); + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return (o.f)();')).toBe(1); + }); + + it('is undefined for a plain call', () => { + expect(parity('function f() { return typeof this; } return f();')).toBe('undefined'); + expect(parity('var o = { f: function () { return typeof this; } }; return (0, o.f)();')).toBe( + 'undefined' + ); + expect( + parity('var o = { f: function () { return typeof this; } }; var g = o.f; return g();') + ).toBe('undefined'); + expect(parity('return typeof this;')).toBe('undefined'); + }); + + it('is what call, apply and bind pass, unboxed', () => { + expect( + parity( + 'function f() { return this; } return [f.call(5), f.apply("s", []), f.bind(true)(), f.call(null)];' + ) + ).toEqual([5, 's', true, null]); + expect(parity('function f() { return typeof this; } return f.call(5);')).toBe('number'); + }); + + it('keeps the bound receiver', () => { + expect(parity('function f() { return this; } return f.bind(1).call(2);')).toBe(1); + expect(parity('function f() { return this; } var o = { g: f.bind(1) }; return o.g();')).toBe(1); + }); + + it('is undefined again in a nested function', () => { + expect( + parity( + 'var o = { f: function () { function g() { return typeof this; } return g(); } }; return o.f();' + ) + ).toBe('undefined'); + }); + + it('lets a method return its object for chaining', () => { + expect( + parity( + 'var o = { n: 0, add: function (k) { this.n += k; return this; } }; return o.add(1).add(2).n;' + ) + ).toBe(3); + }); + + it('is read and written like any object', () => { + expect( + parity('var o = { f: function () { this.x = 1; return this.x; } }; return [o.f(), o.x];') + ).toEqual([1, 1]); + expect(allThrow('function f() { return this.x; } return f();')).toBe('TypeError'); + }); +}); + +describe('thisArg of array callbacks', () => { + it('is this in the callback', () => { + expect( + parity( + 'var t = { k: 10 }; var seen = []; var f = function (x) { seen.push(this.k + x); return true; }; [1].every(f, t); [1].filter(f, t); [1].forEach(f, t); [1].map(f, t); [1].some(f, t); return seen;' + ) + ).toEqual([11, 11, 11, 11, 11]); + }); + + it('passes a primitive unboxed', () => { + expect(parity('return [1].map(function () { return typeof this; }, 5);')).toEqual(['number']); + }); + + it('leaves this undefined when omitted', () => { + expect(parity('return [1].map(function () { return typeof this; });')).toEqual(['undefined']); + }); +}); + describe('assign:function property', () => { it('adds a property to a script function', () => { expect( diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index 8b06e56..851d4c0 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -248,19 +248,38 @@ describe('Array.prototype.sort', () => { }); }); -// Maple departs from JavaScript on purpose. +describe('syntax:ThisExpression', () => { + // As in strict mode, there is no global object to stand in; sloppy js-interpreter passes its own + it('has no global this', () => { + expect(followsJavaScript('return typeof this;', { sloppy: true })).toEqual({ + maple: 'undefined', + jsInterpreter: 'object' + }); + }); -// The object model: Maple has no script-visible prototypes or receivers. An object's behaviour -// comes from its type, so `this`, constructors and prototype chains are left out, not sandboxed. + it('is undefined in a plain call', () => { + expect( + followsJavaScript('function f() { return typeof this; } return f();', { sloppy: true }) + ).toEqual({ maple: 'undefined', jsInterpreter: 'object' }); + }); +}); -describe('syntax:ThisExpression', () => { - it('rejects this in a function', () => { - expect(rejected('function f() { return this; } return f();')).toBe( - MapleErrorCode.DISALLOWED_SYNTAX - ); +describe('JSON.parse', () => { + // js-interpreter ignores the reviver; Maple calls it with the holder as this + it('applies a reviver with the holder as this', () => { + expect( + followsJavaScript( + 'return JSON.parse(\'{"a":1}\', function (k, v) { return k === "a" ? Object.keys(this) : v; });' + ) + ).toEqual({ maple: { a: ['a'] }, jsInterpreter: { a: 1 } }); }); }); +// Maple departs from JavaScript on purpose. + +// The object model: Maple has no script-visible prototypes. An object's behaviour comes from its +// type, so constructors and prototype chains are left out, not sandboxed. + describe('syntax:NewExpression', () => { it('rejects new on a script function', () => { expect(rejected('function Point(x) { this.x = x; } return new Point(1).x;')).toBe( @@ -417,14 +436,6 @@ describe('Function.prototype.length', () => { // There is no global object: globals are read-only bindings, and the host decides what exists. -describe('this', () => { - it('has no global this', () => { - expect(rejected('return typeof this;', { sloppy: true })).toBe( - MapleErrorCode.DISALLOWED_SYNTAX - ); - }); -}); - describe('constructor', () => { // js-interpreter's global object inherits from Object.prototype, so `constructor` is a global it('has no global named constructor', () => { diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 95f8688..a45d456 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -41,6 +41,11 @@ Instance: `length`, `at`, `concat`, `copyWithin`, `entries`, `every`, `fill`, `f Static: `Array.isArray`, `Array.from`, `Array.of`. +`every`, `filter`, `find`, `findIndex`, `findLast`, `findLastIndex`, `flatMap`, `forEach`, `map`, +`some` and `Array.from` take a `thisArg` after the callback, which the callback sees as `this`. +The comparator of `sort` and `toSorted` and the reducer of `reduce` and `reduceRight` take none +and have `this` undefined, as in JavaScript. + ## Number and Boolean Number instance: `toFixed`, `toPrecision`, `toExponential`, `toLocaleString`, `toString`, `valueOf`. @@ -79,8 +84,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). Since there is no `this`, a reviver receives the holder as a third argument, -`(key, value, holder)`. +as `{}`). A reviver and a function replacer receive the holder as `this`, as in JavaScript; a +reviver does not receive the third `context` argument with the `source` text. ## RegExp @@ -123,6 +128,9 @@ Set: `add`, `has`, `delete`, `clear`, `forEach`, `size`, `keys`, `values`, `entr `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`. +`forEach` of a Map and of a Set takes a `thisArg` after the callback, which the callback sees as +`this`. + The set algebra takes a Set or a Map as the other operand. JavaScript's other set-like objects are refused with a `TypeError`, so the host never calls a script `has` or `keys`. diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 18eb2d1..a3c5a8e 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -5,7 +5,7 @@ one that does; the linked section has the detail. ## Syntax and names -- `this`, `class`, `super`, `async` / `await`, generators, getters and setters, tagged templates, +- `class`, `super`, `async` / `await`, generators, getters and setters, tagged templates, `import` / `export`, `import.meta` and `new.target` throw `DISALLOWED_SYNTAX` ([Language](language.md#not-supported)). - An `async` or generator function throws when it is created, not when it is called. @@ -49,8 +49,7 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives the holder as a third argument instead of `this` - ([JSON](builtins.md#json)). +- A JSON reviver receives no third `context` argument ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/extending.md b/packages/maple/docs/extending.md index f86bbd0..6e9ba62 100644 --- a/packages/maple/docs/extending.md +++ b/packages/maple/docs/extending.md @@ -5,7 +5,9 @@ A host function is any function found in the globals or in `data` that is not marked as an intrinsic. A script can call it, store it and pass it around, but never reach the native function. It has no properties, not even `length` or `name`: those are the host's business. Its arguments and -its return value are copied (see [Boundary](boundary.md#host-function-calls)). +its return value are copied (see [Boundary](boundary.md#host-function-calls)). It never receives a +`this`: called as a method, as in `obj.fn()`, or through `call`, `apply` or `bind`, it runs with +`this` undefined. A host call costs what any call costs; the work and the time inside the host function are not measured (see [Limits](limits.md)). @@ -55,6 +57,8 @@ interface Sandbox { program is one more call level and appears as `` in stack traces. Like any run, it neither sees nor leaves properties the calling run added to shared built-ins. Data that is not an object throws a plain `TypeError` to the intrinsic. +- An intrinsic receives no `this`, as a host function does, and `sandbox.call` calls a script + function with `this` undefined. - An exception the intrinsic throws is treated like a host function's. - The handle is usable only while the intrinsic runs. Once it has returned, every method throws a plain `Error`, so a handle or closure an intrinsic keeps cannot run script code outside the call. diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index 3b25e18..468fe32 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -32,9 +32,9 @@ Expressions: ## Not supported -Reaching any of these throws `DISALLOWED_SYNTAX`: `this`, `class`, `super`, `async` / `await`, -generators and `yield`, getters and setters, tagged templates, `import` / `export`, `import.meta`, -`new.target`, and `arguments` at the script level, which is no function. +Reaching any of these throws `DISALLOWED_SYNTAX`: `class`, `super`, `async` / `await`, generators +and `yield`, getters and setters, tagged templates, `import` / `export`, `import.meta`, `new.target`, +and `arguments` at the script level, which is no function. An `async` or generator function throws when the closure is created, not when it is called. For a function declaration that is hoisting time, so the script fails before its first statement runs. @@ -72,11 +72,16 @@ Script functions and built-in functions have JavaScript's read-only `length` and inferred from bindings included. Host functions, and functions bound from them, have no properties at all. Every function answers to `toString`, which gives `[function]`, `valueOf`, `call`, `apply` and `bind` ([Functions](builtins.md#functions)). The receiver these pass on reaches a built-in -method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`. A script -function never sees it, since there is no `this`, which is how JavaScript behaves for a strict -function that does not use `this`. - -Inside a function that is not an arrow, `arguments` is the strict-mode arguments object. +method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script +function as its `this`. + +Inside a function that is not an arrow, `this` and `arguments` are those of a strict-mode function. +`this` is the object of a method call, so `obj.f()` and `obj[key]()` see `obj`, and `undefined` for +a plain call such as `f()` or `(0, obj.f)()`. `call`, `apply` and `bind` pass their receiver as it is, +never boxed, so `f.call(5)` sees `5`, and a bound function keeps its receiver whatever it is called +on. An arrow function has no `this` of its own and sees the one of the function it is written in. +At the top level of the script `this` is `undefined`, since there is no global object. A script +function still cannot be constructed ([Constructors](#constructors)). Every function, and every namespace such as `Math`, takes the properties a script adds, as in `fn.cache = {}`. Built-ins, host functions and namespaces are shared by every run, so what a run diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 5862765..67175cc 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -41,6 +41,11 @@ it can terminate. ignored by `+`, templates, comparison and the built-ins (see [Coercion](language.md#coercion)). The host never holds a script function: callbacks of built-ins run through the interpreter, and set algebra and `Iterator.from` refuse objects whose methods the script wrote. +- **No new reach through `this`.** A receiver is only ever a value the script already holds: the + object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, + the holder a JSON reviver or replacer visits (part of the script's own value, or a fresh object + around it), or `undefined`. There is no global object, built-ins never pass an internal value as + `this`, and host functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index a6bc66b..d202ef3 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -206,6 +206,127 @@ describe('array methods', () => { }); }); + describe('thisArg', () => { + it('every passes it to its callback as this', () => { + expect(run('return [1, 2].every(function (x) { return x < this.max; }, { max: 3 });')).toBe( + true + ); + expect(run('return [1].every(function () { return this === undefined; });')).toBe(true); + expect( + run('const o = { f() { return [1].every(() => this === o, 5); } }; return o.f();') + ).toBe(true); + }); + + it('filter passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].filter(function (x) { return x > this.min; }, { min: 1 });') + ).toEqual([2, 3]); + expect(run('return [1].filter(function () { return this === undefined; });')).toEqual([1]); + expect( + run('const o = { f() { return [1].filter(() => this === o, 5); } }; return o.f();') + ).toEqual([1]); + }); + + it('find passes it to its callback as this', () => { + expect(run('return [1, 2, 3].find(function (x) { return x > this.min; }, { min: 1 });')).toBe( + 2 + ); + expect(run('return [1].find(function () { return this === undefined; });')).toBe(1); + expect( + run('const o = { f() { return [1].find(() => this === o, 5); } }; return o.f();') + ).toBe(1); + }); + + it('findIndex passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findIndex(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(1); + expect(run('return [1].findIndex(function () { return this === undefined; });')).toBe(0); + expect( + run('const o = { f() { return [1].findIndex(() => this === o, 5); } }; return o.f();') + ).toBe(0); + }); + + it('findLast passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findLast(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(3); + expect(run('return [1].findLast(function () { return this === undefined; });')).toBe(1); + expect( + run('const o = { f() { return [1].findLast(() => this === o, 5); } }; return o.f();') + ).toBe(1); + }); + + it('findLastIndex passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findLastIndex(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(2); + expect(run('return [1].findLastIndex(function () { return this === undefined; });')).toBe(0); + expect( + run('const o = { f() { return [1].findLastIndex(() => this === o, 5); } }; return o.f();') + ).toBe(0); + }); + + it('flatMap passes it to its callback as this', () => { + expect( + run('return [1, 2].flatMap(function (x) { return [x, x * this.factor]; }, { factor: 10 });') + ).toEqual([1, 10, 2, 20]); + expect(run('return [1].flatMap(function () { return [this]; });')).toEqual([undefined]); + expect( + run('const o = { f() { return [1].flatMap(() => [this === o], 5); } }; return o.f();') + ).toEqual([true]); + }); + + it('forEach passes it to its callback as this', () => { + expect( + run( + 'let sum = 0; [1, 2].forEach(function (x) { sum += x * this.factor; }, { factor: 10 }); return sum;' + ) + ).toBe(30); + expect( + run('let seen = 1; [1].forEach(function () { seen = this; }); return seen;') + ).toBeUndefined(); + expect( + run( + 'const o = { f() { let seen; [1].forEach(() => { seen = this; }, 5); return seen === o; } }; return o.f();' + ) + ).toBe(true); + }); + + it('map passes it to its callback as this', () => { + expect( + run('return [1, 2].map(function (x) { return x + this.offset; }, { offset: 10 });') + ).toEqual([11, 12]); + expect(run('return [1].map(function () { return this; });')).toEqual([undefined]); + expect( + run('const o = { f() { return [1].map(() => this === o, 5); } }; return o.f();') + ).toEqual([true]); + }); + + it('some passes it to its callback as this', () => { + expect( + run('return [1, 2].some(function (x) { return x === this.wanted; }, { wanted: 2 });') + ).toBe(true); + expect(run('return [1].some(function () { return this === undefined; });')).toBe(true); + expect( + run('const o = { f() { return [1].some(() => this === o, 5); } }; return o.f();') + ).toBe(true); + }); + + it('is passed as it is, never boxed', () => { + expect(run('return [1].map(function () { return typeof this; }, "s");')).toEqual(['string']); + expect(run('return [1].map(function () { return this; }, 5);')).toEqual([5]); + }); + + it('leaves the length of each method at 1', () => { + expect( + run( + 'return [[].every.length, [].filter.length, [].find.length, [].findIndex.length, [].findLast.length, [].findLastIndex.length, [].flatMap.length, [].forEach.length, [].map.length, [].some.length];' + ) + ).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); + }); + }); + describe('flat and flatMap', () => { it('charge one operation per element visited, nested arrays included', () => { expect( @@ -403,6 +524,18 @@ describe('Array statics', () => { expect(run('return Array.foo;')).toBeUndefined(); }); + it('from calls the mapper with the thisArg as this', () => { + expect(run('return Array.from([1], function (x) { return [this, x]; }, 5);')).toEqual([[5, 1]]); + expect(run('return Array.from({ length: 1 }, function () { return this; }, "t");')).toEqual([ + 't' + ]); + expect(run('return Array.from([1], function () { return this; });')).toEqual([undefined]); + expect( + run('const o = { f() { return Array.from([1], () => this, 5)[0] === o; } }; return o.f();') + ).toBe(true); + expect(run('return Array.from.length;')).toBe(1); + }); + it('from reads an array-like length as ToLength does', () => { expect(run('return Array.from({ length: 1.5, 0: "a", 1: "b" });')).toEqual(['a']); expect(run('return Array.from({ length: -1, 0: "a" });')).toEqual([]); diff --git a/packages/maple/src/builtins/array/methods/every.ts b/packages/maple/src/builtins/array/methods/every.ts index 2f3d3b0..7b3dff2 100644 --- a/packages/maple/src/builtins/array/methods/every.ts +++ b/packages/maple/src/builtins/array/methods/every.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_EVERY: Method> = method( Array.prototype.every.length, - (a, [fn], context) => - findEntryBy(a, presentEntries(a), arrayCallback(fn, context), false) === undefined + (a, [fn, thisArg], context) => + findEntryBy(a, presentEntries(a), arrayCallback(fn, context, thisArg), false) === undefined ); diff --git a/packages/maple/src/builtins/array/methods/filter.ts b/packages/maple/src/builtins/array/methods/filter.ts index 6232100..d6d684a 100644 --- a/packages/maple/src/builtins/array/methods/filter.ts +++ b/packages/maple/src/builtins/array/methods/filter.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_FILTER: Method> = method( Array.prototype.filter.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); const result: Array = []; for (const [index, element] of presentEntries(a)) { if (call(element, index, a)) { diff --git a/packages/maple/src/builtins/array/methods/find-index.ts b/packages/maple/src/builtins/array/methods/find-index.ts index a278897..8400959 100644 --- a/packages/maple/src/builtins/array/methods/find-index.ts +++ b/packages/maple/src/builtins/array/methods/find-index.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_INDEX: Method> = method( Array.prototype.findIndex.length, - (a, [fn], context) => - findEntryBy(a, indexedEntries(a), arrayCallback(fn, context), true)?.[0] ?? -1 + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntries(a), arrayCallback(fn, context, thisArg), true)?.[0] ?? -1 ); diff --git a/packages/maple/src/builtins/array/methods/find-last-index.ts b/packages/maple/src/builtins/array/methods/find-last-index.ts index 9a89907..f5fd346 100644 --- a/packages/maple/src/builtins/array/methods/find-last-index.ts +++ b/packages/maple/src/builtins/array/methods/find-last-index.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_LAST_INDEX: Method> = method( Array.prototype.findLastIndex.length, - (a, [fn], context) => - findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context), true)?.[0] ?? -1 + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context, thisArg), true)?.[0] ?? -1 ); diff --git a/packages/maple/src/builtins/array/methods/find-last.ts b/packages/maple/src/builtins/array/methods/find-last.ts index b0f0798..d24c32d 100644 --- a/packages/maple/src/builtins/array/methods/find-last.ts +++ b/packages/maple/src/builtins/array/methods/find-last.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_LAST: Method> = method( Array.prototype.findLast.length, - (a, [fn], context) => - findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context), true)?.[1] + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context, thisArg), true)?.[1] ); diff --git a/packages/maple/src/builtins/array/methods/find.ts b/packages/maple/src/builtins/array/methods/find.ts index b062a21..a491fed 100644 --- a/packages/maple/src/builtins/array/methods/find.ts +++ b/packages/maple/src/builtins/array/methods/find.ts @@ -5,5 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND: Method> = method( Array.prototype.find.length, - (a, [fn], context) => findEntryBy(a, indexedEntries(a), arrayCallback(fn, context), true)?.[1] + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntries(a), arrayCallback(fn, context, thisArg), true)?.[1] ); diff --git a/packages/maple/src/builtins/array/methods/flat-map.ts b/packages/maple/src/builtins/array/methods/flat-map.ts index 036cf17..f7e2635 100644 --- a/packages/maple/src/builtins/array/methods/flat-map.ts +++ b/packages/maple/src/builtins/array/methods/flat-map.ts @@ -5,8 +5,8 @@ import { flattenInto } from '../utils/flatten-into.ts'; export const ARRAY_METHOD_FLAT_MAP: Method> = method( Array.prototype.flatMap.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); const result: Array = []; for (const [index, element] of presentEntries(a)) { const mapped = call(element, index, a); diff --git a/packages/maple/src/builtins/array/methods/for-each.ts b/packages/maple/src/builtins/array/methods/for-each.ts index c552071..bd62bce 100644 --- a/packages/maple/src/builtins/array/methods/for-each.ts +++ b/packages/maple/src/builtins/array/methods/for-each.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_FOR_EACH: Method> = method( Array.prototype.forEach.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); for (const [index, element] of presentEntries(a)) { call(element, index, a); } diff --git a/packages/maple/src/builtins/array/methods/map.ts b/packages/maple/src/builtins/array/methods/map.ts index cd8d35d..d26fe1a 100644 --- a/packages/maple/src/builtins/array/methods/map.ts +++ b/packages/maple/src/builtins/array/methods/map.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_MAP: Method> = method( Array.prototype.map.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); // The result keeps the holes of the source, which the callback never sees const result: Array = []; result.length = a.length; diff --git a/packages/maple/src/builtins/array/methods/some.ts b/packages/maple/src/builtins/array/methods/some.ts index 322c189..4b4e6e9 100644 --- a/packages/maple/src/builtins/array/methods/some.ts +++ b/packages/maple/src/builtins/array/methods/some.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_SOME: Method> = method( Array.prototype.some.length, - (a, [fn], context) => - findEntryBy(a, presentEntries(a), arrayCallback(fn, context), true) !== undefined + (a, [fn, thisArg], context) => + findEntryBy(a, presentEntries(a), arrayCallback(fn, context, thisArg), true) !== undefined ); diff --git a/packages/maple/src/builtins/array/statics/from.ts b/packages/maple/src/builtins/array/statics/from.ts index 951950f..f6eda2c 100644 --- a/packages/maple/src/builtins/array/statics/from.ts +++ b/packages/maple/src/builtins/array/statics/from.ts @@ -6,16 +6,14 @@ import { getMember } from '../../method-dispatch.ts'; export const ARRAY_STATIC_FROM: NativeFunction = new NativeFunction( 'from', Array.from.length, - (_, [source, mapper], context) => { + (_, [source, mapper, thisArg], context) => { const { fail } = context; if (mapper !== undefined && !isCallable(mapper)) { return fail('TypeError', `${describeValue(mapper)} is not a function`); } const items: Array = []; const add = (item: unknown): void => { - items.push( - mapper === undefined ? item : context.call(undefined, mapper, [item, items.length]) - ); + items.push(mapper === undefined ? item : context.call(thisArg, mapper, [item, items.length])); }; const iterator = iterate(source); if (iterator) { diff --git a/packages/maple/src/builtins/array/utils/array-callback.ts b/packages/maple/src/builtins/array/utils/array-callback.ts index ea880c3..c98e840 100644 --- a/packages/maple/src/builtins/array/utils/array-callback.ts +++ b/packages/maple/src/builtins/array/utils/array-callback.ts @@ -3,10 +3,13 @@ import { isCallable, type CallContext } from '../../../values/native.ts'; export type ArrayCallback = (...args: Array) => unknown; -/** The script callback as a host function; a non-callable argument fails like the host would. */ -export function arrayCallback(fn: unknown, context: CallContext): ArrayCallback { +/** + * The script callback as a host function; a non-callable argument fails like the host would. It + * runs with `thisArg` as `this`, which is `undefined` for the methods that take none. + */ +export function arrayCallback(fn: unknown, context: CallContext, thisArg?: unknown): ArrayCallback { if (!isCallable(fn)) { context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(undefined, fn, args); + return (...args) => context.call(thisArg, fn, args); } diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index a40840f..638b409 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -36,7 +36,7 @@ describe('JSON.parse', () => { expect(run('try { JSON.parse(); } catch (e) { return e.name; }')).toBe('SyntaxError'); }); - it('applies a reviver bottom-up, with the holder as third argument', () => { + it('applies a reviver bottom-up, with the holder as this', () => { const text = '{"a":{"b":1},"c":[2,3]}'; expect( @@ -60,9 +60,14 @@ describe('JSON.parse', () => { expect(run('return JSON.parse("{}", (k, v) => (k === "" ? 42 : v));')).toBe(42); expect( run( - 'return JSON.parse(\'{"a":1}\', (k, v, holder) => (k === "a" ? Object.keys(holder) : v));' + 'return JSON.parse(\'{"a":1}\', function (k, v) { return k === "a" ? Object.keys(this) : v; });' ) ).toEqual({ a: ['a'] }); + expect( + run('return JSON.parse("[1]", function (k, v) { return k === "0" ? this.length : v; });') + ).toEqual([1]); + expect(run('return JSON.parse("1", function () { return Object.keys(this); });')).toEqual(['']); + expect(run('return JSON.parse(\'{"a":1}\', (k, v, holder) => holder);')).toBeUndefined(); expect( run( 'return JSON.parse(\'{"when":"1970-01-01T00:00:00.000Z"}\', (k, v) => (k === "when" ? new Date(v) : v)).when.getTime();' @@ -72,6 +77,14 @@ describe('JSON.parse', () => { }); it('keeps a reviver inside the sandbox rules', () => { + expect( + runAndCatch(maple, 'JSON.parse("{}", function () { return this.constructor; });').sandboxError + ?.name + ).toBe('TypeError'); + expect( + runAndCatch(maple, 'JSON.parse("{}", function () { return this.__proto__; });').sandboxError + ?.name + ).toBe('TypeError'); expect(run('return Object.keys(JSON.parse(\'{"__proto__": 1}\', (k, v) => v));')).toEqual([ '__proto__' ]); @@ -169,6 +182,46 @@ describe('JSON.stringify', () => { ); }); + it('calls a function replacer with the holder as this', () => { + expect( + run( + 'return JSON.stringify({ a: 1 }, function (k, v) { return k === "" ? Object.keys(this) : v; });' + ) + ).toBe('[""]'); + expect( + run( + 'const o = { a: 1 }; return JSON.stringify(o, function (k, v) { return k === "a" ? this === o : v; });' + ) + ).toBe('{"a":true}'); + expect( + run('return JSON.stringify([1, 2], function (k, v) { return k === "" ? v : this.length; });') + ).toBe('[2,2]'); + expect( + run( + 'return JSON.stringify({ a: 1 }, function (k, v) { return k === "" ? { b: 2 } : this.b; });' + ) + ).toBe('{"b":2}'); + expect( + run( + 'return JSON.stringify({ when: new Date(0) }, function (k, v) { return this[k] instanceof Date ? this[k].getTime() : v; });' + ) + ).toBe('{"when":0}'); + expect( + run('const o = { f() { return JSON.stringify([1], () => this === o); } }; return o.f();') + ).toBe('true'); + }); + + it('keeps a replacer inside the sandbox rules', () => { + expect( + runAndCatch(maple, 'JSON.stringify({}, function () { return this.constructor; });') + .sandboxError?.name + ).toBe('TypeError'); + expect( + runAndCatch(maple, 'JSON.stringify({}, function () { return this.__proto__; });').sandboxError + ?.name + ).toBe('TypeError'); + }); + it("writes an array replacer's keys in its order, own properties only", () => { expect(run('return JSON.stringify({ a: 1, b: 2 }, ["b", "a", "b"]);')).toBe('{"b":2,"a":1}'); expect(run('return JSON.stringify({ a: 1 }, ["a", "missing", "toString"]);')).toBe('{"a":1}'); diff --git a/packages/maple/src/builtins/json/statics/parse.ts b/packages/maple/src/builtins/json/statics/parse.ts index b164aa5..236e386 100644 --- a/packages/maple/src/builtins/json/statics/parse.ts +++ b/packages/maple/src/builtins/json/statics/parse.ts @@ -26,7 +26,7 @@ export const JSON_STATIC_PARSE: NativeFunction = new NativeFunction( /** * The reviver visits children before their holder's key and the root last under the key `''`, - * as the spec requires. The holder comes as a third argument in place of `this`. + * as the spec requires, with the holder as `this`. */ function revive( holder: Record | Array, @@ -61,5 +61,5 @@ function revive( } } } - return context.call(undefined, reviver, [key, value, holder]); + return context.call(holder, reviver, [key, value]); } diff --git a/packages/maple/src/builtins/json/statics/stringify.ts b/packages/maple/src/builtins/json/statics/stringify.ts index 3250f8f..b57d56d 100644 --- a/packages/maple/src/builtins/json/statics/stringify.ts +++ b/packages/maple/src/builtins/json/statics/stringify.ts @@ -1,5 +1,6 @@ import { isPlainObject } from '../../../support/is-plain-object.ts'; import { MAX_NESTING } from '../../../support/nesting-limit.ts'; +import { defineOwn } from '../../../support/own-property.ts'; import { getOwnPropertiesOf } from '../../../values/members.ts'; import { type CallContext, isCallable, NativeFunction } from '../../../values/native.ts'; import { SandboxObject } from '../../../values/sandbox-object.ts'; @@ -16,7 +17,7 @@ interface PropertyView { interface Serialization { readonly gap: string; readonly allowed: ReadonlySet | undefined; - readonly replace: ((key: string, current: unknown) => unknown) | undefined; + readonly replace: ((holder: object, key: string, current: unknown) => unknown) | undefined; readonly ancestors: Array; readonly context: CallContext; } @@ -28,14 +29,18 @@ interface Serialization { export const JSON_STATIC_STRINGIFY: NativeFunction = new NativeFunction( 'stringify', JSON.stringify.length, - (_, [value, replacer, space], context) => - serialize('', value, '', { + (_, [value, replacer, space], context) => { + // The value is serialized as the property '' of a fresh object, which a replacer sees as this + const root: Record = {}; + defineOwn(root, '', value); + return serialize(root, '', value, '', { gap: gapOf(space), allowed: allowedKeys(replacer), replace: replacerOf(replacer, context), ancestors: [], context - }) + }); + } ); /** @@ -69,17 +74,19 @@ function allowedKeys(replacer: unknown): ReadonlySet | undefined { return keys; } -/** A function replacer, which runs as a script callback. */ +/** A function replacer, which runs as a script callback with the holder as `this`. */ function replacerOf( replacer: unknown, context: CallContext -): ((key: string, current: unknown) => unknown) | undefined { +): ((holder: object, key: string, current: unknown) => unknown) | undefined { return isCallable(replacer) - ? (key, current) => context.call(undefined, replacer, [key, current]) + ? (holder, key, current) => context.call(holder, replacer, [key, current]) : undefined; } +/** `holder` is the object or array whose property `key` is, as the replacer sees it. */ function serialize( + holder: object, key: string, raw: unknown, indent: string, @@ -94,7 +101,7 @@ function serialize( ? null : raw.date.toISOString() : raw; - const current = replace ? replace(key, json) : json; + const current = replace ? replace(holder, key, json) : json; if (current === null) { return 'null'; @@ -143,7 +150,7 @@ function serialize( const parts: Array = []; const length = current.length; for (let index = 0; index < length; index++) { - parts.push(serialize(String(index), current[index], inner, serialization) ?? 'null'); + parts.push(serialize(current, String(index), current[index], inner, serialization) ?? 'null'); } result = wrap('[', ']', parts, gap, indent, inner); } else { @@ -176,7 +183,13 @@ function serialize( ? [...allowed].filter((propertyKey) => properties.has(propertyKey)) : properties.keys(); for (const propertyKey of keys) { - const serialized = serialize(propertyKey, properties.get(propertyKey), inner, serialization); + const serialized = serialize( + current, + propertyKey, + properties.get(propertyKey), + inner, + serialization + ); if (serialized !== undefined) { parts.push(`${JSON.stringify(propertyKey)}:${gap ? ' ' : ''}${serialized}`); } diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 1af68e9..657d957 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -56,6 +56,25 @@ describe('Map', () => { expect(run(`${setup} const [[k]] = m; return k;`)).toBe('b'); }); + it('forEach calls its callback with the thisArg as this', () => { + expect( + run( + 'const seen = []; new Map([[1, 2]]).forEach(function () { seen.push(this); }, 5); return seen;' + ) + ).toEqual([5]); + expect( + run( + 'const seen = []; new Map([[1, 2]]).forEach(function () { seen.push(this); }); return seen;' + ) + ).toEqual([undefined]); + expect( + run( + 'const o = { f() { let s; new Map([[1, 2]]).forEach(() => { s = this; }, 5); return s === o; } }; return o.f();' + ) + ).toBe(true); + expect(run('return new Map().forEach.length;')).toBe(1); + }); + it('has no own properties besides size but takes the ones a script adds', () => { expect(run('return ["size" in new Map(), "get" in new Map()];')).toEqual([true, true]); expect(run('return new Map().foo;')).toBeUndefined(); diff --git a/packages/maple/src/builtins/map/methods/for-each.ts b/packages/maple/src/builtins/map/methods/for-each.ts index 5e17c66..83b2326 100644 --- a/packages/maple/src/builtins/map/methods/for-each.ts +++ b/packages/maple/src/builtins/map/methods/for-each.ts @@ -4,13 +4,13 @@ import type { MapWrapper } from '../../../values/wrappers/map-wrapper.ts'; export const MAP_METHOD_FOR_EACH: Method = method( Map.prototype.forEach.length, - (m, [fn], context) => { + (m, [fn, thisArg], context) => { if (!isCallable(fn)) { return context.fail('TypeError', `${describeValue(fn)} is not a function`); } for (const [key, value] of m.map) { - context.call(undefined, fn, [value, key, m]); + context.call(thisArg, fn, [value, key, m]); } return undefined; diff --git a/packages/maple/src/builtins/set/methods/for-each.ts b/packages/maple/src/builtins/set/methods/for-each.ts index 8328349..5aa3a3e 100644 --- a/packages/maple/src/builtins/set/methods/for-each.ts +++ b/packages/maple/src/builtins/set/methods/for-each.ts @@ -4,13 +4,13 @@ import type { SetWrapper } from '../../../values/wrappers/set-wrapper.ts'; export const SET_METHOD_FOR_EACH: Method = method( Set.prototype.forEach.length, - (s, [fn], context) => { + (s, [fn, thisArg], context) => { if (!isCallable(fn)) { return context.fail('TypeError', `${describeValue(fn)} is not a function`); } for (const value of s.set) { - context.call(undefined, fn, [value, value, s]); + context.call(thisArg, fn, [value, value, s]); } return undefined; diff --git a/packages/maple/src/builtins/set/set.spec.ts b/packages/maple/src/builtins/set/set.spec.ts index 906c8ec..60310e4 100644 --- a/packages/maple/src/builtins/set/set.spec.ts +++ b/packages/maple/src/builtins/set/set.spec.ts @@ -31,6 +31,23 @@ describe('Set', () => { ).toBe('[object Object] is not iterable'); }); + it('forEach calls its callback with the thisArg as this', () => { + expect( + run( + 'const seen = []; new Set([1]).forEach(function () { seen.push(this); }, 5); return seen;' + ) + ).toEqual([5]); + expect( + run('const seen = []; new Set([1]).forEach(function () { seen.push(this); }); return seen;') + ).toEqual([undefined]); + expect( + run( + 'const o = { f() { let s; new Set([1]).forEach(() => { s = this; }, 5); return s === o; } }; return o.f();' + ) + ).toBe(true); + expect(run('return new Set().forEach.length;')).toBe(1); + }); + it('is read-only from the input and crosses the boundary as a native Set', () => { const data = { tags: new Set(['a']) }; diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 811a5ee..ed37e1e 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -34,6 +34,16 @@ function assertHostUntouched(): void { expect(Array.prototype).not.toHaveProperty('polluted'); } +/** Whether `call` invoked `cb` at all, and whether every invocation had `this` undefined. */ +function receiversIn(call: string): unknown { + return run(` + const seen = []; + const cb = function (x) { seen.push(this); return x; }; + ${call}; + return [seen.length > 0, seen.every((receiver) => receiver === undefined)]; + `); +} + describe('escape attempts', () => { afterEach(assertHostUntouched); @@ -79,7 +89,7 @@ describe('escape attempts', () => { expect(errorCode('Function("return this")();')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('eval("1");')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('globalThis;')).toBe('DISALLOWED_SYNTAX'); - expect(errorCode('return this;')).toBe('DISALLOWED_SYNTAX'); + expect(run('return this;')).toBeUndefined(); expect(errorCode('return arguments;')).toBe('DISALLOWED_SYNTAX'); expect(errorName('function f() { return arguments.callee; } f();')).toBe('TypeError'); expect(errorCode('Reflect.get({}, "a");')).toBe('DISALLOWED_SYNTAX'); @@ -317,6 +327,125 @@ describe('escape attempts', () => { }); }); + describe('this', () => { + it('is never a global object', () => { + expect(run('return this;')).toBeUndefined(); + expect(run('return (function () { return this; })();')).toBeUndefined(); + expect(run('return (() => this)();')).toBeUndefined(); + expect(run('function f() { return this; } return [f(), [1].map(f)[0]];')).toEqual([ + undefined, + undefined + ]); + }); + + it('guards its prototype keys like any other object', () => { + const method = (body: string): string | undefined => + errorName(`const o = { f() { return ${body}; } }; o.f();`); + + expect(method('this.constructor')).toBe('TypeError'); + expect(method('this.__proto__')).toBe('TypeError'); + expect(method('this["prototype"]')).toBe('TypeError'); + expect(method('this["__proto__"]')).toBe('TypeError'); + expect( + errorName('const f = () => 1; f.g = function () { return this.constructor; }; f.g();') + ).toBe('TypeError'); + expect(errorName('const o = { f() { this.__proto__ = { polluted: true }; } }; o.f();')).toBe( + 'TypeError' + ); + }); + + it('writes to a shared built-in for the current run only', () => { + const shared = new Maple(); + + expect( + shared.run('Math.f = function () { this.x = 1; return this; }; return Math.f() === Math;') + .value + ).toBe(true); + expect(shared.run('return [typeof Math.f, typeof Math.x];').value).toEqual([ + 'undefined', + 'undefined' + ]); + }); + + it('cannot change frozen input, and changes only the copy of unfrozen input', () => { + const data = { obj: { n: 1 } }; + const code = 'const o = { f() { this.x = 1; return this.x; } }; return o.f.call(obj);'; + + expect(errorName(code, data)).toBe('TypeError'); + expect( + runUnfrozen('obj.f = function () { this.x = 1; return this.x; }; return obj.f();', data) + ).toBe(1); + expect(runUnfrozen(code, data)).toBe(1); + expect(data).toEqual({ obj: { n: 1 } }); + }); + + it('is never passed to a host function or an intrinsic', () => { + const receivers: Array = []; + const probes = new Maple({ + globals: { + probe(this: unknown): void { + receivers.push(this); + }, + intrinsicProbe: intrinsic(function (this: unknown): void { + receivers.push(this); + }) + } + }); + + probes.run( + 'const o = { probe, intrinsicProbe }; o.probe(); o.intrinsicProbe(); probe.call(o); intrinsicProbe.apply(o, []); probe.bind(o)(); obj.probe();', + { + obj: { + probe(this: unknown): void { + receivers.push(this); + } + } + } + ); + + expect(receivers).toEqual(Array.from({ length: 6 }, () => undefined)); + }); + + it('is undefined for every built-in callback that is given no thisArg', () => { + const cases: ReadonlyArray = [ + '[2, 1, 3].sort(cb)', + '[2, 1, 3].toSorted(cb)', + '[1, 2].reduce(cb)', + '[1, 2].reduce(cb, 0)', + '[1, 2].reduceRight(cb)', + '[1].values().map(cb).toArray()', + '[1].values().filter(cb).toArray()', + '[1].values().flatMap((x) => [x]).map(cb).toArray()', + '[1].values().flatMap(function (x) { seen.push(this); return [x]; }).toArray()', + '[1].values().forEach(cb)', + '[1].values().some(cb)', + '[1].values().every(cb)', + '[1].values().find(cb)', + '[1, 2].values().reduce(cb)', + '"ab".replace("a", cb)', + '"ab".replace(/a/g, cb)', + '"aa".replaceAll("a", cb)', + 'Object.groupBy([1], cb)', + 'Map.groupBy([1], cb)', + 'Array.from([1], cb)', + 'Array.from({ length: 1 }, cb)', + '[1].map(cb)', + 'new Map([[1, 1]]).forEach(cb)', + 'new Set([1]).forEach(cb)' + ]; + for (const call of cases) { + expect([call, receiversIn(call)]).toEqual([call, [true, true]]); + } + }); + + it('returns to the host as a plain copy, without its functions', () => { + expect(run('return { n: 1, f() { return this; } }.f();')).toStrictEqual({ + n: 1, + f: undefined + }); + }); + }); + describe('functions never cross the boundary', () => { it('neither out through results nor into host functions', () => { expect(run('return () => 1;')).toBeUndefined(); diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index 5697fa4..a2d812c 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -134,7 +134,6 @@ const HOST_FAILURE_MESSAGE = 'A host function failed'; const NODE_NAMES: Partial> = { ClassDeclaration: 'class', ClassExpression: 'class', - ThisExpression: 'this', Super: 'super', AwaitExpression: 'await', YieldExpression: 'yield', @@ -203,9 +202,13 @@ export class Evaluator { return this.#budget.stats(); } - /** Evaluates the program as a function body below `globals` and returns what the script returned. */ + /** + * Evaluates the program as a function body below `globals` and returns what the script returned. + * Like the body of a strict function called plainly, it has `undefined` as `this`. + */ public run(program: Program, globals: Scope): unknown { const scope = new Scope(globals); + scope.bindReceiver(undefined); const completion = this.evaluateFunctionBody(program.body, scope); return completion.type === 'return' ? completion.value : undefined; } @@ -684,6 +687,8 @@ export class Evaluator { return node.value; case 'Identifier': return this.readIdentifier(node, scope); + case 'ThisExpression': + return scope.receiver(); case 'TemplateLiteral': return this.evaluateTemplate(node, scope); case 'ObjectExpression': @@ -1039,6 +1044,15 @@ export class Evaluator { receiver = access.object; target = access.value; } + } else if (callee.type === 'ChainExpression' && callee.expression.type === 'MemberExpression') { + // `(a?.b)()` keeps `a` as the receiver; a short circuit ends at the parenthesis + this.tick(callee); + this.tick(callee.expression); + const access = this.accessMember(callee.expression, scope); + if (access !== SHORT_CIRCUIT) { + receiver = access.object; + target = access.value; + } } else { target = this.evaluateExpression(callee, scope); } @@ -1138,7 +1152,7 @@ export class Evaluator { ): unknown { this.tick(node); if (callee instanceof Closure) { - return this.callClosure(callee, args, node); + return this.callClosure(receiver, callee, args, node); } this.enterCall(node); try { @@ -1157,7 +1171,7 @@ export class Evaluator { description: string ): unknown { if (callee instanceof Closure) { - return this.callClosure(callee, args, node); + return this.callClosure(receiver, callee, args, node); } if (callee instanceof NativeFunction) { return callee.invoke(receiver, args, this.callContext(node)); @@ -1305,14 +1319,24 @@ export class Evaluator { } } - /** `node` is where the call happened, which is what a depth error points at. */ - private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + /** + * `node` is where the call happened, which is what a depth error points at. An arrow ignores + * `receiver` and sees the `this` of the scope it closed over. + */ + private callClosure( + receiver: unknown, + closure: Closure, + args: ReadonlyArray, + node: Node + ): unknown { this.enterCall(node); const { node: fn } = closure; this.#frames.push({ name: closure.name || '', calledFrom: node }); try { const scope = new Scope(this.scopeForNamedFunction(closure)); if (fn.type !== 'ArrowFunctionExpression') { + // Strict mode: the receiver as it is, never boxed or replaced by a global object + scope.bindReceiver(receiver); // Built on the first read only; copying the arguments costs one operation per argument scope.defineLazy('arguments', () => { this.charge(args.length, node); diff --git a/packages/maple/src/evaluator/functions.spec.ts b/packages/maple/src/evaluator/functions.spec.ts index e1a8b19..2254cbb 100644 --- a/packages/maple/src/evaluator/functions.spec.ts +++ b/packages/maple/src/evaluator/functions.spec.ts @@ -400,6 +400,122 @@ describe('functions', () => { }); }); + describe('this', () => { + it('is the object of a member call', () => { + expect(run('const o = { n: 1, f() { return this.n; } }; return o.f();')).toBe(1); + expect(run('const o = { n: 2, f: function () { return this.n; } }; return o["f"]();')).toBe( + 2 + ); + expect(run('const o = { f() { return this; } }; return o?.f() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return o.f?.() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return (o.f)() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return (o?.f)() === o;')).toBe(true); + }); + + it('is undefined for a plain call', () => { + expect(run('function f() { return this; } return f();')).toBeUndefined(); + expect(run('const o = { f() { return this; } }; return (0, o.f)();')).toBeUndefined(); + expect(run('const o = { f() { return this; } }; const g = o.f; return g();')).toBeUndefined(); + expect(run('return (function () { return typeof this; })();')).toBe('undefined'); + }); + + it('is undefined at the top level and in arrows defined there', () => { + expect(run('return this;')).toBeUndefined(); + expect(run('return typeof this;')).toBe('undefined'); + expect(run('const f = () => this; return f();')).toBeUndefined(); + expect(run('const o = { f: () => this }; return o.f();')).toBeUndefined(); + }); + + it('is what call, apply and bind pass, never boxed', () => { + expect( + run( + 'function f() { return this; } return [f.call(5), f.apply("s", []), f.bind(true)(), f.call(null), f.call()];' + ) + ).toEqual([5, 's', true, null, undefined]); + expect(run('function f() { return typeof this; } return f.call(5);')).toBe('number'); + }); + + it('keeps the bound receiver over a later one', () => { + expect(run('function f() { return this; } return f.bind(1).call(2);')).toBe(1); + expect(run('function f() { return this; } return { g: f.bind(1) }.g();')).toBe(1); + }); + + it('is lexical in an arrow, which ignores any receiver', () => { + expect(run('const o = { f() { return (() => this)(); } }; return o.f() === o;')).toBe(true); + expect( + run('const o = { f() { const g = () => this; return g.call(1); } }; return o.f() === o;') + ).toBe(true); + expect(run('const o = { f() { return [1].map(() => this)[0]; } }; return o.f() === o;')).toBe( + true + ); + }); + + it('is a function of its own in a nested function', () => { + expect( + run('const o = { f() { function g() { return this; } return g(); } }; return o.f();') + ).toBeUndefined(); + expect( + run( + 'const o = { f() { function g() { return () => this; } return g()(); } }; return o.f();' + ) + ).toBeUndefined(); + }); + + it('chains methods that return it', () => { + expect(run('return { n: 0, add(k) { this.n += k; return this; } }.add(1).add(2).n;')).toBe(3); + }); + + it('is the receiver in default parameter values', () => { + expect(run('const o = { n: 1, f(a = this.n) { return a; } }; return o.f();')).toBe(1); + expect(run('const o = { f(a = () => this) { return a(); } }; return o.f() === o;')).toBe( + true + ); + }); + + it('sits next to arguments and a named function expression calling itself', () => { + expect( + run('const o = { f() { return [this === o, arguments.length]; } }; return o.f(1, 2);') + ).toEqual([true, 2]); + expect( + run( + 'const o = { n: 3, f: function g(k) { return k === 0 ? this.n : g.call(this, k - 1); } }; return o.f(2);' + ) + ).toBe(3); + expect( + run('const o = { f: function g(k) { return k ? g(0) : this; } }; return o.f(1);') + ).toBeUndefined(); + }); + + it('stays with each loop iteration', () => { + expect( + run(` + const o = { + f() { + const fs = []; + for (let i = 0; i < 2; i++) { fs.push(() => [this === o, i]); } + for (const k of [2]) { fs.push(() => [this === o, k]); } + return fs.map((g) => g()); + } + }; + return o.f(); + `) + ).toEqual([ + [true, 0], + [true, 1], + [true, 2] + ]); + }); + + it('fails like any undefined value when read from', () => { + expect(errorMessage('function f() { return this.x; } return f();')).toBe( + "Cannot read properties of undefined (reading 'x')" + ); + expect(errorMessage('function f() { this.x = 1; } f();')).toBe( + "Cannot set properties of undefined (setting 'x')" + ); + }); + }); + describe('toString and valueOf', () => { it('convert any function to the placeholder and give it back itself', () => { expect( @@ -430,9 +546,8 @@ describe('functions', () => { expect(runAndCatch(maple, 'function* g() {}').message).toBe('generators are not supported'); }); - it('throws for this and arguments only when reached', () => { - expect(maple.run('const f = () => this; return 1;').value).toBe(1); - expect(runAndCatch(maple, 'return (() => this)();').message).toBe("'this' is not supported"); + it('throws for a script-level arguments only when reached', () => { + expect(maple.run('const f = () => arguments; return 1;').value).toBe(1); expect(runAndCatch(maple, 'return arguments;').message).toBe("'arguments' is not supported"); }); diff --git a/packages/maple/src/evaluator/scope.spec.ts b/packages/maple/src/evaluator/scope.spec.ts index ffbc8be..46b90ed 100644 --- a/packages/maple/src/evaluator/scope.spec.ts +++ b/packages/maple/src/evaluator/scope.spec.ts @@ -96,4 +96,41 @@ describe('Scope', () => { expect(create).toHaveBeenCalledTimes(1); }); }); + + describe('receiver', () => { + it('comes from the nearest scope that binds one', () => { + const outer = new Scope(); + const middle = new Scope(outer); + const inner = new Scope(middle); + outer.bindReceiver(1); + + expect(inner.receiver()).toBe(1); + + middle.bindReceiver(undefined); + + expect(inner.receiver()).toBeUndefined(); + expect(outer.receiver()).toBe(1); + }); + + it('is not a binding named this', () => { + const scope = new Scope(); + scope.bindReceiver(1); + + expect(scope.lookup('this')).toBeUndefined(); + expect(scope.hasOwn('this')).toBe(false); + }); + + it('is carried along by a copy', () => { + const outer = new Scope(); + outer.bindReceiver(1); + const scope = new Scope(outer); + scope.bindReceiver(2); + + expect(scope.copy().receiver()).toBe(2); + }); + + it('fails when no scope binds one', () => { + expect(() => new Scope().receiver()).toThrow('No scope binds a receiver'); + }); + }); }); diff --git a/packages/maple/src/evaluator/scope.ts b/packages/maple/src/evaluator/scope.ts index 586d7ca..e618215 100644 --- a/packages/maple/src/evaluator/scope.ts +++ b/packages/maple/src/evaluator/scope.ts @@ -7,11 +7,17 @@ export interface Binding { value: unknown; } +/** Marks a scope that has no receiver of its own, so `this` comes from a parent. */ +const NO_RECEIVER: unique symbol = Symbol('no-receiver'); + /** One lexical environment; a `Map` so that names like `__proto__` are ordinary keys. */ export class Scope { readonly #bindings = new Map(); readonly #parent: Scope | undefined; + /** What `this` is in this scope; only the scope of a script or a non-arrow call has one. */ + #receiver: unknown = NO_RECEIVER; + constructor(parent?: Scope) { this.#parent = parent; } @@ -65,12 +71,29 @@ export class Scope { return this.#bindings.get(name) ?? this.#parent?.lookup(name); } + /** Makes `value` what `this` is here and in every scope below that has no receiver of its own. */ + public bindReceiver(value: unknown): void { + this.#receiver = value; + } + + /** The receiver of the nearest scope that has one; kept apart from the bindings, as a keyword. */ + public receiver(): unknown { + if (this.#receiver !== NO_RECEIVER) { + return this.#receiver; + } + if (!this.#parent) { + throw new Error('No scope binds a receiver'); + } + return this.#parent.receiver(); + } + /** * A sibling scope with copies of the own bindings, which gives a loop fresh per-iteration * bindings. */ public copy(): Scope { const scope = new Scope(this.#parent); + scope.#receiver = this.#receiver; for (const [name, binding] of this.#bindings) { scope.#bindings.set(name, { ...binding }); }