diff --git a/backend/app/api/routes/intellisense.py b/backend/app/api/routes/intellisense.py index 560715d6..707ee833 100644 --- a/backend/app/api/routes/intellisense.py +++ b/backend/app/api/routes/intellisense.py @@ -83,6 +83,7 @@ def _payload_for(root: Path, entry: Path) -> dict: _TYPE_INDEX_NAME = "_types.json" _TYPE_INDEX_MAX_AGE_S = 6 * 3600 +_MAX_BASES = 8 def _type_index(root: Path) -> dict[str, str]: @@ -167,13 +168,56 @@ def get_type_members(name: str) -> JSONResponse: if not entry.is_dir(): raise HTTPException(status_code=404, detail=f"stale type index for {name}") payload = _payload_for(root, entry) - members = [s for s in payload.get("symbols", []) if s.get("owner") == name] + + # Own members first, then the inheritance chain, breadth-first, each base + # resolved through the same index (it may live in another library: + # Adafruit_SH1106G -> Adafruit_SH110X -> Adafruit_GrayOLED -> Adafruit_GFX + # crosses two). Members are re-owned to the requested type so the editor + # sees one flat class; `bases` lists the chain so a host that already + # embeds one of them (Adafruit_GFX is curated client-side) can dedupe. + symbols: list[dict] = [] + seen_names: set[str] = set() + chain: list[str] = [] + queue = [(name, payload)] + visited = {name} + types = _type_index(root) + while queue and len(chain) < _MAX_BASES: + cur, cur_payload = queue.pop(0) + if cur != name: + chain.append(cur) + for sym in cur_payload.get("symbols", []): + if sym.get("owner") != cur or sym["name"] in seen_names: + continue + seen_names.add(sym["name"]) + out = dict(sym) + out["owner"] = name + if cur != name: + out["inheritedFrom"] = cur + symbols.append(out) + cls = next( + (s for s in cur_payload.get("symbols", []) if s.get("kind") == "class" and s.get("name") == cur), + None, + ) + for base in (cls or {}).get("bases", []): + if base in visited: + continue + visited.add(base) + base_entry = types.get(base) + if base_entry and (root / base_entry).is_dir(): + queue.append((base, _payload_for(root, root / base_entry))) + else: + # Not in the cache (a core class like Print/Stream, or an + # embedded one like Adafruit_GFX): still report it so the + # host can supply the members itself. + chain.append(base) + return JSONResponse( content={ "id": payload.get("id") or entry_name, "triggers": payload.get("triggers", []), "type": name, - "symbols": members, + "bases": chain, + "symbols": symbols, }, headers=_CACHE_HEADERS, ) diff --git a/backend/app/services/symbol_extract.py b/backend/app/services/symbol_extract.py index 0f12a4f1..a0f9a26a 100644 --- a/backend/app/services/symbol_extract.py +++ b/backend/app/services/symbol_extract.py @@ -204,8 +204,28 @@ def _extract_directives(text: str) -> tuple[str, list[str]]: # export macro between the keyword and the name, optional `final`, optional # base-clause. Anchored at the end so `typedef struct` (anonymous) never hits. _CLASS_RE = re.compile( - r"\b(class|struct)\s+(?:[A-Z][A-Z0-9_]*\s+)?([A-Za-z_]\w*)\s*(?:final\s*)?(?::[^{;]*)?$" + r"\b(class|struct)\s+(?:[A-Z][A-Z0-9_]*\s+)?([A-Za-z_]\w*)\s*(?:final\s*)?(?::([^{;]*))?$" ) +# One base in a base-clause: `public Adafruit_GFX`, `virtual private Print`, +# `Adafruit_GrayOLED`, `Print` (template args dropped), `ns::Base`. +_BASE_RE = re.compile(r"(?:\b(?:public|protected|private|virtual)\s+)*([A-Za-z_][\w:]*)") + + +def _parse_bases(clause: Optional[str]) -> list[str]: + """Base class names from the text after the `:` of a class head. Access + and virtual keywords are dropped, `ns::Base` keeps its last segment, + template arguments are cut. Order preserved (first base first).""" + if not clause: + return [] + out: list[str] = [] + for part in clause.split(","): + m = _BASE_RE.search(part.strip()) + if not m: + continue + name = m.group(1).split("<", 1)[0].rsplit("::", 1)[-1] + if name and name not in out: + out.append(name) + return out _ENUM_RE = re.compile( r"\benum(?:\s+(class|struct))?(?:\s+([A-Za-z_]\w*))?\s*(?::\s*[\w:\s]+)?$" ) @@ -267,6 +287,42 @@ def _build_call(name: str, parts: list[str]) -> tuple[str, str]: return ", ".join(parts), insert +def _split_comma_declarators(candidate: str) -> list[str]: + """`void a(int), b(), c(char x);` declares three methods sharing a return + type (TFT_eSPI declares setTextColor/setTextSize and setCursor/... this + way). Split on top-level commas that sit OUTSIDE parentheses and after a + complete `name(args)` group, and prepend the shared head to each tail. + Anything else -- a single declarator, or commas only inside argument + lists -- comes back as a one-element list.""" + depth = 0 + cuts: list[int] = [] + seen_group = False + for k, ch in enumerate(candidate): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + seen_group = True + elif ch == "," and depth == 0 and seen_group: + cuts.append(k) + if not cuts: + return [candidate] + first = candidate[: cuts[0]] + p = first.find("(") + nm = _NAME_TAIL_RE.search(first[:p]) if p > 0 else None + if not nm: + return [candidate] + head = first[: nm.start()] + out = [first] + prev = cuts[0] + 1 + for cut in cuts[1:] + [len(candidate)]: + out.append(head + candidate[prev:cut].strip()) + prev = cut + 1 + # the trailing `;`-less tail keeps whatever trailer it had (const, = 0) + return out + + def _try_method(candidate: str, scope: dict, add: Callable[[dict], Optional[dict]], detail: str) -> None: """Attempt to parse `candidate` (a statement inside a public class/struct section, stripped of access labels) as a method declaration. Emits a @@ -275,6 +331,11 @@ def _try_method(candidate: str, scope: dict, add: Callable[[dict], Optional[dict candidate = re.sub(r"\s+", " ", candidate).strip() if not candidate or "operator" in candidate or "~" in candidate or "template" in candidate: return + parts_ = _split_comma_declarators(candidate) + if len(parts_) > 1: + for one in parts_: + _try_method(one, scope, add, detail) + return p = candidate.find("(") if p <= 0: return @@ -411,6 +472,13 @@ def _scan_text(text: str, add: Callable[[dict], Optional[dict]], detail: str) -> if cm: cname = cm.group(2) sym = {"name": cname, "kind": "class", "detail": detail} + bases = _parse_bases(cm.group(3)) + if bases: + # Lets the /type/ endpoint (and the editor) follow + # inheritance: Adafruit_SH1106G -> Adafruit_SH110X -> + # Adafruit_GrayOLED -> Adafruit_GFX, where the drawing + # API actually lives. + sym["bases"] = bases stored = add(sym) stack.append({ "kind": "classlike", diff --git a/backend/tests/test_symbol_extract.py b/backend/tests/test_symbol_extract.py index efe5fed8..a9c59d87 100644 --- a/backend/tests/test_symbol_extract.py +++ b/backend/tests/test_symbol_extract.py @@ -154,6 +154,7 @@ public: methods = _by_kind(result, "method") assert set(methods) == {"moveTo", "run", "setMaxSpeed"} assert all(m["owner"] == "Stepper" for m in methods.values()) + assert _by_kind(result, "class")["Stepper"].get("bases") is None assert "step" not in methods and "_pin" not in methods assert {"FUNCTION", "DRIVER"} <= set(_by_kind(result, "constant")) @@ -261,6 +262,31 @@ def test_type_endpoint_returns_only_the_declaring_class_members( r = client.get("/api/intellisense/type/BundleThing") assert r.status_code == 200 and r.json()["id"] == "ESP Bundle" + # Inheritance is followed through the index, members re-owned to the + # requested type and tagged with where they came from; bases outside the + # cache are still listed so the host can supply them. + lib( + "gfxlike@1.0.0-cccccccccccc", + "GfxLike", + "class GfxBase : public Print {\npublic:\n void drawPixel(int x, int y);\n" + " void setTextSize(uint8_t s), setTextColor(uint16_t c);\n};\n", + ) + lib( + "oledlike@1.0.0-dddddddddddd", + "OledLike", + "class Oled : public GfxBase {\npublic:\n bool begin();\n};\n", + ) + # cache grew -> index rebuilds + r = client.get("/api/intellisense/type/Oled") + assert r.status_code == 200 + body = r.json() + assert body["bases"] == ["GfxBase", "Print"] + by_name = {s["name"]: s for s in body["symbols"]} + assert set(by_name) == {"begin", "drawPixel", "setTextSize", "setTextColor"} + assert "inheritedFrom" not in by_name["begin"] + assert by_name["setTextSize"]["inheritedFrom"] == "GfxBase" + assert all(s["owner"] == "Oled" for s in body["symbols"]) + assert client.get("/api/intellisense/type/Nope").status_code == 404 assert client.get("/api/intellisense/type/not-a-type!").status_code == 400 # the index is persisted and reused