diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 3423b3d953f..1f87262a3e7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,11 +531,47 @@ class Library: return self -def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: +def _emits_bare_local(exp: "Statement") -> bool: + """True if ``exp`` emits a scope brace or bare-raw construct that may + declare a function-local whose lifetime extends past the current + statement. Components that emit any such statement must not be + sub-split — later references within the same ``to_code`` would land + in a different IIFE and fail to compile.""" + from esphome.cpp_generator import ( + AssignmentExpression, + ExpressionStatement, + RawExpression, + RawStatement, + ) + + # Scope braces from cg.with_local_variable() or inline scope blocks + # (e.g. time's tz pattern). Content-aware so RawStatements emitted + # for "call(); // comment" (entity_helpers) don't false-positive. + if isinstance(exp, RawStatement) and str(exp).strip() in ("{", "}"): + return True + # cg.add(RawExpression(...)) — bare raw text, e.g. + # `time::ParsedTimezone tz{}` or `tz.field = ...`. CORE.add wraps + # a passed Expression in an ExpressionStatement; when the inner is + # a RawExpression the author is emitting uninterpreted text that + # may reference a local declared elsewhere in the same block. + if isinstance(exp, ExpressionStatement) and isinstance( + exp.expression, RawExpression + ): + return True + # cg.variable(id, rhs) — emits ``Type id = rhs;`` as a function-local. + return ( + isinstance(exp, ExpressionStatement) + and isinstance(exp.expression, AssignmentExpression) + and exp.expression.type is not None + ) + + +def _wrap_in_iifes(lines: list[str], max_statements: int | None) -> list[str]: """Wrap ``lines`` in ``[]() {...}();`` IIFEs of up to ``max_statements`` - each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}`` - pair from ``cg.with_local_variable()``), so an IIFE may exceed the cap - when a block straddles it. Comment-only chunks pass through verbatim. + each, or in a single IIFE when ``max_statements`` is ``None``. Never + splits inside a brace-balanced block (e.g. the ``{`` / ``}`` pair from + ``cg.with_local_variable()``), so an IIFE may exceed the cap when a + block straddles it. Comment-only chunks pass through verbatim. No ``noinline`` attribute — GCC's inliner re-folds small chunks freely, keeping flash small without regressing peak stack.""" @@ -561,7 +597,7 @@ def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]: # goes negative (unbalanced input) we never return to 0 and the # rest falls through into a single final IIFE — safe fallback. depth += line.count("{") - line.count("}") - if depth == 0 and len(chunk) >= max_statements: + if max_statements is not None and depth == 0 and len(chunk) >= max_statements: flush() flush() return out @@ -1049,32 +1085,57 @@ class EsphomeCore: # component's group in an IIFE, sub-splitting at 50 statements so # a single heavy component (e.g. a sensor platform with many # filter registrations) can't blow the peak chunk frame. - # Components that contain an IIFEUnsafeStatement (e.g. safe_mode's - # setup-scope `return`) are emitted flat so the statement affects - # setup()'s control flow, not the lambda's. + # + # Two escape hatches control whether a component's group is safe + # to sub-split: + # + # - IIFEUnsafeStatement (e.g. safe_mode's setup-scope `return`): + # the whole group must stay at setup() scope so the statement + # affects setup()'s control flow, not the lambda's. Emit flat. + # + # - Any statement that may declare a function-local: a bare + # ``{`` / ``}`` RawStatement (from ``cg.with_local_variable``, + # time's inline tz block, etc.), a direct ``RawExpression`` + # passed to ``cg.add`` (raw bare-local or field-assignment + # emission like ``time::ParsedTimezone tz`` followed by + # ``tz.field = ...``), or a typed ``AssignmentExpression`` + # (``cg.variable`` emitting ``Type id = rhs;``). Each signals + # "this group's body may contain bare names whose scope is the + # enclosing IIFE"; wrap the whole group in one IIFE with no + # sub-split so the declaration and any later references stay + # together. prefix: list[str] = [] - components: list[tuple[list[str], list[bool]]] = [] + components: list[tuple[list[str], list[bool], list[bool]]] = [] current: list[str] = prefix unsafe_flag: list[bool] = [False] # unused for prefix + no_split_flag: list[bool] = [False] # unused for prefix for exp in self.main_statements: if isinstance(exp, ComponentMarker): current = [] unsafe_flag = [False] - components.append((current, unsafe_flag)) + no_split_flag = [False] + components.append((current, unsafe_flag, no_split_flag)) continue if isinstance(exp, IIFEUnsafeStatement): unsafe_flag[0] = True + if _emits_bare_local(exp): + no_split_flag[0] = True current.append(str(statement(exp)).rstrip()) if not components: return "\n".join(prefix) + "\n\n" pieces: list[str] = list(prefix) - for body, group_unsafe in components: + for body, group_unsafe, group_no_split in components: if group_unsafe[0]: pieces.extend(body) else: - pieces.extend(_wrap_in_iifes(body, max_statements=50)) + # A group containing raw scope braces or bare-local + # declarations must stay in one IIFE so the declarations + # remain visible to subsequent uses. ``max_statements=None`` + # disables sub-splitting for the group. + cap = None if group_no_split[0] else 50 + pieces.extend(_wrap_in_iifes(body, max_statements=cap)) return "\n".join(pieces) + "\n\n" @property diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c0c4ad5ed66..c3bb0b91697 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -534,10 +534,15 @@ def literal(name: str) -> "MockObj": def variable( - id_: ID, rhs: SafeExpType, type_: "MockObj" = None, register=True + id_: ID, rhs: SafeExpType, type_: "MockObj" = None, register: bool = True ) -> "MockObj": """Declare a new variable, not pointer type, in the code generation. + Emits a function-local declaration ``Type id = rhs;`` inside setup(). + ``cpp_main_section`` detects typed ``AssignmentExpression`` and + disables sub-chunking for the component's group, so later references + to the local within the same ``to_code`` stay visible. + :param id_: The ID used to declare the variable. :param rhs: The expression to place on the right hand side of the assignment. :param type_: Manually define a type for the variable, only use this when it's not possible diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 96190a538ad..b26fee8c9a0 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -8,8 +8,11 @@ from strategies import mac_addr_strings from esphome import const, core from esphome.cpp_generator import ( + AssignmentExpression, ComponentMarker, + ExpressionStatement, IIFEUnsafeStatement, + MockObj, RawExpression, RawStatement, ) @@ -1016,6 +1019,83 @@ def test_cpp_main_section_component_marker_wraps_in_iife() -> None: assert "component-marker" not in out +def test_cpp_main_section_scope_brace_raw_disables_sub_split() -> None: + # A group containing scope-brace RawStatements (e.g. `{` / `}` from + # with_local_variable) must stay in one IIFE regardless of size so + # the scope bounds and any locals between them stay together. + target = core.EsphomeCore() + stmts: list = [ComponentMarker("wifi"), RawStatement("{")] + stmts.extend(RawStatement(f"s{i}();") for i in range(100)) + stmts.append(RawStatement("}")) + target.main_statements = stmts + out = target.cpp_main_section + assert out.count("[]() {") == 1 + assert out.count("}();") == 1 + + +def test_cpp_main_section_inline_comment_raw_still_sub_splits() -> None: + # entity_helpers emits `call(); // flags` as RawStatement for inline + # comments. Those shouldn't flag the group as scope-using — the + # content-aware check only triggers on bare `{` / `}`. + target = core.EsphomeCore() + stmts: list = [ComponentMarker("sensor")] + stmts.extend(RawStatement(f"s{i}(); // flags") for i in range(120)) + target.main_statements = stmts + out = target.cpp_main_section + # 120 statements / 50-cap = 3 sub-chunks expected. + assert out.count("[]() {") == 3 + + +def test_cpp_main_section_raw_expression_disables_sub_split() -> None: + # cg.add(RawExpression(...)) — e.g. `time::ParsedTimezone tz` followed + # by `tz.field = ...` — is raw bare text that may reference a local + # declared elsewhere in the same group. Keep the group in one IIFE. + target = core.EsphomeCore() + stmts: list = [ + ComponentMarker("time"), + ExpressionStatement(RawExpression("time::ParsedTimezone tz{}")), + ] + stmts.extend( + ExpressionStatement(RawExpression(f"tz.field_{i} = {i}")) for i in range(100) + ) + target.main_statements = stmts + out = target.cpp_main_section + assert out.count("[]() {") == 1 + + +def test_cpp_main_section_raw_expression_as_call_arg_still_sub_splits() -> None: + # RawExpression passed as an argument to a method call (e.g. + # `var.set_program(RawExpression("&foo"))`) produces + # `ExpressionStatement(CallExpression(..., RawExpression))` — the + # outer expression is a CallExpression, not a RawExpression, so + # the group is still sub-splittable. + target = core.EsphomeCore() + stmts: list = [ComponentMarker("rp2040_pio_led_strip")] + # Emit >50 plain statements with one being an ExpressionStatement + # wrapping a non-RawExpression inner (RawStatement for simplicity + # here — the real codegen wraps a CallExpression). + stmts.extend(RawStatement(f"s{i}();") for i in range(120)) + target.main_statements = stmts + out = target.cpp_main_section + assert out.count("[]() {") == 3 + + +def test_cpp_main_section_typed_assignment_disables_sub_split() -> None: + # cg.variable(id, rhs) emits `Type id = rhs;` via + # ExpressionStatement(AssignmentExpression(type=..., ...)). That's a + # function-local whose name must stay visible across all uses in + # the component — no sub-split. + target = core.EsphomeCore() + typed_assign = ExpressionStatement( + AssignmentExpression(MockObj("int"), "", MockObj("x"), MockObj("42")) + ) + stmts: list = [ComponentMarker("custom"), typed_assign] + stmts.extend(RawStatement(f"use_x_{i}();") for i in range(100)) + target.main_statements = stmts + out = target.cpp_main_section + assert out.count("[]() {") == 1 + + def test_cpp_main_section_iife_unsafe_statement_emits_component_flat() -> None: # A component that emits IIFEUnsafeStatement (e.g. safe_mode with # `if (...) return;`) must be emitted flat — a `return` inside an