"""Dependency-free C++ header symbol scanner for the intellisense endpoint. Scans an Arduino library tree (as laid out in the content-addressed library cache: library.properties + headers in the root and src/) and extracts a small, editor-friendly symbol table: classes, their PUBLIC methods, object-like #define constants and enum values. Regex/state-machine based on purpose — no tree-sitter, no libclang, no new pip dependency. Robustness beats completeness: any construct that cannot be parsed confidently is skipped. This module also mirrors (reimplements — it must NOT import) the pro overlay's library-cache naming rules from pro/backend/app/pro/services/library_cache.py: cache entries are directories named `@-`, where norm_name keeps lowercase alphanumerics only and norm_version collapses the untrusted library.properties version into a safe path token. The OSS image must resolve those entries without the overlay being importable, so the normalization and newest-version-first ordering are duplicated here verbatim. """ from __future__ import annotations import json import logging import os import re from pathlib import Path from typing import Callable, Optional logger = logging.getLogger(__name__) # Hard cap on symbols per library: keeps responses and the on-disk symbol # cache bounded even for pathological headers. MAX_SYMBOLS = 400 _HEADER_SUFFIXES = frozenset({".h", ".hpp"}) # Directories never scanned for symbols (case-insensitive match on the name). _SKIP_DIRS = frozenset({"examples", "example", "test", "tests", "docs", "doc", "extras"}) _CACHE_META = ".velxio-cache-meta.json" # --------------------------------------------------------------------------- # Library-cache naming (mirrors pro library_cache.py — do not import it) # --------------------------------------------------------------------------- def norm_name(name: str) -> str: """Lowercase alphanumerics only — matches the pro cache's norm_name so a request spec, a folder name and a cache key all compare equal.""" return "".join(ch for ch in (name or "").lower() if ch.isalnum()) def norm_version(version: str) -> str: """Collapse an untrusted version string into a single safe, traversal-free path token (mirrors the pro cache's norm_version).""" v = re.sub(r"[^A-Za-z0-9._-]", "-", (version or "").strip()) v = v.lstrip(".") # never a leading dot (no '.'/'..' dir tokens) return v or "0" def parse_spec(spec: str) -> tuple[str, Optional[str]]: """Split a `Name@Version` request spec into (name, version). A bare `Name` yields (name, None). Whitespace around either part is ignored.""" name, sep, version = (spec or "").partition("@") return name.strip(), (version.strip() if sep else None) def _entry_version_key(entry: Path) -> tuple: """Rank cache entries for the SAME name: newest version first when the sort is reversed (mirrors the pro cache's _entry_version_key). Parses the version token out of '@-' into numeric release tokens + pre-release tag so '3.10.1' > '3.6.0' compares numerically, and '1.2.3' > '1.2.3-rc1'. Ties break on the sidecar's populated_at.""" ver = entry.name.rsplit("-", 1)[0].partition("@")[2] tokens = re.split(r"[.\-_]", ver) release: list[int] = [] for tok in tokens: if not tok.isdigit(): break release.append(int(tok)) pre = tuple(tokens[len(release):]) populated = 0 try: meta = json.loads((entry / _CACHE_META).read_text(encoding="utf-8")) populated = int(meta.get("populated_at") or 0) except (OSError, ValueError, TypeError): pass return (release, 0 if pre else 1, pre, populated) def find_cache_entry(cache_root: Path, name: str, version: Optional[str] = None) -> Optional[Path]: """Resolve a cache entry dir under `cache_root` for a library name and optional version. With a version -> first `@-*` match in key order (mirrors the pro cache's lookup). Without one -> the NEWEST version for the bare name (mirrors lookup_by_name). Returns None when the root is missing, the name normalizes to empty, or nothing matches — callers degrade gracefully (OSS self-host may have no cache at all).""" n = norm_name(name) if not n or not cache_root.is_dir(): return None if version is not None: prefix = f"{n}@{norm_version(version)}-" for d in sorted(cache_root.iterdir()): if d.is_dir() and not d.name.startswith(".") and d.name.startswith(prefix): return d return None prefix = f"{n}@" matches = [ d for d in sorted(cache_root.iterdir()) if d.is_dir() and not d.name.startswith(".") and d.name.startswith(prefix) ] if not matches: return None matches.sort(key=_entry_version_key, reverse=True) return matches[0] # --------------------------------------------------------------------------- # C++ header scanning # --------------------------------------------------------------------------- def _strip_comments(text: str) -> str: """Remove // and /* */ comments, respecting string and char literals so a quoted "//" never truncates a line. Newlines inside block comments are preserved so directive lines keep their own line.""" out: list[str] = [] i, n = 0, len(text) state = "code" # code | line | block | dq | sq while i < n: c = text[i] nxt = text[i + 1] if i + 1 < n else "" if state == "code": if c == "/" and nxt == "/": state = "line" i += 2 continue if c == "/" and nxt == "*": state = "block" i += 2 continue if c == '"': state = "dq" elif c == "'": state = "sq" out.append(c) i += 1 elif state == "line": if c == "\n": out.append(c) state = "code" i += 1 elif state == "block": if c == "*" and nxt == "/": state = "code" i += 2 continue if c == "\n": out.append(c) i += 1 else: # inside a string/char literal if c == "\\" and nxt: out.append(c) out.append(nxt) i += 2 continue if (state == "dq" and c == '"') or (state == "sq" and c == "'"): state = "code" out.append(c) i += 1 return "".join(out) # Object-like #define with an UPPERCASE name and a value. The mandatory space # after the name rejects function-like macros (`#define NAME(x)` has no space # before the paren); the mandatory value rejects bare include guards. _DEFINE_RE = re.compile(r"^\s*#\s*define\s+([A-Z][A-Z0-9_]*)\s+\S") def _extract_directives(text: str) -> tuple[str, list[str]]: """Drop every preprocessor directive line (conservative #if/#ifdef handling: the guarded content is KEPT, only the directive lines go), collecting object-like uppercase #define names on the way. Backslash continuations are swallowed with their directive.""" kept: list[str] = [] defines: list[str] = [] lines = text.split("\n") i = 0 while i < len(lines): line = lines[i] if line.lstrip().startswith("#"): m = _DEFINE_RE.match(line) if m: defines.append(m.group(1)) kept.append("") while line.rstrip().endswith("\\") and i + 1 < len(lines): i += 1 line = lines[i] kept.append("") i += 1 continue kept.append(line) i += 1 return "\n".join(kept), defines # Statement ends with a class/struct definition head: optional single ALL-CAPS # 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*)?(?::([^{;]*))?$" ) # 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]+)?$" ) _ACCESS_RE = re.compile(r"\b(public|private|protected)\s*:(?!:)") _TRAILER_RE = re.compile( r"^(?:(?:const|override|final|noexcept(?:\([^)]*\))?|=\s*0|=\s*default)\s*)*(?::.*)?$" ) _HEAD_QUALIFIERS = ("virtual", "static", "inline", "explicit", "constexpr") _RET_OK_RE = re.compile(r"^[\w:<>,\s*&\[\]]+$") _NAME_TAIL_RE = re.compile(r"([A-Za-z_]\w*)\s*$") _KEYWORDS = frozenset({"if", "for", "while", "switch", "return", "sizeof", "new", "delete", "throw"}) def _split_args(args: str) -> list[str]: """Split an argument list on top-level commas (nested <>, () and [] stay intact). '(void)' and '()' both yield no arguments.""" args = args.strip() if not args or args == "void": return [] parts: list[str] = [] cur: list[str] = [] depth = 0 for ch in args: if ch in "<([": depth += 1 elif ch in ">)]": depth -= 1 if ch == "," and depth == 0: parts.append("".join(cur).strip()) cur = [] else: cur.append(ch) tail = "".join(cur).strip() if tail: parts.append(tail) return [p for p in parts if p] def _param_hint(arg: str) -> str: """Snippet placeholder text for one argument: the declared parameter name when it can be found, else the whole trimmed argument. Snippet-breaking characters are removed.""" decl = arg.split("=", 1)[0].rstrip() m = _NAME_TAIL_RE.search(re.sub(r"\[\s*\]\s*$", "", decl)) hint = m.group(1) if m else arg return re.sub(r"[{}$]", "", hint).strip() or "arg" def _build_call(name: str, parts: list[str]) -> tuple[str, str]: """(signature-args, insertText) for a callable with the given split args.""" required = [p for p in parts if "=" not in p] if not required: insert = f"{name}()" else: placeholders = ", ".join( f"${{{k}:{_param_hint(p)}}}" for k, p in enumerate(required, 1) ) insert = f"{name}({placeholders})$0" 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 method symbol, or fills the owning class symbol's insertText/params from its first public constructor. Anything unconfident is skipped.""" 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 depth = 0 q = -1 for k in range(p, len(candidate)): if candidate[k] == "(": depth += 1 elif candidate[k] == ")": depth -= 1 if depth == 0: q = k break if q < 0: return head = candidate[:p].strip() args_raw = candidate[p + 1:q].strip() trailer = candidate[q + 1:].strip() if "delete" in trailer or not _TRAILER_RE.match(trailer): return if head.startswith("friend"): return nm = _NAME_TAIL_RE.search(head) if not nm: return name = nm.group(1) if name in _KEYWORDS: return ret = head[: nm.start()].strip() for qual in _HEAD_QUALIFIERS: ret = re.sub(rf"\b{qual}\b", " ", ret) ret = re.sub(r"\s+", " ", ret).strip() parts = _split_args(args_raw) if not ret: # No return type: a constructor when the name matches the class; # anything else (macro invocation, unparsable) is skipped. if name == scope["name"]: sym = scope.get("sym") if sym is not None and "insertText" not in sym: sig_args, insert = _build_call(name, parts) sym["signature"] = f"{name}({sig_args})" sym["insertText"] = insert if parts: sym["params"] = parts return if not _RET_OK_RE.match(ret): return sig_args, insert = _build_call(name, parts) method = { "name": name, "kind": "method", "owner": scope["name"], "signature": f"{ret} {name}({sig_args})", "detail": detail, "insertText": insert, } if parts: method["params"] = parts add(method) def _capture_braces(text: str, i: int) -> tuple[str, int]: """Given `i` at a '{', return (body, index-just-past-matching-'}').""" depth = 0 for k in range(i, len(text)): if text[k] == "{": depth += 1 elif text[k] == "}": depth -= 1 if depth == 0: return text[i + 1:k], k + 1 return text[i + 1:], len(text) def _emit_enum(scoped: bool, ename: Optional[str], body: str, add: Callable[[dict], Optional[dict]], detail: str) -> None: for piece in _split_args(body): m = re.match(r"^([A-Za-z_]\w*)", piece.strip()) if not m: continue sym = {"name": m.group(1), "kind": "constant", "detail": detail} if scoped and ename: sym["owner"] = ename add(sym) def _scan_text(text: str, add: Callable[[dict], Optional[dict]], detail: str) -> None: """Statement-oriented walk of comment/directive-free source: track brace depth and a scope stack; classify each statement when it terminates at '{', '}' or ';'. Multi-line declarations fall out naturally.""" buf: list[str] = [] stack: list[dict] = [] i, n = 0, len(text) 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 stmt scope = stack[-1] candidate = stmt for m in _ACCESS_RE.finditer(stmt): scope["access"] = m.group(1) candidate = stmt[m.end():] return candidate def handle_statement(stmt: str) -> None: candidate = apply_access(stmt) if not stack or stack[-1]["kind"] != "classlike": return 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): body, j = _capture_braces(text, i) _emit_enum(em.group(1) is not None, em.group(2), body, add, detail) i = j continue cm = _CLASS_RE.search(stmt) 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", "name": cname, "access": "public" if cm.group(1) == "struct" else "private", "sym": stored, }) i += 1 continue # Inline method definition (`ret name(args) {`), namespace, # function body, brace initializer, ... — try the method parse, # then descend as an opaque scope. handle_statement(stmt) stack.append({"kind": "other"}) i += 1 continue if c == "}": if stack: stack.pop() buf = [] i += 1 continue if c == ";": stmt = "".join(buf).strip() buf = [] if stmt: handle_statement(stmt) i += 1 continue buf.append(c) i += 1 def _read_props(lib_dir: Path) -> dict[str, str]: props: dict[str, str] = {} try: text = (lib_dir / "library.properties").read_text(encoding="utf-8", errors="ignore") except OSError: return props for line in text.splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, _, v = line.partition("=") props[k.strip().lower()] = v.strip() return props def _iter_headers(lib_dir: Path): """Yield every *.h/*.hpp under the library tree, pruning examples/test/ docs style directories, in a deterministic order.""" for dirpath, dirnames, filenames in os.walk(lib_dir): dirnames[:] = sorted( d for d in dirnames if d.lower() not in _SKIP_DIRS and not d.startswith(".") ) for fn in sorted(filenames): if Path(fn).suffix.lower() in _HEADER_SUFFIXES: yield Path(dirpath) / fn def scan_library(lib_dir: Path) -> dict: """Scan one library tree into an intellisense payload: {"id": , "triggers": [
], "symbols": [...]} Symbol dicts use the ApiSymbol field names exactly: name, kind, signature, detail, doc, insertText, owner, params — optional fields are simply omitted when unknown. `detail` is always the library display name.""" props = _read_props(lib_dir) display = props.get("name") or lib_dir.name triggers: list[str] = [] for root in (lib_dir, lib_dir / "src"): if not root.is_dir(): continue try: children = sorted(root.iterdir()) except OSError: continue for child in children: if child.is_file() and child.suffix.lower() in _HEADER_SUFFIXES: if child.name not in triggers: triggers.append(child.name) symbols: list[dict] = [] seen: set[tuple[str, str, str]] = set() def add(sym: dict) -> Optional[dict]: if len(symbols) >= MAX_SYMBOLS: return None key = (sym["name"], sym["kind"], sym.get("owner", "")) if key in seen: return None seen.add(key) symbols.append(sym) return sym for hdr in _iter_headers(lib_dir): if len(symbols) >= MAX_SYMBOLS: break try: raw = hdr.read_text(encoding="utf-8", errors="ignore") except OSError: continue try: text = _strip_comments(raw) text, defines = _extract_directives(text) for dname in defines: add({"name": dname, "kind": "constant", "detail": display}) _scan_text(text, add, display) except Exception: # A single hostile/bizarre header must never take the payload down. logger.warning("[symbol_extract] failed scanning %s", hdr, exc_info=True) continue return {"id": display, "triggers": triggers, "symbols": symbols}