From d7db53a482fb2c40a1ca0e2dfde8d19e74b5e8ee Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Fri, 14 Aug 2026 17:54:34 +0200 Subject: [PATCH] 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. --- ilsp/bicep_lsp/bicep_functions.py | 249 ++++++++++++++++++++++++++++++ ilsp/bicep_lsp/local_features.py | 177 +++++++++++++++++++++ ilsp/bicep_lsp/proxy.py | 82 +++++++++- tests/test_local_features.py | 132 ++++++++++++++++ 4 files changed, 632 insertions(+), 8 deletions(-) create mode 100644 ilsp/bicep_lsp/bicep_functions.py create mode 100644 ilsp/bicep_lsp/local_features.py create mode 100644 tests/test_local_features.py diff --git a/ilsp/bicep_lsp/bicep_functions.py b/ilsp/bicep_lsp/bicep_functions.py new file mode 100644 index 0000000..9b1c2ea --- /dev/null +++ b/ilsp/bicep_lsp/bicep_functions.py @@ -0,0 +1,249 @@ +""" +Curated signature database for Bicep's built-in functions. + +Bicep.LangServer (as of 0.46.1) does not implement textDocument/signatureHelp, +so iLSP provides its own for the most commonly used built-in functions. +Not exhaustive — covers the functions people actually type day to day. +""" + +from typing import Any + +# name -> list of parameter labels (one entry per overload, simplest form first) +BUILTIN_FUNCTIONS: dict[str, dict[str, Any]] = { + "resourceId": { + "params": ["resourceType", "resourceName1", "resourceName2..."], + "doc": "Returns the unique identifier of a resource. Also usable with a subscriptionId/resourceGroupName prefix.", + }, + "subscriptionResourceId": { + "params": ["subscriptionId", "resourceType", "resourceName1", "resourceName2..."], + "doc": "Returns the unique identifier for a resource deployed at the subscription level.", + }, + "tenantResourceId": { + "params": ["resourceType", "resourceName1", "resourceName2..."], + "doc": "Returns the unique identifier for a resource deployed at the tenant level.", + }, + "extensionResourceId": { + "params": ["resourceId", "resourceType", "resourceName1", "resourceName2..."], + "doc": "Returns the resource ID for an extension resource, which is applied to another resource.", + }, + "guid": { + "params": ["baseString1", "baseString2..."], + "doc": "Creates a deterministic GUID based on the values passed as parameters.", + }, + "uniqueString": { + "params": ["baseString1", "baseString2..."], + "doc": "Creates a deterministic hash string based on the values passed as parameters (13 chars).", + }, + "take": { + "params": ["originalValue", "count"], + "doc": "Returns a string or array with the specified number of elements from the start.", + }, + "skip": { + "params": ["originalValue", "count"], + "doc": "Returns a string or array with all the elements after the specified number skipped.", + }, + "first": { + "params": ["arg1"], + "doc": "Returns the first element of an array, or first character of a string.", + }, + "last": { + "params": ["arg1"], + "doc": "Returns the last element of an array, or last character of a string.", + }, + "length": { + "params": ["arg1"], + "doc": "Returns the number of elements in an array, characters in a string, or properties in an object.", + }, + "concat": { + "params": ["arg1", "arg2..."], + "doc": "Combines multiple arrays or strings.", + }, + "union": { + "params": ["arg1", "arg2..."], + "doc": "Returns a single array/object with all elements from the parameters, merging objects.", + }, + "contains": { + "params": ["container", "itemToFind"], + "doc": "Checks whether an array contains a value, an object contains a key, or a string contains a substring.", + }, + "empty": { + "params": ["itemToTest"], + "doc": "Determines if an array, object, or string is empty.", + }, + "format": { + "params": ["formatString", "arg1..."], + "doc": "Creates a formatted string using .NET-style {0} placeholders.", + }, + "join": { + "params": ["inputArray", "delimiter"], + "doc": "Joins multiple strings from an array into a single string, separated by delimiter.", + }, + "split": { + "params": ["inputString", "delimiter"], + "doc": "Splits a string into an array using a delimiter.", + }, + "replace": { + "params": ["originalString", "oldString", "newString"], + "doc": "Returns a new string with all instances of oldString replaced by newString.", + }, + "toLower": { + "params": ["stringToChange"], + "doc": "Converts a string to lowercase.", + }, + "toUpper": { + "params": ["stringToChange"], + "doc": "Converts a string to uppercase.", + }, + "trim": { + "params": ["stringToTrim"], + "doc": "Removes leading/trailing whitespace from a string.", + }, + "substring": { + "params": ["stringToParse", "startIndex", "length"], + "doc": "Returns a substring starting at the specified position.", + }, + "padLeft": { + "params": ["valueToPad", "totalLength", "paddingCharacter"], + "doc": "Pads a string to a fixed total length by adding characters to the left.", + }, + "startsWith": { + "params": ["stringToSearch", "stringToFind"], + "doc": "Checks whether a string starts with a value (case-insensitive).", + }, + "endsWith": { + "params": ["stringToSearch", "stringToFind"], + "doc": "Checks whether a string ends with a value (case-insensitive).", + }, + "indexOf": { + "params": ["stringToSearch", "stringToFind"], + "doc": "Returns the first position of a value within a string (case-insensitive).", + }, + "reference": { + "params": ["resourceNameOrIdentifier", "apiVersion", "'Full'"], + "doc": "Returns an object representing a resource's runtime state.", + }, + "listKeys": { + "params": ["resourceNameOrIdentifier", "apiVersion"], + "doc": "Returns the keys for a resource (e.g. storage account access keys).", + }, + "listSecrets": { + "params": ["resourceNameOrIdentifier", "apiVersion"], + "doc": "Returns the secrets for a resource.", + }, + "resourceGroup": { + "params": [], + "doc": "Returns an object representing the current resource group.", + }, + "subscription": { + "params": [], + "doc": "Returns an object representing the current subscription.", + }, + "deployment": { + "params": [], + "doc": "Returns an object representing the current deployment operation.", + }, + "environment": { + "params": [], + "doc": "Returns an object describing the Azure cloud environment.", + }, + "az": { + "params": [], + "doc": "Namespace for Azure Bicep built-in functions.", + }, + "json": { + "params": ["jsonString"], + "doc": "Converts a valid JSON string into a JSON object / array.", + }, + "string": { + "params": ["valueToConvert"], + "doc": "Converts the specified value to a string.", + }, + "int": { + "params": ["valueToConvert"], + "doc": "Converts the specified value to an integer.", + }, + "bool": { + "params": ["value"], + "doc": "Converts the specified value to a boolean.", + }, + "array": { + "params": ["valueToConvert"], + "doc": "Converts the specified value to an array.", + }, + "base64": { + "params": ["inputString"], + "doc": "Returns the base64 representation of the input string.", + }, + "base64ToString": { + "params": ["base64Value"], + "doc": "Converts a base64 representation to a string.", + }, + "dateTimeAdd": { + "params": ["base", "duration", "format"], + "doc": "Adds a time duration to a base UTC datetime value.", + }, + "utcNow": { + "params": ["format"], + "doc": "Returns the current UTC datetime in the specified format.", + }, + "loadTextContent": { + "params": ["filePath", "encoding"], + "doc": "Loads the content of the specified file as a string at compile time.", + }, + "loadJsonContent": { + "params": ["filePath", "jsonPath", "encoding"], + "doc": "Loads the content of the specified JSON file at compile time.", + }, + "loadFileAsBase64": { + "params": ["filePath"], + "doc": "Loads the specified file as a base64 string at compile time.", + }, + "managementGroup": { + "params": ["name"], + "doc": "Returns an object representing the current or specified management group.", + }, + "newGuid": { + "params": [], + "doc": "Returns a new (random, non-deterministic) GUID. Only valid for param defaults.", + }, + "filter": { + "params": ["array", "lambdaExpression"], + "doc": "Filters an array using a custom filtering lambda expression.", + }, + "map": { + "params": ["array", "lambdaExpression"], + "doc": "Applies a custom mapping lambda expression to each element of an array.", + }, + "reduce": { + "params": ["array", "initialValue", "lambdaExpression"], + "doc": "Reduces an array with a custom lambda expression.", + }, + "sort": { + "params": ["array", "lambdaExpression"], + "doc": "Sorts an array with a custom lambda expression comparator.", + }, + "min": { + "params": ["arg1", "arg2..."], + "doc": "Returns the minimum value from an array of integers, or a comma-separated list.", + }, + "max": { + "params": ["arg1", "arg2..."], + "doc": "Returns the maximum value from an array of integers, or a comma-separated list.", + }, + "range": { + "params": ["startIndex", "count"], + "doc": "Creates an array of integers from startIndex to startIndex+count-1.", + }, + "items": { + "params": ["object"], + "doc": "Returns an array of key/value pair objects that represent the properties of an object.", + }, + "pickZones": { + "params": ["providerNamespace", "resourceType", "location", "numberOfZones", "offset"], + "doc": "Determines which availability zones are recommended for a resource type/location.", + }, +} + + +def get_signature(name: str) -> dict[str, Any] | None: + return BUILTIN_FUNCTIONS.get(name) diff --git a/ilsp/bicep_lsp/local_features.py b/ilsp/bicep_lsp/local_features.py new file mode 100644 index 0000000..f3b85dd --- /dev/null +++ b/ilsp/bicep_lsp/local_features.py @@ -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 diff --git a/ilsp/bicep_lsp/proxy.py b/ilsp/bicep_lsp/proxy.py index 04555e8..5793732 100644 --- a/ilsp/bicep_lsp/proxy.py +++ b/ilsp/bicep_lsp/proxy.py @@ -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() diff --git a/tests/test_local_features.py b/tests/test_local_features.py new file mode 100644 index 0000000..de7b426 --- /dev/null +++ b/tests/test_local_features.py @@ -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"}