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,132 @@
import json
from ilsp.bicep_lsp.local_features import (
compute_folding_ranges,
compute_signature_help,
compute_workspace_symbols,
)
# ── signatureHelp ────────────────────────────────────────────────────────────
def test_signature_help_simple_call():
lines = ["var id = resourceId('Microsoft.Storage/storageAccounts', "]
result = compute_signature_help(lines, 0, len(lines[0]))
assert result is not None
sig = result["signatures"][0]
assert sig["label"].startswith("resourceId(")
assert result["activeParameter"] == 1
def test_signature_help_first_arg():
lines = ["var g = guid("]
result = compute_signature_help(lines, 0, len(lines[0]))
assert result is not None
assert result["activeParameter"] == 0
assert result["signatures"][0]["label"].startswith("guid(")
def test_signature_help_unknown_function_returns_none():
lines = ["var x = notARealFunction("]
result = compute_signature_help(lines, 0, len(lines[0]))
assert result is None
def test_signature_help_outside_call_returns_none():
lines = ["var x = 'hello world'"]
result = compute_signature_help(lines, 0, len(lines[0]))
assert result is None
def test_signature_help_nested_call_uses_innermost():
lines = ["var x = concat(toLower("]
result = compute_signature_help(lines, 0, len(lines[0]))
assert result is not None
assert result["signatures"][0]["label"].startswith("toLower(")
# ── foldingRange ─────────────────────────────────────────────────────────────
def test_folding_range_multiline_object():
lines = [
"resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {",
" name: 'demo'",
" sku: {",
" name: 'Standard_LRS'",
" }",
"}",
]
ranges = compute_folding_ranges(lines)
assert {"startLine": 0, "endLine": 5, "kind": "region"} in ranges
assert {"startLine": 2, "endLine": 4, "kind": "region"} in ranges
def test_folding_range_ignores_single_line_brackets():
lines = ["var x = { a: 1 }"]
ranges = compute_folding_ranges(lines)
assert ranges == []
def test_folding_range_comment_block():
lines = [
"// line one",
"// line two",
"// line three",
"var x = 1",
]
ranges = compute_folding_ranges(lines)
assert {"startLine": 0, "endLine": 2, "kind": "comment"} in ranges
def test_folding_range_short_comment_block_not_folded():
lines = ["// only one line", "var x = 1"]
ranges = compute_folding_ranges(lines)
assert not any(r["kind"] == "comment" for r in ranges)
# ── workspace/symbol ─────────────────────────────────────────────────────────
def test_workspace_symbol_finds_declarations():
docs = {
"file:///a.bicep": [
"param location string = 'westeurope'",
"var storageName = 'demo'",
"resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {",
" name: storageName",
"}",
"module roleAssignment 'br/modules:modules/roleassignments:2.0.x' = {",
" name: 'ra'",
"}",
],
}
results = compute_workspace_symbols(docs, "")
names = {r["name"] for r in results}
assert names == {"location", "storageName", "sa", "roleAssignment"}
def test_workspace_symbol_filters_by_query():
docs = {
"file:///a.bicep": [
"var storageName = 'demo'",
"var otherThing = 'x'",
],
}
results = compute_workspace_symbols(docs, "storage")
assert len(results) == 1
assert results[0]["name"] == "storageName"
def test_workspace_symbol_query_is_case_insensitive():
docs = {"file:///a.bicep": ["var StorageName = 'demo'"]}
results = compute_workspace_symbols(docs, "storagename")
assert len(results) == 1
def test_workspace_symbol_searches_across_multiple_documents():
docs = {
"file:///a.bicep": ["var fromA = 1"],
"file:///b.bicep": ["var fromB = 2"],
}
results = compute_workspace_symbols(docs, "")
names = {r["name"] for r in results}
assert names == {"fromA", "fromB"}