chromium/chrome/browser/resources/ash/settings/os_languages_page/tools/editor_tsconfig.py

#!/usr/bin/env python3
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Script for generating tsconfig.json files for editors during development.

WARNING: This script is NOT supported by the WebUI team. This script may break
at any time due to changes to the build system. Use at your own risk.

Before using this script, please consider using
ash/webui/personalization_app/tools/gen_tsconfig.py, as it is more maintained.
Unlike gen_tsconfig.py, editor_tsconfig.py tries to mimic the tsconfig used for
the build as much as possible.
Unfortunately, this also means that this script needs to be re-run every time
that files are added or removed from the ts_library() to keep it in sync.

This script parses a tsconfig.json file built by a ts_library() GN rule, and
rewrites paths to point to source files instead of files generated by
preprocess_if_expr(). As a result, the resulting tsconfig.json might result in
different behaviour compared to the build process, as if expressions such as
<if expr="is_win"> are not evaluated.

Files that are completely generated by the build process, such as those
generated by html_to_wrapper() and css_to_wrapper(), will not be affected by the
"generated-file-to-source" path rewrite. Files that are listed in deps or
definitions are also unaffected, so this script will need to be run for every
ts_library() you are working on.

This script will need to be re-run whenever the ts_library()'s tsconfig.json is
updated, such as when files are added or removed to the build rule.

When using the generated tsconfig.json for editor purposes, editors might
prioritise symbols from lazy_load.ts when symbols are automatically imported,
instead of the file where the symbols are defined. To mitigate this, an option
is provided to remove lazy_load.ts from the resulting tsconfig.json.


