blob: 16d130413c8a2bcf352fc4224d28241c79c7cfcb [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""The stable API endpoint for ChromiumIDE Java language support.
ChromiumIDE executes this script to query information and perform operations.
This script is not meant to be run manually.
"""
import argparse
import concurrent.futures
import dataclasses
import logging
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from typing import Iterator, List, Optional, Set, Tuple
_SRC_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
sys.path.append(os.path.join(_SRC_ROOT, 'build'))
import gn_helpers
sys.path.append(os.path.join(_SRC_ROOT, 'build', 'android', 'gyp'))
from util import build_utils
_DEPOT_TOOLS_PATH = os.path.join(_SRC_ROOT, 'third_party', 'depot_tools')
# The API version of the script.
_API_VERSION = 1
# Matches with the package declaration in a Java source file.
_PACKAGE_PATTERN = re.compile(r'package\s+([A-Za-z0-9._]*)\s*;')
@dataclasses.dataclass(frozen=True)
class BuildInfo:
"""Defines the output schema of build-info subcommand."""
# Directory paths containing Java source files, relative from the src
# directory.
source_paths: List[str]
# JAR file paths, relative from the src directory.
class_paths: List[str]
def to_dict(self) -> dict:
"""Converts the BuildInfo object to a dictionary for JSON serialization."""
return {'sourcePaths': self.source_paths, 'classPaths': self.class_paths}
def _gn_gen(output_dir: str) -> None:
"""Runs 'gn gen' to generate build files for the specified output directory.
Args:
output_dir: The path to the build output directory.
"""
cmd = [
sys.executable,
os.path.join(_DEPOT_TOOLS_PATH, 'gn.py'), 'gen', output_dir
]
logging.info('Running: %s', shlex.join(cmd))
subprocess.check_call(cmd, stdout=sys.stderr)
def _compile(output_dir: str, args: List[str]) -> None:
"""Compiles the specified targets using the build system.
Args:
output_dir: The path to the build output directory.
args: A list of build targets or arguments to pass to the build command.
"""
cmd = gn_helpers.CreateBuildCommand(output_dir) + args
logging.info('Running: %s', shlex.join(cmd))
subprocess.check_call(cmd, stdout=sys.stderr)
def _is_useful_source_jar(source_jar_path: str, output_dir: str) -> bool:
"""Determines if a source JAR is useful for IDE indexing.
This function filters out certain types of source JARs that are not
beneficial or could cause issues for IDEs.
Args:
source_jar_path: The path to the source JAR file.
output_dir: The path to the build output directory.
Returns:
True if the source JAR should be included, False otherwise.
"""
# JNI placeholder srcjars contain random stubs.
if source_jar_path.endswith('_placeholder.srcjar'):
return False
# When building a java_library target (e.g. //chrome/android:chrome_java),
# a srcjar containing relevant resource definitions are generated first (e.g.
# gen/chrome/android/chrome_java__assetres.srcjar), and it's included in the
# source path when building the java_library target. This is how R references
# are resolved when *.java are compiled with javac.
#
# When multiple java libraries are linked into an APK (e.g.
# //clank/java:chrome_apk), another srcjar containing all relevant resource
# definitions is generated (e.g.
# gen/clank/java/chrome_apk__compile_resources.srcjar). Note that
# *__assetres.srcjar used on compiling java libraries are NOT linked to final
# APKs. This is how R definitions looked up in the run time are linked to an
# APK.
#
# Then let's talk about IDE's business. We cannot add *__assetres.srcjar to
# the source path of the language server because many of those srcjars
# contain identically named R classes (e.g. org.chromium.chrome.R) with
# different sets of resource names. (Note that we don't explicitly exclude
# them here, but actually they don't appear in *.params.json at all.)
# Therefore we want to pick a single resource jar that covers all resource
# definitions in the whole Chromium tree and add it to the source path. An
# approximation used here is to pick the __compile_resources.srcjar for the
# main browser binary. This is not perfect though, because there can be some
# resources not linked into the main browser binary. Ideally we should
# introduce a GN target producing a resource jar covering all resources
# across the repository.
if source_jar_path.endswith('__compile_resources.srcjar'):
if os.path.exists(os.path.join(_SRC_ROOT, 'clank')):
private_resources_jar_path = os.path.join(
output_dir, 'gen/clank/java/chrome_apk__compile_resources.srcjar')
return source_jar_path == private_resources_jar_path
public_resources_jar_path = os.path.join(
output_dir,
'gen/chrome/android/chrome_public_apk__compile_resources.srcjar')
return source_jar_path == public_resources_jar_path
return True
def _find_source_root(source_file: str) -> Optional[str]:
"""Finds the root directory for a given source file based on its package.
For example, if a file '/path/to/src/org/chromium/foo/Bar.java' declares
'package org.chromium.foo;', this function will return '/path/to/src'.
Args:
source_file: The path to the Java source file.
Returns:
The path to the source root, or None if the package declaration cannot be
found or parsed.
"""
with open(source_file) as f:
for line in f:
if match := _PACKAGE_PATTERN.match(line):
package_name = match.group(1)
break
else:
return None
depth = package_name.count('.') + 1
source_root = source_file.rsplit('/', depth + 1)[0]
return source_root
def _process_sources(source_files: List[str], output_dir: str,
source_path_set: Set[str]) -> None:
"""Processes a list of source files to find their source roots.
Args:
source_files: A list of source file paths, relative to the output directory.
output_dir: The path to the build output directory.
source_path_set: A set to which identified source root paths will be added.
"""
processed_dir_set = set()
for source_file in source_files:
if not source_file.endswith('.java') or not source_file.startswith('../'):
continue
source_file = os.path.normpath(os.path.join(output_dir, source_file))
source_dir = os.path.dirname(source_file)
if source_dir in processed_dir_set:
continue
processed_dir_set.add(source_dir)
if source_root := _find_source_root(source_file):
source_path_set.add(source_root)
def _process_params(params_path: str, output_dir: str,
source_path_set: Set[str], class_path_set: Set[str],
source_jar_set: Set[str]) -> None:
"""Processes a .params.json file to extract build information.
.params.json files are generated on `gn gen` and contain metadata about build
targets, including sources, dependencies, and generated JARs.
Args:
params_path: The path to the .params.json file.
output_dir: The path to the build output directory.
source_path_set: A set to which identified source root paths will be added.
class_path_set: A set to which identified classpath JAR paths will be added.
source_jar_set: A set to which identified source JAR paths will be added.
"""
with open(params_path) as f:
params = json.load(f)
if target_sources_file := params.get('target_sources_file'):
# If the target is built from source files, add their source roots.
_process_sources(
build_utils.ReadSourcesList(
os.path.join(output_dir, target_sources_file)), output_dir,
source_path_set)
elif unprocessed_jar_path := params.get('unprocessed_jar_path'):
# If is_prebuilt is not set, we guess it from the jar path. The path is
# relative to outDir, so it starts with ../ if it points to a prebuilt
# jar in the chrome source tree.
if params.get('is_prebuilt') or unprocessed_jar_path.startswith('../'):
# This is a prebuilt jar file. Add it to the class path.
class_path_set.add(
os.path.normpath(os.path.join(output_dir, unprocessed_jar_path)))
source_jar_relative_paths = params.get('bundled_srcjars', [])
for source_jar_relative_path in source_jar_relative_paths:
if not source_jar_relative_path.startswith('gen/'):
continue
source_jar_path = os.path.join(output_dir, source_jar_relative_path)
if _is_useful_source_jar(source_jar_path, output_dir):
source_jar_set.add(source_jar_path)
def _find_params(output_dir: str) -> Iterator[str]:
"""Finds all .params.json files within the output directory.
It uses list_java_targets.py to enumerate .params.json files, correctly
ignoring stale ones in the output directory.
Args:
output_dir: The path to the build output directory.
Yields:
The paths to the .params.json files.
"""
output = subprocess.check_output(
[
os.path.join(_SRC_ROOT, 'build', 'android', 'list_java_targets.py'),
'--output-directory=' + output_dir,
'--omit-targets',
'--print-params-paths',
],
cwd=_SRC_ROOT,
encoding='utf-8',
)
return output.splitlines()
def _scan_params(output_dir: str) -> Tuple[List[str], List[str], List[str]]:
"""Scans the output directory for .params.json files and processes them.
This function walks through the 'gen' subdirectory of the output directory
to find all .params.json files and extracts source paths, class paths,
and source JARs from them.
Args:
output_dir: The path to the build output directory.
Returns:
A tuple containing:
- A sorted list of source root directory paths.
- A sorted list of classpath JAR file paths.
- A sorted list of source JAR file paths.
"""
source_path_set: Set[str] = set()
class_path_set: Set[str] = set()
source_jar_set: Set[str] = set()
for params_path in _find_params(output_dir):
_process_params(params_path, output_dir, source_path_set, class_path_set,
source_jar_set)
return sorted(source_path_set), sorted(class_path_set), sorted(source_jar_set)
def _extract_source_jar(source_jar: str) -> str:
"""Extracts a source JAR file to a directory.
The extraction is skipped if the JAR has not been modified since the last
extraction.
Args:
source_jar: The path to the source JAR file.
Returns:
The path to the directory where the JAR was extracted.
"""
extract_dir = source_jar + '.extracted-for-vscode'
# Compare timestamps to avoid extracting source jars on every startup.
source_jar_mtime = os.stat(source_jar).st_mtime
try:
extract_dir_mtime = os.stat(extract_dir).st_mtime
except OSError:
extract_dir_mtime = 0
if source_jar_mtime <= extract_dir_mtime:
return extract_dir
logging.info('Extracting %s', source_jar)
os.makedirs(extract_dir, exist_ok=True)
# Use `jar` command from the JDK for optimal performance. Python's zipfile is
# not very fast, and suffer from GIL on parallelizing.
subprocess.check_call(
[
os.path.join(_SRC_ROOT, 'third_party', 'jdk', 'current', 'bin',
'jar'),
'-x',
'-f',
os.path.abspath(source_jar),
],
cwd=extract_dir,
stdout=sys.stderr,
)
# Remove org.jni_zero placeholders, if any.
jni_zero_dir = os.path.join(extract_dir, 'org', 'jni_zero')
if os.path.exists(jni_zero_dir):
shutil.rmtree(jni_zero_dir)
return extract_dir
def _extract_source_jars(source_jars: List[str], output_dir: str) -> List[str]:
"""Extracts a list of source JARs in parallel.
Before extraction, it ensures that the source JARs themselves are up-to-date
by attempting to build them.
Args:
source_jars: A list of paths to source JAR files.
output_dir: The path to the build output directory.
Returns:
A sorted list of paths to the directories where the source JARs were
extracted.
"""
if not source_jars:
return []
source_jar_targets = [
os.path.relpath(source_jar, output_dir) for source_jar in source_jars
]
_compile(output_dir, source_jar_targets)
# Parallelize extracting source JARs as it takes a significant amount of time
# to process all JARs for Chromium serially.
with concurrent.futures.ThreadPoolExecutor() as executor:
new_source_dirs = executor.map(_extract_source_jar, source_jars)
return sorted(new_source_dirs)
def _version_main(_options: argparse.Namespace) -> None:
"""Handles the 'version' subcommand. Prints the API version."""
print(_API_VERSION)
def _build_info_main(options: argparse.Namespace) -> None:
"""Handles the 'build-info' subcommand.
Gathers build information (source paths, class paths) and prints it
as JSON.
"""
_gn_gen(options.output_dir)
source_roots, class_jars, source_jars = _scan_params(options.output_dir)
source_roots.extend(_extract_source_jars(source_jars, options.output_dir))
build_info = BuildInfo(
source_paths=source_roots,
class_paths=class_jars,
)
json.dump(build_info.to_dict(), sys.stdout, indent=2, sort_keys=True)
def _parse_arguments(args: List[str]) -> argparse.Namespace:
"""Parses command-line arguments for the script."""
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest='subcommand', required=True)
version_parser = subparsers.add_parser('version', help='Prints version')
version_parser.set_defaults(main_func=_version_main)
build_info_parser = subparsers.add_parser(
'build-info', help='Returns information needed to build Java files')
build_info_parser.set_defaults(main_func=_build_info_main)
build_info_parser.add_argument(
'--output-dir',
required=True,
help='Relative path to the output directory, e.g. "out/Debug"')
return parser.parse_args(args)
def main(args: List[str]) -> None:
build_utils.InitLogging('CHROMIUMIDE_API_DEBUG')
assert os.path.exists('.gn'), 'This script must be run from the src directory'
options = _parse_arguments(args)
options.main_func(options)
if __name__ == '__main__':
main(sys.argv[1:])