Files
iLSP/ilsp/bicep_lsp/local_features.py
Henrik Jess Nielsen d7db53a482
All checks were successful
Build and Deploy iLSP / test (push) Successful in 22s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m26s
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.
2026-08-14 17:54:34 +02:00

178 lines
6.2 KiB
Python

"""
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