Add `build/gen.py --gen ~/chromium/src=out/Default`

This allows for:
* `ninja -C out gen` to rerun `gn gen` if gn has changed.
* `ninja -C out gen.json` to calculate the build graph generated by `gn gen`

Change-Id: I9805e438abbd332d3acd9b3b54f8c4c66a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/25602
Commit-Queue: Matt Stark <msta@google.com>
Reviewed-by: Takuto Ikuta <tikuta@google.com>
diff --git a/build/gen.py b/build/gen.py
index 5cb1015..80b1c0d 100755
--- a/build/gen.py
+++ b/build/gen.py
@@ -7,6 +7,7 @@
 
 import argparse
 import os
+import pathlib
 import platform
 import re
 import shlex
@@ -216,6 +217,12 @@
                           '`ninja -t compdb`.'))
   args_list.add('--starlark', action='store_true', default=False,
                     help='Enable (experimental) starlark integration')
+  args_list.add('--gen', default=None,
+                    metavar='SRC_DIR=OUT_DIR', dest='gen_target',
+                    help=('Generate ninja targets that invoke `gn gen` on an ' +
+                          'external repository.\n' +
+                          'Format: <src_dir>=<out_dir> ' +
+                          '(e.g. ~/chromium/src=out/Default)'))
 
   args_list.add_to_parser(parser)
   options = parser.parse_args(argv)
@@ -459,6 +466,16 @@
       ],
   )
 
+  if options.gen_target:
+    if '=' not in options.gen_target:
+      raise ValueError(f'Invalid --gen format: {repr(options.gen_target)}. Expected <src_dir>=<out_dir>')
+    gen_src_dir, gen_out_dir = options.gen_target.split('=', 1)
+    ninja.AddExternalGenTarget(
+      'gen',
+      pathlib.Path(os.path.expanduser(gen_src_dir)).resolve(),
+      pathlib.Path(gen_out_dir)
+    )
+
   with open(path, 'w') as f:
     f.write('\n'.join(ninja_header_lines))
     f.write(ninja_template)
diff --git a/build/ninja_file.py b/build/ninja_file.py
index 3f53289..d5b6a3d 100644
--- a/build/ninja_file.py
+++ b/build/ninja_file.py
@@ -21,7 +21,6 @@
   return f'"{path}"'
 
 
-
 @dataclasses.dataclass
 class Action:
   rule: 'DummyRule'
@@ -91,13 +90,6 @@
     self.actions = []
 
     self._gn_exe = pathlib.Path('gn' + self.platform.exe_suffix)
-    build_prefix = '' if self.platform.is_windows() else './'
-
-    def python(path, args):
-      return (
-          f'{escape_path_command(sys.executable)}'
-          f' {escape_path_command(self.source_file(path))} {args}'
-      )
 
     # Define standard/dummy rules (no rule block generated in build.ninja)
     self.Phony = DummyRule('phony', self)
@@ -108,7 +100,8 @@
         name='run_binary',
         ninja_file=self,
         command=self.chain(
-            f'$env {build_prefix}$in $args', python('tools/touch.py', '$out')
+            f'$env {self.build_prefix}$in $args',
+            self.python('tools/touch.py', '$out'),
         ),
         description='RUN BINARY $in',
     )
@@ -120,10 +113,10 @@
         command=self.chain(
             # For golden tests it's very important that if a ninja file is no
             # longer generated, it is actually deleted.
-            python('tools/clean.py', '$path/out'),
-            f'{build_prefix}{self._gn_exe} gen $path/out --quiet'
+            self.python('tools/clean.py', '$path/out'),
+            f'{self.build_prefix}{self._gn_exe} gen $path/out --quiet'
             ' --root=$path',
-            python('tools/touch.py', '$out'),
+            self.python('tools/touch.py', '$out'),
         ),
         description='RUN GN $out',
         inputs=[self._gn_exe],
@@ -134,8 +127,8 @@
         name='compare_goldens',
         ninja_file=self,
         command=self.chain(
-            python('tools/compare_goldens.py', '$path $goldens'),
-            python('tools/touch.py', '$out'),
+            self.python('tools/compare_goldens.py', '$path $goldens'),
+            self.python('tools/touch.py', '$out'),
         ),
         description='COMPARE $out',
         inputs=[compare_script],
@@ -146,11 +139,12 @@
     self.Cargo = Rule(
         name='cargo',
         ninja_file=self,
-        command=python(
+        command=self.python(
             run_cargo_rel_path,
             '$target_type $out $cargo_out_dir $cxx "$cxxflags"'
             ' cargo build --color=always'
-            ' --manifest-path=$manifest_path $cargo_target_dir $cargo_flags' + ('' if self.debug else ' --release'),
+            ' --manifest-path=$manifest_path $cargo_target_dir $cargo_flags'
+            + ('' if self.debug else ' --release'),
         ),
         description='CARGO build $out',
         inputs=[run_cargo_script],
@@ -173,7 +167,9 @@
   def rust_profile(self):
     return 'debug' if self.debug else 'release'
 
-  def CargoLibTarget(self, name, *, crate_dir, target_dir, cargo_flags='', **kwargs):
+  def CargoLibTarget(
+      self, name, *, crate_dir, target_dir, cargo_flags='', **kwargs
+  ):
     return self.Cargo(
         name,
         inputs=self.directory(crate_dir, ['target', 'testdata']),
@@ -186,7 +182,9 @@
         **kwargs,
     )
 
-  def CargoTestTarget(self, name, *, crate_dir, target_dir, cargo_flags='', **kwargs):
+  def CargoTestTarget(
+      self, name, *, crate_dir, target_dir, cargo_flags='', **kwargs
+  ):
     return self.Cargo(
         name,
         inputs=self.directory(crate_dir, ['target']),
@@ -229,6 +227,53 @@
         goldens=golden_path,
     )
 
+  @property
+  def build_prefix(self):
+    return '' if self.platform.is_windows() else './'
+
+  def python(self, path, args):
+    return (
+        f'{escape_path_command(sys.executable)}'
+        f' {escape_path_command(self.source_file(path))} {args}'
+    )
+
+  def AddExternalGenTarget(
+      self, name: str, src_dir: pathlib.Path, out_dir: pathlib.Path
+  ):
+    build = (
+        f'{self.build_prefix}{self._gn_exe} gen'
+        f' --root={escape_path_command(src_dir)}'
+        f' {escape_path_command(src_dir / out_dir)}'
+    )
+    run_gn_external = Rule(
+        name='run_gn_external',
+        ninja_file=self,
+        command=self.chain(
+            build,
+            self.python('tools/touch.py', '$out'),
+        ),
+        description=f'GEN {src_dir} -> {out_dir}',
+        inputs=[self._gn_exe],
+    )
+    gen_action = run_gn_external(name)
+
+    run_hyperfine = Rule(
+        name='hyperfine',
+        ninja_file=self,
+        command=self.chain(
+            (
+                f"hyperfine $${{HYPERFINE_OPTS:-}} '{build}"
+                f" --tracelog=${{out}}_$${{HYPERFINE_ITERATION}}.trace'"
+            ),
+            self.python('tools/touch.py', '$out'),
+        ),
+        description=f'BENCH {src_dir} -> {out_dir}',
+        inputs=[self._gn_exe],
+        # Allow hyperfine's spinning progress bars.
+        pool='console',
+    )
+    hyperfine_action = run_hyperfine(f'{name}_bench')
+
   def write_ninja(self):
     out = []
     for rule in self.rules: