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:
249
ilsp/bicep_lsp/bicep_functions.py
Normal file
249
ilsp/bicep_lsp/bicep_functions.py
Normal file
@@ -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)
|
||||||
177
ilsp/bicep_lsp/local_features.py
Normal file
177
ilsp/bicep_lsp/local_features.py
Normal 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
|
||||||
@@ -27,6 +27,11 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .local_features import (
|
||||||
|
compute_folding_ranges,
|
||||||
|
compute_signature_help,
|
||||||
|
compute_workspace_symbols,
|
||||||
|
)
|
||||||
from .modules import BicepModuleCatalog
|
from .modules import BicepModuleCatalog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
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(
|
def _client_to_ls(
|
||||||
conn_file,
|
conn_file,
|
||||||
|
conn: socket.socket,
|
||||||
proc_stdin,
|
proc_stdin,
|
||||||
session: _ProxySession,
|
session: _ProxySession,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -276,6 +299,7 @@ def _client_to_ls(
|
|||||||
logger.debug("Client→LS: %d bytes", len(body))
|
logger.debug("Client→LS: %d bytes", len(body))
|
||||||
|
|
||||||
# Track document state and completion context (never block forwarding)
|
# Track document state and completion context (never block forwarding)
|
||||||
|
forward = True
|
||||||
try:
|
try:
|
||||||
msg = json.loads(body)
|
msg = json.loads(body)
|
||||||
method = msg.get("method", "")
|
method = msg.get("method", "")
|
||||||
@@ -291,9 +315,33 @@ def _client_to_ls(
|
|||||||
session.update_doc(uri, changes[-1].get("text", ""))
|
session.update_doc(uri, changes[-1].get("text", ""))
|
||||||
elif method == "textDocument/completion":
|
elif method == "textDocument/completion":
|
||||||
session.record_completion_request(msg)
|
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:
|
except Exception:
|
||||||
pass # parsing errors must never block forwarding
|
forward = True # parsing/handling errors must never block forwarding
|
||||||
|
|
||||||
|
if forward:
|
||||||
framed = _frame(body)
|
framed = _frame(body)
|
||||||
proc_stdin.write(framed)
|
proc_stdin.write(framed)
|
||||||
proc_stdin.flush()
|
proc_stdin.flush()
|
||||||
@@ -327,7 +375,19 @@ def _ls_to_client(
|
|||||||
# textDocument/completion request we tracked — any other
|
# textDocument/completion request we tracked — any other
|
||||||
# response (hover, definition, references, documentSymbol,
|
# response (hover, definition, references, documentSymbol,
|
||||||
# formatting, codeAction, ...) must pass through untouched.
|
# 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:
|
except json.JSONDecodeError:
|
||||||
out = body
|
out = body
|
||||||
conn.sendall(_frame(out))
|
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
|
# Unbuffered read from the socket — critical for correct LSP framing
|
||||||
conn_file = conn.makefile("rb", buffering=0)
|
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: client → LS (finishes when client closes write side)
|
||||||
t1 = threading.Thread(
|
t1 = threading.Thread(
|
||||||
target=_client_to_ls,
|
target=_client_to_ls,
|
||||||
args=(conn_file, proc.stdin, session),
|
args=(conn_file, locked_conn, proc.stdin, session),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
# t2: LS → client (finishes when LS closes stdout)
|
# t2: LS → client (finishes when LS closes stdout)
|
||||||
t2 = threading.Thread(
|
t2 = threading.Thread(
|
||||||
target=_ls_to_client,
|
target=_ls_to_client,
|
||||||
args=(proc.stdout, conn, session),
|
args=(proc.stdout, locked_conn, session),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
t1.start()
|
t1.start()
|
||||||
|
|||||||
132
tests/test_local_features.py
Normal file
132
tests/test_local_features.py
Normal 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"}
|
||||||
Reference in New Issue
Block a user