Add a DOM pinned properties dataset generator

Signed-off-by: Victor Porof <victorporof@chromium.org>
Bug: 1325812
Change-Id: I292d49d273d74d33a82e25179478405395159537
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3695372
Reviewed-by: Mathias Bynens <mathias@chromium.org>
diff --git a/scripts/deps/roll_deps.py b/scripts/deps/roll_deps.py
index 7c1eb05..21c9b70 100755
--- a/scripts/deps/roll_deps.py
+++ b/scripts/deps/roll_deps.py
@@ -81,8 +81,18 @@
     ])
 
 
+def generate_dom_pinned_properties(options):
+    print('generating DOM pinned properties dataset from .idl definitions')
+    subprocess.check_call([
+        node_path(options),
+        os.path.join(options.devtools_dir, 'scripts', 'webidl-properties',
+                     'index.js'), options.devtools_dir
+    ])
+
+
 if __name__ == '__main__':
     OPTIONS = parse_options(sys.argv[1:])
     update(OPTIONS)
     copy_files(OPTIONS)
     generate_signatures(OPTIONS)
+    generate_dom_pinned_properties(OPTIONS)
diff --git a/scripts/webidl-properties/config.js b/scripts/webidl-properties/config.js
new file mode 100644
index 0000000..ceddb72
--- /dev/null
+++ b/scripts/webidl-properties/config.js
@@ -0,0 +1,279 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * All the specs relevant for generating the DOM pinned properties dataset.
+ * These strings represent file names. A list of all file names can be found at
+ * https://github.com/w3c/webref/tree/main/tr/idl or via the @webref/idl API.
+ *
+ * These spec names are included in the generated dataset. To keep it small,
+ * the values are bitwise or'd.
+ */
+export const SPECS = {
+  'html': 1,
+  'dom': 2,
+  'uievents': 4,
+  'pointerevents': 8,
+  'cssom': 16,
+  'wai-aria': 32
+};
+
+/**
+ * All the "global" attributes as defined in the DOM specification.
+ * Used to annotate the extracted properties from the WebIDL types.
+ */
+export const GLOBAL_ATTRIBUTES = new Set([
+  // https://html.spec.whatwg.org/multipage/dom.html#global-attributes
+  'accesskey', 'autocapitalize', 'autofocus', 'contenteditable', 'dir',      'draggable', 'enterkeyhint',
+  'hidden',    'inputmode',      'is',        'itemid',          'itemprop', 'itemref',   'itemscope',
+  'itemtype',  'lang',           'nonce',     'spellcheck',      'style',    'tabindex',  'title',
+  'translate',
+]);
+
+/**
+ * The "applicable" members for certain "states" that WebIDL types can be in.
+ * In other words, some members are "valid" only valid in certain situations:
+ * for example, with the HTML input element, the set of valid members are
+ * determined by the "type" attribute.
+ */
+export const VALID_MEMBERS = {
+  // https://html.spec.whatwg.org/multipage/input.html#input-type-attr-summary
+  HTMLInputElement: {
+    '[type=hidden]': new Set([
+      'autocomplete',
+      'value',
+    ]),
+    '[type=text]': new Set([
+      'autocomplete',
+      'dirname',
+      'list',
+      'maxlength',
+      'minlength',
+      'pattern',
+      'placeholder',
+      'readonly',
+      'required',
+      'size',
+      'value',
+      'list',
+      'selectionstart',
+      'selectionend',
+      'selectiondirection',
+    ]),
+    '[type=search]': new Set([
+      'autocomplete',
+      'dirname',
+      'list',
+      'maxlength',
+      'minlength',
+      'pattern',
+      'placeholder',
+      'readonly',
+      'required',
+      'size',
+      'value',
+      'list',
+      'selectionstart',
+      'selectionend',
+      'selectiondirection',
+    ]),
+    '[type=url]': new Set([
+      'autocomplete',
+      'list',
+      'maxlength',
+      'minlength',
+      'pattern',
+      'placeholder',
+      'readonly',
+      'required',
+      'size',
+      'value',
+      'list',
+      'selectionstart',
+      'selectionend',
+      'selectiondirection',
+    ]),
+    '[type=tel]': new Set([
+      'autocomplete',
+      'list',
+      'maxlength',
+      'minlength',
+      'pattern',
+      'placeholder',
+      'readonly',
+      'required',
+      'size',
+      'value',
+      'list',
+      'selectionstart',
+      'selectionend',
+      'selectiondirection',
+    ]),
+    '[type=email]': new Set([
+      'autocomplete',
+      'list',
+      'maxlength',
+      'minlength',
+      'multiple',
+      'pattern',
+      'placeholder',
+      'readonly',
+      'required',
+      'size',
+      'value',
+      'list',
+    ]),
+    '[type=password]': new Set([
+      'autocomplete',
+      'maxlength',
+      'minlength',
+      'pattern',
+      'placeholder',
+      'readonly',
+      'required',
+      'size',
+      'value',
+      'selectionstart',
+      'selectionend',
+      'selectiondirection',
+    ]),
+    '[type=date]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'readonly',
+      'required',
+      'step',
+      'value',
+      'valueasdate',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=month]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'readonly',
+      'required',
+      'step',
+      'value',
+      'valueasdate',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=week]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'readonly',
+      'required',
+      'step',
+      'value',
+      'valueasdate',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=time]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'readonly',
+      'required',
+      'step',
+      'value',
+      'valueasdate',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=datetime-local]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'readonly',
+      'required',
+      'step',
+      'value',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=number]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'placeholder',
+      'readonly',
+      'required',
+      'step',
+      'value',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=range]': new Set([
+      'autocomplete',
+      'list',
+      'max',
+      'min',
+      'step',
+      'value',
+      'valueasnumber',
+      'list',
+    ]),
+    '[type=color]': new Set([
+      'autocomplete',
+      'list',
+      'value',
+    ]),
+    '[type=checkbox]': new Set([
+      'checked',
+      'required',
+      'checked',
+      'value',
+    ]),
+    '[type=radio]': new Set([
+      'checked',
+      'required',
+      'checked',
+      'value',
+    ]),
+    '[type=file]': new Set([
+      'accept',
+      'multiple',
+      'required',
+      'files',
+      'value',
+    ]),
+    '[type=submit]': new Set([
+      'formaction',
+      'formenctype',
+      'formmethod',
+      'formnovalidate',
+      'formtarget',
+      'value',
+    ]),
+    '[type=image]': new Set([
+      'alt',
+      'formaction',
+      'formenctype',
+      'formmethod',
+      'formnovalidate',
+      'formtarget',
+      'height',
+      'src',
+      'width',
+      'value',
+    ]),
+    '[type=reset]': new Set([
+      'value',
+    ]),
+    '[type=button]': new Set([
+      'value',
+    ]),
+  },
+};
diff --git a/scripts/webidl-properties/get-props.js b/scripts/webidl-properties/get-props.js
new file mode 100644
index 0000000..75d8796
--- /dev/null
+++ b/scripts/webidl-properties/get-props.js
@@ -0,0 +1,139 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {GLOBAL_ATTRIBUTES, SPECS, VALID_MEMBERS} from './config.js';
+import {merge} from './util.js';
+
+/**
+ * All the members relevant for generating the DOM pinned properties dataset
+ * from WebIDL interfaces, mixins and dictionaries.
+ */
+const ACCEPTED_MEMBER_TYPES = new Set(['attribute', 'field']);
+
+/**
+ * Generates the DOM pinned properties dataset.
+ *
+ * @param {array} specs A list of specs. Each spec specifies its name and
+ * all the idl definitions it contains.
+ * @returns {object} output An object with WebIDL type names as keys and their
+ * WebIDL properties and inheritance/include chains as values.
+ */
+export function getIDLProps(specs, output = {}) {
+  for (const spec of specs) {
+    transform(spec, output);
+  }
+  return output;
+}
+
+function transform({name, idls}, output = {}) {
+  const makeEntry = () => ({
+    inheritance: null,
+    includes: [],
+    props: {},
+  });
+
+  for (const idl of idls) {
+    switch (idl.type) {
+      case 'interface':
+      case 'interface mixin':
+      case 'dictionary': {
+        output[idl.name] = output[idl.name] ?? makeEntry();
+        let props = idl.members?.filter(member => ACCEPTED_MEMBER_TYPES.has(member.type));
+        props = props?.map(member => [member.name, {global: GLOBAL_ATTRIBUTES.has(member.name), specs: [name]}, ]);
+        merge(output[idl.name], {
+          inheritance: idl.inheritance,
+          props: Object.fromEntries(props),
+        });
+        break;
+      }
+      case 'includes': {
+        output[idl.target] = output[idl.target] ?? makeEntry();
+        merge(output[idl.target], {
+          includes: [idl.includes],
+        });
+        break;
+      }
+      case 'callback':
+      case 'callback interface':
+      case 'enum':
+      case 'typedef':
+      case 'namespace': {
+        break;
+      }
+      default: {
+        console.warn('Skipping unknown WebIDL type', idl.type);
+      }
+    }
+  }
+}
+
+/**
+ * Adds additional metadata to the DOM pinned properties dataset.
+ *
+ * Currently only adds information about which properties are valid based on
+ * some state, such as for the HTMLInputElement. See `VALID_MEMBERS`.
+ *
+ * @param {*} output
+ */
+export function addMetadata(output) {
+  for (const [key, value] of Object.entries(output)) {
+    const rule = VALID_MEMBERS[key];
+    if (!rule) {
+      continue;
+    }
+    const states = Object.entries(rule).map(([selector, allowlist]) => {
+      const valid = Object.entries(value.props).filter(([prop]) => allowlist.has(prop.toLowerCase()));
+      return [selector, Object.fromEntries(valid)];
+    });
+    value.states = Object.fromEntries(states);
+  }
+  return output;
+}
+
+/**
+ * Minimizes the DOM pinned properties dataset to remove the bits of data that
+ * don't contain information. For example, empty inheritance/includes chains.
+ *
+ * This should be done right at the end, before writing into the output file, to
+ * allow for certain diagnostics (such as finding "missing types").
+ *
+ * @param {*} output
+ * @returns {object}
+ */
+export function minimize(output) {
+  for (const [key, value] of Object.entries(output)) {
+    if (!value.inheritance) {
+      // Remove empty inheritance chains.
+      delete value.inheritance;
+    }
+    if (!value.includes.length) {
+      // Remove empty include chains.
+      delete value.includes;
+    }
+    const props = Object.entries(value.props);
+    if (!props.length) {
+      // Remove empty 'prop' lists.
+      delete value.props;
+    } else {
+      for (const [, value] of props) {
+        if (!value.global) {
+          // Remove the 'global' flag if it's false.
+          delete value.global;
+        }
+        if (value.specs.length === 1 && value.specs[0] === 'html') {
+          // Remove the 'specs' list if it's just "html".
+          delete value.specs;
+        } else {
+          // Combine multiple spec names into a single value.
+          value.specs = value.specs.reduce((acc, name) => acc | SPECS[name], 0);
+        }
+      }
+    }
+    // Remove the entire entry if there's nothing left after the cleanup above.
+    if (!Object.entries(value).length) {
+      delete output[key];
+    }
+  }
+  return output;
+}
diff --git a/scripts/webidl-properties/index.js b/scripts/webidl-properties/index.js
new file mode 100644
index 0000000..ca78bc8
--- /dev/null
+++ b/scripts/webidl-properties/index.js
@@ -0,0 +1,85 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import idl from '@webref/idl';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as url from 'url';
+
+import {SPECS} from './config.js';
+import {addMetadata, getIDLProps, minimize} from './get-props.js';
+import {getMissingTypes} from './util.js';
+
+if (process.argv.length !== 3) {
+  throw new Error('Please provide the path to devtools-frontend');
+}
+
+const files = await idl.listAll();
+const names = Object.keys(SPECS);
+const specs = await Promise.all(names.map(name => files[name].parse().then(idls => ({name, idls}))));
+
+const output = addMetadata(getIDLProps(specs));
+const missing = getMissingTypes(output);
+
+for (const type of missing) {
+  console.warn('Found missing type:', type);
+}
+
+const frontendPath = path.resolve(process.argv[2]);
+const jsMetadataPath = path.join(frontendPath, 'front_end/models/javascript_metadata/');
+const outPath = path.join(jsMetadataPath, 'DOMPinnedProperties.ts');
+const thisPath = path.relative(frontendPath, url.fileURLToPath(import.meta.url));
+
+fs.writeFileSync(outPath, `
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+// Generated from ${thisPath}
+
+/**
+ * All the specs used when generating the DOM pinned properties dataset.
+ */
+export const SPECS = ${JSON.stringify(SPECS)};
+
+export interface DOMPinnedWebIDLProp {
+  // A flag specifying whether it's a "global" attribute.
+  global?: boolean;
+  // A bitfield of the specs in which the property is found.
+  // If missing, it implies the default spec: "html".
+  specs?: number;
+}
+
+export interface DOMPinnedWebIDLType {
+  // An inherited Type.
+  inheritance?: string;
+  // A set of Types to also include properties from.
+  includes?: Array<string>;
+  // The properties defined on this Type.
+  props?: {
+    // A property name such as "checked".
+    [PropName: string]: DOMPinnedWebIDLProp,
+  };
+  // The "states" in which only certain properties are "applicable".
+  states?: {
+    // A CSS selector such as "[type=checkbox]".
+    [State: string]: {
+      [PropName: string]: DOMPinnedWebIDLProp,
+    },
+  };
+}
+
+export interface DOMPinnedPropertiesDataset {
+  [TypeName: string]: DOMPinnedWebIDLType;
+}
+
+/**
+ * The DOM pinned properties dataset. Generated from WebIDL data parsed from
+ * the SPECS above.
+ *
+ * This is an object with WebIDL type names as keys and their WebIDL properties
+ * and inheritance/include chains as values.
+ */
+export const DOMPinnedProperties: DOMPinnedPropertiesDataset = ${JSON.stringify(minimize(output))};
+`);
diff --git a/scripts/webidl-properties/package.json b/scripts/webidl-properties/package.json
new file mode 100644
index 0000000..e746aa7
--- /dev/null
+++ b/scripts/webidl-properties/package.json
@@ -0,0 +1,9 @@
+{
+  "private": true,
+  "main": "index.js",
+  "type": "module",
+  "scripts": {
+    "start": "node index.js",
+    "test": "mocha tests.js"
+  }
+}
diff --git a/scripts/webidl-properties/tests.js b/scripts/webidl-properties/tests.js
new file mode 100644
index 0000000..7301840
--- /dev/null
+++ b/scripts/webidl-properties/tests.js
@@ -0,0 +1,118 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import idl from '@webref/idl';
+import * as assert from 'assert';
+
+import {SPECS} from './config.js';
+import {addMetadata, getIDLProps, minimize} from './get-props.js';
+import {getMissingTypes} from './util.js';
+
+describe('DOM pinned properties dataset generation', function() {
+  let output;
+
+  this.beforeEach(async () => {
+    const files = await idl.listAll();
+    const names = Object.keys(SPECS);
+    const specs = await Promise.all(names.map(name => files[name].parse().then(idls => ({name, idls}))));
+    output = addMetadata(getIDLProps(specs));
+  });
+
+  it('doesn\'t have missing types', () => {
+    const missing = getMissingTypes(output);
+    assert.strictEqual(missing.length, 0);
+  });
+
+  it('generates valid data for HTMLElement', () => {
+    const type = output.HTMLElement;
+    assert.strictEqual(type.inheritance, 'Element');
+    assert.deepEqual(type.includes, [
+      'GlobalEventHandlers',
+      'DocumentAndElementEventHandlers',
+      'ElementContentEditable',
+      'HTMLOrSVGElement',
+      'ElementCSSInlineStyle',
+    ]);
+    assert.deepEqual(type.props.title, {
+      global: true,
+      specs: ['html'],
+    });
+    assert.strictEqual(type.states, undefined);
+  });
+
+  it('generates valid data for HTMLInputElement', () => {
+    const type = output.HTMLInputElement;
+    assert.strictEqual(type.inheritance, 'HTMLElement');
+    assert.deepEqual(type.includes, []);
+    assert.deepEqual(type.props.checked, {
+      global: false,
+      specs: ['html'],
+    });
+    assert.deepEqual(type.states['[type=checkbox]'], {
+      checked: {global: false, specs: ['html']},
+      required: {global: false, specs: ['html']},
+      value: {global: false, specs: ['html']},
+    });
+  });
+
+  it('generates valid data for MouseEvent', () => {
+    const type = output.MouseEvent;
+    assert.strictEqual(type.inheritance, 'UIEvent');
+    assert.deepEqual(type.includes, []);
+    assert.deepEqual(type.props.screenX, {
+      global: false,
+      specs: ['uievents'],
+    });
+    assert.strictEqual(type.states, undefined);
+  });
+
+  it('generates valid data for PointerEvent', () => {
+    const type = output.PointerEvent;
+    assert.strictEqual(type.inheritance, 'MouseEvent');
+    assert.deepEqual(type.includes, []);
+    assert.deepEqual(type.props.pressure, {
+      global: false,
+      specs: ['pointerevents'],
+    });
+    assert.strictEqual(type.states, undefined);
+  });
+
+  it('generates an entry for DOMParser', () => {
+    const type = output.DOMParser;
+    assert.strictEqual(type.inheritance, null);
+    assert.deepEqual(type.includes, []);
+    assert.deepEqual(type.props, {});
+    assert.strictEqual(type.states, undefined);
+  });
+
+  it('minimizes the data for HTMLInputElement', () => {
+    const minimized = minimize(output);
+    const type = minimized.HTMLInputElement;
+    assert.strictEqual(type.inheritance, 'HTMLElement');
+    assert.strictEqual(type.includes, undefined);
+    assert.deepEqual(type.props.checked, {});
+    assert.deepEqual(type.states['[type=checkbox]'], {
+      checked: {},
+      required: {},
+      value: {},
+    });
+  });
+
+  it('minimizes the data for PointerEvent', () => {
+    const minimized = minimize(output);
+    const type = minimized.PointerEvent;
+    assert.strictEqual(type.inheritance, 'MouseEvent');
+    assert.strictEqual(type.includes, undefined);
+    assert.deepEqual(type.props.pressure, {
+      specs: 8,
+    });
+    assert.strictEqual(type.states, undefined);
+  });
+
+  it('removes the entry for DOMParser in the minimized output', () => {
+    const minimized = minimize(output);
+    assert.strictEqual(minimized.DOMParser, undefined);
+  });
+});
diff --git a/scripts/webidl-properties/util.js b/scripts/webidl-properties/util.js
new file mode 100644
index 0000000..f4e94e9
--- /dev/null
+++ b/scripts/webidl-properties/util.js
@@ -0,0 +1,80 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * Merges objects or arrays of objects. This is a simplistic merge operation
+ * that is only useful for generating the DOM pinned properties dataset.
+ *
+ * The merge happens in-place: b is merged *into* a.
+ * Both objects must be of the same type.
+ * Arrays are merged as unions with simple same-value-zero equality.
+ * Objects are merged with truthy-property precedence.
+ *
+ * @param {array|object} a
+ * @param {array|object} b
+ */
+export function merge(a, b) {
+  if (Array.isArray(a) && Array.isArray(b)) {
+    mergeArrays(a, b);
+  } else if (isNonNullObject(a) && isNonNullObject(b)) {
+    mergeObjects(a, b);
+  } else {
+    throw Error;
+  }
+
+  function isNonNullObject(value) {
+    return typeof value === 'object' && value !== null;
+  }
+
+  function mergeArrays(a, b) {
+    for (const value of b) {
+      if (!a.includes(value)) {
+        a.push(value);
+      }
+    }
+  }
+
+  function mergeObjects(a, b) {
+    for (const key of Object.keys(b)) {
+      if (isNonNullObject(a[key]) && isNonNullObject(b[key])) {
+        merge(a[key], b[key]);
+      } else {
+        a[key] = a[key] ?? b[key];
+      }
+    }
+  }
+}
+
+/**
+ * Finds "missing" types in a DOM pinned properties dataset.
+ * A "missing" type is defined as a type that is inherited or included by/in
+ * another type, but for which a definition wasn't found in the specs.
+ *
+ * This is a helper which helps to ensure that all relevant specs are parsed.
+ * E.g. some specs might reference types defined in other specs.
+ *
+ * @param {object} data
+ * @returns {array}
+ */
+export function getMissingTypes(data) {
+  const missing = new Set();
+  const keys = new Set(Object.keys(data));
+
+  for (const value of Object.values(data)) {
+    if (value.inherits) {
+      if (!keys.has(value.inherits)) {
+        missing.add(value.inherits);
+      }
+    }
+    if (value.includes) {
+      for (const include of value.includes) {
+        if (!keys.has(include)) {
+          missing.add(include);
+        }
+      }
+    }
+  }
+
+  return [...missing];
+}