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.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. 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"