[core] Detect bare-local emission and disable sub-split for those groups

A component's group is wrapped in a single IIFE with no sub-splitting when its to_code emits any of: scope-brace RawStatement, direct RawExpression via cg.add (raw bare-local or field-assignment like 'tz.field = x'), or typed AssignmentExpression (cg.variable). Detection is content-aware so entity_helpers' inline-comment RawStatements and RawExpression-as-CallExpression-arg don't false-positive. Adds 4 regression tests covering each detection path and the non-triggering inverse cases.
This commit is contained in:
J. Nick Koston
2026-04-17 18:46:30 -05:00
parent 5f2582efcd
commit 550f6e7c72
3 changed files with 159 additions and 13 deletions
+73 -12
View File
@@ -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
+6 -1
View File
@@ -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
+80
View File
@@ -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