summaryrefslogtreecommitdiff
path: root/tools/build_utils.py
blob: 1eeef026968fda77129be06a0ab21a7400b16b12 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
#!/usr/bin/env python3
import functools
import subprocess
import pathlib
import re

build_format = '| {:29} | {:30} | {:18} | {:7} | {:6} | {:6} |'

SUCCEEDED = "\033[32msucceeded\033[0m"
FAILED = "\033[31mfailed\033[0m"
SKIPPED = "\033[33mskipped\033[0m"


# Every read here is a source file, not user text: decode it the same way on every
# machine. Without this the reads take the locale's encoding, and one of the eight
# tracked non-ASCII files this now touches (hw/bsp/nrf/boards/nrf54lm20dk/board.cmake
# among them) raises UnicodeDecodeError under LC_ALL=C - a ValueError, which sails
# straight through the `except OSError` fail-opens.
_TEXT = {'encoding': 'utf-8', 'errors': 'replace'}

_FAMILY_MCUS_RE = re.compile(r'set\s*\(\s*FAMILY_MCUS\s+([^)]*)\)')
_CMAKE_SET_RE = re.compile(r'set\s*\(\s*([A-Za-z_]\w*)\s+([^)\s]+)')
_CMAKE_VAR_RE = re.compile(r'\$\{([A-Za-z_]\w*)\}')
_CMAKE_CASE_RE = re.compile(r'string\s*\(\s*(TOUPPER|TOLOWER)\s+(\S+)\s+([A-Za-z_]\w*)\s*\)')


@functools.lru_cache(maxsize=None)
def _cmake_sets(path):
    """One cmake file's variable assignments as NAME -> first definition seen, as
    either a literal value or an ('TOUPPER'|'TOLOWER', source) pair. Only used to
    expand ${...} tokens; never mutate the cached dict.

    string(TOUPPER ...) is not decoration: hw/bsp/maxim derives its ONLY FAMILY_MCUS
    entry that way (`string(TOUPPER ${MAX_DEVICE} MAX_DEVICE_UPPER)`), as do the eight
    at32 families, so dropping those lines left nine families with an empty MCU set."""
    try:
        text = pathlib.Path(path).read_text(**_TEXT)
    except OSError:
        return {}
    out = {}
    for line in text.splitlines():
        line = line.strip()
        if line.startswith('#'):
            continue
        m = _CMAKE_CASE_RE.match(line)
        if m:
            # strip quotes like the set() branch below: string(TOUPPER "${VAR}" DST) is
            # idiomatic cmake, and keeping them yields a '"NAME"' token that can never
            # equal a mcu: entry
            out.setdefault(m.group(3), (m.group(1), m.group(2).strip('"')))
            continue
        m = _CMAKE_SET_RE.match(line)
        if m:
            out.setdefault(m.group(1), m.group(2).strip('"'))
    return out


def _cmake_expand(value, files, depth=0):
    """`value` with every ${VAR} replaced, resolving each name against `files` in
    order, or None when any name resolves nowhere OR the result still carries a `${`.
    That last case is the one _CMAKE_VAR_RE cannot see - a hyphen in the name, a nested
    ${${X}}, an unterminated brace - where the loop below finds nothing to substitute
    and would otherwise hand the raw text back as if it were a resolved MCU name.
    Bounded depth: a cmake file may define a var in terms of another one, and a
    self-referential set() must not recurse forever."""
    if depth > 4:
        return None
    out = value
    for name in set(_CMAKE_VAR_RE.findall(value)):
        val = None
        for f in files:
            val = _cmake_sets(f).get(name)
            if val is not None:
                break
        if val is None:
            return None
        if isinstance(val, tuple):                     # string(TOUPPER src DST)
            src = _cmake_expand(val[1], files, depth + 1)
            if src is None:
                return None
            val = src.upper() if val[0] == 'TOUPPER' else src.lower()
        else:
            val = _cmake_expand(val, files, depth + 1)
            if val is None:
                return None
        out = out.replace('${' + name + '}', val)
    return None if '${' in out else out


@functools.lru_cache(maxsize=None)
def _board_dirs(board):
    """(board_dir, family_dir) for a board name, or (None, None). Cached: skip_example
    is asked (board x example) times - 566k lstat calls per selector run without this,
    since the glob rescans every hw/bsp/*/boards for each example."""
    hits = list(pathlib.Path("hw/bsp").glob("*/boards/" + board))
    if not hits:
        return None, None
    return hits[0], hits[0].parent.parent


