Skip to content

Commit d6ab576

Browse files
committed
feat(cli): add rich-formatted data models for LSP output
1 parent 5e2d27d commit d6ab576

File tree

4 files changed

+360
-2
lines changed

4 files changed

+360
-2
lines changed

AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
## Development Commands
44

55
- Lint & format: `ruff check --fix && ruff format`
6-
- Type checking: `mypy src/`
6+
- Type checking: `ty check <dir_or_file>`
77
- Run tests: `pytest`
88

99
## Code Style Guidelines
1010

1111
- Python: 3.12+ required
12-
- Imports & Formatting: use ruff
1312
- Types: Full type annotations required, use `lsp_client.utils.types.lsp_type` for standard LSP types
1413
- Error handling: Use tenacity for retries, `anyio.fail_after` for timeouts
1514
- Async: Use async/await, `asyncer.TaskGroup` for concurrency

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ lsp = "lsp_client.cli:app"
2929
[dependency-groups]
3030
cli = [
3131
"typer>=0.12.0",
32+
"rich>=14.2.0",
3233
]
3334
dev = [
3435
"pytest>=8.4.1",

src/lsp_client/cli/model.py

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
from pathlib import Path
5+
6+
from lsprotocol import types as lsp_type
7+
from pydantic import BaseModel, Field
8+
from rich.console import Console, ConsoleOptions, RenderResult
9+
from rich.markdown import Markdown
10+
11+
from lsp_client.utils.uri import from_local_uri
12+
13+
14+
class CLIOutput(BaseModel):
15+
"""Base class for CLI output models."""
16+
17+
def to_markdown(self) -> str:
18+
"""Convert the model to a markdown string."""
19+
raise NotImplementedError
20+
21+
def __rich_console__(
22+
self, console: Console, options: ConsoleOptions
23+
) -> RenderResult:
24+
"""Rich render method."""
25+
yield Markdown(self.to_markdown())
26+
27+
28+
class CLILocation(CLIOutput):
29+
"""
30+
Represents a specific location in a source file.
31+
Normalized from lsp_type.Location and lsp_type.LocationLink.
32+
"""
33+
34+
path: str = Field(..., description="Absolute path to the file")
35+
line: int = Field(..., description="Start line number (0-indexed)")
36+
character: int = Field(..., description="Start character position (0-indexed)")
37+
end_line: int = Field(..., description="End line number")
38+
end_character: int = Field(..., description="End character position")
39+
snippet: str | None = Field(None, description="Code snippet at the location")
40+
41+
@classmethod
42+
def from_lsp(
43+
cls,
44+
location: lsp_type.Location | lsp_type.LocationLink,
45+
read_snippet: bool = True,
46+
) -> CLILocation:
47+
if isinstance(location, lsp_type.Location):
48+
uri = location.uri
49+
range_ = location.range
50+
elif isinstance(location, lsp_type.LocationLink):
51+
uri = location.target_uri
52+
range_ = location.target_selection_range
53+
else:
54+
raise ValueError(f"Unsupported location type: {type(location)}")
55+
56+
path = str(from_local_uri(uri))
57+
58+
snippet = None
59+
if read_snippet:
60+
try:
61+
p = Path(path)
62+
if p.exists() and p.is_file():
63+
with open(p, encoding="utf-8", errors="replace") as f:
64+
for i, file_line in enumerate(f):
65+
if i == range_.start.line:
66+
snippet = file_line.strip()
67+
break
68+
except Exception:
69+
pass
70+
71+
return cls(
72+
path=path,
73+
line=range_.start.line,
74+
character=range_.start.character,
75+
end_line=range_.end.line,
76+
end_character=range_.end.character,
77+
snippet=snippet,
78+
)
79+
80+
def to_markdown(self) -> str:
81+
md = f"**{self.path}:{self.line}:{self.character}**"
82+
if self.snippet:
83+
ext = Path(self.path).suffix.lstrip(".")
84+
md += f"\n\n```{ext}\n{self.snippet}\n```"
85+
return md
86+
87+
def __str__(self) -> str:
88+
base = f"{self.path}:{self.line}:{self.character}"
89+
if self.snippet:
90+
return f"{base}\n {self.snippet}"
91+
return base
92+
93+
94+
class CLIHover(CLIOutput):
95+
"""
96+
Represents hover information.
97+
"""
98+
99+
contents: str = Field(..., description="The content of the hover")
100+
range_str: str | None = Field(None, description="Text representation of the range")
101+
102+
@classmethod
103+
def from_lsp(cls, hover: lsp_type.Hover) -> CLIHover:
104+
contents = ""
105+
raw = hover.contents
106+
if isinstance(raw, lsp_type.MarkupContent):
107+
contents = raw.value
108+
elif isinstance(raw, str):
109+
contents = raw
110+
elif isinstance(raw, list):
111+
parts = []
112+
for item in raw:
113+
if isinstance(item, str):
114+
parts.append(item)
115+
elif hasattr(item, "value"):
116+
parts.append(str(item.value))
117+
contents = "\n\n".join(parts)
118+
elif hasattr(raw, "value"):
119+
contents = str(raw.value)
120+
121+
range_str = None
122+
if hover.range:
123+
range_str = f"{hover.range.start.line}:{hover.range.start.character}-{hover.range.end.line}:{hover.range.end.character}"
124+
125+
return cls(contents=contents, range_str=range_str)
126+
127+
def to_markdown(self) -> str:
128+
res = self.contents
129+
if self.range_str:
130+
res = f"> {self.range_str}\n\n{res}"
131+
return res
132+
133+
def __str__(self) -> str:
134+
res = self.contents
135+
if self.range_str:
136+
res = f"[{self.range_str}]\n{res}"
137+
return res
138+
139+
140+
class CLISymbolKind(str, Enum):
141+
"""String mapping for lsp_type.SymbolKind"""
142+
143+
File = "File"
144+
Module = "Module"
145+
Namespace = "Namespace"
146+
Package = "Package"
147+
Class = "Class"
148+
Method = "Method"
149+
Property = "Property"
150+
Field = "Field"
151+
Constructor = "Constructor"
152+
Enum = "Enum"
153+
Interface = "Interface"
154+
Function = "Function"
155+
Variable = "Variable"
156+
Constant = "Constant"
157+
String = "String"
158+
Number = "Number"
159+
Boolean = "Boolean"
160+
Array = "Array"
161+
Object = "Object"
162+
Key = "Key"
163+
Null = "Null"
164+
EnumMember = "EnumMember"
165+
Struct = "Struct"
166+
Event = "Event"
167+
Operator = "Operator"
168+
TypeParameter = "TypeParameter"
169+
Unknown = "Unknown"
170+
171+
@classmethod
172+
def from_lsp(cls, kind: lsp_type.SymbolKind) -> str:
173+
try:
174+
return cls(kind.name).value
175+
except (ValueError, AttributeError):
176+
mapping = {
177+
1: "File",
178+
2: "Module",
179+
3: "Namespace",
180+
4: "Package",
181+
5: "Class",
182+
6: "Method",
183+
7: "Property",
184+
8: "Field",
185+
9: "Constructor",
186+
10: "Enum",
187+
11: "Interface",
188+
12: "Function",
189+
13: "Variable",
190+
14: "Constant",
191+
15: "String",
192+
16: "Number",
193+
17: "Boolean",
194+
18: "Array",
195+
19: "Object",
196+
20: "Key",
197+
21: "Null",
198+
22: "EnumMember",
199+
23: "Struct",
200+
24: "Event",
201+
25: "Operator",
202+
26: "TypeParameter",
203+
}
204+
return mapping.get(int(kind), "Unknown")
205+
206+
207+
class CLISymbol(CLIOutput):
208+
"""
209+
Represents a symbol (document or workspace).
210+
Normalized from lsp_type.DocumentSymbol and lsp_type.SymbolInformation.
211+
"""
212+
213+
name: str
214+
kind: str
215+
location: CLILocation | None = Field(default=None)
216+
container_name: str | None = None
217+
children: list[CLISymbol] = Field(default_factory=list)
218+
219+
@classmethod
220+
def from_lsp(
221+
cls,
222+
symbol: lsp_type.DocumentSymbol | lsp_type.SymbolInformation,
223+
uri: str | None = None,
224+
) -> CLISymbol:
225+
if isinstance(symbol, lsp_type.DocumentSymbol):
226+
loc = None
227+
if uri:
228+
loc_obj = lsp_type.Location(uri=uri, range=symbol.selection_range)
229+
loc = CLILocation.from_lsp(loc_obj)
230+
231+
children = []
232+
if symbol.children:
233+
children = [cls.from_lsp(child, uri) for child in symbol.children]
234+
235+
return cls(
236+
name=symbol.name,
237+
kind=CLISymbolKind.from_lsp(symbol.kind),
238+
location=loc,
239+
children=children,
240+
)
241+
242+
elif isinstance(symbol, lsp_type.SymbolInformation):
243+
return cls(
244+
name=symbol.name,
245+
kind=CLISymbolKind.from_lsp(symbol.kind),
246+
location=CLILocation.from_lsp(symbol.location, read_snippet=False),
247+
container_name=symbol.container_name,
248+
)
249+
else:
250+
raise ValueError(f"Unsupported symbol type: {type(symbol)}")
251+
252+
def to_markdown(self) -> str:
253+
loc_str = self.location.to_markdown() if self.location else ""
254+
md = f"### {self.kind}: `{self.name}`\n"
255+
if self.container_name:
256+
md += f"*(in {self.container_name})*\n"
257+
if loc_str:
258+
md += f"\n{loc_str}\n"
259+
260+
if self.children:
261+
md += "\n#### Children\n"
262+
for child in self.children:
263+
child_md = child.to_markdown()
264+
md += "\n" + "\n".join(f" {line}" for line in child_md.split("\n"))
265+
return md
266+
267+
def __str__(self) -> str:
268+
loc_str = str(self.location) if self.location else ""
269+
if "\n" in loc_str:
270+
loc_lines = loc_str.split("\n")
271+
loc_str = loc_lines[0] + "\n " + "\n ".join(loc_lines[1:])
272+
273+
return f"{self.kind} {self.name} {loc_str}".strip()
274+
275+
276+
class CLITextEdit(CLIOutput):
277+
"""
278+
Represents a text edit.
279+
"""
280+
281+
range: str = Field(..., description="Text representation of the range")
282+
new_text: str = Field(..., description="The new text")
283+
284+
@classmethod
285+
def from_lsp(
286+
cls,
287+
edit: lsp_type.TextEdit | lsp_type.AnnotatedTextEdit | lsp_type.SnippetTextEdit,
288+
) -> CLITextEdit:
289+
r = edit.range
290+
range_str = f"{r.start.line}:{r.start.character}-{r.end.line}:{r.end.character}"
291+
new_text = str(getattr(edit, "new_text", getattr(edit, "snippet", "")))
292+
return cls(range=range_str, new_text=new_text)
293+
294+
def to_markdown(self) -> str:
295+
return f"- `[{self.range}]` -> `{self.new_text!r}`"
296+
297+
298+
class CLIFileEdit(CLIOutput):
299+
"""
300+
Represents edits in a single file.
301+
"""
302+
303+
path: str
304+
edits: list[CLITextEdit]
305+
306+
def to_markdown(self) -> str:
307+
edits_md = "\n".join(e.to_markdown() for e in self.edits)
308+
return f"#### {self.path}\n{edits_md}"
309+
310+
311+
class CLIWorkspaceEdit(CLIOutput):
312+
"""
313+
Represents a workspace edit (e.g. from rename).
314+
"""
315+
316+
file_edits: list[CLIFileEdit] = Field(default_factory=list)
317+
318+
@classmethod
319+
def from_lsp(cls, edit: lsp_type.WorkspaceEdit) -> CLIWorkspaceEdit:
320+
file_edits = []
321+
if edit.changes:
322+
for uri, edits in edit.changes.items():
323+
path = str(from_local_uri(uri))
324+
cli_edits = [CLITextEdit.from_lsp(e) for e in edits]
325+
file_edits.append(CLIFileEdit(path=path, edits=cli_edits))
326+
327+
if edit.document_changes:
328+
for dc in edit.document_changes:
329+
if isinstance(dc, lsp_type.TextDocumentEdit):
330+
path = str(from_local_uri(dc.text_document.uri))
331+
cli_edits = [CLITextEdit.from_lsp(e) for e in dc.edits]
332+
file_edits.append(CLIFileEdit(path=path, edits=cli_edits))
333+
334+
return cls(file_edits=file_edits)
335+
336+
def to_markdown(self) -> str:
337+
if not self.file_edits:
338+
return "_No changes._"
339+
return "## Workspace Edits\n\n" + "\n\n".join(
340+
fe.to_markdown() for fe in self.file_edits
341+
)
342+
343+
def __rich_console__(
344+
self, console: Console, options: ConsoleOptions
345+
) -> RenderResult:
346+
yield Markdown(self.to_markdown())
347+
348+
def __str__(self) -> str:
349+
if not self.file_edits:
350+
return "No changes."
351+
res = []
352+
for fe in self.file_edits:
353+
res.append(f"File: {fe.path}")
354+
for e in fe.edits:
355+
res.append(f" [{e.range}] -> {e.new_text!r}")
356+
return "\n".join(res)

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)