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
This commit is contained in:
@@ -17,6 +17,7 @@ Context detection:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -28,10 +29,121 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_CHUNK = 65536
|
_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 ────────────────────────────────────────────────────────────────
|
# ── 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:
|
class _LspFrameBuffer:
|
||||||
"""Reassembles LSP Content-Length framed messages from a stream of bytes."""
|
"""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()
|
raw = msg.data if msg.type == WSMsgType.BINARY else msg.data.encode()
|
||||||
frames = ws_buf.feed(raw)
|
frames = ws_buf.feed(raw)
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
|
_parsed: Any = None
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(frame)
|
_parsed = json.loads(frame)
|
||||||
method = parsed.get("method", "")
|
method = _parsed.get("method", "")
|
||||||
if method in ("textDocument/didOpen", "textDocument/didChange"):
|
if method == "initialize":
|
||||||
params = parsed.get("params", {})
|
# 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", "")
|
uri = params.get("textDocument", {}).get("uri", "")
|
||||||
text = params.get("textDocument", {}).get("text") or ""
|
text = params.get("textDocument", {}).get("text") or ""
|
||||||
if not text:
|
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:
|
if uri and text:
|
||||||
session.update_doc(uri, text)
|
session.update_doc(uri, text)
|
||||||
elif method == "textDocument/completion":
|
elif method == "textDocument/completion":
|
||||||
session.record_request(parsed)
|
session.record_request(_parsed)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
proc.stdin.write(_lsp_frame(frame))
|
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()
|
await proc.stdin.drain()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -362,6 +484,18 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int = 0) -> web.W
|
|||||||
try:
|
try:
|
||||||
parsed = json.loads(frame)
|
parsed = json.loads(frame)
|
||||||
msg_id = parsed.get("id")
|
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:
|
if msg_id is not None and "result" in parsed:
|
||||||
ctx = session.pop_context(msg_id)
|
ctx = session.pop_context(msg_id)
|
||||||
modified = _inject_completions(parsed, ctx)
|
modified = _inject_completions(parsed, ctx)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Tests:
|
|||||||
5. Module-path completion: 'br/modules:' → module list injected
|
5. Module-path completion: 'br/modules:' → module list injected
|
||||||
6. YAML: /yaml WebSocket accepts LSP initialize
|
6. YAML: /yaml WebSocket accepts LSP initialize
|
||||||
7. YAML: pipeline template completion → template list injected
|
7. YAML: pipeline template completion → template list injected
|
||||||
|
8. YAML: AzDO task completion → task@version list from schema
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 scripts/smoke_test_completions.py [wss://ilsp.i80.dk]
|
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")
|
print("\n[6] YAML LSP initialize handshake")
|
||||||
await ws.send_bytes(_frame({
|
await ws.send_bytes(_frame({
|
||||||
"jsonrpc": "2.0", "id": 100, "method": "initialize",
|
"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)
|
resp = await _recv_until_id(ws, 100, timeout=20.0)
|
||||||
if resp and "result" in resp and "capabilities" in resp["result"]:
|
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])
|
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: <cursor>@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 ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def main() -> int:
|
async def main() -> int:
|
||||||
@@ -414,6 +481,7 @@ async def main() -> int:
|
|||||||
fail("Skipping YAML completion tests (no initialize)", "")
|
fail("Skipping YAML completion tests (no initialize)", "")
|
||||||
else:
|
else:
|
||||||
await check_yaml_template_completion(ws)
|
await check_yaml_template_completion(ws)
|
||||||
|
await check_azdo_task_completion(ws)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fail("YAML WebSocket connect failed", str(e))
|
fail("YAML WebSocket connect failed", str(e))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user