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/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/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..acb3a69 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 @@ -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 458d5ce..7683c8e 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()) @@ -941,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, ...). @@ -1071,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 c4a1746..21e5f30 100644 --- a/src/libxr/generator_code_stm32.py +++ b/src/libxr/generator_code_stm32.py @@ -19,11 +19,11 @@ 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 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 @@ -3368,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" { @@ -3387,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: @@ -3469,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: @@ -3618,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/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_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() 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")) 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()