feat: YAML LSP (/yaml endpoint) + IAC source catalog enrichment
All checks were successful
Build and Deploy iLSP / test (push) Successful in 21s
Build and Deploy iLSP / build-and-deploy (push) Successful in 2m49s

- Add yaml-language-server (Node.js) to Dockerfile stage 3
- Add YAML_LSP_PORT=2090 env var (Dockerfile + ilsp.nomad)
- Start yaml-language-server in background thread (_serve_yaml_lsp)
- Expose /yaml WebSocket endpoint (same WS→TCP proxy as /python and /bicep)
- Load iac_source_catalog.json alongside bicep_modules_catalog.json
- Enrich param_completion_items() with descriptions + required flag from IAC source
  - Required params sorted first (sortText 0_lru_param_0_...) and marked with *
  - detail field shows * prefix for required params
- Update /health to report iac_source_modules + yaml_lsp fields
- Rewrite EDITOR_SETUP.md: WebSocket URLs, YAML schemas config for all editors
  (Helix, Neovim, PyCharm, VS Code) with azure-pipelines + gitea actions schemas
- All 35 tests pass
This commit is contained in:
Henrik Jess Nielsen
2026-05-10 15:40:13 +02:00
parent b93aa84737
commit 5501254b55
5 changed files with 161 additions and 36 deletions

View File

