feat(backend): intellisense symbol server for the library long tail

GET /api/intellisense/symbols/{Name@Version} scans the content-addressed
library cache entry's headers (dependency-free C++ scanner) into an editor
symbol table, disk-cached per entry and served with long cache headers.
Plain OSS hosts without a lib cache 404 and the editor degrades gracefully.
This commit is contained in:
David Montero Crespo 2026-08-15 06:46:55 +02:00
parent 7c3b1c6e24
commit 63a91a08ef
4 changed files with 775 additions and 1 deletions

View File

@ -0,0 +1,87 @@
"""Intellisense symbol server.
Serves editor symbol tables (classes, public methods, constants, enum values)
for Arduino libraries present in the server-side content-addressed library
cache (VELXIO_LIBCACHE_DIR, default /var/velxio/libcache — entries named
`<norm_name>@<norm_version>-<sha12>`). Extraction is done by the
dependency-free scanner in app.services.symbol_extract, which also mirrors
the pro overlay's cache naming rules; this module never imports app.pro.
Scans are cached on disk under `<cache_root>/.symbols/<entry>.json`: entry
names are content-addressed, so a cached scan can never go stale and the
response is safe to cache aggressively client-side too. A host with no cache
dir (plain OSS self-host) simply 404s — the frontend degrades gracefully.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from app.services.symbol_extract import find_cache_entry, parse_spec, scan_library
logger = logging.getLogger(__name__)
router = APIRouter()
# Entry dir names are content-addressed (sha12 suffix), so the payload for a
# given spec resolution can be cached hard by browsers/proxies.
_CACHE_HEADERS = {"Cache-Control": "public, max-age=86400"}
def _cache_root() -> Path:
"""Resolved per-request so tests (and container reconfigs) can point the
endpoint elsewhere via the environment."""
return Path(os.environ.get("VELXIO_LIBCACHE_DIR", "/var/velxio/libcache"))
def _write_symbol_cache(sym_file: Path, payload: dict) -> None:
"""Atomically persist a scan result (tmp file + rename). Failures are
logged and ignored — the response was already computed."""
try:
sym_file.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(sym_file.parent), prefix=".tmp-", suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.replace(tmp, sym_file)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError as e:
logger.warning("[intellisense] symbol cache write failed for %s: %s", sym_file, e)
@router.get("/symbols/{spec}")
def get_symbols(spec: str) -> JSONResponse:
"""Symbol table for one library. `spec` is `Name@Version` or a bare
`Name` (which resolves to the newest cached version). Names and versions
are normalized with the same rules the cache uses, so `Adafruit GFX
Library@1.12.6` and `adafruitgfxlibrary@1.12.6` hit the same entry."""
name, version = parse_spec(spec)
root = _cache_root()
if not root.is_dir():
raise HTTPException(status_code=404, detail=f"library cache not available at {root}")
entry = find_cache_entry(root, name, version)
if entry is None:
raise HTTPException(status_code=404, detail=f"library not found in cache: {spec}")
sym_file = root / ".symbols" / f"{entry.name}.json"
if sym_file.is_file():
try:
cached = json.loads(sym_file.read_text(encoding="utf-8"))
return JSONResponse(content=cached, headers=_CACHE_HEADERS)
except (OSError, ValueError):
pass # corrupt/unreadable cache file -> rescan below
payload = scan_library(entry)
_write_symbol_cache(sym_file, payload)
return JSONResponse(content=payload, headers=_CACHE_HEADERS)

View File

@ -13,7 +13,7 @@ if sys.platform == 'win32':
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import compile, compile_chip, compile_rom, flash, libraries
from app.api.routes import compile, compile_chip, compile_rom, flash, intellisense, libraries
from app.core.config import settings
from app.core.hooks import run_lifespan_startup
@ -94,6 +94,7 @@ app.include_router(compile.router, prefix="/api/compile", tags=["compilation"])
app.include_router(compile_chip.router, prefix="/api/compile-chip", tags=["custom-chips"])
app.include_router(compile_rom.router, prefix="/api/compile-rom", tags=["custom-chips"])
app.include_router(libraries.router, prefix="/api/libraries", tags=["libraries"])
app.include_router(intellisense.router, prefix="/api/intellisense", tags=["intellisense"])
# Hardware flash: subprocesses arduino-cli upload to write a compiled
# sketch to a real USB-attached board. Desktop-only in practice (the
# web build has no access to local serial ports without WebSerial),

View File

@ -0,0 +1,514 @@
"""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 `<norm_name>@<norm_version>-<sha12>`, 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 '<name>@<version>-<sha12>' 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 `<norm>@<norm_version>-*` 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*)?(?::[^{;]*)?$"
)
_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 _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
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 handle_statement(stmt: str) -> None:
if not stack or stack[-1]["kind"] != "classlike":
return
scope = stack[-1]
candidate = stmt
for m in _ACCESS_RE.finditer(stmt):
scope["access"] = m.group(1)
candidate = stmt[m.end():]
if scope["access"] != "public":
return
_try_method(candidate.strip(), scope, add, detail)
while i < n:
c = text[i]
if c == "{":
stmt = "".join(buf).strip()
buf = []
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}
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": <display name>, "triggers": [<header basenames>], "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}

View File