@functools.lru_cache(maxsize=None)
def _family_mcus(family_dir, board_dir):
    """The MCU names CMake's family_filter iterates. family_support.cmake:176/190
    loop `foreach(MCU IN LISTS FAMILY_MCUS)`, so a family-wide list (broadcom_64bit
    sets "BCM2711 BCM2835") makes ANY of its entries decide skip.txt/only.txt -- not
    just the one CFG_TUSB_MCU the configured board names.

    ${...} tokens are expanded from `set(VAR value)` and `string(TOUPPER src VAR)` in
    the board's board.cmake first, then in family.cmake: hw/bsp/ra sets
    `FAMILY_MCUS RAXXX ${MCU_VARIANT}` and ra6m5_ek/board.cmake sets MCU_VARIANT ra6m5,
    which is the token dual/host_info_to_device_cdc/only.txt actually spells; hw/bsp/maxim
    sets `FAMILY_MCUS ${MAX_DEVICE_UPPER}`, upper-cased from the board's MAX_DEVICE. A
    token resolving nowhere is dropped (nothing can be said about it).

    A family that never spells `set(FAMILY_MCUS ...)` at all gets one more chance: the
    name is resolved as a variable, which covers the derived form hw/bsp/espressif uses
    (`string(TOUPPER ${IDF_TARGET} FAMILY_MCUS)`).

    Only unconditional set() calls count: nrf and mcx pick FAMILY_MCUS per board
    inside if() blocks this does not evaluate, so for those two families the whole
    cmake-side MCU set is whatever the CFG_TUSB_MCU scrape in _board_mcu finds.

    nrf: the scrape reads the FIRST CFG_TUSB_MCU token of hw/bsp/nrf/family.mk, so
    every nrf board answers NRF54, the NRF5X ones included. Harmless only because no
    skip.txt/only.txt names an nrf token today.

    mcx: load-bearing, not academic -- mcu:MCXA15 is live in six examples' skip.txt
    (device/{cdc_msc,audio_test,hid_composite,audio_4_channel_mic,midi_test}_freertos
    and device/net_lwip_webserver). Those answers come out right only because the
    scrape falls through to each board's make-only board.mk, which still spells the
    token; an mcx board carrying board.cmake alone (MCU_VARIANT and no CFG_TUSB_MCU)
    would scrape 'NONE' and skip EVERY example on it, silently. TestFamilyMcusFallback
    fails the day such a board lands. The fix then is to evaluate the
    if(MCU_VARIANT STREQUAL ...) branches, not to add another scrape.
    """
    fam_cmake = pathlib.Path(family_dir) / "family.cmake"
    try:
        text = fam_cmake.read_text(**_TEXT)
    except OSError:
        return frozenset()
    board_cmake = pathlib.Path(board_dir) / "board.cmake"
    out = set()
    depth = 0
    any_set = False
    for line in text.splitlines():
        line = line.strip()
        m = _FAMILY_MCUS_RE.match(line)
        if m:
            any_set = True
        if m and depth == 0:
            files = (str(board_cmake), str(fam_cmake))
            for tok in m.group(1).split():
                if tok in ("CACHE", "INTERNAL") or tok.startswith('"'):
                    continue
                val = _cmake_expand(tok, files)
                if val:
                    out.add(val)
        if re.match(r'if\s*\(', line):
            depth += 1
        elif re.match(r'endif\s*\(', line):
            depth = max(0, depth - 1)
    if not out and not any_set:
        # FAMILY_MCUS can also be produced rather than set: hw/bsp/espressif derives it
        # with `string(TOUPPER ${IDF_TARGET} FAMILY_MCUS)`, which _FAMILY_MCUS_RE cannot
        # see, leaving espressif's whole cmake answer resting on the IDF_TARGET scrape.
        #
        # `not any_set` is load-bearing: _cmake_sets is if()-blind and keeps the FIRST
        # definition, so on a family that sets FAMILY_MCUS only inside conditionals
        # (mcx, nrf) this would leak branch one's value onto every board - mcx/frdm_mcxn947
        # answered MCXA15, which six examples' skip.txt names, dropping 12 firmware
        # images CMake actually builds. Those families keep the CFG_TUSB_MCU scrape.
        val = _cmake_expand('${FAMILY_MCUS}', (str(board_cmake), str(fam_cmake)))
        if val:
            out.add(val)
    return frozenset(out)


