feat(intellisense): /type/ follows inheritance; extractor records bases and splits comma declarators

Measured against the user corpus, the by-resolved-type long tail was
losing what the old catch-all happened to include: for `Adafruit_SH1106G
display(...); display.setTextSize(` the class itself declares one method
(begin) and everything people call lives four levels up (Adafruit_SH110X ->
Adafruit_GrayOLED -> Adafruit_GFX -> Print). Same story for MCUFRIEND_kbv,
Adafruit_NeoMatrix and every GFX-derived display driver.

Three changes:

- The scanner records `bases` on class symbols (access/virtual keywords
  dropped, `ns::Base` -> Base, template args cut). 2,529 classes across the
  production cache now carry their bases.

- /type/{name} walks the chain breadth-first through the type index (bases
  routinely live in a different library), re-owns inherited members to the
  requested type so the editor sees one flat class, tags them with
  `inheritedFrom`, and lists the chain in `bases` -- including bases NOT in
  the cache (Print, Stream, or an embedded one like Adafruit_GFX) so the
  host can supply those itself. Depth capped at 8. Adafruit_SH1106G goes
  from 1 member to 64.

- `void a(int), b(), c(char);` declares three methods; the extractor took
  only the first. TFT_eSPI declares setTextColor/setTextSize and
  setCursor/getCursorX/... this way. Split on top-level commas after a
  complete `name(args)` group. TFT_eSPI 118 -> 173 members.

Rescan of the 1,125-entry production cache: +274 member symbols, 15
libraries gain, none lose.
This commit is contained in:
David Montero 2026-08-17 19:08:57 +02:00
parent 1a112324ab
commit 9118529d10
3 changed files with 141 additions and 3 deletions

View File

@ -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,
)

View File

@ -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<T>` (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",

View File

@ -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