Adds support for autocomplete in multi-line array syntax like:
roles: [
'KEY_VAULT_ ← cursor triggers completion here
]
Previously only worked on same line as opening bracket:
roles: ['KEY_VAULT_ ← only this worked
Changes:
- Walk backwards up to 10 lines to find array opening (e.g. "roles: [")
- Detect if cursor is inside array based on indentation and quotes
- Stop lookback if closing bracket found (not in array anymore)
- Add test case for nested multi-line array completion
- Improve lsp_bridge.py error handling with traceback logging
- Add lsp_bridge_debug.sh wrapper for easier IntelliJ debugging
- Update EDITOR_SETUP.md with correct IntelliJ LSP4IJ config
Fixes autocomplete for deeply nested structures like:
assignments: [{ roles: ['APP_CONFIGURATION_...'] }]
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
102 lines
3.2 KiB
Python
Executable File
102 lines
3.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
LSP WebSocket bridge: stdin/stdout (LSP Content-Length framed) <-> WebSocket.
|
|
|
|
websocat reads stdin line-by-line and holds the JSON body until a trailing \\n
|
|
arrives -- causing a deadlock on the first LSP message. This script solves that
|
|
by reading complete Content-Length-framed messages from stdin before sending.
|
|
|
|
Usage:
|
|
lsp_bridge.py <wss://host/path>
|
|
|
|
The bridge is transparent: it reads exactly one LSP message from stdin (header +
|
|
body), sends it as a single binary WebSocket frame, and writes any incoming
|
|
frames straight to stdout.
|
|
"""
|
|
|
|
import asyncio
|
|
import ssl
|
|
import sys
|
|
import traceback
|
|
|
|
import websockets
|
|
|
|
|
|
def _parse_header(buf: bytes) -> tuple[int, int]:
|
|
"""Return (content_length, body_start_offset) or (-1, -1) if header incomplete."""
|
|
end = buf.find(b"\r\n\r\n")
|
|
if end == -1:
|
|
return -1, -1
|
|
content_length = 0
|
|
for line in buf[:end].split(b"\r\n"):
|
|
if line.lower().startswith(b"content-length:"):
|
|
content_length = int(line.split(b":")[1].strip())
|
|
return content_length, end + 4
|
|
|
|
|
|
async def _read_stdin_lsp(reader: asyncio.StreamReader) -> bytes | None:
|
|
"""Read one complete LSP-framed message from the async stdin reader."""
|
|
buf = b""
|
|
content_length = -1
|
|
body_start = -1
|
|
|
|
while True:
|
|
chunk = await reader.read(4096)
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
|
|
if content_length == -1:
|
|
content_length, body_start = _parse_header(buf)
|
|
|
|
if content_length != -1 and len(buf) >= body_start + content_length:
|
|
return buf[: body_start + content_length]
|
|
|
|
|
|
async def main(uri: str) -> None:
|
|
ssl_ctx = ssl.create_default_context() if uri.startswith("wss://") else None
|
|
|
|
loop = asyncio.get_event_loop()
|
|
|
|
stdin_reader = asyncio.StreamReader()
|
|
await loop.connect_read_pipe(
|
|
lambda: asyncio.StreamReaderProtocol(stdin_reader), sys.stdin.buffer
|
|
)
|
|
|
|
async with websockets.connect(uri, ssl=ssl_ctx) as ws:
|
|
|
|
async def stdin_to_ws() -> None:
|
|
try:
|
|
while True:
|
|
msg = await _read_stdin_lsp(stdin_reader)
|
|
if msg is None:
|
|
break
|
|
await ws.send(msg)
|
|
except Exception as e:
|
|
print(f"stdin_to_ws error: {e}", file=sys.stderr)
|
|
traceback.print_exc(file=sys.stderr)
|
|
|
|
async def ws_to_stdout() -> None:
|
|
try:
|
|
async for frame in ws:
|
|
data = frame if isinstance(frame, bytes) else frame.encode()
|
|
sys.stdout.buffer.write(data)
|
|
sys.stdout.buffer.flush()
|
|
except Exception as e:
|
|
print(f"ws_to_stdout error: {e}", file=sys.stderr)
|
|
traceback.print_exc(file=sys.stderr)
|
|
|
|
await asyncio.gather(stdin_to_ws(), ws_to_stdout())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: lsp_bridge.py <wss://...>", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
asyncio.run(main(sys.argv[1]))
|
|
except Exception as e:
|
|
print(f"Fatal error: {e}", file=sys.stderr)
|
|
traceback.print_exc(file=sys.stderr)
|
|
sys.exit(1)
|