crates/cli/ents-lens/src/render.rs
render.rshistorycomment 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 | |
| 10 | use ents_anchor::{Anchor, LineRange, Projection}; |
| 11 | use ents_forge::comment::Comment; |
| 12 | use lsp_types::{ |
| 13 | CodeLens, Command, Diagnostic, DiagnosticSeverity, MarkupContent, MarkupKind, Position, Range, |
| 14 | }; |
| 15 | use serde_json::json; |
| 16 | |
| 17 | /// The `workspace/executeCommand` command that opens the thread |
| 18 | /// (`lens.lenses`: the view operation). |
| 19 | pub const CMD_VIEW: &str = "ents.view"; |
| 20 | /// The command that starts a reply compose (`lens.lenses`, `lens.compose`). |
| 21 | pub const CMD_REPLY: &str = "ents.reply"; |
| 22 | /// The command that resolves a comment (`lens.lenses`, |
| 23 | /// `model.comment-state`). |
| 24 | pub const CMD_RESOLVE: &str = "ents.resolve"; |
| 25 | /// The command that opens the compose template for a new comment |
| 26 | /// (`lens.compose`). |
| 27 | pub 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`). |
| 31 | pub 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] |
| 39 | pub 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`). |
| 50 | fn 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 | |
| 72 | fn 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] |
| 79 | pub 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] |
| 100 | pub 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] |
| 141 | pub 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] |
| 167 | pub 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. |
| 190 | fn short(id: &str) -> &str { |
| 191 | id.get(..7).unwrap_or(id) |
| 192 | } |
| 193 | |
| 194 | #[cfg(test)] |
| 195 | mod 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 | } |