1#!/usr/bin/env python3
2
3import argparse, os, sys
4
5# List of directories we want to exclude when traversing the project files. This list should
6# contain directories related to tests, documentation/samples, API, resources, etc.
7EXCLUDED_DIRS = ['androidTest', 'androidAndroidTest', 'api', 'docs', 'res', 'samples', 'test',
8                 'androidInstrumentedTest']
9# List of packages that should be excluded when traversing the project files. These packages might
10# include Java/Kotlin source files that contain Composable, but we're not interested in including
11# them on the output list because it's unlikely that developers will use them in their code.
12# For example, this list should contain (test) utility and tooling-related packages.
13EXCLUDED_PACKAGES = ['benchmark-utils', 'compiler', 'integration-tests', 'test-utils',
14                     'ui-android-stubs', 'ui-tooling', 'ui-tooling-data', 'ui-tooling-preview']
15# Set of directories that will be excluded when traversing the project files. Excluding a directory
16# means our search won't look into its subdirectories, so this list should be populated
17# with caution.
18EXCLUDED_FROM_FILE_SEARCH = set(EXCLUDED_DIRS + EXCLUDED_PACKAGES)
19
20# The directory containing this script, relative to androidx-main root.
21SCRIPT_DIR_PATH = 'frameworks/support/compose/ui/ui-inspection/generate-packages/'
22# The file name of this script.
23SCRIPT_NAME = 'generate_compose_packages.py'
24# File containing an ordered list of packages that contain at least one Composable.
25# The file is formatted as one package per line.
26COMPOSE_PACKAGES_LIST_FILE = 'compose_packages_list.txt'
27
28# `frameworks/support/compose/`, `frameworks/support/navigation/navigation-compose`, and
29# `frameworks/support/wear/compose`, relative to this script directory, should be the root
30# directories where we search for composables.
31TARGET_DIRECTORIES = [
32    '../../..',
33    '../../../../navigation/navigation-compose',
34    '../../../../wear/compose',
35    '../../../../lifecycle/lifecycle-runtime-compose',
36]
37
38# Reads a source file with the given file_path and adds its package to the current set of packages
39# if the file contains at least one Composable.
40def add_package_if_composable(file_path, packages):
41    with open(file_path, 'r') as file:
42        lines = file.readlines()
43        for line in lines:
44            if line.startswith('package '):
45                package = line.lstrip('package').strip().strip(';')
46                # Early return to prevent reading the rest of the file.
47                if package in packages: return
48            if line.lstrip().startswith('@Composable') and package:
49                packages.add(package)
50                return
51
52# Iterates on a directory recursively, looking for Java/Kotlin source files that contain Composable
53# functions, and add their corresponding packages to a set that will be returned when the traversal
54# is complete.
55def extract_packages_from_directory(directory):
56    packages = set()
57    for root, dirs, files in os.walk(directory, topdown=True):
58        dirs[:] = [d for d in dirs if d not in EXCLUDED_FROM_FILE_SEARCH]
59        for filename in files:
60            if filename.endswith('.java') or filename.endswith('.kt'):
61                add_package_if_composable(os.path.join(root, filename), packages)
62    return packages
63
64# Searches the given directories and returns a sorted list of all the packages that contain
65# Composable functions.
66def sorted_packages_from_directories(directories):
67    packages = []
68    for directory in directories:
69        packages.extend(extract_packages_from_directory(directory))
70    return sorted(packages)
71
72# Verifies that the given the list of packages match the ones currently listed on the
73# compose_packages_list.txt file
74def verify_packages(packages):
75    with open(COMPOSE_PACKAGES_LIST_FILE, 'r') as file:
76        file_packages = file.readlines()
77        if len(file_packages) != len(packages): report_failure_and_exit()
78        for i in range(len(file_packages)):
79            if packages[i] != file_packages[i].strip('\n'): report_failure_and_exit()
80
81def report_failure_and_exit():
82    print(
83        'Compose packages mismatch\n The current list of Compose packages does not match the list '
84        'stored in %s%s. If the current list of packages have changed, please regenerate the list '
85        'by running the following command:\n\t%s%s --regenerate' % (
86            SCRIPT_DIR_PATH,
87            COMPOSE_PACKAGES_LIST_FILE,
88            SCRIPT_DIR_PATH,
89            SCRIPT_NAME
90        ),
91        file=sys.stderr
92    )
93    sys.exit(1)
94
95# Regenerates the compose_packages_list.txt file, given the list of packages.
96def regenerate_packages_file(packages):
97    with open(COMPOSE_PACKAGES_LIST_FILE, 'w') as file:
98        file.write('\n'.join(packages))
99
100# Regenerates the PackageHashes.kt, given the list of packages. The file format is:
101# 1) Header indicating the file should not be edited manually
102# 2) Package definition
103# 3) Required imports
104# 4) packageNameHash function
105# 5) systemPackages val, which is a list containing the result of the packageNameHash
106#    function applied to each package name of the given packages list.
107def regenerate_packages_kt_file(packages):
108    kt_file = '../src/main/java/androidx/compose/ui/inspection/inspector/PackageHashes.kt'
109    header = (
110        '// WARNING: DO NOT EDIT THIS FILE MANUALLY. It\'s automatically generated by running:\n'
111        '//    %s%s -r\n' % (SCRIPT_DIR_PATH, SCRIPT_NAME)
112    )
113    package = 'package androidx.compose.ui.inspection.inspector\n\n'
114    imports = (
115        'import androidx.annotation.VisibleForTesting\n'
116        'import androidx.collection.intSetOf\n'
117        'import kotlin.math.absoluteValue\n\n'
118    )
119    package_name_hash_function = (
120        '@VisibleForTesting\n'
121        'fun packageNameHash(packageName: String) =\n'
122        '    packageName.fold(0) { hash, char -> hash * 31 + char.code }.absoluteValue\n\n'
123    )
124    system_packages_val = (
125        'val systemPackages =\n'
126        '    intSetOf(\n'
127        '        -1,\n'
128        '%s\n'
129        '    )\n' % (
130            '\n'.join(['        packageNameHash("' + package + '"),' for package in packages])
131        )
132    )
133    with open(kt_file, 'w') as file:
134        file.write(
135            header + package + imports + package_name_hash_function + system_packages_val
136        )
137
138if __name__ == '__main__':
139    parser = argparse.ArgumentParser(
140        description='This script is invoked to check whether the current list of packages '
141                    'containing Composables is up-to-date. This list is used by Layout Inspector '
142                    'and Compose Preview to filter out framework Composables.'
143    )
144    parser.add_argument(
145        '-r',
146        '--regenerate',
147        action='store_true',
148        help='this argument should be used to regenerate the list of packages'
149    )
150    args = parser.parse_args()
151
152    # cd into directory of script
153    os.chdir(os.path.dirname(os.path.abspath(__file__)))
154    current_packages = sorted_packages_from_directories(TARGET_DIRECTORIES)
155
156    if args.regenerate:
157        regenerate_packages_file(current_packages)
158        regenerate_packages_kt_file(current_packages)
159    else:
160        verify_packages(current_packages)
161