Compare commits
5 Commits
4e957133d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ff4055a044 | |||
| d7db53a482 | |||
| dcdf22dac3 | |||
| 066033bd50 | |||
| 71738d856e |
@@ -75,13 +75,20 @@ tail -f /tmp/lsp_bridge_debug.log
|
|||||||
|
|
||||||
## Neovim setup
|
## 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
|
```lua
|
||||||
-- In your LSP config (e.g. ~/.config/nvim/lua/lsp.lua)
|
-- In your LSP config (e.g. ~/.config/nvim/lua/lsp.lua)
|
||||||
|
local BRIDGE = "/Users/lrihni/Projects/iLSP/scripts/lsp_bridge_debug.sh"
|
||||||
|
|
||||||
-- Bicep
|
-- Bicep
|
||||||
vim.lsp.start({
|
vim.lsp.start({
|
||||||
name = "ilsp-bicep",
|
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]),
|
root_dir = vim.fs.dirname(vim.fs.find({ "bicepconfig.json", ".git" }, { upward = true })[1]),
|
||||||
filetypes = { "bicep" },
|
filetypes = { "bicep" },
|
||||||
})
|
})
|
||||||
@@ -89,7 +96,7 @@ vim.lsp.start({
|
|||||||
-- YAML (pipeline files)
|
-- YAML (pipeline files)
|
||||||
vim.lsp.start({
|
vim.lsp.start({
|
||||||
name = "ilsp-yaml",
|
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]),
|
root_dir = vim.fs.dirname(vim.fs.find({ ".git" }, { upward = true })[1]),
|
||||||
filetypes = { "yaml" },
|
filetypes = { "yaml" },
|
||||||
})
|
})
|
||||||
@@ -97,7 +104,7 @@ vim.lsp.start({
|
|||||||
-- Python
|
-- Python
|
||||||
vim.lsp.start({
|
vim.lsp.start({
|
||||||
name = "ilsp-python",
|
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]),
|
root_dir = vim.fs.dirname(vim.fs.find({ "pyproject.toml", ".git" }, { upward = true })[1]),
|
||||||
filetypes = { "python" },
|
filetypes = { "python" },
|
||||||
})
|
})
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -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.
|
→ 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
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
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__)
|
||||||
@@ -187,8 +192,12 @@ class _ProxySession:
|
|||||||
|
|
||||||
return {"type": "unknown"}
|
return {"type": "unknown"}
|
||||||
|
|
||||||
def pop_context(self, msg_id) -> dict:
|
def pop_context(self, msg_id) -> dict | None:
|
||||||
return self.pending.pop(msg_id, {"type": "unknown"})
|
"""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 ───────────────────────────────────────────────────────
|
# ── Completion injection ───────────────────────────────────────────────────────
|
||||||
@@ -261,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:
|
||||||
@@ -272,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", "")
|
||||||
@@ -287,13 +315,37 @@ 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
|
||||||
|
|
||||||
framed = _frame(body)
|
if forward:
|
||||||
proc_stdin.write(framed)
|
framed = _frame(body)
|
||||||
proc_stdin.flush()
|
proc_stdin.write(framed)
|
||||||
logger.debug("Client→LS: flushed")
|
proc_stdin.flush()
|
||||||
|
logger.debug("Client→LS: flushed")
|
||||||
except EOFError:
|
except EOFError:
|
||||||
logger.debug("Client write side closed — signalling EOF to LS")
|
logger.debug("Client write side closed — signalling EOF to LS")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -316,10 +368,26 @@ def _ls_to_client(
|
|||||||
logger.debug("LS→Client: %d bytes", len(body))
|
logger.debug("LS→Client: %d bytes", len(body))
|
||||||
try:
|
try:
|
||||||
msg = json.loads(body)
|
msg = json.loads(body)
|
||||||
context: dict = {}
|
context = None
|
||||||
if "id" in msg and "result" in msg:
|
if "id" in msg and "result" in msg:
|
||||||
context = session.pop_context(msg["id"])
|
context = session.pop_context(msg["id"])
|
||||||
out = _inject_completions(msg, context)
|
# 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:
|
except json.JSONDecodeError:
|
||||||
out = body
|
out = body
|
||||||
conn.sendall(_frame(out))
|
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
|
# 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()
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ ilsp = "ilsp.server:main"
|
|||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["ilsp*"]
|
include = ["ilsp*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"ilsp.bicep_lsp" = ["*.json"]
|
||||||
|
"ilsp.yaml_lsp" = ["*.json"]
|
||||||
|
"ilsp.python_lsp" = ["*.json"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
|||||||
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"}
|
||||||
@@ -502,6 +502,7 @@ def test_session_records_and_pops_context():
|
|||||||
assert ctx["type"] == "param"
|
assert ctx["type"] == "param"
|
||||||
assert ctx["module"] == "appservice"
|
assert ctx["module"] == "appservice"
|
||||||
|
|
||||||
# Second pop returns unknown
|
# Second pop (or any untracked id) returns None — the response must pass
|
||||||
assert session.pop_context(42)["type"] == "unknown"
|
# through untouched rather than being mistaken for a completion response.
|
||||||
|
assert session.pop_context(42) is None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user