New script to downgrade .mojom files from the new to the old mojo types

Following the discussion in chromium-mojo and platform-architecture-dev,
we're proposing to add this script to the chromium repository so that
external repositories that still depend on the old mojo types (e.g.
Android's and ChromeOS's libchrome repos) have a simple and maintained
way to downgrade Chromium's .mojom files to the old syntax, while they
can't support the new one.

This CL includes a new mojom_types_downgrader.py script that can be
integrates with those external builds requiring it, plus the necessary
unit tests to guarantee that the downgraded files can still be parsed
correctly by the bindings generator (i.e. mojom_bindings_generator.py).

For extra context, see the relevant discussion in the chromium-mojo ML:
https://groups.google.com/a/chromium.org/d/msg/chromium-mojo/BRK0Xeu7bgQ/z00hkpaGEQAJ

Bug: 1035484
Change-Id: I4c92345e82a188052b06efce9a1bc749d561cca1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2041759
Commit-Queue: Mario Sanchez Prada <mario@igalia.com>
Reviewed-by: Ken Rockot <rockot@google.com>
Cr-Commit-Position: refs/heads/master@{#745386}
diff --git a/mojo/public/cpp/bindings/tests/BUILD.gn b/mojo/public/cpp/bindings/tests/BUILD.gn
index 0c7d2d0..6fb6542 100644
--- a/mojo/public/cpp/bindings/tests/BUILD.gn
+++ b/mojo/public/cpp/bindings/tests/BUILD.gn
@@ -34,6 +34,7 @@
     "message_queue.h",
     "message_quota_checker_unittest.cc",
     "message_unittest.cc",
+    "mojom_types_downgrader_unittest.cc",
     "multiplex_router_unittest.cc",
     "native_struct_unittest.cc",
     "new_endpoint_types_unittest.cc",
@@ -61,6 +62,7 @@
   ]
 
   deps = [
+    ":downgraded_mojom",
     ":mojo_public_bindings_test_utils",
     ":test_extra_cpp_template_mojom",
     ":test_mojom",
@@ -209,3 +211,20 @@
   sources = [ "$target_gen_dir/generated.test-mojom" ]
   parser_deps = [ ":generate_test_mojom" ]
 }
