Update javascript_natives/index.js script

This change also includes a manual run of the script to update
NativeFunctions.js.

Change-Id: I9ce1c4979674d9513cb7259e3e311e30b13de9ed
Bug: 1255619
Doc: https://goo.gle/devtools-js-argument-hints
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3399837
Reviewed-by: Mathias Bynens <mathias@chromium.org>
Commit-Queue: Johan Bay <jobay@chromium.org>
diff --git a/scripts/javascript_natives/helpers.js b/scripts/javascript_natives/helpers.js
new file mode 100644
index 0000000..ac0b71e
--- /dev/null
+++ b/scripts/javascript_natives/helpers.js
@@ -0,0 +1,211 @@
+// Copyright 2021 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.
+
+// @ts-check
+
+import * as fs from 'fs';
+import * as path from 'path';
+import {fileURLToPath} from 'url';
+
+/** @type {Map<string, Map<string, string[][]>>} */
+const methods = new Map();
+
+export function clearState() {
+  methods.clear();
+}
+
+export function parseTSFunction(func, node) {
+  if (!func.name.escapedText) {
+    return;
+  }
+
+  const args = func.parameters
+                   .map(p => {
+                     let text = p.name.escapedText;
+                     if (p.questionToken) {
+                       text = '?' + text;
+                     }
+                     if (p.dotDotDotToken) {
+                       text = '...' + text;
+                     }
+                     return text;
+                   })
+                   .filter(x => x !== 'this');
+  storeMethod(node.name.text, func.name.escapedText, args);
+}
+
+/**
+ * @param {WebIDL2.IDLRootType} thing
+ * */
+export function walkRoot(thing) {
+  switch (thing.type) {
+    case 'interface':
+      walkInterface(thing);
+      break;
+    case 'interface mixin':
+    case 'namespace':
+      walkMembers(thing);
+      break;
+  }
+}
+
+/**
+ * @param {WebIDL2.InterfaceType} thing
+ * */
+function walkInterface(thing) {
+  thing.members.forEach(member => {
+    switch (member.type) {
+      case 'constructor':
+        storeMethod('Window', thing.name, member.arguments.map(argName));
+        break;
+      case 'operation':
+        handleOperation(member);
+    }
+  });
+  const namedConstructor = thing.extAttrs.find(extAttr => extAttr.name === 'NamedConstructor');
+  if (namedConstructor && namedConstructor.arguments) {
+    storeMethod('Window', namedConstructor.rhs.value, namedConstructor.arguments.map(argName));
+  }
+}
+
+/**
+ * @param {WebIDL2.NamespaceType | WebIDL2.InterfaceMixinType} thing
+ * */
+function walkMembers(thing) {
+  thing.members.forEach(member => {
+    if (member.type === 'operation') {
+      handleOperation(member);
+    }
+  });
+}
+
+/**
+ * @param {WebIDL2.OperationMemberType} member
+ * */
+function handleOperation(member) {
+  storeMethod(
+      member.special === 'static' ? (parent.name + 'Constructor') : member.parent.name, member.name,
+      member.arguments.map(argName));
+}
+
+/**
+ * @param {WebIDL2.Argument} a
+ * */
+function argName(a) {
+  let name = a.name;
+  if (a.optional) {
+    name = '?' + name;
+  }
+  if (a.variadic) {
+    name = '...' + name;
+  }
+  return name;
+}
+
+/**
+ * @param {string} parent
+ * @param {string} name
+ * @param {Array<string>} args
+ * */
+function storeMethod(parent, name, args) {
+  if (!methods.has(name)) {
+    methods.set(name, new Map());
+  }
+  const method = methods.get(name);
+  if (!method.has(parent)) {
+    method.set(parent, []);
+  }
+  method.get(parent).push(args);
+}
+
+export function postProcess(dryRun = false) {
+  for (const name of methods.keys()) {
+    // We use the set jsonParents to track the set of different signatures across parent for this function name.
+    // If all signatures are identical, we leave out the parent and emit a single NativeFunction entry without receiver.
+    const jsonParents = new Set();
+    for (const [parent, signatures] of methods.get(name)) {
+      signatures.sort((a, b) => a.length - b.length);
+      const filteredSignatures = [];
+      for (const signature of signatures) {
+        const smallerIndex = filteredSignatures.findIndex(smaller => startsTheSame(smaller, signature));
+        if (smallerIndex !== -1) {
+          filteredSignatures[smallerIndex] = (signature.map((arg, index) => {
+            const otherArg = filteredSignatures[smallerIndex][index];
+            if (otherArg) {
+              return otherArg.length > arg.length ? otherArg : arg;
+            }
+            if (arg.startsWith('?') || arg.startsWith('...')) {
+              return arg;
+            }
+            return '?' + arg;
+          }));
+        } else {
+          filteredSignatures.push(signature);
+        }
+      }
+
+      function startsTheSame(smaller, bigger) {
+        for (let i = 0; i < smaller.length; i++) {
+          const withoutQuestion = str => /[\?]?(.*)/.exec(str)[1];
+          if (withoutQuestion(smaller[i]) !== withoutQuestion(bigger[i])) {
+            return false;
+          }
+        }
+        return true;
+      }
+
+      methods.get(name).set(parent, filteredSignatures);
+      jsonParents.add(JSON.stringify(filteredSignatures));
+    }
+    // If all parents had the same signature for this name, we put a `*` as parent for this entry.
+    if (jsonParents.size === 1) {
+      methods.set(name, new Map([['*', JSON.parse(jsonParents.values().next().value)]]));
+    }
+    for (const [parent, signatures] of methods.get(name)) {
+      if (signatures.length === 1 && !signatures[0].length) {
+        methods.get(name).delete(parent);
+      }
+    }
+    if (methods.get(name).size === 0) {
+      methods.delete(name);
+    }
+  }
+  const functions = [];
+  for (const [name, method] of methods) {
+    if (method.has('*')) {
+      // All parents had the same signature so we emit an entry without receiver.
+      functions.push({name, signatures: method.get('*')});
+    } else {
+      for (const [parent, signatures] of method) {
+        if (parent.endsWith('Constructor')) {
+          functions.push(
+              {name, signatures, static: true, receiver: parent.substring(0, parent.length - 'Constructor'.length)});
+        } else {
+          functions.push({name, signatures, receiver: parent});
+        }
+      }
+    }
+  }
+  const output = `export const NativeFunctions = [\n${
+      functions
+          .map(
+              entry =>
+                  `  {\n${Object.entries(entry).map(kv => `    ${kv[0]}: ${JSON.stringify(kv[1])}`).join(',\n')}\n  }`)
+          .join(',\n')}\n];`;
+
+  if (dryRun) {
+    return output;
+  }
+
+  fs.writeFileSync(
+      (new URL('../../front_end/models/javascript_metadata/NativeFunctions.js', import.meta.url)).pathname,
+      `// 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.
+// Generated from ${
+          path.relative(path.join(fileURLToPath(import.meta.url), '..', '..'), fileURLToPath(import.meta.url))}
+
+${output}
+`);
+}
diff --git a/scripts/javascript_natives/index.js b/scripts/javascript_natives/index.js
index 4bbfd9a..26dc00e 100644
--- a/scripts/javascript_natives/index.js
+++ b/scripts/javascript_natives/index.js
@@ -2,19 +2,20 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-const WebIDL2 = require('webidl2');
-const fs = require('fs');
-const path = require('path');
-const ts = require('typescript');
-const glob = require('glob');
-const methods = {
-  __proto__: null
-};
+import * as fs from 'fs';
+import glob from 'glob';
+import * as path from 'path';
+import ts from 'typescript';
+import * as WebIDL2 from 'webidl2';
+
+import {parseTSFunction, postProcess, walkRoot} from './helpers.js';
+
 const program = ts.createProgram(
     [
-      path.join(__dirname, 'node_modules', 'typescript', 'lib', 'lib.esnext.d.ts'),
+      new URL('node_modules/typescript/lib/lib.esnext.d.ts', import.meta.url).pathname,
     ],
-    {noLib: true});
+    {noLib: false, types: []});
+
 for (const file of program.getSourceFiles()) {
   ts.forEachChild(file, node => {
     if (node.kind === ts.SyntaxKind.InterfaceDeclaration) {
@@ -31,201 +32,63 @@
   });
 }
 
-function parseTSFunction(func, node) {
-  if (!func.name.escapedText) {
-    return;
-  }
-
-  const args = func.parameters
-                   .map(p => {
-                     let text = p.name.escapedText;
-                     if (p.questionToken) {
-                       text = '?' + text;
-                     }
-                     if (p.dotDotDotToken) {
-                       text = '...' + text;
-                     }
-                     return text;
-                   })
-                   .filter(x => x !== 'this');
-  storeMethod(node.name.text, func.name.escapedText, args);
-}
-
 // Assume the DevTools front-end repository is at
 // `devtools/devtools-frontend`, where `devtools` is on the same level
 // as `chromium`. This matches `scripts/npm_test.js`.
-glob(
-    '../../../../chromium/src/third_party/blink/renderer/+(core|modules)/**/*.idl', {cwd: process.env.PWD},
-    function(er, files) {
-      for (const file of files) {
-        if (file.includes('testing')) {
-          continue;
-        }
-        const data = fs.readFileSync(path.join(process.env.PWD, file), 'utf8');
-        const lines = data.split('\n');
-        const newLines = [];
-        for (const line of lines) {
-          if (!line.includes(' attribute ')) {
-            newLines.push(line);
-          }
-        }
+const files =
+    glob.sync('../../../../chromium/src/third_party/blink/renderer/+(core|modules)/**/*.idl', {cwd: process.env.PWD});
 
-        try {
-          WebIDL2.parse(newLines.join('\n')).forEach(walk);
-        } catch (e) {
-          // console.error(file);
-        }
-      }
-      WebIDL2
-          .parse(`
-  namespace console {
-    void assert(optional boolean condition = false, any... data);
-    void clear();
-    void count(optional DOMString label = "default");
-    void debug(any... data);
-    void dir(any item, optional object? options);
-    void dirxml(any... data);
-    void error(any... data);
-    void group(any... data);
-    void groupCollapsed(any... data);
-    void groupEnd();
-    void info(any... data);
-    void log(any... data);
-    void profile(optional DOMString title);
-    void profileEnd(optional DOMString title);
-    void table(any... tabularData);
-    void time(optional DOMString label);
-    void timeEnd(optional DOMString label);
-    void timeStamp(optional DOMString name);
-    void trace(any... data);
-    void warn(any... data);
-  };
-`).forEach(walk);
-      postProcess();
-    });
-
-function walk(thing, parent) {
-  if (thing.type === 'interface') {
-    const constructor = thing.extAttrs.find(extAttr => extAttr.name === 'Constructor');
-    if (constructor && constructor.arguments && thing.extAttrs.find(extAttr => extAttr.name === 'Exposed')) {
-      storeMethod('Window', thing.name, constructor.arguments.map(argName));
-    }
-
-    const namedConstructor = thing.extAttrs.find(extAttr => extAttr.name === 'NamedConstructor');
-    if (namedConstructor && namedConstructor.arguments) {
-      storeMethod('Window', namedConstructor.rhs.value, namedConstructor.arguments.map(argName));
+for (const file of files) {
+  if (file.includes('testing')) {
+    continue;
+  }
+  const data = fs.readFileSync(path.join(process.env.PWD, file), 'utf8');
+  const lines = data.split('\n');
+  const newLines = [];
+  for (const line of lines) {
+    if (!line.includes(' attribute ')) {
+      newLines.push(line);
     }
   }
-  if (thing.type.includes('operation')) {
-    storeMethod(thing.static ? (parent.name + 'Constructor') : parent.name, thing.name, thing.arguments.map(argName));
-    return;
+
+  try {
+    WebIDL2.parse(newLines.join('\n')).forEach(walkRoot);
+  } catch (e) {
+    // console.error(file);
   }
-  if (thing.members) {
-    for (const member of thing.members) {
-      walk(member, thing);
-    }
-  }
+
+  // Source for Console spec: https://console.spec.whatwg.org/#idl-index
+  WebIDL2
+      .parse(`
+[Exposed=(Window,Worker,Worklet)]
+namespace console { // but see namespace object requirements below
+  // Logging
+  undefined assert(optional boolean condition = false, any... data);
+  undefined clear();
+  undefined debug(any... data);
+  undefined error(any... data);
+  undefined info(any... data);
+  undefined log(any... data);
+  undefined table(optional any tabularData, optional sequence<DOMString> properties);
+  undefined trace(any... data);
+  undefined warn(any... data);
+  undefined dir(optional any item, optional object? options);
+  undefined dirxml(any... data);
+
+  // Counting
+  undefined count(optional DOMString label = "default");
+  undefined countReset(optional DOMString label = "default");
+
+  // Grouping
+  undefined group(any... data);
+  undefined groupCollapsed(any... data);
+  undefined groupEnd();
+
+  // Timing
+  undefined time(optional DOMString label = "default");
+  undefined timeLog(optional DOMString label = "default", any... data);
+  undefined timeEnd(optional DOMString label = "default");
+};
+`).forEach(walkRoot);
 }
-
-function argName(a) {
-  let name = a.name;
-  if (a.optional) {
-    name = '?' + name;
-  }
-  if (a.variadic) {
-    name = '...' + name;
-  }
-  return name;
-}
-
-function storeMethod(parent, name, args) {
-  if (!methods[name]) {
-    methods[name] = {__proto__: null};
-  }
-  if (!methods[name][parent]) {
-    methods[name][parent] = [];
-  }
-  methods[name][parent].push(args);
-}
-
-function postProcess() {
-  for (const name in methods) {
-    const jsonParents = new Set();
-    for (const parent in methods[name]) {
-      const signatures = methods[name][parent];
-      signatures.sort((a, b) => a.length - b.length);
-      const filteredSignatures = [];
-      for (const signature of signatures) {
-        const smallerIndex = filteredSignatures.findIndex(smaller => startsThesame(smaller, signature));
-        if (smallerIndex !== -1) {
-          filteredSignatures[smallerIndex] = (signature.map((arg, index) => {
-            const otherArg = filteredSignatures[smallerIndex][index];
-            if (otherArg) {
-              return otherArg.length > arg.length ? otherArg : arg;
-            }
-            if (arg.startsWith('?') || arg.startsWith('...')) {
-              return arg;
-            }
-            return '?' + arg;
-          }));
-        } else {
-          filteredSignatures.push(signature);
-        }
-      }
-
-      function startsThesame(smaller, bigger) {
-        for (let i = 0; i < smaller.length; i++) {
-          const withoutQuestion = str => /[\?]?(.*)/.exec(str)[1];
-          if (withoutQuestion(smaller[i]) !== withoutQuestion(bigger[i])) {
-            return false;
-          }
-        }
-        return true;
-      }
-
-      methods[name][parent] = filteredSignatures;
-      jsonParents.add(JSON.stringify(filteredSignatures));
-    }
-    if (jsonParents.size === 1) {
-      methods[name] = {'*': JSON.parse(jsonParents.values().next().value)};
-    }
-    for (const parent in methods[name]) {
-      const signatures = methods[name][parent];
-      if (signatures.length === 1 && !signatures[0].length) {
-        delete methods[name][parent];
-      }
-    }
-    if (!Object.keys(methods[name]).length) {
-      delete methods[name];
-    }
-  }
-  const functions = [];
-  for (const name in methods) {
-    if (methods[name]['*']) {
-      functions.push({name, signatures: methods[name]['*']});
-    } else {
-      for (const parent in methods[name]) {
-        if (parent.endsWith('Constructor')) {
-          functions.push({
-            name,
-            signatures: methods[name][parent],
-            static: true,
-            receiver: parent.substring(0, parent.length - 'Constructor'.length)
-          });
-        } else {
-          functions.push({name, signatures: methods[name][parent], receiver: parent});
-        }
-      }
-    }
-  }
-
-  fs.writeFileSync(
-      path.join(__dirname, '..', '..', 'front_end', 'models', 'javascript_metadata', 'NativeFunctions.js'),
-      `// 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.
-// Generated from ${path.relative(path.join(__dirname, '..', '..'), __filename)}
-
-export const NativeFunctions = ${JSON.stringify(functions, null, 2)};
-`);
-}
+postProcess();
diff --git a/scripts/javascript_natives/package.json b/scripts/javascript_natives/package.json
index 4e9e907..86c8679 100644
--- a/scripts/javascript_natives/package.json
+++ b/scripts/javascript_natives/package.json
@@ -1,11 +1,16 @@
 {
+  "private": true,
   "main": "index.js",
   "scripts": {
-    "start": "node index.js"
+    "start": "node index.js",
+    "test": "mocha tests.js"
   },
+  "type": "module",
   "dependencies": {
+    "@types/webidl2": "^23.13.6",
     "glob": "^7.1.2",
-    "typescript": "^2.8.3",
-    "webidl2": "^10.3.3"
+    "mocha": "^9.1.4",
+    "typescript": "latest",
+    "webidl2": "^24.2.0"
   }
 }
diff --git a/scripts/javascript_natives/test.d.ts b/scripts/javascript_natives/test.d.ts
new file mode 100644
index 0000000..8caa11e
--- /dev/null
+++ b/scripts/javascript_natives/test.d.ts
@@ -0,0 +1,9 @@
+interface Array<T> {
+  at(index: number): T|undefined;
+  diffSig(oneSig: number): T|undefined;
+}
+
+interface ReadonlyArray<T> {
+  at(index: number): T|undefined;
+  diffSig(twoSig: number): T|undefined;
+}
diff --git a/scripts/javascript_natives/tests.js b/scripts/javascript_natives/tests.js
new file mode 100644
index 0000000..84e3499
--- /dev/null
+++ b/scripts/javascript_natives/tests.js
@@ -0,0 +1,195 @@
+// 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 * as assert from 'assert';
+import ts from 'typescript';
+import * as WebIDL2 from 'webidl2';
+
+import {clearState, parseTSFunction, postProcess, walkRoot} from './helpers.js';
+
+describe('NativeFunction signature generation', function() {
+  this.afterEach(() => {
+    clearState();
+  });
+
+  it('should produce correct signatures for IDL interface', function() {
+    WebIDL2
+        .parse(`
+[
+    Exposed=Window
+] interface Document : Node {
+    [CallWith=Document] constructor();
+    [Affects=Nothing] HTMLCollection getElementsByTagName(DOMString localName);
+    [Affects=Nothing] HTMLCollection getElementsByTagNameNS(DOMString? namespaceURI, DOMString localName);
+    [Affects=Nothing] HTMLCollection getElementsByClassName(DOMString classNames);
+
+    [NewObject, DoNotTestNewObject, PerWorldBindings, RaisesException, ImplementedAs=CreateElementForBinding] Element createElement(DOMString localName);
+    [NewObject, DoNotTestNewObject, RaisesException] Element createElementNS(DOMString? namespaceURI, DOMString qualifiedName);
+    [NewObject] DocumentFragment createDocumentFragment();
+    [NewObject] Text createTextNode(DOMString data);
+};
+`).forEach(walkRoot);
+    const output = postProcess(/* dryRun: */ true);
+    const expected = `export const NativeFunctions = [
+  {
+    name: "getElementsByTagName",
+    signatures: [["localName"]]
+  },
+  {
+    name: "getElementsByTagNameNS",
+    signatures: [["namespaceURI","localName"]]
+  },
+  {
+    name: "getElementsByClassName",
+    signatures: [["classNames"]]
+  },
+  {
+    name: "createElement",
+    signatures: [["localName"]]
+  },
+  {
+    name: "createElementNS",
+    signatures: [["namespaceURI","qualifiedName"]]
+  },
+  {
+    name: "createTextNode",
+    signatures: [["data"]]
+  }
+];`;
+    assert.equal(output, expected);
+  });
+
+  it('should produce correct signatures for IDL mixin interface', function() {
+    WebIDL2
+        .parse(`[
+    LegacyTreatAsPartialInterface,
+    Exposed=(Window,Worker)
+] interface mixin WindowOrWorkerGlobalScope {
+    [CallWith=ScriptState] void reportError(any e);
+
+    [RaisesException] DOMString atob(DOMString atob);
+    [CallWith=ScriptState, RuntimeCallStatsCounter=WindowSetTimeout] long setTimeout(Function handler, optional long timeout = 0, any... arguments);
+    [CallWith=ScriptState] long setTimeout(ScriptString handler, optional long timeout = 0, any... arguments);
+};
+`).forEach(walkRoot);
+    const output = postProcess(/* dryRun: */ true);
+    const expected = `export const NativeFunctions = [
+  {
+    name: "reportError",
+    signatures: [["e"]]
+  },
+  {
+    name: "atob",
+    signatures: [["atob"]]
+  },
+  {
+    name: "setTimeout",
+    signatures: [["handler","?timeout","...arguments"]]
+  }
+];`;
+    assert.equal(output, expected);
+  });
+
+  it('should produce correct signatures for Console IDL', function() {
+    WebIDL2
+        .parse(`
+[Exposed=(Window,Worker,Worklet)]
+namespace console {
+  undefined assert(optional boolean condition = false, any... data);
+  undefined table(optional any tabularData, optional sequence<DOMString> properties);
+  undefined count(optional DOMString label = "default");
+  undefined groupEnd();
+};
+`).forEach(walkRoot);
+    const output = postProcess(/* dryRun: */ true);
+    const expected = `export const NativeFunctions = [
+  {
+    name: "assert",
+    signatures: [["?condition","...data"]]
+  },
+  {
+    name: "table",
+    signatures: [["?tabularData","?properties"]]
+  },
+  {
+    name: "count",
+    signatures: [["?label"]]
+  }
+];`;
+    assert.equal(output, expected);
+  });
+
+  it('should produce correct signatures for Console IDL', function() {
+    WebIDL2
+        .parse(`
+// https://html.spec.whatwg.org/C/#the-slot-element
+[
+    Exposed=Window,
+    HTMLConstructor
+] interface HTMLSlotElement : HTMLElement {
+    [CEReactions, Reflect] attribute DOMString name;
+    [ImplementedAs=AssignedNodesForBinding] sequence<Node> assignedNodes(optional AssignedNodesOptions options = {});
+    [ImplementedAs=AssignedElementsForBinding] sequence<Element> assignedElements(optional AssignedNodesOptions options = {});
+    [RaisesException] void assign((Element or Text)... nodes);
+};
+`).forEach(walkRoot);
+    const output = postProcess(/* dryRun: */ true);
+    const expected = `export const NativeFunctions = [
+  {
+    name: "assignedNodes",
+    signatures: [["?options"]]
+  },
+  {
+    name: "assignedElements",
+    signatures: [["?options"]]
+  },
+  {
+    name: "assign",
+    signatures: [["...nodes"]]
+  }
+];`;
+    assert.equal(output, expected);
+  });
+
+  it('should produce correct signatures for typescript typings', function() {
+    const program = ts.createProgram(
+        [
+          new URL('test.d.ts', import.meta.url).pathname,
+        ],
+        {noLib: true, types: []});
+
+    for (const file of program.getSourceFiles()) {
+      ts.forEachChild(file, node => {
+        if (node.kind === ts.SyntaxKind.InterfaceDeclaration) {
+          for (const member of node.members) {
+            if (member.kind === ts.SyntaxKind.MethodSignature) {
+              parseTSFunction(member, node);
+            }
+          }
+        }
+        if (node.kind === ts.SyntaxKind.FunctionDeclaration) {
+          parseTSFunction(node, {name: {text: 'Window'}});
+        }
+      });
+    }
+    const output = postProcess(/* dryRun: */ true);
+    const expected = `export const NativeFunctions = [
+  {
+    name: "at",
+    signatures: [["index"]]
+  },
+  {
+    name: "diffSig",
+    signatures: [["oneSig"]],
+    receiver: "Array"
+  },
+  {
+    name: "diffSig",
+    signatures: [["twoSig"]],
+    receiver: "ReadonlyArray"
+  }
+];`;
+    assert.equal(output, expected);
+  });
+});