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_NAME = "_types.json"
|
||||||
_TYPE_INDEX_MAX_AGE_S = 6 * 3600
|
_TYPE_INDEX_MAX_AGE_S = 6 * 3600
|
||||||
|
_MAX_BASES = 8
|
||||||
|
|
||||||
|
|
||||||
def _type_index(root: Path) -> dict[str, str]:
|
def _type_index(root: Path) -> dict[str, str]:
|
||||||
|
|
@ -167,13 +168,56 @@ def get_type_members(name: str) -> JSONResponse:
|
||||||
if not entry.is_dir():
|
if not entry.is_dir():
|
||||||
raise HTTPException(status_code=404, detail=f"stale type index for {name}")
|
raise HTTPException(status_code=404, detail=f"stale type index for {name}")
|
||||||
payload = _payload_for(root, entry)
|
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(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"id": payload.get("id") or entry_name,
|
"id": payload.get("id") or entry_name,
|
||||||
"triggers": payload.get("triggers", []),
|
"triggers": payload.get("triggers", []),
|
||||||
"type": name,
|
"type": name,
|
||||||
"symbols": members,
|
"bases": chain,
|
||||||
|
"symbols": symbols,
|
||||||
},
|
},
|
||||||
headers=_CACHE_HEADERS,
|
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
|
# export macro between the keyword and the name, optional `final`, optional
|
||||||
# base-clause. Anchored at the end so `typedef struct` (anonymous) never hits.
|
# base-clause. Anchored at the end so `typedef struct` (anonymous) never hits.
|
||||||
_CLASS_RE = re.compile(
|
_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(
|
_ENUM_RE = re.compile(
|
||||||
r"\benum(?:\s+(class|struct))?(?:\s+([A-Za-z_]\w*))?\s*(?::\s*[\w:\s]+)?$"
|
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
|
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:
|
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
|
"""Attempt to parse `candidate` (a statement inside a public class/struct
|
||||||
section, stripped of access labels) as a method declaration. Emits a
|
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()
|
candidate = re.sub(r"\s+", " ", candidate).strip()
|
||||||
if not candidate or "operator" in candidate or "~" in candidate or "template" in candidate:
|
if not candidate or "operator" in candidate or "~" in candidate or "template" in candidate:
|
||||||
return
|
return
|
||||||
|
parts_ = _split_comma_declarators(candidate)
|
||||||
|
if len(parts_) > 1:
|
||||||
|
for one in parts_:
|
||||||
|
_try_method(one, scope, add, detail)
|
||||||
|
return
|
||||||
p = candidate.find("(")
|
p = candidate.find("(")
|
||||||
if p <= 0:
|
if p <= 0:
|
||||||
return
|
return
|
||||||
|
|
@ -411,6 +472,13 @@ def _scan_text(text: str, add: Callable[[dict], Optional[dict]], detail: str) ->
|
||||||
if cm:
|
if cm:
|
||||||
cname = cm.group(2)
|
cname = cm.group(2)
|
||||||
sym = {"name": cname, "kind": "class", "detail": detail}
|
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)
|
stored = add(sym)
|
||||||
stack.append({
|
stack.append({
|
||||||
"kind": "classlike",
|
"kind": "classlike",
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ public:
|
||||||
methods = _by_kind(result, "method")
|
methods = _by_kind(result, "method")
|
||||||
assert set(methods) == {"moveTo", "run", "setMaxSpeed"}
|
assert set(methods) == {"moveTo", "run", "setMaxSpeed"}
|
||||||
assert all(m["owner"] == "Stepper" for m in methods.values())
|
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 "step" not in methods and "_pin" not in methods
|
||||||
assert {"FUNCTION", "DRIVER"} <= set(_by_kind(result, "constant"))
|
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")
|
r = client.get("/api/intellisense/type/BundleThing")
|
||||||
assert r.status_code == 200 and r.json()["id"] == "ESP Bundle"
|
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/Nope").status_code == 404
|
||||||
assert client.get("/api/intellisense/type/not-a-type!").status_code == 400
|
assert client.get("/api/intellisense/type/not-a-type!").status_code == 400
|
||||||
# the index is persisted and reused
|
# the index is persisted and reused
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue