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:
64
.gitea/workflows/ci.yml
Normal file
64
.gitea/workflows/ci.yml
Normal file
@@ -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 .
|
||||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -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"]
|
||||||
65
editor_configs/EDITOR_SETUP.md
Normal file
65
editor_configs/EDITOR_SETUP.md
Normal file
@@ -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.
|
||||||
128
ilsp.nomad
128
ilsp.nomad
@@ -1,11 +1,11 @@
|
|||||||
variable "service_name" {
|
variable "service_name" {
|
||||||
description = "Service name for consistent naming"
|
description = "Service name"
|
||||||
type = string
|
type = string
|
||||||
default = "ilsp"
|
default = "ilsp"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "image_tag" {
|
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
|
type = string
|
||||||
default = "latest"
|
default = "latest"
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,8 @@ job "ilsp" {
|
|||||||
type = "service"
|
type = "service"
|
||||||
|
|
||||||
meta {
|
meta {
|
||||||
uuid = uuidv4()
|
uuid = uuidv4()
|
||||||
deployed_at = "[[ timeNowUTC ]]"
|
deployed_at = "[[ timeNowUTC ]]"
|
||||||
service_name = var.service_name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update {
|
update {
|
||||||
@@ -31,26 +30,27 @@ job "ilsp" {
|
|||||||
group "ilsp-group" {
|
group "ilsp-group" {
|
||||||
count = 1
|
count = 1
|
||||||
|
|
||||||
# Deploy specifically on the 'autobox.i80.dk' node
|
|
||||||
constraint {
|
constraint {
|
||||||
attribute = "${node.unique.name}"
|
attribute = "${node.unique.name}"
|
||||||
value = "autobox.i80.dk"
|
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 {
|
update {
|
||||||
canary = 1 # Start 1 new alloc before stopping old
|
canary = 1
|
||||||
auto_promote = true # Promote automatically when healthy
|
auto_promote = true
|
||||||
min_healthy_time = "15s"
|
min_healthy_time = "15s"
|
||||||
healthy_deadline = "20m"
|
healthy_deadline = "20m"
|
||||||
progress_deadline = "25m"
|
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 {
|
network {
|
||||||
port "http" {
|
port "python_lsp" { static = 2087 }
|
||||||
}
|
port "bicep_lsp" { static = 2088 }
|
||||||
|
port "health" { static = 2089 }
|
||||||
}
|
}
|
||||||
|
|
||||||
reschedule {
|
reschedule {
|
||||||
@@ -62,43 +62,24 @@ job "ilsp" {
|
|||||||
unlimited = false
|
unlimited = false
|
||||||
}
|
}
|
||||||
|
|
||||||
# Volumes disabled for quick deployment
|
# Health check only — Traefik not used for LSP TCP traffic
|
||||||
# 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
|
|
||||||
service {
|
service {
|
||||||
provider = "consul"
|
provider = "consul"
|
||||||
name = var.service_name
|
name = var.service_name
|
||||||
port = "http"
|
port = "health"
|
||||||
|
|
||||||
# Traefik-specific tags for routing
|
|
||||||
tags = [
|
tags = [
|
||||||
"traefik.enable=true",
|
"traefik.enable=true",
|
||||||
"traefik.http.routers.${var.service_name}.rule=Host(`${var.service_name}.i80.dk`)",
|
"traefik.http.routers.${var.service_name}.rule=Host(`${var.service_name}.i80.dk`)",
|
||||||
"traefik.http.routers.${var.service_name}.tls=true",
|
"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 {
|
check {
|
||||||
name = "http_health_check"
|
name = "health_http"
|
||||||
type = "http"
|
type = "http"
|
||||||
port = "http"
|
port = "health"
|
||||||
path = "/health"
|
path = "/health"
|
||||||
interval = "10s"
|
interval = "15s"
|
||||||
timeout = "5s"
|
timeout = "5s"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +88,8 @@ job "ilsp" {
|
|||||||
driver = "docker"
|
driver = "docker"
|
||||||
|
|
||||||
config {
|
config {
|
||||||
image = "registry.i80.dk/gitea/ilsp:${var.image_tag}"
|
image = "registry.i80.dk/gitea/ilsp:${var.image_tag}"
|
||||||
ports = ["http"]
|
ports = ["python_lsp", "bicep_lsp", "health"]
|
||||||
force_pull = true
|
force_pull = true
|
||||||
auth {
|
auth {
|
||||||
username = "robot$gitserver"
|
username = "robot$gitserver"
|
||||||
@@ -117,77 +98,34 @@ job "ilsp" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restart {
|
restart {
|
||||||
attempts = 10
|
attempts = 5
|
||||||
interval = "10m"
|
interval = "10m"
|
||||||
delay = "15s"
|
delay = "30s"
|
||||||
mode = "fail"
|
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 {
|
env {
|
||||||
# DevOpsMCP Configuration
|
PYTHON_LSP_PORT = "${NOMAD_PORT_python_lsp}"
|
||||||
# Server Configuration
|
BICEP_LSP_PORT = "${NOMAD_PORT_bicep_lsp}"
|
||||||
|
HEALTH_PORT = "${NOMAD_PORT_health}"
|
||||||
|
DEVOPS_MCP_URL = "https://devops-mcp.i80.dk"
|
||||||
PYTHONUNBUFFERED = "1"
|
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 {
|
template {
|
||||||
data = <<EOH
|
data = <<EOH
|
||||||
HARBOR_ROBOT_TOKEN="{{ key "harbor/robot/token" }}"
|
HARBOR_ROBOT_TOKEN="{{ key "harbor/robot/token" }}"
|
||||||
EOH
|
EOH
|
||||||
destination = "secrets/registry.env"
|
destination = "secrets/registry.env"
|
||||||
env = true
|
env = true
|
||||||
}
|
}
|
||||||
|
|
||||||
# External MCP server tokens (from Consul KV)
|
|
||||||
template {
|
|
||||||
data = <<EOH
|
|
||||||
EOH
|
|
||||||
destination = "secrets/external-mcp.env"
|
|
||||||
env = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# Context7 API key disabled for now (can be added via env vars)
|
|
||||||
# template {
|
|
||||||
# data = <<EOH
|
|
||||||
# CONTEXT7_API_KEY="{{ key "ilsp/context7/api_key" }}"
|
|
||||||
# EOH
|
|
||||||
# destination = "secrets/context7.env"
|
|
||||||
# env = true
|
|
||||||
# }
|
|
||||||
|
|
||||||
resources {
|
resources {
|
||||||
cpu = 1000 # MHz - Higher CPU for code analysis
|
# dotnet runtime (Bicep LS) + pylsp both in one container
|
||||||
memory = 1024 # MB - reserved for scheduling (canary needs 2x)
|
cpu = 1000 # MHz
|
||||||
memory_max = 2048 # MB - can burst up to 2GB for Playwright/Chromium
|
memory = 1536 # MB — dotnet needs headroom
|
||||||
|
memory_max = 2560 # MB burst
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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())
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -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*"]
|
||||||
31
scripts/download_bicep_ls.sh
Executable file
31
scripts/download_bicep_ls.sh
Executable file
@@ -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"
|
||||||
Reference in New Issue
Block a user