@ -0,0 +1,172 @@
"""Unit tests for the intellisense symbol scanner and its library-cache
resolution helpers. Pure-Python — no FastAPI app, no HTTP server, no
toolchain — so they run anywhere pytest does.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.services.symbol_extract import (
find_cache_entry,
norm_name,
norm_version,
parse_spec,
scan_library,
)
# ── fixture library ───────────────────────────────────────────────────────
_HEADER = """\
#pragma once
#include <stdint.h>
#define FAKE_MAX_PIXELS 64
#define FAKE_NAME "fake" // string-valued define still counts
#define FAKE_FUNC(x) ((x) * 2)
#define lowercase_define 1
#define FAKE_GUARD_H
enum FakeMode { FAKE_MODE_A, FAKE_MODE_B = 2 };
enum class FakeColor : uint8_t { Red, Green };
/* block comment with a fake decl: void notReal(); */
class FakeLib {
public:
FakeLib(uint8_t pin);
~FakeLib();
bool begin(uint8_t pin, int count = 8);
void show();
private:
void internalUpdate();
uint8_t pin_;
};
"""
@pytest.fixture
def fake_lib(tmp_path: Path) -> Path:
lib = tmp_path / "fakelib@1.2.3-0123456789ab"
src = lib / "src"
src.mkdir(parents=True)
(lib / "library.properties").write_text(
"name=Fake Lib\nversion=1.2.3\n", encoding="utf-8"
)
(src / "FakeLib.h").write_text(_HEADER, encoding="utf-8")
examples = lib / "examples"
examples.mkdir()
(examples / "Noise.h").write_text(
"class NotMe { public: void nope(); };\n", encoding="utf-8"
)
return lib
def _by_kind(result: dict, kind: str) -> dict[str, dict]:
return {s["name"]: s for s in result["symbols"] if s["kind"] == kind}
# ── scan_library ──────────────────────────────────────────────────────────
def test_scan_library_id_and_triggers(fake_lib: Path) -> None:
result = scan_library(fake_lib)
assert result["id"] == "Fake Lib"
assert "FakeLib.h" in result["triggers"]
# examples/ headers are neither triggers nor scanned for symbols
assert "Noise.h" not in result["triggers"]
assert all(s["name"] != "NotMe" for s in result["symbols"])
def test_scan_library_class_and_public_methods(fake_lib: Path) -> None:
result = scan_library(fake_lib)
classes = _by_kind(result, "class")
methods = _by_kind(result, "method")
assert "FakeLib" in classes
# constructor is folded into the class symbol, not a separate method
assert classes["FakeLib"]["insertText"] == "FakeLib(${1:pin})$0"
assert classes["FakeLib"]["params"] == ["uint8_t pin"]
assert set(methods) == {"begin", "show"}
for m in methods.values():
assert m["owner"] == "FakeLib"
assert m["detail"] == "Fake Lib"
assert methods["begin"]["signature"] == "bool begin(uint8_t pin, int count = 8)"
# only the required (non-defaulted) arg becomes a snippet placeholder
assert methods["begin"]["insertText"] == "begin(${1:pin})$0"
assert methods["begin"]["params"] == ["uint8_t pin", "int count = 8"]
assert methods["show"]["insertText"] == "show()"
# private method and destructor never surface
assert "internalUpdate" not in methods
assert all("~" not in s["name"] for s in result["symbols"])
def test_scan_library_constants_and_enums(fake_lib: Path) -> None:
result = scan_library(fake_lib)
constants = _by_kind(result, "constant")
assert "FAKE_MAX_PIXELS" in constants
assert "FAKE_NAME" in constants
# function-like macro, lowercase define, and value-less include guard: out
assert "FAKE_FUNC" not in constants
assert "lowercase_define" not in constants
assert "FAKE_GUARD_H" not in constants
# plain enum values: no owner; enum class values: owner set
assert "FAKE_MODE_A" in constants and "owner" not in constants["FAKE_MODE_A"]
assert "FAKE_MODE_B" in constants
assert constants["Red"]["owner"] == "FakeColor"
assert constants["Green"]["owner"] == "FakeColor"
# ── spec parsing / normalization (used by the route) ──────────────────────
def test_parse_spec() -> None:
assert parse_spec("Adafruit GFX Library@1.12.6") == ("Adafruit GFX Library", "1.12.6")
assert parse_spec("FastLED") == ("FastLED", None)
assert parse_spec("") == ("", None)
def test_norm_rules_mirror_library_cache() -> None:
assert norm_name("Adafruit GFX Library") == "adafruitgfxlibrary"
assert norm_name("DHT sensor library") == "dhtsensorlibrary"
assert norm_name("") == ""
assert norm_version("1.12.6") == "1.12.6"
assert norm_version(" 1.2.3 ") == "1.2.3"
# unsafe chars become '-', leading dots are stripped (no '.'/'..' tokens)
assert norm_version("../evil/1") == "-evil-1"
assert norm_version("") == "0"
def test_find_cache_entry(tmp_path: Path) -> None:
for entry in (
"fastled@3.6.0-aaaaaaaaaaaa",
"fastled@3.10.1-bbbbbbbbbbbb",
"other@1.0.0-cccccccccccc",
".symbols",
):
(tmp_path / entry).mkdir()
exact = find_cache_entry(tmp_path, "FastLED", "3.6.0")
assert exact is not None and exact.name == "fastled@3.6.0-aaaaaaaaaaaa"
# bare name -> NEWEST version, compared numerically (3.10.1 > 3.6.0 even
# though it sorts first lexicographically) — mirrors lookup_by_name
bare = find_cache_entry(tmp_path, "FastLED")
assert bare is not None and bare.name == "fastled@3.10.1-bbbbbbbbbbbb"
assert find_cache_entry(tmp_path, "FastLED", "9.9.9") is None
assert find_cache_entry(tmp_path, "Nope") is None
assert find_cache_entry(tmp_path / "missing", "FastLED") is None
assert find_cache_entry(tmp_path, "!!!") is None # normalizes to empty
# ── route wiring (skipped where fastapi is not installed) ─────────────────
def test_route_registers_symbols_path() -> None:
pytest.importorskip("fastapi")
from app.api.routes import intellisense
assert any(r.path == "/symbols/{spec}" for r in intellisense.router.routes)