From d8536468abeead1d3d691889cb14ffe1e4c05eed Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sun, 10 May 2026 12:23:05 +0200 Subject: [PATCH] feat: initial iLSP project scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/ci.yml | 64 +++++++++++++ Dockerfile | 60 ++++++++++++ editor_configs/EDITOR_SETUP.md | 65 +++++++++++++ ilsp.nomad | 128 +++++++------------------ ilsp/__init__.py | 3 + ilsp/bicep_lsp/__init__.py | 1 + ilsp/bicep_lsp/modules.py | 105 +++++++++++++++++++++ ilsp/bicep_lsp/proxy.py | 164 +++++++++++++++++++++++++++++++++ ilsp/python_lsp/__init__.py | 1 + ilsp/python_lsp/catalog.py | 94 +++++++++++++++++++ ilsp/python_lsp/plugin.py | 75 +++++++++++++++ ilsp/server.py | 87 +++++++++++++++++ pyproject.toml | 25 +++++ scripts/download_bicep_ls.sh | 31 +++++++ 14 files changed, 808 insertions(+), 95 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 editor_configs/EDITOR_SETUP.md create mode 100644 ilsp/__init__.py create mode 100644 ilsp/bicep_lsp/__init__.py create mode 100644 ilsp/bicep_lsp/modules.py create mode 100644 ilsp/bicep_lsp/proxy.py create mode 100644 ilsp/python_lsp/__init__.py create mode 100644 ilsp/python_lsp/catalog.py create mode 100644 ilsp/python_lsp/plugin.py create mode 100644 ilsp/server.py create mode 100644 pyproject.toml create mode 100755 scripts/download_bicep_ls.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9388ec4 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + REGISTRY: registry.i80.dk + IMAGE: registry.i80.dk/gitea/ilsp + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set image tag + id: tag + run: echo "tag=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.ref == 'refs/heads/main' }} + tags: | + ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }} + ${{ env.IMAGE }}:latest + cache-from: type=registry,ref=${{ env.IMAGE }}:latest + cache-to: type=inline + + deploy: + runs-on: ubuntu-latest + needs: build-and-push + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Set image tag + id: tag + run: echo "tag=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" + + - name: Deploy to Nomad + run: | + scp ilsp.nomad ${{ secrets.DEPLOY_HOST }}:/tmp/ilsp.nomad + ssh ${{ secrets.DEPLOY_HOST }} \ + "NOMAD_ADDR=https://nomad.i80.dk:4646 nomad job run \ + -var='image_tag=${{ steps.tag.outputs.tag }}' /tmp/ilsp.nomad" + + - name: Health check + run: | + sleep 20 + curl -sf https://ilsp.i80.dk/health | jq . diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7ce572 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.6 +# iLSP — multi-stage: downloads Bicep LS, then builds the Python package +# Final image: dotnet runtime + Python 3.12 + +# ── Stage 1: download Bicep Language Server ────────────────────────────────── +FROM debian:bookworm-slim AS bicep-downloader + +ARG BICEP_VERSION=latest +RUN apt-get update && apt-get install -y --no-install-recommends curl unzip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts/download_bicep_ls.sh /scripts/ +RUN chmod +x /scripts/download_bicep_ls.sh && BICEP_VERSION=${BICEP_VERSION} /scripts/download_bicep_ls.sh + + +# ── Stage 2: Python wheel build ─────────────────────────────────────────────── +FROM python:3.12-slim AS builder + +WORKDIR /build +COPY pyproject.toml . +COPY ilsp/ ilsp/ + +RUN pip install --upgrade pip build \ + && python -m build --wheel --outdir /dist + + +# ── Stage 3: final runtime ──────────────────────────────────────────────────── +FROM python:3.12-slim + +# Install .NET runtime (needed by Bicep.LangServer.dll) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + wget apt-transport-https ca-certificates \ + && wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \ + && dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends dotnet-runtime-8.0 \ + && rm -rf /var/lib/apt/lists/* + +# Copy Bicep Language Server (baked in at build time — no volume needed) +COPY --from=bicep-downloader /opt/bicep-langserver /opt/bicep-langserver + +# Install Python package and dependencies +COPY --from=builder /dist/*.whl /tmp/ +RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl + +# Configuration defaults (override via Nomad env) +ENV PYTHON_LSP_PORT=2087 \ + BICEP_LSP_PORT=2088 \ + HEALTH_PORT=2089 \ + BICEP_LS_PATH=/opt/bicep-langserver/Bicep.LangServer.dll \ + DEVOPS_MCP_URL=https://devops-mcp.i80.dk \ + PYTHONUNBUFFERED=1 + +EXPOSE 2087 2088 2089 + +HEALTHCHECK --interval=15s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost:${HEALTH_PORT}/health || exit 1 + +CMD ["ilsp"] diff --git a/editor_configs/EDITOR_SETUP.md b/editor_configs/EDITOR_SETUP.md new file mode 100644 index 0000000..65adca1 --- /dev/null +++ b/editor_configs/EDITOR_SETUP.md @@ -0,0 +1,65 @@ +# Editor configs for lsp.i80.dk + +## Helix (`~/.config/helix/languages.toml`) + +```toml +[language-server.ilsp-python] +command = "nc" +args = ["lsp.i80.dk", "2087"] + +[language-server.ilsp-bicep] +command = "nc" +args = ["lsp.i80.dk", "2088"] + +[[language]] +name = "python" +language-servers = ["ilsp-python", "pylsp"] # runs alongside local pylsp if present + +[[language]] +name = "bicep" +file-types = ["bicep", "bicepparam"] +language-servers = ["ilsp-bicep"] +``` + +## Neovim (`~/.config/nvim/lua/lsp.lua`) + +```lua +-- Python: i80 completions alongside pyright +vim.lsp.start({ + name = "ilsp-python", + cmd = { "nc", "lsp.i80.dk", "2087" }, + filetypes = { "python" }, +}) + +-- Bicep: full Bicep LS via i80 proxy +vim.lsp.start({ + name = "ilsp-bicep", + cmd = { "nc", "lsp.i80.dk", "2088" }, + filetypes = { "bicep" }, +}) +``` + +## PyCharm / IntelliJ IDEA Ultimate (LSP4IJ plugin) + +1. Install **LSP4IJ** from JetBrains Marketplace +2. Settings → Languages & Frameworks → Language Servers → **+** + +| Field | Python | Bicep | +|----------------|-------------------------|-------------------------| +| Name | `ilsp-python` | `ilsp-bicep` | +| Server type | External process | External process | +| Command | `nc lsp.i80.dk 2087` | `nc lsp.i80.dk 2088` | +| File patterns | `*.py` | `*.bicep, *.bicepparam` | + +> PyCharm's built-in Python intelligence runs **alongside** ilsp-python — additive, not replacing. + +## VS Code (`.vscode/settings.json`) + +```json +{ + "pylsp.server.command": ["nc", "lsp.i80.dk", "2087"], + "bicep.languageServerPath": "nc" +} +``` + +> Note: VS Code has better native support. Use only if you want the i80-specific completions. diff --git a/ilsp.nomad b/ilsp.nomad index de9c8bb..552021f 100644 --- a/ilsp.nomad +++ b/ilsp.nomad @@ -1,11 +1,11 @@ variable "service_name" { - description = "Service name for consistent naming" + description = "Service name" type = string default = "ilsp" } variable "image_tag" { - description = "Docker image tag — override in CI with commit SHA: -var=\"image_tag=$SHA\"" + description = "Docker image tag — override in CI: -var=\"image_tag=$SHA\"" type = string default = "latest" } @@ -16,9 +16,8 @@ job "ilsp" { type = "service" meta { - uuid = uuidv4() + uuid = uuidv4() deployed_at = "[[ timeNowUTC ]]" - service_name = var.service_name } update { @@ -31,26 +30,27 @@ job "ilsp" { group "ilsp-group" { count = 1 - # Deploy specifically on the 'autobox.i80.dk' node constraint { attribute = "${node.unique.name}" value = "autobox.i80.dk" } - # Zero-downtime update strategy: canary ensures new alloc is healthy - # before old alloc is stopped. Both run briefly during transition. update { - canary = 1 # Start 1 new alloc before stopping old - auto_promote = true # Promote automatically when healthy - min_healthy_time = "15s" - healthy_deadline = "20m" + canary = 1 + auto_promote = true + min_healthy_time = "15s" + healthy_deadline = "20m" progress_deadline = "25m" - auto_revert = true + auto_revert = true } + # Static ports: LSP uses raw TCP — Traefik isn't involved for 2087/2088. + # DNS: lsp.i80.dk → autobox.i80.dk (A record) + # Editors connect directly: nc lsp.i80.dk 2087 / nc lsp.i80.dk 2088 network { - port "http" { - } + port "python_lsp" { static = 2087 } + port "bicep_lsp" { static = 2088 } + port "health" { static = 2089 } } reschedule { @@ -62,43 +62,24 @@ job "ilsp" { unlimited = false } - # Volumes disabled for quick deployment - # volume "ssl-certs" { - # type = "host" - # source = "certs" - # read_only = true - # } - - volume "refactor-data" { - type = "host" - source = "refactor-data" - read_only = false - } - - # Register the service with Consul + # Health check only — Traefik not used for LSP TCP traffic service { provider = "consul" name = var.service_name - port = "http" + port = "health" - # Traefik-specific tags for routing tags = [ "traefik.enable=true", "traefik.http.routers.${var.service_name}.rule=Host(`${var.service_name}.i80.dk`)", "traefik.http.routers.${var.service_name}.tls=true", - # Rate limiting for refactoring operations - "traefik.http.middlewares.${var.service_name}-limit.ratelimit.burst=10", - "traefik.http.middlewares.${var.service_name}-limit.ratelimit.period=1m", - "traefik.http.routers.${var.service_name}.middlewares=${var.service_name}-limit" ] - # Primary health check - HTTP check { - name = "http_health_check" + name = "health_http" type = "http" - port = "http" + port = "health" path = "/health" - interval = "10s" + interval = "15s" timeout = "5s" } } @@ -107,8 +88,8 @@ job "ilsp" { driver = "docker" config { - image = "registry.i80.dk/gitea/ilsp:${var.image_tag}" - ports = ["http"] + image = "registry.i80.dk/gitea/ilsp:${var.image_tag}" + ports = ["python_lsp", "bicep_lsp", "health"] force_pull = true auth { username = "robot$gitserver" @@ -117,77 +98,34 @@ job "ilsp" { } restart { - attempts = 10 + attempts = 5 interval = "10m" - delay = "15s" + delay = "30s" mode = "fail" } - # Volume mounts disabled for quick deployment - # volume_mount { - # volume = "ssl-certs" - # destination = "/certs" - # read_only = true - # } - - volume_mount { - volume = "refactor-data" - destination = "/app/data" - read_only = false - } - env { - # DevOpsMCP Configuration - # Server Configuration + PYTHON_LSP_PORT = "${NOMAD_PORT_python_lsp}" + BICEP_LSP_PORT = "${NOMAD_PORT_bicep_lsp}" + HEALTH_PORT = "${NOMAD_PORT_health}" + DEVOPS_MCP_URL = "https://devops-mcp.i80.dk" PYTHONUNBUFFERED = "1" - PORT = "${NOMAD_PORT_http}" - HOST = "0.0.0.0" - - # Gitea (gea.i80.dk) API token for server-side CI/Actions queries - GITEA_TOKEN = "441b0e1f3f23d2b29984c970743ec8f7fc4081fa" - GITEA_URL = "https://gea.i80.dk" - - # LanguageTool — self-hosted grammar/spell-check (autobox.i80.dk:8010) - LANGUAGETOOL_URL = "http://192.168.15.124:8010" - # SSL certificate paths (available but not required for app) - SSL_CERT_PATH = "/certs/wildcard.i80.dk.crt_cert.crt" - SSL_KEY_PATH = "/certs/wildcard.i80.dk.key" - SSL_FULLCHAIN_PATH = "/certs/wildcard.i80.dk.crt_fullchain.crt" - - # External MCP servers — tokens loaded from Consul (see template block below) - # Servers start automatically in entrypoint.sh when tokens are present } - # Registry authentication template + # Secrets from Consul KV template { data = < 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 diff --git a/ilsp/bicep_lsp/proxy.py b/ilsp/bicep_lsp/proxy.py new file mode 100644 index 0000000..467c711 --- /dev/null +++ b/ilsp/bicep_lsp/proxy.py @@ -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() diff --git a/ilsp/python_lsp/__init__.py b/ilsp/python_lsp/__init__.py new file mode 100644 index 0000000..d57c041 --- /dev/null +++ b/ilsp/python_lsp/__init__.py @@ -0,0 +1 @@ +"""pylsp plugin that adds i80/LRU-specific completions.""" diff --git a/ilsp/python_lsp/catalog.py b/ilsp/python_lsp/catalog.py new file mode 100644 index 0000000..b36b448 --- /dev/null +++ b/ilsp/python_lsp/catalog.py @@ -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 pkg-name + 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 + ] diff --git a/ilsp/python_lsp/plugin.py b/ilsp/python_lsp/plugin.py new file mode 100644 index 0000000..190a0d6 --- /dev/null +++ b/ilsp/python_lsp/plugin.py @@ -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 diff --git a/ilsp/server.py b/ilsp/server.py new file mode 100644 index 0000000..ac47016 --- /dev/null +++ b/ilsp/server.py @@ -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()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2c999ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "ilsp" +version = "0.1.0" +description = "i80 LSP server — extends Python and Bicep LSPs with LRU/i80 knowledge" +requires-python = ">=3.11" +dependencies = [ + "python-lsp-server[all]>=1.12", + "aiohttp>=3.9", + "lsprotocol>=2023.0", + "pygls>=1.3", +] + +[project.entry-points."pylsp"] +ilsp = "ilsp.python_lsp.plugin" + +[project.scripts] +ilsp = "ilsp.server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["ilsp*"] diff --git a/scripts/download_bicep_ls.sh b/scripts/download_bicep_ls.sh new file mode 100755 index 0000000..8a31e08 --- /dev/null +++ b/scripts/download_bicep_ls.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Download Bicep Language Server at Docker build time. +# Uses the official GitHub releases — no VS Code needed. +set -euo pipefail + +BICEP_VERSION="${BICEP_VERSION:-latest}" +DEST="/opt/bicep-langserver" +ZIP="/tmp/bicep-langserver.zip" + +mkdir -p "$DEST" + +if [ "$BICEP_VERSION" = "latest" ]; then + URL="https://github.com/Azure/bicep/releases/latest/download/bicep-langserver.zip" +else + URL="https://github.com/Azure/bicep/releases/download/${BICEP_VERSION}/bicep-langserver.zip" +fi + +echo "Downloading Bicep Language Server from: $URL" +curl -fsSL "$URL" -o "$ZIP" +unzip -q "$ZIP" -d "$DEST" +rm "$ZIP" + +# Verify the DLL is present +if [ ! -f "$DEST/Bicep.LangServer.dll" ]; then + echo "ERROR: Bicep.LangServer.dll not found in $DEST" >&2 + ls -la "$DEST" >&2 + exit 1 +fi + +echo "Bicep Language Server installed at $DEST" +ls -lh "$DEST/Bicep.LangServer.dll"