feat(bicep): implement signatureHelp, foldingRange, workspace/symbol locally
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user