diff --git a/backend/app/services/symbol_extract.py b/backend/app/services/symbol_extract.py index 72521547..0f12a4f1 100644 --- a/backend/app/services/symbol_extract.py +++ b/backend/app/services/symbol_extract.py @@ -368,23 +368,38 @@ def _scan_text(text: str, add: Callable[[dict], Optional[dict]], detail: str) -> stack: list[dict] = [] i, n = 0, len(text) - def handle_statement(stmt: str) -> None: + def apply_access(stmt: str) -> str: + """Consume `public:` / `private:` / `protected:` labels at the head of + a statement, updating the enclosing class scope, and return the rest. + Must run for EVERY statement inside a class, including the ones that + open a nested enum/struct/brace: `public:\n typedef enum {` used to + take the enum branch without ever seeing its `public:`, leaving the + class in its default private access and dropping every method that + followed (AccelStepper lost its whole API that way).""" if not stack or stack[-1]["kind"] != "classlike": - return + return stmt scope = stack[-1] candidate = stmt for m in _ACCESS_RE.finditer(stmt): scope["access"] = m.group(1) candidate = stmt[m.end():] - if scope["access"] != "public": + return candidate + + def handle_statement(stmt: str) -> None: + candidate = apply_access(stmt) + if not stack or stack[-1]["kind"] != "classlike": return - _try_method(candidate.strip(), scope, add, detail) + if stack[-1]["access"] != "public": + return + _try_method(candidate.strip(), stack[-1], add, detail) while i < n: c = text[i] if c == "{": stmt = "".join(buf).strip() buf = [] + # Access labels apply whichever branch the statement takes below. + stmt = apply_access(stmt) if "template" not in stmt: em = _ENUM_RE.search(stmt) if em and re.search(r"\benum\b", stmt): diff --git a/backend/tests/test_symbol_extract.py b/backend/tests/test_symbol_extract.py index 94c20975..095e7261 100644 --- a/backend/tests/test_symbol_extract.py +++ b/backend/tests/test_symbol_extract.py @@ -123,6 +123,41 @@ def test_scan_library_constants_and_enums(fake_lib: Path) -> None: # ── spec parsing / normalization (used by the route) ────────────────────── +def test_access_label_survives_a_nested_enum(tmp_path: Path) -> None: + """`public:` followed by a nested typedef enum used to take the enum branch + of the walker without ever applying the access label, so the class stayed + private and every method after the enum vanished. AccelStepper shipped 4 + of its ~50 public methods that way; DallasTemperature shipped 0.""" + lib = tmp_path / "steplib@1.0.0-abcdefabcdef" + src = lib / "src" + src.mkdir(parents=True) + (lib / "library.properties").write_text("name=Step Lib\nversion=1.0.0\n", encoding="utf-8") + (src / "Step.h").write_text( + """\ +class Stepper { +public: + typedef enum { FUNCTION = 0, DRIVER = 1 } MotorInterfaceType; + Stepper(uint8_t iface = DRIVER); + void moveTo(long absolute); + bool run(); +protected: + void step(long n); +private: + int _pin; +public: + void setMaxSpeed(float speed); +}; +""", + encoding="utf-8", + ) + result = scan_library(lib) + methods = _by_kind(result, "method") + assert set(methods) == {"moveTo", "run", "setMaxSpeed"} + assert all(m["owner"] == "Stepper" for m in methods.values()) + assert "step" not in methods and "_pin" not in methods + assert {"FUNCTION", "DRIVER"} <= set(_by_kind(result, "constant")) + + def test_parse_spec() -> None: assert parse_spec("Adafruit GFX Library@1.12.6") == ("Adafruit GFX Library", "1.12.6") assert parse_spec("FastLED") == ("FastLED", None)