From 2186fc92b0111612e5196a63a8379516ae87966c Mon Sep 17 00:00:00 2001 From: Jiu-xiao <2592509183@qq.com> Date: Tue, 13 Oct 2026 16:35:41 +0100 Subject: [PATCH 1/3] Use the shared layout engine from xr-syntax libxr.cpp_layout is gone: the layout of the generated C++ comes from xr_syntax.format. That module holds the line-breaking rules of both generators, each behind its own entry point and on shared primitives; the three code generators call layout_statement instead of their own line breaker. Pin xr-syntax 0.3.0. --- pyproject.toml | 2 +- src/libxr/cpp_layout.py | 307 ------------------------------ src/libxr/generator_code_hpm.py | 10 +- src/libxr/generator_code_mspm0.py | 20 +- src/libxr/generator_code_stm32.py | 50 ++--- tests/test_cpp_layout.py | 233 ----------------------- 6 files changed, 45 insertions(+), 577 deletions(-) delete mode 100644 src/libxr/cpp_layout.py delete mode 100644 tests/test_cpp_layout.py diff --git a/pyproject.toml b/pyproject.toml index 4bad990..56145f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pyyaml", "packaging", "ruamel.yaml>=0.17.21", - "xr-syntax==0.2.0", + "xr-syntax==0.3.0", "python-xlib; sys_platform == 'linux'" ] diff --git a/src/libxr/cpp_layout.py b/src/libxr/cpp_layout.py deleted file mode 100644 index 31f7b73..0000000 --- a/src/libxr/cpp_layout.py +++ /dev/null @@ -1,307 +0,0 @@ -"""按 LibXR 的 clang-format 风格(Google、列宽 90、Allman)排版生成的 C++ 语句。 -Lay out generated C++ statements in the clang-format style of LibXR (Google, column limit 90, -Allman). - -生成器不在运行时调用 clang-format(版本不同,输出就可能不同),而是在这里按 clang-format 的 -规则自己断行:每个可以断开的位置有一个断行代价,取总代价最小的断行方式,代价相同时与 -clang-format 一样先试不断行的状态。规则只覆盖生成器写出的语句形式:``头部(参数, ...);``,参数 -是不可拆分的文本或花括号列表,花括号列表可以嵌套。测试用固定版本的 clang-format 核对输出。 -The generator does not call clang-format at run time, because another version may format -differently. It breaks lines here by the rules of clang-format instead: every place where a -line may break has a break penalty, the cheapest set of breaks wins, and equal penalties try -the state that does not break first, as clang-format does. The rules cover only the statement -form the generator writes, ``head(argument, ...);``, whose arguments are unbreakable text or -braced lists; braced lists may nest. The tests check the output against a pinned -clang-format. -""" - -import heapq -from collections.abc import Sequence -from dataclasses import dataclass, replace - -COLUMN_LIMIT = 90 -CONTINUATION_INDENT = 4 -# 超出列宽的每个字符的代价;比任何断行都大,所以只有无处可断时才超出。 -# The penalty of each character past the column limit; it exceeds any break, so a line goes -# past the limit only when nothing can break. -EXCESS_PENALTY = 1_000_000 -# 嵌套每深一层,断行代价加 20。 -# A break costs 20 more for each level of nesting. -NESTING_PENALTY = 20 - - -class Braces: - """一个花括号列表参数,元素是文本或另一个 Braces。 - A braced list argument whose items are text or another Braces. - """ - - def __init__(self, *items: "str | Braces") -> None: - """记下列表的元素。 - Keep the items of the list. - """ - self.items = items - - def text(self) -> str: - """列表写在一行时的文本,例如 ``{a, b}``。 - The list written on one line, such as ``{a, b}``. - """ - return "{" + ", ".join(_flat(item) for item in self.items) + "}" - - -def _flat(argument: "str | Braces") -> str: - """参数写在一行时的文本。 - An argument written on one line. - """ - return argument if isinstance(argument, str) else argument.text() - - -@dataclass(frozen=True) -class _Token: - """排版用的一个词法单元。 - One token for the layout. - - kind 为 atom、comma、open、close、assign 或 semi;depth 是它外面的括号层数;items 是 - open 所开列表的元素个数,boxed 表示这个列表有元素是花括号列表。 - kind is atom, comma, open, close, assign or semi; depth is the number of brackets around - it; items is the number of items of the list that an open opens, boxed says that some - item of that list is a braced list, and last that the list is the last item of its level. - """ - - text: str - kind: str - depth: int - items: int = 0 - boxed: bool = False - last: bool = False - - -@dataclass(frozen=True) -class _Scope: - """一层括号的排版状态:续行缩进、本层最近一行的起始列和断行标记。 - The layout state of one bracket level: the continuation indent, the start column of its - latest line and the break flags. - - pending 为真表示这一层之内断过行,下一个参数必须换行;boxed_mode 对有元素是花括号列表的 - 列表记录各元素是否都换行:0 未决定,1 都换行,2 都不换行;commas 是这一层已有的逗号数; - late 表示这个列表接在同一行的其他参数之后开始,clang-format 不让这样的列表跨行。 - pending means a break inside this level has happened and the next argument must start a - new line; boxed_mode records, for a list with a braced list among its items, whether every - item starts a line: 0 undecided, 1 all, 2 none; commas is the number of commas of this - level so far; late means the list starts after other arguments on its line, which - clang-format does not let span lines. - """ - - indent: int - last_space: int - pending: bool = False - boxed: bool = False - boxed_mode: int = 0 - commas: int = 0 - late: bool = False - - -def _tokens(head: str, args: Sequence["str | Braces"]) -> list[_Token]: - """语句 ``head(args);`` 的词法单元;head 中的 `` = `` 把它分成赋值号两侧。 - The tokens of the statement ``head(args);``; a `` = `` in head splits it around the - assignment. - """ - tokens: list[_Token] = [] - if " = " in head: - target, callee = head.split(" = ", 1) - tokens += [_Token(target, "atom", 0), _Token("=", "assign", 0), _Token(callee, "atom", 0)] - else: - tokens.append(_Token(head, "atom", 0)) - tokens.append(_Token("(", "open", 0, len(args))) - - def emit(items: Sequence["str | Braces"], depth: int) -> None: - """追加一组以逗号分隔的元素。 - Append a comma-separated group of items. - """ - for position, item in enumerate(items): - if position: - tokens.append(_Token(",", "comma", depth)) - if isinstance(item, str): - tokens.append(_Token(item, "atom", depth)) - continue - boxed = any(isinstance(inner, Braces) for inner in item.items) - last = position == len(items) - 1 - tokens.append(_Token("{", "open", depth, len(item.items), boxed, last)) - emit(item.items, depth + 1) - tokens.append(_Token("}", "close", depth + 1)) - - emit(args, 1) - tokens.append(_Token(")", "close", 1)) - tokens.append(_Token(";", "semi", 0)) - return tokens - - -def _space_before(previous: _Token, token: _Token) -> int: - """token 与 previous 写在同一行时它们之间的空格数。 - The number of spaces between previous and token when they share a line. - """ - if token.kind in ("comma", "close", "semi") or token.text == "(": - return 0 - if previous.kind in ("comma", "assign") or token.kind == "assign": - return 1 - return 0 - - -def _break_penalty(tokens: list[_Token], index: int) -> int: - """在 tokens[index] 之前断行的代价:嵌套层数的代价加上断行位置的代价。 - The penalty of a break before tokens[index]: the nesting penalty plus the penalty of the - place of the break. - """ - token, previous = tokens[index], tokens[index - 1] - base = NESTING_PENALTY * (token.depth + 1) - if previous.kind == "comma": - return base + 1 - if previous.kind == "assign": - return base + 2 - if previous.kind == "open": - return base + 19 - return base + 3 - - -def _can_break(tokens: list[_Token], index: int) -> bool: - """tokens[index] 之前是否允许断行:逗号、圆括号和赋值号之后可以,花括号之后和收尾之前不可。 - Whether a break before tokens[index] is allowed: after a comma, an opening parenthesis or an - assignment, but not after a brace or before a closer. - """ - token, previous = tokens[index], tokens[index - 1] - if token.kind in ("close", "semi", "comma"): - return False - if previous.kind == "open": - return previous.text == "(" - return previous.kind in ("comma", "assign") - - -def _place(tokens: list[_Token], index: int, column: int, stack: tuple, newline: bool): - """把 tokens[index] 放在当前行或新的一行;不允许时为 None。 - Put tokens[index] on the current line or on a new one; None when that is not allowed. - - 返回 (增加的代价, 新的列, 新的括号层, 本词法单元起始的列或 None)。 - Returns (added penalty, new column, new bracket levels, the start column of the token when - it starts a line, else None). - """ - token, previous = tokens[index], tokens[index - 1] - top = stack[-1] - penalty = 0 - if newline: - if not _can_break(tokens, index) or any(scope.late for scope in stack): - return None - # 有元素是花括号列表的列表,只要其中断过行,各元素就都换行。 - # A list with a braced list among its items breaks at every item as soon as a line - # breaks inside it. - if any(scope.boxed and scope.boxed_mode == 2 for scope in stack): - return None - stack = tuple( - replace(scope, boxed_mode=1) if scope.boxed and scope.boxed_mode == 0 else scope - for scope in stack - ) - top = stack[-1] - start = top.indent - penalty += _break_penalty(tokens, index) - outer = tuple(replace(scope, pending=True) for scope in stack[:-1]) - top = replace(top, pending=False, last_space=start) - stack = outer + (top,) - new_column = start + len(token.text) - line_start = start - else: - # 内部断过行的一层,其下一个参数必须换行;选了各元素都换行的列表,每个元素必须换行。 - # After a break inside a level, its next argument must start a new line; a list that - # chose to break at every item must break before each item. - if previous.kind == "comma" and (top.pending or (top.boxed and top.boxed_mode == 1)): - return None - if top.boxed and previous.kind == "comma": - top = replace(top, boxed_mode=2) - spaces = _space_before(previous, token) - position = column + spaces - if previous.kind == "open": - top = replace(top, indent=position, last_space=position) - if token.kind == "comma": - top = replace(top, commas=top.commas + 1) - stack = stack[:-1] + (top,) - new_column = position + len(token.text) - line_start = None - if new_column > COLUMN_LIMIT: - penalty += EXCESS_PENALTY * (new_column - COLUMN_LIMIT) - if token.kind == "open": - parent = stack[-1] - # 列表接在同一行的其他参数之后时,只有它是第二个也是最后一个参数才可以跨行。 - # A list that follows other arguments on its line may span lines only as the second and - # last argument. - late = previous.kind == "comma" and not newline and not (parent.commas == 1 and token.last) - scope = _Scope( - indent=parent.last_space + CONTINUATION_INDENT, - last_space=parent.last_space, - boxed=token.boxed, - late=late, - ) - stack = stack + (scope,) - elif token.kind == "close": - stack = stack[:-1] - return penalty, new_column, stack, line_start - - -def layout(head: str, args: Sequence["str | Braces"], indent: int = 2) -> list[str]: - """排版语句 ``head(args);``,返回缩进 indent 列的各行。 - Lay out the statement ``head(args);`` and return its lines indented by indent columns. - - 取断行代价最小的排版,能写在一行时写在一行。head 是圆括号之前的全部文本,例如 - ``static STM32UART usart1``;它含 `` = `` 时,赋值号之后也可以断行。 - The layout with the cheapest breaks wins, and a statement that fits on one line stays on - one line. head is all the text before the parenthesis, such as ``static STM32UART usart1``; - when it holds `` = `` the line may also break after the assignment. - """ - one_line = " " * indent + head + "(" + ", ".join(_flat(arg) for arg in args) + ");" - if len(one_line) <= COLUMN_LIMIT: - return [one_line] - tokens = _tokens(head, args) - start = _Scope(indent=indent + CONTINUATION_INDENT, last_space=indent) - initial_column = indent + len(tokens[0].text) - counter = 0 - # 队列项:(代价, 次序, 下一个词法单元, 列, 括号层, 各行的起始位置)。次序使代价相同时先展开 - # 先加入的状态,加入时先试不断行。 - # Queue entries: (penalty, sequence, next token, column, brackets, line starts). The - # sequence makes equal penalties expand the state added first, and a state that does not - # break is added first. - queue = [(0, counter, 1, initial_column, (start,), ())] - seen = set() - while queue: - penalty, _, index, column, stack, starts = heapq.heappop(queue) - if index == len(tokens): - return _render(tokens, indent, starts) - key = (index, column, stack) - if key in seen: - continue - seen.add(key) - for newline in (False, True): - placed = _place(tokens, index, column, stack, newline) - if placed is None: - continue - added, new_column, new_stack, line_start = placed - counter += 1 - new_starts = starts + ((index, line_start),) if newline else starts - heapq.heappush( - queue, (penalty + added, counter, index + 1, new_column, new_stack, new_starts) - ) - raise AssertionError("no layout") - - -def _render(tokens: list[_Token], indent: int, starts: tuple) -> list[str]: - """按各行的起始词法单元和缩进列拼出文本。 - Assemble the text from the start token and the indent column of each line. - """ - breaks = dict(starts) - lines: list[str] = [] - current = "" - for index, token in enumerate(tokens): - if index in breaks: - lines.append(current) - current = " " * breaks[index] + token.text - elif index == 0: - current = " " * indent + token.text - else: - current += " " * _space_before(tokens[index - 1], token) + token.text - lines.append(current) - return lines diff --git a/src/libxr/generator_code_hpm.py b/src/libxr/generator_code_hpm.py index 4d9b566..ccbebb8 100644 --- a/src/libxr/generator_code_hpm.py +++ b/src/libxr/generator_code_hpm.py @@ -45,11 +45,11 @@ import re import sys +from xr_syntax.format import Braces, layout_statement from xr_syntax.i18n import tr from libxr import generator_code_stm32 as stm32 from libxr import pin_layout -from libxr.cpp_layout import Braces, layout from libxr.generator_code_stm32 import CPP_KEYWORDS, GENERATED_NOTICE, INDENT # generator_code_stm32 的其余接口(登记表、设置、写入助手)经模块属性访问:测试会用 @@ -412,7 +412,7 @@ def _gpio_section(project_data: dict, gpio: dict[str, dict]) -> list[str]: ) for label, pin in gpio.items(): port = pin["Port"] - lines += layout( + lines += layout_statement( f"static HPMGPIO {label}", ["HPM_GPIO0", f"GPIO_DI_GPIO{port}", str(pin["Line"]), f"IRQn_GPIO0_{port}"], ) @@ -439,7 +439,7 @@ def _i2c_section(i2cs: list) -> list[str]: f"{', '.join(map(str, I2C_SPEEDS))} 之一;HPM 的 I2C 驱动只支持这几档", ) ) - lines += layout( + lines += layout_statement( f"static HPMI2C {name}", [f"HPM_{instance}", f"clock_{instance.lower()}", Braces(f"{speed}U")], ) @@ -467,7 +467,7 @@ def _pwm_section(pwms: list) -> list[str]: INDENT, ) for name, instance, _record, channel in pwms: - lines += layout( + lines += layout_statement( f"static HPMPWM {name}", [ f"reinterpret_cast(HPM_{instance})", @@ -482,7 +482,7 @@ def _pwm_section(pwms: list) -> list[str]: frequency = _keyed_settings("PWM", name).setdefault("frequency", None) if frequency is not None: frequency = _setting_int(f"PWM.{name}.frequency", frequency) - lines += layout(f"{name}.SetConfig", [Braces(str(frequency))]) + lines += layout_statement(f"{name}.SetConfig", [Braces(str(frequency))]) return lines diff --git a/src/libxr/generator_code_mspm0.py b/src/libxr/generator_code_mspm0.py index 458d5ce..c241455 100644 --- a/src/libxr/generator_code_mspm0.py +++ b/src/libxr/generator_code_mspm0.py @@ -67,10 +67,10 @@ import sys from xr_syntax.cpp import CppDocument +from xr_syntax.format import Braces, layout_statement from xr_syntax.i18n import tr from libxr import generator_code_stm32 as stm32 -from libxr.cpp_layout import Braces, layout from libxr.generator_code_stm32 import GENERATED_NOTICE, INDENT # generator_code_stm32 的其余接口(登记表、设置、写入助手)经模块属性访问:测试会用 @@ -582,7 +582,9 @@ def _gpio_section(gpio: dict) -> list[str]: INDENT, ) for label, pin in gpio.items(): - lines += layout(f"static MSPM0GPIO {label}", [pin["Port"], pin["PinMacro"], pin["IOMUX"]]) + lines += layout_statement( + f"static MSPM0GPIO {label}", [pin["Port"], pin["PinMacro"], pin["IOMUX"]] + ) return lines @@ -622,7 +624,7 @@ def _uart_section(uarts: list, extend: set[str]) -> list[str]: tx_buf = f"{name}_tx_buf" if instance in extend: rx_buf = f"{name}_rx_dma_buf" - lines += layout( + lines += layout_statement( f"static MSPM0UART {name}", [ f"MSPM0_UART_EXTEND_INIT({instance}", @@ -637,7 +639,7 @@ def _uart_section(uarts: list, extend: set[str]) -> list[str]: ], ) continue - lines += layout( + lines += layout_statement( f"static MSPM0UART {name}", [ f"MSPM0_UART_MAIN_INIT({instance}", @@ -662,7 +664,7 @@ def _i2c_section(i2cs: list, clocks: dict[str, str]) -> list[str]: lines = [f"{INDENT}// I2C: polling; the buffer stages DMA transfers."] for name, instance, _record, _channel in i2cs: settings = _instance_settings("I2C", name) - lines += layout( + lines += layout_statement( f"static MSPM0I2C {name}", [ f"MSPM0_I2C_INIT({instance}", @@ -717,7 +719,7 @@ def _spi_section(spis: list, clocks: dict[str, str]) -> list[str]: ) ) settings = _instance_settings("SPI", name) - lines += layout( + lines += layout_statement( f"static MSPM0SPI {name}", [ f"MSPM0_SPI_INIT({instance}", @@ -750,13 +752,15 @@ def _pwm_section(pwms: list) -> list[str]: ] frequencies: dict[str, dict[str, int]] = {} for name, instance, _record, channel in pwms: - lines += layout(f"static MSPM0PWM {name}", [f"MSPM0_PWM_CH({instance}, {channel})"]) + lines += layout_statement( + f"static MSPM0PWM {name}", [f"MSPM0_PWM_CH({instance}, {channel})"] + ) settings = _settings_of("PWM", name) frequency = settings.setdefault("frequency", None) if frequency is not None: frequency = stm32._integer(f"PWM.{name}.frequency", frequency) frequencies.setdefault(instance, {})[name] = frequency - lines += layout(f"{name}.SetConfig", [Braces(str(frequency))]) + lines += layout_statement(f"{name}.SetConfig", [Braces(str(frequency))]) for instance, channels in frequencies.items(): if len(set(channels.values())) > 1: listed = ", ".join(f"{name} {value}" for name, value in channels.items()) diff --git a/src/libxr/generator_code_stm32.py b/src/libxr/generator_code_stm32.py index c4a1746..6342551 100644 --- a/src/libxr/generator_code_stm32.py +++ b/src/libxr/generator_code_stm32.py @@ -20,10 +20,10 @@ import yaml from xr_syntax.cpp import CppDocument, identifier_occurrences +from xr_syntax.format import COLUMN_LIMIT, Braces, layout_statement from xr_syntax.i18n import tr from libxr import libxr_config_file, update_notice -from libxr.cpp_layout import COLUMN_LIMIT, Braces, layout from libxr.libxr_config_file import LibXRConfigError # -------------------------- @@ -1826,7 +1826,7 @@ def _generate_adc(instance: str, config: dict) -> tuple: _use_header("stm32_adc.hpp") _use_handle("ADC_HandleTypeDef", f"h{name}") - lines = layout( + lines = layout_statement( f"static STM32ADC {name}", [f"&h{name}", dma_argument(f"{name}_buf"), Braces(*conversions), str(vref)], ) @@ -1837,7 +1837,9 @@ def _generate_adc(instance: str, config: dict) -> tuple: if channel_name in names: channel_name = f"{channel_name}_rank{index + 1}" names.add(channel_name) - lines += layout(f"static auto& {channel_name} = {name}.GetChannel", [str(index)]) + lines += layout_statement( + f"static auto& {channel_name} = {name}.GetChannel", [str(index)] + ) if not generating_for_xrobot: lines.append(f"{INDENT}UNUSED({channel_name});") _register_device(channel_name, "ADC") @@ -1875,7 +1877,7 @@ def _generate_dac(instance: str, config: dict) -> tuple: var_name = f"{name}_{out_name.lower()}" if var_name.startswith("dac_dac_"): var_name = var_name.replace("dac_dac_", "dac_") - lines += layout( + lines += layout_statement( f"static STM32DAC {var_name}", [f"&h{name}", channel_id, str(init_voltage), str(vref)], ) @@ -1907,7 +1909,9 @@ def _generate_uart(instance: str, config: dict) -> tuple: handle = f"h{name.replace('usart', 'uart')}" _use_header("stm32_uart.hpp") _use_handle("UART_HandleTypeDef", handle) - lines = layout(f"static STM32UART {name}", [f"&{handle}", rx_buf, tx_buf, str(tx_queue)]) + lines = layout_statement( + f"static STM32UART {name}", [f"&{handle}", rx_buf, tx_buf, str(tx_queue)] + ) _register_device(name, "UART") return "uart", lines @@ -1927,7 +1931,7 @@ def _generate_i2c(instance: str, config: dict) -> tuple: _use_header("stm32_i2c.hpp") _use_handle("I2C_HandleTypeDef", f"h{name}") _register_device(name, "I2C") - return "i2c", layout( + return "i2c", layout_statement( f"static STM32I2C {name}", [f"&h{name}", dma_argument(f"{name}_buf"), str(dma_min_size)], ) @@ -1955,7 +1959,7 @@ def _generate_tim(instance: str, config: dict) -> tuple: complementary = ch_cfg.get("Complementary", False) if complementary and ch_num.endswith("n"): ch_num = ch_num[:-1] - lines += layout( + lines += layout_statement( f"static STM32PWM {dev_name}", [f"&h{name}", f"TIM_CHANNEL_{ch_num}", "true" if complementary else "false"], ) @@ -1975,7 +1979,7 @@ def _generate_canfd(instance: str, config: dict) -> tuple: _use_header("stm32_canfd.hpp") _use_handle("FDCAN_HandleTypeDef", f"h{name}") _register_device(name, "FDCAN") - return "can", layout(f"static STM32CANFD {name}", [f"&h{name}", str(queue_size)]) + return "can", layout_statement(f"static STM32CANFD {name}", [f"&h{name}", str(queue_size)]) @staticmethod def _generate_can(instance: str, config: dict) -> tuple: @@ -1996,7 +2000,7 @@ def _generate_can(instance: str, config: dict) -> tuple: "CAN", tr(f"classic CAN peripheral {instance}", f"经典 CAN 外设 {instance}"), ) - return "can", layout(f"static STM32CAN {name}", [f"&h{name}", str(queue_size)]) + return "can", layout_statement(f"static STM32CAN {name}", [f"&h{name}", str(queue_size)]) @staticmethod def _generate_spi(instance: str, config: dict) -> tuple: @@ -2047,7 +2051,7 @@ def _generate_spi(instance: str, config: dict) -> tuple: _use_header("stm32_spi.hpp") _use_handle("SPI_HandleTypeDef", f"h{name}") _register_device(name, "SPI") - return "spi", layout( + return "spi", layout_statement( f"static STM32SPI {name}", [f"&h{name}", rx_buf, tx_buf, str(dma_min_size)] ) @@ -2071,7 +2075,7 @@ def _generate_iwdg(instance: str, config: dict) -> tuple: _use_header("stm32_watchdog.hpp") _use_handle("IWDG_HandleTypeDef", f"h{name}") _register_device(name, "Watchdog") - return "watchdog", layout( + return "watchdog", layout_statement( f"static STM32Watchdog {name}", [f"&h{name}", str(timeout_ms), str(feed_ms)] ) @@ -2171,7 +2175,7 @@ def endpoint(number: int) -> str: title = name.replace("_", " ") lines = [f"{INDENT}// {title}: {len(cdcs)} CDC"] - lines += layout( + lines += layout_statement( f"static constexpr auto {strings} = USB::DescriptorStrings::MakeLanguagePack", ["USB::DescriptorStrings::Language::EN_US", f'"{manufacturer}"', f'"{product}"'] + [f'"{serial}"'], @@ -2192,7 +2196,7 @@ def endpoint(number: int) -> str: # A set interface names both the control and the data interface; the host tells the # CDCs that share VID:PID apart by it. interface = cdc["interface"].replace('"', '\\"') - lines += layout( + lines += layout_statement( f"static USB::CDCUart {cdc_name}", [ endpoint(data_in), @@ -2235,7 +2239,7 @@ def buffer(suffix: str) -> "str | Braces": ) configs.append(Braces(buffer(f"ep{data + 1}_in_buf"), "16", "true")) endpoints = [Braces(*configs)] - lines += layout( + lines += layout_statement( f"static {instance_type} {inst_lower}", [f"&{pcd_handle}"] + endpoints @@ -2750,7 +2754,7 @@ def _generate_core_system(project_data: dict) -> list[str]: handler = f"h{source.lower()}" if source.startswith("TIM"): _use_handle("TIM_HandleTypeDef", handler) - timebase = layout("static STM32TimerTimebase timebase", [f"&{handler}"]) + timebase = layout_statement("static STM32TimerTimebase timebase", [f"&{handler}"]) else: # 默认使用 SysTick / Default to SysTick timebase = [f"{INDENT}static STM32Timebase timebase;"] @@ -2774,7 +2778,7 @@ def _generate_core_system(project_data: dict) -> list[str]: return ( [f"{INDENT}// Timebase and platform"] + timebase - + layout("PlatformInit", init_args) + + layout_statement("PlatformInit", init_args) + [f"{INDENT}static STM32PowerManager power_manager;"] ) @@ -2790,7 +2794,7 @@ def generate_gpio_config(project_data: dict) -> list[str]: lines = [] for port, config in project_data.get("GPIO", {}).items(): name, arguments = generate_gpio_declaration(port, config, project_data) - lines += layout(f"static STM32GPIO {name}", arguments) + lines += layout_statement(f"static STM32GPIO {name}", arguments) if not lines: return [] _use_header("stm32_gpio.hpp") @@ -2919,7 +2923,7 @@ def configure_watchdog(project_data: dict) -> list[str]: "Watchdog.thread_priority", wdg_config.setdefault("thread_priority", 3) ) lines.append(f"{INDENT}static Thread {name}_thread;") - lines += layout( + lines += layout_statement( f"{name}_thread.Create", [ watchdog, @@ -2930,7 +2934,7 @@ def configure_watchdog(project_data: dict) -> list[str]: ], ) else: - lines += layout( + lines += layout_statement( f"static auto {name}_task = Timer::CreateTask", [f"{name}.TaskFun", watchdog, str(feed_interval)], ) @@ -3018,7 +3022,7 @@ def configure_terminal(project_data: dict) -> list[str]: ] if run_as_thread: lines.append(f"{INDENT}static Thread term_thread;") - lines += layout( + lines += layout_statement( "term_thread.Create", [ "&terminal", @@ -3029,7 +3033,7 @@ def configure_terminal(project_data: dict) -> list[str]: ], ) else: - lines += layout( + lines += layout_statement( "static auto terminal_task = Timer::CreateTask", ["terminal.TaskFun", "&terminal", "10"], ) @@ -3296,13 +3300,13 @@ def generate_full_code( Generate the full content of the app_main source file and put back the User Code bodies of the existing code. - 输出已按 LibXR 的 clang-format 风格排版(见 libxr.cpp_layout),不再用 clang-format 和 + 输出已按 LibXR 的 clang-format 风格排版(见 xr_syntax.format),不再用 clang-format 和 NOLINT 标记保护。启用 XRobot 时为每个生成的对象输出 XR_REGISTER,并在 User Code 3 之后调用 XROBOT_MAIN();否则 User Code 3 的默认内容是一个无限休眠的循环。启用 XRobot 时,已有代码的 User Code 3 若仍是这个循环(或 libxr 5.x 的默认内容 XRobotMain(peripherals);)则被清空,否则 XROBOT_MAIN() 永远执行不到。User Code 中其他用到 HardwareContainer 的代码被拒绝(见 reject_user_hardware_container())。flash_map 为假表示没有 flash_map.hpp,这时不能启用数据库。 - The output is laid out in the clang-format style of LibXR (see libxr.cpp_layout) and is + The output is laid out in the clang-format style of LibXR (see xr_syntax.format) and is not protected by clang-format and NOLINT markers. With XRobot every generated object gets an XR_REGISTER line and XROBOT_MAIN() is called after User Code 3; otherwise the default body of User Code 3 is a loop that sleeps forever. With XRobot, a User Code 3 of the diff --git a/tests/test_cpp_layout.py b/tests/test_cpp_layout.py deleted file mode 100644 index 9a2e0b9..0000000 --- a/tests/test_cpp_layout.py +++ /dev/null @@ -1,233 +0,0 @@ -"""生成语句的排版(libxr.cpp_layout):与 clang-format 在 LibXR 的风格下断行相同。 -The layout of generated statements (libxr.cpp_layout): the same line breaks as clang-format -makes in the LibXR style. -""" - -import random -import unittest - -from fixtures import TestCase, clang_format, requires_clang_format - -from libxr.cpp_layout import Braces, layout - - -def format_lines(statements: list[str]) -> list[list[str]]: - """clang-format 对每条语句的排版:各行,缩进 2 列。 - The layout clang-format gives each statement: its lines, indented by 2 columns. - - 每条语句放进自己的函数,两侧各有一条语句,使函数体不会被合并成一行。 - Each statement goes into a function of its own with a statement on either side, so that the - body is not merged into one line. - """ - source = "".join( - f"void f{number}()\n{{\n int before = 0;\n {statement}\n int after = 0;\n}}\n" - for number, statement in enumerate(statements) - ) - formatted = clang_format(source, "layout.cpp") - results = [] - for chunk in formatted.split("void f")[1:]: - results.append(chunk.rstrip("\n").split("\n")[3:-2]) - return results - - -def statement(head: str, args: list) -> str: - """语句 ``head(args);`` 写在一行里的文本。 - The text of the statement ``head(args);`` written on one line. - """ - text = ", ".join(arg if isinstance(arg, str) else arg.text() for arg in args) - return f"{head}({text});" - - -class Layout(TestCase): - """语句写在一行里放不下时的断行。 - Line breaks when a statement does not fit on one line. - """ - - def test_a_statement_that_fits_stays_on_one_line(self): - self.assertEqual( - layout("static STM32SPI spi1", ["&hspi1", "spi1_rx_buf", "spi1_tx_buf", "3"]), - [" static STM32SPI spi1(&hspi1, spi1_rx_buf, spi1_tx_buf, 3);"], - ) - self.assertEqual(layout("PlatformInit", []), [" PlatformInit();"]) - - def test_arguments_are_packed_and_aligned_after_the_parenthesis(self): - endpoint = "USB::Endpoint::EPNumber::EP" - self.assertEqual( - layout( - "static USB::CDCUart usb_otg_fs_cdc", - [endpoint + "1", endpoint + "1", endpoint + "2", "128", "128", "3"], - ), - [ - " static USB::CDCUart usb_otg_fs_cdc(USB::Endpoint::EPNumber::EP1,", - " USB::Endpoint::EPNumber::EP1,", - " USB::Endpoint::EPNumber::EP2, 128, 128, 3);", - ], - ) - - def test_a_long_name_breaks_after_the_parenthesis(self): - self.assertEqual( - layout( - "static STM32UART usart_very_long_name_number_one", - [ - "&huart1", - "usart_very_long_name_number_one_rx_buf", - "usart_very_long_name_number_one_tx_buf", - "5", - ], - ), - [ - " static STM32UART usart_very_long_name_number_one(", - " &huart1, usart_very_long_name_number_one_rx_buf,", - " usart_very_long_name_number_one_tx_buf, 5);", - ], - ) - - def test_an_assignment_breaks_after_the_parenthesis_or_the_equals_sign(self): - self.assertEqual( - layout( - "static constexpr auto usb_otg_fs_strings = USB::DescriptorStrings::MakeLanguagePack", - [ - "USB::DescriptorStrings::Language::EN_US", - '"QDU-Future"', - '"MainCtrl"', - '"QDU-Future-MainCtrl-89ABCDEF0123456701234567"', - ], - ), - [ - " static constexpr auto usb_otg_fs_strings = USB::DescriptorStrings::MakeLanguagePack(", - ' USB::DescriptorStrings::Language::EN_US, "QDU-Future", "MainCtrl",', - ' "QDU-Future-MainCtrl-89ABCDEF0123456701234567");', - ], - ) - - def test_a_list_of_braced_lists_breaks_at_every_item(self): - entries = [Braces(f"usb_otg_hs_ep{n}_in_buf", "128") for n in range(4)] - self.assertEqual( - layout("x.Create", ["a", Braces(*entries)]), - [ - " x.Create(a, {{usb_otg_hs_ep0_in_buf, 128},", - " {usb_otg_hs_ep1_in_buf, 128},", - " {usb_otg_hs_ep2_in_buf, 128},", - " {usb_otg_hs_ep3_in_buf, 128}});", - ], - ) - - def test_a_list_with_a_braced_list_among_its_items_breaks_at_every_item(self): - buffers = [f"usb_otg_hs_ep{n}_out_buf" for n in range(4)] - self.assertEqual( - layout("static T x", ["&hpcd", "256", Braces(Braces(buffers[0], "8"), *buffers[1:])]), - [ - " static T x(&hpcd, 256,", - " {{usb_otg_hs_ep0_out_buf, 8},", - " usb_otg_hs_ep1_out_buf,", - " usb_otg_hs_ep2_out_buf,", - " usb_otg_hs_ep3_out_buf});", - ], - ) - - def test_a_list_that_spans_lines_starts_a_line_after_two_arguments(self): - entries = [Braces(f"usb_otg_hs_ep{n}_in_buf", "128") for n in range(4)] - self.assertEqual( - layout("x.Create", ["a", "b", Braces(*entries)]), - [ - " x.Create(a, b,", - " {{usb_otg_hs_ep0_in_buf, 128},", - " {usb_otg_hs_ep1_in_buf, 128},", - " {usb_otg_hs_ep2_in_buf, 128},", - " {usb_otg_hs_ep3_in_buf, 128}});", - ], - ) - - def test_the_argument_after_a_list_that_spans_lines_starts_a_line(self): - channels = Braces(*[f"ADC_CHANNEL_{n}" for n in range(6)]) - self.assertEqual( - layout("static STM32ADC adc3", ["&hadc3", "adc3_buf", channels, "3.3"]), - [ - " static STM32ADC adc3(&hadc3, adc3_buf,", - " {ADC_CHANNEL_0, ADC_CHANNEL_1, ADC_CHANNEL_2, ADC_CHANNEL_3,", - " ADC_CHANNEL_4, ADC_CHANNEL_5},", - " 3.3);", - ], - ) - - -@requires_clang_format -class AgainstClangFormat(TestCase): - """排版与固定版本的 clang-format 的结果逐行相同。 - The layout equals the result of the pinned clang-format line by line. - """ - - @staticmethod - def atom(rng: random.Random, length: int | None = None) -> str: - """随机的不可拆分参数:标识符、数字、字符串或带 & 的句柄;length 给出时按这个长度。 - A random unbreakable argument: an identifier, a number, a string or a handle with &; of - the given length when length is set. - """ - length = length or rng.choice([1, 2, 3, 5, 8, 12, 16, 20, 25, 30, 38, 45]) - kind = rng.random() - if kind < 0.5: - prefix = rng.choice(["usart", "buf", "spi", "usb_otg_hs_ep", "ADC_CHANNEL_"]) - return prefix + "x" * length if rng.random() < 0.5 else prefix + str(rng.randint(0, 99)) - if kind < 0.7: - return str(rng.randint(0, 10 ** rng.randint(1, 6))) - if kind < 0.8: - return "&h" + "a" * length - if kind < 0.9: - return '"' + "s" * length + '"' - return "a" * length - - def items(self, rng: random.Random) -> list[str]: - """花括号列表的随机元素:同一个前缀加序号,如通道列表和缓冲区列表。 - Random items of a braced list: one prefix and a running number, like a list of channels - or of buffers. - """ - prefix = rng.choice(["ADC_CHANNEL_", "usb_otg_hs_ep", "buf", "spi_" + "x" * 12]) - return [prefix + str(number) for number in range(rng.randint(1, 8))] - - def argument(self, rng: random.Random) -> "str | Braces": - """随机的参数:不可拆分的文本,或元素为文本、文本列表的花括号列表。 - A random argument: unbreakable text, or a braced list of text or lists of text. - """ - if rng.random() < 0.35: - if rng.random() < 0.2: - return Braces(Braces(self.atom(rng), str(rng.randint(8, 256))), *self.items(rng)) - if rng.random() < 0.4: - return Braces( - *[ - Braces(self.atom(rng), str(rng.randint(8, 256))) - for _ in range(rng.randint(1, 6)) - ] - ) - return Braces(*self.items(rng)) - return self.atom(rng) - - def test_random_statements_break_where_clang_format_breaks(self): - rng = random.Random(20261003) - cases = [] - for _ in range(400): - name = "n" * rng.randint(2, 30) - head = rng.choice( - [ - f"static STM32T {name}", - f"{name}.Create", - f"static constexpr auto {name} = A::B::Make", - ] - ) - # 赋值只用于带文本参数的 MakeLanguagePack,不带列表。 - # An assignment is written only for MakeLanguagePack, whose arguments are text and - # no lists. - arguments = [self.argument(rng) for _ in range(rng.randint(2, 9))] - if "=" in head: - arguments = [self.atom(rng) for _ in arguments] - cases.append((head, arguments)) - expected = format_lines([statement(head, args) for head, args in cases]) - mismatches = [ - statement(head, args) - for (head, args), lines in zip(cases, expected, strict=True) - if layout(head, args) != lines - ] - self.assertEqual(mismatches, []) - - -if __name__ == "__main__": - unittest.main() From f13b3ced8d494988908c84a95492e1499ff1ad98 Mon Sep 17 00:00:00 2001 From: Jiu-xiao <2592509183@qq.com> Date: Tue, 13 Oct 2026 16:35:52 +0100 Subject: [PATCH 2/3] Assemble the generated C++ files with CppFileBuilder app_main.cpp, app_main.h and flash_map.hpp are built through the shared file builder, which parses the whole file once at the build boundary; app_main.h and flash_map.hpp hold no user code, so they require a clean parse. HPM and MSPM0 assemble app_main.cpp through the same helper as STM32. The generated output is unchanged: the clang-format format tests and the generator tests pass. --- src/libxr/generator_code_hpm.py | 4 +- src/libxr/generator_code_mspm0.py | 4 +- src/libxr/generator_code_stm32.py | 65 ++++++++++++++++++++++-------- tests/test_generator_code_hpm.py | 20 +++++++++ tests/test_generator_code_mspm0.py | 20 +++++++++ 5 files changed, 92 insertions(+), 21 deletions(-) diff --git a/src/libxr/generator_code_hpm.py b/src/libxr/generator_code_hpm.py index ccbebb8..acb3a69 100644 --- a/src/libxr/generator_code_hpm.py +++ b/src/libxr/generator_code_hpm.py @@ -627,7 +627,7 @@ def generate_full_code(project_data: dict, use_xrobot: bool, existing_code: str) if use_xrobot: lines.append(f"{INDENT}XROBOT_MAIN();") lines.append("}") - generated = "\n".join(lines) + "\n" + generated = stm32._assemble_source(lines) # 与生成代码里其他名字相同的 GPIO 名字(固定名单之外的):数一数出现次数。 # GPIO names equal to another name of the generated code (beyond the fixed list): count # the occurrences. @@ -730,7 +730,7 @@ def generate( os.path.basename(output_path): generate_full_code( project_data, use_xrobot, existing_code ), - "app_main.h": stm32.APP_MAIN_HEADER, + "app_main.h": stm32.app_main_header(), "libxr_config.yaml": stm32.libxr_config_text(), } diff --git a/src/libxr/generator_code_mspm0.py b/src/libxr/generator_code_mspm0.py index c241455..7683c8e 100644 --- a/src/libxr/generator_code_mspm0.py +++ b/src/libxr/generator_code_mspm0.py @@ -945,7 +945,7 @@ def generate_full_code(project_data: dict, use_xrobot: bool, existing_code: str) if use_xrobot: lines.append(f"{INDENT}XROBOT_MAIN();") lines.append("}") - generated = "\n".join(lines) + "\n" + generated = stm32._assemble_source(lines) # 与生成代码里其他名字(timebase、ramfs、terminal、时钟常量……)相同的 GPIO 名字。 # GPIO names equal to another name of the generated code (timebase, ramfs, terminal, the # clock constants, ...). @@ -1075,7 +1075,7 @@ def generate( _warn_hand_written(output_path, existing_code, code) files = { os.path.basename(output_path): code, - "app_main.h": stm32.APP_MAIN_HEADER, + "app_main.h": stm32.app_main_header(), "libxr_config.yaml": stm32.libxr_config_text(), } diff --git a/src/libxr/generator_code_stm32.py b/src/libxr/generator_code_stm32.py index 6342551..21e5f30 100644 --- a/src/libxr/generator_code_stm32.py +++ b/src/libxr/generator_code_stm32.py @@ -19,7 +19,7 @@ from dataclasses import dataclass, replace import yaml -from xr_syntax.cpp import CppDocument, identifier_occurrences +from xr_syntax.cpp import CppDocument, CppFileBuilder, identifier_occurrences from xr_syntax.format import COLUMN_LIMIT, Braces, layout_statement from xr_syntax.i18n import tr @@ -3372,16 +3372,32 @@ def generate_full_code( if use_xrobot: lines.append(f"{INDENT}XROBOT_MAIN();") lines.append("}") - generated = "\n".join(lines) + "\n" + generated = _assemble_source(lines) check_gpio_names(project_data, generated, use_xrobot) return _preserve_generated_regions(existing_code, generated) -APP_MAIN_HEADER = ( - "#pragma once\n" - + GENERATED_NOTICE - + "\n" - + """ +def _assemble_source(lines: list[str]) -> str: + """把生成的行交给 CppFileBuilder 拼成整份文件:每一行原样追加,最后解析一次。 + Hand the generated lines to CppFileBuilder: every line is appended as written and the whole + file is parsed once at the end. + + 结果里有用户手写的 User Code 区域,所以不要求解析结果没有诊断;解析一次是为了让生成结果和读取 + 已有源码走同一套模型,生成器写出自己读不了的东西时能立刻发现。 + The result holds hand-written User Code regions, so a clean parse is not required; the one parse + keeps the generated file and the reading of existing source on the same model, so a generator + that writes something it cannot read is caught at once. + """ + builder = CppFileBuilder() + for line in lines: + builder.raw(line) + return builder.build().render() + + +# app_main.h 中 extern "C" 声明的正文;pragma once、生成说明和空行由 app_main_header() 拼上。 +# The body of the extern "C" declaration of app_main.h; app_main_header() adds pragma once, the +# generated-file notice and the blank line. +APP_MAIN_HEADER_BODY = """\ #ifdef __cplusplus extern "C" { @@ -3391,9 +3407,20 @@ def generate_full_code( #ifdef __cplusplus } -#endif -""" -) +#endif""" + + +def app_main_header() -> str: + """app_main.h 的内容:pragma once、生成说明和 extern "C" 的声明。 + The content of app_main.h: pragma once, the generated-file notice and the extern "C" + declaration. + """ + builder = CppFileBuilder() + builder.directive("#pragma once") + builder.raw(GENERATED_NOTICE) + builder.raw("") + builder.raw(APP_MAIN_HEADER_BODY) + return builder.build(require_clean=True).render() def generate_flash_map_cpp(flash_info: dict) -> str: @@ -3473,12 +3500,16 @@ def flash_map_header(flash_info: dict, mcu_model: str) -> str: The content of flash_map.hpp: the generated-file notice, the MCU type, the includes and the code of generate_flash_map_cpp(). """ - return ( - f"#pragma once\n{GENERATED_NOTICE}\n// MCU: {mcu_model}\n\n" - '#include "main.h"\n#include "stm32_flash.hpp"\n\n' - + generate_flash_map_cpp(flash_info) - + "\n" - ) + builder = CppFileBuilder() + builder.directive("#pragma once") + builder.raw(GENERATED_NOTICE) + builder.raw(f"// MCU: {mcu_model}") + builder.raw("") + builder.include("main.h") + builder.include("stm32_flash.hpp") + builder.raw("") + builder.raw(generate_flash_map_cpp(flash_info)) + return builder.build(require_clean=True).render() def _remove_generated(path: str) -> bool: @@ -3622,7 +3653,7 @@ def generate( os.path.basename(output_path): generate_full_code( project_data, use_xrobot, existing_code, flash_info is not None ), - "app_main.h": APP_MAIN_HEADER, + "app_main.h": app_main_header(), } if flash_info is None: files["flash_map.hpp"] = None diff --git a/tests/test_generator_code_hpm.py b/tests/test_generator_code_hpm.py index 3226c70..1022546 100644 --- a/tests/test_generator_code_hpm.py +++ b/tests/test_generator_code_hpm.py @@ -654,3 +654,23 @@ def test_the_notice_says_when_main_calls_no_pinmux_function(self): if __name__ == "__main__": unittest.main() + + +class GeneratedThroughTheFileBuilder(Generation): + """app_main 由 CppFileBuilder 拼装,因此生成结果能被 xr-syntax 自己解析。 + app_main is assembled by CppFileBuilder, so the generated source is something xr-syntax can + parse itself. + """ + + def test_the_generated_source_parses_without_diagnostics(self): + from xr_syntax.cpp import CppDocument + + _root, code = self.generate("hpm5301evklite") + document = CppDocument.parse(code.encode("utf-8")) + self.assertEqual(document.diagnostics, ()) + self.assertEqual(document.render_bytes(), code.encode("utf-8")) + + def test_the_regenerated_source_is_the_same_bytes(self): + _root, code = self.generate("hpm5301evklite") + root, _ = self.generate("hpm5301evklite") + self.assertEqual((root / "User" / "app_main.cpp").read_bytes(), code.encode("utf-8")) diff --git a/tests/test_generator_code_mspm0.py b/tests/test_generator_code_mspm0.py index 4964e85..8e104f0 100644 --- a/tests/test_generator_code_mspm0.py +++ b/tests/test_generator_code_mspm0.py @@ -991,3 +991,23 @@ def test_a_numbered_dma_trigger_names_the_channel_as_before(self): } self.assertEqual(analyzer._header_dma_channel("UART_0", "TX", defines), "DMA_CH_UART0_TX") self.assertEqual(analyzer._header_dma_channel("UART_0", "RX", defines), "DMA_CH_UART0_RX") + + +class GeneratedThroughTheFileBuilder(Generation): + """app_main 由 CppFileBuilder 拼装,因此生成结果能被 xr-syntax 自己解析。 + app_main is assembled by CppFileBuilder, so the generated source is something xr-syntax can + parse itself. + """ + + def test_the_generated_source_parses_without_diagnostics(self): + from xr_syntax.cpp import CppDocument + + _root, code = self.generate("g3507") + document = CppDocument.parse(code.encode("utf-8")) + self.assertEqual(document.diagnostics, ()) + self.assertEqual(document.render_bytes(), code.encode("utf-8")) + + def test_the_regenerated_source_is_the_same_bytes(self): + _root, code = self.generate("g3507") + root, _ = self.generate("g3507") + self.assertEqual((root / "User" / "app_main.cpp").read_bytes(), code.encode("utf-8")) From ac2201aad0df2e15e1f0cca1a8bde55268189e18 Mon Sep 17 00:00:00 2001 From: Jiu-xiao <2592509183@qq.com> Date: Tue, 13 Oct 2026 16:35:54 +0100 Subject: [PATCH 3/3] Read and write LibXR.CMake through the CMake frontend libxr.cmake_text is gone: LibXR.CMake is read and written with CMakeDocument and CMakeFileBuilder, so the block structure, the User Code regions and the settings are found by the CMake lexer instead of by regular expressions over whole lines. The settings keep the user's writing: LIBXR_SYSTEM is written into the existing line, LIBXR_DRIVER and the optimization levels are added after the line they belong to, and XROBOT_MODULES_DIR follows the entry source and is removed together with the line it is on. Both branches check the text they are about to write with the very same checks the next read applies, so a file the generator would refuse is never written, and the notice names the two User Code regions that a user may edit. --- src/libxr/cmake_text.py | 175 --------- src/libxr/generator_stm32_cmake.py | 536 +++++++++++++++++++++------- tests/test_cmake_text.py | 47 --- tests/test_generator_stm32_cmake.py | 410 ++++++++++++++++++++- 4 files changed, 805 insertions(+), 363 deletions(-) delete mode 100644 src/libxr/cmake_text.py delete mode 100644 tests/test_cmake_text.py diff --git a/src/libxr/cmake_text.py b/src/libxr/cmake_text.py deleted file mode 100644 index dfc9017..0000000 --- a/src/libxr/cmake_text.py +++ /dev/null @@ -1,175 +0,0 @@ -"""把 CMake 脚本切成顶层语句,供 libxr stm32 cmake 迁移已有的 LibXR.CMake。 -Split a CMake script into top-level statements, so that libxr stm32 cmake can migrate an -existing LibXR.CMake. - -只做这件事需要的最少解析:注释(# 行注释和 #[[ ]] 括号注释)、引号和括号参数、嵌套的圆括号, -以及 if/foreach/while/function/macro 到各自结束命令的块。不展开变量,也不检查语法。 -It parses only as much as that needs: comments (# line comments and #[[ ]] bracket comments), -quoted and bracket arguments, nested parentheses, and the blocks from if, foreach, while, -function and macro to their end commands. Variables are not expanded and the syntax is not -checked. -""" - -import re -from dataclasses import dataclass - -# 开启一个块的命令及其结束命令。 -# The commands that open a block and the commands that end it. -_BLOCKS = { - "if": "endif", - "foreach": "endforeach", - "while": "endwhile", - "function": "endfunction", - "macro": "endmacro", - "block": "endblock", -} -_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") -_BRACKET_OPEN = re.compile(r"\[(=*)\[") - - -@dataclass(frozen=True) -class Statement: - """一条顶层语句:一个命令,或 if/foreach 等一直到结束命令的块。 - One top-level statement: a command, or a block such as if or foreach up to its end command. - - name 是第一个命令的名字(小写),args 是它括号中的文本;start 和 end 是语句在原文中的范围, - 不含行末的换行;leading 为语句紧上方连续的注释行起点,没有注释时为语句所在行的行首。 - name is the name of the first command, in lower case, and args the text in its parentheses; - start and end are the range of the statement in the text, without the newline at its end; - leading is the start of the comment lines directly above the statement, the start of the - line of the statement without any. - """ - - name: str - args: str - start: int - end: int - leading: int - - -def _skip_comment(text: str, index: int) -> int: - """index 处的 # 注释结束的位置:括号注释到对应的 ]],行注释到行尾。 - The end of the # comment at index: a bracket comment ends at its closing ]], a line comment - at the end of the line. - """ - opener = _BRACKET_OPEN.match(text, index + 1) - if opener: - close = "]" + opener.group(1) + "]" - found = text.find(close, opener.end()) - return len(text) if found < 0 else found + len(close) - found = text.find("\n", index) - return len(text) if found < 0 else found - - -def _skip_arguments(text: str, index: int) -> int: - """index 处的 ( 对应的 ) 之后的位置;引号、括号参数和注释中的括号不计。 - The position after the ) that matches the ( at index; parentheses in quotes, bracket - arguments and comments do not count. - - 没有匹配的 ) 时为文本末尾。 - At the end of the text when no ) matches. - """ - depth = 0 - position = index - while position < len(text): - char = text[position] - if char == "(": - depth += 1 - elif char == ")": - depth -= 1 - if depth == 0: - return position + 1 - elif char == '"': - position += 1 - while position < len(text) and text[position] != '"': - position += 2 if text[position] == "\\" else 1 - elif char == "#": - position = _skip_comment(text, position) - 1 - elif char == "[": - opener = _BRACKET_OPEN.match(text, position) - if opener: - close = "]" + opener.group(1) + "]" - found = text.find(close, opener.end()) - position = (len(text) if found < 0 else found + len(close)) - 1 - position += 1 - return len(text) - - -def commands(text: str) -> list[tuple[str, str, int, int]]: - """文本中的全部命令(含块内的),依次为 (名字, 参数文本, 起点, 终点);名字为小写。 - All commands of the text, those inside blocks included, as (name, argument text, start, - end) in order; the name is in lower case. - """ - found = [] - position = 0 - while position < len(text): - char = text[position] - if char == "#": - position = _skip_comment(text, position) - continue - match = _NAME.match(text, position) - if match is None: - position += 1 - continue - after = match.end() - while after < len(text) and text[after] in " \t": - after += 1 - if after < len(text) and text[after] == "(": - end = _skip_arguments(text, after) - found.append((match.group().lower(), text[after + 1 : end - 1], position, end)) - position = end - else: - position = match.end() - return found - - -def statements(text: str) -> list[Statement]: - """文本的顶层语句:命令,以及 if/foreach/while/function/macro/block 到结束命令的整块。 - The top-level statements of the text: commands, and whole blocks from if, foreach, while, - function, macro or block to their end command. - - 块中的命令不单独列出。leading 记下语句紧上方没有空行隔开的注释行,迁移时随语句一起保留。 - Commands inside a block are not listed on their own. leading records the comment lines - directly above a statement with no blank line between, which move along with the statement - when it is kept. - """ - result = [] - depth = 0 - opener: tuple[str, str, int] | None = None - for name, args, start, end in commands(text): - if depth == 0: - if name in _BLOCKS: - opener = (name, args, start) - depth = 1 - continue - result.append(Statement(name, args, start, end, _leading(text, start))) - continue - if name in _BLOCKS: - depth += 1 - elif name in _BLOCKS.values(): - depth -= 1 - if depth == 0 and opener is not None: - result.append( - Statement(opener[0], opener[1], opener[2], end, _leading(text, opener[2])) - ) - opener = None - if opener is not None: - result.append( - Statement(opener[0], opener[1], opener[2], len(text), _leading(text, opener[2])) - ) - return result - - -def _leading(text: str, start: int) -> int: - """start 所在行紧上方连续的注释行的起点;上方不是注释行时为 start。 - The start of the comment lines directly above the line of start; start itself when the line - above is not a comment. - """ - line_start = text.rfind("\n", 0, start) + 1 - leading = line_start - while leading > 0: - previous = text.rfind("\n", 0, leading - 1) + 1 - if not text[previous : leading - 1].lstrip().startswith("#"): - break - leading = previous - return leading if text[line_start:start].strip() == "" else start diff --git a/src/libxr/generator_stm32_cmake.py b/src/libxr/generator_stm32_cmake.py index 472ea79..75b83a8 100644 --- a/src/libxr/generator_stm32_cmake.py +++ b/src/libxr/generator_stm32_cmake.py @@ -8,16 +8,17 @@ CubeMX wrote, and includes LibXR.CMake from CMakeLists.txt. CMake reconfigures existing build directories on their next build after these files change. -LibXR.CMake 的结构固定:“Project settings”块在最前,其后依次是 LibXR、Application、库的优化 -设置和固件镜像(链接后由 ELF 生成 .hex 和 .bin)。重写时保留 “Project settings” 块中用户改过的 -值;已有的旧格式文件迁移到这个结构,设置的值和用到的优化级别都保留,不认识的语句放在末尾的 -“Kept” 块中保留。 -The structure of LibXR.CMake is fixed: the "Project settings" block comes first, followed by -LibXR, Application, the optimization of the libraries and the firmware images (the .hex and -.bin made from the ELF after linking). Rewriting keeps the values the user changed in the -"Project settings" block; a file in an earlier format migrates to this structure, keeping the -values of the settings and the optimization levels it uses, and the statements it does not -recognize are kept in a "Kept" block at the end. +LibXR.CMake 的结构固定:“Project settings”块在最前,其后依次是编译选项的 User Code 区域、LibXR、 +Application、库的优化设置和固件镜像(链接后由 ELF 生成 .hex 和 .bin),最后是应用设置的 User Code 区域。 +重写时保留 “Project settings” 块中用户改过的值和两个 User Code 区域的内容;已有的旧格式文件迁移到这个 +结构,设置的值和用到的优化级别都保留,不认识的语句放在末尾的 “Kept” 块中保留。 +The structure of LibXR.CMake is fixed: the "Project settings" block comes first, followed by the +User Code region of the compile options, LibXR, Application, the optimization of the libraries and the +firmware images (the .hex and .bin made from the ELF after linking), and the User Code region of the +application settings at the end. Rewriting keeps the values the user changed in the "Project settings" +block and the contents of the two User Code regions; a file in an earlier format migrates to this +structure, keeping the values of the settings and the optimization levels it uses, and the statements +it does not recognize are kept in a "Kept" block at the end. """ import logging @@ -26,26 +27,53 @@ import sys from pathlib import Path +from xr_syntax.cmake import ( + CMakeCommandView, + CMakeDocument, + CMakeFileBuilder, + CMakeStatementView, +) from xr_syntax.cpp import CppDocument from xr_syntax.i18n import tr -from libxr import cmake_text - -# LibXR.CMake 第一行:说明文件由 libxr stm32 setup 生成,只有设置块供用户修改。 -# The first line of LibXR.CMake: the file is generated by libxr stm32 setup, and only the -# settings block is for the user to edit. +# LibXR.CMake 第一行:说明文件由 libxr stm32 setup 生成,供用户修改的是设置块和两个 User Code 区域。 +# The first line of LibXR.CMake: the file is generated by libxr stm32 setup, and the settings block +# and the two User Code regions are the parts for the user to edit. +# +# 这一行同时是格式哨兵:split_blocks() 用它区分新格式和旧格式。加上 User Code 区域之前的写法也仍然算 +# 新格式(EARLIER_GENERATED_LINE),因此老文件走的是重写而不是迁移。 +# The same line is the format sentinel: split_blocks() tells the current format from an earlier one +# by it. The spelling from before the User Code regions were added still counts as the current format +# (EARLIER_GENERATED_LINE), so an older file is rewritten rather than migrated. GENERATED_LINE = ( + '# Generated by `libxr stm32 setup`; edit the "Project settings" block and the User Code ' + "regions." +) +EARLIER_GENERATED_LINE = ( '# Generated by `libxr stm32 setup`; edit the values in the "Project settings" block only.' ) +GENERATED_LINES = (GENERATED_LINE, EARLIER_GENERATED_LINE) # 块标题的宽度。 # The width of the block titles. RULE_WIDTH = 80 SETTINGS_TITLE = "Project settings" +COMPILE_OPTIONS_TITLE = "Compile options" LIBXR_TITLE = "LibXR" APPLICATION_TITLE = "Application" OPTIMIZATION_TITLE = "Library optimization" FIRMWARE_TITLE = "Firmware images" KEPT_TITLE = "Kept from the earlier LibXR.CMake" +APPLICATION_SETTINGS_TITLE = "Application settings" +# 两个 User Code 区域的名字:重新生成时保留其中的内容。 +# The names of the two User Code regions whose contents survive a rewrite. +USER_REGION_COMPILE_OPTIONS = "CompileOptions" +USER_REGION_APPLICATION_SETTINGS = "ApplicationSettings" +USER_REGIONS = (USER_REGION_COMPILE_OPTIONS, USER_REGION_APPLICATION_SETTINGS) +# 标记:与 C++ 侧的 User Code 标记一致地区分大小写,注释语法换成 #。 +# The markers: case-sensitive exactly like the C++ User Code markers, with # as the comment syntax. +_USER_MARKER_TEXT = re.compile(r"#\s*User Code (?:Begin|End)\b") +_USER_MARKER_BEGIN = re.compile(r"#\s*User Code Begin(?:\s+(.+?))?\s*") +_USER_MARKER_END = re.compile(r"#\s*User Code End(?:\s+(.+?))?\s*") # 新工程的默认优化级别:应用代码在 Debug 构建中的级别和 Release 构建的级别;空字符串表示沿用 # 工具链文件中的级别(CubeMX 写出的 gcc 为 -Os,ST Arm Clang 为 -Oz)。 # The default optimization levels of a new project: that of the application code in Debug builds @@ -117,11 +145,9 @@ include_cmake_cmd = "include(${CMAKE_CURRENT_LIST_DIR}/cmake/LibXR.CMake)\n" -# 非注释行中任意写法的 include(...LibXR.CMake)。 -# An include(...LibXR.CMake) in any spelling on a line that is not a comment. -_LIBXR_CMAKE_INCLUDE = re.compile( - r"^[ \t]*include[ \t]*\([^)#\n]*LibXR\.CMake[^)\n]*\)", re.IGNORECASE | re.MULTILINE -) +# 被 include 的文件的写法,与 CMakeLists.txt 中实际写的大小写无关。 +# The spelling of the included file, whatever case the CMakeLists.txt actually writes. +_LIBXR_CMAKE_NAME = "libxr.cmake" def includes_libxr_cmake(content: str) -> bool: @@ -134,7 +160,11 @@ def includes_libxr_cmake(content: str) -> bool: commented-out line does not. Comparing whole lines would append a second include for these spellings, and LibXR would then be added twice and fail the CMake configure. """ - return _LIBXR_CMAKE_INCLUDE.search(content) is not None + for view in CMakeDocument.parse(content).command_views("include"): + for argument in view.arguments: + if _LIBXR_CMAKE_NAME in argument.text.casefold(): + return True + return False def project_uses_xrobot(input_directory: str) -> bool: @@ -195,33 +225,52 @@ def _rule(title: str) -> str: return head + "-" * max(4, RULE_WIDTH - len(head)) -def render_libxr_cmake(settings: str, kept: str = "") -> str: +def render_libxr_cmake( + settings: str, + kept: str = "", + compile_options: str | None = None, + application_settings: str | None = None, +) -> str: """LibXR.CMake 的完整内容:settings 是 “Project settings” 块的内容,kept 是末尾 “Kept” 块的 - 内容(为空时没有这个块)。 - The complete content of LibXR.CMake: settings is the content of the "Project settings" - block and kept the content of the "Kept" block at the end (without that block when empty). + 内容(为空时没有这个块),compile_options 和 application_settings 是两个 User Code 区域的内容 + (None 表示空区域)。 + The complete content of LibXR.CMake: settings is the content of the "Project settings" block + and kept the content of the "Kept" block at the end (without that block when empty); + compile_options and application_settings are the contents of the two User Code regions (None + for an empty region). + + 各节的顺序和拼装都交给 CMakeFileBuilder,它在 build() 时把整份源码解析一次。 + CMakeFileBuilder writes the sections in order and assembles them, parsing the whole source once + in build(). """ - parts = [ - GENERATED_LINE, - "", - _rule(SETTINGS_TITLE), - settings.strip("\n"), - "", - _rule(LIBXR_TITLE), - LIBXR_BLOCK, - "", - _rule(APPLICATION_TITLE), - APPLICATION_BLOCK, - "", - _rule(OPTIMIZATION_TITLE), - OPTIMIZATION_BLOCK, - "", - _rule(FIRMWARE_TITLE), - FIRMWARE_BLOCK, - ] + builder = CMakeFileBuilder() + builder.raw(GENERATED_LINE) + builder.raw("") + builder.raw(_rule(SETTINGS_TITLE)) + builder.raw(settings.strip("\n")) + builder.raw("") + builder.raw(_rule(COMPILE_OPTIONS_TITLE)) + builder.user_region(USER_REGION_COMPILE_OPTIONS, compile_options) + builder.raw("") + builder.raw(_rule(LIBXR_TITLE)) + builder.raw(LIBXR_BLOCK) + builder.raw("") + builder.raw(_rule(APPLICATION_TITLE)) + builder.raw(APPLICATION_BLOCK) + builder.raw("") + builder.raw(_rule(OPTIMIZATION_TITLE)) + builder.raw(OPTIMIZATION_BLOCK) + builder.raw("") + builder.raw(_rule(FIRMWARE_TITLE)) + builder.raw(FIRMWARE_BLOCK) if kept.strip(): - parts += ["", _rule(KEPT_TITLE), kept.strip("\n")] - return "\n".join(parts) + "\n" + builder.raw("") + builder.raw(_rule(KEPT_TITLE)) + builder.raw(kept.strip("\n")) + builder.raw("") + builder.raw(_rule(APPLICATION_SETTINGS_TITLE)) + builder.user_region(USER_REGION_APPLICATION_SETTINGS, application_settings) + return builder.build().render() def _setting_lines(opt_debug: str, opt_release: str, user_sources: str) -> list[str]: @@ -250,62 +299,152 @@ def default_settings(system: str, opt_debug: str, opt_release: str) -> str: return "\n".join(lines) + "\n" +def _text(source: bytes, start: int, end: int) -> str: + """source 的 [start, end) 字节范围解码为文本;非 UTF-8 字节用 surrogateescape 保留。 + The byte range [start, end) of source decoded as text, keeping bytes that are not UTF-8 through + surrogateescape. + """ + return source[start:end].decode("utf-8", errors="surrogateescape") + + +def _line_start(source: bytes, offset: int) -> int: + """source 中 offset 所在行的行首字节偏移。 + The byte offset of the start of the line holding offset in source. + """ + return source.rfind(b"\n", 0, offset) + 1 + + +# 分节标题:`# ---- <标题> ----`。 +# A section title: `# ---- ----`. +_SECTION_TITLE = re.compile(r"# ---- (.+?) -{4,}") + + def split_blocks(content: str) -> dict[str, str] | None: - """新格式 LibXR.CMake 的各块:{标题: 内容};第一行不是 GENERATED_LINE(旧格式)时为 None。 - The blocks of a LibXR.CMake in the current format as {title: content}; None when the first - line is not GENERATED_LINE, an earlier format. + """新格式 LibXR.CMake 的各块:{标题: 内容};第一行不是 GENERATED_LINE 或 + EARLIER_GENERATED_LINE(旧格式)时为 None。 + The blocks of a LibXR.CMake in the current format as {title: content}; None when the first line is + neither GENERATED_LINE nor EARLIER_GENERATED_LINE, an earlier format. + + 块由 `# ---- <标题> ----` 形式的注释行划分。注释由 CMake 前端识别,因此块内的同形文字(例如字符串 + 里的 `# ----`)不会被当成分节。 + The sections are delimited by comment lines of the form `# ---- <title> ----`. The CMake frontend + finds the comments, so the same text inside a section (a `# ----` inside a string, say) does not + start one. """ lines = content.split("\n") - if not lines or lines[0].strip() != GENERATED_LINE: + if not lines or lines[0].strip() not in GENERATED_LINES: return None - blocks: dict[str, list[str]] = {} - current = None - for line in lines[1:]: - title = re.fullmatch(r"# ---- (.+?) -{4,}", line.rstrip()) + document = CMakeDocument.parse(content) + source = document.render_bytes() + headers: list[tuple[str, int, int]] = [] + for comment in document.comments(): + title = _SECTION_TITLE.fullmatch(comment.text.strip()) if title: - current = title.group(1) - blocks[current] = [] - elif current is not None: - blocks[current].append(line) - return {title: "\n".join(body).strip("\n") for title, body in blocks.items()} - - -# set(<name> <value>) 的写法:值取到右括号或行尾的注释之前。 -# The spelling of set(<name> <value>): the value runs up to the closing parenthesis or a -# comment at the end of the line. -def _setting(name: str) -> re.Pattern: - """匹配设置块中 set(name ...) 一行的正则;第 1 组为 set( 和名字,第 2 组为值。 - The regular expression for a set(name ...) line of the settings block; group 1 is set( and - the name, group 2 the value. + headers.append((title.group(1), comment.span.start, comment.span.end)) + blocks: dict[str, str] = {} + for index, (title, _, header_end) in enumerate(headers): + end = headers[index + 1][1] if index + 1 < len(headers) else len(source) + blocks[title] = _text(source, header_end, end).strip("\n") + return blocks + + +# 设置块里的 set(...) 语句;命令名与 CMake 一致地大小写不敏感,设置名按写法比较。 +# The set(...) statements of the settings block; the command name is case-insensitive like every +# CMake command, and a setting name is compared as written. +def _has_setting(document: CMakeDocument, name: str) -> bool: + """document 中是否有 `set(<name> ...)`,块内的也算。 + Whether document has a `set(<name> ...)`, those inside blocks included. + + 与旧实现一致:只按整块文本搜索,因此嵌在 if/foreach 里的设置也算存在。 + As before: the search covers the whole block, so a setting nested in an if/foreach counts too. + """ + return any( + view.arguments and view.arguments[0].text == name for view in document.command_views("set") + ) + + +def _find_setting(document: CMakeDocument, name: str) -> CMakeStatementView | None: + """document 中第一条 `set(<name> ...)` 顶层语句;没有时为 None。 + The first top-level `set(<name> ...)` statement of document, or None. + + 插入位置只取顶层语句:嵌在块里的设置行之后不能插入,那会改变块的结构。 + Only a top-level statement is an insertion point: inserting after a setting nested in a block + would change the structure of that block. + """ + for statement in document.top_level_statements(): + if statement.name.casefold() != "set": + continue + if statement.arguments and statement.arguments[0].text == name: + return statement + return None + + +def _statement_of(document: CMakeDocument, view: CMakeCommandView) -> CMakeStatementView: + """view 所在的那条顶层语句;嵌在块里的命令给出包住它的块语句。 + The top-level statement that holds view; a command inside a block gives the block around it. + + 按结构路径比较:red 视图每次访问都是新对象,用 is 比较不可靠。 + The comparison is by structural path: a red view is a new object every time it is reached, so + comparing identity is not reliable. """ - return re.compile(rf"^([ \t]*set[ \t]*\([ \t]*{name}[ \t]+)([^)#\n]*?)([ \t]*\))", re.MULTILINE) + path = view.node.path + for statement in document.top_level_statements(): + if statement.node.path == path or path[: len(statement.node.path)] == statement.node.path: + return statement + raise ValueError("no top-level statement holds the command") def _set_system(settings: str, system: str) -> str: """把设置块中所有 set(LIBXR_SYSTEM ...) 的值换成 system;没有这一行时插在块的开头。 - Replace the value of every set(LIBXR_SYSTEM ...) of the settings block with system; the - line is inserted at the top of the block when it is missing. + Replace the value of every set(LIBXR_SYSTEM ...) of the settings block with system; the line + is inserted at the top of the block when it is missing. + + 只换值本身:命令名、括号、行尾注释和其余空白都不动。没有值的那一行就地补上值——在它前面另插 + 一行会让 CMake 最后读到空值。 + Only the value changes: the command name, the parentheses, a trailing comment and the rest of + the whitespace stay. A line without a value gets one in place: inserting a line before it would + leave CMake reading the empty value at the end. """ - pattern = _setting("LIBXR_SYSTEM") - if pattern.search(settings): - return pattern.sub(lambda m: f"{m.group(1)}{system}{m.group(3)}", settings) - return f"set(LIBXR_SYSTEM {system})\n" + settings + document = CMakeDocument.parse(settings) + found = False + index = 0 + while index < len(document.command_views("set")): + view = document.command_views("set")[index] + index += 1 + arguments = view.arguments + if not arguments or arguments[0].text != "LIBXR_SYSTEM": + continue + found = True + if len(arguments) < 2: + # set(LIBXR_SYSTEM ) 没有值:整条语句换掉,值就地写入。 + # set(LIBXR_SYSTEM ) has no value: the whole statement is replaced and the value is + # written in place. + document = document.replace_statement( + _statement_of(document, view), f"set(LIBXR_SYSTEM {system})\n" + ) + continue + document = document.replace_argument(arguments[1], system) + return document.render() if found else f"set(LIBXR_SYSTEM {system})\n" + settings def _add_missing(settings: str, name: str, line: str, after: str | None = None) -> str: """设置块中没有 set(name ...) 时加入 line:放在 set(after ...) 之后,没有 after 时放在块的末尾。 - Add line when the settings block has no set(name ...): after set(after ...), or at the end - of the block without after. + Add line when the settings block has no set(name ...): after set(after ...), or at the end of + the block without after. + + line 可以带紧邻上方的注释行,插入时一起保留。 + line may carry the comment lines directly above it, which are inserted with it. """ - if _setting(name).search(settings): + document = CMakeDocument.parse(settings) + if _has_setting(document, name): return settings - if after: - match = _setting(after).search(settings) - if match: - end = settings.find("\n", match.end()) - end = len(settings) if end < 0 else end + 1 - return settings[:end] + line + "\n" + settings[end:] - return settings.rstrip("\n") + "\n" + line + "\n" + target = _find_setting(document, after) if after else None + if target is None: + statements = document.top_level_statements() + target = statements[-1] if statements else None + if target is None: + return line + "\n" + settings + return document.insert_after(target, line).render() def _follow_xrobot_choice(settings: str, use_xrobot: bool) -> str: @@ -318,8 +457,8 @@ def _follow_xrobot_choice(settings: str, use_xrobot: bool) -> str: With XRobot and no such setting, XROBOT_MODULES_DIR_LINE is added after LIBXR_DRIVER; without XRobot, an existing setting is removed. Each change is logged. """ - setting = re.compile(r"^[ \t]*set\s*\(\s*XROBOT_MODULES_DIR\b[^\n]*\n?", re.MULTILINE) - if use_xrobot and not setting.search(settings): + document = CMakeDocument.parse(settings) + if use_xrobot and not _has_setting(document, "XROBOT_MODULES_DIR"): logging.info( tr( f"LibXR.CMake: added {XROBOT_MODULES_DIR_LINE}, as User/app_main.cpp uses XRobot", @@ -327,15 +466,33 @@ def _follow_xrobot_choice(settings: str, use_xrobot: bool) -> str: ) ) return _add_missing(settings, "XROBOT_MODULES_DIR", XROBOT_MODULES_DIR_LINE, "LIBXR_DRIVER") - if not use_xrobot and setting.search(settings): + if use_xrobot: + return settings + result = settings + while True: + document = CMakeDocument.parse(result) + target = next( + ( + view + for view in document.command_views("set") + if view.arguments and view.arguments[0].text == "XROBOT_MODULES_DIR" + ), + None, + ) + if target is None: + break + # 块内的这一行同样删掉:只删这一条命令和它所在的那一行,块的其余部分不变。 + # A line inside a block goes too: only that command and the line it starts are removed and + # the rest of the block stays. + result = document.remove_command(target).render() + if result != settings: logging.info( tr( "LibXR.CMake: removed XROBOT_MODULES_DIR, as User/app_main.cpp does not use XRobot", "LibXR.CMake:User/app_main.cpp 没有使用 XRobot,已删除 XROBOT_MODULES_DIR", ) ) - return setting.sub("", settings) - return settings + return result def _complete_settings( @@ -442,13 +599,12 @@ def _is_debug_block(text: str) -> bool: 块中只有 target_compile_options(<目标> PRIVATE -O<级别>) 和 if(TARGET <目标>) 才算。 Only target_compile_options(<target> PRIVATE -O<level>) and if(TARGET <target>) count. """ - commands = cmake_text.commands(text) - if not commands or _canonical(text[commands[0][2] : commands[0][3]]) != ( - 'if(CMAKE_BUILD_TYPE STREQUAL "Debug")' - ): + views = CMakeDocument.parse(text).command_views() + if not views or _canonical(views[0].node.text) != 'if(CMAKE_BUILD_TYPE STREQUAL "Debug")': return False - for name, args, _, _ in commands[1:]: - arguments = args.split() + for view in views[1:]: + arguments = [argument.text for argument in view.arguments] + name = view.name.casefold() option = ( name == "target_compile_options" and len(arguments) == 3 @@ -480,30 +636,26 @@ def _migrate_debug_block(text: str) -> tuple[str | None, str]: "Kept" block, after the "Library optimization" block, so its -O comes later on the compile line and still applies. """ - - def line_start(offset: int) -> int: - """offset 所在行的行首。 - The start of the line holding offset. - """ - return text.rfind("\n", 0, offset) + 1 - - commands = cmake_text.commands(text) + document = CMakeDocument.parse(text) + source = document.render_bytes() + views = document.command_views() app_debug = None kept: list[str] = [] # 正在读的 if(TARGET ...) 的起点,以及其中是否有要保留的选项。 # The start of the if(TARGET ...) being read, and whether it holds an option to keep. guard: int | None = None guard_kept = False - for name, args, start, end in commands[1:-1]: + for view in views[1:-1]: + name = view.name.casefold() if name == "if": - guard, guard_kept = start, False + guard, guard_kept = view.node.span.start, False continue if name == "endif": if guard is not None and guard_kept: - kept.append(text[line_start(guard) : end]) + kept.append(_text(source, _line_start(source, guard), view.node.span.end)) guard = None continue - target, _, level = args.split() + target, _, level = [argument.text for argument in view.arguments] if target == "${CMAKE_PROJECT_NAME}": app_debug = level continue @@ -519,13 +671,15 @@ def line_start(offset: int) -> int: ) ) if guard is None: - kept.append(text[line_start(start) : end]) + kept.append( + _text(source, _line_start(source, view.node.span.start), view.node.span.end) + ) else: guard_kept = True if not kept: return app_debug, "" - opening = text[commands[0][2] : commands[0][3]] - closing = text[commands[-1][2] : commands[-1][3]] + opening = views[0].node.text + closing = views[-1].node.text return app_debug, "\n".join([opening, *kept, closing]) @@ -601,6 +755,9 @@ def migrate_libxr_cmake(content: str, cmake_dir: str) -> tuple[str, str]: statement is lost: those before add_subdirectory(LibXR) go into the settings block and those after it into the "Kept" block, with a notice each. """ + document = CMakeDocument.parse(content) + source = document.render_bytes() + statements = document.top_level_statements() driver = "st" modules_dir = None app_debug = None @@ -610,11 +767,10 @@ def migrate_libxr_cmake(content: str, cmake_dir: str) -> tuple[str, str]: user_sources = "" before: list[str] = [] after: list[str] = [] - parsed = cmake_text.statements(content) globs = [ - _user_sources_glob(statement.args.split()) - for statement in parsed - if statement.name == "file" + _user_sources_glob([argument.text for argument in statement.arguments]) + for statement in statements + if statement.name.casefold() == "file" ] globs = [glob for glob in globs if glob is not None] # 只有一条单一模式的 file(GLOB ...) 时由 LIBXR_USER_SOURCES_GLOB 接管,它的 target_sources 随之 @@ -636,15 +792,16 @@ def migrate_libxr_cmake(content: str, cmake_dir: str) -> tuple[str, str]: library_at = next( ( index - for index, statement in enumerate(parsed) - if statement.name == "add_subdirectory" and "LibXR" in statement.args + for index, statement in enumerate(statements) + if statement.name.casefold() == "add_subdirectory" + and any("LibXR" in argument.text for argument in statement.arguments) ), None, ) - for index, statement in enumerate(parsed): - text = content[statement.start : statement.end] - arguments = statement.args.split() - if statement.name == "set" and arguments and arguments[0] in _TEMPLATE_VARIABLES: + for index, statement in enumerate(statements): + text = _text(source, statement.span.start, statement.span.end) + arguments = [argument.text for argument in statement.arguments] + if statement.name.casefold() == "set" and arguments and arguments[0] in _TEMPLATE_VARIABLES: if arguments[0] == "LIBXR_DRIVER" and len(arguments) > 1: driver = arguments[1] elif arguments[0] == "XROBOT_MODULES_DIR" and len(arguments) > 1: @@ -653,7 +810,7 @@ def migrate_libxr_cmake(content: str, cmake_dir: str) -> tuple[str, str]: continue if index == library_at: continue - is_glob = statement.name == "file" and _user_sources_glob(arguments) is not None + is_glob = statement.name.casefold() == "file" and _user_sources_glob(arguments) is not None if is_glob and taken_over: user_sources = globs[0] continue @@ -664,16 +821,16 @@ def migrate_libxr_cmake(content: str, cmake_dir: str) -> tuple[str, str]: and (taken_over or not globs or canonical != _USER_SOURCES_TARGET) ): continue - if statement.name == "if" and _is_debug_block(text): + if statement.name.casefold() == "if" and _is_debug_block(text): app_debug, kept_debug = _migrate_debug_block(text) if kept_debug: after.append(kept_debug) continue - kept = content[statement.leading : statement.end].strip("\n") + kept = _text(source, statement.leading_span.start, statement.span.end).strip("\n") settings_side = library_at is None or index < library_at (before if settings_side else after).append(kept) where = "Project settings" if settings_side else "Kept" - summary = f"{statement.name}({_canonical(statement.args)[:60]})" + summary = f"{statement.name}({_canonical(' '.join(arguments))[:60]})" logging.info( tr( f"LibXR.CMake: kept {summary} of the earlier file in the {where} block", @@ -690,18 +847,121 @@ def migrate_libxr_cmake(content: str, cmake_dir: str) -> tuple[str, str]: return settings, "\n\n".join(after) +def _read_user_regions(content: str) -> tuple[str | None, str | None]: + """读回两个 User Code 区域的内容;文件里没有某个区域时为 None。 + Read the contents of the two User Code regions; None when the file has no such region. + """ + document = CMakeDocument.parse(content) + _validate_user_region_markers(document) + bodies = { + region.name: region.body_text + for region in document.user_regions() + if region.name in USER_REGIONS + } + return bodies.get(USER_REGION_COMPILE_OPTIONS), bodies.get(USER_REGION_APPLICATION_SETTINGS) + + +def _validate_user_region_markers(document: CMakeDocument) -> None: + """标记有问题时拒绝写入,避免静默丢掉用户内容或写出第二对标记。 + Refuse a rewrite whose markers have a problem, so user content is never lost silently and a + second pair of markers is never written. + + Raises: + ValueError: 有标记格式错误、名字不是本文件的区域、或某个区域的 Begin 与 End 数量不是一对; + 信息逐条列出全部问题。 + A marker is malformed, names a region this file does not have, or a region does not have + exactly one Begin and one End; the message lists every problem. + """ + markers: list[tuple[str, str]] = [] + problems = [] + for comment in document.comments(): + text = comment.text.strip() + if not _USER_MARKER_TEXT.match(text): + continue + begin = _USER_MARKER_BEGIN.fullmatch(text) + end = _USER_MARKER_END.fullmatch(text) + if begin is None and end is None: + problems.append( + tr(f"malformed User Code marker {text}", f"User Code 标记格式错误:{text}") + ) + continue + name = ((begin or end).group(1) or "").strip() + if name not in USER_REGIONS: + names = ", ".join(USER_REGIONS) + problems.append( + tr( + f"User Code marker {text} names a region this file does not have " + f"(expected {names})", + f"User Code 标记 {text} 指定的区域不是本文件的区域(应为 {names})", + ) + ) + markers.append(("begin" if begin is not None else "end", name)) + for name in USER_REGIONS: + begins = sum(1 for kind, item in markers if kind == "begin" and item == name) + ends = sum(1 for kind, item in markers if kind == "end" and item == name) + # 文件里还没有这个区域时(0 对)不算问题:重新生成会补上空的区域。 + # A file that does not have the region yet (no pair) is not a problem: the rewrite adds an + # empty one. + if begins != ends or begins > 1: + problems.append( + tr( + f"User Code {name} has {begins} Begin and {ends} End markers; at most one " + "pair is expected", + f"User Code {name} 有 {begins} 个 Begin 和 {ends} 个 End 标记;最多只应有一对", + ) + ) + if problems: + raise ValueError( + tr( + "existing User Code markers cannot be preserved safely; nothing was " + "written. Fix the markers and regenerate:\n ", + "已有的 User Code 标记无法安全保留,未写入任何文件。请修正这些标记后重新生成:\n ", + ) + + "\n ".join(problems) + ) + + +def _refuse_diagnostics(content: str) -> None: + """已有的 LibXR.CMake 解析出诊断时拒绝继续,避免半份源码被写出来。 + Refuse to go on when an existing LibXR.CMake has parse diagnostics, so half of the file is never + written out. + + 诊断按位置报出(行、列,从 1 开始)。旧版本对这类文件照常输出整份内容,写出来的文件可能重复 + 或残缺。 + The diagnostic is reported with its position (row and column, both from 1). The earlier versions + wrote the whole file for such a file as well, which could repeat or truncate content. + + Raises: + ValueError: 文件有解析诊断。 + The file has parse diagnostics. + """ + document = CMakeDocument.parse(content) + if not document.diagnostics: + return + where = ", ".join( + f"{item.message} (row {item.start_point.row + 1}, column {item.start_point.column + 1})" + for item in document.diagnostics + ) + raise ValueError( + tr( + "the existing LibXR.CMake cannot be parsed; nothing was written: " + where, + f"已有的 LibXR.CMake 无法解析,未写入任何文件:{where}", + ) + ) + + def update_or_create_libxr_cmake(file_path: str, system: str, use_xrobot: bool) -> None: """生成 LibXR.CMake,或把已有的文件重写为固定结构。 Generate LibXR.CMake, or rewrite an existing file into the fixed structure. 已有的新格式文件保留 “Project settings” 块(只校正 LIBXR_SYSTEM 和 XROBOT_MODULES_DIR,补上缺少 - 的设置)和 “Kept” 块,其余块重新生成;旧格式的文件见 migrate_libxr_cmake()。新文件只在 - use_xrobot 为真时设置 XROBOT_MODULES_DIR。内容有变化时才写回。 - An existing file in the current format keeps its "Project settings" block (only LIBXR_SYSTEM - and XROBOT_MODULES_DIR are corrected and missing settings added) and its "Kept" block, and - the other blocks are generated again; a file in an earlier format is handled by - migrate_libxr_cmake(). A new file sets XROBOT_MODULES_DIR only when use_xrobot is true. The - file is written only when its content changes. + 的设置)、“Kept” 块和两个 User Code 区域的内容,其余块重新生成;旧格式的文件见 + migrate_libxr_cmake()。新文件只在 use_xrobot 为真时设置 XROBOT_MODULES_DIR。内容有变化时才写回。 + An existing file in the current format keeps its "Project settings" block (only LIBXR_SYSTEM and + XROBOT_MODULES_DIR are corrected and missing settings added), its "Kept" block and the contents + of its two User Code regions, and the other blocks are generated again; a file in an earlier + format is handled by migrate_libxr_cmake(). A new file sets XROBOT_MODULES_DIR only when + use_xrobot is true. The file is written only when its content changes. """ cmake_path = Path(file_path) if not cmake_path.exists(): @@ -710,13 +970,17 @@ def update_or_create_libxr_cmake(file_path: str, system: str, use_xrobot: bool) # XRobot 工程通过 LibXR 构建其 Modules;纯 LibXR 工程不得指定 Modules 目录。 # XRobot projects build their Modules through LibXR; plain LibXR # projects must not name a Modules directory. - cmake_path.write_text(render_libxr_cmake(settings), encoding="utf-8", newline="\n") + new_content = render_libxr_cmake(settings) + _refuse_diagnostics(new_content) + _validate_user_region_markers(CMakeDocument.parse(new_content)) + cmake_path.write_text(new_content, encoding="utf-8", newline="\n") logging.info( tr(f"Generated LibXR.CMake at: {cmake_path}", f"已生成 LibXR.CMake:{cmake_path}") ) return content = read_text_with_fallback(str(cmake_path)) + _refuse_diagnostics(content) blocks = split_blocks(content) if blocks is None: settings, kept = migrate_libxr_cmake(content, str(cmake_path.parent)) @@ -729,10 +993,16 @@ def update_or_create_libxr_cmake(file_path: str, system: str, use_xrobot: bool) else: settings = blocks.get(SETTINGS_TITLE, "") kept = blocks.get(KEPT_TITLE, "") + compile_options, application_settings = _read_user_regions(content) settings = _complete_settings( settings, system, use_xrobot, DEFAULT_OPT_DEBUG, DEFAULT_OPT_RELEASE ) - new_content = render_libxr_cmake(settings, kept) + new_content = render_libxr_cmake(settings, kept, compile_options, application_settings) + # 写文件之前,对生成结果做一遍与下一次读取完全相同的校验:校验不通过就报错,不写文件。 + # Before writing, the generated text goes through the very same check the next read applies: a + # failure is an error and the file is not written. + _refuse_diagnostics(new_content) + _validate_user_region_markers(CMakeDocument.parse(new_content)) if new_content != content.replace("\r\n", "\n"): cmake_path.write_text(new_content, encoding="utf-8", newline="\n") # LIBXR_SYSTEM None 表示裸机,与生成代码时的“系统:裸机”一致。 diff --git a/tests/test_cmake_text.py b/tests/test_cmake_text.py deleted file mode 100644 index 79d6306..0000000 --- a/tests/test_cmake_text.py +++ /dev/null @@ -1,47 +0,0 @@ -"""CMake 脚本的顶层语句(libxr.cmake_text)。 -The top-level statements of a CMake script (libxr.cmake_text). -""" - -from fixtures import TestCase - -from libxr import cmake_text - - -class Statements(TestCase): - """语句的划分:命令、块、注释和方括号参数。 - The split into statements: commands, blocks, comments and bracket arguments. - """ - - def test_commands_and_blocks_are_split_at_the_top_level(self): - text = ( - "set(A 1)\n" - "# about B\n" - "if(X)\n set(B 2)\n if(Y)\n set(C 3)\n endif()\nendif()\n" - 'message("a (b")\n' - "foreach(i IN LISTS L)\n message(${i})\nendforeach()\n" - ) - statements = cmake_text.statements(text) - self.assertEqual([s.name for s in statements], ["set", "if", "message", "foreach"]) - block = statements[1] - self.assertEqual( - text[block.leading : block.end], - "# about B\nif(X)\n set(B 2)\n if(Y)\n set(C 3)\n endif()\nendif()", - ) - self.assertEqual(statements[2].args, '"a (b"') - - def test_comments_and_brackets_hide_what_they_hold(self): - text = ( - "# set(HIDDEN 1)\n" - "#[[ set(HIDDEN 2)\n set(HIDDEN 3) ]]\n" - "set(SHOWN [=[ ) set(HIDDEN 4) ]=]) # trailing )\n" - "SET(second_one 1)\n" - ) - statements = cmake_text.statements(text) - self.assertEqual([s.name for s in statements], ["set", "set"]) - self.assertEqual(statements[0].args, "SHOWN [=[ ) set(HIDDEN 4) ]=]") - - def test_the_comment_directly_above_goes_with_the_statement(self): - text = "set(A 1)\n\n# one\n# two\nset(B 2)\n\n# apart\n\nset(C 3)\n" - a, b, c = cmake_text.statements(text) - self.assertEqual(text[b.leading : b.end], "# one\n# two\nset(B 2)") - self.assertEqual(text[c.leading : c.end], "set(C 3)") diff --git a/tests/test_generator_stm32_cmake.py b/tests/test_generator_stm32_cmake.py index d52b165..82592f7 100644 --- a/tests/test_generator_stm32_cmake.py +++ b/tests/test_generator_stm32_cmake.py @@ -13,6 +13,7 @@ from unittest import mock from fixtures import CUBEMX_STARM, GeneratorTestCase, TestCase +from xr_syntax.cmake import CMakeDocument from libxr import generator_stm32_cmake as stm32_cmake @@ -215,15 +216,22 @@ def test_a_new_file_has_the_fixed_structure(self): self.assertEqual( text.splitlines()[:3], [ - '# Generated by `libxr stm32 setup`; edit the values in the "Project settings" ' - "block only.", + stm32_cmake.GENERATED_LINE, "", "# ---- Project settings " + "-" * 56, ], ) self.assertEqual( list(stm32_cmake.split_blocks(text)), - ["Project settings", "LibXR", "Application", "Library optimization", "Firmware images"], + [ + "Project settings", + "Compile options", + "LibXR", + "Application", + "Library optimization", + "Firmware images", + "Application settings", + ], ) self.assertEqual( self.settings(text).splitlines(), @@ -271,9 +279,11 @@ def test_the_hex_and_bin_images_are_made_next_to_the_elf(self): ) def test_an_existing_file_gains_the_firmware_images(self): - # 由没有固件镜像块的版本写出的文件:设置块和 Kept 块保留,固件镜像块排在 Kept 块之前。 - # A file written by a version without the firmware image block: the settings block and - # the Kept block stay, and the firmware image block comes before the Kept block. + # 由没有固件镜像块的版本写出的文件:设置块、Kept 块和两个 User Code 区域保留,固件镜像块 + # 排在 Kept 块之前,应用设置区域排在最后。 + # A file written by a version without the firmware image block: the settings block, the + # Kept block and the two User Code regions stay, the firmware image block comes before the + # Kept block, and the application settings region comes last. text = self.run_cmake_generator() blocks = stm32_cmake.split_blocks(text) settings = blocks[stm32_cmake.SETTINGS_TITLE].replace('"-Og"', '"-O1"') @@ -286,8 +296,12 @@ def test_an_existing_file_gains_the_firmware_images(self): rewritten = self.run_cmake_generator() self.assertEqual(rewritten, stm32_cmake.render_libxr_cmake(settings, kept)) self.assertEqual( - list(stm32_cmake.split_blocks(rewritten))[-2:], - [stm32_cmake.FIRMWARE_TITLE, stm32_cmake.KEPT_TITLE], + list(stm32_cmake.split_blocks(rewritten))[-3:], + [ + stm32_cmake.FIRMWARE_TITLE, + stm32_cmake.KEPT_TITLE, + stm32_cmake.APPLICATION_SETTINGS_TITLE, + ], ) self.assertIn("INFO:root:Updated existing LibXR.CMake for system: bare metal", logs.output) @@ -793,5 +807,385 @@ def test_line_endings_stay_as_the_file_has_them(self): self.assertEqual(text, CUBEMX_STARM.replace("\n", "\r\n")) +class UserRegions(Integration): + """LibXR.CMake 的两个 User Code 区域:重新生成时保留内容,缺失时补上空的区域。 + The two User Code regions of LibXR.CMake: their contents survive a rewrite, and a missing + region is added empty. + """ + + def regions(self, text): + """text 中的 User Code 区域,按 (名字, 内容) 排列。 + The User Code regions of text as (name, content) pairs. + """ + return [ + (region.name, region.body_text) for region in CMakeDocument.parse(text).user_regions() + ] + + def without_regions(self, text): + """去掉两个 User Code 区域所在的节,得到加上区域之前的那种文件。 + The text without the two User Code sections, as it was before the regions were added. + """ + for title, name in ( + (stm32_cmake.COMPILE_OPTIONS_TITLE, stm32_cmake.USER_REGION_COMPILE_OPTIONS), + (stm32_cmake.APPLICATION_SETTINGS_TITLE, stm32_cmake.USER_REGION_APPLICATION_SETTINGS), + ): + section = ( + "\n" + + stm32_cmake._rule(title) + + f"\n# User Code Begin {name}\n# User Code End {name}" + ) + self.assertIn(section, text) + text = text.replace(section, "") + return text + + def test_a_new_file_has_both_regions_empty(self): + text = self.run_cmake_generator() + self.assertEqual( + self.regions(text), + [ + (stm32_cmake.USER_REGION_COMPILE_OPTIONS, "\n"), + (stm32_cmake.USER_REGION_APPLICATION_SETTINGS, "\n"), + ], + ) + + def test_the_content_of_the_regions_survives_a_rewrite(self): + text = self.run_cmake_generator() + edited = text.replace( + "# User Code Begin CompileOptions\n# User Code End CompileOptions", + "# User Code Begin CompileOptions\n" + "add_compile_options(-Wall)\n" + "# User Code End CompileOptions", + ).replace( + "# User Code Begin ApplicationSettings\n# User Code End ApplicationSettings", + "# User Code Begin ApplicationSettings\n" + "set_target_properties(xr PROPERTIES POSITION_INDEPENDENT_CODE ON)\n" + "# User Code End ApplicationSettings", + ) + self.libxr_cmake.write_text(edited, encoding="utf-8") + self.assertEqual(self.run_cmake_generator(), edited) + + def test_the_regions_are_not_part_of_the_settings_or_kept_blocks(self): + text = self.run_cmake_generator() + blocks = stm32_cmake.split_blocks(text) + self.assertNotIn("User Code", blocks[stm32_cmake.SETTINGS_TITLE]) + self.assertIn("# User Code Begin CompileOptions", blocks[stm32_cmake.COMPILE_OPTIONS_TITLE]) + self.assertIn( + "# User Code Begin ApplicationSettings", + blocks[stm32_cmake.APPLICATION_SETTINGS_TITLE], + ) + + def test_a_file_with_the_earlier_first_line_is_rewritten_not_migrated(self): + # 加上 User Code 区域之前写出的文件:首行仍是新格式的标志,因此走重写;只有首行变化、补上两个 + # 空的区域,设置块和 Kept 块原样保留。 + # A file written before the User Code regions were added: its first line still marks the + # current format, so it is rewritten; only the first line changes and the two empty regions + # are added, and the settings and Kept blocks stay as they are. + text = self.run_cmake_generator() + earlier = text.replace(stm32_cmake.GENERATED_LINE, stm32_cmake.EARLIER_GENERATED_LINE) + self.libxr_cmake.write_text(earlier, encoding="utf-8") + rewritten = self.run_cmake_generator() + self.assertEqual(rewritten, text) + self.assertEqual( + stm32_cmake.split_blocks(rewritten)[stm32_cmake.SETTINGS_TITLE], + stm32_cmake.split_blocks(earlier)[stm32_cmake.SETTINGS_TITLE], + ) + # 迁移会把不认识的语句丢进 Kept 块;这里没有 Kept 块。 + # A migration would collect unrecognized statements in the Kept block; there is none here. + self.assertNotIn(stm32_cmake.KEPT_TITLE, stm32_cmake.split_blocks(rewritten)) + + def test_the_modules_directory_inside_a_block_is_removed_like_before(self): + # 块内的 set(XROBOT_MODULES_DIR ...) 同样删掉,只删那一行,块的其余部分不变——与加上区域之前 + # 的实现逐字节相同。 + # A set(XROBOT_MODULES_DIR ...) inside a block goes too: only its line is removed and the rest + # of the block stays, byte for byte as the implementation before the regions did it. + text = self.run_cmake_generator() + inside = text.replace( + "set(LIBXR_SYSTEM None)\n", + "set(LIBXR_SYSTEM None)\n" + "if(FOO)\n" + ' set(XROBOT_MODULES_DIR "${CMAKE_CURRENT_SOURCE_DIR}/Modules")\n' + " set(KEEP 1)\n" + "endif()\n", + ) + self.libxr_cmake.write_text(inside, encoding="utf-8") + # 只少了那一行:块本身和其他设置都留在原处。 + # Only that line is gone: the block itself and the other settings stay where they were. + self.assertEqual( + self.run_cmake_generator(), + inside.replace( + ' set(XROBOT_MODULES_DIR "${CMAKE_CURRENT_SOURCE_DIR}/Modules")\n', "" + ), + ) + + def test_a_file_without_regions_gains_them_empty(self): + text = self.run_cmake_generator() + self.libxr_cmake.write_text(self.without_regions(text), encoding="utf-8") + self.assertEqual(self.run_cmake_generator(), text) + + def test_a_region_inside_the_kept_block_survives(self): + # Kept 块里的内容与区域互不影响:区域排在最后,Kept 块的内容原样保留。 + # The Kept block and the regions do not disturb each other: the regions come last and the + # Kept block stays as written. + text = self.run_cmake_generator() + kept = "target_compile_options(xr PRIVATE -Wno-deprecated-volatile)" + with_kept = text.replace( + "\n" + stm32_cmake._rule(stm32_cmake.APPLICATION_SETTINGS_TITLE), + "\n" + + stm32_cmake._rule(stm32_cmake.KEPT_TITLE) + + "\n" + + kept + + "\n\n" + + stm32_cmake._rule(stm32_cmake.APPLICATION_SETTINGS_TITLE), + ) + self.libxr_cmake.write_text(with_kept, encoding="utf-8") + self.assertEqual(self.run_cmake_generator(), with_kept) + self.assertIn(kept, stm32_cmake.split_blocks(with_kept)[stm32_cmake.KEPT_TITLE]) + + def test_a_begin_without_its_end_is_refused(self): + text = self.run_cmake_generator() + broken = text.replace("# User Code End CompileOptions\n", "") + self.libxr_cmake.write_text(broken, encoding="utf-8") + with self.assertRaises(ValueError) as raised: + self.run_cmake_generator() + self.assertIn("1 Begin and 0 End", str(raised.exception)) + # 拒绝时什么都不写。 + # Nothing is written when it refuses. + self.assertEqual(self.libxr_cmake.read_text(encoding="utf-8"), broken) + + def test_a_marker_of_an_unknown_region_is_refused(self): + text = self.run_cmake_generator() + broken = text.replace( + "# User Code Begin CompileOptions", + "# User Code Begin CompileOptions\n# User Code Begin Other", + ) + self.libxr_cmake.write_text(broken, encoding="utf-8") + with self.assertRaises(ValueError) as raised: + self.run_cmake_generator() + self.assertIn( + "not a region this file does not have".replace("not a region", "names a region"), + str(raised.exception), + ) + self.assertEqual(self.libxr_cmake.read_text(encoding="utf-8"), broken) + + def test_a_missing_region_is_added_next_to_the_kept_content(self): + text = self.run_cmake_generator() + broken = text.replace( + "\n" + + stm32_cmake._rule(stm32_cmake.COMPILE_OPTIONS_TITLE) + + "\n# User Code Begin CompileOptions\n# User Code End CompileOptions", + "", + ) + self.libxr_cmake.write_text(broken, encoding="utf-8") + self.assertEqual(self.run_cmake_generator(), text) + + +if __name__ == "__main__": + unittest.main() + + +class RefusedFiles(Integration): + """写文件之前的校验:生成器不写自己下一次会拒绝的文件。 + The check before writing: the generator does not write a file it would refuse on the next read. + """ + + def test_a_paired_region_in_the_settings_block_is_refused(self): + # 设置块里成对出现的 CompileOptions 标记:重新生成会写出第二对,因此拒绝写入。 + # A paired CompileOptions marker in the settings block: a rewrite would write a second pair, + # so it is refused. + text = self.run_cmake_generator() + with_regions = text.replace( + "set(LIBXR_OPT_RELEASE", + "# User Code Begin CompileOptions\nadd_compile_options(-Wall)\n" + "# User Code End CompileOptions\nset(LIBXR_OPT_RELEASE", + ) + self.libxr_cmake.write_text(with_regions, encoding="utf-8") + with self.assertRaises(ValueError) as raised: + self.run_cmake_generator() + self.assertIn("CompileOptions", str(raised.exception)) + # 拒绝时什么都不写。 + # Nothing is written when it refuses. + self.assertEqual(self.libxr_cmake.read_text(encoding="utf-8"), with_regions) + + def test_an_unparsable_file_is_refused_with_its_position(self): + # 引号没有闭合:报出诊断的位置,不写文件。旧版对这种文件照常输出。 + # An unterminated quote: the position of the diagnostic is reported and nothing is written. + # The earlier versions wrote such a file as well. + text = self.run_cmake_generator() + broken = text.replace( + 'set(LIBXR_USER_SOURCES_GLOB "${CMAKE_CURRENT_SOURCE_DIR}/User/*.cpp")', + 'set(LIBXR_USER_SOURCES_GLOB "${CMAKE_CURRENT_SOURCE_DIR}/User/*.cpp)', + ) + self.libxr_cmake.write_text(broken, encoding="utf-8") + with self.assertRaises(ValueError) as raised: + self.run_cmake_generator() + self.assertIn("unterminated quoted argument", str(raised.exception)) + self.assertIn("row", str(raised.exception)) + # 拒绝时什么都不写。 + # Nothing is written when it refuses. + self.assertEqual(self.libxr_cmake.read_text(encoding="utf-8"), broken) + + +class SettingValues(Integration): + """设置值的写入:只有值本身变化,没有值的那一行就地补上。 + The value written into a setting: only the value changes and a line without one gets it in place. + """ + + def test_a_setting_without_a_value_is_written_in_place(self): + # set(LIBXR_SYSTEM ) 没有值:就地写入值。在它前面另插一行会让 CMake 最后读到空值。 + # set(LIBXR_SYSTEM ) has no value: the value is written in place. Inserting a line before it + # would leave CMake reading the empty value at the end. + text = self.run_cmake_generator() + empty = text.replace("set(LIBXR_SYSTEM None)", "set(LIBXR_SYSTEM )") + self.libxr_cmake.write_text(empty, encoding="utf-8") + written = self.run_cmake_generator() + self.assertEqual(written, text) + + def test_a_value_is_replaced_without_touching_the_rest_of_the_line(self): + text = self.run_cmake_generator() + commented = text.replace( + "set(LIBXR_DRIVER st)", + "set(LIBXR_DRIVER st) # driver", + ) + self.libxr_cmake.write_text(commented, encoding="utf-8") + written = self.run_cmake_generator() + # 行尾注释留在原行,值本身换了。 + # The comment at the end of the line stays and only the value changes. + self.assertEqual( + written, + commented.replace("set(LIBXR_DRIVER st) # driver", "set(LIBXR_DRIVER st) # driver"), + ) + self.assertIn("set(LIBXR_DRIVER st) # driver", written) + + +MODULES_DIR_SETTING = 'set(XROBOT_MODULES_DIR "${CMAKE_CURRENT_SOURCE_DIR}/Modules")' + + +def assert_cmake_parses(text): + """让真正的 cmake 解析这份 LibXR.CMake;没有安装 cmake 时跳过。 + Let the real cmake parse this LibXR.CMake; skipped when cmake is not installed. + + 这份文件里有 add_subdirectory 之类不能在 -P 模式执行的命令,因此只看有没有解析错误。 + The file holds commands such as add_subdirectory that -P mode cannot run, so only a parse error + counts. + """ + import shutil + import subprocess + + executable = shutil.which("cmake") + if executable is None: + raise unittest.SkipTest("cmake is not installed") + with tempfile.TemporaryDirectory() as directory: + script = Path(directory) / "LibXR.CMake" + script.write_text(text, encoding="utf-8", newline="") + completed = subprocess.run( + [executable, "-P", str(script)], capture_output=True, text=True, check=False + ) + if "Parse error" in completed.stderr: + raise AssertionError(completed.stderr) + + +class InsertedSettings(Integration): + """补写的设置另起一行,排在同一组设置里,不挤进上一条设置那一行的行尾。 + A setting added later gets a line of its own next to the settings it belongs with, instead of + being glued to the end of the line of the setting before it. + + 插入点是原命令所在那一行的换行之后:后面有几个空行、有没有行尾注释、有没有行尾空格、是 LF 还是 + CRLF 都不改变结果。写出的文件因此总是 CMake 能读的。 + The insertion point is right after the line ending of the line that holds the command: how many + blank lines follow, whether the line carries a trailing comment or trailing spaces, and whether + the file uses LF or CRLF change nothing. The file written is therefore always one CMake can read. + """ + + def standard_file(self): + """生成一个不使用 XRobot 的工程,返回 cmake/LibXR.CMake 的文本。 + Generate the project without XRobot and return the text of cmake/LibXR.CMake. + """ + self.write_app_main(False) + return self.run_cmake_generator() + + def regenerate_with(self, text, settings, use_xrobot, newline="\n"): + """把设置块换成 settings,用给定的入口选择再生成一次,返回新的文本。 + Replace the settings block with settings, generate again with the given entry choice and + return the new text. + + newline 给出写回文件时的换行符,用来构造 CRLF 的输入文件。 + newline is the line ending the file is written with, used to build a CRLF input file. + """ + current = self.settings(text) + self.libxr_cmake.write_text( + text.replace(current, settings, 1), encoding="utf-8", newline=newline + ) + self.write_app_main(use_xrobot) + written = self.run_cmake_generator() + # 写出的文件必须是 CMake 能读的,也必须是本解析器能读的。 + # The file written must be one CMake can read and one this parser can read. + self.assertEqual(CMakeDocument.parse(written).diagnostics, ()) + assert_cmake_parses(written) + return written + + def test_a_setting_added_after_a_blank_line_stays_on_its_own_line(self): + text = self.standard_file() + driver = "set(LIBXR_DRIVER st)\n" + written = self.regenerate_with( + text, self.settings(text).replace(driver, driver + "\n"), True + ) + self.assertEqual(written, text.replace(driver, driver + MODULES_DIR_SETTING + "\n\n", 1)) + + def test_a_setting_added_after_a_line_that_ends_with_a_comment(self): + text = self.standard_file() + driver = "set(LIBXR_DRIVER st)\n" + commented = "set(LIBXR_DRIVER st) # d\n" + written = self.regenerate_with( + text, self.settings(text).replace(driver, commented + "\n"), True + ) + self.assertEqual(written, text.replace(driver, commented + MODULES_DIR_SETTING + "\n\n", 1)) + + def test_a_missing_driver_is_added_after_the_line_of_the_system(self): + text = self.standard_file() + driver = "set(LIBXR_DRIVER st)\n" + written = self.regenerate_with(text, self.settings(text).replace(driver, "\n"), False) + self.assertEqual(written, text.replace(driver, driver + "\n", 1)) + + def test_a_missing_release_level_goes_before_a_blank_line_at_the_end(self): + text = self.standard_file() + debug = 'set(LIBXR_OPT_DEBUG "-Og")\n' + release = 'set(LIBXR_OPT_RELEASE "")\n' + without_release = text.replace(release, "", 1) + written = self.regenerate_with(text, self.settings(without_release) + "\n", False) + self.assertEqual(written, without_release.replace(debug, debug + release, 1)) + + def test_a_missing_release_level_goes_before_a_blank_line_and_a_comment(self): + text = self.standard_file() + debug = 'set(LIBXR_OPT_DEBUG "-Og")\n' + release = 'set(LIBXR_OPT_RELEASE "")\n' + without_release = text.replace(release, "", 1) + written = self.regenerate_with( + text, self.settings(without_release) + "\n\n# my note\n", False + ) + self.assertEqual( + written, + without_release.replace(debug, debug + release + "\n# my note\n", 1), + ) + + def test_trailing_spaces_on_the_setting_line_are_kept(self): + text = self.standard_file() + driver = "set(LIBXR_DRIVER st)\n" + padded = "set(LIBXR_DRIVER st) \n" + written = self.regenerate_with( + text, self.settings(text).replace(driver, padded + "\n"), True + ) + self.assertEqual(written, text.replace(driver, padded + MODULES_DIR_SETTING + "\n\n", 1)) + + def test_a_file_with_crlf_is_rewritten_like_one_with_lf(self): + text = self.standard_file() + driver = "set(LIBXR_DRIVER st)\n" + # 读文件时 CRLF 归一为 LF,因此 CRLF 的文件得到与 LF 相同的输出。 + # Reading a file folds CRLF into LF, so a CRLF file gives the same output as an LF one. + written = self.regenerate_with( + text, self.settings(text).replace(driver, driver + "\n"), True, newline="\r\n" + ) + self.assertEqual(written, text.replace(driver, driver + MODULES_DIR_SETTING + "\n\n", 1)) + + if __name__ == "__main__": unittest.main()