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:
David Montero 2026-08-25 00:06:05 +02:00
parent 4583d9013c
commit 0b3e165784
2 changed files with 419 additions and 53 deletions

View File

@ -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}")

View File

@ -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