feat(bicep): implement signatureHelp, foldingRange, workspace/symbol locally
All checks were successful
Build and Deploy iLSP / test (push) Successful in 22s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m26s

Bicep.LangServer 0.46.1 (latest release) does not implement these three LSP
features, so iLSP now answers them itself directly in the proxy instead of
forwarding to the underlying LS:

- textDocument/signatureHelp: curated database of ~50 commonly used Bicep
  built-in functions (resourceId, guid, concat, take, format, ...) with a
  simple bracket-depth call-context detector (ilsp/bicep_lsp/bicep_functions.py,
  ilsp/bicep_lsp/local_features.py:compute_signature_help).
- textDocument/foldingRange: stack-based bracket matcher over {}/[]/() plus
  contiguous // comment-block folding (compute_folding_ranges).
- workspace/symbol: best-effort search over documents currently open in the
  editor session (iLSP has no full-workspace index, only what's been opened),
  matching top-level resource/module/var/param/output/func/type declarations
  (compute_workspace_symbols).

The proxy now advertises these in the initialize response capabilities
(signatureHelpProvider, foldingRangeProvider, workspaceSymbolProvider) so
editors actually send the requests, and intercepts them in _client_to_ls
before they'd otherwise be silently dropped/errored by the real Bicep LS.
A send lock (_LockedConn) prevents interleaved frames now that both
_client_to_ls and _ls_to_client can write responses to the client socket.

13 new unit tests in tests/test_local_features.py. Closes the last 3 gaps
found in the full LSP capability audit.
This commit is contained in:
2026-08-14 17:54:34 +02:00
parent dcdf22dac3
commit d7db53a482
4 changed files with 632 additions and 8 deletions

View File

@@ -27,6 +27,11 @@ import subprocess
import threading
from typing import Any
from .local_features import (
compute_folding_ranges,
compute_signature_help,
compute_workspace_symbols,
)
from .modules import BicepModuleCatalog
logger = logging.getLogger(__name__)
@@ -265,8 +270,26 @@ def _inject_completions(msg: dict[str, Any], context: dict | None = None) -> byt
return json.dumps(msg).encode()
def _local_response(msg_id, result: Any) -> bytes:
return json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": result}).encode()
class _LockedConn:
"""Wraps a socket so concurrent writers (locally-answered responses vs.
forwarded LS responses) never interleave partial frames on the wire."""
def __init__(self, conn: socket.socket, lock: threading.Lock) -> None:
self._conn = conn
self._lock = lock
def sendall(self, data: bytes) -> None:
with self._lock:
self._conn.sendall(data)
def _client_to_ls(
conn_file,
conn: socket.socket,
proc_stdin,
session: _ProxySession,
) -> None:
@@ -276,6 +299,7 @@ def _client_to_ls(
logger.debug("Client→LS: %d bytes", len(body))
# Track document state and completion context (never block forwarding)
forward = True
try:
msg = json.loads(body)
method = msg.get("method", "")
@@ -291,13 +315,37 @@ def _client_to_ls(
session.update_doc(uri, changes[-1].get("text", ""))
elif method == "textDocument/completion":
session.record_completion_request(msg)
elif method == "textDocument/signatureHelp":
# Bicep.LangServer doesn't implement this — answer locally.
forward = False
params = msg.get("params", {})
uri = params.get("textDocument", {}).get("uri", "")
pos = params.get("position", {})
lines = session.docs.get(uri, [])
result = compute_signature_help(lines, pos.get("line", 0), pos.get("character", 0))
conn.sendall(_frame(_local_response(msg.get("id"), result)))
elif method == "textDocument/foldingRange":
# Bicep.LangServer doesn't implement this — answer locally.
forward = False
uri = msg.get("params", {}).get("textDocument", {}).get("uri", "")
lines = session.docs.get(uri, [])
result = compute_folding_ranges(lines)
conn.sendall(_frame(_local_response(msg.get("id"), result)))
elif method == "workspace/symbol":
# Bicep.LangServer doesn't implement this — answer locally,
# scoped to documents open in this editor session.
forward = False
query = msg.get("params", {}).get("query", "")
result = compute_workspace_symbols(session.docs, query)
conn.sendall(_frame(_local_response(msg.get("id"), result)))
except Exception:
pass # parsing errors must never block forwarding
forward = True # parsing/handling errors must never block forwarding
framed = _frame(body)
proc_stdin.write(framed)
proc_stdin.flush()
logger.debug("Client→LS: flushed")
if forward:
framed = _frame(body)
proc_stdin.write(framed)
proc_stdin.flush()
logger.debug("Client→LS: flushed")
except EOFError:
logger.debug("Client write side closed — signalling EOF to LS")
except Exception as exc:
@@ -327,7 +375,19 @@ def _ls_to_client(
# textDocument/completion request we tracked — any other
# response (hover, definition, references, documentSymbol,
# formatting, codeAction, ...) must pass through untouched.
out = _inject_completions(msg, context) if context is not None else body
if context is not None:
out = _inject_completions(msg, context)
elif msg.get("method") is None and isinstance(msg.get("result"), dict) and "capabilities" in msg.get("result", {}):
# This is the initialize response — advertise the extra
# capabilities iLSP answers locally (Bicep.LangServer
# itself doesn't implement these).
caps = msg["result"]["capabilities"]
caps["signatureHelpProvider"] = {"triggerCharacters": ["(", ","]}
caps["foldingRangeProvider"] = True
caps["workspaceSymbolProvider"] = True
out = json.dumps(msg).encode()
else:
out = body
except json.JSONDecodeError:
out = body
conn.sendall(_frame(out))
@@ -351,16 +411,22 @@ def _handle_client(conn: socket.socket, addr: tuple) -> None:
# Unbuffered read from the socket — critical for correct LSP framing
conn_file = conn.makefile("rb", buffering=0)
# t1 may write locally-answered responses (signatureHelp/foldingRange/
# workspace-symbol) to conn concurrently with t2's LS responses — a lock
# prevents interleaved/corrupted frames on the wire.
send_lock = threading.Lock()
locked_conn = _LockedConn(conn, send_lock)
# t1: client → LS (finishes when client closes write side)
t1 = threading.Thread(
target=_client_to_ls,
args=(conn_file, proc.stdin, session),
args=(conn_file, locked_conn, proc.stdin, session),
daemon=True,
)
# t2: LS → client (finishes when LS closes stdout)
t2 = threading.Thread(
target=_ls_to_client,
args=(proc.stdout, conn, session),
args=(proc.stdout, locked_conn, session),
daemon=True,
)
t1.start()