tunacode-cli 0.1.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
tunacode/lsp/client.py ADDED
@@ -0,0 +1,351 @@
1
+ """Minimal LSP client for diagnostic feedback.
2
+
3
+ Implements just enough of the Language Server Protocol to:
4
+ 1. Initialize a language server
5
+ 2. Open a document
6
+ 3. Receive publishDiagnostics notifications
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import json
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from tunacode.lsp.servers import get_language_id
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DEFAULT_TIMEOUT = 5.0
22
+
23
+
24
+ @dataclass
25
+ class Diagnostic:
26
+ """A single diagnostic from a language server."""
27
+
28
+ line: int
29
+ character: int
30
+ message: str
31
+ severity: str # "error", "warning", "info", "hint"
32
+ source: str | None = None
33
+
34
+
35
+ class LSPClient:
36
+ """Minimal LSP client for diagnostic feedback.
37
+
38
+ Communicates with language servers via JSON-RPC over stdin/stdout.
39
+ Uses a single reader task to avoid concurrent stream access.
40
+ """
41
+
42
+ def __init__(self, command: list[str], root: Path):
43
+ """Initialize the LSP client.
44
+
45
+ Args:
46
+ command: Server command to execute (e.g., ["pyright-langserver", "--stdio"])
47
+ root: Project root directory
48
+ """
49
+ self.command = command
50
+ self.root = root
51
+ self.process: asyncio.subprocess.Process | None = None
52
+ self._request_id = 0
53
+ self._diagnostics: dict[str, list[Diagnostic]] = {}
54
+ self._initialized = False
55
+ self._reader_task: asyncio.Task[None] | None = None
56
+ self._pending_requests: dict[int, asyncio.Future[dict[str, Any] | None]] = {}
57
+ self._shutdown_requested = False
58
+
59
+ async def start(self) -> bool:
60
+ """Start the language server process and initialize.
61
+
62
+ Returns:
63
+ True if server started successfully, False otherwise
64
+ """
65
+ try:
66
+ self.process = await asyncio.create_subprocess_exec(
67
+ *self.command,
68
+ stdin=asyncio.subprocess.PIPE,
69
+ stdout=asyncio.subprocess.PIPE,
70
+ stderr=asyncio.subprocess.DEVNULL,
71
+ cwd=self.root,
72
+ )
73
+ except (FileNotFoundError, PermissionError) as e:
74
+ logger.debug("Failed to start LSP server %s: %s", self.command[0], e)
75
+ return False
76
+
77
+ # Start the single reader task
78
+ self._reader_task = asyncio.create_task(self._read_messages())
79
+
80
+ try:
81
+ await asyncio.wait_for(self._initialize(), timeout=DEFAULT_TIMEOUT)
82
+ self._initialized = True
83
+ return True
84
+ except TimeoutError:
85
+ logger.debug("LSP server %s initialization timed out", self.command[0])
86
+ await self.shutdown()
87
+ return False
88
+ except Exception as e:
89
+ logger.debug("LSP server %s initialization failed: %s", self.command[0], e)
90
+ await self.shutdown()
91
+ return False
92
+
93
+ async def _initialize(self) -> None:
94
+ """Send initialize request and wait for response."""
95
+ response = await self._request(
96
+ "initialize",
97
+ {
98
+ "processId": None,
99
+ "rootUri": f"file://{self.root}",
100
+ "capabilities": {
101
+ "textDocument": {
102
+ "publishDiagnostics": {
103
+ "relatedInformation": False,
104
+ }
105
+ }
106
+ },
107
+ },
108
+ )
109
+
110
+ if response is None:
111
+ raise RuntimeError("No initialize response")
112
+
113
+ # Send initialized notification
114
+ await self._notify("initialized", {})
115
+
116
+ async def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any] | None:
117
+ """Send a JSON-RPC request and wait for response.
118
+
119
+ Args:
120
+ method: LSP method name
121
+ params: Method parameters
122
+
123
+ Returns:
124
+ Response result or None
125
+ """
126
+ self._request_id += 1
127
+ request_id = self._request_id
128
+
129
+ # Create a future to receive the response
130
+ loop = asyncio.get_event_loop()
131
+ future: asyncio.Future[dict[str, Any] | None] = loop.create_future()
132
+ self._pending_requests[request_id] = future
133
+
134
+ message = {
135
+ "jsonrpc": "2.0",
136
+ "id": request_id,
137
+ "method": method,
138
+ "params": params,
139
+ }
140
+
141
+ try:
142
+ await self._send(message)
143
+ return await asyncio.wait_for(future, timeout=DEFAULT_TIMEOUT)
144
+ except TimeoutError:
145
+ return None
146
+ finally:
147
+ self._pending_requests.pop(request_id, None)
148
+
149
+ async def _notify(self, method: str, params: dict[str, Any]) -> None:
150
+ """Send a JSON-RPC notification (no response expected).
151
+
152
+ Args:
153
+ method: LSP method name
154
+ params: Method parameters
155
+ """
156
+ message = {
157
+ "jsonrpc": "2.0",
158
+ "method": method,
159
+ "params": params,
160
+ }
161
+ await self._send(message)
162
+
163
+ async def _send(self, message: dict[str, Any]) -> None:
164
+ """Send a JSON-RPC message to the server.
165
+
166
+ Args:
167
+ message: Message dictionary to send
168
+ """
169
+ if self.process is None or self.process.stdin is None:
170
+ return
171
+
172
+ body = json.dumps(message, ensure_ascii=False)
173
+ body_bytes = body.encode("utf-8")
174
+ header = f"Content-Length: {len(body_bytes)}\r\n\r\n".encode("ascii")
175
+ data = header + body_bytes
176
+
177
+ self.process.stdin.write(data)
178
+ await self.process.stdin.drain()
179
+
180
+ async def _read_messages(self) -> None:
181
+ """Single reader task that processes all incoming messages."""
182
+ while not self._shutdown_requested:
183
+ if self.process is None or self.process.stdout is None:
184
+ break
185
+ if self.process.returncode is not None:
186
+ break
187
+
188
+ message = await self._receive_one()
189
+ if message is None:
190
+ continue
191
+
192
+ # Check if this is a response to a pending request
193
+ msg_id = message.get("id")
194
+ if msg_id is not None and msg_id in self._pending_requests:
195
+ future = self._pending_requests.get(msg_id)
196
+ if future and not future.done():
197
+ future.set_result(message.get("result"))
198
+ continue
199
+
200
+ # Handle notifications
201
+ method = message.get("method")
202
+ if method == "textDocument/publishDiagnostics":
203
+ self._handle_diagnostics(message.get("params", {}))
204
+
205
+ async def _receive_one(self) -> dict[str, Any] | None:
206
+ """Receive a single JSON-RPC message.
207
+
208
+ Returns:
209
+ Parsed message or None
210
+ """
211
+ if self.process is None or self.process.stdout is None:
212
+ return None
213
+
214
+ try:
215
+ # Read Content-Length header
216
+ header_line = await asyncio.wait_for(self.process.stdout.readline(), timeout=0.5)
217
+ if not header_line:
218
+ return None
219
+
220
+ header = header_line.decode("utf-8").strip()
221
+ if not header.startswith("Content-Length:"):
222
+ return None
223
+
224
+ content_length = int(header.split(":")[1].strip())
225
+
226
+ # Read blank line
227
+ await self.process.stdout.readline()
228
+
229
+ # Read body
230
+ body = await asyncio.wait_for(
231
+ self.process.stdout.readexactly(content_length), timeout=5.0
232
+ )
233
+ return json.loads(body.decode("utf-8"))
234
+
235
+ except (TimeoutError, asyncio.IncompleteReadError, json.JSONDecodeError):
236
+ return None
237
+
238
+ def _handle_diagnostics(self, params: dict[str, Any]) -> None:
239
+ """Handle publishDiagnostics notification.
240
+
241
+ Args:
242
+ params: Notification parameters containing uri and diagnostics
243
+ """
244
+ uri = params.get("uri", "")
245
+ raw_diagnostics = params.get("diagnostics", [])
246
+
247
+ severity_map = {1: "error", 2: "warning", 3: "info", 4: "hint"}
248
+
249
+ diagnostics: list[Diagnostic] = []
250
+ for raw in raw_diagnostics:
251
+ range_data = raw.get("range", {})
252
+ start = range_data.get("start", {})
253
+
254
+ diagnostics.append(
255
+ Diagnostic(
256
+ line=start.get("line", 0) + 1, # LSP is 0-indexed
257
+ character=start.get("character", 0),
258
+ message=raw.get("message", ""),
259
+ severity=severity_map.get(raw.get("severity", 1), "error"),
260
+ source=raw.get("source"),
261
+ )
262
+ )
263
+
264
+ self._diagnostics[uri] = diagnostics
265
+
266
+ async def open_file(self, path: Path) -> None:
267
+ """Open a file in the language server.
268
+
269
+ Args:
270
+ path: Path to the file to open
271
+ """
272
+ if not self._initialized:
273
+ return
274
+
275
+ content = path.read_text(encoding="utf-8")
276
+ language_id = get_language_id(path) or "plaintext"
277
+
278
+ await self._notify(
279
+ "textDocument/didOpen",
280
+ {
281
+ "textDocument": {
282
+ "uri": f"file://{path}",
283
+ "languageId": language_id,
284
+ "version": 1,
285
+ "text": content,
286
+ }
287
+ },
288
+ )
289
+
290
+ async def get_diagnostics(
291
+ self, path: Path, timeout: float = DEFAULT_TIMEOUT
292
+ ) -> list[Diagnostic]:
293
+ """Get diagnostics for a file.
294
+
295
+ Opens the file and waits for diagnostics to be published.
296
+
297
+ Args:
298
+ path: Path to the file
299
+ timeout: Maximum time to wait for diagnostics
300
+
301
+ Returns:
302
+ List of diagnostics
303
+ """
304
+ uri = f"file://{path}"
305
+
306
+ # Clear any existing diagnostics for this file
307
+ self._diagnostics.pop(uri, None)
308
+
309
+ # Open the file
310
+ await self.open_file(path)
311
+
312
+ # Wait for diagnostics
313
+ start = asyncio.get_event_loop().time()
314
+ while (asyncio.get_event_loop().time() - start) < timeout:
315
+ if uri in self._diagnostics:
316
+ return self._diagnostics[uri]
317
+ await asyncio.sleep(0.1)
318
+
319
+ return []
320
+
321
+ async def shutdown(self) -> None:
322
+ """Shutdown the language server."""
323
+ self._shutdown_requested = True
324
+
325
+ # Cancel any pending requests
326
+ for future in self._pending_requests.values():
327
+ if not future.done():
328
+ future.set_result(None)
329
+ self._pending_requests.clear()
330
+
331
+ if self._reader_task is not None:
332
+ self._reader_task.cancel()
333
+ with contextlib.suppress(asyncio.CancelledError):
334
+ await self._reader_task
335
+
336
+ if self.process is not None:
337
+ try:
338
+ await self._notify("shutdown", {})
339
+ await self._notify("exit", {})
340
+ except Exception:
341
+ pass
342
+
343
+ if self.process.returncode is None:
344
+ self.process.terminate()
345
+ try:
346
+ await asyncio.wait_for(self.process.wait(), timeout=2.0)
347
+ except TimeoutError:
348
+ self.process.kill()
349
+
350
+ self.process = None
351
+ self._initialized = False
@@ -0,0 +1,19 @@
1
+ """Utilities for formatting and truncating LSP diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAX_DIAGNOSTIC_MESSAGE_LENGTH = 80
6
+
7
+
8
+ def truncate_diagnostic_message(
9
+ message: str, max_length: int = MAX_DIAGNOSTIC_MESSAGE_LENGTH
10
+ ) -> str:
11
+ """Truncate verbose diagnostic messages to essential info.
12
+
13
+ Pyright often produces multi-line explanations. We keep only the first line
14
+ (the actionable part), and cap it to max_length with an ellipsis suffix.
15
+ """
16
+ first_line = message.split("\n")[0].strip()
17
+ if len(first_line) > max_length:
18
+ return first_line[: max_length - 3] + "..."
19
+ return first_line
@@ -0,0 +1,101 @@
1
+ """Language server command mapping.
2
+
3
+ Maps file extensions to their corresponding language server commands.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from shutil import which
8
+
9
+ # Extension to (language_id, server_commands) mapping
10
+ # server_commands is a list of possible commands to try in order
11
+ SERVER_CONFIG: dict[str, tuple[str, list[list[str]]]] = {
12
+ ".py": (
13
+ "python",
14
+ [
15
+ ["ruff", "server"],
16
+ ],
17
+ ),
18
+ ".pyi": (
19
+ "python",
20
+ [
21
+ ["ruff", "server"],
22
+ ],
23
+ ),
24
+ ".ts": (
25
+ "typescript",
26
+ [
27
+ ["typescript-language-server", "--stdio"],
28
+ ],
29
+ ),
30
+ ".tsx": (
31
+ "typescriptreact",
32
+ [
33
+ ["typescript-language-server", "--stdio"],
34
+ ],
35
+ ),
36
+ ".js": (
37
+ "javascript",
38
+ [
39
+ ["typescript-language-server", "--stdio"],
40
+ ],
41
+ ),
42
+ ".jsx": (
43
+ "javascriptreact",
44
+ [
45
+ ["typescript-language-server", "--stdio"],
46
+ ],
47
+ ),
48
+ ".go": (
49
+ "go",
50
+ [
51
+ ["gopls"],
52
+ ],
53
+ ),
54
+ ".rs": (
55
+ "rust",
56
+ [
57
+ ["rust-analyzer"],
58
+ ],
59
+ ),
60
+ }
61
+
62
+
63
+ def get_language_id(path: Path) -> str | None:
64
+ """Get the LSP language ID for a file.
65
+
66
+ Args:
67
+ path: Path to the file
68
+
69
+ Returns:
70
+ Language ID string or None if unsupported
71
+ """
72
+ ext = path.suffix.lower()
73
+ config = SERVER_CONFIG.get(ext)
74
+ return config[0] if config else None
75
+
76
+
77
+ def get_server_command(path: Path) -> list[str] | None:
78
+ """Get the server command for a file based on its extension.
79
+
80
+ Checks if the server binary exists before returning.
81
+
82
+ Args:
83
+ path: Path to the file
84
+
85
+ Returns:
86
+ Server command list or None if no server available
87
+ """
88
+ ext = path.suffix.lower()
89
+ config = SERVER_CONFIG.get(ext)
90
+
91
+ if config is None:
92
+ return None
93
+
94
+ _, command_options = config
95
+
96
+ for command in command_options:
97
+ binary = command[0]
98
+ if which(binary) is not None:
99
+ return command
100
+
101
+ return None