Skip to content

Commit 6e8d675

Browse files
committed
Add clangd integration file
1 parent 9e17ad4 commit 6e8d675

File tree

1 file changed

+225
-0
lines changed

1 file changed

+225
-0
lines changed

src/cwhy/clangd_lsp_integration.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import io
2+
import json
3+
import os
4+
import subprocess
5+
import urllib.parse
6+
from typing import Any, IO
7+
8+
import llm_utils
9+
10+
11+
def _to_lsp_request(id: int, method: str, params: dict[str, Any]) -> str:
12+
request: dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method}
13+
if params:
14+
request["params"] = params
15+
16+
content = json.dumps(request)
17+
header = f"Content-Length: {len(content)}\r\n\r\n"
18+
return header + content
19+
20+
21+
# Same as a request, but without an id.
22+
def _to_lsp_notification(method: str, params: dict[str, Any]) -> str:
23+
request: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
24+
if params:
25+
request["params"] = params
26+
27+
content = json.dumps(request)
28+
header = f"Content-Length: {len(content)}\r\n\r\n"
29+
return header + content
30+
31+
32+
def _parse_lsp_response(id: int, file: IO[str]) -> dict[str, Any]:
33+
# Ignore all messages until the response with the correct id is found.
34+
while True:
35+
header = {}
36+
while True:
37+
line = file.readline().strip()
38+
if not line:
39+
break
40+
key, value = line.split(":", 1)
41+
header[key.strip()] = value.strip()
42+
43+
content = file.read(int(header["Content-Length"]))
44+
response: dict[str, Any] = json.loads(content)
45+
if "id" in response and response["id"] == id:
46+
return response
47+
48+
49+
def _path_to_uri(path: str) -> str:
50+
return "file://" + os.path.abspath(path)
51+
52+
53+
def uri_to_path(uri: str) -> str:
54+
data = urllib.parse.urlparse(uri)
55+
56+
assert data.scheme == "file"
57+
assert not data.netloc
58+
assert not data.params
59+
assert not data.query
60+
assert not data.fragment
61+
62+
path = data.path
63+
if path.startswith(os.getcwd()):
64+
path = os.path.relpath(path, os.getcwd())
65+
return urllib.parse.unquote(path) # clangd seems to escape paths.
66+
67+
68+
def is_available(executable:str="clangd") -> bool:
69+
try:
70+
clangd = subprocess.run(
71+
[executable, "--version"],
72+
stdout=subprocess.DEVNULL,
73+
stderr=subprocess.DEVNULL,
74+
)
75+
return clangd.returncode == 0
76+
except FileNotFoundError:
77+
return False
78+
79+
80+
class clangd:
81+
def __init__(
82+
self,
83+
executable: str="clangd",
84+
working_directory: str=os.getcwd(),
85+
) -> None:
86+
self.id = 0
87+
self.process = subprocess.Popen(
88+
[executable],
89+
text=True,
90+
stdin=subprocess.PIPE,
91+
stdout=subprocess.PIPE,
92+
stderr=subprocess.DEVNULL,
93+
cwd=working_directory,
94+
)
95+
self.initialize()
96+
97+
def __del__(self) -> None:
98+
self.process.terminate()
99+
100+
def initialize(self) -> dict[str, Any]:
101+
self.id += 1
102+
request = _to_lsp_request(self.id, "initialize", {"processId": os.getpid()})
103+
assert self.process.stdin
104+
assert self.process.stdout
105+
self.process.stdin.write(request)
106+
self.process.stdin.flush()
107+
return _parse_lsp_response(self.id, self.process.stdout)
108+
# TODO: Assert there is no error.
109+
110+
def didOpen(self, filename: str, languageId: str) -> None:
111+
with open(filename, "r") as file:
112+
text = file.read()
113+
114+
notification = _to_lsp_notification(
115+
"textDocument/didOpen",
116+
{
117+
"textDocument": {
118+
"uri": _path_to_uri(filename),
119+
"languageId": languageId,
120+
"version": 1,
121+
"text": text,
122+
}
123+
},
124+
)
125+
assert self.process.stdin
126+
self.process.stdin.write(notification)
127+
self.process.stdin.flush()
128+
129+
def didClose(self, filename: str) -> None:
130+
notification = _to_lsp_notification(
131+
"textDocument/didClose", {"textDocument": {"uri": _path_to_uri(filename)}}
132+
)
133+
assert self.process.stdin
134+
self.process.stdin.write(notification)
135+
self.process.stdin.flush()
136+
137+
def definition(self, filename: str, line: int, character: int) -> dict[str, Any]:
138+
self.id += 1
139+
request = _to_lsp_request(
140+
self.id,
141+
"textDocument/definition",
142+
{
143+
"textDocument": {"uri": _path_to_uri(filename)},
144+
"position": {
145+
# Things are 0-indexed in LSP.
146+
"line": line - 1,
147+
"character": character - 1,
148+
},
149+
},
150+
)
151+
assert self.process.stdin
152+
assert self.process.stdout
153+
self.process.stdin.write(request)
154+
self.process.stdin.flush()
155+
return _parse_lsp_response(self.id, self.process.stdout)
156+
157+
158+
def native_definition(command: str) -> str:
159+
if not is_available():
160+
return "`clangd` was not found. The `definition` function will not be made available."
161+
last_space_index = command.rfind(" ")
162+
if last_space_index == -1:
163+
return "usage: definition <filename>:<lineno> <symbol>"
164+
filename_lineno = command[:last_space_index]
165+
symbol = command[last_space_index + 1 :]
166+
parts = filename_lineno.split(":")
167+
if len(parts) != 2:
168+
return "usage: definition <filename>:<lineno> <symbol>"
169+
filename, lineno = parts[0], int(parts[1])
170+
171+
try:
172+
with open(filename, "r") as file:
173+
lines = file.readlines()
174+
except FileNotFoundError:
175+
return f"file '{filename}' not found."
176+
177+
if lineno - 1 >= len(lines):
178+
return "symbol not found at that location."
179+
180+
# We just return the first match here. Maybe we should find all definitions.
181+
character = lines[lineno - 1].find(symbol)
182+
183+
# Now, some heuristics to make up for GPT's terrible math skills.
184+
if character == -1:
185+
symbol = symbol.lstrip("*")
186+
character = lines[lineno - 1].find(symbol)
187+
188+
if character == -1:
189+
symbol = symbol.split("::")[-1]
190+
character = lines[lineno - 1].find(symbol)
191+
192+
# Check five lines above and below.
193+
if character == -1:
194+
for i in range(-5, 6, 1):
195+
if lineno - 1 + i < 0 or lineno - 1 + i >= len(lines):
196+
continue
197+
character = lines[lineno - 1 + i].find(symbol)
198+
if character != -1:
199+
lineno += i
200+
break
201+
202+
if character == -1:
203+
return "symbol not found at that location."
204+
205+
assert is_available()
206+
_clangd = clangd()
207+
208+
_clangd.didOpen(filename, "c" if filename.endswith(".c") else "cpp")
209+
definition = _clangd.definition(filename, lineno, character + 1)
210+
_clangd.didClose(filename)
211+
212+
if "result" not in definition or not definition["result"]:
213+
return "No definition found."
214+
215+
path = uri_to_path(definition["result"][0]["uri"])
216+
start_lineno = definition["result"][0]["range"]["start"]["line"] + 1
217+
end_lineno = definition["result"][0]["range"]["end"]["line"] + 1
218+
lines, first = llm_utils.read_lines(path, start_lineno - 5, end_lineno + 5)
219+
content = llm_utils.number_group_of_lines(lines, first)
220+
line_string = (
221+
f"line {start_lineno}"
222+
if start_lineno == end_lineno
223+
else f"lines {start_lineno}-{end_lineno}"
224+
)
225+
return f"""File '{path}' at {line_string}:\n```\n{content}\n```"""

0 commit comments

Comments
 (0)