feat(intellisense): answer from the local sketchbook, not only the cache
The symbol server assumed one library layout: the content-addressed cache, where an entry dir name IS its content hash. A host without that cache -- Velxio Desktop's sidecar, and any self-host -- has its libraries where arduino-cli put them, under plain folder names with the version inside library.properties, and got a 404 for every request. The editor there falls back to the engine's embedded catalog, so `client.` on a library the user installed himself resolves to nothing. Both layouts are now sources, tried best-first. The only real difference is mutability: `lib upgrade` rewrites a sketchbook folder in place and keeps its name, so a scan cannot be keyed by that name. Sketchbook entries are keyed by a cheap stat-only stamp (file count, total size, newest mtime) in the same `<norm_name>@<norm_version>-<12 hex>` shape, which makes the disk cache exact rather than merely long-lived. Cache entries keep their existing keys, so no deployed scan is invalidated. Where the sketchbook is: an explicit override, then the sketchbook env vars, then arduino-cli itself (`config get directories.user`, probed once and remembered) -- guessing ~/Arduino is wrong on the platforms whose default is ~/Documents/Arduino. A sketchbook that resolves to the cache is dropped: the pro overlay points arduino-cli at a symlink to the cache root, and scanning it twice under two shapes buys nothing. Scans for a sketchbook are written to `<sketchbook>/.velxio-symbols/`, beside `libraries/` and never inside it, so nothing Velxio writes shows up in `arduino-cli lib list`. The type index now keys on a fingerprint of the source's entry keys instead of the entry count, which is exact for both layouts (an in-place upgrade changes no count). Deployed indexes rebuild once, ~1s from the cached scans.
This commit is contained in:
parent
4583d9013c
commit
0b3e165784
|
|
@ -1,34 +1,47 @@
|
|||
"""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
|
||||
for the Arduino libraries a host actually has. 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.
|
||||
Two library layouts answer this endpoint, in that order:
|
||||
|
||||
1. The content-addressed library cache (VELXIO_LIBCACHE_DIR, default
|
||||
/var/velxio/libcache — entry dirs named `<norm_name>@<norm_version>-
|
||||
<sha12>`). Immutable by construction, so a scan cached under an entry
|
||||
name can never go stale and the response is safe to cache hard.
|
||||
2. A plain arduino-cli sketchbook `libraries/` dir — what Velxio Desktop's
|
||||
sidecar and a self-host have. Entry dirs are named after the library and
|
||||
the version lives in library.properties, and they are MUTABLE (`lib
|
||||
upgrade` rewrites one in place), so scans are keyed by a cheap content
|
||||
stamp instead of by the dir name.
|
||||
|
||||
A host with neither simply 404s and the editor degrades to the engine's
|
||||
embedded catalog.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services.symbol_extract import (
|
||||
_entry_version_key,
|
||||
_read_props,
|
||||
find_cache_entry,
|
||||
norm_name,
|
||||
norm_version,
|
||||
parse_spec,
|
||||
scan_library,
|
||||
)
|
||||
|
|
@ -42,12 +55,170 @@ router = APIRouter()
|
|||
_CACHE_HEADERS = {"Cache-Control": "public, max-age=86400"}
|
||||
|
||||
|
||||
class _Source(NamedTuple):
|
||||
"""One library layout this endpoint can answer from. `content_addressed`
|
||||
is the whole difference: it decides how an entry is found, how its scan is
|
||||
keyed, and where the scan cache is written."""
|
||||
|
||||
root: Path
|
||||
content_addressed: bool
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
# arduino-cli's own sketchbook, asked once and remembered: on the desktop
|
||||
# sidecar nobody sets an env var for it, and guessing ~/Arduino is wrong on
|
||||
# the platforms where the default is ~/Documents/Arduino. Whatever arduino-cli
|
||||
# answers is by definition the dir the compile resolves libraries from.
|
||||
_CLI_PROBE_TTL_S = 600
|
||||
_cli_probe: tuple[float, Optional[Path]] = (0.0, None)
|
||||
|
||||
|
||||
def _cli_sketchbook() -> Optional[Path]:
|
||||
global _cli_probe
|
||||
ts, cached = _cli_probe
|
||||
now = time.time()
|
||||
if ts and now - ts < _CLI_PROBE_TTL_S:
|
||||
return cached
|
||||
found: Optional[Path] = None
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["arduino-cli", "config", "get", "directories.user"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if out.returncode == 0:
|
||||
p = Path(out.stdout.strip().strip('"'))
|
||||
if p.is_dir():
|
||||
found = p
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass # no arduino-cli on PATH (web image is fine — it has the cache)
|
||||
_cli_probe = (now, found)
|
||||
return found
|
||||
|
||||
|
||||
def _sketchbook_libraries() -> Optional[Path]:
|
||||
"""The `libraries/` dir of the local sketchbook, or None.
|
||||
|
||||
VELXIO_SYMBOL_LIBRARIES_DIR names the libraries dir itself (the explicit
|
||||
override, for a host that keeps its libraries somewhere unusual); the
|
||||
sketchbook env vars name its parent; arduino-cli is asked last but knows
|
||||
best. The home-dir guesses are only reached when arduino-cli cannot be
|
||||
run at all.
|
||||
"""
|
||||
direct = os.environ.get("VELXIO_SYMBOL_LIBRARIES_DIR")
|
||||
if direct and Path(direct).is_dir():
|
||||
return Path(direct)
|
||||
for env in ("ARDUINO_DIRECTORIES_USER", "VELXIO_FALLBACK_SKETCHBOOK"):
|
||||
val = os.environ.get(env)
|
||||
if val and (Path(val) / "libraries").is_dir():
|
||||
return Path(val) / "libraries"
|
||||
probed = _cli_sketchbook()
|
||||
if probed is not None and (probed / "libraries").is_dir():
|
||||
return probed / "libraries"
|
||||
home = Path.home()
|
||||
for rel in ("Arduino/libraries", "Documents/Arduino/libraries"):
|
||||
if (home / rel).is_dir():
|
||||
return home / rel
|
||||
return None
|
||||
|
||||
|
||||
def _sources() -> list[_Source]:
|
||||
"""The layouts to try, best first. The sketchbook is skipped when it
|
||||
resolves to the cache itself: the pro overlay points arduino-cli's
|
||||
sketchbook at `<cache>/../cache-sketchbook/libraries`, a symlink to the
|
||||
cache root, and scanning it twice under two shapes buys nothing."""
|
||||
out: list[_Source] = []
|
||||
cache = _cache_root()
|
||||
if cache.is_dir():
|
||||
out.append(_Source(cache, True))
|
||||
libs = _sketchbook_libraries()
|
||||
if libs is not None:
|
||||
try:
|
||||
same = any(s.root.resolve() == libs.resolve() for s in out)
|
||||
except OSError:
|
||||
same = False
|
||||
if not same:
|
||||
out.append(_Source(libs, False))
|
||||
return out
|
||||
|
||||
|
||||
def _stamp(entry: Path) -> str:
|
||||
"""Content fingerprint of a MUTABLE library dir: file count, total size and
|
||||
newest mtime over the tree, stat only — no reads. Changes whenever
|
||||
arduino-cli installs or upgrades the library, which is what lets a scan be
|
||||
cached on disk without ever going stale."""
|
||||
newest = 0.0
|
||||
total = 0
|
||||
count = 0
|
||||
for parent, dirs, files in os.walk(entry):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for f in files:
|
||||
try:
|
||||
st = os.stat(os.path.join(parent, f))
|
||||
except OSError:
|
||||
continue
|
||||
count += 1
|
||||
total += st.st_size
|
||||
newest = max(newest, st.st_mtime)
|
||||
raw = f"{count}:{total}:{newest:.6f}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _entry_key(src: _Source, entry: Path) -> str:
|
||||
"""Cache key for one entry's scan. A content-addressed entry IS its key;
|
||||
a sketchbook folder gets the same `<norm_name>@<norm_version>-<12 hex>`
|
||||
shape built from its properties plus the content stamp."""
|
||||
if src.content_addressed:
|
||||
return entry.name
|
||||
props = _read_props(entry)
|
||||
name = norm_name(props.get("name", "")) or norm_name(entry.name) or "lib"
|
||||
return f"{name}@{norm_version(props.get('version', ''))}-{_stamp(entry)}"
|
||||
|
||||
|
||||
def _symbols_dir(src: _Source) -> Path:
|
||||
"""Where scans are persisted. Inside the cache root for the cache; beside
|
||||
the sketchbook's `libraries/` for a sketchbook, so nothing Velxio writes
|
||||
ever lands where arduino-cli looks for libraries."""
|
||||
if src.content_addressed:
|
||||
return src.root / ".symbols"
|
||||
return src.root.parent / ".velxio-symbols"
|
||||
|
||||
|
||||
def _entries(src: _Source) -> list[Path]:
|
||||
try:
|
||||
return sorted(
|
||||
d for d in src.root.iterdir() if d.is_dir() and not d.name.startswith(".")
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _find_entry(src: _Source, name: str, version: Optional[str]) -> Optional[Path]:
|
||||
if src.content_addressed:
|
||||
return find_cache_entry(src.root, name, version)
|
||||
# A sketchbook folder is matched on library.properties `name=` (or the
|
||||
# folder name), and the version is a PREFERENCE, not a filter: the local
|
||||
# build compiles against whatever is installed, so answering with the
|
||||
# installed version beats 404ing because the project pinned another one.
|
||||
want = norm_name(name)
|
||||
if not want:
|
||||
return None
|
||||
fallback: Optional[Path] = None
|
||||
for d in _entries(src):
|
||||
props = _read_props(d)
|
||||
if norm_name(props.get("name", "")) != want and norm_name(d.name) != want:
|
||||
continue
|
||||
if version is None or norm_version(props.get("version", "")) == norm_version(version):
|
||||
return d
|
||||
if fallback is None:
|
||||
fallback = d
|
||||
return fallback
|
||||
|
||||
|
||||
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."""
|
||||
|
|
@ -68,9 +239,9 @@ def _write_symbol_cache(sym_file: Path, payload: dict) -> None:
|
|||
logger.warning("[intellisense] symbol cache write failed for %s: %s", sym_file, e)
|
||||
|
||||
|
||||
def _payload_for(root: Path, entry: Path) -> dict:
|
||||
"""Cached scan of one entry (same disk cache get_symbols uses)."""
|
||||
sym_file = root / ".symbols" / f"{entry.name}.json"
|
||||
def _payload_for(src: _Source, entry: Path) -> dict:
|
||||
"""Cached scan of one entry (the same disk cache get_symbols uses)."""
|
||||
sym_file = _symbols_dir(src) / f"{_entry_key(src, entry)}.json"
|
||||
if sym_file.is_file():
|
||||
try:
|
||||
return json.loads(sym_file.read_text(encoding="utf-8"))
|
||||
|
|
@ -86,23 +257,30 @@ _TYPE_INDEX_MAX_AGE_S = 6 * 3600
|
|||
_MAX_BASES = 8
|
||||
|
||||
|
||||
def _type_index(root: Path) -> dict[str, str]:
|
||||
"""owner-class name -> cache entry dir name, over the whole library
|
||||
cache. Built by scanning every entry once (each scan is itself disk-
|
||||
cached), persisted under .symbols/_types.json and rebuilt when older than
|
||||
a few hours or when the cache has grown. When two entries declare the
|
||||
same class (a library and its fork, or two versions), the newest entry
|
||||
for the alphabetically-first library wins -- deterministic and, for the
|
||||
common case of one library per class, irrelevant."""
|
||||
idx_file = root / ".symbols" / _TYPE_INDEX_NAME
|
||||
entries = sorted(
|
||||
d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")
|
||||
)
|
||||
def _index_key(src: _Source, entries: list[Path]) -> str:
|
||||
"""Fingerprint of the whole source. Entry keys already move whenever any
|
||||
library's bytes change, so an index built under this key describes exactly
|
||||
the libraries present."""
|
||||
joined = ",".join(_entry_key(src, e) for e in entries)
|
||||
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _type_index(src: _Source) -> dict[str, str]:
|
||||
"""owner-class name -> entry dir name, over the whole source. Built by
|
||||
scanning every entry once (each scan is itself disk-cached), persisted
|
||||
next to the scans and rebuilt when the source's fingerprint moves or the
|
||||
index ages out. When two entries declare the same class (a library and
|
||||
its fork, or two versions), the newest entry for the alphabetically-first
|
||||
library wins -- deterministic and, for the common case of one library per
|
||||
class, irrelevant."""
|
||||
idx_file = _symbols_dir(src) / _TYPE_INDEX_NAME
|
||||
entries = _entries(src)
|
||||
key = _index_key(src, entries)
|
||||
if idx_file.is_file():
|
||||
try:
|
||||
cached = json.loads(idx_file.read_text(encoding="utf-8"))
|
||||
fresh = (time.time() - idx_file.stat().st_mtime) < _TYPE_INDEX_MAX_AGE_S
|
||||
if fresh and cached.get("entry_count") == len(entries):
|
||||
if fresh and cached.get("index_key") == key:
|
||||
return cached["types"]
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
|
|
@ -117,12 +295,14 @@ def _type_index(root: Path) -> dict[str, str]:
|
|||
# 3. ties: alphabetical, deterministic.
|
||||
best: dict[str, tuple[int, int, str]] = {} # owner -> (name_match, member_count, entry) with a sortable key
|
||||
by_name: dict[str, list[Path]] = {}
|
||||
for name in entries:
|
||||
by_name.setdefault(name.partition("@")[0], []).append(root / name)
|
||||
for entry in entries:
|
||||
# Cache entries carry the version in the dir name, so several map to
|
||||
# one library; a sketchbook holds one dir per library already.
|
||||
by_name.setdefault(entry.name.partition("@")[0], []).append(entry)
|
||||
for lib_name in sorted(by_name):
|
||||
newest = sorted(by_name[lib_name], key=_entry_version_key, reverse=True)[0]
|
||||
try:
|
||||
payload = _payload_for(root, newest)
|
||||
payload = _payload_for(src, newest)
|
||||
except Exception as e: # noqa: BLE001 - one bad library must not kill the index
|
||||
logger.warning("[intellisense] type index: skipping %s: %s", newest.name, e)
|
||||
continue
|
||||
|
|
@ -132,7 +312,7 @@ def _type_index(root: Path) -> dict[str, str]:
|
|||
if owner:
|
||||
counts[owner] = counts.get(owner, 0) + 1
|
||||
for owner, n in counts.items():
|
||||
name_match = 1 if norm_name(owner) == lib_name else 0
|
||||
name_match = 1 if norm_name(owner) == norm_name(lib_name) else 0
|
||||
candidate = (name_match, n, newest.name)
|
||||
cur = best.get(owner)
|
||||
# higher name_match, then higher count, then (already alphabetical
|
||||
|
|
@ -140,13 +320,13 @@ def _type_index(root: Path) -> dict[str, str]:
|
|||
if cur is None or (candidate[0], candidate[1]) > (cur[0], cur[1]):
|
||||
best[owner] = candidate
|
||||
types = {owner: entry for owner, (_, _, entry) in best.items()}
|
||||
_write_symbol_cache(idx_file, {"entry_count": len(entries), "types": types})
|
||||
_write_symbol_cache(idx_file, {"index_key": key, "types": types})
|
||||
return types
|
||||
|
||||
|
||||
@router.get("/type/{name}")
|
||||
def get_type_members(name: str) -> JSONResponse:
|
||||
"""Members of one class/struct, whichever cached library declares it.
|
||||
"""Members of one class/struct, whichever library declares it.
|
||||
|
||||
This is the long-tail path that is safe for completion quality: the
|
||||
engine calls it only after tree-sitter has resolved a receiver to a
|
||||
|
|
@ -156,18 +336,24 @@ def get_type_members(name: str) -> JSONResponse:
|
|||
which are what made feeding whole library sets into the candidate pool
|
||||
measurably worse. Payload shape matches /symbols/ so the engine's
|
||||
RemoteSymbolCache can store it unchanged."""
|
||||
root = _cache_root()
|
||||
if not root.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"library cache not available at {root}")
|
||||
sources = _sources()
|
||||
if not sources:
|
||||
raise HTTPException(status_code=404, detail="no library source available")
|
||||
if not re.fullmatch(r"[A-Za-z_]\w{0,80}", name):
|
||||
raise HTTPException(status_code=400, detail="not a type name")
|
||||
entry_name = _type_index(root).get(name)
|
||||
if entry_name is None:
|
||||
raise HTTPException(status_code=404, detail=f"no cached library declares {name}")
|
||||
entry = root / entry_name
|
||||
if not entry.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"stale type index for {name}")
|
||||
payload = _payload_for(root, entry)
|
||||
|
||||
for src in sources:
|
||||
types = _type_index(src)
|
||||
entry_name = types.get(name)
|
||||
if entry_name is None:
|
||||
continue
|
||||
entry = src.root / entry_name
|
||||
if not entry.is_dir():
|
||||
continue # stale index for this source; try the next
|
||||
payload = _payload_for(src, entry)
|
||||
break
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"no installed library declares {name}")
|
||||
|
||||
# Own members first, then the inheritance chain, breadth-first, each base
|
||||
# resolved through the same index (it may live in another library:
|
||||
|
|
@ -180,7 +366,6 @@ def get_type_members(name: str) -> JSONResponse:
|
|||
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:
|
||||
|
|
@ -203,17 +388,17 @@ def get_type_members(name: str) -> JSONResponse:
|
|||
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)))
|
||||
if base_entry and (src.root / base_entry).is_dir():
|
||||
queue.append((base, _payload_for(src, src.root / base_entry)))
|
||||
else:
|
||||
# Not in the cache (a core class like Print/Stream, or an
|
||||
# Not in this source (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,
|
||||
"id": payload.get("id") or entry.name,
|
||||
"triggers": payload.get("triggers", []),
|
||||
"type": name,
|
||||
"bases": chain,
|
||||
|
|
@ -226,15 +411,15 @@ def get_type_members(name: str) -> JSONResponse:
|
|||
@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
|
||||
`Name` (which resolves to the newest version present). 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}")
|
||||
|
||||
return JSONResponse(content=_payload_for(root, entry), headers=_CACHE_HEADERS)
|
||||
sources = _sources()
|
||||
if not sources:
|
||||
raise HTTPException(status_code=404, detail="no library source available")
|
||||
for src in sources:
|
||||
entry = _find_entry(src, name, version)
|
||||
if entry is not None:
|
||||
return JSONResponse(content=_payload_for(src, entry), headers=_CACHE_HEADERS)
|
||||
raise HTTPException(status_code=404, detail=f"library not installed: {spec}")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
"""The symbol server answers from a plain arduino-cli sketchbook too.
|
||||
|
||||
velxio.dev keeps its libraries in a content-addressed cache, where an entry
|
||||
dir name IS the content hash. Velxio Desktop's sidecar has no such cache: its
|
||||
libraries are whatever `arduino-cli lib install` put in the sketchbook, under
|
||||
plain folder names, and they get rewritten in place on upgrade. Without this
|
||||
path the desktop editor gets the engine's embedded catalog and nothing else —
|
||||
`client.` on a library the user installed himself resolves to nothing.
|
||||
|
||||
The routes are called directly (no TestClient) so the suite keeps needing
|
||||
nothing but pytest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(_REPO / "backend"))
|
||||
|
||||
from app.api.routes import intellisense # noqa: E402
|
||||
|
||||
HEADER = """
|
||||
class Widget : public Print {
|
||||
public:
|
||||
Widget(int pin);
|
||||
void begin(void);
|
||||
bool ready();
|
||||
private:
|
||||
int _pin;
|
||||
};
|
||||
"""
|
||||
|
||||
|
||||
def _lib(libraries: Path, folder: str, name: str, version: str, header: str = HEADER) -> Path:
|
||||
d = libraries / folder
|
||||
(d / "src").mkdir(parents=True, exist_ok=True)
|
||||
(d / "library.properties").write_text(
|
||||
f"name={name}\nversion={version}\narchitectures=*\n", encoding="utf-8"
|
||||
)
|
||||
(d / "src" / f"{folder}.h").write_text(header, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sketchbook(tmp_path, monkeypatch):
|
||||
"""A sketchbook with no content-addressed cache in sight. Both env vars are
|
||||
set so the resolver never reaches arduino-cli or the home-dir guesses —
|
||||
a unit test must not depend on what is installed on the machine."""
|
||||
libraries = tmp_path / "sketchbook" / "libraries"
|
||||
libraries.mkdir(parents=True)
|
||||
monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(tmp_path / "no-cache-here"))
|
||||
monkeypatch.setenv("VELXIO_SYMBOL_LIBRARIES_DIR", str(libraries))
|
||||
return libraries
|
||||
|
||||
|
||||
def _body(response) -> dict:
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
def test_a_sketchbook_library_answers_by_display_name(sketchbook):
|
||||
_lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0")
|
||||
|
||||
payload = _body(intellisense.get_symbols("My Widget Library"))
|
||||
|
||||
assert payload["id"] == "My Widget Library"
|
||||
assert "MyWidget.h" in payload["triggers"]
|
||||
assert {"begin", "ready"} <= {s["name"] for s in payload["symbols"]}
|
||||
|
||||
|
||||
def test_the_installed_version_answers_a_pin_for_another_one(sketchbook):
|
||||
"""The project pins what velxio.dev cached; the desktop compiles against
|
||||
what is on disk. Answering with the installed version beats a 404."""
|
||||
_lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0")
|
||||
|
||||
exact = _body(intellisense.get_symbols("My Widget Library@1.4.0"))
|
||||
other = _body(intellisense.get_symbols("My Widget Library@9.9.9"))
|
||||
|
||||
assert exact["id"] == other["id"] == "My Widget Library"
|
||||
|
||||
|
||||
def test_an_uninstalled_library_is_404(sketchbook):
|
||||
_lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0")
|
||||
|
||||
with pytest.raises(HTTPException) as err:
|
||||
intellisense.get_symbols("Nothing Like This")
|
||||
assert err.value.status_code == 404
|
||||
|
||||
|
||||
def test_type_lookup_resolves_a_class_from_the_sketchbook(sketchbook):
|
||||
_lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0")
|
||||
|
||||
payload = _body(intellisense.get_type_members("Widget"))
|
||||
|
||||
assert payload["type"] == "Widget"
|
||||
assert {"begin", "ready"} <= {s["name"] for s in payload["symbols"]}
|
||||
# Print is not an installed library — reported as an unresolved base so the
|
||||
# editor can supply its own members for it.
|
||||
assert "Print" in payload["bases"]
|
||||
|
||||
|
||||
def test_scans_are_cached_beside_libraries_not_inside_them(sketchbook):
|
||||
"""arduino-cli scans `libraries/` for libraries. Nothing we write may land
|
||||
there, or a stray dir shows up in `lib list`."""
|
||||
_lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0")
|
||||
|
||||
intellisense.get_symbols("My Widget Library")
|
||||
|
||||
assert (sketchbook.parent / ".velxio-symbols").is_dir()
|
||||
assert [d.name for d in sketchbook.iterdir()] == ["MyWidget"]
|
||||
|
||||
|
||||
def test_upgrading_a_library_in_place_invalidates_its_cached_scan(sketchbook):
|
||||
"""A sketchbook folder keeps its name across `lib upgrade`, so the scan
|
||||
cannot be keyed by name — it is keyed by a content stamp."""
|
||||
lib = _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0")
|
||||
first = _body(intellisense.get_symbols("My Widget Library"))
|
||||
assert "shutdown" not in {s["name"] for s in first["symbols"]}
|
||||
|
||||
(lib / "src" / "MyWidget.h").write_text(
|
||||
HEADER.replace("bool ready();", "bool ready();\n void shutdown();"), encoding="utf-8"
|
||||
)
|
||||
(lib / "library.properties").write_text(
|
||||
"name=My Widget Library\nversion=1.5.0\narchitectures=*\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
second = _body(intellisense.get_symbols("My Widget Library"))
|
||||
assert "shutdown" in {s["name"] for s in second["symbols"]}
|
||||
|
||||
|
||||
def test_the_content_addressed_cache_wins_over_the_sketchbook(tmp_path, monkeypatch):
|
||||
"""On velxio.dev both exist (the pro overlay points arduino-cli's
|
||||
sketchbook at the cache). The immutable one answers."""
|
||||
cache = tmp_path / "libcache"
|
||||
cache.mkdir()
|
||||
_lib(cache, "mywidgetlibrary@2.0.0-abcdef123456", "My Widget Library", "2.0.0")
|
||||
libraries = tmp_path / "sketchbook" / "libraries"
|
||||
libraries.mkdir(parents=True)
|
||||
_lib(libraries, "MyWidget", "My Widget Library", "1.4.0")
|
||||
monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(cache))
|
||||
monkeypatch.setenv("VELXIO_SYMBOL_LIBRARIES_DIR", str(libraries))
|
||||
|
||||
payload = _body(intellisense.get_symbols("My Widget Library"))
|
||||
|
||||
# The cache entry's scan is keyed by its dir name; the sketchbook's is not.
|
||||
assert (cache / ".symbols" / "mywidgetlibrary@2.0.0-abcdef123456.json").is_file()
|
||||
assert not (libraries.parent / ".velxio-symbols").exists()
|
||||
assert payload["id"] == "My Widget Library"
|
||||
|
||||
|
||||
def test_a_sketchbook_that_resolves_to_the_cache_is_not_scanned_twice(tmp_path, monkeypatch):
|
||||
"""The pro overlay's fallback sketchbook is a symlink to the cache root."""
|
||||
cache = tmp_path / "libcache"
|
||||
cache.mkdir()
|
||||
_lib(cache, "mywidgetlibrary@2.0.0-abcdef123456", "My Widget Library", "2.0.0")
|
||||
sketchbook = tmp_path / "cache-sketchbook"
|
||||
sketchbook.mkdir()
|
||||
(sketchbook / "libraries").symlink_to(cache, target_is_directory=True)
|
||||
monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(cache))
|
||||
monkeypatch.delenv("VELXIO_SYMBOL_LIBRARIES_DIR", raising=False)
|
||||
monkeypatch.setenv("VELXIO_FALLBACK_SKETCHBOOK", str(sketchbook))
|
||||
|
||||
assert [s.content_addressed for s in intellisense._sources()] == [True]
|
||||
|
||||
|
||||
def test_no_library_source_at_all_is_404(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(tmp_path / "nope"))
|
||||
monkeypatch.setenv("VELXIO_SYMBOL_LIBRARIES_DIR", str(tmp_path / "also-nope"))
|
||||
monkeypatch.delenv("ARDUINO_DIRECTORIES_USER", raising=False)
|
||||
monkeypatch.delenv("VELXIO_FALLBACK_SKETCHBOOK", raising=False)
|
||||
# Never probe arduino-cli or the home dir from a unit test.
|
||||
monkeypatch.setattr(intellisense, "_cli_sketchbook", lambda: None)
|
||||
monkeypatch.setattr(intellisense.Path, "home", staticmethod(lambda: tmp_path / "empty-home"))
|
||||
|
||||
with pytest.raises(HTTPException) as err:
|
||||
intellisense.get_symbols("Anything")
|
||||
assert err.value.status_code == 404
|
||||
Loading…
Reference in New Issue