Files
iLSP/ilsp/server.py

491 lines
15 KiB
Python
Raw Normal View History

"""
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 &rarr; Languages &amp; Frameworks &rarr; 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 &mdash; <a href="https://gea.i80.dk/hjess/iLSP">source</a> &mdash;
<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())