git-ents.gitmain
⌘K
foforge
render.rs294 lines · 9.9 KB · rusthistorycomment on this file
1//! Turning projected comments and threads into the LSP values the lens
2//! publishes: code lenses (`lens.lenses`), hint diagnostics
3//! (`lens.diagnostics`), and hover markup (`lens.hover`).
4//!
5//! Pure rendering only — every input is already-derived data (a `Listed`
6//! row, a thread), so the mapping from a comment to its on-screen shape is
7//! unit-testable without a repository, and [`crate::Lens`] owns the
8//! per-request derivation that feeds it.
9
10use ents_anchor::{Anchor, LineRange, Projection};
11use ents_forge::comment::Comment;
12use lsp_types::{
13 CodeLens, Command, Diagnostic, DiagnosticSeverity, MarkupContent, MarkupKind, Position, Range,
14};
15use serde_json::json;
16
17/// The `workspace/executeCommand` command that opens the thread
18/// (`lens.lenses`: the view operation).
19pub const CMD_VIEW: &str = "ents.view";
20/// The command that starts a reply compose (`lens.lenses`, `lens.compose`).
21pub const CMD_REPLY: &str = "ents.reply";
22/// The command that resolves a comment (`lens.lenses`,
23/// `model.comment-state`).
24pub const CMD_RESOLVE: &str = "ents.resolve";
25/// The command that opens the compose template for a new comment
26/// (`lens.compose`).
27pub const CMD_COMPOSE: &str = "ents.compose";
28
29/// The diagnostic/lens source label the lens stamps every item with, so a
30/// client can suppress just the conversation (`lens.diagnostics`).
31pub const SOURCE: &str = "ents";
32
33/// Where a projected comment lands on the open document, and whether its
34/// anchored lines were edited out from under it (`Projection::Outdated`) —
35/// `None` when the comment does not project onto the document at all
36/// (`Projection::Deleted`, or no anchor), so the caller omits it
37/// (`lens.lenses`).
38#[must_use]
39pub fn landed_range(projection: &Projection, anchor: &Anchor) -> Option<(Range, bool)> {
40 match projection {
41 Projection::Current => Some((line_range(anchor.lines), false)),
42 Projection::Relocated { lines, .. } => Some((line_range(*lines), false)),
43 Projection::Outdated { .. } => Some((line_range(anchor.lines), true)),
44 Projection::Deleted => None,
45 }
46}
47
48/// The half-open LSP [`Range`] covering a 1-based inclusive line range, or
49/// the document's first line for a whole-file anchor (`lines` is `None`).
50fn line_range(lines: Option<LineRange>) -> Range {
51 let (start, end) = match lines {
52 Some(range) => (
53 to_u32(range.start.saturating_sub(1)),
54 to_u32(range.end.saturating_sub(1)),
55 ),
56 None => (0, 0),
57 };
58 Range {
59 start: Position {
60 line: start,
61 character: 0,
62 },
63 // Extend to the end of the last line so a diagnostic underlines the
64 // whole anchored region; clients clamp the character to line length.
65 end: Position {
66 line: end,
67 character: u32::MAX,
68 },
69 }
70}
71
72fn to_u32(value: u64) -> u32 {
73 u32::try_from(value).unwrap_or(u32::MAX)
74}
75
76/// A one-line summary of a comment body for a lens title or diagnostic
77/// message: the first non-empty line, trimmed and capped so it fits inline.
78#[must_use]
79pub fn summary(body: &str) -> String {
80 const CAP: usize = 60;
81 let first = body
82 .lines()
83 .find(|line| !line.trim().is_empty())
84 .unwrap_or("")
85 .trim();
86 let mut chars = first.chars();
87 let capped: String = chars.by_ref().take(CAP).collect();
88 if chars.next().is_some() {
89 format!("{capped}…")
90 } else {
91 capped
92 }
93}
94
95/// The code lenses for one open root comment at `range` (`lens.lenses`):
96/// a primary lens identifying the comment and summarizing its body, then a
97/// Reply and a Resolve lens — the thread's operations offered as commands
98/// that call the same library operations the CLI exposes (`lens.parity`).
99#[must_use]
100pub fn code_lenses(id: &str, comment: &Comment, range: Range, outdated: bool) -> Vec<CodeLens> {
101 let mut title = format!("💬 {}: {}", short(id), summary(&comment.body));
102 if outdated {
103 title.push_str(" (outdated)");
104 }
105 let arg = vec![json!(id)];
106 vec![
107 CodeLens {
108 range,
109 command: Some(Command {
110 title,
111 command: CMD_VIEW.to_owned(),
112 arguments: Some(arg.clone()),
113 }),
114 data: None,
115 },
116 CodeLens {
117 range,
118 command: Some(Command {
119 title: "Reply".to_owned(),
120 command: CMD_REPLY.to_owned(),
121 arguments: Some(arg.clone()),
122 }),
123 data: None,
124 },
125 CodeLens {
126 range,
127 command: Some(Command {
128 title: "Resolve".to_owned(),
129 command: CMD_RESOLVE.to_owned(),
130 arguments: Some(arg),
131 }),
132 data: None,
133 },
134 ]
135}
136
137/// The hint-severity diagnostic mirroring one open comment at `range`
138/// (`lens.diagnostics`): the same conversation the code lens carries, for
139/// clients that do not render lenses. Never a warning or an error.
140#[must_use]
141pub fn diagnostic(id: &str, comment: &Comment, range: Range, outdated: bool) -> Diagnostic {
142 let mut message = format!("{}: {}", short(id), summary(&comment.body));
143 if outdated {
144 message.push_str(" (outdated — the anchored lines changed)");
145 }
146 Diagnostic {
147 range,
148 // `lens.diagnostics` is binding: conversation carries no judgment,
149 // so this is always a hint, never a warning or error.
150 severity: Some(DiagnosticSeverity::HINT),
151 code: None,
152 code_description: None,
153 source: Some(SOURCE.to_owned()),
154 message,
155 related_information: None,
156 tags: None,
157 data: None,
158 }
159}
160
161/// The hover markup for a thread (`lens.hover`): every comment in it —
162/// bodies, states, and authorship — rendered as Markdown so the whole
163/// conversation is readable in the buffer. `rows` are `(id, comment,
164/// author, when)` in thread order; `author`/`when` come from each ref's
165/// mutation commit chain (`meta-ref.identity-binding`), read by the caller.
166#[must_use]
167pub fn hover_markup(rows: &[(String, Comment, String, String)]) -> MarkupContent {
168 let mut value = String::new();
169 for (index, (id, comment, author, when)) in rows.iter().enumerate() {
170 if index > 0 {
171 value.push_str("\n---\n\n");
172 }
173 let reply = if comment.parent.is_some() { "↳ " } else { "" };
174 value.push_str(&format!(
175 "**{reply}{author}** · `{}` · _{}_ · {when}\n\n",
176 comment.state,
177 short(id)
178 ));
179 value.push_str(comment.body.trim());
180 value.push('\n');
181 }
182 MarkupContent {
183 kind: MarkupKind::Markdown,
184 value,
185 }
186}
187
188/// A comment id shortened for display — the first seven characters, the
189/// same length git uses for a short object id.
190fn short(id: &str) -> &str {
191 id.get(..7).unwrap_or(id)
192}
193
194#[cfg(test)]
195mod tests {
196 #![allow(clippy::unwrap_used, reason = "unit test")]
197
198 use super::*;
199
200 fn comment(body: &str, state: &str) -> Comment {
201 Comment {
202 body: body.to_owned(),
203 state: state.to_owned(),
204 anchor: None,
205 context: None,
206 parent: None,
207 }
208 }
209
210 #[test]
211 // @relation(lens.diagnostics, scope=function, role=Verifies)
212 fn a_diagnostic_is_always_a_hint() {
213 let diag = diagnostic(
214 "abc1234def",
215 &comment("hi", "open"),
216 line_range(None),
217 false,
218 );
219 assert_eq!(diag.severity, Some(DiagnosticSeverity::HINT));
220 assert_eq!(diag.source.as_deref(), Some(SOURCE));
221 }
222
223 #[test]
224 // @relation(lens.lenses, scope=function, role=Verifies)
225 fn lenses_offer_view_reply_resolve() {
226 let lenses = code_lenses(
227 "abc1234def",
228 &comment("body text", "open"),
229 line_range(None),
230 false,
231 );
232 let commands: Vec<&str> = lenses
233 .iter()
234 .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str()))
235 .collect();
236 assert_eq!(commands, vec![CMD_VIEW, CMD_REPLY, CMD_RESOLVE]);
237 let primary = lenses.first().unwrap().command.as_ref().unwrap();
238 assert!(primary.title.contains("body text"));
239 }
240
241 #[test]
242 fn summary_caps_and_takes_the_first_nonempty_line() {
243 assert_eq!(summary("\n\nfirst real line\nsecond"), "first real line");
244 let long = "x".repeat(80);
245 assert!(summary(&long).ends_with('…'));
246 }
247
248 #[test]
249 // @relation(anchor.projection, scope=function, role=Verifies)
250 fn deleted_projection_does_not_land() {
251 let anchor_lines = None;
252 let range = line_range(anchor_lines);
253 // Current lands; Deleted does not.
254 assert!(landed_range(&Projection::Current, &fake_anchor()).is_some());
255 assert!(landed_range(&Projection::Deleted, &fake_anchor()).is_none());
256 let _ = range;
257 }
258
259 fn fake_anchor() -> Anchor {
260 // A whole-file anchor is enough for `landed_range`, which only reads
261 // `anchor.lines`.
262 let dir = tempfile::tempdir().unwrap();
263 std::process::Command::new("git")
264 .arg("init")
265 .arg("-q")
266 .arg(dir.path())
267 .status()
268 .unwrap();
269 std::fs::write(dir.path().join("f.txt"), "a\n").unwrap();
270 std::process::Command::new("git")
271 .arg("-C")
272 .arg(dir.path())
273 .args(["add", "-A"])
274 .status()
275 .unwrap();
276 std::process::Command::new("git")
277 .arg("-C")
278 .arg(dir.path())
279 .args([
280 "-c",
281 "user.name=t",
282 "-c",
283 "user.email=t@e.com",
284 "commit",
285 "-q",
286 "-m",
287 "x",
288 ])
289 .status()
290 .unwrap();
291 let repo = gix::open(dir.path()).unwrap();
292 ents_anchor::capture(&repo, "HEAD", "f.txt", None).unwrap()
293 }
294}