+
+action("mojom_types_downgrader") {
+  script = "//mojo/public/tools/bindings/mojom_types_downgrader.py"
+  sources = [ "module_to_downgrade.test-mojom" ]
+  outputs = [ "$target_gen_dir/module_to_downgrade.test-mojom" ]
+  args = [
+    "--outdir",
+    rebase_path(target_gen_dir),
+    rebase_path("module_to_downgrade.test-mojom"),
+  ]
+}
+
+mojom("downgraded_mojom") {
+  testonly = true
+  sources = [ "$target_gen_dir/module_to_downgrade.test-mojom" ]
+  parser_deps = [ ":mojom_types_downgrader" ]
+}
diff --git a/mojo/public/cpp/bindings/tests/module_to_downgrade.test-mojom b/mojo/public/cpp/bindings/tests/module_to_downgrade.test-mojom
new file mode 100644
index 0000000..c63abbb
--- /dev/null
+++ b/mojo/public/cpp/bindings/tests/module_to_downgrade.test-mojom
@@ -0,0 +1,37 @@
+module mojo.test.mojom_types_downgrader_unittest.mojom;
+
+interface Foo {
+  DummyMethod();
+};
+
+interface DowngradedTestInterface {
+  // Old Mojo Types
+  InterfaceRequest(Foo& request);
+  InterfacePtr(Foo ptr);
+  AssociatedInterfacePtrInfo(associated Foo associated_ptr_info);
+  AssociatedInterfaceRequest(associated Foo& associated_request);
+
+  // New Mojo types
+  PendingReceiver(pending_receiver<Foo> receiver);
+  PendingRemote(pending_remote<Foo> remote);
+  PendingAssociatedReceiver(pending_associated_receiver<Foo> associated_remote);
+  PendingAssociatedRemote(pending_associated_remote<Foo> associated_receiver);
+
+  // Multiple parameters in both one line or across multiple lines
+  MultipleParams2(pending_remote<Foo> remote, pending_receiver<Foo> receiver);
+  MultipleParams3(pending_remote<Foo> remote, pending_receiver<Foo> receiver,
+                  pending_associated_remote<Foo> associated_remote);
+  MultipleParams4(pending_remote<Foo> remote, pending_receiver<Foo> receiver,
+                  pending_associated_remote<Foo> associated_remote,
+                  pending_associated_receiver<Foo> associated_receiver);
+
+  // Methods with a response callback defined
+  MethodWithResponseCallbackOneLine(pending_remote<Foo> data) => ();
+  MethodWithResponseCallbackTwoLines(pending_remote<Foo> data)
+      => (pending_receiver<Foo> receiver);
+
+  // Odd number of spaces and line breaks and
+  OddSpaces( pending_remote  < Foo  >  remote, pending_receiver<Foo > receiver);
+  OddSpacesAndLineBreak( pending_remote  < Foo  >  remote, pending_receiver<
+                                                             Foo > receiver);
+};
diff --git a/mojo/public/cpp/bindings/tests/mojom_types_downgrader_unittest.cc b/mojo/public/cpp/bindings/tests/mojom_types_downgrader_unittest.cc
new file mode 100644
index 0000000..b80921d
--- /dev/null
+++ b/mojo/public/cpp/bindings/tests/mojom_types_downgrader_unittest.cc
@@ -0,0 +1,105 @@
+// 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.
+
+#include "base/bind.h"
+#include "base/callback.h"
+#include "mojo/public/cpp/bindings/tests/module_to_downgrade.test-mojom.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace mojo {
+namespace test {
+namespace mojom_types_downgrader_unittest {
+
+class DowngradedTestInterfaceImpl : public mojom::DowngradedTestInterface {
+ public:
+  DowngradedTestInterfaceImpl() = default;
+  ~DowngradedTestInterfaceImpl() override = default;
+
+  void InterfaceRequest(mojom::FooRequest request) override {}
+  void InterfacePtr(mojom::FooPtr ptr) override {}
+  void AssociatedInterfacePtrInfo(
+      mojom::FooAssociatedPtrInfo associated_ptr_info) override {}
+  void AssociatedInterfaceRequest(
+      mojom::FooAssociatedRequest associated_request) override {}
+  void PendingReceiver(mojom::FooRequest receiver) override {}
+  void PendingRemote(mojom::FooPtr remote) override {}
+  void PendingAssociatedReceiver(
+      mojom::FooAssociatedRequest associated_remote) override {}
+  void PendingAssociatedRemote(
+      mojom::FooAssociatedPtrInfo associated_receiver) override {}
+  void MultipleParams2(mojom::FooPtr remote,
+                       mojom::FooRequest receiver) override {}
+  void MultipleParams3(mojom::FooPtr remote,
+                       mojom::FooRequest receiver,
+                       mojom::FooAssociatedPtrInfo associated_remote) override {
+  }
+  void MultipleParams4(
+      mojom::FooPtr remote,
+      mojom::FooRequest receiver,
+      mojom::FooAssociatedPtrInfo associated_remote,
+      mojom::FooAssociatedRequest associated_receiver) override {}
+  void MethodWithResponseCallbackOneLine(
+      mojom::FooPtr data,
+      MethodWithResponseCallbackOneLineCallback callback) override {}
+  void MethodWithResponseCallbackTwoLines(
+      mojom::FooPtr data,
+      MethodWithResponseCallbackTwoLinesCallback callback) override {}
+  void OddSpaces(mojom::FooPtr remote, mojom::FooRequest receiver) override {}
+  void OddSpacesAndLineBreak(mojom::FooPtr remote,
+                             mojom::FooRequest receiver) override {}
+};
+
+using DowngradedInterfaceTest = testing::Test;
+
+TEST_F(DowngradedInterfaceTest, DowngradedInterfaceCompiles) {
+  // Simply check that this test compiles (it will fail to compile if the
+  // mojom_types_downgrader.py script failed to produce the right output).
+  DowngradedTestInterfaceImpl test;
+
+  // Methods already declared with the old types in the mojom interface.
+  base::OnceCallback<void(mojom::FooRequest)> foo_request_callback =
+      base::BindOnce(&mojom_types_downgrader_unittest::mojom::
+                         DowngradedTestInterface::InterfaceRequest,
+                     base::Unretained(&test));
+  base::OnceCallback<void(mojom::FooPtr)> foo_ptr_callback =
+      base::BindOnce(&mojom_types_downgrader_unittest::mojom::
+                         DowngradedTestInterface::InterfacePtr,
+                     base::Unretained(&test));
+  base::OnceCallback<void(mojom::FooAssociatedPtrInfo)>
+      foo_associated_ptr_info_callback = base::BindOnce(
+          &mojom_types_downgrader_unittest::mojom::DowngradedTestInterface::
+              AssociatedInterfacePtrInfo,
+          base::Unretained(&test));
+  base::OnceCallback<void(mojom::FooAssociatedRequest)>
+      foo_associated_request_callback = base::BindOnce(
+          &mojom_types_downgrader_unittest::mojom::DowngradedTestInterface::
+              AssociatedInterfaceRequest,
+          base::Unretained(&test));
+
+  // Methods declared with the new types in the mojom interface. Note that these
+  // assignments won't compile unless the module_to_downgrade.test-mojom has
+  // been properly downgraded via the mojom_types_downgrader.py script.
+  base::OnceCallback<void(mojom::FooRequest)> foo_pending_receiver_callback =
+      base::BindOnce(&mojom_types_downgrader_unittest::mojom::
+                         DowngradedTestInterface::PendingReceiver,
+                     base::Unretained(&test));
+  base::OnceCallback<void(mojom::FooPtr)> foo_pending_remote_callback =
+      base::BindOnce(&mojom_types_downgrader_unittest::mojom::
+                         DowngradedTestInterface::PendingRemote,
+                     base::Unretained(&test));
+  base::OnceCallback<void(mojom::FooAssociatedRequest)>
+      foo_pending_associated_receiver_callback =
+          base::BindOnce(&mojom_types_downgrader_unittest::mojom::
+                             DowngradedTestInterface::PendingAssociatedReceiver,
+                         base::Unretained(&test));
+  base::OnceCallback<void(mojom::FooAssociatedPtrInfo)>
+      foo_pending_associated_remote_callback =
+          base::BindOnce(&mojom_types_downgrader_unittest::mojom::
+                             DowngradedTestInterface::PendingAssociatedRemote,
+                         base::Unretained(&test));
+}
+
+}  // namespace mojom_types_downgrader_unittest
+}  // namespace test
+}  // namespace mojo
diff --git a/mojo/public/tools/bindings/mojom_types_downgrader.py b/mojo/public/tools/bindings/mojom_types_downgrader.py
new file mode 100755
index 0000000..9c32d602
--- /dev/null
+++ b/mojo/public/tools/bindings/mojom_types_downgrader.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python
+# 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.
+"""Downgrades *.mojom files to the old mojo types for remotes and receivers."""
+
+import argparse
+import fnmatch
+import os
+import re
+import shutil
+import sys
+import tempfile
+
+# List of patterns and replacements to match and use against the contents of a
+# mojo file. Each replacement string will be used with Python string's format()
+# function, so the '{}' substring is used to mark where the mojo type should go.
+_MOJO_REPLACEMENTS = {
+    r'pending_remote': r'{}',
+    r'pending_receiver': r'{}&',
+    r'pending_associated_remote': r'associated {}',
+    r'pending_associated_receiver': r'associated {}&',
+}
+
+# Pre-compiled regular expression that matches against any of the replacements.
+_REGEXP_PATTERN = re.compile(
+    r'|'.join(
+        ['{}\s*<\s*(.*?)\s*>'.format(k) for k in _MOJO_REPLACEMENTS.keys()]),
+    flags=re.DOTALL)
+
+
+def ReplaceFunction(match_object):
+  """Returns the right replacement for the string matched against the regexp."""
+  for index, (match, repl) in enumerate(_MOJO_REPLACEMENTS.items(), 1):
+    if match_object.group(0).startswith(match):
+      return repl.format(match_object.group(index))
+
+
+def DowngradeFile(path, output_dir=None):
+  """Downgrades the mojom file specified by |path| to the old mojo types.
+
+  Optionally pass |output_dir| to place the result under a separate output
+  directory, preserving the relative path to the file included in |path|.
+  """
+  # Use a temporary file to dump the new contents after replacing the patterns.
+  with open(path) as src_mojo_file:
+    with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp_mojo_file:
+      tmp_contents = _REGEXP_PATTERN.sub(ReplaceFunction, src_mojo_file.read())
+      tmp_mojo_file.write(tmp_contents)
+
+  # Files should be placed in the desired output directory
+  if output_dir:
+    output_filepath = os.path.join(output_dir, os.path.basename(path))
+    if not os.path.exists(output_dir):
+      os.makedirs(output_dir)
+  else:
+    output_filepath = path
+
+  # Write the new contents preserving the original file's attributes.
+  shutil.copystat(path, tmp_mojo_file.name)
+  shutil.move(tmp_mojo_file.name, output_filepath)
+
+
+def DowngradeDirectory(path, output_dir=None):
+  """Downgrades mojom files inside directory |path| to the old mojo types.
+
+  Optionally pass |output_dir| to place the result under a separate output
+  directory, preserving the relative path to the file included in |path|.
+  """
+  # We don't have recursive glob.glob() nor pathlib.Path.rglob() in Python 2.7
+  mojom_filepaths = []
+  for dir_path, _, filenames in os.walk(path):
+    for filename in fnmatch.filter(filenames, "*mojom"):
+      mojom_filepaths.append(os.path.join(dir_path, filename))
+
+  for path in mojom_filepaths:
+    absolute_dirpath = os.path.dirname(os.path.abspath(path))
+    if output_dir:
+      dest_dirpath = output_dir + absolute_dirpath
+    else:
+      dest_dirpath = absolute_dirpath
+    DowngradeFile(path, dest_dirpath)
+
+
+def DowngradePath(src_path, output_dir=None):
+  """Downgrades the mojom files pointed by |src_path| to the old mojo types.
+
+  Optionally pass |output_dir| to place the result under a separate output
+  directory, preserving the relative path to the file included in |path|.
+  """
+  if os.path.isdir(src_path):
+    DowngradeDirectory(src_path, output_dir)
+  elif os.path.isfile(src_path):
+    DowngradeFile(src_path, output_dir)
+  else:
+    print(">>> {} not pointing to a valid file or directory".format(src_path))
+    sys.exit(1)
+
+
+def main():
+  parser = argparse.ArgumentParser(
+      description="Downgrade *.mojom files to use the old mojo types.")
+  parser.add_argument(
+      "srcpath", help="path to the file or directory to apply the conversion")
+  parser.add_argument(
+      "--outdir", help="the directory to place the converted file(s) under")
+  args = parser.parse_args()
+
+  DowngradePath(args.srcpath, args.outdir)
+
+
+if __name__ == "__main__":
+  sys.exit(main())