@functools.lru_cache(maxsize=None)
def _scrape_mcu(family_dir, board_dir, family):
    """(CFG_TUSB_MCU token of this board, the text it was read from), master's
    algorithm verbatim: family.mk (family.cmake when there is none) first, falling
    back to the board's board.mk (board.cmake when there is none) only when the
    family file names no token at all. espressif spells its MCU as
    `set(IDF_TARGET "...")` instead. The text comes back with it because the make
    path reads MAX3421_HOST out of that same single file - which file that is IS
    part of master's answer, so it cannot be re-derived by the caller."""
    family_mk = family_dir / "family.mk"
    if not family_mk.exists():
        family_mk = family_dir / "family.cmake"
    mk_contents = family_mk.read_text(**_TEXT)

    # Find the mcu, first in family mk then board mk
    if "CFG_TUSB_MCU=OPT_MCU_" not in mk_contents:
        board_mk = board_dir / "board.mk"
        if not board_mk.exists():
            board_mk = board_dir / "board.cmake"
        mk_contents = board_mk.read_text(**_TEXT)

    mcu = "NONE"
    if family == "espressif":
        for line in mk_contents.splitlines():
            match = re.search(r'set\(IDF_TARGET\s+"([^"]+)"\)', line)
            if match:
                mcu = match.group(1).upper()
                break
    else:
        for token in mk_contents.split():
            if "CFG_TUSB_MCU=OPT_MCU_" in token:
                # Strip " because cmake files has them.
                token = token.strip("\"")
                _, opt_mcu = token.split("=")
                mcu = opt_mcu[len("OPT_MCU_"):]
            if mcu != "NONE":
                break
    return mcu, mk_contents


@functools.lru_cache(maxsize=None)
def _board_mcu(board_dir, family_dir, family):
    """(CFG_TUSB_MCU of this board, MAX3421_HOST enabled by its cmake BSP).

    MAX3421_HOST is read from family.cmake AND board.cmake rather than only the file
    the MCU token came from: feather_rp2040_max3421 sets it in its board.cmake while
    its MCU token comes from rp2040's family file, and family_support.cmake:940
    appends MAX3421 to FAMILY_MCUS for it. board.mk is deliberately not read - a
    make-only option compiles nothing in a cmake build (and the make path answers
    with master's own single-file scrape, see _skip_example_make)."""
    family_dir = pathlib.Path(family_dir)
    board_dir = pathlib.Path(board_dir)
    mcu, _ = _scrape_mcu(family_dir, board_dir, family)
    if "${" in mcu:
        # the scrape is textual, so a computed token comes back verbatim
        # (tm4c board.cmake spells OPT_MCU_TM4C${MCU_SUB_VARIANT}, maxim
        # OPT_MCU_${MAX_DEVICE_UPPER}). Expand it the same way FAMILY_MCUS tokens are;
        # what still will not resolve stays as-is and _skip_example treats it as
        # "MCU unknown" rather than silently matching no mcu: token at all.
        mcu = _cmake_expand(mcu, (str(board_dir / "board.cmake"),
                                  str(family_dir / "family.cmake"))) or mcu

    max3421_enabled = False
    for f in (family_dir / "family.cmake", board_dir / "board.cmake"):
        try:
            text = f.read_text(**_TEXT)
        except OSError:
            continue
        # a commented-out `# set(MAX3421_HOST 1)` (feather_nrf52840_express) enables
        # nothing; master never hit one because it only read the MCU token's file
        if any(not l.lstrip().startswith('#') and
               ("MAX3421_HOST=1" in l or 'MAX3421_HOST 1' in l)
               for l in text.splitlines()):
            max3421_enabled = True
            break

    return mcu, max3421_enabled


@functools.lru_cache(maxsize=None)
def _filter_tokens(path):
    """skip.txt / only.txt as a token set, or None when the file does not exist."""
    f = pathlib.Path(path)
    return frozenset(f.read_text(**_TEXT).split()) if f.exists() else None


