bareagent-cli 0.1.0__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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
bareagent/lsp/tools.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""LSP -> BareAgent tool schema + handler factory.
|
|
2
|
+
|
|
3
|
+
Exposes four Tier-1 LSP capabilities to the LLM under the ``lsp_*`` prefix:
|
|
4
|
+
|
|
5
|
+
* ``lsp_outline(file)`` — ``textDocument/documentSymbol``
|
|
6
|
+
* ``lsp_definition(file, line, col)`` — ``textDocument/definition``
|
|
7
|
+
* ``lsp_references(file, line, col)`` — ``textDocument/references``
|
|
8
|
+
* ``lsp_diagnostics(file)`` — published-diagnostics cache (pull request API
|
|
9
|
+
is not yet surfaced by multilspy, so we read whatever the underlying
|
|
10
|
+
language-server handler has buffered).
|
|
11
|
+
|
|
12
|
+
The schema marks ``line`` / ``col`` as **1-based** to match editor convention.
|
|
13
|
+
Handlers convert to LSP's 0-based form internally before calling multilspy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from bareagent.core.schema import tool_schema as _schema
|
|
24
|
+
|
|
25
|
+
from .coord import line_col_0_to_1, line_col_1_to_0, to_repo_relative
|
|
26
|
+
from .workspace_edit import apply_workspace_edit
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .manager import LanguageServerManager
|
|
30
|
+
|
|
31
|
+
_log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
LSP_TOOL_NAMES = (
|
|
34
|
+
"lsp_outline",
|
|
35
|
+
"lsp_definition",
|
|
36
|
+
"lsp_references",
|
|
37
|
+
"lsp_diagnostics",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# The reference-aware rename tool. Deliberately *not* prefixed ``lsp_`` — the
|
|
41
|
+
# four ``lsp_*`` tools are read-only queries, whereas ``semantic_rename`` writes
|
|
42
|
+
# to disk. Keeping the prefix free means read-only agent types (which set
|
|
43
|
+
# ``lsp_tools_enabled=True``) cannot accidentally retain the write tool through
|
|
44
|
+
# the ``lsp_*`` name filter; isolation is instead handled by the explicit
|
|
45
|
+
# ``disallowed_tools`` entry in :data:`agent_types._READ_ONLY_DEFAULTS`.
|
|
46
|
+
SEMANTIC_RENAME_TOOL_NAME = "semantic_rename"
|
|
47
|
+
|
|
48
|
+
_COORD_DOC = (
|
|
49
|
+
"line and column are 1-based (matching editor convention). "
|
|
50
|
+
"Position (1, 1) is the very first character of the file."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
LSP_TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
55
|
+
_schema(
|
|
56
|
+
"lsp_outline",
|
|
57
|
+
(
|
|
58
|
+
"Return a hierarchical symbol outline (classes, functions, "
|
|
59
|
+
"methods, variables) for a single file using the language server's "
|
|
60
|
+
"documentSymbol response. Cheaper than reading the whole file when "
|
|
61
|
+
"you want to understand its shape."
|
|
62
|
+
),
|
|
63
|
+
{
|
|
64
|
+
"file": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"description": "Workspace-relative or absolute path to the file.",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
["file"],
|
|
70
|
+
),
|
|
71
|
+
_schema(
|
|
72
|
+
"lsp_definition",
|
|
73
|
+
("Jump to the definition of the symbol at the given position. " + _COORD_DOC),
|
|
74
|
+
{
|
|
75
|
+
"file": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Workspace-relative or absolute path to the file.",
|
|
78
|
+
},
|
|
79
|
+
"line": {
|
|
80
|
+
"type": "integer",
|
|
81
|
+
"description": "1-based line number of the symbol.",
|
|
82
|
+
"minimum": 1,
|
|
83
|
+
},
|
|
84
|
+
"col": {
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"description": "1-based column number of the symbol.",
|
|
87
|
+
"minimum": 1,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
["file", "line", "col"],
|
|
91
|
+
),
|
|
92
|
+
_schema(
|
|
93
|
+
"lsp_references",
|
|
94
|
+
("List every reference to the symbol at the given position. " + _COORD_DOC),
|
|
95
|
+
{
|
|
96
|
+
"file": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"description": "Workspace-relative or absolute path to the file.",
|
|
99
|
+
},
|
|
100
|
+
"line": {
|
|
101
|
+
"type": "integer",
|
|
102
|
+
"description": "1-based line number of the symbol.",
|
|
103
|
+
"minimum": 1,
|
|
104
|
+
},
|
|
105
|
+
"col": {
|
|
106
|
+
"type": "integer",
|
|
107
|
+
"description": "1-based column number of the symbol.",
|
|
108
|
+
"minimum": 1,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
["file", "line", "col"],
|
|
112
|
+
),
|
|
113
|
+
_schema(
|
|
114
|
+
"lsp_diagnostics",
|
|
115
|
+
(
|
|
116
|
+
"Return the language server's diagnostics for a single file "
|
|
117
|
+
"(errors, warnings, hints). Prefers the pull-diagnostics request "
|
|
118
|
+
"when available; otherwise falls back to the publishDiagnostics "
|
|
119
|
+
"cache."
|
|
120
|
+
),
|
|
121
|
+
{
|
|
122
|
+
"file": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": "Workspace-relative or absolute path to the file.",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
["file"],
|
|
128
|
+
),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
SEMANTIC_RENAME_TOOL_SCHEMA: dict[str, Any] = _schema(
|
|
133
|
+
SEMANTIC_RENAME_TOOL_NAME,
|
|
134
|
+
(
|
|
135
|
+
"Rename the symbol at the given position across the whole workspace "
|
|
136
|
+
"using the language server's textDocument/rename (a reference-aware, "
|
|
137
|
+
"symbol-level rename). Unlike a text find-and-replace, this updates "
|
|
138
|
+
"only real references to the symbol — never same-named strings, "
|
|
139
|
+
"comments, or unrelated symbols — and follows the rename across every "
|
|
140
|
+
"file that references it. If the language server is unavailable, no "
|
|
141
|
+
"server handles the file's extension, or the rename produces no edits, "
|
|
142
|
+
"this returns an explicit Error and changes nothing (it never falls "
|
|
143
|
+
"back to a text replacement). " + _COORD_DOC
|
|
144
|
+
),
|
|
145
|
+
{
|
|
146
|
+
"file": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"description": "Workspace-relative or absolute path to the file.",
|
|
149
|
+
},
|
|
150
|
+
"line": {
|
|
151
|
+
"type": "integer",
|
|
152
|
+
"description": "1-based line number of the symbol to rename.",
|
|
153
|
+
"minimum": 1,
|
|
154
|
+
},
|
|
155
|
+
"col": {
|
|
156
|
+
"type": "integer",
|
|
157
|
+
"description": "1-based column number of the symbol to rename.",
|
|
158
|
+
"minimum": 1,
|
|
159
|
+
},
|
|
160
|
+
"new_name": {
|
|
161
|
+
"type": "string",
|
|
162
|
+
"description": "The new identifier for the symbol.",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
["file", "line", "col", "new_name"],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_lsp_tools(
|
|
170
|
+
manager: LanguageServerManager,
|
|
171
|
+
) -> tuple[list[dict[str, Any]], dict[str, Callable[..., Any]]]:
|
|
172
|
+
"""Return ``(schemas, handlers)`` for the four Tier-1 LSP tools plus the
|
|
173
|
+
``semantic_rename`` write tool.
|
|
174
|
+
|
|
175
|
+
Schemas are stable across managers; only the handlers close over
|
|
176
|
+
``manager`` so they can look up the live server on every call. The
|
|
177
|
+
``semantic_rename`` entry rides along here (rather than in a separate
|
|
178
|
+
builder) so the registry has a single injection point for everything that
|
|
179
|
+
needs a live ``LanguageServerManager``.
|
|
180
|
+
"""
|
|
181
|
+
schemas = [dict(schema) for schema in LSP_TOOL_SCHEMAS]
|
|
182
|
+
schemas.append(dict(SEMANTIC_RENAME_TOOL_SCHEMA))
|
|
183
|
+
handlers: dict[str, Callable[..., Any]] = {
|
|
184
|
+
"lsp_outline": _make_outline_handler(manager),
|
|
185
|
+
"lsp_definition": _make_definition_handler(manager),
|
|
186
|
+
"lsp_references": _make_references_handler(manager),
|
|
187
|
+
"lsp_diagnostics": _make_diagnostics_handler(manager),
|
|
188
|
+
SEMANTIC_RENAME_TOOL_NAME: _make_semantic_rename_handler(manager),
|
|
189
|
+
}
|
|
190
|
+
return schemas, handlers
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Handler factories
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _make_outline_handler(
|
|
199
|
+
manager: LanguageServerManager,
|
|
200
|
+
) -> Callable[..., str]:
|
|
201
|
+
def _handler(file: str) -> str:
|
|
202
|
+
prelude = _prelude_or_error(manager, file)
|
|
203
|
+
if isinstance(prelude, str):
|
|
204
|
+
return prelude
|
|
205
|
+
server, relpath = prelude
|
|
206
|
+
try:
|
|
207
|
+
result = server.request_document_symbols(relpath)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
return f"Error: LSP call failed: {type(exc).__name__}: {exc}"
|
|
210
|
+
return _format_outline(result)
|
|
211
|
+
|
|
212
|
+
return _handler
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _make_definition_handler(
|
|
216
|
+
manager: LanguageServerManager,
|
|
217
|
+
) -> Callable[..., str]:
|
|
218
|
+
def _handler(file: str, line: int, col: int) -> str:
|
|
219
|
+
prelude = _prelude_or_error(manager, file)
|
|
220
|
+
if isinstance(prelude, str):
|
|
221
|
+
return prelude
|
|
222
|
+
server, relpath = prelude
|
|
223
|
+
line0, col0 = line_col_1_to_0(line, col)
|
|
224
|
+
try:
|
|
225
|
+
locations = server.request_definition(relpath, line0, col0)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
return f"Error: LSP call failed: {type(exc).__name__}: {exc}"
|
|
228
|
+
if not locations:
|
|
229
|
+
return "(no definition found)"
|
|
230
|
+
return _format_locations(locations, manager)
|
|
231
|
+
|
|
232
|
+
return _handler
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _make_references_handler(
|
|
236
|
+
manager: LanguageServerManager,
|
|
237
|
+
) -> Callable[..., str]:
|
|
238
|
+
def _handler(file: str, line: int, col: int) -> str:
|
|
239
|
+
prelude = _prelude_or_error(manager, file)
|
|
240
|
+
if isinstance(prelude, str):
|
|
241
|
+
return prelude
|
|
242
|
+
server, relpath = prelude
|
|
243
|
+
line0, col0 = line_col_1_to_0(line, col)
|
|
244
|
+
try:
|
|
245
|
+
locations = server.request_references(relpath, line0, col0)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
return f"Error: LSP call failed: {type(exc).__name__}: {exc}"
|
|
248
|
+
if not locations:
|
|
249
|
+
return "(no references found)"
|
|
250
|
+
return _format_locations(locations, manager)
|
|
251
|
+
|
|
252
|
+
return _handler
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _make_diagnostics_handler(
|
|
256
|
+
manager: LanguageServerManager,
|
|
257
|
+
) -> Callable[..., str]:
|
|
258
|
+
def _handler(file: str) -> str:
|
|
259
|
+
prelude = _prelude_or_error(manager, file)
|
|
260
|
+
if isinstance(prelude, str):
|
|
261
|
+
return prelude
|
|
262
|
+
server, relpath = prelude
|
|
263
|
+
|
|
264
|
+
# Try pull-diagnostics first (LSP 3.17+). multilspy 0.0.15 does not
|
|
265
|
+
# expose this on ``SyncLanguageServer``; fall through to the manager-
|
|
266
|
+
# side push cache when the method is missing or raises. Per-call pull
|
|
267
|
+
# errors land in debug logs only — they're expected on stock multilspy.
|
|
268
|
+
diagnostics: list[Any] | None = None
|
|
269
|
+
pull = getattr(server, "request_text_document_diagnostics", None)
|
|
270
|
+
if callable(pull):
|
|
271
|
+
try:
|
|
272
|
+
pull_result: Any = pull(relpath)
|
|
273
|
+
diagnostics = list(pull_result)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
_log.debug(
|
|
276
|
+
"lsp_diagnostics pull failed for %r: %s: %s",
|
|
277
|
+
relpath,
|
|
278
|
+
type(exc).__name__,
|
|
279
|
+
exc,
|
|
280
|
+
)
|
|
281
|
+
diagnostics = None
|
|
282
|
+
|
|
283
|
+
if not diagnostics:
|
|
284
|
+
# Push-cache path. The manager installs a publishDiagnostics
|
|
285
|
+
# handler at handshake — multilspy itself registers ``do_nothing``
|
|
286
|
+
# for that notification on every bundled adapter (verified
|
|
287
|
+
# against 0.0.15 source). Pyright only publishes once the file
|
|
288
|
+
# is opened (``textDocument/didOpen``), and multilspy's
|
|
289
|
+
# ``request_*`` paths auto-open via ``with self.open_file(...)``
|
|
290
|
+
# but ``lsp_diagnostics`` has no analogue. We invoke ``open_file``
|
|
291
|
+
# explicitly here so pyright analyses the document before we read
|
|
292
|
+
# the cache. ``wait_for_diagnostics`` then gives the server up to
|
|
293
|
+
# a few seconds to push.
|
|
294
|
+
_trigger_open_and_wait(server, manager, file, relpath)
|
|
295
|
+
diagnostics = manager.get_diagnostics_snapshot(file)
|
|
296
|
+
|
|
297
|
+
if not diagnostics:
|
|
298
|
+
return "(no diagnostics)"
|
|
299
|
+
return _format_diagnostics(diagnostics)
|
|
300
|
+
|
|
301
|
+
return _handler
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _trigger_open_and_wait(
|
|
305
|
+
server: Any,
|
|
306
|
+
manager: LanguageServerManager,
|
|
307
|
+
file: str,
|
|
308
|
+
relpath: str,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Force pyright/etc. to analyse ``file`` so its diagnostics land in the cache.
|
|
311
|
+
|
|
312
|
+
multilspy's ``open_file`` is a context manager that sends ``didOpen`` on
|
|
313
|
+
entry and ``didClose`` on exit. We need to keep the file open long
|
|
314
|
+
enough for the server to respond with a publishDiagnostics notification.
|
|
315
|
+
Pattern: open in a worker thread + wait for the manager-side Event with
|
|
316
|
+
a short budget. Best-effort — any exception swallowed and the caller
|
|
317
|
+
falls back to whatever the cache holds (possibly empty).
|
|
318
|
+
"""
|
|
319
|
+
import threading as _threading
|
|
320
|
+
|
|
321
|
+
open_file = getattr(server, "open_file", None)
|
|
322
|
+
if not callable(open_file):
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
holder: dict[str, Any] = {"done": _threading.Event()}
|
|
326
|
+
|
|
327
|
+
def _hold() -> None:
|
|
328
|
+
try:
|
|
329
|
+
cm: Any = open_file(relpath)
|
|
330
|
+
with cm:
|
|
331
|
+
# Block briefly while pyright analyses and publishes. The
|
|
332
|
+
# outer wait_for_diagnostics is the primary signal; this is
|
|
333
|
+
# a safety net so the context exits even if no publish lands.
|
|
334
|
+
holder["done"].wait(timeout=4.0)
|
|
335
|
+
except Exception: # pragma: no cover — best-effort
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
worker = _threading.Thread(target=_hold, daemon=True, name="lsp-open-hold")
|
|
339
|
+
worker.start()
|
|
340
|
+
try:
|
|
341
|
+
manager.wait_for_diagnostics(file, timeout=4.0)
|
|
342
|
+
finally:
|
|
343
|
+
holder["done"].set()
|
|
344
|
+
worker.join(timeout=1.0)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _make_semantic_rename_handler(
|
|
348
|
+
manager: LanguageServerManager,
|
|
349
|
+
) -> Callable[..., str]:
|
|
350
|
+
def _handler(file: str, line: int, col: int, new_name: str) -> str:
|
|
351
|
+
if not new_name or not str(new_name).strip():
|
|
352
|
+
return "Error: new_name must be a non-empty identifier"
|
|
353
|
+
prelude = _prelude_or_error(manager, file)
|
|
354
|
+
if isinstance(prelude, str):
|
|
355
|
+
return prelude
|
|
356
|
+
_server, _relpath = prelude
|
|
357
|
+
abs_path = file if os.path.isabs(file) else os.path.abspath(file)
|
|
358
|
+
line0, col0 = line_col_1_to_0(line, col)
|
|
359
|
+
try:
|
|
360
|
+
workspace_edit = manager.request_rename(abs_path, line0, col0, new_name)
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
return f"Error: LSP rename failed: {type(exc).__name__}: {exc}"
|
|
363
|
+
|
|
364
|
+
# D1 — no grep/regex fallback. A None / empty WorkspaceEdit means the
|
|
365
|
+
# server could not (or would not) rename here; surface an explicit
|
|
366
|
+
# error so the caller can decide whether to fall back to ``edit_file``
|
|
367
|
+
# itself. Silently degrading to a text replacement would break the
|
|
368
|
+
# "safe rename" contract this tool exists to provide.
|
|
369
|
+
if not workspace_edit:
|
|
370
|
+
return (
|
|
371
|
+
"Error: language server returned no rename edits for "
|
|
372
|
+
f"{file}:{line}:{col} (the position may not be a renameable "
|
|
373
|
+
"symbol). No files were changed."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
result = apply_workspace_edit(workspace_edit)
|
|
377
|
+
if not result.changed_any:
|
|
378
|
+
note = ""
|
|
379
|
+
if result.skipped:
|
|
380
|
+
note = " Skipped resource operations: " + "; ".join(result.skipped)
|
|
381
|
+
return (
|
|
382
|
+
"Error: rename produced no applicable text edits. "
|
|
383
|
+
"No files were changed." + note
|
|
384
|
+
)
|
|
385
|
+
return _format_rename_result(new_name, result)
|
|
386
|
+
|
|
387
|
+
return _handler
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _format_rename_result(new_name: str, result: Any) -> str:
|
|
391
|
+
"""Render a ``WorkspaceEditResult`` as a short, LLM-readable summary."""
|
|
392
|
+
file_count = len(result.files)
|
|
393
|
+
edit_count = result.total_edits
|
|
394
|
+
lines = [
|
|
395
|
+
f"Renamed symbol to {new_name!r}: {edit_count} edit"
|
|
396
|
+
f"{'s' if edit_count != 1 else ''} across {file_count} file"
|
|
397
|
+
f"{'s' if file_count != 1 else ''}.",
|
|
398
|
+
]
|
|
399
|
+
for path in sorted(result.files):
|
|
400
|
+
count = result.files[path]
|
|
401
|
+
lines.append(f" {path}: {count} edit{'s' if count != 1 else ''}")
|
|
402
|
+
if result.skipped:
|
|
403
|
+
lines.append(
|
|
404
|
+
"Skipped resource operations (file create/rename/delete are not "
|
|
405
|
+
"performed by semantic_rename):"
|
|
406
|
+
)
|
|
407
|
+
for note in result.skipped:
|
|
408
|
+
lines.append(f" {note}")
|
|
409
|
+
return "\n".join(lines)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Shared helpers
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _prelude_or_error(
|
|
418
|
+
manager: LanguageServerManager,
|
|
419
|
+
file: str,
|
|
420
|
+
) -> tuple[Any, str] | str:
|
|
421
|
+
"""Validate input and return ``(server, relative_path)`` or an error string.
|
|
422
|
+
|
|
423
|
+
Centralizes the file-not-found / no-route / unhealthy-server checks so
|
|
424
|
+
every handler returns the same error wording for the same failure mode.
|
|
425
|
+
"""
|
|
426
|
+
if not file:
|
|
427
|
+
return "Error: file argument is required"
|
|
428
|
+
|
|
429
|
+
# Resolve absolute path so existence + routing both work whether the
|
|
430
|
+
# caller supplied a workspace-relative or absolute path.
|
|
431
|
+
abs_path = file if os.path.isabs(file) else os.path.abspath(file)
|
|
432
|
+
if not os.path.exists(abs_path):
|
|
433
|
+
return f"Error: file not found: {file}"
|
|
434
|
+
|
|
435
|
+
language = manager.language_for_file(abs_path)
|
|
436
|
+
if language is None:
|
|
437
|
+
_, ext = os.path.splitext(abs_path)
|
|
438
|
+
ext_display = ext or "(no extension)"
|
|
439
|
+
return f"Error: no LSP server configured for {ext_display}"
|
|
440
|
+
|
|
441
|
+
server = manager.get_server_for_file(abs_path)
|
|
442
|
+
if server is None:
|
|
443
|
+
return f"Error: language server {language!r} is unhealthy"
|
|
444
|
+
|
|
445
|
+
relpath = to_repo_relative(abs_path, manager.repository_root)
|
|
446
|
+
return server, relpath
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _format_outline(result: Any) -> str:
|
|
450
|
+
"""Render multilspy's ``request_document_symbols`` return value as a
|
|
451
|
+
plain text indented tree."""
|
|
452
|
+
symbols: list[dict[str, Any]] = []
|
|
453
|
+
tree: Any = None
|
|
454
|
+
if isinstance(result, tuple) and len(result) >= 1:
|
|
455
|
+
symbols = list(result[0]) if result[0] else []
|
|
456
|
+
if len(result) >= 2:
|
|
457
|
+
tree = result[1]
|
|
458
|
+
elif isinstance(result, list):
|
|
459
|
+
symbols = list(result)
|
|
460
|
+
|
|
461
|
+
if tree:
|
|
462
|
+
rendered = _render_tree(tree, symbols)
|
|
463
|
+
if rendered:
|
|
464
|
+
return rendered
|
|
465
|
+
|
|
466
|
+
if not symbols:
|
|
467
|
+
return "(no symbols)"
|
|
468
|
+
return "\n".join(_format_symbol_flat(sym) for sym in symbols)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _render_tree(
|
|
472
|
+
tree: Any,
|
|
473
|
+
symbols: list[dict[str, Any]],
|
|
474
|
+
*,
|
|
475
|
+
depth: int = 0,
|
|
476
|
+
) -> str:
|
|
477
|
+
"""Best-effort rendering of multilspy's ``TreeRepr`` (``Dict[int, List]``).
|
|
478
|
+
|
|
479
|
+
The TreeRepr maps a symbol index to its child indices. Falls back to a
|
|
480
|
+
flat listing when the tree is malformed.
|
|
481
|
+
"""
|
|
482
|
+
if not isinstance(tree, dict):
|
|
483
|
+
return ""
|
|
484
|
+
if not symbols:
|
|
485
|
+
return ""
|
|
486
|
+
|
|
487
|
+
lines: list[str] = []
|
|
488
|
+
visited: set[int] = set()
|
|
489
|
+
|
|
490
|
+
def _walk(node: Any, level: int) -> None:
|
|
491
|
+
if not isinstance(node, dict):
|
|
492
|
+
return
|
|
493
|
+
for raw_index, children in node.items():
|
|
494
|
+
try:
|
|
495
|
+
index = int(raw_index)
|
|
496
|
+
except (TypeError, ValueError):
|
|
497
|
+
continue
|
|
498
|
+
if index in visited or not (0 <= index < len(symbols)):
|
|
499
|
+
continue
|
|
500
|
+
visited.add(index)
|
|
501
|
+
lines.append(" " * level + _format_symbol_flat(symbols[index]))
|
|
502
|
+
if isinstance(children, list):
|
|
503
|
+
for child in children:
|
|
504
|
+
_walk(child, level + 1)
|
|
505
|
+
if isinstance(child, dict):
|
|
506
|
+
_walk(child, level + 1)
|
|
507
|
+
|
|
508
|
+
_walk(tree, depth)
|
|
509
|
+
return "\n".join(lines)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _format_symbol_flat(sym: dict[str, Any]) -> str:
|
|
513
|
+
name = sym.get("name", "?")
|
|
514
|
+
kind = _symbol_kind_label(sym.get("kind"))
|
|
515
|
+
location = sym.get("location") or {}
|
|
516
|
+
range_ = location.get("range") if isinstance(location, dict) else None
|
|
517
|
+
if isinstance(range_, dict):
|
|
518
|
+
start = range_.get("start") or {}
|
|
519
|
+
end = range_.get("end") or {}
|
|
520
|
+
start_line, _ = line_col_0_to_1(
|
|
521
|
+
int(start.get("line", 0) or 0),
|
|
522
|
+
int(start.get("character", 0) or 0),
|
|
523
|
+
)
|
|
524
|
+
end_line, _ = line_col_0_to_1(
|
|
525
|
+
int(end.get("line", 0) or 0),
|
|
526
|
+
int(end.get("character", 0) or 0),
|
|
527
|
+
)
|
|
528
|
+
if start_line == end_line:
|
|
529
|
+
range_part = f"line {start_line}"
|
|
530
|
+
else:
|
|
531
|
+
range_part = f"lines {start_line}-{end_line}"
|
|
532
|
+
else:
|
|
533
|
+
range_part = "line ?"
|
|
534
|
+
return f"{kind} {name} ({range_part})"
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _symbol_kind_label(kind: Any) -> str:
|
|
538
|
+
"""Map an LSP ``SymbolKind`` numeric value to a short label."""
|
|
539
|
+
# Subset of LSP SymbolKind that the outline cares about. Anything else
|
|
540
|
+
# falls through to a generic "symbol" label so the renderer never crashes
|
|
541
|
+
# on a server that returns a numeric kind we don't know yet.
|
|
542
|
+
labels = {
|
|
543
|
+
2: "module",
|
|
544
|
+
3: "namespace",
|
|
545
|
+
4: "package",
|
|
546
|
+
5: "class",
|
|
547
|
+
6: "method",
|
|
548
|
+
7: "property",
|
|
549
|
+
8: "field",
|
|
550
|
+
9: "constructor",
|
|
551
|
+
10: "enum",
|
|
552
|
+
11: "interface",
|
|
553
|
+
12: "function",
|
|
554
|
+
13: "variable",
|
|
555
|
+
14: "constant",
|
|
556
|
+
22: "enum-member",
|
|
557
|
+
23: "struct",
|
|
558
|
+
}
|
|
559
|
+
try:
|
|
560
|
+
return labels.get(int(kind), "symbol")
|
|
561
|
+
except (TypeError, ValueError):
|
|
562
|
+
return "symbol"
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _format_locations(
|
|
566
|
+
locations: list[Any],
|
|
567
|
+
manager: LanguageServerManager,
|
|
568
|
+
) -> str:
|
|
569
|
+
"""Format multilspy ``Location`` dicts into ``<file>:<line>:<col>`` rows
|
|
570
|
+
using 1-based coordinates."""
|
|
571
|
+
rendered: list[str] = []
|
|
572
|
+
for loc in locations:
|
|
573
|
+
if not isinstance(loc, dict):
|
|
574
|
+
continue
|
|
575
|
+
path = (
|
|
576
|
+
loc.get("absolutePath")
|
|
577
|
+
or loc.get("relativePath")
|
|
578
|
+
or loc.get("uri")
|
|
579
|
+
or "<unknown>"
|
|
580
|
+
)
|
|
581
|
+
# Prefer a path relative to the repository root for readability.
|
|
582
|
+
if isinstance(path, str) and os.path.isabs(path):
|
|
583
|
+
try:
|
|
584
|
+
path = os.path.relpath(path, start=manager.repository_root)
|
|
585
|
+
except ValueError:
|
|
586
|
+
pass
|
|
587
|
+
range_ = loc.get("range") or {}
|
|
588
|
+
start = range_.get("start") if isinstance(range_, dict) else None
|
|
589
|
+
if isinstance(start, dict):
|
|
590
|
+
line, col = line_col_0_to_1(
|
|
591
|
+
int(start.get("line", 0) or 0),
|
|
592
|
+
int(start.get("character", 0) or 0),
|
|
593
|
+
)
|
|
594
|
+
rendered.append(f"{path}:{line}:{col}")
|
|
595
|
+
else:
|
|
596
|
+
rendered.append(str(path))
|
|
597
|
+
if not rendered:
|
|
598
|
+
return "(no location data)"
|
|
599
|
+
return "\n".join(rendered)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _format_diagnostics(diagnostics: list[Any]) -> str:
|
|
603
|
+
rows: list[str] = []
|
|
604
|
+
for diag in diagnostics:
|
|
605
|
+
if not isinstance(diag, dict):
|
|
606
|
+
continue
|
|
607
|
+
severity = _severity_label(diag.get("severity"))
|
|
608
|
+
message = diag.get("message", "")
|
|
609
|
+
range_ = diag.get("range") or {}
|
|
610
|
+
start = range_.get("start") if isinstance(range_, dict) else None
|
|
611
|
+
if isinstance(start, dict):
|
|
612
|
+
line, _col = line_col_0_to_1(
|
|
613
|
+
int(start.get("line", 0) or 0),
|
|
614
|
+
int(start.get("character", 0) or 0),
|
|
615
|
+
)
|
|
616
|
+
rows.append(f"[{severity}] Line {line}: {message}")
|
|
617
|
+
else:
|
|
618
|
+
rows.append(f"[{severity}] {message}")
|
|
619
|
+
if not rows:
|
|
620
|
+
return "(no diagnostics)"
|
|
621
|
+
return "\n".join(rows)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _severity_label(severity: Any) -> str:
|
|
625
|
+
labels = {1: "Error", 2: "Warning", 3: "Info", 4: "Hint"}
|
|
626
|
+
try:
|
|
627
|
+
return labels.get(int(severity), "Diagnostic")
|
|
628
|
+
except (TypeError, ValueError):
|
|
629
|
+
return "Diagnostic"
|