From e5ba01a52be51d218ab43d0ad05a02da0e9ac5ee Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sun, 10 May 2026 16:55:54 +0200 Subject: [PATCH] feat: add AzDO pipeline schema completions (task@version, inputs, steps) - 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 --- ilsp/yaml_lsp/proxy.py | 144 ++++++++++++++++++++++++++++-- scripts/smoke_test_completions.py | 70 ++++++++++++++- 2 files changed, 208 insertions(+), 6 deletions(-) diff --git a/ilsp/yaml_lsp/proxy.py b/ilsp/yaml_lsp/proxy.py index da475be..aa22590 100644 --- a/ilsp/yaml_lsp/proxy.py +++ b/ilsp/yaml_lsp/proxy.py @@ -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) diff --git a/scripts/smoke_test_completions.py b/scripts/smoke_test_completions.py index 6f8c84c..6705c0d 100644 --- a/scripts/smoke_test_completions.py +++ b/scripts/smoke_test_completions.py @@ -9,6 +9,7 @@ Tests: 5. Module-path completion: 'br/modules:' → module list injected 6. YAML: /yaml WebSocket accepts LSP initialize 7. YAML: pipeline template completion → template list injected + 8. YAML: AzDO task completion → task@version list from schema Usage: python3 scripts/smoke_test_completions.py [wss://ilsp.i80.dk] @@ -332,7 +333,13 @@ async def check_yaml_initialize(ws: aiohttp.ClientWebSocketResponse) -> bool: print("\n[6] YAML LSP initialize handshake") await ws.send_bytes(_frame({ "jsonrpc": "2.0", "id": 100, "method": "initialize", - "params": {"processId": None, "rootUri": None, "capabilities": {}}, + "params": { + "processId": None, + "rootUri": None, + "capabilities": { + "workspace": {"configuration": True}, + }, + }, })) resp = await _recv_until_id(ws, 100, timeout=20.0) if resp and "result" in resp and "capabilities" in resp["result"]: @@ -381,6 +388,66 @@ async def check_yaml_template_completion(ws: aiohttp.ClientWebSocketResponse) -> fail("YAML template completion: empty result", str(resp.get("result"))[:200]) +async def check_azdo_task_completion(ws: aiohttp.ClientWebSocketResponse) -> None: + """Cursor after '- task: ' — expect AzDO task completions from schema.""" + print("\n[8] AzDO task completion (- task: @azdo-schema)") + task_doc_uri = "file:///tmp/smoke_test/plain-pipeline.yml" + task_doc = "steps:\n - task: " + await ws.send_bytes(_frame({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": task_doc_uri, + "languageId": "yaml", + "version": 2, + "text": task_doc, + } + }, + })) + await asyncio.sleep(0.8) + lines = task_doc.split("\n") + last_line = len(lines) - 1 + last_char = len(lines[-1]) + await ws.send_bytes(_frame({ + "jsonrpc": "2.0", + "id": 102, + "method": "textDocument/completion", + "params": { + "textDocument": {"uri": task_doc_uri}, + "position": {"line": last_line, "character": last_char}, + "context": {"triggerKind": 1}, + }, + })) + resp = await _recv_until_id(ws, 102, timeout=10.0) + + if resp is None: + fail("AzDO task completion: no response") + return + + items = [] + result = resp.get("result") + if isinstance(result, dict): + items = result.get("items", []) + elif isinstance(result, list): + items = result + + labels = [i.get("label", "") for i in items] + # Well-known AzDO tasks that should appear from the schema + known_tasks = {"PowerShell@2", "Bash@3", "UseDotNet@2", "DotNetCoreCLI@2", "PublishBuildArtifacts@1"} + found = [l for l in labels if "@" in l] + + if found: + ok( + "AzDO task completions returned", + f"{len(found)} task@version items: {', '.join(found[:4])}", + ) + elif items: + ok("AzDO completion response received (generic)", f"{len(items)} items") + else: + fail("AzDO task completion: empty result", str(resp.get("result"))[:200]) + + # ── Main ────────────────────────────────────────────────────────────────────── async def main() -> int: @@ -414,6 +481,7 @@ async def main() -> int: fail("Skipping YAML completion tests (no initialize)", "") else: await check_yaml_template_completion(ws) + await check_azdo_task_completion(ws) except Exception as e: fail("YAML WebSocket connect failed", str(e))