Clean HTML page describing iLSP, what LSP is, the WebSocket endpoints, and setup instructions for Neovim, IntelliJ and VS Code. Live stats (Bicep modules, pipeline templates, PyPI packages) are rendered from the in-memory catalog at request time. No emojis. Minimal CSS, no external dependencies.
491 lines
15 KiB
Python
491 lines
15 KiB
Python
"""
|
|
iLSP main entrypoint.
|
|
|
|
Starts all services in one container:
|
|
- pylsp on localhost:2087 (internal only — Python LSP + i80 completions)
|
|
- Bicep proxy on localhost:2088 (internal only — Bicep LS wrapper + LRU modules)
|
|
- aiohttp HTTP on $HTTP_PORT (exposed — health, reload, WebSocket proxies)
|
|
|
|
Only the HTTP port is exposed to Traefik/Nomad. Editors connect via:
|
|
ws://ilsp.i80.dk/python → pylsp
|
|
ws://ilsp.i80.dk/bicep → Bicep LS
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
|
|
from aiohttp import web
|
|
|
|
from .python_lsp.catalog import PypiCatalog
|
|
from .bicep_lsp.modules import BicepModuleCatalog
|
|
from .bicep_lsp.proxy import serve_bicep
|
|
from .yaml_lsp.catalog import PipelineTemplateCatalog
|
|
from .yaml_lsp.proxy import yaml_ws_handler
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
|
|
BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
|
|
HTTP_PORT = int(os.getenv("HTTP_PORT", "8000"))
|
|
|
|
_CHUNK = 65536
|
|
|
|
|
|
def _serve_python_lsp(port: int) -> None:
|
|
"""Start pylsp in TCP mode on localhost only; restart on unexpected exit."""
|
|
while True:
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-m", "pylsp", "--tcp", "--host", "127.0.0.1", "--port", str(port)],
|
|
)
|
|
logger.info("pylsp listening on localhost:%d PID=%d", port, proc.pid)
|
|
proc.wait()
|
|
logger.warning("pylsp exited (code %s) — restarting", proc.returncode)
|
|
|
|
|
|
async def _pipe(reader: asyncio.StreamReader, writer) -> None:
|
|
"""Pipe bytes from reader to writer until EOF."""
|
|
try:
|
|
while True:
|
|
data = await reader.read(_CHUNK)
|
|
if not data:
|
|
break
|
|
writer.write(data)
|
|
if hasattr(writer, "drain"):
|
|
await writer.drain()
|
|
else:
|
|
await writer.send_bytes(data)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _ws_proxy(request: web.Request, host: str, port: int) -> web.WebSocketResponse:
|
|
"""Bridge an incoming WebSocket connection to an internal TCP LSP server."""
|
|
ws = web.WebSocketResponse()
|
|
await ws.prepare(request)
|
|
|
|
try:
|
|
tcp_reader, tcp_writer = await asyncio.wait_for(
|
|
asyncio.open_connection(host, port), timeout=3.0
|
|
)
|
|
except (OSError, asyncio.TimeoutError) as exc:
|
|
logger.error("Cannot connect to LSP on %s:%d — %s", host, port, exc)
|
|
await ws.close(code=1011, message=b"LSP backend unavailable", timeout=2.0)
|
|
return ws
|
|
|
|
async def ws_to_tcp():
|
|
try:
|
|
async for msg in ws:
|
|
if msg.type == web.WSMsgType.BINARY:
|
|
tcp_writer.write(msg.data)
|
|
await tcp_writer.drain()
|
|
elif msg.type == web.WSMsgType.TEXT:
|
|
tcp_writer.write(msg.data.encode())
|
|
await tcp_writer.drain()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
tcp_writer.close()
|
|
|
|
async def tcp_to_ws():
|
|
try:
|
|
while True:
|
|
data = await tcp_reader.read(_CHUNK)
|
|
if not data:
|
|
break
|
|
await ws.send_bytes(data)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
await ws.close()
|
|
|
|
await asyncio.gather(ws_to_tcp(), tcp_to_ws(), return_exceptions=True)
|
|
return ws
|
|
|
|
|
|
_INDEX_HTML = """\
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>iLSP — Language Server Proxy</title>
|
|
<style>
|
|
*, *::before, *::after {{ box-sizing: border-box; }}
|
|
:root {{
|
|
--bg: #ffffff;
|
|
--surface: #f6f7f9;
|
|
--border: #e2e4e8;
|
|
--text: #1a1d23;
|
|
--muted: #6b7280;
|
|
--accent: #2563eb;
|
|
--accent-light: #eff6ff;
|
|
--code-bg: #f1f3f5;
|
|
--green: #16a34a;
|
|
font-size: 16px;
|
|
}}
|
|
body {{
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
}}
|
|
header {{
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 1.5rem 2rem;
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 1rem;
|
|
}}
|
|
header h1 {{
|
|
margin: 0;
|
|
font-size: 1.4rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
}}
|
|
header span {{
|
|
color: var(--muted);
|
|
font-size: 0.9rem;
|
|
}}
|
|
.badge {{
|
|
display: inline-block;
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 999px;
|
|
border: 1px solid #bfdbfe;
|
|
}}
|
|
main {{
|
|
max-width: 860px;
|
|
margin: 0 auto;
|
|
padding: 2.5rem 2rem 4rem;
|
|
}}
|
|
h2 {{
|
|
font-size: 1.05rem;
|
|
font-weight: 700;
|
|
margin: 2.5rem 0 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 0.4rem;
|
|
letter-spacing: -0.01em;
|
|
}}
|
|
p {{ margin: 0.5rem 0 0.75rem; color: var(--text); }}
|
|
.intro {{ color: var(--muted); margin-top: 0; }}
|
|
table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.9rem;
|
|
margin: 1rem 0;
|
|
}}
|
|
th {{
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--muted);
|
|
border-bottom: 2px solid var(--border);
|
|
padding: 0.5rem 0.75rem;
|
|
}}
|
|
td {{
|
|
padding: 0.55rem 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
vertical-align: top;
|
|
}}
|
|
tr:last-child td {{ border-bottom: none; }}
|
|
td:first-child {{ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 0.85rem; white-space: nowrap; }}
|
|
code {{
|
|
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
font-size: 0.85em;
|
|
background: var(--code-bg);
|
|
padding: 0.15em 0.35em;
|
|
border-radius: 4px;
|
|
}}
|
|
pre {{
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 1rem 1.25rem;
|
|
overflow-x: auto;
|
|
font-size: 0.85rem;
|
|
line-height: 1.5;
|
|
margin: 0.75rem 0 1.25rem;
|
|
}}
|
|
pre code {{ background: none; padding: 0; font-size: inherit; }}
|
|
.status-row {{
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
flex-wrap: wrap;
|
|
margin: 1rem 0;
|
|
}}
|
|
.stat {{
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 0.75rem 1.25rem;
|
|
min-width: 140px;
|
|
}}
|
|
.stat .num {{
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: var(--accent);
|
|
}}
|
|
.stat .lbl {{
|
|
font-size: 0.78rem;
|
|
color: var(--muted);
|
|
margin-top: 0.2rem;
|
|
}}
|
|
.note {{
|
|
background: var(--accent-light);
|
|
border-left: 3px solid var(--accent);
|
|
border-radius: 0 6px 6px 0;
|
|
padding: 0.75rem 1rem;
|
|
font-size: 0.88rem;
|
|
color: #1e3a8a;
|
|
margin: 1rem 0;
|
|
}}
|
|
footer {{
|
|
text-align: center;
|
|
font-size: 0.8rem;
|
|
color: var(--muted);
|
|
padding: 2rem;
|
|
border-top: 1px solid var(--border);
|
|
}}
|
|
a {{ color: var(--accent); text-decoration: none; }}
|
|
a:hover {{ text-decoration: underline; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>iLSP</h1>
|
|
<span>Language Server Proxy</span>
|
|
<span class="badge">self-hosted</span>
|
|
</header>
|
|
|
|
<main>
|
|
|
|
<p class="intro">
|
|
iLSP is a WebSocket gateway that exposes language server protocol (LSP)
|
|
backends over a single HTTP endpoint. Editors connect via WebSocket and
|
|
receive code intelligence — completions, hover, diagnostics — for Bicep,
|
|
YAML pipelines, and Python, enriched with organisation-specific catalogs.
|
|
</p>
|
|
|
|
<h2>What is LSP</h2>
|
|
<p>
|
|
The <a href="https://microsoft.github.io/language-server-protocol/">Language Server Protocol</a>
|
|
is a standard protocol between editors and language analysis tools.
|
|
An editor sends requests (go-to-definition, completion, hover) and receives
|
|
structured responses without knowing anything about the language itself.
|
|
Any editor that speaks LSP — IntelliJ, Neovim, VS Code, Helix — works
|
|
with any LSP server.
|
|
</p>
|
|
<p>
|
|
iLSP wraps existing language servers (Bicep Language Server,
|
|
yaml-language-server, pylsp) and injects completion items from
|
|
internal catalogs before responses are returned to the editor.
|
|
</p>
|
|
|
|
<h2>Live status</h2>
|
|
<div class="status-row">
|
|
<div class="stat"><div class="num">{bicep_modules}</div><div class="lbl">Bicep modules</div></div>
|
|
<div class="stat"><div class="num">{pipeline_templates}</div><div class="lbl">Pipeline templates</div></div>
|
|
<div class="stat"><div class="num">{pypi_packages}</div><div class="lbl">Python packages</div></div>
|
|
<div class="stat"><div class="num">254</div><div class="lbl">AzDO tasks</div></div>
|
|
</div>
|
|
<p style="font-size:0.82rem;color:var(--muted)">
|
|
Full JSON status: <a href="/health">/health</a>
|
|
</p>
|
|
|
|
<h2>WebSocket endpoints</h2>
|
|
<table>
|
|
<thead><tr><th>Endpoint</th><th>Language server</th><th>Extra completions</th></tr></thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>wss://ilsp.i80.dk/bicep</td>
|
|
<td>Bicep Language Server</td>
|
|
<td>ACR module paths, versions, params</td>
|
|
</tr>
|
|
<tr>
|
|
<td>wss://ilsp.i80.dk/yaml</td>
|
|
<td>yaml-language-server</td>
|
|
<td>AzDO task schema (254 tasks), pipeline templates</td>
|
|
</tr>
|
|
<tr>
|
|
<td>wss://ilsp.i80.dk/python</td>
|
|
<td>pylsp (Jedi)</td>
|
|
<td>Internal PyPI package stubs</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Editor setup</h2>
|
|
|
|
<p><strong>Neovim</strong> — add to your LSP config (<code>init.lua</code>):</p>
|
|
<pre><code>-- Bicep
|
|
vim.lsp.start({{
|
|
name = "ilsp-bicep",
|
|
cmd = {{ "websocat", "wss://ilsp.i80.dk/bicep" }},
|
|
filetypes = {{ "bicep" }},
|
|
}})
|
|
|
|
-- YAML pipelines
|
|
vim.lsp.start({{
|
|
name = "ilsp-yaml",
|
|
cmd = {{ "websocat", "wss://ilsp.i80.dk/yaml" }},
|
|
filetypes = {{ "yaml" }},
|
|
}})</code></pre>
|
|
|
|
<p>
|
|
<strong>IntelliJ IDEA / Rider</strong> — install the
|
|
<a href="https://plugins.jetbrains.com/plugin/10209-lsp-client">LSP client plugin</a>,
|
|
then add a server under <em>Settings → Languages & Frameworks → Language Servers</em>:
|
|
</p>
|
|
<pre><code>Name: iLSP Bicep
|
|
Command: websocat wss://ilsp.i80.dk/bicep
|
|
Pattern: *.bicep
|
|
|
|
Name: iLSP YAML
|
|
Command: websocat wss://ilsp.i80.dk/yaml
|
|
Pattern: azure-pipelines.yml, *.yaml</code></pre>
|
|
|
|
<div class="note">
|
|
<a href="https://github.com/vi/websocat">websocat</a> bridges a WebSocket to
|
|
stdio so editors that expect a local process can connect to a remote LSP.
|
|
Install with <code>brew install websocat</code> or
|
|
<code>cargo install websocat</code>.
|
|
</div>
|
|
|
|
<p><strong>VS Code</strong> — use the
|
|
<a href="https://marketplace.visualstudio.com/items?itemName=mesonbuild.mesonlsp">LSP client</a>
|
|
or configure the built-in
|
|
<code>vscode-languageclient</code> in a workspace extension, pointing
|
|
<code>serverOptions.command</code> at <code>websocat wss://ilsp.i80.dk/bicep</code>.
|
|
</p>
|
|
|
|
<h2>Updating the catalogs</h2>
|
|
<p>
|
|
Catalogs are baked into the Docker image at build time and can also be
|
|
refreshed at runtime without restarting. Run the sync scripts from the
|
|
iLSP repository and call the reload endpoint:
|
|
</p>
|
|
<pre><code># Sync Bicep module catalog from ACR and push
|
|
python3 scripts/sync_push_catalogs.py
|
|
|
|
# Trigger a hot reload without container restart
|
|
curl -X POST https://ilsp.i80.dk/reload</code></pre>
|
|
|
|
</main>
|
|
|
|
<footer>
|
|
iLSP — <a href="https://gea.i80.dk/hjess/iLSP">source</a> —
|
|
<a href="/health">health</a>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
async def _build_app() -> web.Application:
|
|
app = web.Application()
|
|
|
|
async def index(_: web.Request) -> web.Response:
|
|
html = _INDEX_HTML.format(
|
|
bicep_modules=len(BicepModuleCatalog._modules),
|
|
pipeline_templates=PipelineTemplateCatalog.template_count(),
|
|
pypi_packages=len(PypiCatalog._packages),
|
|
)
|
|
return web.Response(text=html, content_type="text/html", charset="utf-8")
|
|
|
|
async def health(_: web.Request) -> web.Response:
|
|
import shutil
|
|
from .yaml_lsp.proxy import _AZDO_SCHEMA_PATH
|
|
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")),
|
|
"pipeline_templates": PipelineTemplateCatalog.template_count(),
|
|
"azdo_pipeline_schema": _AZDO_SCHEMA_PATH.exists(),
|
|
})
|
|
|
|
async def reload(_: web.Request) -> web.Response:
|
|
before_bicep = len(BicepModuleCatalog._modules)
|
|
before_tmpl = PipelineTemplateCatalog.template_count()
|
|
BicepModuleCatalog.load()
|
|
PipelineTemplateCatalog.load()
|
|
after_bicep = len(BicepModuleCatalog._modules)
|
|
after_tmpl = PipelineTemplateCatalog.template_count()
|
|
logger.info(
|
|
"Catalog reloaded: bicep %d→%d templates %d→%d",
|
|
before_bicep, after_bicep, before_tmpl, after_tmpl,
|
|
)
|
|
return web.json_response({
|
|
"status": "reloaded",
|
|
"bicep_modules_before": before_bicep,
|
|
"bicep_modules_after": after_bicep,
|
|
"pipeline_templates_before": before_tmpl,
|
|
"pipeline_templates_after": after_tmpl,
|
|
})
|
|
|
|
async def python_ws(request: web.Request) -> web.WebSocketResponse:
|
|
return await _ws_proxy(request, "127.0.0.1", PYTHON_LSP_PORT)
|
|
|
|
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 yaml_ws_handler(request)
|
|
|
|
app.router.add_get("/", index)
|
|
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
|
|
|
|
|
|
async def main_async() -> None:
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
|
|
logger.info("Pre-warming catalogs…")
|
|
BicepModuleCatalog.load()
|
|
PipelineTemplateCatalog.load()
|
|
await PypiCatalog.start_background_refresh()
|
|
|
|
# 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()
|
|
# yaml-language-server is spawned per-connection in yaml_ws_handler (--stdio mode)
|
|
|
|
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 ws://.../yaml)",
|
|
HTTP_PORT,
|
|
)
|
|
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
def main() -> None:
|
|
loop = asyncio.new_event_loop()
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
loop.add_signal_handler(sig, loop.stop)
|
|
loop.run_until_complete(main_async())
|