1#!/usr/bin/env python3
2#
3# Copyright (c) 2024 Raspberry Pi (Trading) Ltd.
4#
5# SPDX-License-Identifier: BSD-3-Clause
6#
7#
8# A script to ensure that all declared configuration options match across both
9# CMake and Bazel.
10#
11# Usage:
12#
13# Run from anywhere.
14
15from dataclasses import dataclass
16import glob
17import logging
18import os
19from pathlib import Path
20import re
21import subprocess
22import sys
23from typing import Dict
24
25from bazel_common import SDK_ROOT, setup_logging
26
27_LOG = logging.getLogger(__file__)
28
29CMAKE_FILE_TYPES = (
30    "**/CMakeLists.txt",
31    "**/*.cmake",
32)
33
34BAZEL_FILE_TYPES = (
35    "**/BUILD.bazel",
36    "**/*.bzl",
37    "**/*.BUILD",
38)
39
40ATTR_REGEX = re.compile(r",?\s*(?P<key>[^=]+)=(?P<value>[^,]+)")
41
42BAZEL_MODULE_REGEX = re.compile(r'\s*commit\s*=\s*\"(?P<commit>[0-9a-fA-F]+)\"\s*,\s*#\s*keep-in-sync-with-submodule:\s*(?P<dependency>\S*)')
43
44BAZEL_VERSION_REGEX = re.compile(r'module\(\s*name\s*=\s*"pico-sdk",\s*version\s*=\s*"(?P<sdk_version>[^"]+)",?\s*\)')
45
46CMAKE_VERSION_REGEX = re.compile(r'^[^#]*set\(PICO_SDK_VERSION_(?P<part>\S+)\s+(?P<value>\S+)\)')
47
48# Sometimes the build systems are supposed to be implemented differently. This
49# allowlist permits the descriptions to differ between CMake and Bazel.
50BUILD_SYSTEM_DESCRIPTION_DIFFERENCE_ALLOWLIST = (
51    # Minor semantic differences in Bazel.
52    "PICO_DEFAULT_BOOT_STAGE2_FILE",
53    # In Bazel, not overridable by user environment variables (only flags).
54    "PICO_BOARD",
55    # In Bazel, it's a build label rather than a path.
56    "PICO_CMSIS_PATH",
57    # In Bazel, the semantics of embedded binary info are slightly different.
58    "PICO_PROGRAM_NAME",
59    "PICO_PROGRAM_DESCRIPTION",
60    "PICO_PROGRAM_URL",
61    "PICO_PROGRAM_VERSION_STRING",
62    "PICO_TARGET_NAME",
63)
64
65CMAKE_ONLY_ALLOWLIST = (
66    # Not relevant to Bazel: toolchain is fetched dynamically, and can be
67    # overridden with native Bazel features.
68    "PICO_TOOLCHAIN_PATH",
69    # Bazel uses native --platforms mechanics.
70    "PICO_PLATFORM",
71    # Named PICO_TOOLCHAIN in Bazel.
72    "PICO_COMPILER",
73    # Entirely irrelevant to Bazel, use Bazel platforms:
74    #     https://bazel.build/extending/platforms
75    "PICO_CMAKE_PRELOAD_PLATFORM_FILE",
76    # Both of these are marked as TODO and not actually set up in CMake.
77    "PICO_CMSIS_VENDOR",
78    "PICO_CMSIS_DEVICE",
79    # Bazel build uses PICO_CONFIG_EXTRA_HEADER and PICO_CONFIG_PLATFORM_HEADER
80    # instead.
81    "PICO_CONFIG_HEADER_FILES",
82    "PICO_RP2040_CONFIG_HEADER_FILES",
83    "PICO_HOST_CONFIG_HEADER_FILES",
84    # Bazel uses PICO_CONFIG_HEADER.
85    "PICO_BOARD_CMAKE_DIRS",
86    "PICO_BOARD_HEADER_FILE",
87    "PICO_BOARD_HEADER_DIRS",
88    # Bazel supports this differently.
89    # TODO: Provide a helper rule for explicitly generating a UF2 so users don't
90    # have to write out a bespoke run_binary.
91    "PICO_NO_UF2",
92    # Bazel will not provide a default for this.
93    # TODO: Provide handy rules for PIOASM so users don't have to write out a
94    # bespoke run_binary.
95    "PICO_DEFAULT_PIOASM_OUTPUT_FORMAT",
96    # Bazel always has picotool.
97    "PICO_NO_PICOTOOL",
98    # These aren't supported as build flags in Bazel. Prefer to
99    # set these in board header files like other SDK defines.
100    "CYW43_DEFAULT_PIN_WL_REG_ON",
101    "CYW43_DEFAULT_PIN_WL_DATA_OUT",
102    "CYW43_DEFAULT_PIN_WL_DATA_IN",
103    "CYW43_DEFAULT_PIN_WL_HOST_WAKE",
104    "CYW43_DEFAULT_PIN_WL_CLOCK",
105    "CYW43_DEFAULT_PIN_WL_CS",
106    "CYW43_PIO_CLOCK_DIV_INT",
107    "CYW43_PIO_CLOCK_DIV_FRAC",
108    "CYW43_PIO_CLOCK_DIV_DYNAMIC",
109)
110
111BAZEL_ONLY_ALLOWLIST = (
112    # Allows users to fully replace the final image for boot_stage2.
113    "PICO_BOOT_STAGE2_LINK_IMAGE",
114    # Allows users to inject an alternative TinyUSB library since TinyUSB
115    # doesn't have native Bazel support.
116    "PICO_TINYUSB_LIB",
117    # Bazel can't do pico_set_* for the binary info defines, so there's a
118    # different mechanism.
119    "PICO_DEFAULT_BINARY_INFO",
120    # Bazel analogue for PICO_CMAKE_BUILD_TYPE.
121    "PICO_BAZEL_BUILD_TYPE",
122    # Different mechanism for setting a linker script that is less complex.
123    "PICO_DEFAULT_LINKER_SCRIPT",
124    # Not yet documented in CMake (but probably should be):
125    "PICO_CMAKE_BUILD_TYPE",
126    # Replaces PICO_RP2040_CONFIG_HEADER_FILES and
127    # PICO_HOST_CONFIG_HEADER_FILES.
128    "PICO_CONFIG_EXTRA_HEADER",
129    "PICO_CONFIG_PLATFORM_HEADER",
130    # Effectively replaces:
131    # - PICO_BOARD_CMAKE_DIRS
132    # - PICO_BOARD_HEADER_FILE
133    # - PICO_BOARD_HEADER_DIRS
134    "PICO_CONFIG_HEADER",
135    # Bazel configuration for 3p deps.
136    "PICO_BTSTACK_CONFIG",
137    "PICO_LWIP_CONFIG",
138    "PICO_FREERTOS_LIB",
139    "PICO_MBEDTLS_LIB",
140    # CMake has PICO_DEFAULT_CLIB, but it's not user-facing.
141    "PICO_CLIB",
142    # Selecting default library implementations.
143    "PICO_MULTICORE_ENABLED",
144    "PICO_DEFAULT_DOUBLE_IMPL",
145    "PICO_DEFAULT_FLOAT_IMPL",
146    "PICO_DEFAULT_DIVIDER_IMPL",
147    "PICO_DEFAULT_PRINTF_IMPL",
148    "PICO_DEFAULT_RAND_IMPL",
149    "PICO_BINARY_INFO_ENABLED",
150    "PICO_ASYNC_CONTEXT_IMPL",
151    # Allows selection of clang/gcc when using the dynamically fetched
152    # toolchains.
153    "PICO_TOOLCHAIN",
154    # In CMake, linking these libraries also sets defines for adjacent
155    # libraries. That's an antipattern in Bazel, so there's flags to control
156    # which modules to enable instead.
157    "PICO_BT_ENABLE_BLE",
158    "PICO_BT_ENABLE_CLASSIC",
159    "PICO_BT_ENABLE_MESH",
160)
161
162
163@dataclass
164class Option:
165    name: str
166    description: str
167    attrs: Dict[str, str]
168
169    def matches(self, other):
170        matches = (self.name == other.name) and (self.attrs == other.attrs)
171        if not self.name in BUILD_SYSTEM_DESCRIPTION_DIFFERENCE_ALLOWLIST:
172            matches = matches and (self.description == other.description)
173        return matches
174
175
176def FindKnownOptions(option_pattern_matcher, file_paths):
177    pattern = re.compile(
178        option_pattern_matcher
179        + r":\s+(?P<name>\w+),\s+(?P<description>[^,]+)(?:,\s+(?P<attrs>.*))?$"
180    )
181    options = {}
182    for p in file_paths:
183        with open(p, "r") as f:
184            for line in f:
185                match = re.search(pattern, line)
186                if not match:
187                    continue
188
189                attrs = {
190                    m.group("key"): m.group("value")
191                    for m in re.finditer(ATTR_REGEX, match.group("attrs"))
192                }
193
194                options[match.group("name")] = Option(
195                    match.group("name"),
196                    match.group("description"),
197                    attrs,
198                )
199    return options
200
201
202def OptionsAreEqual(bazel_option, cmake_option, warnings_as_errors):
203    if bazel_option is None:
204        if cmake_option.name in CMAKE_ONLY_ALLOWLIST:
205            return True
206        _LOG.warning(f"    {cmake_option.name} does not exist in Bazel")
207        return not warnings_as_errors
208    elif cmake_option is None:
209        if bazel_option.name in BAZEL_ONLY_ALLOWLIST:
210            return True
211        _LOG.warning(f"    {bazel_option.name} does not exist in CMake")
212        return not warnings_as_errors
213    elif not bazel_option.matches(cmake_option):
214        _LOG.error("    Bazel and CMAKE definitions do not match:")
215        _LOG.error(f"    [CMAKE]    {bazel_option}")
216        _LOG.error(f"    [BAZEL]    {cmake_option}")
217        return False
218
219    return True
220
221
222def CompareOptions(bazel_pattern, bazel_files, cmake_pattern, cmake_files, warnings_as_errors=True):
223    bazel_options = FindKnownOptions(bazel_pattern, bazel_files)
224    cmake_options = FindKnownOptions(cmake_pattern, cmake_files)
225
226    are_equal = True
227    both = {}
228    both.update(bazel_options)
229    both.update(cmake_options)
230    for k in both.keys():
231        if not OptionsAreEqual(
232            bazel_options.get(k, None),
233            cmake_options.get(k, None),
234            warnings_as_errors,
235        ):
236            are_equal = False
237    return are_equal
238
239def CompareExternalDependencyVersions():
240    pattern = re.compile(BAZEL_MODULE_REGEX)
241    all_okay = True
242    with open(Path(SDK_ROOT) / "MODULE.bazel", "r") as bazel_module_file:
243        for line in bazel_module_file:
244            maybe_match = pattern.match(line)
245            if not maybe_match:
246                continue
247
248            current_submodule_pin = subprocess.run(
249                ("git", "-C", SDK_ROOT, "rev-parse", f'HEAD:{maybe_match.group("dependency")}'),
250                text=True,
251                check=True,
252                capture_output=True,
253            ).stdout.strip()
254            if current_submodule_pin != maybe_match.group("commit"):
255                _LOG.error("    External pins for %s do not match:", maybe_match.group("dependency"))
256                _LOG.error("    [CMAKE]    %s", current_submodule_pin)
257                _LOG.error("    [BAZEL]    %s", maybe_match.group("commit"))
258                all_okay = False
259            else:
260                _LOG.info("    External pins for %s match!", maybe_match.group("dependency"))
261
262    return all_okay
263
264def CompareSdkVersion():
265    # Find version string specified in Bazel.
266    bazel_module_file_path = Path(SDK_ROOT) / "MODULE.bazel"
267    bazel_module_file_contents = bazel_module_file_path.read_text()
268    bazel_sdk_version = BAZEL_VERSION_REGEX.search(bazel_module_file_contents)
269    if not bazel_sdk_version:
270        _LOG.error("    Failed to find Bazel Pico SDK version string")
271        return False
272    bazel_version_string = bazel_sdk_version.group("sdk_version")
273
274    # Find version string specified in CMake.
275    cmake_version_parts = {}
276    with open(Path(SDK_ROOT) / "pico_sdk_version.cmake", "r") as cmake_version_file:
277        for line in cmake_version_file:
278            match = CMAKE_VERSION_REGEX.match(line)
279            if match:
280                cmake_version_parts[match.group("part")] = match.group("value")
281    if len(cmake_version_parts) < 3:
282        _LOG.error("    Failed to find CMake Pico SDK version string")
283        return False
284    cmake_version_string = ".".join((
285        cmake_version_parts["MAJOR"],
286        cmake_version_parts["MINOR"],
287        cmake_version_parts["REVISION"],
288    ))
289    if "PRE_RELEASE_ID" in cmake_version_parts:
290        cmake_version_string += "-" + cmake_version_parts["PRE_RELEASE_ID"]
291
292    if cmake_version_string != bazel_version_string:
293        _LOG.error("    Declared CMake SDK version is %s and Bazel is %s", cmake_version_string, bazel_version_string)
294        return False
295
296    return True
297
298def compare_build_systems():
299    cmake_files = [
300        f
301        for p in CMAKE_FILE_TYPES
302        for f in glob.glob(os.path.join(SDK_ROOT, p), recursive=True)
303    ]
304    bazel_files = [
305        f
306        for p in BAZEL_FILE_TYPES
307        for f in glob.glob(os.path.join(SDK_ROOT, p), recursive=True)
308    ]
309
310    results = []
311    _LOG.info("[1/3] Checking build system configuration flags...")
312    results.append(CompareOptions(
313        "PICO_BAZEL_CONFIG",
314        bazel_files,
315        "PICO_CMAKE_CONFIG",
316        cmake_files,
317        # For now, allow CMake and Bazel to go out of sync when it comes to
318        # build configurability since it's a big ask to make contributors
319        # implement the same functionality in both builds.
320        warnings_as_errors=False,
321    ))
322
323    _LOG.info("[2/4] Checking build system defines...")
324    results.append(CompareOptions(
325        "PICO_BUILD_DEFINE", bazel_files, "PICO_BUILD_DEFINE", cmake_files
326    ))
327
328    _LOG.info("[3/4] Checking submodule pins...")
329    results.append(CompareExternalDependencyVersions())
330
331    _LOG.info("[4/4] Checking version strings...")
332    results.append(CompareSdkVersion())
333
334    if False not in results:
335        _LOG.info("Passed with no blocking failures")
336        return 0
337
338    _LOG.error("One or more blocking failures detected")
339    return 1
340
341
342if __name__ == "__main__":
343    setup_logging()
344    sys.exit(compare_build_systems())
345