From 5c096fd01c92f1eb00d0bb535c0a346334c17708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philip=20J=C3=A4genstedt?= Date: Mon, 26 Apr 2021 18:14:24 +0200 Subject: [PATCH] Implement a merge() method to apply partials and mixins Fixes https://github.com/w3c/webidl2.js/issues/581. --- README.md | 18 +++++-- index.js | 1 + lib/merge.js | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/merge.js | 72 ++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 lib/merge.js create mode 100644 test/merge.js diff --git a/README.md b/README.md index 8ce14fb0..6a6517fd 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,21 @@ In the browser without module support: ## Documentation -WebIDL2 provides two functions: `parse` and `write`. +WebIDL2 provides these functions: * `parse`: Converts a WebIDL string into a syntax tree. * `write`: Converts a syntax tree into a WebIDL string. Useful for programmatic code modification. +* `merge`: Merges partial definitions and interface mixins. +* `validate`: Validates that the parsed syntax against a number of rules. In Node, that happens with: ```JS -const { parse, write, validate } = require("webidl2"); +const { parse, write, merge, validate } = require("webidl2"); const tree = parse("string of WebIDL"); const text = write(tree); +const merged = merge(tree); const validation = validate(tree); ``` @@ -48,14 +51,16 @@ In the browser: ``` @@ -140,6 +145,13 @@ var result = WebIDL2.write(tree, { "Wrapped value" here will all be raw strings when the `wrap()` callback is absent. +`merge()` receives an AST or an array of AST, and TODO: + +```js +const merged = merge(tree); +// TODO example +``` + `validate()` receives an AST or an array of AST, and returns semantic errors as an array of objects. Their fields are same as [errors](#errors) have, with one addition: diff --git a/index.js b/index.js index ed5785fa..476083a0 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ export { parse } from "./lib/webidl2.js"; export { write } from "./lib/writer.js"; +export { merge } from "./lib/merge.js"; export { validate } from "./lib/validator.js"; export { WebIDLParseError } from "./lib/tokeniser.js"; diff --git a/lib/merge.js b/lib/merge.js new file mode 100644 index 00000000..c478b620 --- /dev/null +++ b/lib/merge.js @@ -0,0 +1,140 @@ +import { ExtendedAttributes } from "./productions/extended-attributes.js"; +import { Tokeniser } from "./tokeniser.js"; + +// Remove this once all of our support targets expose `.flat()` by default +function flatten(array) { + if (array.flat) { + return array.flat(); + } + return [].concat(...array); +} + +// https://heycam.github.io/webidl/#own-exposure-set +function getOwnExposureSet(node) { + const exposedAttr = node.extAttrs.find((a) => a.name === "Exposed"); + if (!exposedAttr) { + return null; + } + const exposure = new Set(); + const { type, value } = exposedAttr.rhs; + if (type === "identifier") { + exposure.add(value); + } else if (type === "identifier-list") { + for (const ident of value) { + exposure.add(ident.value); + } + } + return exposure; +} + +/** + * @param {Set?} a a Set or null + * @param {Set?} b a Set or null + * @return {Set?} a new intersected set, one of the original sets, or null + */ +function intersectNullable(a, b) { + if (a && b) { + const intersection = new Set(); + for (const v of a.values()) { + if (b.has(v)) { + intersection.add(v); + } + } + return intersection; + } + return a || b; +} + +/** + * @param {Set?} a a Set or null + * @param {Set?} b a Set or null + * @return true if a and b have the same values, or both are null + */ +function equalsNullable(a, b) { + if (a && b) { + if (a.size !== b.size) { + return false; + } + for (const v of a.values()) { + if (!b.has(v)) { + return false; + } + } + } + return a === b; +} + +/** + * @param {Container} target definition to copy members to + * @param {Container} source definition to copy members from + */ +function copyMembers(target, source) { + const targetExposure = getOwnExposureSet(target); + const parentExposure = intersectNullable( + targetExposure, + getOwnExposureSet(source) + ); + // TODO: extended attributes + for (const orig of source.members) { + const origExposure = getOwnExposureSet(orig); + const copyExposure = intersectNullable(origExposure, parentExposure); + + // Make a copy of the member with the same prototype and own properties. + const copy = Object.create( + Object.getPrototypeOf(orig), + Object.getOwnPropertyDescriptors(orig) + ); + + if (!equalsNullable(targetExposure, copyExposure)) { + let value = Array.from(copyExposure.values()).join(","); + if (copyExposure.size !== 1) { + value = `(${value})`; + } + copy.extAttrs = ExtendedAttributes.parse( + new Tokeniser(` [Exposed=${value}] `) + ); + } + + target.members.push(copy); + } +} + +/** + * @param {*[]} ast AST or array of ASTs + * @return {*[]} + */ +export function merge(ast) { + const dfns = new Map(); + const partials = []; + const includes = []; + + for (const dfn of flatten(ast)) { + if (dfn.partial) { + partials.push(dfn); + } else if (dfn.type === "includes") { + includes.push(dfn); + } else if (dfn.name) { + dfns.set(dfn.name, dfn); + } else { + throw new Error(`definition with no name`); + } + } + + // merge partials (including partial mixins) + for (const partial of partials) { + const target = dfns.get(partial.name); + if (!target) { + throw new Error( + `original definition of partial ${partial.type} ${partial.name} not found` + ); + } + if (partial.type !== target.type) { + throw new Error( + `partial ${partial.type} ${partial.name} inherits from ${target.type} ${target.name} (wrong type)` + ); + } + copyMembers(target, partial); + } + + return Array.from(dfns.values()); +} diff --git a/test/merge.js b/test/merge.js new file mode 100644 index 00000000..e5d204e6 --- /dev/null +++ b/test/merge.js @@ -0,0 +1,72 @@ +import expect from "expect"; +import { parse, write, merge } from "webidl2"; + +// Collapse sequences of whitespace to a single space. +function collapse(s) { + return s.trim().replace(/\s+/g, " "); +} + +expect.extend({ + toMergeAs(received, expected) { + received = collapse(received); + expected = collapse(expected); + const ast = parse(received); + const merged = merge(ast); + const actual = collapse(write(merged)); + if (actual === expected) { + return { + message: () => + `expected ${JSON.stringify( + received + )} to not merge as ${JSON.stringify(expected)} but it did`, + pass: true, + }; + } else { + return { + message: () => + `expected ${JSON.stringify(received)} to merge as ${JSON.stringify( + expected + )} but got ${JSON.stringify(actual)}`, + pass: false, + }; + } + }, +}); + +describe("merge()", () => { + it("empty array", () => { + const result = merge([]); + expect(result).toHaveLength(0); + }); + + it("partial dictionary", () => { + expect(` + dictionary D { }; + partial dictionary D { boolean extra = true; }; + `).toMergeAs(` + dictionary D { boolean extra = true; }; + `); + }); + + it("partial interface", () => { + expect(` + interface I { }; + partial interface I { attribute boolean extra; }; + `).toMergeAs(` + interface I { attribute boolean extra; }; + `); + }); + + it("partial interface with [Exposed]", () => { + expect(` + [Exposed=(Window,Worker)] interface I { }; + [Exposed=Worker] partial interface I { + attribute boolean extra; + }; + `).toMergeAs(` + [Exposed=(Window,Worker)] interface I { + [Exposed=Worker] attribute boolean extra; + }; + `); + }); +});