feat: add AzDO pipeline schema completions (task@version, inputs, steps)
All checks were successful
Build and Deploy iLSP / test (push) Successful in 20s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m27s

- Inject AzDO schema into yaml-language-server via initializationOptions
  at startup (primary mechanism, works without workspace.configuration)
- Respond to workspace/configuration pull requests from yaml-language-server
  so schema is applied even when editors declare configuration capability
- Keep post-init workspace/didChangeConfiguration as belt-and-suspenders
- Bake azdo-pipeline-schema.json (~1.6MB, 119 defs) into Docker image
- Add smoke test [8]: AzDO task@version completions (254 items)
- Update smoke test YAML initialize to declare workspace.configuration
This commit is contained in:
Henrik Jess Nielsen
2026-05-10 16:55:54 +02:00
parent acccd9ba20
commit e5ba01a52b
2 changed files with 208 additions and 6 deletions

View File

@@ -17,6 +17,7 @@ Context detection:
import asyncio
import json
import logging
import pathlib
import re
from typing import Any
@@ -28,10 +29,121 @@ logger = logging.getLogger(__name__)
_CHUNK = 65536
# AzDO pipeline schema — baked into the Docker image; falls back to upstream URL
_AZDO_SCHEMA_PATH = pathlib.Path("/azdo-pipeline-schema.json")
_AZDO_SCHEMA_URL = (
"https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode"
"/main/service-schema.json"
)
# Glob patterns that yaml-language-server matches against the file path
_AZDO_SCHEMA_GLOBS = [
"azure-pipelines.yml",
"azure-pipelines.yaml",
"*azure-pipelines.yml",
"*azure-pipelines.yaml",
]
def _get_azdo_schema_uri() -> str:
"""Return the best available URI for the AzDO pipeline schema."""
if _AZDO_SCHEMA_PATH.exists():
return _AZDO_SCHEMA_PATH.as_uri()
return _AZDO_SCHEMA_URL
def _inject_azdo_init_options(msg: dict) -> dict:
"""Inject AzDO schema into yaml-language-server's initializationOptions.
yaml-language-server reads schemas from initializationOptions.settings.yaml.schemas
at startup — this is more reliable than a post-init didChangeConfiguration.
"""
schema_uri = _get_azdo_schema_uri()
params = msg.setdefault("params", {})
init_opts = params.setdefault("initializationOptions", {})
settings = init_opts.setdefault("settings", {})
yaml_cfg = settings.setdefault("yaml", {})
schemas = yaml_cfg.setdefault("schemas", {})
schemas[schema_uri] = _AZDO_SCHEMA_GLOBS
yaml_cfg.setdefault("completion", True)
yaml_cfg.setdefault("validate", True)
yaml_cfg.setdefault("hover", True)
logger.debug(
"Injected AzDO schema into initializationOptions (uri=%s)", schema_uri
)
return msg
# ── LSP framing ────────────────────────────────────────────────────────────────
async def _inject_azdo_schema_config(proc: asyncio.subprocess.Process) -> None:
"""Send workspace/didChangeConfiguration to load the AzDO pipeline schema.
Called immediately after the editor's 'initialized' notification as a
belt-and-suspenders complement to the initializationOptions injection.
"""
schema_uri = _get_azdo_schema_uri()
config = {
"jsonrpc": "2.0",
"method": "workspace/didChangeConfiguration",
"params": {
"settings": {
"yaml": {
"schemas": {schema_uri: _AZDO_SCHEMA_GLOBS},
"completion": True,
"validate": True,
"hover": True,
}
}
},
}
body = json.dumps(config).encode()
proc.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
await proc.stdin.drain()
logger.info("Injected AzDO pipeline schema config (source: %s)", schema_uri)
async def _respond_workspace_configuration(
proc: asyncio.subprocess.Process, request: dict
) -> None:
"""Reply to yaml-language-server's workspace/configuration pull request.
yaml-language-server asks the client for settings via this request.
We respond on behalf of the client with the AzDO schema config so that
task:, inputs:, steps: and pipeline structure completions work even if
the editor doesn't handle workspace/configuration responses.
"""
schema_uri = _get_azdo_schema_uri()
yaml_settings = {
"schemas": {schema_uri: _AZDO_SCHEMA_GLOBS},
"completion": True,
"validate": True,
"hover": True,
"schemaStore": {"enable": False},
}
# The request.params.items is a list of {section: "yaml"} etc.
# We reply with one result entry per requested item.
items = request.get("params", {}).get("items", [])
result = []
for item in items:
section = item.get("section", "")
if section == "yaml":
result.append(yaml_settings)
elif section == "http":
result.append({"proxy": None, "proxyStrictSSL": False})
else:
result.append(None)
response = {"jsonrpc": "2.0", "id": request["id"], "result": result}
body = json.dumps(response).encode()
proc.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
await proc.stdin.drain()
logger.debug(
"Responded to workspace/configuration (id=%s, sections=%s)",
request.get("id"),
[i.get("section") for i in items],
)
class _LspFrameBuffer:
"""Reassembles LSP Content-Length framed messages from a stream of bytes."""
@@ -322,11 +434,17 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int = 0) -> web.W
raw = msg.data if msg.type == WSMsgType.BINARY else msg.data.encode()
frames = ws_buf.feed(raw)
for frame in frames:
_parsed: Any = None
try:
parsed = json.loads(frame)
method = parsed.get("method", "")
if method in ("textDocument/didOpen", "textDocument/didChange"):
params = parsed.get("params", {})
_parsed = json.loads(frame)
method = _parsed.get("method", "")
if method == "initialize":
# Inject AzDO schema into initializationOptions so
# yaml-language-server loads it from the very start.
_parsed = _inject_azdo_init_options(_parsed)
frame = json.dumps(_parsed).encode()
elif method in ("textDocument/didOpen", "textDocument/didChange"):
params = _parsed.get("params", {})
uri = params.get("textDocument", {}).get("uri", "")
text = params.get("textDocument", {}).get("text") or ""
if not text:
@@ -336,10 +454,14 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int = 0) -> web.W
if uri and text:
session.update_doc(uri, text)
elif method == "textDocument/completion":
session.record_request(parsed)
session.record_request(_parsed)
except Exception:
pass
proc.stdin.write(_lsp_frame(frame))
# After 'initialized', inject AzDO pipeline schema config so
# yaml-language-server provides task:/inputs: completions.
if _parsed is not None and _parsed.get("method") == "initialized":
await _inject_azdo_schema_config(proc)
await proc.stdin.drain()
except Exception:
pass
@@ -362,6 +484,18 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int = 0) -> web.W
try:
parsed = json.loads(frame)
msg_id = parsed.get("id")
# yaml-language-server uses the PULL model: it sends
# workspace/configuration requests to ask the client for
# settings. Respond directly from the proxy so the
# schema is always applied regardless of editor support.
if parsed.get("method") == "workspace/configuration":
await _respond_workspace_configuration(proc, parsed)
# Still forward to the WS client so the editor can
# see and optionally override with its own response.
await ws.send_bytes(_lsp_frame(frame))
continue
if msg_id is not None and "result" in parsed:
ctx = session.pop_context(msg_id)
modified = _inject_completions(parsed, ctx)