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

@@ -0,0 +1,177 @@
"""
Local (non-forwarded) implementations of LSP features that Bicep.LangServer
(as of 0.46.1) does not implement itself: textDocument/signatureHelp,
textDocument/foldingRange, and workspace/symbol.
These are intentionally simple, regex/bracket-based implementations — good
enough for real editor usage, not a full Bicep parser. They run entirely in
the iLSP proxy and never touch the underlying Bicep LS process.
"""
from __future__ import annotations
import re
from typing import Any
from .bicep_functions import get_signature
_DECL_RE = re.compile(
r"^\s*(resource|module|var|param|output|func|type)\s+([A-Za-z_][\w]*)",
)
_SYMBOL_KIND = {
"resource": 6, # Method-ish; LSP doesn't have a great "resource" kind, use Variable-family
"module": 2, # Module
"var": 13, # Variable
"param": 13, # Variable
"output": 13, # Variable
"func": 12, # Function
"type": 5, # Class
}
# ── signatureHelp ───────────────────────────────────────────────────────────
def _find_enclosing_call(current: str) -> tuple[str, int] | None:
"""
Walk backwards from the cursor to find the nearest unmatched '(' and the
identifier immediately preceding it, returning (function_name, arg_index).
Returns None if the cursor isn't inside a recognizable function call.
"""
depth = 0
comma_count = 0
i = len(current) - 1
in_string = False
while i >= 0:
ch = current[i]
if ch == "'" and (i == 0 or current[i - 1] != "\\"):
in_string = not in_string
elif not in_string:
if ch in ")]}":
depth += 1
elif ch in "(":
if depth == 0:
# Found the opening paren for the innermost call
name_m = re.search(r"([A-Za-z_][\w]*)\s*$", current[:i])
if name_m:
return name_m.group(1), comma_count
return None
depth -= 1
elif ch in "[{":
depth -= 1
elif ch == "," and depth == 0:
comma_count += 1
i -= 1
return None
def compute_signature_help(lines: list[str], line: int, character: int) -> dict[str, Any] | None:
if line >= len(lines):
return None
current = lines[line][:character]
found = _find_enclosing_call(current)
if not found:
return None
func_name, arg_index = found
sig = get_signature(func_name)
if not sig:
return None
params = sig["params"]
parameters = [{"label": p} for p in params] if params else []
active_param = min(arg_index, max(len(parameters) - 1, 0)) if parameters else 0
label = f"{func_name}({', '.join(params)})"
return {
"signatures": [
{
"label": label,
"documentation": {"kind": "markdown", "value": sig["doc"]},
"parameters": parameters,
}
],
"activeSignature": 0,
"activeParameter": active_param,
}
# ── foldingRange ────────────────────────────────────────────────────────────
def compute_folding_ranges(lines: list[str]) -> list[dict[str, Any]]:
"""
Simple stack-based bracket matcher over {}, [], () — collapses any
multi-line bracket pair into a folding range. Also folds contiguous
leading '//' comment blocks.
"""
ranges: list[dict[str, Any]] = []
stack: list[tuple[str, int]] = []
in_string = False
for lineno, text in enumerate(lines):
i = 0
while i < len(text):
ch = text[i]
if ch == "'" and (i == 0 or text[i - 1] != "\\"):
in_string = not in_string
elif not in_string:
if ch in "{[(":
stack.append((ch, lineno))
elif ch in "}])":
if stack:
_open_ch, open_line = stack.pop()
if lineno > open_line:
ranges.append({"startLine": open_line, "endLine": lineno, "kind": "region"})
i += 1
# Fold contiguous line-comment blocks (3+ lines)
i = 0
while i < len(lines):
if lines[i].strip().startswith("//"):
start = i
while i < len(lines) and lines[i].strip().startswith("//"):
i += 1
if i - start >= 3:
ranges.append({"startLine": start, "endLine": i - 1, "kind": "comment"})
else:
i += 1
ranges.sort(key=lambda r: (r["startLine"], r["endLine"]))
return ranges
# ── workspace/symbol ────────────────────────────────────────────────────────
def compute_workspace_symbols(docs: dict[str, list[str]], query: str) -> list[dict[str, Any]]:
"""
Best-effort workspace symbol search over documents currently open in this
editor session (iLSP has no full-workspace index — only what the client
has opened). Matches top-level resource/module/var/param/output/func/type
declarations whose name contains the query (case-insensitive).
"""
query_l = query.lower()
results: list[dict[str, Any]] = []
for uri, lines in docs.items():
for lineno, text in enumerate(lines):
m = _DECL_RE.match(text)
if not m:
continue
kind_word, name = m.group(1), m.group(2)
if query_l and query_l not in name.lower():
continue
start_char = text.index(name, m.end(1))
results.append({
"name": name,
"kind": _SYMBOL_KIND.get(kind_word, 13),
"location": {
"uri": uri,
"range": {
"start": {"line": lineno, "character": start_char},
"end": {"line": lineno, "character": start_char + len(name)},
},
},
"containerName": kind_word,
})
return results