Compare commits

...

18 Commits

Author SHA1 Message Date
J. Nick Koston
4ea417966d [core] Poison brace-depth tracking once it goes negative
Address Copilot review: the prior comment promised that a negative depth would never re-enable splitting, but an arithmetically-balanced later { could bring depth back to 0 and resume flushing mid-stream. Track an explicit 'poisoned' flag set once depth < 0 that permanently disables further flushes for the rest of the input. Adds a regression test where a leading } and a later { would have re-enabled splitting without the poison flag.
2026-04-17 19:01:12 -05:00
J. Nick Koston
6446f309c1 [core] Replace mutable-list-flag pattern with _ComponentGroup dataclass
The mutable-list-of-bool trick for rebinding-safe flag mutation works but reads poorly. Replace with a _ComponentGroup dataclass carrying lines + unsafe + no_split fields. cpp_main_section now reads as a straight iteration over typed groups; no apologetic comments needed.
2026-04-17 18:56:59 -05:00
J. Nick Koston
093c34d4a4 [core] Review cleanup: docstring accuracy, rationale comments, unsafe+no_split test
- Fix the ComponentMarker docstring's incomplete 'either placement-news or mutates a global' claim — acknowledge that function-local patterns also exist and note the bare-local detection covers them.
- Document that _emits_bare_local's RawExpression detection is intentionally safety-biased: false negatives break compilation, false positives just keep a slightly larger IIFE. Note the CallExpression(..., RawExpression) negative case explicitly.
- Explain the mutable-list-flag pattern in cpp_main_section — dataclass would read cleaner but the pattern is localized.
- Add regression test for a group with BOTH IIFEUnsafeStatement and a bare-local: unsafe wins (flat emission) because a return inside any IIFE, even a single big one, only exits the lambda.
2026-04-17 18:55:39 -05:00
J. Nick Koston
3ab935bebb [core] Expose IIFE_MAX_STATEMENTS constant and derive test sizes from it
Tests were hardcoding 120 statements and expecting 3 sub-chunks from a 50-cap. Extract the cap as a named module constant and compute the test-input size from it, so bumping the cap doesn't silently invalidate the tests.
2026-04-17 18:50:41 -05:00
J. Nick Koston
bb0067f517 [core] Strengthen RawExpression-as-arg test
Exercise the actual CallExpression(..., RawExpression) pattern that components use for passing raw arguments. The previous test used RawStatement filler which didn't exercise the detection path we wanted to assert doesn't trigger.
2026-04-17 18:49:04 -05:00
J. Nick Koston
550f6e7c72 [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.
2026-04-17 18:46:30 -05:00
J. Nick Koston
5f2582efcd [safe_mode] Fix setup()-exit return getting trapped in IIFE
safe_mode emits `if (should_enter_safe_mode(...)) return` via
cg.add(RawExpression(...)) to short-circuit the rest of setup() and
boot into safe mode. With setup() split into per-component IIFEs,
that `return` was only exiting the lambda, so the rest of setup() ran
anyway — breaking safe-mode recovery.

Add IIFEUnsafeStatement, a Statement wrapper that marks its containing
component's block for flat emission (no IIFE). safe_mode wraps its
return expression in it. cpp_main_section detects any such statement
in a group and emits that group flat so control-flow constructs like
`return` still affect setup() itself.

IIFEUnsafeStatement.__str__ routes its inner through statement() so
bare Expression subclasses pick up the terminating semicolon. Reported
by @swoboda1337.
2026-04-17 18:12:14 -05:00
J. Nick Koston
e26ce59797 for progmem 2026-04-17 16:18:39 -05:00
J. Nick Koston
9fa6d224c2 [core] Tighten docstrings and inline comments 2026-04-17 15:35:53 -05:00
J. Nick Koston
91b238aa97 [core] Fix grammar in ComponentMarker docstring 2026-04-17 15:34:15 -05:00
J. Nick Koston
00f08ba6ed [core] Drop per-component begin/end labels from generated main.cpp
The labels were there to help humans scanning the generated main.cpp
find component boundaries, but they were:

- Unreliable: CORE.flush_tasks can interleave coroutines on each
  await, so a component's later statements can land in another
  component's begin/end block.
- Load-bearing for a pile of complexity: a tuple return from
  _wrap_in_iifes, a has_iife flag, a comment-only detector to
  suppress trailing end-markers for comment-only components, and
  a brittle `"[]()" in line` check that could false-positive on
  YAML dumps containing lambda syntax.
- Not actually needed — generated main.cpp is a build artifact
  rarely read by anyone, and cg.LineComment("name:") already puts
  the component name at the start of its block.

ComponentMarker stays as a pure chunking sentinel — it tells
cpp_main_section where component boundaries are (for grouping) but
produces no C++ output. _wrap_in_iifes returns a plain list again.
Added a regression test for the now-defused case of a comment
containing "[]()" that was previously flagged by review.
2026-04-17 15:19:48 -05:00
J. Nick Koston
f82401a504 [core] Address Copilot review: robust brace depth, accurate docstrings
- Count { and } characters per line instead of matching whole-line
  tokens. Current codegen only emits scope braces as standalone lines
  (from cg.with_local_variable()), but the defensive change is robust
  against future codegen emitting inline control flow like
  `if (cond) {` or `} else {` on one line.
- Add a regression test covering those inline-brace patterns.
- Fix stale docstrings on ComponentMarker and cpp_main_section that
  still claimed "stack frame released on return" and described the
  IIFEs as "noinline". The IIFEs have no noinline attribute and rely
  on scope-based lifetime shortening rather than guaranteed frames.
2026-04-17 15:06:42 -05:00
J. Nick Koston
178f23a7aa [core] Use begin/end marker pairs around each component's IIFE
Rename the bracket markers from "// === X ===" (same on both sides)
to "// === begin X ===" and "// === end X ===" so the generated
main.cpp reads unambiguously when scanning by component. Comment-only
components still get a single "begin X" marker since they have no
IIFE to close.
2026-04-17 15:06:42 -05:00
J. Nick Koston
864d31aa65 [core] Put ComponentMarker outside the IIFE as a visual bracket
The marker comment was being emitted as the first line *inside* each
IIFE:

  []() {
    // === logger ===
    // logger:
    //   ...
    ...
  }();

That works but buries the component label inside the lambda body, so
scanning generated main.cpp to find "where does component X's setup
live" is harder than it needs to be. Emit the marker before and after
the IIFE instead:

  // === logger ===
  []() {
    // logger:
    //   ...
    ...
  }();
  // === logger ===

Comment-only components (e.g. sha256, async_tcp, empty platforms like
binary_sensor:) don't grow a useless trailing duplicate marker —
when there's no IIFE to bracket, the marker is emitted once.
2026-04-17 15:06:42 -05:00
J. Nick Koston
936694af2c [core] Don't emit IIFE for comment-only chunks
Some components (sha256, async_tcp, network, empty text_sensor:, etc.)
emit only a ComponentMarker plus config-dump comments and no actual
C++ statements. Wrapping those in a `[]() { ... }();` IIFE is pure
clutter in the generated main.cpp — the IIFE has no body.

When _wrap_in_iifes sees a chunk whose lines are all // comments,
emit them verbatim instead of wrapping. Peak stack and flash are
unchanged on apollo and neargaragedoor since GCC was already
eliding the empty IIFEs; this just makes the generated code read
cleanly to humans.
2026-04-17 15:06:42 -05:00
J. Nick Koston
6a7c9af870 [core] Drop noinline from IIFE chunks and rename helper
Additional measurements showed GCC's -Os inliner re-inlines most IIFE
chunks back into setup() by choice, and the structural scoping alone
captures nearly all of the peak-stack benefit on esp32 without the
flash cost of forcing all chunks to stay as real functions.

Apollo (esp32-s3, -Os) with vs without noinline:
  peak setup stack     176 B (noinline)  vs  304 B (scope-only)
  flash delta         +388 B (noinline)  vs   -504 B (scope-only)
  chunks kept          86               vs    20

Issue #15796 is an LVGL-setup class of bug that has only surfaced on
esp32 after years in the field; the extra guarantee that noinline
provides is not worth the flash cost in practice. Also rename the
helper from _wrap_in_noinline_iifes to _wrap_in_iifes to match.
2026-04-17 15:06:42 -05:00
J. Nick Koston
29dcf9fc51 [core] Use __attribute__((noinline)) on IIFE lambdas to honor attribute
The C++ standard-attribute spelling [[gnu::noinline]] placed between a
lambda's parameter list and body binds to the return type, not the
call operator. GCC 14 silently ignores it and emits -Wattributes
warnings at every chunk site. Switch to GCC's __attribute__((...))
syntax which binds to operator() as intended.

Measured impact on apollo-r-pro-1-eth (esp32-s3, -Os) vs the broken
[[gnu::noinline]] version: setup() frame 160 B -> 32 B, peak stack
304 B -> 176 B (another -42%). Flash grows by 888 B because all 86
chunks now stay as separate functions instead of GCC inlining the
small ones (which it was free to do when the attribute was ignored).

Net vs baseline -Os: peak stack 1264 B -> 176 B (-86%); flash
+388 B (<0.05% of a typical esp32 partition).
2026-04-17 15:06:42 -05:00
J. Nick Koston
6b67224286 [core] Chunk setup() into per-component noinline IIFEs
Generated setup() is a single monolithic function whose stack frame
scales super-linearly with config size. On a 5,943-line apollo build
the frame reached 1,264 B at -Os; extrapolation onto larger configs
(e.g. the 16k-line LVGL config in #15796) plausibly overflows the
8 KB loop task stack before safe_mode can increment its boot counter.

Emit a ComponentMarker sentinel at the start of each component's
to_code output, then have cpp_main_section wrap each component's
block (and sub-splits of up to 50 statements within each block) in a
noinline IIFE lambda. Each lambda's ENTRY frame is released on
return, bounding peak stack to setup() frame + max chunk frame.

Measured on apollo-r-pro-1-eth (esp32-s3, -Os):

  setup() frame        1264 B  ->  160 B
  max chunk frame      n/a     ->  144 B
  peak setup stack     1264 B  ->  304 B  (-76%)
  total flash      792,471 B   ->  791,995 B  (-476 B)

The brace-depth guard in _wrap_in_noinline_iifes ensures we never
split between the RawStatement("{") / RawStatement("}") pair emitted
by cg.with_local_variable() (currently only wifi), so scoped locals
stay intact.
2026-04-17 15:06:41 -05:00
6 changed files with 561 additions and 11 deletions

View File

@@ -569,6 +569,7 @@ def wrap_to_code(name, comp):
@functools.wraps(comp.to_code)
async def wrapped(conf):
cg.add(cg.ComponentMarker(name))
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
conf_str = yaml_util.dump(conf)

View File

@@ -10,8 +10,10 @@
# pylint: disable=unused-import
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
ComponentMarker,
Expression,
FlashStringLiteral,
IIFEUnsafeStatement,
LineComment,
LogStringLiteral,
MockObj,

View File

@@ -87,7 +87,10 @@ async def to_code(config):
config[CONF_REBOOT_TIMEOUT],
config[CONF_BOOT_IS_GOOD_AFTER],
)
cg.add(RawExpression(f"if ({condition}) return"))
# Wrap in IIFEUnsafeStatement so cpp_main_section emits this
# component's block flat rather than inside an IIFE lambda —
# the `return` must exit setup() itself, not just the lambda.
cg.add(cg.IIFEUnsafeStatement(RawExpression(f"if ({condition}) return")))
CORE.data[CONF_SAFE_MODE] = {}
CORE.data[CONF_SAFE_MODE][KEY_PAST_SAFE_MODE] = True

View File

@@ -1,5 +1,6 @@
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass, field
import logging
import math
import os
@@ -531,6 +532,126 @@ class Library:
return self
# Cap on the number of statements in a single IIFE chunk when a
# component's to_code body is sub-split. Picks a frame-size sweet spot
# on esp32-s3 — large enough that most components fit in one chunk and
# small enough that heavy sensor platforms (many filter registrations)
# don't produce a chunk with a very large spill frame.
IIFE_MAX_STATEMENTS = 50
@dataclass
class _ComponentGroup:
"""A contiguous run of statements emitted by one component's to_code."""
lines: list[str] = field(default_factory=list)
# True when the group contains a statement that must affect setup()'s
# own control flow (e.g. safe_mode's `return`). Emit the group flat,
# bypassing IIFE wrapping entirely.
unsafe: bool = False
# True when the group contains a statement that may declare a
# function-local whose lifetime extends past the current statement
# (scope-brace RawStatement, direct RawExpression, typed
# AssignmentExpression). Wrap the group in a single IIFE without
# sub-splitting so the declaration and any later references stay
# in the same lambda.
no_split: bool = False
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.
The detection is intentionally safety-biased: false negatives cause
silent broken C++, false positives just keep a component in one
slightly larger IIFE. Any ``cg.add(RawExpression(...))`` disables
sub-splitting for its group regardless of whether the raw text
actually references a local, because the chunker can't introspect
arbitrary raw text."""
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. A
# RawExpression passed as a CallExpression argument does NOT land
# here (its ExpressionStatement's .expression is the CallExpression),
# so value-pass patterns like `var.set_program(RawExpression("&foo"))`
# continue to sub-split normally.
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, 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."""
out: list[str] = []
chunk: list[str] = []
depth: int = 0
# Once depth goes negative we stop trusting the brace count and
# keep everything remaining in one final IIFE. A later ``{`` could
# arithmetically bring depth back to 0, but by that point the brace
# tracking is already unreliable — re-enabling mid-stream splits
# could land between a declaration and its use.
poisoned: bool = False
def flush() -> None:
if not chunk:
return
if all(line.lstrip().startswith("//") for line in chunk):
out.extend(chunk)
else:
out.append("[]() {")
out.extend(chunk)
out.append("}();")
chunk.clear()
for line in lines:
chunk.append(line)
# Count { and } per line so inline control flow (e.g. `if (cond) {`)
# and balanced inline lambdas are tracked correctly.
depth += line.count("{") - line.count("}")
if depth < 0:
poisoned = True
if (
not poisoned
and max_statements is not None
and depth == 0
and len(chunk) >= max_statements
):
flush()
flush()
return out
# pylint: disable=too-many-public-methods
class EsphomeCore:
def __init__(self):
@@ -1002,15 +1123,64 @@ class EsphomeCore:
self.data[KEY_CONTROLLER_REGISTRY_COUNT] = controller_count + 1
@property
def cpp_main_section(self):
from esphome.cpp_generator import statement
def cpp_main_section(self) -> str:
from esphome.cpp_generator import (
ComponentMarker,
IIFEUnsafeStatement,
statement,
)
main_code = []
# Split main_statements at ComponentMarker sentinels and wrap each
# 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.
#
# 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[_ComponentGroup] = []
current: list[str] = prefix
group: _ComponentGroup | None = None
for exp in self.main_statements:
text = str(statement(exp))
text = text.rstrip()
main_code.append(text)
return "\n".join(main_code) + "\n\n"
if isinstance(exp, ComponentMarker):
group = _ComponentGroup()
components.append(group)
current = group.lines
continue
if group is not None:
if isinstance(exp, IIFEUnsafeStatement):
group.unsafe = True
if _emits_bare_local(exp):
group.no_split = True
current.append(str(statement(exp)).rstrip())
if not components:
return "\n".join(prefix) + "\n\n"
pieces: list[str] = list(prefix)
for g in components:
if g.unsafe:
pieces.extend(g.lines)
else:
cap = None if g.no_split else IIFE_MAX_STATEMENTS
pieces.extend(_wrap_in_iifes(g.lines, max_statements=cap))
return "\n".join(pieces) + "\n\n"
@property
def cpp_global_section(self):

View File

@@ -434,6 +434,48 @@ class LineComment(Statement):
return "\n".join(parts)
class IIFEUnsafeStatement(Statement):
"""Statement that must not be placed inside an IIFE lambda when
``cpp_main_section`` chunks ``setup()``. Causes the containing
component's block to be emitted flat (no IIFE), so constructs that
rely on exiting ``setup()`` directly — e.g. safe_mode's
``if (should_enter_safe_mode(...)) return;`` — still work.
Accepts either a ``Statement`` or a bare ``Expression``; bare
expressions are wrapped so they terminate with a semicolon."""
__slots__ = ("inner",)
def __init__(self, inner: Expression | Statement) -> None:
self.inner = inner
def __str__(self) -> str:
return str(statement(self.inner))
class ComponentMarker(Statement):
"""Chunking-boundary sentinel. ``cpp_main_section`` wraps the
statements between two markers in an IIFE to shorten temporary
lifetimes and bound peak setup-time stack. Emits no C++ output.
Grouping is best-effort: ``flush_tasks`` can interleave coroutines
on ``await``, so a component's later statements may land in another
component's chunk. This is safe for the dominant codegen patterns
(placement-new into static storage, assignment to a file-scope
global); patterns that depend on function-local state within the
IIFE scope (cg.variable, with_local_variable, raw bare locals)
are kept together by the bare-local detection in cpp_main_section
so they aren't split across sibling lambdas."""
__slots__ = ("name",)
def __init__(self, name: str) -> None:
self.name = name
def __str__(self) -> str:
return f"// component-marker: {self.name}"
class ProgmemAssignmentExpression(AssignmentExpression):
__slots__ = ()
@@ -458,7 +500,13 @@ def progmem_array(id_, rhs) -> "MockObj":
rhs = safe_exp(rhs)
obj = MockObj(id_, ".")
assignment = ProgmemAssignmentExpression(id_.type, id_, rhs)
CORE.add(assignment)
# Emit at file scope, not inside setup(). setup() is split into
# per-component IIFE lambdas; a function-local static declared in one
# lambda is not visible to statements in sibling lambdas that
# reference the same shared table (e.g. two lights sharing a gamma
# lookup). File-scope static constexpr is semantically identical for
# read-only lookup tables.
CORE.add_global(assignment)
CORE.register_variable(id_, obj)
return obj
@@ -467,7 +515,7 @@ def static_const_array(id_, rhs) -> "MockObj":
rhs = safe_exp(rhs)
obj = MockObj(id_, ".")
assignment = StaticConstAssignmentExpression(id_.type, id_, rhs)
CORE.add(assignment)
CORE.add_global(assignment)
CORE.register_variable(id_, obj)
return obj
@@ -490,10 +538,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

View File

@@ -7,6 +7,16 @@ import pytest
from strategies import mac_addr_strings
from esphome import const, core
from esphome.cpp_generator import (
AssignmentExpression,
CallExpression,
ComponentMarker,
ExpressionStatement,
IIFEUnsafeStatement,
MockObj,
RawExpression,
RawStatement,
)
class TestHexInt:
@@ -867,3 +877,314 @@ class TestEsphomeCore:
mock_enable.assert_called_once_with("Wire")
assert "Wire" in target.platformio_libraries
def test_wrap_in_iifes_empty_input() -> None:
assert core._wrap_in_iifes([], max_statements=10) == []
def test_wrap_in_iifes_fewer_lines_than_limit() -> None:
lines = ["a();", "b();", "c();"]
assert core._wrap_in_iifes(lines, max_statements=10) == [
"[]() {",
"a();",
"b();",
"c();",
"}();",
]
def test_wrap_in_iifes_splits_at_max_statements() -> None:
lines = [f"s{i}();" for i in range(5)]
result = core._wrap_in_iifes(lines, max_statements=2)
# With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs.
assert sum(1 for line in result if line.startswith("[]()")) == 3
def test_wrap_in_iifes_never_splits_inside_braces() -> None:
# max=2 would naively split after "{" but brace guard keeps block whole.
lines = ["a();", "{", "inner();", "}", "b();"]
assert core._wrap_in_iifes(lines, max_statements=2) == [
"[]() {",
"a();",
"{",
"inner();",
"}",
"}();",
"[]() {",
"b();",
"}();",
]
def test_wrap_in_iifes_nested_braces() -> None:
lines = ["{", "{", "deep();", "}", "}", "after();"]
assert core._wrap_in_iifes(lines, max_statements=1) == [
"[]() {",
"{",
"{",
"deep();",
"}",
"}",
"}();",
"[]() {",
"after();",
"}();",
]
def test_wrap_in_iifes_unbalanced_braces_fall_through() -> None:
# Pathological input where "}" appears before "{": don't crash; emit
# a single IIFE with all lines rather than splitting mid-flight.
lines = ["a();", "}", "b();"]
result = core._wrap_in_iifes(lines, max_statements=1)
assert result[0] == "[]() {"
assert result[-1] == "}();"
assert [line for line in result if line in lines] == lines
def test_wrap_in_iifes_negative_depth_stays_poisoned() -> None:
# Once depth goes negative the brace tracker is unreliable; a later
# arithmetic return to depth 0 must not re-enable splitting. Here
# "}" drives depth to -1 immediately, then "{" later brings depth
# back to 0 arithmetically. With max=1 every statement would flush
# if splitting were still enabled — assert everything emits as one
# IIFE because the poisoned flag stayed set.
lines = ["}", "a();", "{", "b();", "}", "c();"]
result = core._wrap_in_iifes(lines, max_statements=1)
assert sum(1 for line in result if line == "[]() {") == 1
assert result[0] == "[]() {"
assert result[-1] == "}();"
def test_wrap_in_iifes_never_splits_inline_brace_lines() -> None:
# Defensive: if codegen ever emits control flow with braces on the
# same line (if/else/for), the depth tracker should keep the whole
# scoped block together even with aggressive max_statements.
lines = [
"before();",
"if (cond) {",
"then_branch();",
"} else {",
"for (;;) {",
"loop_body();",
"}",
"}",
"after();",
]
assert core._wrap_in_iifes(lines, max_statements=1) == [
"[]() {",
"before();",
"}();",
"[]() {",
"if (cond) {",
"then_branch();",
"} else {",
"for (;;) {",
"loop_body();",
"}",
"}",
"}();",
"[]() {",
"after();",
"}();",
]
def test_wrap_in_iifes_skips_comment_only_chunks() -> None:
# A chunk with no C++ statements (only comments, e.g. a component's
# config dump) should be emitted verbatim without a no-op IIFE.
lines = ["// sha256:", "// {}"]
assert core._wrap_in_iifes(lines, max_statements=50) == lines
def test_wrap_in_iifes_ignores_iife_pattern_in_comment() -> None:
# A comment whose text mentions "[]()" (e.g. a YAML dump of a
# lambda) must not fool the comment-only detector into wrapping.
lines = [
"// on_value:",
"// - !lambda |-",
"// return []() { return 5; };",
]
assert core._wrap_in_iifes(lines, max_statements=50) == lines
def test_cpp_main_section_no_components_emits_flat() -> None:
target = core.EsphomeCore()
target.main_statements = [RawStatement("a();"), RawStatement("b();")]
out = target.cpp_main_section
assert "[]() {" not in out
assert "a();" in out
assert "b();" in out
def test_cpp_main_section_component_marker_wraps_in_iife() -> None:
target = core.EsphomeCore()
target.main_statements = [
ComponentMarker("logger"),
RawStatement("new_logger();"),
ComponentMarker("wifi"),
RawStatement("new_wifi();"),
]
out = target.cpp_main_section
# One IIFE per component that emits C++ statements.
assert out.count("[]() {") == 2
assert out.count("}();") == 2
# ComponentMarker produces no output of its own.
assert "component-marker" not in out
_CHUNK_COUNT = 3 # number of sub-chunks to force when testing splitting
# Statement count that produces exactly _CHUNK_COUNT IIFEs when sub-split
# at IIFE_MAX_STATEMENTS (full chunks at the cap, no partial).
_STATEMENTS_OVER_CAP = core.IIFE_MAX_STATEMENTS * _CHUNK_COUNT
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(_STATEMENTS_OVER_CAP))
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(_STATEMENTS_OVER_CAP)
)
target.main_statements = stmts
out = target.cpp_main_section
assert out.count("[]() {") == _CHUNK_COUNT
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(_STATEMENTS_OVER_CAP)
)
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")]
stmts.extend(
ExpressionStatement(
CallExpression(MockObj(f"var_{i}.set_program"), RawExpression("&foo"))
)
for i in range(_STATEMENTS_OVER_CAP)
)
target.main_statements = stmts
out = target.cpp_main_section
assert out.count("[]() {") == _CHUNK_COUNT
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(_STATEMENTS_OVER_CAP))
target.main_statements = stmts
out = target.cpp_main_section
assert out.count("[]() {") == 1
def test_cpp_main_section_iife_unsafe_wins_over_no_split() -> None:
# A group that triggers BOTH flags (IIFEUnsafeStatement present AND
# a bare-local emission) must still be emitted flat — the unsafe
# flag wins because a `return` inside any IIFE, even a single big
# one, only exits the lambda.
target = core.EsphomeCore()
target.main_statements = [
ComponentMarker("safe_mode_with_local"),
RawStatement("{"), # would trigger no_split
RawStatement("new_foo();"),
RawStatement("}"),
IIFEUnsafeStatement(RawExpression("if (cond) return")),
]
out = target.cpp_main_section
assert "[]() {" not in out
assert "if (cond) return;" in out
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
# IIFE would only exit the lambda, not setup().
target = core.EsphomeCore()
target.main_statements = [
ComponentMarker("logger"),
RawStatement("new_logger();"),
ComponentMarker("safe_mode"),
RawStatement("new_safe_mode();"),
IIFEUnsafeStatement(RawExpression("if (entering) return")),
ComponentMarker("sensor"),
RawStatement("new_sensor();"),
]
out = target.cpp_main_section
# logger and sensor wrapped; safe_mode flat.
assert out.count("[]() {") == 2
# safe_mode's statements appear at top level, not indented in a lambda.
assert "new_safe_mode();" in out
assert "if (entering) return;" in out
# The IIFEUnsafeStatement wrapper picks up the trailing semicolon
# via statement() when inner is a bare Expression.
assert "if (entering) return\n" not in out
def test_cpp_main_section_comment_only_component_omits_iife() -> None:
# A component that emits only a ComponentMarker (no statements) adds
# nothing to the generated output. A neighboring component with
# actual code still gets its own IIFE.
target = core.EsphomeCore()
target.main_statements = [
ComponentMarker("sha256"),
ComponentMarker("wifi"),
RawStatement("new_wifi();"),
]
out = target.cpp_main_section
assert out.count("[]() {") == 1
assert "new_wifi();" in out
def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None:
target = core.EsphomeCore()
target.main_statements = [
RawStatement("prefix();"),
ComponentMarker("c"),
RawStatement("body();"),
]
out = target.cpp_main_section
assert out.index("prefix();") < out.index("[]() {")