Skip to content

Commit

Permalink
Implement a merge() method to apply partials and mixins
Browse files Browse the repository at this point in the history
Fixes w3c#581.
  • Loading branch information
foolip committed Apr 27, 2021
1 parent b62d2cf commit 5c096fd
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 3 deletions.
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```

Expand All @@ -48,14 +51,16 @@ In the browser:
<script>
const tree = WebIDL2.parse("string of WebIDL");
const text = WebIDL2.write(tree);
const merged = WebIDL2.merge(tree);
const validation = WebIDL2.validate(tree);
</script>

<!-- Or when module is supported -->
<script type="module">
import { parse, write, validate } from "./webidl2/index.js";
import { parse, write, merge, validate } from "./webidl2/index.js";
const tree = parse("string of WebIDL");
const text = write(tree);
const merged = merge(tree);
const validation = validate(tree);
</script>
```
Expand Down Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -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";
140 changes: 140 additions & 0 deletions lib/merge.js
Original file line number Diff line number Diff line change
@@ -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());
}
72 changes: 72 additions & 0 deletions test/merge.js
Original file line number Diff line number Diff line change
@@ -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;
};
`);
});
});

0 comments on commit 5c096fd

Please sign in to comment.