def skip_example(example, board, extra_defines=(), build_system='cmake'):
    """Is this example unbuildable on this board, for this build system?

    The two build systems ask DIFFERENT questions and must not share an answer:

    'cmake' mirrors CMake's family_filter (hw/bsp/family_support.cmake:171-207),
    including the whole FAMILY_MCUS list the family.cmake sets.

    'make' is master's original algorithm, unchanged. family.mk and family.cmake are
    not the same build: hw/bsp/lpc54/family.cmake sets FAMILY_MCUS LPC54 and wires the
    ohci host sources, while family.mk builds OPT_MCU_LPC54XXX and compiles no HCD
    source at all -- feeding the cmake MCU union to a make build un-skips the host
    examples only.txt gates on mcu:LPC54 and they fail to link (undefined hcd_init).

    extra_defines: NAME=VALUE tokens the build passes on the command line
    (build.py -D). MAX3421_HOST=1 there enables the max3421 host controller
    exactly like a BSP that sets it, and family_support.cmake:940 appends MAX3421
    to FAMILY_MCUS for it -- so a roster board whose MAX3421 comes from the build
    args (metro_m4_express) must resolve its only.txt the same way. cmake only:
    master's make algorithm never looked at them.
    """
    return _skip_example(example, board, tuple(extra_defines), build_system)


@functools.lru_cache(maxsize=None)
def _skip_example_make(example, board):
    """master's skip_example, verbatim (tools/build_utils.py @ 9c202e8c6): the
    make build's own answer, derived from family.mk/board.mk with the single
    CFG_TUSB_MCU token that file names. Do not "improve" it -- it is the mirror of
    what `make BOARD=... all` actually compiles."""
    ex_dir = pathlib.Path('examples/') / example

    # board within family
    board_dir, family_dir = _board_dirs(board)
    if board_dir is None:
        # Skip unknown boards
        return True
    family = family_dir.name

    mcu, mk_contents = _scrape_mcu(family_dir, board_dir, family)

    # Skip all OPT_MCU_NONE these are WIP port
    if mcu == "NONE":
        return True

    max3421_enabled = False
    for line in mk_contents.splitlines():
        if "MAX3421_HOST=1" in line or 'MAX3421_HOST 1' in line:
            max3421_enabled = True
            break

    skip_file = ex_dir / "skip.txt"
    only_file = ex_dir / "only.txt"

    if skip_file.exists():
        skips = skip_file.read_text(**_TEXT).split()
        if ("mcu:" + mcu in skips or
            "board:" + board in skips or
            "family:" + family in skips):
            return True

    if only_file.exists():
        onlys = only_file.read_text(**_TEXT).split()
        if not ("mcu:" + mcu in onlys or
                ("mcu:MAX3421" in onlys and max3421_enabled) or
                "board:" + board in onlys or
                "family:" + family in onlys):
            return True

    return False


@functools.lru_cache(maxsize=None)
def _skip_example(example, board, extra_defines, build_system):
    if build_system == 'make':
        return _skip_example_make(example, board)

    ex_dir = pathlib.Path('examples/') / example

    # board within family
    board_dir, family_dir = _board_dirs(board)
    if board_dir is None:
        # Skip unknown boards
        return True
    family = family_dir.name

    mcu, max3421_enabled = _board_mcu(str(board_dir), str(family_dir), family)

    # Skip all OPT_MCU_NONE these are WIP port
    if mcu == "NONE":
        return True

    if any(t.strip().strip('"') == "MAX3421_HOST=1" for t in extra_defines):
        max3421_enabled = True

    mcus = set(_family_mcus(str(family_dir), str(board_dir)))
    if "${" not in mcu:
        mcus.add(mcu)
    if not mcus:
        # nothing resolved: neither FAMILY_MCUS nor the scraped CFG_TUSB_MCU token
        # yielded a name. Answering "skip" here would silently drop EVERY example on
        # the board (an only.txt can then never match), so say "buildable" and let
        # the real filter decide - build.py checks the targets CMake actually
        # registered, and CMake itself is the authority on the make/cmake legs.
        return False
    if max3421_enabled:
        mcus.add("MAX3421")                      # family_support.cmake:940

    keys = {"board:" + board, "family:" + family} | {"mcu:" + m for m in mcus}

    skips = _filter_tokens(str(ex_dir / "skip.txt"))
    if skips is not None and (skips & keys):
        return True

    onlys = _filter_tokens(str(ex_dir / "only.txt"))
    if onlys is not None and not (onlys & keys):
        return True

    return False


def build_size(make_cmd):
    size_output = subprocess.run(make_cmd + ' size', shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8").splitlines()
    for i, l in enumerate(size_output):
        text_title = 'text	   data	    bss	    dec'
        if text_title in l:
            size_list = size_output[i+1].split('\t')
            flash_size = int(size_list[0])
            sram_size = int(size_list[1]) + int(size_list[2])
            return (flash_size, sram_size)

    return (0, 0)