feat: initial iLSP project scaffolding
- Python LSP (pylsp + pylsp_i80 plugin): i80 pypi package completions - Bicep LSP (asyncio TCP proxy → Bicep.LangServer.dll): LRU module injection - Health HTTP endpoint (:2089) for Consul/Nomad checks - Startup catalog fetch from pypi-server.i80.dk + DevOpsMCP (no volume needed) - Multi-stage Dockerfile: downloads Bicep LS at build time, dotnet-runtime-8.0 + python3.12 - Nomad job: static TCP ports 2087/2088, health check on 2089 - Gitea Actions CI: build + push + deploy pipeline - Editor configs: Helix / nvim / LSP4IJ / VS Code
This commit is contained in:
3
ilsp/__init__.py
Normal file
3
ilsp/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""iLSP — i80 LSP service extending Python and Bicep language servers."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
ilsp/bicep_lsp/__init__.py
Normal file
1
ilsp/bicep_lsp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Bicep LSP asyncio proxy with LRU module injection."""
|
||||
105
ilsp/bicep_lsp/modules.py
Normal file
105
ilsp/bicep_lsp/modules.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
LRU Bicep module catalog — fetched from DevOpsMCP at startup.
|
||||
|
||||
Provides completion items for LRU-internal Bicep modules with
|
||||
higher sort priority than standard Azure modules.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEVOPS_MCP_URL = os.getenv("DEVOPS_MCP_URL", "https://devops-mcp.i80.dk")
|
||||
REFRESH_INTERVAL = 3600 # 1 hour
|
||||
|
||||
|
||||
class BicepModuleCatalog:
|
||||
"""Fetches and caches LRU Bicep modules from DevOpsMCP."""
|
||||
|
||||
_modules: list[dict[str, Any]] = []
|
||||
_last_refresh: float = 0
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_modules(cls) -> list[dict[str, Any]]:
|
||||
if not cls._modules or time.time() - cls._last_refresh > REFRESH_INTERVAL:
|
||||
await cls._refresh()
|
||||
return cls._modules
|
||||
|
||||
@classmethod
|
||||
async def start_background_refresh(cls) -> None:
|
||||
asyncio.create_task(cls._refresh_loop())
|
||||
|
||||
@classmethod
|
||||
async def _refresh_loop(cls) -> None:
|
||||
while True:
|
||||
await cls._refresh()
|
||||
await asyncio.sleep(REFRESH_INTERVAL)
|
||||
|
||||
@classmethod
|
||||
async def _refresh(cls) -> None:
|
||||
async with cls._lock:
|
||||
try:
|
||||
modules = await cls._fetch_from_devops_mcp()
|
||||
cls._modules = modules
|
||||
cls._last_refresh = time.time()
|
||||
logger.info("Bicep module catalog refreshed: %d modules", len(modules))
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh Bicep module catalog — using stale cache")
|
||||
|
||||
@classmethod
|
||||
async def _fetch_from_devops_mcp(cls) -> list[dict[str, Any]]:
|
||||
"""Call DevOpsMCP list_bicep_modules tool via HTTP."""
|
||||
url = f"{DEVOPS_MCP_URL}/call-tool"
|
||||
payload = {"tool": "list_bicep_modules", "arguments": {}}
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20)) as session:
|
||||
async with session.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"DevOpsMCP returned {resp.status}")
|
||||
data = await resp.json()
|
||||
|
||||
modules = []
|
||||
for m in data.get("modules", []):
|
||||
path = m.get("path", "")
|
||||
versions = m.get("versions", ["latest"])
|
||||
name = path.split("/")[-1] if "/" in path else path
|
||||
modules.append({
|
||||
"name": name,
|
||||
"path": path,
|
||||
"versions": versions,
|
||||
"latest": versions[-1] if versions else "latest",
|
||||
"registry": data.get("registry", "iactemplatereg.azurecr.io"),
|
||||
})
|
||||
|
||||
return modules
|
||||
|
||||
@classmethod
|
||||
def as_completion_items(cls) -> list[dict[str, Any]]:
|
||||
items = []
|
||||
for mod in cls._modules:
|
||||
ref = f"br/modules:{mod['path']}:{mod['latest']}"
|
||||
items.append({
|
||||
"label": mod["name"],
|
||||
"kind": 9, # Module
|
||||
"detail": f"LRU Bicep module — {mod['registry']}",
|
||||
"insertText": ref,
|
||||
"sortText": f"0_lru_{mod['name']}", # sorts above standard az modules
|
||||
"documentation": {
|
||||
"kind": "markdown",
|
||||
"value": (
|
||||
f"**{mod['name']}** (LRU internal)\n\n"
|
||||
f"Registry: `{mod['registry']}`\n"
|
||||
f"Versions: {', '.join(mod['versions'])}\n\n"
|
||||
f"```bicep\nmodule {mod['name'].lower()} '{ref}' = {{\n"
|
||||
f" name: '{mod['name'].lower()}'\n params: {{}}\n}}\n```"
|
||||
),
|
||||
},
|
||||
})
|
||||
return items
|
||||
164
ilsp/bicep_lsp/proxy.py
Normal file
164
ilsp/bicep_lsp/proxy.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Asyncio TCP proxy that wraps Bicep.LangServer.dll.
|
||||
|
||||
Architecture:
|
||||
Editor (TCP:2088) ──► BicepProxy ──► Bicep.LangServer subprocess (stdio)
|
||||
|
||||
The proxy intercepts textDocument/completion responses and injects
|
||||
LRU Bicep module completions with higher sort priority (sortText "0_lru_...").
|
||||
All other LSP messages are forwarded unchanged.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from .modules import BicepModuleCatalog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BICEP_LS_PATH = os.getenv(
|
||||
"BICEP_LS_PATH",
|
||||
"/opt/bicep-langserver/Bicep.LangServer.dll",
|
||||
)
|
||||
LISTEN_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
|
||||
|
||||
|
||||
class _ContentLengthFramer:
|
||||
"""Reads/writes LSP Content-Length framed messages."""
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader):
|
||||
self._reader = reader
|
||||
|
||||
async def read_message(self) -> bytes:
|
||||
headers = b""
|
||||
while not headers.endswith(b"\r\n\r\n"):
|
||||
chunk = await self._reader.read(1)
|
||||
if not chunk:
|
||||
raise EOFError("Connection closed")
|
||||
headers += chunk
|
||||
|
||||
content_length = 0
|
||||
for line in headers.split(b"\r\n"):
|
||||
if line.lower().startswith(b"content-length:"):
|
||||
content_length = int(line.split(b":")[1].strip())
|
||||
|
||||
body = await self._reader.readexactly(content_length)
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def frame(body: bytes) -> bytes:
|
||||
return f"Content-Length: {len(body)}\r\n\r\n".encode() + body
|
||||
|
||||
|
||||
class BicepProxy:
|
||||
"""Per-connection proxy between one editor client and one Bicep LS process."""
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
self._client_reader = reader
|
||||
self._client_writer = writer
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._ls_reader: asyncio.StreamReader | None = None
|
||||
self._ls_writer: asyncio.StreamWriter | None = None
|
||||
|
||||
async def run(self) -> None:
|
||||
peer = self._client_writer.get_extra_info("peername")
|
||||
logger.info("New Bicep client: %s", peer)
|
||||
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
"dotnet", BICEP_LS_PATH, "--stdio",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
self._ls_reader = self._proc.stdout
|
||||
self._ls_writer = self._proc.stdin
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
self._client_to_ls(),
|
||||
self._ls_to_client(),
|
||||
)
|
||||
except (EOFError, ConnectionResetError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
async def _client_to_ls(self) -> None:
|
||||
framer = _ContentLengthFramer(self._client_reader)
|
||||
while True:
|
||||
body = await framer.read_message()
|
||||
framed = _ContentLengthFramer.frame(body)
|
||||
self._ls_writer.write(framed)
|
||||
await self._ls_writer.drain()
|
||||
|
||||
async def _ls_to_client(self) -> None:
|
||||
framer = _ContentLengthFramer(self._ls_reader)
|
||||
while True:
|
||||
body = await framer.read_message()
|
||||
|
||||
try:
|
||||
msg = json.loads(body)
|
||||
body = self._maybe_inject_completions(msg)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
framed = _ContentLengthFramer.frame(
|
||||
body if isinstance(body, bytes) else json.dumps(body).encode()
|
||||
)
|
||||
self._client_writer.write(framed)
|
||||
await self._client_writer.drain()
|
||||
|
||||
def _maybe_inject_completions(self, msg: dict[str, Any]) -> dict[str, Any] | bytes:
|
||||
"""Inject LRU modules into completion responses."""
|
||||
result = msg.get("result")
|
||||
if result is None:
|
||||
return json.dumps(msg).encode()
|
||||
|
||||
# Completion result is either a list or {isIncomplete, items}
|
||||
items: list | None = None
|
||||
if isinstance(result, list):
|
||||
items = result
|
||||
elif isinstance(result, dict) and "items" in result:
|
||||
items = result["items"]
|
||||
|
||||
if items is None:
|
||||
return json.dumps(msg).encode()
|
||||
|
||||
lru_items = BicepModuleCatalog.as_completion_items()
|
||||
if lru_items:
|
||||
# Downgrade standard items so LRU sorts first
|
||||
for item in items:
|
||||
st = item.get("sortText", item.get("label", ""))
|
||||
item["sortText"] = f"1_az_{st}"
|
||||
|
||||
if isinstance(result, list):
|
||||
msg["result"] = lru_items + items
|
||||
else:
|
||||
result["items"] = lru_items + items
|
||||
result["isIncomplete"] = True
|
||||
|
||||
return json.dumps(msg).encode()
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if self._proc and self._proc.returncode is None:
|
||||
self._proc.terminate()
|
||||
self._client_writer.close()
|
||||
|
||||
|
||||
async def serve_bicep(port: int = LISTEN_PORT) -> None:
|
||||
"""Start the Bicep LSP TCP proxy server."""
|
||||
await BicepModuleCatalog.start_background_refresh()
|
||||
|
||||
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
proxy = BicepProxy(reader, writer)
|
||||
await proxy.run()
|
||||
|
||||
server = await asyncio.start_server(_handle, "0.0.0.0", port)
|
||||
logger.info("Bicep LSP proxy listening on TCP :%d", port)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
1
ilsp/python_lsp/__init__.py
Normal file
1
ilsp/python_lsp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""pylsp plugin that adds i80/LRU-specific completions."""
|
||||
94
ilsp/python_lsp/catalog.py
Normal file
94
ilsp/python_lsp/catalog.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
pypi-server.i80.dk catalog fetcher with in-memory TTL cache.
|
||||
|
||||
Fetches the package list at startup and refreshes every hour.
|
||||
No persistent storage needed — all data lives in memory.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from lsprotocol.types import CompletionItem, CompletionItemKind, MarkupContent, MarkupKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYPI_SIMPLE_URL = "https://pypi-server.i80.dk/simple/"
|
||||
PYPI_BASE_URL = "https://pypi-server.i80.dk"
|
||||
REFRESH_INTERVAL = 3600 # seconds
|
||||
|
||||
|
||||
class PypiCatalog:
|
||||
"""Thread-safe singleton catalog for pypi-server.i80.dk packages."""
|
||||
|
||||
_packages: list[dict[str, Any]] = []
|
||||
_last_refresh: float = 0
|
||||
_lock = asyncio.Lock()
|
||||
_refresh_task: asyncio.Task | None = None
|
||||
|
||||
@classmethod
|
||||
async def get_packages(cls) -> list[dict[str, Any]]:
|
||||
if not cls._packages or time.time() - cls._last_refresh > REFRESH_INTERVAL:
|
||||
await cls._refresh()
|
||||
return cls._packages
|
||||
|
||||
@classmethod
|
||||
async def start_background_refresh(cls) -> None:
|
||||
if cls._refresh_task is None or cls._refresh_task.done():
|
||||
cls._refresh_task = asyncio.create_task(cls._refresh_loop())
|
||||
|
||||
@classmethod
|
||||
async def _refresh_loop(cls) -> None:
|
||||
while True:
|
||||
await cls._refresh()
|
||||
await asyncio.sleep(REFRESH_INTERVAL)
|
||||
|
||||
@classmethod
|
||||
async def _refresh(cls) -> None:
|
||||
async with cls._lock:
|
||||
try:
|
||||
packages = await cls._fetch()
|
||||
cls._packages = packages
|
||||
cls._last_refresh = time.time()
|
||||
logger.info("PyPI catalog refreshed: %d packages", len(packages))
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh PyPI catalog")
|
||||
|
||||
@classmethod
|
||||
async def _fetch(cls) -> list[dict[str, Any]]:
|
||||
packages = []
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
|
||||
async with session.get(PYPI_SIMPLE_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
html = await resp.text()
|
||||
|
||||
# Parse simple index HTML — each <a href="/simple/pkg-name/"> pkg-name </a>
|
||||
import re
|
||||
for match in re.finditer(r'href="[^"]+/([^/]+)/"[^>]*>([^<]+)<', html):
|
||||
pkg_name = match.group(2).strip()
|
||||
packages.append({
|
||||
"name": pkg_name,
|
||||
"label": pkg_name,
|
||||
"detail": f"i80 package — pypi-server.i80.dk",
|
||||
"sort_prefix": "0_i80_", # sorts before standard packages
|
||||
})
|
||||
|
||||
return packages
|
||||
|
||||
@classmethod
|
||||
def as_completion_items(cls) -> list[CompletionItem]:
|
||||
return [
|
||||
CompletionItem(
|
||||
label=pkg["name"],
|
||||
kind=CompletionItemKind.Module,
|
||||
detail=pkg["detail"],
|
||||
sort_text=f"{pkg['sort_prefix']}{pkg['name']}",
|
||||
documentation=MarkupContent(
|
||||
kind=MarkupKind.Markdown,
|
||||
value=f"**{pkg['name']}**\n\nCustom i80/LRU package from `pypi-server.i80.dk`",
|
||||
),
|
||||
)
|
||||
for pkg in cls._packages
|
||||
]
|
||||
75
ilsp/python_lsp/plugin.py
Normal file
75
ilsp/python_lsp/plugin.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
pylsp plugin: injects i80/LRU packages into import completions.
|
||||
|
||||
Registered via entry_points group "pylsp" in pyproject.toml.
|
||||
pylsp calls these hooks automatically when the plugin is installed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from pylsp import hookimpl
|
||||
|
||||
from .catalog import PypiCatalog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Trigger characters that indicate we're completing an import statement
|
||||
_IMPORT_TRIGGERS = {"import", "from"}
|
||||
|
||||
|
||||
def _is_import_context(document, position) -> bool:
|
||||
"""Return True if the cursor is on an import line."""
|
||||
line_num = position["line"]
|
||||
if line_num >= len(document.lines):
|
||||
return False
|
||||
line = document.lines[line_num].strip()
|
||||
return any(line.startswith(kw) for kw in _IMPORT_TRIGGERS)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def pylsp_completions(config, workspace, document, position):
|
||||
"""Inject i80 pypi packages when completing import statements."""
|
||||
if not _is_import_context(document, position):
|
||||
return []
|
||||
|
||||
# PypiCatalog._packages is populated at startup; safe to read synchronously
|
||||
packages = PypiCatalog._packages
|
||||
if not packages:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"label": pkg["name"],
|
||||
"kind": 9, # Module
|
||||
"detail": "i80 — pypi-server.i80.dk",
|
||||
"sortText": f"{pkg['sort_prefix']}{pkg['name']}",
|
||||
"documentation": {
|
||||
"kind": "markdown",
|
||||
"value": f"**{pkg['name']}**\n\nCustom i80/LRU package from `pypi-server.i80.dk`",
|
||||
},
|
||||
}
|
||||
for pkg in packages
|
||||
]
|
||||
|
||||
|
||||
@hookimpl
|
||||
def pylsp_hover(config, workspace, document, position):
|
||||
"""Show package docs on hover for i80 packages."""
|
||||
word = document.word_at_position(position)
|
||||
if not word:
|
||||
return None
|
||||
|
||||
for pkg in PypiCatalog._packages:
|
||||
if pkg["name"] == word:
|
||||
return {
|
||||
"contents": {
|
||||
"kind": "markdown",
|
||||
"value": (
|
||||
f"**{pkg['name']}** — i80 internal package\n\n"
|
||||
f"Source: `pypi-server.i80.dk`\n\n"
|
||||
f"Install: `pip install {pkg['name']} --index-url https://pypi-server.i80.dk/simple/`"
|
||||
),
|
||||
}
|
||||
}
|
||||
return None
|
||||
87
ilsp/server.py
Normal file
87
ilsp/server.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
iLSP main entrypoint.
|
||||
|
||||
Starts three services concurrently:
|
||||
- pylsp server on TCP :2087 (Python LSP + i80 completions)
|
||||
- Bicep proxy on TCP :2088 (Bicep LS wrapper + LRU modules)
|
||||
- Health HTTP on TCP :2089 (for Consul/Nomad health checks)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .python_lsp.catalog import PypiCatalog
|
||||
from .bicep_lsp.proxy import serve_bicep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
|
||||
BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
|
||||
HEALTH_PORT = int(os.getenv("HEALTH_PORT", "2089"))
|
||||
|
||||
|
||||
async def _health_app() -> web.Application:
|
||||
app = web.Application()
|
||||
|
||||
async def health(_: web.Request) -> web.Response:
|
||||
pypi_count = len(PypiCatalog._packages)
|
||||
from .bicep_lsp.modules import BicepModuleCatalog
|
||||
bicep_count = len(BicepModuleCatalog._modules)
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"pypi_packages": pypi_count,
|
||||
"bicep_modules": bicep_count,
|
||||
})
|
||||
|
||||
app.router.add_get("/health", health)
|
||||
return app
|
||||
|
||||
|
||||
async def _serve_python_lsp(port: int) -> None:
|
||||
"""Start pylsp in TCP server mode."""
|
||||
import subprocess, sys
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-m", "pylsp",
|
||||
"--tcp", "--host", "0.0.0.0", "--port", str(port),
|
||||
)
|
||||
logger.info("Python LSP (pylsp) listening on TCP :%d PID=%d", port, proc.pid)
|
||||
await proc.wait()
|
||||
logger.warning("pylsp exited — restarting")
|
||||
|
||||
|
||||
async def main_async() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
# Pre-warm caches
|
||||
logger.info("Pre-warming catalogs…")
|
||||
await asyncio.gather(
|
||||
PypiCatalog.start_background_refresh(),
|
||||
)
|
||||
|
||||
# Build health app
|
||||
health_app = await _health_app()
|
||||
runner = web.AppRunner(health_app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", HEALTH_PORT)
|
||||
await site.start()
|
||||
logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT)
|
||||
|
||||
# Run all services
|
||||
await asyncio.gather(
|
||||
_serve_python_lsp(PYTHON_LSP_PORT),
|
||||
serve_bicep(BICEP_LSP_PORT),
|
||||
)
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user