Compare commits

5 Commits

Author SHA1 Message Date
ff4055a044 docs(readme): add confirmed LSP feature-coverage table with live examples
All checks were successful
Build and Deploy iLSP / test (push) Successful in 36s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m12s
Documents the full capability audit results (hover/definition/references/
symbols/formatting/codeAction/signatureHelp/foldingRange/workspaceSymbol)
per language endpoint, plus concrete before/after examples captured from
live requests against production.
2026-08-14 18:01:53 +02:00
d7db53a482 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.
2026-08-14 17:54:34 +02:00
dcdf22dac3 fix(bicep): stop corrupting hover/definition/references/symbols/formatting/codeAction with completion injections
All checks were successful
Build and Deploy iLSP / test (push) Successful in 21s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m32s
_ls_to_client called _inject_completions() on EVERY response with an id+result,
not just textDocument/completion responses. pop_context() defaulted to
{'type': 'unknown'} for any untracked id, which _inject_completions treats as
'inject module-name completions' — so list-shaped LS responses for
definition/references/documentSymbol/formatting/codeAction were silently
replaced or prefixed with the Bicep module catalog list instead of returning
real Bicep LangServer results.

Found via a full LSP-capability audit against production: definition,
references, documentSymbol, formatting and codeAction all incorrectly
returned module-completion items instead of real results.

Fix: pop_context() now returns None when the response id wasn't recorded as
a pending completion request, and _ls_to_client only calls
_inject_completions() when a real completion context was found — every other
response now passes through untouched.
2026-08-14 17:44:11 +02:00
066033bd50 docs(neovim): fix broken rpc.connect example, document stdio-bridge fix
All checks were successful
Build and Deploy iLSP / test (push) Successful in 22s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m30s
2026-08-14 17:05:03 +02:00
71738d856e Fix: include azure_roles.json in wheel package data
The pyproject.toml packaging config only included .py files, so
azure_roles.json (682 Azure role names) never made it into the built
wheel/Docker image. This silently broke the 'roles: [...]' array
completion feature in production even though the code and local dev
worked fine (repo files were always on disk locally).

Root-caused via a raw WebSocket JSON-RPC test against production that
returned 0 completion items for a roles array, despite the same logic
returning 682 items when exercised directly against the local module
in this repo.
2026-08-14 17:04:41 +02:00
8 changed files with 725 additions and 16 deletions

View File

