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:
parent
1a112324ab
commit
9118529d10
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue