feat: initial iLSP project scaffolding
Some checks failed
CI / deploy (push) Has been cancelled
CI / build-and-push (push) Has been cancelled

- 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:
Henrik Jess Nielsen
2026-05-10 12:23:05 +02:00
parent e8708191f6
commit d8536468ab
14 changed files with 808 additions and 95 deletions

View File

@@ -0,0 +1 @@
"""pylsp plugin that adds i80/LRU-specific completions."""

View 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
View 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