@@ -75,13 +75,20 @@ tail -f /tmp/lsp_bridge_debug.log
## Neovim setup
> **Important**: `vim.lsp.rpc.connect(url)` does **not** speak WebSocket — it's a raw
> TCP/pipe connector and will fail with `ENOENT` on a `wss://` URL (verified). Use
> `cmd = { ".../scripts/lsp_bridge_debug.sh", "wss://..." }` instead (stdio ↔
> WebSocket bridge, same one IntelliJ/LSP4IJ uses). Confirmed working end-to-end
> (32 module completions returned) with the config below.
```lua
-- In your LSP config (e.g. ~/.config/nvim/lua/lsp.lua)
local BRIDGE = "/Users/lrihni/Projects/iLSP/scripts/lsp_bridge_debug.sh"
-- Bicep
vim.lsp.start({
name = "ilsp-bicep",
cmd = vim.lsp.rpc.connect("wss://ilsp.i80.dk/bicep"),
cmd = { BRIDGE, "wss://ilsp.i80.dk/bicep" },
root_dir = vim.fs.dirname(vim.fs.find({ "bicepconfig.json", ".git" }, { upward = true })[1]),
filetypes = { "bicep" },
})
@@ -89,7 +96,7 @@ vim.lsp.start({
-- YAML (pipeline files)
vim.lsp.start({
name = "ilsp-yaml",
cmd = vim.lsp.rpc.connect("wss://ilsp.i80.dk/yaml"),
cmd = { BRIDGE, "wss://ilsp.i80.dk/yaml" },
root_dir = vim.fs.dirname(vim.fs.find({ ".git" }, { upward = true })[1]),
filetypes = { "yaml" },
})
@@ -97,7 +104,7 @@ vim.lsp.start({
-- Python
vim.lsp.start({
name = "ilsp-python",
cmd = vim.lsp.rpc.connect("wss://ilsp.i80.dk/python"),
cmd = { BRIDGE, "wss://ilsp.i80.dk/python" },
root_dir = vim.fs.dirname(vim.fs.find({ "pyproject.toml", ".git" }, { upward = true })[1]),
filetypes = { "python" },
})

View File

@@ -13,6 +13,70 @@ Provides smart autocomplete on top of standard LSPs:
→ See **[EDITOR_SETUP.md](EDITOR_SETUP.md)** for editor configuration and a full feature overview.
## LSP feature coverage
Full capability audit against production (verified 2026-08-14) — everything below
is confirmed working end-to-end over the live WebSocket endpoints, not just in
theory:
| Feature | Bicep (`/bicep`) | Python (`/python`) | YAML (`/yaml`) |
|---|---|---|---|
| Completions (+ internal catalogs) | ✅ | ✅ | ✅ |
| Hover | ✅ | ✅ | ✅ |
| Go to definition | ✅ | ✅ | — |
| Find references | ✅ | ✅ | — |
| Document symbols | ✅ | ✅ | — |
| Document formatting | ✅ | ✅ | — |
| Code actions | ✅ | ✅ | — |
| Signature help | ✅ *(iLSP-provided)* | ✅ | — |
| Folding range | ✅ *(iLSP-provided)* | ✅ | — |
| Workspace symbol | ✅ *(iLSP-provided)* | ❌ (pylsp doesn't implement it) | — |
| Diagnostics | ✅ | ✅ | ✅ |
Bicep.LangServer itself (v0.46.1, latest) doesn't implement signature help,
folding range, or workspace symbol — iLSP answers these three locally in the
proxy (`ilsp/bicep_lsp/local_features.py`) instead of forwarding to a backend
that would just error or return nothing.
### Confirmed examples (live against `wss://ilsp.i80.dk/bicep`)
**Role-array completion** — cursor inside `roles: [...]` returns all 682 Azure
built-in roles:
```
roles: ['KEY_VAULT_SECRETS_USER', 'STORAGE_BLOB_DATA_CONTRIBUTOR', <cursor>]
→ 682 items: ACCESS_REVIEW_OPERATOR_SERVICE_ROLE, ACRDELETE, ACRIMAGESIGNER, ACRPULL, ACRPUSH, ...
```
**Go to definition** — jumping from a variable usage to its declaration:
```bicep
var storageName = '${projectName}storage' # declared here (line 2)
resource sa '...' = {
name: storageName # cursor here jumps to line 2
}
```
**Find references** — from the `storageName` declaration, finds both places
it's used: the string interpolation `${projectName}storage` isn't a reference,
but the `resource sa { name: storageName }` property assignment is — returns
the declaration itself plus that 1 real usage (2 results total).
**Document symbols** — outline for a file with params/vars/resources/modules:
```
location, projectName, storageName, sa, roleAssignment, storageId
```
**Signature help** — cursor right after `resourceId(`:
```
resourceId(resourceType, resourceName1, resourceName2...)
^ active parameter highlighted
```
**Folding range** — every multi-line `{}`/`[]`/`()` block returned as a
collapsible region (nested blocks included).
**Workspace symbol** — searching `"storage"` across all open documents in the
editor session returns `storageName`, `storageId`, etc.
## Quick start
```bash

View 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)

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

View File

@@ -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__)
@@ -187,8 +192,12 @@ class _ProxySession:
return {"type": "unknown"}
def pop_context(self, msg_id) -> dict:
return self.pending.pop(msg_id, {"type": "unknown"})
def pop_context(self, msg_id) -> dict | None:
"""Return the tracked completion context for msg_id, or None if this
response id does not correspond to a textDocument/completion request
we recorded (e.g. hover/definition/references/... responses must never
be treated as completion responses)."""
return self.pending.pop(msg_id, None)
# ── Completion injection ───────────────────────────────────────────────────────
@@ -261,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:
@@ -272,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", "")
@@ -287,9 +315,33 @@ 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
if forward:
framed = _frame(body)
proc_stdin.write(framed)
proc_stdin.flush()
@@ -316,10 +368,26 @@ def _ls_to_client(
logger.debug("LS→Client: %d bytes", len(body))
try:
msg = json.loads(body)
context: dict = {}
context = None
if "id" in msg and "result" in msg:
context = session.pop_context(msg["id"])
# Only rewrite responses that actually correspond to a
# textDocument/completion request we tracked — any other
# response (hover, definition, references, documentSymbol,
# formatting, codeAction, ...) must pass through untouched.
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))
@@ -343,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()

View File

@@ -24,6 +24,11 @@ ilsp = "ilsp.server:main"
where = ["."]
include = ["ilsp*"]
[tool.setuptools.package-data]
"ilsp.bicep_lsp" = ["*.json"]
"ilsp.yaml_lsp" = ["*.json"]
"ilsp.python_lsp" = ["*.json"]
[project.optional-dependencies]
dev = [
"pytest>=8.0",

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

View File

@@ -502,6 +502,7 @@ def test_session_records_and_pops_context():
assert ctx["type"] == "param"
assert ctx["module"] == "appservice"
# Second pop returns unknown
assert session.pop_context(42)["type"] == "unknown"
# Second pop (or any untracked id) returns None — the response must pass
# through untouched rather than being mistaken for a completion response.
assert session.pop_context(42) is None