@@ -29,6 +29,31 @@ _CATALOG_PATHS = [
pathlib.Path(__file__).parent.parent.parent / "bicep_modules_catalog.json", # dev
]
# IAC source catalog — richer param descriptions from Bicep source code
_IAC_SOURCE_PATHS = [
pathlib.Path("/data/iac_source_catalog.json"), # volume-mount
pathlib.Path(__file__).parent.parent.parent / "iac_source_catalog.json", # dev
]
def _load_iac_source_catalog() -> dict[str, dict[str, Any]]:
"""Load IAC source catalog for enriched param descriptions.
Returns dict keyed by module name (e.g. 'roleassignments') → module info
with 'params' list containing name/description/required/type.
"""
for path in _IAC_SOURCE_PATHS:
if path.exists():
try:
data = json.loads(path.read_text())
modules = data.get("modules", {})
logger.info("IAC source catalog loaded from %s: %d modules", path, len(modules))
return modules
except Exception:
logger.exception("Failed to parse IAC source catalog at %s", path)
logger.info("No iac_source_catalog.json found — param descriptions unavailable")
return {}
def _load_catalog() -> list[dict[str, Any]]:
"""Load modules from the bundled catalog file, preserving per-version schema."""
@@ -62,11 +87,19 @@ class BicepModuleCatalog:
"""In-memory catalog of LRU Bicep modules, loaded once at startup."""
_modules: list[dict[str, Any]] = []
_iac: dict[str, dict[str, Any]] = {} # module name → IAC source info
@classmethod
def load(cls) -> None:
"""Load catalog from disk. Call once at startup."""
"""Load both catalogs from disk. Call once at startup."""
cls._modules = _load_catalog()
cls._iac = _load_iac_source_catalog()
@classmethod
def _iac_param_map(cls, module_name: str) -> dict[str, dict[str, Any]]:
"""Return {param_name: {description, required, type}} from IAC source catalog."""
iac_mod = cls._iac.get(module_name, {})
return {p["name"]: p for p in iac_mod.get("params", [])}
@classmethod
def get_modules(cls) -> list[dict[str, Any]]:
@@ -206,12 +239,23 @@ class BicepModuleCatalog:
logger.debug("Param fallback: %s %s%s", module_name, version, v)
break
# IAC source catalog: richer descriptions + required flag
iac_params = cls._iac_param_map(module_name)
items = []
for param_name, param_info in ver_params.items():
ptype = param_info.get("type", "any")
description = param_info.get("description", "").strip()
allowed = param_info.get("allowed", [])
# Prefer IAC source description (has human-readable text from Bicep source)
iac = iac_params.get(param_name, {})
description = iac.get("description", "") or param_info.get("description", "")
description = description.strip()
required = iac.get("required", False)
doc_lines = [f"**{param_name}** (`{ptype}`)"]
if required:
doc_lines[0] += " ⚠️ required"
if description:
doc_lines.append(f"\n{description}")
if allowed:
@@ -221,9 +265,9 @@ class BicepModuleCatalog:
items.append({
"label": param_name,
"kind": 5, # Field
"detail": ptype,
"detail": f"{'*' if required else ''}{ptype}",
"insertText": f"{param_name}: ",
"sortText": f"0_lru_param_{param_name}",
"sortText": f"0_lru_param_{'0' if required else '1'}_{param_name}",
"documentation": {
"kind": "markdown",
"value": "\n".join(doc_lines),

View File

@@ -29,6 +29,7 @@ logger = logging.getLogger(__name__)
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
YAML_LSP_PORT = int(os.getenv("YAML_LSP_PORT", "2090"))
HTTP_PORT = int(os.getenv("HTTP_PORT", "8000"))
_CHUNK = 65536
@@ -45,6 +46,20 @@ def _serve_python_lsp(port: int) -> None:
logger.warning("pylsp exited (code %s) — restarting", proc.returncode)
def _serve_yaml_lsp(port: int) -> None:
"""Start yaml-language-server in TCP socket mode; restart on unexpected exit."""
import shutil
yaml_ls = shutil.which("yaml-language-server")
if not yaml_ls:
logger.warning("yaml-language-server not found — /yaml completions disabled")
return
while True:
proc = subprocess.Popen([yaml_ls, f"--socket={port}"])
logger.info("yaml-language-server listening on localhost:%d PID=%d", port, proc.pid)
proc.wait()
logger.warning("yaml-language-server exited (code %s) — restarting", proc.returncode)
async def _pipe(reader: asyncio.StreamReader, writer) -> None:
"""Pipe bytes from reader to writer until EOF."""
try:
@@ -109,10 +124,13 @@ async def _build_app() -> web.Application:
app = web.Application()
async def health(_: web.Request) -> web.Response:
import shutil
return web.json_response({
"status": "ok",
"pypi_packages": len(PypiCatalog._packages),
"bicep_modules": len(BicepModuleCatalog._modules),
"iac_source_modules": len(BicepModuleCatalog._iac),
"yaml_lsp": bool(shutil.which("yaml-language-server")),
})
async def reload(_: web.Request) -> web.Response:
@@ -132,10 +150,14 @@ async def _build_app() -> web.Application:
async def bicep_ws(request: web.Request) -> web.WebSocketResponse:
return await _ws_proxy(request, "127.0.0.1", BICEP_LSP_PORT)
async def yaml_ws(request: web.Request) -> web.WebSocketResponse:
return await _ws_proxy(request, "127.0.0.1", YAML_LSP_PORT)
app.router.add_get("/health", health)
app.router.add_post("/reload", reload)
app.router.add_get("/python", python_ws)
app.router.add_get("/bicep", bicep_ws)
app.router.add_get("/yaml", yaml_ws)
return app
@@ -152,13 +174,17 @@ async def main_async() -> None:
# LSP servers run internally on localhost — not exposed outside the container
threading.Thread(target=_serve_python_lsp, args=(PYTHON_LSP_PORT,), daemon=True).start()
threading.Thread(target=serve_bicep, args=(BICEP_LSP_PORT,), daemon=True).start()
threading.Thread(target=_serve_yaml_lsp, args=(YAML_LSP_PORT,), daemon=True).start()
app = await _build_app()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT)
await site.start()
logger.info("iLSP HTTP on http://0.0.0.0:%d (ws://.../python ws://.../bicep)", HTTP_PORT)
logger.info(
"iLSP HTTP on http://0.0.0.0:%d (ws://.../python ws://.../bicep ws://.../yaml)",
HTTP_PORT,
)
await asyncio.Event().wait()