diff --git a/README.md b/README.md
index d26d35d6..6b06de8b 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;
+ };
+ `);
+ });
+});