Example invocations:
$ ./chrome/browser/resources/ash/settings/os_languages_page/tools/\
editor_tsconfig.py \
--remove_lazy_load \
./out/Debug/gen/chrome/browser/resources/settings/tsconfig_build_ts.json
$ ./chrome/browser/resources/ash/settings/os_languages_page/tools/\
editor_tsconfig.py \
--remove_lazy_load \
./out/Debug/gen/chrome/browser/resources/ash/settings/\
tsconfig_build_ts.json
$ ./chrome/browser/resources/ash/settings/os_languages_page/tools/\
editor_tsconfig.py \
./out/Debug/gen/ui/webui/resources/cr_components/most_visited/\
tsconfig_build_ts.json
"""

import argparse
import json
import os.path
import pathlib
import sys
from typing import Dict, List

_SRC_DIR = pathlib.Path(__file__).parents[7]
assert _SRC_DIR.joinpath('LICENSE').exists(), (
    'cannot find root src dir, please update editor_tsconfig.py')


def _relative_to_with_parents(path: pathlib.Path, other: pathlib.Path) -> str:
    """Gets 'path' relative to 'other', adding '..'s if needed."""
    # This '.' is required, as tsc seems to not understand relative paths
    # without it.
    parts: List[str] = ['.']
    while not path.is_relative_to(other):
        if other == other.parent:
            raise ValueError(f'path ({path}) and other ({other}) do not share'
                             ' any parents')
        other = other.parent
        parts.append('..')
    parts.append(str(path.relative_to(other)))
    return os.path.join(*parts)


def _rebase_relative_path(
    path: str,
    originally_relative_to: pathlib.Path,
    newly_relative_to: pathlib.Path,
) -> str:
    original_path = originally_relative_to.joinpath(path).resolve()
    return _relative_to_with_parents(original_path, newly_relative_to)


def _convert_tsconfig_for_editor(
    tsconfig: dict,
    original_dir: pathlib.Path,
    editor_dir: pathlib.Path,
    editor_root_dir: str,
    remove_lazy_load: bool,
) -> dict:
    original_dir = original_dir.resolve()
    editor_dir = editor_dir.resolve()

    compiler_options = tsconfig['compilerOptions']
    original_root_dir: str = compiler_options['rootDir']
    paths: Dict[str, List[str]] = compiler_options['paths']
    files: List[str] = tsconfig['files']

    editor_files: List[str] = []
    # editor_files tries to use original source files if possible, but falls
    # back to using generated files if needed. We don't add _both_ source and
    # generated, as TypeScript sometimes gets confused with two identical
    # definitions of the same thing. Notably, it dislikes repeated definitions
    # of the same key, but different values, in HTMLElementTagNameMap.
    for file in files:
        file_path = pathlib.Path(file)
        if remove_lazy_load and file_path.stem == 'lazy_load':
            continue
        if file_path.is_relative_to(original_root_dir):
            relative_path = file_path.relative_to(original_root_dir)
            source_path = editor_dir.joinpath(editor_root_dir,
                                              relative_path).resolve()
            if source_path.is_file():
                editor_files.append(
                    _relative_to_with_parents(source_path, editor_dir))
                continue

        # This file is generated, or is not under the root directory.
        # Add it as-is.
        editor_files.append(
            _rebase_relative_path(file, original_dir, editor_dir))

    # As of Python 3.7, dicts are guaranteed to be ordered:
    # https://mail.python.org/pipermail/python-dev/2017-December/151283.html
    # Note that dicts are already ordered in CPython 3.6 - Python 3.7 ensures it
    # as part of the language spec so other implementations should also have
    # ordered dicts.
    editor_tsconfig = {
        **({
            'extends':
            _rebase_relative_path(tsconfig['extends'], original_dir, editor_dir)
        } if 'extends' in tsconfig else {}),
        'compilerOptions': {
            'rootDirs': [
                editor_root_dir,
                _rebase_relative_path(original_root_dir, original_dir,
                                      editor_dir),
            ],
            'noEmit':
            True,
            'paths': {
                path: [
                    _rebase_relative_path(mapping, original_dir, editor_dir)
                    for mapping in mappings
                ]
                for path, mappings in paths.items()
            }
        },
        'files':
        editor_files,
        **({
            'references': [{
                'path':
                _rebase_relative_path(
                    reference['path'],
                    original_dir,
                    editor_dir,
                )
            } for reference in tsconfig['references']]
        } if 'references' in tsconfig else {}),
    }
    return editor_tsconfig


def main(argv: List[str]) -> None:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        'original_tsconfig',
        type=pathlib.Path,
        help=
        'path to tsconfig.json generated by ts_library in the out directory',
    )
    parser.add_argument(
        '--root_dir',
        help=(
            'relative path to the root directory of source files specified in '
            'tsconfig.json (default: \'.\')'),
        default='.',
    )
    parser.add_argument(
        '--remove_lazy_load',
        action=argparse.BooleanOptionalAction,
        help=('removes all files named lazy_load from the tsconfig to prevent '
              'automatic imports prioritising it'),
        default=False,
    )
    args = parser.parse_args(argv)
    original_tsconfig: pathlib.Path = args.original_tsconfig.resolve(
        strict=True)
    editor_root_dir: str = args.root_dir
    remove_lazy_load: bool = args.remove_lazy_load

    # ts_library.gni always generates a tsconfig to
    # '$target_gen_dir/tsconfig_$target_name.json' as of writing.
    if not (original_tsconfig.stem.startswith('tsconfig_')
            and original_tsconfig.suffix == '.json'):
        raise ValueError(f'original_tsconfig ({original_tsconfig}) is not'
                         ' named tsconfig_$target_name.json')

    # Figure out where the target tsconfig should go, using the /gen/ part of
    # the supplied.
    gen_dir = original_tsconfig
    while gen_dir.name != "gen":
        if gen_dir == gen_dir.parent:
            raise ValueError(f'original_tsconfig ({original_tsconfig}) does'
                             ' not have a parent "gen" directory')
        gen_dir = gen_dir.parent

    original_tsconfig_dir = original_tsconfig.parent
    # Directory relative to _SRC_DIR where the original BUILD.gn should be in.
    relative_tsconfig_dir = original_tsconfig_dir.relative_to(gen_dir)
    new_tsconfig_dir = _SRC_DIR.joinpath(relative_tsconfig_dir)

    with original_tsconfig.open('r', encoding='utf-8') as f:
        original_tsconfig_obj = json.load(f)

    new_tsconfig_obj = _convert_tsconfig_for_editor(
        original_tsconfig_obj,
        original_tsconfig_dir,
        new_tsconfig_dir,
        editor_root_dir,
        remove_lazy_load,
    )

    new_tsconfig = new_tsconfig_dir.joinpath('tsconfig.json')
    with new_tsconfig.open('w', encoding='utf-8') as f:
        json.dump(new_tsconfig_obj, f, indent=2)


if __name__ == '__main__':
    main(sys.argv[1:])