xref: /aosp_15_r20/external/angle/build/gn_ast/gn_editor.py (revision 8975f5c5ed3d1c378011245431ada316dfb6f244)
1#!/usr/bin/env python3
2# Copyright 2022 The Chromium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7import dataclasses
8import functools
9import json
10import logging
11import multiprocessing
12import os
13import pathlib
14import subprocess
15import sys
16from typing import List, Optional, Set
17
18import json_gn_editor
19import utils
20
21_SRC_PATH = pathlib.Path(__file__).resolve().parents[2]
22
23_BUILD_ANDROID_PATH = _SRC_PATH / 'build/android'
24if str(_BUILD_ANDROID_PATH) not in sys.path:
25    sys.path.append(str(_BUILD_ANDROID_PATH))
26from pylib import constants
27
28_BUILD_ANDROID_GYP_PATH = _SRC_PATH / 'build/android/gyp'
29if str(_BUILD_ANDROID_GYP_PATH) not in sys.path:
30    sys.path.append(str(_BUILD_ANDROID_GYP_PATH))
31
32from util import build_utils
33
34_GIT_IGNORE_STR = '(git ignored file) '
35
36NO_VALID_GN_STR = 'No valid GN files found after filtering.'
37
38
39@dataclasses.dataclass
40class OperationResult:
41    path: str
42    git_ignored: bool = False
43    dryrun: bool = False
44    skipped: bool = False
45    skip_reason: str = ''
46
47    def __str__(self):
48        msg = f'Skipped ' if self.skipped else 'Updated '
49        dryrun = '[DRYRUN] ' if self.dryrun else ''
50        ignore = _GIT_IGNORE_STR if self.git_ignored else ''
51        skip = f' ({self.skip_reason})' if self.skipped else ''
52        return f'{dryrun}{msg}{ignore}{self.path}{skip}'
53
54
55def _add_deps(target: str, deps: List[str], root: pathlib.Path, path: str):
56    with json_gn_editor.BuildFile(path, root) as build_file:
57        build_file.add_deps(target, deps)
58
59
60def _search_deps(name_query: Optional[str], path_query: Optional[str],
61                 root: pathlib.Path, path: str):
62    with json_gn_editor.BuildFile(path, root) as build_file:
63        build_file.search_deps(name_query, path_query)
64
65
66def _split_deps(existing_dep: str, new_deps: List[str], root: pathlib.Path,
67                path: str, dryrun: bool) -> Optional[OperationResult]:
68    with json_gn_editor.BuildFile(path, root, dryrun=dryrun) as build_file:
69        if build_file.split_deps(existing_dep, new_deps):
70            return OperationResult(path=os.path.relpath(path, start=root),
71                                   git_ignored=utils.is_git_ignored(
72                                       root, path),
73                                   dryrun=dryrun)
74    return None
75
76
77def _remove_deps(
78        *, deps: List[str], out_dir: str, root: pathlib.Path, path: str,
79        dryrun: bool, targets: List[str], inline_mode: bool,
80        target_name_filter: Optional[str]) -> Optional[OperationResult]:
81    with json_gn_editor.BuildFile(path, root, dryrun=dryrun) as build_file:
82        if build_file.remove_deps(deps, out_dir, targets, target_name_filter,
83                                  inline_mode):
84            return OperationResult(path=os.path.relpath(path, start=root),
85                                   git_ignored=utils.is_git_ignored(
86                                       root, path),
87                                   dryrun=dryrun)
88    return None
89
90
91def _add(args: argparse.Namespace, build_filepaths: List[str],
92         root: pathlib.Path):
93    deps = args.deps
94    target = args.target
95    with multiprocessing.Pool() as pool:
96        pool.map(
97            functools.partial(_add_deps, target, deps, root),
98            build_filepaths,
99        )
100
101
102def _search(args: argparse.Namespace, build_filepaths: List[str],
103            root: pathlib.Path):
104    name_query = args.name
105    path_query = args.path
106    if name_query:
107        logging.info(f'Searching dep names using: {name_query}')
108    if path_query:
109        logging.info(f'Searching paths using: {path_query}')
110    with multiprocessing.Pool() as pool:
111        pool.map(
112            functools.partial(_search_deps, name_query, path_query, root),
113            build_filepaths,
114        )
115
116
117def _split(args: argparse.Namespace, build_filepaths: List[str],
118           root: pathlib.Path) -> List[OperationResult]:
119    num_total = len(build_filepaths)
120    results = []
121    with multiprocessing.Pool() as pool:
122        tasks = {
123            filepath: pool.apply_async(
124                _split_deps,
125                (args.existing, args.new, root, filepath, args.dryrun))
126            for filepath in build_filepaths
127        }
128        for idx, filepath in enumerate(tasks.keys()):
129            relpath = os.path.relpath(filepath, start=root)
130            logging.info('[%d/%d] Checking %s', idx, num_total, relpath)
131            operation_result = tasks[filepath].get()
132            if operation_result:
133                logging.info(operation_result)
134                results.append(operation_result)
135    return results
136
137
138def _get_project_json_contents(out_dir: str) -> str:
139    project_json_path = os.path.join(out_dir, 'project.json')
140    with open(project_json_path) as f:
141        return f.read()
142
143
144def _calculate_targets_for_file(relpath: str, arg_extra_targets: List[str],
145                                all_targets: Set[str]) -> Optional[List[str]]:
146    if os.path.basename(relpath) != 'BUILD.gn':
147        # Build all targets when we are dealing with build files that might be
148        # imported by other build files (e.g. config.gni or other_name.gn).
149        return []
150    dirpath = os.path.dirname(relpath)
151    file_extra_targets = []
152    for full_target_name in all_targets:
153        target_dir, short_target_name = full_target_name.split(':', 1)
154        # __ is used for sub-targets in GN, only focus on top-level ones. Also
155        # skip targets using other toolchains, e.g.
156        # base:feature_list_buildflags(//build/toolchain/linux:clang_x64)
157        if (target_dir == dirpath and '__' not in short_target_name
158                and '(' not in short_target_name):
159            file_extra_targets.append(full_target_name)
160    targets = arg_extra_targets + file_extra_targets
161    return targets or None
162
163
164def _remove(args: argparse.Namespace, build_filepaths: List[str],
165            root: pathlib.Path) -> List[OperationResult]:
166    num_total = len(build_filepaths)
167
168    if args.output_directory:
169        constants.SetOutputDirectory(args.output_directory)
170    constants.CheckOutputDirectory()
171    out_dir: str = constants.GetOutDirectory()
172
173    args_gn_path = os.path.join(out_dir, 'args.gn')
174    if not os.path.exists(args_gn_path):
175        raise Exception(f'No args.gn in out directory {out_dir}')
176    with open(args_gn_path) as f:
177        # Although the target may compile fine, bytecode checks are necessary
178        # for correctness at runtime.
179        assert 'android_static_analysis = "on"' in f.read(), (
180            'Static analysis must be on to ensure correctness.')
181        # TODO: Ensure that the build server is not running.
182
183    logging.info(f'Running "gn gen" in output directory: {out_dir}')
184    build_utils.CheckOutput(['gn', 'gen', '-C', out_dir, '--ide=json'])
185
186    if args.all_java_deps:
187        assert not args.dep, '--all-java-target does not support passing deps.'
188        assert args.file, '--all-java-target requires passing --file.'
189        logging.info(f'Finding java deps under {out_dir}.')
190        all_java_deps = build_utils.CheckOutput([
191            str(_SRC_PATH / 'build' / 'android' / 'list_java_targets.py'),
192            '--gn-labels', '-C', out_dir
193        ]).split('\n')
194        logging.info(f'Found {len(all_java_deps)} java deps.')
195        args.dep += all_java_deps
196    else:
197        assert args.dep, 'At least one explicit dep is required.'
198
199    project_json_contents = _get_project_json_contents(out_dir)
200    project_json = json.loads(project_json_contents)
201    # The input file names have a // prefix. (e.g. //android_webview/BUILD.gn)
202    known_build_files = set(
203        name[2:] for name in project_json['build_settings']['gen_input_files'])
204    # Remove the // prefix for target names so ninja can build them.
205    known_target_names = set(name[2:]
206                             for name in project_json['targets'].keys())
207
208    unknown_targets = [
209        t for t in args.extra_build_targets if t not in known_target_names
210    ]
211    assert not unknown_targets, f'Cannot build {unknown_targets} in {out_dir}.'
212
213    logging.info('Building all targets in preparation for removing deps')
214    # Avoid capturing stdout/stderr to see the progress of the full build.
215    subprocess.run(['autoninja', '-C', out_dir], check=True)
216
217    results = []
218    for idx, filepath in enumerate(build_filepaths):
219        # Since removal can take a long time, provide an easy way to resume the
220        # command if something fails.
221        try:
222            # When resuming, the first build file is the one that is being
223            # resumed. Avoid inline mode skipping it since it's already started
224            # to be processed and the first dep may already have been removed.
225            if args.resume_from and idx == 0 and args.inline_mode:
226                logging.info(f'Resuming: skipping inline mode for {filepath}.')
227                should_inline = False
228            else:
229                should_inline = args.inline_mode
230            relpath = os.path.relpath(filepath, start=root)
231            logging.info('[%d/%d] Checking %s', idx, num_total, relpath)
232            if relpath not in known_build_files:
233                operation_result = OperationResult(
234                    path=relpath,
235                    skipped=True,
236                    skip_reason='Not in the list of known build files.')
237            else:
238                targets = _calculate_targets_for_file(relpath,
239                                                      args.extra_build_targets,
240                                                      known_target_names)
241                if targets is None:
242                    operation_result = OperationResult(
243                        path=relpath,
244                        skipped=True,
245                        skip_reason='Could not find any valid targets.')
246                else:
247                    operation_result = _remove_deps(
248                        deps=args.dep,
249                        out_dir=out_dir,
250                        root=root,
251                        path=filepath,
252                        dryrun=args.dryrun,
253                        targets=targets,
254                        inline_mode=should_inline,
255                        target_name_filter=args.target_name_filter)
256            if operation_result:
257                logging.info(operation_result)
258                results.append(operation_result)
259        # Use blank except: to show this for KeyboardInterrupt as well.
260        except:
261            logging.error(
262                f'Encountered error while processing {filepath}. Append the '
263                'following args to resume from this file once the error is '
264                f'fixed:\n\n--resume-from {filepath}\n')
265            raise
266    return results
267
268
269def main():
270    parser = argparse.ArgumentParser(
271        prog='gn_editor', description='Add or remove deps programatically.')
272
273    common_args_parser = argparse.ArgumentParser(add_help=False)
274    common_args_parser.add_argument(
275        '-n',
276        '--dryrun',
277        action='store_true',
278        help='Show which files would be updated but avoid changing them.')
279    common_args_parser.add_argument('-v',
280                                    '--verbose',
281                                    action='store_true',
282                                    help='Used to print ninjalog.')
283    common_args_parser.add_argument('-q',
284                                    '--quiet',
285                                    action='store_true',
286                                    help='Used to print less logging.')
287    common_args_parser.add_argument('--file',
288                                    help='Run on a specific build file.')
289    common_args_parser.add_argument(
290        '--resume-from',
291        help='Skip files before this build file path (debugging).')
292
293    subparsers = parser.add_subparsers(
294        required=True, help='Use subcommand -h to see full usage.')
295
296    add_parser = subparsers.add_parser(
297        'add',
298        parents=[common_args_parser],
299        help='Add one or more deps to a specific target (pass the path to the '
300        'BUILD.gn via --file for faster results). The target **must** '
301        'have a deps variable defined, even if it is an empty [].')
302    add_parser.add_argument('--target', help='The name of the target.')
303    add_parser.add_argument('--deps',
304                            nargs='+',
305                            help='The name(s) of the new dep(s).')
306    add_parser.set_defaults(command=_add)
307
308    search_parser = subparsers.add_parser(
309        'search',
310        parents=[common_args_parser],
311        help='Search for strings in build files. Each query is a regex string.'
312    )
313    search_parser.add_argument('--name',
314                               help='This is checked against dep names.')
315    search_parser.add_argument(
316        '--path', help='This checks the relative path of the build file.')
317    search_parser.set_defaults(command=_search)
318
319    split_parser = subparsers.add_parser(
320        'split',
321        parents=[common_args_parser],
322        help='Split one or more deps from an existing dep.')
323    split_parser.add_argument('existing', help='The dep to split from.')
324    split_parser.add_argument('new',
325                              nargs='+',
326                              help='One of the new deps to be added.')
327    split_parser.set_defaults(command=_split)
328
329    remove_parser = subparsers.add_parser(
330        'remove',
331        parents=[common_args_parser],
332        help='Remove one or more deps if the build still succeeds. Removing '
333        'one dep at a time is recommended.')
334    remove_parser.add_argument(
335        'dep',
336        nargs='*',
337        help='One or more deps to be removed. Zero when other options are used.'
338    )
339    remove_parser.add_argument(
340        '-C',
341        '--output-directory',
342        metavar='OUT',
343        help='If outdir is not provided, will attempt to guess.')
344    remove_parser.add_argument(
345        '--target-name-filter',
346        help='This will cause the script to only remove deps from targets that '
347        'match the filter provided. The filter should be a valid python regex '
348        'string and is used in a re.search on the full GN target names, e.g. '
349        're.search(pattern, "//base:base_java").')
350    remove_parser.add_argument(
351        '--all-java-deps',
352        action='store_true',
353        help='This will attempt to remove all known java deps. This option '
354        'requires no explicit deps to be passed.')
355    remove_parser.add_argument(
356        '--extra-build-targets',
357        metavar='T',
358        nargs='*',
359        default=[],
360        help='The set of extra targets to compile after each dep removal. This '
361        'is in addition to file-based targets that are automatically added.')
362    remove_parser.add_argument(
363        '--inline-mode',
364        action='store_true',
365        help='Skip the build file if the first dep is not found and removed. '
366        'This is especially useful when inlining deps so that a build file '
367        'that does not contain the dep being inlined can be skipped. This '
368        'mode assumes that the first dep is the one being inlined.')
369    remove_parser.set_defaults(command=_remove)
370
371    args = parser.parse_args()
372
373    if args.quiet:
374        level = logging.WARNING
375    elif args.verbose:
376        level = logging.DEBUG
377    else:
378        level = logging.INFO
379    logging.basicConfig(
380        level=level, format='%(levelname).1s %(relativeCreated)7d %(message)s')
381
382    root = _SRC_PATH
383    if args.file:
384        build_filepaths = [os.path.relpath(args.file, root)]
385    else:
386        build_filepaths = []
387        logging.info('Finding build files under %s', root)
388        for dirpath, _, filenames in os.walk(root):
389            for filename in filenames:
390                filepath = os.path.join(dirpath, filename)
391                if filename.endswith(('.gn', '.gni')):
392                    build_filepaths.append(filepath)
393        build_filepaths.sort()
394
395    logging.info('Found %d build files.', len(build_filepaths))
396
397    if args.resume_from:
398        resume_idx = None
399        for idx, path in enumerate(build_filepaths):
400            if path.endswith(args.resume_from):
401                resume_idx = idx
402                break
403        assert resume_idx is not None, f'Did not find {args.resume_from}.'
404        logging.info('Skipping %d build files with --resume-from.', resume_idx)
405        build_filepaths = build_filepaths[resume_idx:]
406
407    filtered_build_filepaths = [
408        p for p in build_filepaths if not utils.is_bad_gn_file(p, root)
409    ]
410    num_total = len(filtered_build_filepaths)
411    if num_total == 0:
412        logging.error(NO_VALID_GN_STR)
413        sys.exit(1)
414    logging.info('Running on %d valid build files.', num_total)
415
416    operation_results: List[OperationResult] = args.command(
417        args, filtered_build_filepaths, root)
418    if operation_results is None:
419        return
420    ignored_operation_results = [r for r in operation_results if r.git_ignored]
421    skipped_operation_results = [r for r in operation_results if r.skipped]
422    num_ignored = len(ignored_operation_results)
423    num_skipped = len(skipped_operation_results)
424    num_updated = len(operation_results) - num_skipped
425    print(f'Checked {num_total}, updated {num_updated} ({num_ignored} of '
426          f'which are ignored by git under {root}), and skipped {num_skipped} '
427          'build files.')
428    if num_ignored:
429        print(f'\nThe following {num_ignored} files were ignored by git and '
430              'may need separate CLs in their respective repositories:')
431        for result in ignored_operation_results:
432            print('  ' + result.path)
433
434
435if __name__ == '__main__':
436    main()
437