lens: add the ents-lens crate — the LSP editor surface over comments
commit 8ca6c92
lens: add the ents-lens crate — the LSP editor surface over comments
The lens is the third frontend of the local root, after the CLI and the
web UI, and earns no third mechanism: it is a read-time Language Server
Protocol view over refs/meta/comments/*, deriving every response
per-request from anchor projection onto the working tree and writing new
comments back through the same ents_forge::comment calls the CLI makes
(lens.parity). It owns no state and caches nothing across a comment-ref
mutation (lens.lenses).
Implements docs/spec/lens.adoc in full:
lens.serve — serve_stdio over lsp-server, stdio only, no socket, no git
transport; the signing identity is injected as an owned Signing value.
lens.lenses — one View/Reply/Resolve code-lens set per open comment that
projects onto the document.
lens.diagnostics — the same comments as hint-severity diagnostics, never
warnings or errors, so lens-less clients still show the conversation.
lens.hover — the full thread as Markdown, authorship read from each
ref’s mutation commit chain.
lens.compose — a code action opens a git-style template under
.git/ENTS_COMMENT_EDITMSG; saving a non-empty body creates the comment
via textDocument/didSave, an empty body aborts. Needs no client-specific
extension: only executeCommand, showDocument, and didSave.
lens.working-tree — projection targets the working tree, the open buffer
standing in for disk, re-projected on every change.
Uses lsp-server + lsp-types (rust-analyzer’s synchronous stack): the
lens’s work is blocking git and filesystem IO, so an async runtime would
only wrap it in spawn_blocking for no gain. All derivation lives in
directly-testable Lens methods; the server module is a thin dispatch loop.
crates/cli/ents-lens/src/compose.rs
@@ -1,0 +1,138 @@
+//! The editor-file compose flow (`lens.compose`): building the template
+//! git-style commit-message file the editor opens, and parsing it back
+//! once the user saves.
+//!
+//! This module is pure — string in, string out, no IO and no git — so the
+//! exact template grammar and the "an empty body aborts, `#` lines are
+//! ignored" rule are unit-testable on their own, and [`crate::Lens`] owns
+//! only the filesystem and mutation halves.
+//!
+//! # The mechanism, precisely
+//!
+//! `lens.compose` requires composing to work through a file "the way git
+//! itself takes a commit message", using no client-specific extension. The
+//! flow the lens drives, using only standard LSP a plain client provides
+//! (`workspace/executeCommand`, `window/showDocument`, and
+//! `textDocument/didSave`):
+//!
+//! 1. A `textDocument/codeAction` on the selection returns the
+//! `ents.compose` command.
+//! 2. The client runs it via `workspace/executeCommand`; the lens writes
+//! [`template_text`] to `.git/ENTS_COMMENT_EDITMSG` and asks the client
+//! to open it with `window/showDocument`.
+//! 3. The user edits the body and saves. The lens's `textDocument/didSave`
+//! handler reads the file, [`parse`]s it, and — if the body is
+//! non-empty — creates the comment through `ents_forge::comment::add`
+//! (the same call the CLI makes, `lens.parity`), anchoring to the
+//! working tree (`lens.working-tree`). An empty body aborts.
+//!
+//! The template is self-describing: the anchor target (path, lines,
+//! working-tree flag, and an optional reply parent) rides in `#`-prefixed
+//! metadata lines, so [`parse`] recovers it from the saved file alone and
+//! the lens keeps no per-compose state of its own ("owning no state of its
+//! own", the lens's whole premise). Because those lines start with `#`
+//! they are ignored for the body exactly as any other comment line is, so
+//! the metadata can never leak into the comment text.
+
+/// The prefix every machine-readable metadata line in the template carries,
+/// after the `#` comment marker: `# ents-compose-<key>: <value>`.
+const META_PREFIX: &str = "# ents-compose-";
+
+/// What a compose targets: an anchor (a path, optional `<start>:<end>`
+/// lines) captured against the working tree, and/or a reply parent.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Target {
+ /// Repository-relative path to anchor to, or `None` for a reply that
+ /// inherits its aboutness from its parent.
+ pub path: Option<String>,
+ /// Lines to anchor, as `<start>[:<end>]`.
+ pub lines: Option<String>,
+ /// Id of the comment being replied to, when this compose is a reply.
+ pub parent: Option<String>,
+}
+
+/// A parsed, saved template: the body (with `#` lines and surrounding
+/// blank lines stripped) and the anchor [`Target`] its metadata named.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Composed {
+ /// The comment body the user typed. Empty (after trimming) means the
+ /// compose was aborted (`lens.compose`).
+ pub body: String,
+ /// Where the comment anchors / who it replies to.
+ pub target: Target,
+}
+
+impl Composed {
+ /// Whether the saved template aborts the compose: an empty body once
+ /// `#` lines and surrounding whitespace are stripped (`lens.compose`).
+ #[must_use]
+ pub fn is_abort(&self) -> bool {
+ self.body.trim().is_empty()
+ }
+}
+
+/// The initial template text for a new anchored comment on `path`/`lines`
+/// against the working tree, or a reply when `target.parent` is set — a
+/// blank body followed by git-style `#` guidance and the machine-readable
+/// metadata [`parse`] reads back.
+#[must_use]
+pub fn template_text(target: &Target) -> String {
+ let mut out = String::new();
+ // One blank line for the body; the user types above the guidance.
+ out.push('\n');
+ out.push_str("# Leave an ents comment. Lines starting with '#' are ignored;\n");
+ out.push_str("# an empty message aborts. Save this file to create the comment.\n");
+ out.push_str("#\n");
+ match (&target.parent, &target.path) {
+ (Some(parent), _) => {
+ out.push_str(&format!("# Replying to comment {parent}.\n"));
+ }
+ (None, Some(path)) => match &target.lines {
+ Some(lines) => out.push_str(&format!("# On: {path} lines {lines} (working tree).\n")),
+ None => out.push_str(&format!("# On: {path} (working tree).\n")),
+ },
+ (None, None) => {}
+ }
+ // Machine-readable metadata: one value per line, so a path containing
+ // spaces round-trips without any escaping.
+ if let Some(path) = &target.path {
+ out.push_str(&format!("{META_PREFIX}path: {path}\n"));
+ }
+ if let Some(lines) = &target.lines {
+ out.push_str(&format!("{META_PREFIX}lines: {lines}\n"));
+ }
+ if let Some(parent) = &target.parent {
+ out.push_str(&format!("{META_PREFIX}parent: {parent}\n"));
+ }
+ out
+}
+
+/// Parse a saved template back into its body and [`Target`]
+/// (`lens.compose`): every line starting with `#` is dropped from the body,
+/// and the `# ents-compose-<key>: <value>` metadata lines reconstruct the
+/// anchor target the compose was started with.
+#[must_use]
+pub fn parse(content: &str) -> Composed {
+ let mut target = Target::default();
+ let mut body_lines: Vec<&str> = Vec::new();
+ for line in content.lines() {
+ if let Some(rest) = line.strip_prefix(META_PREFIX) {
+ if let Some((key, value)) = rest.split_once(':') {
+ let value = value.trim().to_owned();
+ match key {
+ "path" => target.path = Some(value),
+ "lines" => target.lines = Some(value),
+ "parent" => target.parent = Some(value),
+ _ => {}
+ }
+ }
+ continue;
+ }
+ if line.starts_with('#') {
+ continue;
+ }
+ body_lines.push(line);
+ }
+ let body = body_lines.join("\n").trim().to_owned();
+ Composed { body, target }
+}
crates/cli/ents-lens/src/document.rs
@@ -1,0 +1,92 @@
+//! The lens's view of the client's open buffers, and the file-URI ↔
+//! repository-path arithmetic that ties an LSP document to the anchor
+//! paths `refs/meta/comments/*` records.
+//!
+//! The lens caches nothing derived — no projection, no lens, no diagnostic
+//! survives a comment-ref mutation (`lens.lenses`) — but it must remember
+//! the *buffer text* the client has sent, because the client owns the only
+//! copy of a document's unsaved content: `textDocument/didChange` ships an
+//! edit, never the file, and the disk still holds the old bytes. Holding
+//! the latest buffer per open URI is what lets projection target the bytes
+//! the user is actually looking at (`lens.working-tree`), and it is dropped
+//! the moment the client closes the document.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use lsp_types::Url;
+
+/// The client's open text documents, keyed by URI, each holding the latest
+/// full text the client has sent (`textDocumentSync` is full-text, so every
+/// change replaces the whole buffer).
+#[derive(Debug, Default)]
+pub struct Documents {
+ open: HashMap<Url, String>,
+}
+
+impl Documents {
+ /// Record (or replace) the full text of the document at `uri` — the
+ /// `didOpen`/`didChange` handler's whole job.
+ pub fn set(&mut self, uri: Url, text: String) {
+ self.open.insert(uri, text);
+ }
+
+ /// Forget the document at `uri` — `didClose`; projection falls back to
+ /// the on-disk bytes afterward.
+ pub fn remove(&mut self, uri: &Url) {
+ self.open.remove(uri);
+ }
+
+ /// The latest buffer text for `uri`, if the client has it open.
+ #[must_use]
+ pub fn text(&self, uri: &Url) -> Option<&str> {
+ self.open.get(uri).map(String::as_str)
+ }
+
+ /// Every open document's URI — the server republishes diagnostics for
+ /// these after a comment-ref mutation.
+ #[must_use]
+ pub fn open_uris(&self) -> Vec<Url> {
+ self.open.keys().cloned().collect()
+ }
+}
+
+/// The repository-relative, forward-slashed path a file URI names inside
+/// the working tree at `workdir`, or `None` when the URI is not a file
+/// under it — the key that matches an [`ents_anchor::Anchor`]'s own
+/// recorded path.
+///
+/// Both sides are canonicalized when possible (the working tree always
+/// exists; an open document usually does), so a symlinked temp directory
+/// like macOS's `/var` → `/private/var` does not defeat the prefix match;
+/// when the document has no on-disk form yet, the raw paths are compared.
+#[must_use]
+pub fn relative_path(workdir: &Path, uri: &Url) -> Option<String> {
+ let file = uri.to_file_path().ok()?;
+ let file_canon = canonical(&file);
+ let workdir_canon = canonical(workdir);
+ let rel = file_canon.strip_prefix(&workdir_canon).ok()?;
+ Some(rel.to_string_lossy().replace('\\', "/"))
+}
+
+/// Resolve `path` through symlinks even when its leaf does not exist yet,
+/// by canonicalizing the deepest ancestor that does and re-appending the
+/// rest — so a not-yet-saved document under a symlinked temp directory
+/// (macOS's `/var` → `/private/var`) still shares a prefix with the
+/// canonicalized working tree.
+fn canonical(path: &Path) -> PathBuf {
+ if let Ok(resolved) = path.canonicalize() {
+ return resolved;
+ }
+ match (path.parent(), path.file_name()) {
+ (Some(parent), Some(name)) => canonical(parent).join(name),
+ _ => path.to_owned(),
+ }
+}
+
+/// The absolute file URI for `path` — used to open the compose template
+/// with `window/showDocument`.
+#[must_use]
+pub fn file_uri(path: &Path) -> Option<Url> {
+ Url::from_file_path(path).ok()
+}
crates/cli/ents-lens/src/error.rs
@@ -1,0 +1,45 @@
+//! The lens's error type: every failure a request handler can hit while
+//! reading `refs/meta/*`, projecting an anchor, or writing a new comment.
+
+/// A lens operation's result.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Everything that can go wrong deriving a lens response or composing a
+/// comment through it.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// A comment read, listing, projection, or mutation failed in the
+ /// shared `ents-forge` library the lens calls (`lens.parity`). The
+ /// caller should surface the message; it is never a protocol-level
+ /// fault.
+ #[error(transparent)]
+ Forge(#[from] ents_forge::Error),
+
+ /// The repository could not be opened at the injected path — the lens
+ /// was wired against a directory that is not a git working tree.
+ #[error("open repository: {0}")]
+ Repo(String),
+
+ /// A filesystem operation on the compose template under `.git/`
+ /// (`lens.compose`) failed. The caller should report it; the comment
+ /// was not created.
+ #[error("template {path}: {source}")]
+ Template {
+ /// The template path the operation targeted.
+ path: std::path::PathBuf,
+ /// The underlying IO error.
+ source: std::io::Error,
+ },
+
+ /// An `executeCommand` request named a command the lens exposes but
+ /// carried the wrong arguments (a missing or non-string comment id, for
+ /// instance). The caller sent a malformed request.
+ #[error("bad command arguments: {0}")]
+ BadArguments(String),
+}
+
+impl From<gix::open::Error> for Error {
+ fn from(source: gix::open::Error) -> Self {
+ Self::Repo(source.to_string())
+ }
+}
crates/cli/ents-lens/src/lens.rs
@@ -1,0 +1,519 @@
+//! The lens core: a read-time view over `refs/meta/comments/*` projected
+//! into whatever buffer the client has open, plus the compose flow that
+//! writes new comments back through the shared library.
+//!
+//! Every response is derived per request from an anchor projection onto the
+//! working tree — never cached across a comment-ref mutation (`lens.lenses`)
+//! — and every listing, projection, and write is the same
+//! `ents_forge::comment` call the CLI porcelain makes (`lens.parity`), so a
+//! comment is one entity across the editor, the CLI, and the web.
+
+use std::path::PathBuf;
+
+use ents_forge::comment::{self, ListFilter, Listed, NewComment};
+use ents_receive::{EventSink, Identity, Mode};
+use gix_object::{CommitRef, Find, Write};
+use gix_ref_store::RefStore;
+use lsp_types::{
+ CodeAction, CodeActionKind, CodeActionOrCommand, CodeLens, Command, Diagnostic, Hover,
+ HoverContents, Position, Range, Url,
+};
+use serde_json::{Value, json};
+
+use crate::compose::{self, Target};
+use crate::document::{self, Documents};
+use crate::error::{Error, Result};
+use crate::render;
+use crate::signing::Signing;
+
+/// What an `executeCommand` or a `didSave` produced, in protocol-neutral
+/// terms the server layer turns into LSP messages: an optional command
+/// result value, an optional file to open with `window/showDocument`, and
+/// whether the open documents' diagnostics should be republished
+/// (a comment-ref mutation invalidates every derived view, `lens.lenses`).
+#[derive(Debug, Default)]
+pub struct Outcome {
+ /// The `workspace/executeCommand` result value (the thread markup, for
+ /// View); `None` for a command whose effect is a side effect only.
+ pub response: Option<Value>,
+ /// A file the client should open (`window/showDocument`) — the compose
+ /// template, for Compose and Reply (`lens.compose`).
+ pub show_document: Option<PathBuf>,
+ /// Whether every open document's diagnostics should be recomputed and
+ /// republished, because a comment ref just changed.
+ pub refresh: bool,
+}
+
+/// The editor surface over one repository's comments. Holds the four
+/// composition-root seams it needs (the ref store, the object store, the
+/// event sink, and the signing identity — all injected, `lens.serve`), the
+/// gate mode, the working-tree path, and the client's open buffers; owns no
+/// derived state.
+///
+/// Generic over only the object store `O`, exactly as
+/// `ents_web::state::AppState` is and for the same reason: `refs` and
+/// `events` are already trait objects everywhere in this codebase, while
+/// every mutation primitive takes the object store as `&(impl Find +
+/// Write)`.
+pub struct Lens<O> {
+ refs: Box<dyn RefStore>,
+ objects: O,
+ events: Box<dyn EventSink>,
+ mode: Mode,
+ signing: Signing,
+ path: PathBuf,
+ documents: Documents,
+}
+
+impl<O: Find + Write> Lens<O> {
+ /// Wire a lens from already-resolved seams — the one constructor a
+ /// composition root calls (`git ents lsp`); the lens never opens a
+ /// store or resolves a key itself.
+ pub fn new(
+ refs: Box<dyn RefStore>,
+ objects: O,
+ events: Box<dyn EventSink>,
+ mode: Mode,
+ signing: Signing,
+ path: PathBuf,
+ ) -> Self {
+ Self {
+ refs,
+ objects,
+ events,
+ mode,
+ signing,
+ path,
+ documents: Documents::default(),
+ }
+ }
+
+ /// Record a document the client opened, with its full text
+ /// (`textDocument/didOpen`) — projection targets this buffer afterward
+ /// (`lens.working-tree`).
+ pub fn did_open(&mut self, uri: Url, text: String) {
+ self.documents.set(uri, text);
+ }
+
+ /// Replace an open document's text on a full-sync change
+ /// (`textDocument/didChange`), so ranges re-project against the unsaved
+ /// edit (`lens.working-tree`).
+ pub fn did_change(&mut self, uri: Url, text: String) {
+ self.documents.set(uri, text);
+ }
+
+ /// Forget a closed document (`textDocument/didClose`); projection falls
+ /// back to on-disk bytes.
+ pub fn did_close(&mut self, uri: &Url) {
+ self.documents.remove(uri);
+ }
+
+ /// The open comments (`model.comment-state`) anchored to `uri`'s
+ /// document, each projected onto its live buffer when open, on-disk
+ /// bytes otherwise — the one derivation every read response is built
+ /// from, recomputed here on every call and never cached (`lens.lenses`,
+ /// `lens.working-tree`, `lens.parity`).
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.lenses, lens.working-tree, lens.parity, scope=function)
+ fn document_comments(&self, uri: &Url) -> Result<Vec<Listed>> {
+ let Some(rel) = document::relative_path(&self.path, uri) else {
+ return Ok(Vec::new());
+ };
+ let buffer = self.documents.text(uri).map(str::as_bytes);
+ let filter = ListFilter {
+ state: Some("open".to_owned()),
+ context: None,
+ };
+ Ok(comment::list_for_document(
+ self.refs.as_ref(),
+ &self.objects,
+ &self.path,
+ &rel,
+ buffer,
+ &filter,
+ )?)
+ }
+
+ /// The code lenses for `uri` (`lens.lenses`): three per open comment
+ /// that projects onto the document — a summary lens plus Reply and
+ /// Resolve — omitting a comment whose anchor no longer lands there.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.lenses, scope=function)
+ pub fn code_lenses(&self, uri: &Url) -> Result<Vec<CodeLens>> {
+ let mut out = Vec::new();
+ for row in self.document_comments(uri)? {
+ if let Some((range, outdated)) = landed(&row) {
+ out.extend(render::code_lenses(&row.id, &row.comment, range, outdated));
+ }
+ }
+ Ok(out)
+ }
+
+ /// The hint-severity diagnostics for `uri` (`lens.diagnostics`): the
+ /// same projected comments as the lenses, one hint each, for clients
+ /// that do not render lenses. Never a warning or error.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.diagnostics, scope=function)
+ pub fn diagnostics(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
+ let mut out = Vec::new();
+ for row in self.document_comments(uri)? {
+ if let Some((range, outdated)) = landed(&row) {
+ out.push(render::diagnostic(&row.id, &row.comment, range, outdated));
+ }
+ }
+ Ok(out)
+ }
+
+ /// The hover for a position in `uri` (`lens.hover`): if it falls on a
+ /// projected comment's range, the whole thread rendered as Markdown —
+ /// bodies, states, and authorship read from each ref's commit chain.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.hover, scope=function)
+ pub fn hover(&self, uri: &Url, position: Position) -> Result<Option<Hover>> {
+ for row in self.document_comments(uri)? {
+ let Some((range, _outdated)) = landed(&row) else {
+ continue;
+ };
+ if position_in(position, range) {
+ let markup = self.thread_markup(&row.id)?;
+ return Ok(Some(Hover {
+ contents: HoverContents::Markup(markup),
+ range: Some(range),
+ }));
+ }
+ }
+ Ok(None)
+ }
+
+ /// The code actions for a selection in `uri` (`lens.compose`): the
+ /// "Leave an ents comment" action, whose command opens the compose
+ /// template anchored to exactly the selected lines against the working
+ /// tree. Empty when the URI is not a file in the working tree.
+ ///
+ /// # Errors
+ ///
+ /// Never fails today; returns [`Result`] for symmetry with the other
+ /// request handlers.
+ // @relation(lens.compose, scope=function)
+ pub fn code_actions(&self, uri: &Url, range: Range) -> Result<Vec<CodeActionOrCommand>> {
+ let Some(rel) = document::relative_path(&self.path, uri) else {
+ return Ok(Vec::new());
+ };
+ let lines = selection_lines(range);
+ let command = Command {
+ title: "Leave an ents comment".to_owned(),
+ command: render::CMD_COMPOSE.to_owned(),
+ arguments: Some(vec![json!({ "path": rel, "lines": lines })]),
+ };
+ Ok(vec![CodeActionOrCommand::CodeAction(CodeAction {
+ title: "Leave an ents comment".to_owned(),
+ kind: Some(CodeActionKind::EMPTY),
+ diagnostics: None,
+ edit: None,
+ command: Some(command),
+ is_preferred: None,
+ disabled: None,
+ data: None,
+ })])
+ }
+
+ /// Run a `workspace/executeCommand` the lens registered
+ /// (`lens.lenses`, `lens.compose`): View returns the thread, Resolve
+ /// records the state mutation through the shared library call, and
+ /// Reply/Compose open the compose template.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::BadArguments`] for a missing or malformed argument;
+ /// otherwise propagates the underlying comment library or template
+ /// failure.
+ // @relation(lens.lenses, lens.compose, lens.parity, scope=function)
+ pub fn execute_command(&self, command: &str, arguments: &[Value]) -> Result<Outcome> {
+ match command {
+ render::CMD_VIEW => {
+ let id = arg_id(arguments)?;
+ let markup = self.thread_markup(&id)?;
+ Ok(Outcome {
+ response: Some(json!(markup.value)),
+ ..Outcome::default()
+ })
+ }
+ render::CMD_RESOLVE => {
+ let id = arg_id(arguments)?;
+ let signer = &self.signing;
+ let sign = |payload: &[u8]| signer.sign(payload);
+ let identity = Identity {
+ actor: signer.actor(),
+ sign: &sign,
+ };
+ comment::resolve(
+ self.refs.as_ref(),
+ &self.objects,
+ self.events.as_ref(),
+ &id,
+ &identity,
+ self.mode,
+ )?;
+ Ok(Outcome {
+ refresh: true,
+ ..Outcome::default()
+ })
+ }
+ render::CMD_REPLY => {
+ let id = arg_id(arguments)?;
+ let target = Target {
+ parent: Some(id),
+ ..Target::default()
+ };
+ let template = self.write_template(&target)?;
+ Ok(Outcome {
+ show_document: Some(template),
+ ..Outcome::default()
+ })
+ }
+ render::CMD_COMPOSE => {
+ let target = compose_target(arguments)?;
+ let template = self.write_template(&target)?;
+ Ok(Outcome {
+ show_document: Some(template),
+ ..Outcome::default()
+ })
+ }
+ other => Err(Error::BadArguments(format!("unknown command {other}"))),
+ }
+ }
+
+ /// Handle a `textDocument/didSave`: if the saved file is the compose
+ /// template, finalize the comment (`lens.compose`); otherwise recompute
+ /// diagnostics, since the saved buffer now matches disk.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a template read or comment-creation failure.
+ // @relation(lens.compose, scope=function)
+ pub fn did_save(&self, uri: &Url) -> Result<Outcome> {
+ if self.is_template(uri) {
+ return self.finalize_compose();
+ }
+ Ok(Outcome {
+ refresh: true,
+ ..Outcome::default()
+ })
+ }
+
+ /// Every open document's URI — the server republishes diagnostics for
+ /// these after a mutation ([`Outcome::refresh`]).
+ #[must_use]
+ pub fn open_documents(&self) -> Vec<Url> {
+ self.documents.open_uris()
+ }
+
+ /// Diagnostics for `uri` even when the document is not open — the server
+ /// uses this to clear or refresh a specific document.
+ ///
+ /// # Errors
+ ///
+ /// See [`Lens::diagnostics`].
+ pub fn diagnostics_for(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
+ self.diagnostics(uri)
+ }
+
+ /// The compose template's absolute path, `<git-dir>/ENTS_COMMENT_EDITMSG`
+ /// (`lens.compose`).
+ fn template_path(&self) -> Result<PathBuf> {
+ let repo = gix::open(&self.path)?;
+ Ok(repo.git_dir().join("ENTS_COMMENT_EDITMSG"))
+ }
+
+ /// Whether `uri` names the compose template (a saved-template event).
+ fn is_template(&self, uri: &Url) -> bool {
+ let Ok(template) = self.template_path() else {
+ return false;
+ };
+ let Ok(saved) = uri.to_file_path() else {
+ return false;
+ };
+ let template = template.canonicalize().unwrap_or(template);
+ let saved = saved.canonicalize().unwrap_or(saved);
+ saved == template
+ }
+
+ /// Write the compose template for `target` and return its path
+ /// (`lens.compose`).
+ fn write_template(&self, target: &Target) -> Result<PathBuf> {
+ let template = self.template_path()?;
+ let text = compose::template_text(target);
+ std::fs::write(&template, text).map_err(|source| Error::Template {
+ path: template.clone(),
+ source,
+ })?;
+ Ok(template)
+ }
+
+ /// Read the saved template and create the comment it describes through
+ /// the shared library call (`lens.parity`), anchoring to the working
+ /// tree (`lens.working-tree`); an empty body aborts (`lens.compose`).
+ /// The template is always removed afterward so a stale one is never
+ /// reused.
+ fn finalize_compose(&self) -> Result<Outcome> {
+ let template = self.template_path()?;
+ let content = std::fs::read_to_string(&template).map_err(|source| Error::Template {
+ path: template.clone(),
+ source,
+ })?;
+ let composed = compose::parse(&content);
+ // Best effort: a leftover template is harmless — the next compose
+ // overwrites it — so a removal failure never aborts a comment that
+ // was otherwise created successfully.
+ if let Err(_error) = std::fs::remove_file(&template) {}
+ if composed.is_abort() {
+ return Ok(Outcome::default());
+ }
+
+ let signer = &self.signing;
+ let sign = |payload: &[u8]| signer.sign(payload);
+ let identity = Identity {
+ actor: signer.actor(),
+ sign: &sign,
+ };
+ if let Some(parent) = composed.target.parent {
+ comment::reply(
+ self.refs.as_ref(),
+ &self.objects,
+ self.events.as_ref(),
+ &parent,
+ composed.body,
+ &identity,
+ self.mode,
+ )?;
+ } else {
+ let new = NewComment {
+ body: composed.body,
+ path: composed.target.path,
+ lines: composed.target.lines,
+ rev: "HEAD".to_owned(),
+ worktree: true,
+ context: None,
+ parent: None,
+ };
+ comment::add(
+ self.refs.as_ref(),
+ &self.objects,
+ self.events.as_ref(),
+ &self.path,
+ new,
+ &identity,
+ self.mode,
+ )?;
+ }
+ Ok(Outcome {
+ refresh: true,
+ ..Outcome::default()
+ })
+ }
+
+ /// The whole thread rooted at `root_id` as hover Markdown (`lens.hover`)
+ /// — root plus replies (`thread_of`), each stamped with the author and
+ /// time read from its ref's tip mutation commit (`meta-ref.trailers`).
+ fn thread_markup(&self, root_id: &str) -> Result<lsp_types::MarkupContent> {
+ let rows = comment::thread_of(self.refs.as_ref(), &self.objects, root_id)?;
+ let mut with_authors = Vec::with_capacity(rows.len());
+ for (id, comment) in rows {
+ let (author, when) = self
+ .authorship(&id)
+ .unwrap_or_else(|| ("unknown".to_owned(), String::new()));
+ with_authors.push((id, comment, author, when));
+ }
+ Ok(render::hover_markup(&with_authors))
+ }
+
+ /// The author display name and a short date for the comment at `id`,
+ /// read from its ref's tip mutation commit (`model.comment`: authorship
+ /// lives in the commit chain, never a stored field). `None` when the
+ /// ref or its commit cannot be read.
+ fn authorship(&self, id: &str) -> Option<(String, String)> {
+ let ref_name = ents_model::namespace::comment_ref(id).ok()?;
+ let tip = self.refs.get(ref_name.as_ref()).ok().flatten()?;
+ let mut buf = Vec::new();
+ let data = Find::try_find(&self.objects, &tip, &mut buf).ok()??;
+ let commit = CommitRef::from_bytes(data.data, tip.kind()).ok()?;
+ let author = commit.author().ok()?;
+ let name = author.name.to_string();
+ let when = author
+ .time()
+ .ok()
+ .and_then(|time| time.format(gix::date::time::format::SHORT).ok())
+ .unwrap_or_default();
+ Some((name, when))
+ }
+}
+
+/// The `(range, outdated)` a listed comment lands at, or `None` when it does
+/// not project onto the document (deleted, or unanchored).
+fn landed(row: &Listed) -> Option<(Range, bool)> {
+ let anchor = row.anchor.as_ref()?;
+ let projection = row.projection.as_ref()?;
+ render::landed_range(projection, anchor)
+}
+
+/// Whether `position`'s line falls within `range` — hovering anywhere on an
+/// anchored line reveals its thread.
+fn position_in(position: Position, range: Range) -> bool {
+ position.line >= range.start.line && position.line <= range.end.line
+}
+
+/// The 1-based inclusive `<start>:<end>` line span a selection covers,
+/// collapsing a trailing full-line boundary (`end` at column 0 of the next
+/// line) back onto the last selected line.
+fn selection_lines(range: Range) -> String {
+ let start = range.start.line.saturating_add(1);
+ let end = if range.end.character == 0 && range.end.line > range.start.line {
+ range.end.line
+ } else {
+ range.end.line.saturating_add(1)
+ };
+ format!("{start}:{end}")
+}
+
+/// Extract a single comment-id string argument.
+fn arg_id(arguments: &[Value]) -> Result<String> {
+ arguments
+ .first()
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .ok_or_else(|| Error::BadArguments("expected a comment id argument".to_owned()))
+}
+
+/// Extract a [`Target`] from the `ents.compose` command's `{path,
+/// lines}` object argument.
+fn compose_target(arguments: &[Value]) -> Result<Target> {
+ let object = arguments
+ .first()
+ .ok_or_else(|| Error::BadArguments("compose needs a target".to_owned()))?;
+ Ok(Target {
+ path: object
+ .get("path")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ lines: object
+ .get("lines")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ parent: object
+ .get("parent")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ })
+}
crates/cli/ents-lens/src/lib.rs
@@ -1,0 +1,102 @@
+//! The lens: an editor-facing Language Server Protocol surface over
+//! `refs/meta/comments/*`, projecting the repository's anchored comments
+//! into whatever buffer the user is reading and writing new ones back
+//! through the same signed mutation path every other frontend uses.
+//!
+//! # One responsibility
+//!
+//! This crate is the third place a git-ents conversation surfaces, after
+//! the CLI and the web UI, and it earns no third mechanism (`docs/spec/lens.adoc`):
+//! it is a read-time *view* over `refs/meta/*`, owning no state of its own,
+//! so a comment left in an editor, on the web, or by an agent at the CLI is
+//! one and the same entity everywhere. Every listing, projection, and write
+//! is the exact `ents_forge::comment` library call the `git ents comment`
+//! porcelain makes (`lens.parity`); the lens never shells out and never
+//! reimplements listing or projection. It is a frontend of the local root
+//! and receives its signing identity by injection (`lens.serve`,
+//! `roots.web-agnostic`), exactly as `ents-web` does.
+//!
+//! # Spec coverage (`docs/spec/lens.adoc`)
+//!
+//! - `lens.serve` — [`serve_stdio`], stdio only, no socket, no git
+//! transport; the signing identity is the injected [`Signing`].
+//! - `lens.lenses` — [`Lens::code_lenses`]: one View/Reply/Resolve lens set
+//! per open comment projecting onto the document, derived per request.
+//! - `lens.diagnostics` — [`Lens::diagnostics`]: the same comments as
+//! hint-severity diagnostics, never warnings or errors.
+//! - `lens.hover` — [`Lens::hover`]: the full thread as Markdown, authorship
+//! read from each ref's commit chain.
+//! - `lens.compose` — [`Lens::code_actions`] plus the compose flow in
+//! [`compose`]: a code action opens a git-style template file, saving a
+//! non-empty body creates the comment.
+//! - `lens.working-tree` — projection targets the working tree, the open
+//! buffer standing in for disk, re-projected on every change.
+//! - `lens.parity` — every operation is an `ents_forge::comment` call.
+//!
+//! # The compose-on-save mechanism
+//!
+//! Composing works entirely through standard LSP a plain client provides —
+//! `workspace/executeCommand`, `window/showDocument`, and
+//! `textDocument/didSave` — with no client-specific extension. The
+//! `ents.compose` command writes a git-commit-style template under
+//! `.git/ENTS_COMMENT_EDITMSG` and asks the client to open it; when the user
+//! saves it, the `didSave` handler creates the comment (or aborts on an
+//! empty body). See [`compose`] for the exact grammar and rationale.
+//!
+//! # Worked example
+//!
+//! Wire a lens against a fresh repository (as `git ents lsp`'s composition
+//! root does) and ask it for the code lenses on a document — none yet, since
+//! no comment has been written:
+//!
+//! ```
+//! use ents_lens::{Lens, Signing};
+//! use ents_receive::{Mode, NullEventSink};
+//! use ents_testutil::{MemRefStore, ObjectStore};
+//!
+//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! let dir = tempfile::tempdir()?;
+//! gix::init(dir.path())?;
+//!
+//! // The composition root injects the signing identity (`lens.serve`); a
+//! // fixed fixture stands in for the user's own resolved key here.
+//! let signing = Signing::new(
+//! gix::actor::Signature {
+//! name: "jdc".into(),
+//! email: "jdc@ents.test".into(),
+//! time: gix::date::Time { seconds: 0, offset: 0 },
+//! },
+//! Box::new(|_payload| "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned()),
+//! "ssh-ed25519 AAAA jdc".to_owned(),
+//! );
+//!
+//! let lens = Lens::new(
+//! Box::new(MemRefStore::default()),
+//! ObjectStore::default(),
+//! Box::new(NullEventSink),
+//! Mode::Advisory,
+//! signing,
+//! dir.path().to_owned(),
+//! );
+//!
+//! let uri = lsp_types::Url::from_file_path(dir.path().join("src/lib.rs")).unwrap();
+//! assert!(lens.code_lenses(&uri)?.is_empty());
+//! assert!(lens.diagnostics(&uri)?.is_empty());
+//! # Ok(())
+//! # }
+//! ```
+
+pub mod compose;
+mod document;
+mod error;
+mod lens;
+mod render;
+mod server;
+mod signing;
+
+pub use compose::{Composed, Target};
+pub use error::{Error, Result};
+pub use lens::{Lens, Outcome};
+pub use render::{CMD_COMPOSE, CMD_REPLY, CMD_RESOLVE, CMD_VIEW};
+pub use server::{capabilities, serve_stdio};
+pub use signing::Signing;
crates/cli/ents-lens/src/render.rs
@@ -1,0 +1,294 @@
+//! Turning projected comments and threads into the LSP values the lens
+//! publishes: code lenses (`lens.lenses`), hint diagnostics
+//! (`lens.diagnostics`), and hover markup (`lens.hover`).
+//!
+//! Pure rendering only — every input is already-derived data (a `Listed`
+//! row, a thread), so the mapping from a comment to its on-screen shape is
+//! unit-testable without a repository, and [`crate::Lens`] owns the
+//! per-request derivation that feeds it.
+
+use ents_anchor::{Anchor, LineRange, Projection};
+use ents_forge::comment::Comment;
+use lsp_types::{
+ CodeLens, Command, Diagnostic, DiagnosticSeverity, MarkupContent, MarkupKind, Position, Range,
+};
+use serde_json::json;
+
+/// The `workspace/executeCommand` command that opens the thread
+/// (`lens.lenses`: the view operation).
+pub const CMD_VIEW: &str = "ents.view";
+/// The command that starts a reply compose (`lens.lenses`, `lens.compose`).
+pub const CMD_REPLY: &str = "ents.reply";
+/// The command that resolves a comment (`lens.lenses`,
+/// `model.comment-state`).
+pub const CMD_RESOLVE: &str = "ents.resolve";
+/// The command that opens the compose template for a new comment
+/// (`lens.compose`).
+pub const CMD_COMPOSE: &str = "ents.compose";
+
+/// The diagnostic/lens source label the lens stamps every item with, so a
+/// client can suppress just the conversation (`lens.diagnostics`).
+pub const SOURCE: &str = "ents";
+
+/// Where a projected comment lands on the open document, and whether its
+/// anchored lines were edited out from under it (`Projection::Outdated`) —
+/// `None` when the comment does not project onto the document at all
+/// (`Projection::Deleted`, or no anchor), so the caller omits it
+/// (`lens.lenses`).
+#[must_use]
+pub fn landed_range(projection: &Projection, anchor: &Anchor) -> Option<(Range, bool)> {
+ match projection {
+ Projection::Current => Some((line_range(anchor.lines), false)),
+ Projection::Relocated { lines, .. } => Some((line_range(*lines), false)),
+ Projection::Outdated { .. } => Some((line_range(anchor.lines), true)),
+ Projection::Deleted => None,
+ }
+}
+
+/// The half-open LSP [`Range`] covering a 1-based inclusive line range, or
+/// the document's first line for a whole-file anchor (`lines` is `None`).
+fn line_range(lines: Option<LineRange>) -> Range {
+ let (start, end) = match lines {
+ Some(range) => (
+ to_u32(range.start.saturating_sub(1)),
+ to_u32(range.end.saturating_sub(1)),
+ ),
+ None => (0, 0),
+ };
+ Range {
+ start: Position {
+ line: start,
+ character: 0,
+ },
+ // Extend to the end of the last line so a diagnostic underlines the
+ // whole anchored region; clients clamp the character to line length.
+ end: Position {
+ line: end,
+ character: u32::MAX,
+ },
+ }
+}
+
+fn to_u32(value: u64) -> u32 {
+ u32::try_from(value).unwrap_or(u32::MAX)
+}
+
+/// A one-line summary of a comment body for a lens title or diagnostic
+/// message: the first non-empty line, trimmed and capped so it fits inline.
+#[must_use]
+pub fn summary(body: &str) -> String {
+ const CAP: usize = 60;
+ let first = body
+ .lines()
+ .find(|line| !line.trim().is_empty())
+ .unwrap_or("")
+ .trim();
+ let mut chars = first.chars();
+ let capped: String = chars.by_ref().take(CAP).collect();
+ if chars.next().is_some() {
+ format!("{capped}…")
+ } else {
+ capped
+ }
+}
+
+/// The code lenses for one open root comment at `range` (`lens.lenses`):
+/// a primary lens identifying the comment and summarizing its body, then a
+/// Reply and a Resolve lens — the thread's operations offered as commands
+/// that call the same library operations the CLI exposes (`lens.parity`).
+#[must_use]
+pub fn code_lenses(id: &str, comment: &Comment, range: Range, outdated: bool) -> Vec<CodeLens> {
+ let mut title = format!("💬 {}: {}", short(id), summary(&comment.body));
+ if outdated {
+ title.push_str(" (outdated)");
+ }
+ let arg = vec![json!(id)];
+ vec![
+ CodeLens {
+ range,
+ command: Some(Command {
+ title,
+ command: CMD_VIEW.to_owned(),
+ arguments: Some(arg.clone()),
+ }),
+ data: None,
+ },
+ CodeLens {
+ range,
+ command: Some(Command {
+ title: "Reply".to_owned(),
+ command: CMD_REPLY.to_owned(),
+ arguments: Some(arg.clone()),
+ }),
+ data: None,
+ },
+ CodeLens {
+ range,
+ command: Some(Command {
+ title: "Resolve".to_owned(),
+ command: CMD_RESOLVE.to_owned(),
+ arguments: Some(arg),
+ }),
+ data: None,
+ },
+ ]
+}
+
+/// The hint-severity diagnostic mirroring one open comment at `range`
+/// (`lens.diagnostics`): the same conversation the code lens carries, for
+/// clients that do not render lenses. Never a warning or an error.
+#[must_use]
+pub fn diagnostic(id: &str, comment: &Comment, range: Range, outdated: bool) -> Diagnostic {
+ let mut message = format!("{}: {}", short(id), summary(&comment.body));
+ if outdated {
+ message.push_str(" (outdated — the anchored lines changed)");
+ }
+ Diagnostic {
+ range,
+ // `lens.diagnostics` is binding: conversation carries no judgment,
+ // so this is always a hint, never a warning or error.
+ severity: Some(DiagnosticSeverity::HINT),
+ code: None,
+ code_description: None,
+ source: Some(SOURCE.to_owned()),
+ message,
+ related_information: None,
+ tags: None,
+ data: None,
+ }
+}
+
+/// The hover markup for a thread (`lens.hover`): every comment in it —
+/// bodies, states, and authorship — rendered as Markdown so the whole
+/// conversation is readable in the buffer. `rows` are `(id, comment,
+/// author, when)` in thread order; `author`/`when` come from each ref's
+/// mutation commit chain (`meta-ref.trailers`), read by the caller.
+#[must_use]
+pub fn hover_markup(rows: &[(String, Comment, String, String)]) -> MarkupContent {
+ let mut value = String::new();
+ for (index, (id, comment, author, when)) in rows.iter().enumerate() {
+ if index > 0 {
+ value.push_str("\n---\n\n");
+ }
+ let reply = if comment.parent.is_some() { "↳ " } else { "" };
+ value.push_str(&format!(
+ "**{reply}{author}** · `{}` · _{}_ · {when}\n\n",
+ comment.state,
+ short(id)
+ ));
+ value.push_str(comment.body.trim());
+ value.push('\n');
+ }
+ MarkupContent {
+ kind: MarkupKind::Markdown,
+ value,
+ }
+}
+
+/// A comment id shortened for display — the first seven characters, the
+/// same length git uses for a short object id.
+fn short(id: &str) -> &str {
+ id.get(..7).unwrap_or(id)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use super::*;
+
+ fn comment(body: &str, state: &str) -> Comment {
+ Comment {
+ body: body.to_owned(),
+ state: state.to_owned(),
+ anchor: None,
+ context: None,
+ parent: None,
+ }
+ }
+
+ #[test]
+ // @relation(lens.diagnostics, scope=function, role=Verifies)
+ fn a_diagnostic_is_always_a_hint() {
+ let diag = diagnostic(
+ "abc1234def",
+ &comment("hi", "open"),
+ line_range(None),
+ false,
+ );
+ assert_eq!(diag.severity, Some(DiagnosticSeverity::HINT));
+ assert_eq!(diag.source.as_deref(), Some(SOURCE));
+ }
+
+ #[test]
+ // @relation(lens.lenses, scope=function, role=Verifies)
+ fn lenses_offer_view_reply_resolve() {
+ let lenses = code_lenses(
+ "abc1234def",
+ &comment("body text", "open"),
+ line_range(None),
+ false,
+ );
+ let commands: Vec<&str> = lenses
+ .iter()
+ .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str()))
+ .collect();
+ assert_eq!(commands, vec![CMD_VIEW, CMD_REPLY, CMD_RESOLVE]);
+ let primary = lenses.first().unwrap().command.as_ref().unwrap();
+ assert!(primary.title.contains("body text"));
+ }
+
+ #[test]
+ fn summary_caps_and_takes_the_first_nonempty_line() {
+ assert_eq!(summary("\n\nfirst real line\nsecond"), "first real line");
+ let long = "x".repeat(80);
+ assert!(summary(&long).ends_with('…'));
+ }
+
+ #[test]
+ // @relation(anchor.projection, scope=function, role=Verifies)
+ fn deleted_projection_does_not_land() {
+ let anchor_lines = None;
+ let range = line_range(anchor_lines);
+ // Current lands; Deleted does not.
+ assert!(landed_range(&Projection::Current, &fake_anchor()).is_some());
+ assert!(landed_range(&Projection::Deleted, &fake_anchor()).is_none());
+ let _ = range;
+ }
+
+ fn fake_anchor() -> Anchor {
+ // A whole-file anchor is enough for `landed_range`, which only reads
+ // `anchor.lines`.
+ let dir = tempfile::tempdir().unwrap();
+ std::process::Command::new("git")
+ .arg("init")
+ .arg("-q")
+ .arg(dir.path())
+ .status()
+ .unwrap();
+ std::fs::write(dir.path().join("f.txt"), "a\n").unwrap();
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(["add", "-A"])
+ .status()
+ .unwrap();
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args([
+ "-c",
+ "user.name=t",
+ "-c",
+ "user.email=t@e.com",
+ "commit",
+ "-q",
+ "-m",
+ "x",
+ ])
+ .status()
+ .unwrap();
+ let repo = gix::open(dir.path()).unwrap();
+ ents_anchor::capture(&repo, "HEAD", "f.txt", None).unwrap()
+ }
+}
crates/cli/ents-lens/src/server.rs
@@ -1,0 +1,350 @@
+//! The stdio Language Server Protocol adapter (`lens.serve`): a thin,
+//! synchronous dispatch loop over `lsp-server` that forwards each request to
+//! a [`Lens`] method and turns the [`Outcome`] back into LSP messages.
+//!
+//! All derivation lives in [`Lens`]; this module only frames JSON-RPC,
+//! declares capabilities, and routes. It binds no socket and adds no git
+//! transport (`lens.serve`) — the only IO is stdin/stdout.
+//!
+//! `lsp-server` (rust-analyzer's own scaffold) is synchronous, which suits
+//! the lens exactly: every operation it performs — reading `refs/meta/*`,
+//! diffing against the working tree, writing a signed commit — is blocking
+//! git and filesystem work, so an async runtime would only wrap blocking
+//! calls in `spawn_blocking` for no gain. The framing and pack of message
+//! types come from the crate; nothing here hand-rolls JSON-RPC.
+#![expect(
+ clippy::let_underscore_must_use,
+ reason = "sending on the LSP transport is best-effort: a closed pipe ends the loop on the \
+ next receive, so a failed send needs no separate handling"
+)]
+
+use gix_object::{Find, Write};
+use lsp_server::{Connection, ExtractError, Message, Request, RequestId, Response};
+use lsp_types::notification::{
+ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, DidSaveTextDocument,
+ Notification as _, PublishDiagnostics,
+};
+use lsp_types::request::{
+ CodeActionRequest, CodeLensRequest, ExecuteCommand, HoverRequest, Request as _, ShowDocument,
+};
+use lsp_types::{
+ CodeActionOptions, CodeActionProviderCapability, CodeLensOptions, DidChangeTextDocumentParams,
+ DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
+ ExecuteCommandOptions, HoverProviderCapability, PublishDiagnosticsParams, ServerCapabilities,
+ ShowDocumentParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
+ TextDocumentSyncSaveOptions, Url,
+};
+
+use crate::lens::{Lens, Outcome};
+use crate::render;
+
+/// The capabilities the lens advertises (`lens.serve`): full-text document
+/// sync with save notifications (the compose flow needs the save,
+/// `lens.compose`), code lenses (`lens.lenses`), hover (`lens.hover`), code
+/// actions (`lens.compose`), and the four executable commands
+/// (`lens.lenses`, `lens.compose`). No workspace, symbol, or completion
+/// surface — the lens is a conversation view, not a language analyzer.
+#[must_use]
+pub fn capabilities() -> ServerCapabilities {
+ ServerCapabilities {
+ text_document_sync: Some(TextDocumentSyncCapability::Options(
+ TextDocumentSyncOptions {
+ open_close: Some(true),
+ change: Some(TextDocumentSyncKind::FULL),
+ save: Some(TextDocumentSyncSaveOptions::Supported(true)),
+ ..TextDocumentSyncOptions::default()
+ },
+ )),
+ code_lens_provider: Some(CodeLensOptions {
+ resolve_provider: Some(false),
+ }),
+ hover_provider: Some(HoverProviderCapability::Simple(true)),
+ code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
+ ..CodeActionOptions::default()
+ })),
+ execute_command_provider: Some(ExecuteCommandOptions {
+ commands: vec![
+ render::CMD_VIEW.to_owned(),
+ render::CMD_REPLY.to_owned(),
+ render::CMD_RESOLVE.to_owned(),
+ render::CMD_COMPOSE.to_owned(),
+ ],
+ work_done_progress_options: lsp_types::WorkDoneProgressOptions::default(),
+ }),
+ ..ServerCapabilities::default()
+ }
+}
+
+/// Serve the lens over stdio until the client shuts it down (`lens.serve`).
+///
+/// Performs the LSP initialize handshake advertising [`capabilities`], then
+/// runs the dispatch loop. Binds no socket and speaks only stdin/stdout.
+///
+/// # Errors
+///
+/// [`std::io::Error`] if the JSON-RPC transport fails (a broken pipe, a
+/// malformed frame) or the initialize handshake does not complete.
+pub fn serve_stdio<O: Find + Write>(lens: Lens<O>) -> std::io::Result<()> {
+ let (connection, io_threads) = Connection::stdio();
+ let capabilities = serde_json::to_value(capabilities())
+ .map_err(|source| std::io::Error::other(source.to_string()))?;
+ let _init_params = connection
+ .initialize(capabilities)
+ .map_err(|source| std::io::Error::other(source.to_string()))?;
+ let mut server = ServerLoop { connection, lens };
+ server.run()?;
+ io_threads.join()?;
+ Ok(())
+}
+
+/// The running dispatch loop: owns the connection and the lens, and a
+/// counter for the ids of the server-initiated requests it sends (only
+/// `window/showDocument`).
+struct ServerLoop<O> {
+ connection: Connection,
+ lens: Lens<O>,
+}
+
+impl<O: Find + Write> ServerLoop<O> {
+ fn run(&mut self) -> std::io::Result<()> {
+ // `iter()` yields until the client closes the connection; a
+ // `shutdown` request breaks the loop through `handle_shutdown`.
+ while let Ok(message) = self.connection.receiver.recv() {
+ match message {
+ Message::Request(request) => {
+ if self
+ .connection
+ .handle_shutdown(&request)
+ .map_err(|source| std::io::Error::other(source.to_string()))?
+ {
+ break;
+ }
+ self.on_request(request);
+ }
+ Message::Notification(notification) => self.on_notification(notification),
+ // Responses to our own `window/showDocument` requests carry
+ // nothing the lens needs to act on.
+ Message::Response(_) => {}
+ }
+ }
+ Ok(())
+ }
+
+ /// Route one request to a [`Lens`] read handler, replying with its
+ /// result or an error response.
+ fn on_request(&mut self, request: Request) {
+ let request = match self.request::<CodeLensRequest, _>(request, |lens, params| {
+ lens.code_lenses(¶ms.text_document.uri).map(Some)
+ }) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ let request = match self.request::<HoverRequest, _>(request, |lens, params| {
+ let position = params.text_document_position_params;
+ lens.hover(&position.text_document.uri, position.position)
+ }) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ let request = match self.request::<CodeActionRequest, _>(request, |lens, params| {
+ lens.code_actions(¶ms.text_document.uri, params.range)
+ .map(Some)
+ }) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ // `executeCommand` is the one request with side effects, handled on
+ // its own so its `Outcome` can drive `showDocument` and refreshes.
+ let request = match self.on_execute_command(request) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ // Any other request: an empty success, so a client probing an
+ // unsupported method gets a well-formed (null) reply, never a hang.
+ self.respond(Response::new_ok(request.id, serde_json::Value::Null));
+ }
+
+ /// Extract and answer a plain read request `R`, returning `Err(request)`
+ /// unchanged when it is a different method so the caller can try the
+ /// next.
+ fn request<R, F>(&mut self, request: Request, handle: F) -> Result<(), Request>
+ where
+ R: lsp_types::request::Request,
+ F: FnOnce(&Lens<O>, R::Params) -> crate::error::Result<R::Result>,
+ R::Result: serde::Serialize,
+ {
+ match request.extract::<R::Params>(R::METHOD) {
+ Ok((id, params)) => {
+ let response = match handle(&self.lens, params) {
+ Ok(result) => Response::new_ok(id, result),
+ Err(error) => error_response(id, &error),
+ };
+ self.respond(response);
+ Ok(())
+ }
+ Err(ExtractError::MethodMismatch(request)) => Err(request),
+ Err(ExtractError::JsonError { .. }) => {
+ // A malformed params payload for a method we do own: there is
+ // no id to reply against cleanly here, so drop it — the
+ // client will observe the missing response.
+ Ok(())
+ }
+ }
+ }
+
+ /// Handle `workspace/executeCommand`, applying the [`Outcome`]: reply
+ /// with its result value, open the compose template if it named one, and
+ /// republish diagnostics when a mutation invalidated them.
+ fn on_execute_command(&mut self, request: Request) -> Result<(), Request> {
+ match request.extract::<<ExecuteCommand as lsp_types::request::Request>::Params>(
+ ExecuteCommand::METHOD,
+ ) {
+ Ok((id, params)) => {
+ match self
+ .lens
+ .execute_command(¶ms.command, ¶ms.arguments)
+ {
+ Ok(outcome) => {
+ let value = outcome.response.clone().unwrap_or(serde_json::Value::Null);
+ self.respond(Response::new_ok(id, value));
+ self.apply(outcome);
+ }
+ Err(error) => self.respond(error_response(id, &error)),
+ }
+ Ok(())
+ }
+ Err(ExtractError::MethodMismatch(request)) => Err(request),
+ Err(ExtractError::JsonError { .. }) => Ok(()),
+ }
+ }
+
+ /// Route one notification to the matching [`Lens`] document or save
+ /// handler, republishing diagnostics as the sync events demand.
+ fn on_notification(&mut self, notification: lsp_server::Notification) {
+ match notification.method.as_str() {
+ DidOpenTextDocument::METHOD => {
+ if let Ok(params) = extract_notification::<DidOpenTextDocumentParams>(notification)
+ {
+ let uri = params.text_document.uri.clone();
+ self.lens.did_open(uri.clone(), params.text_document.text);
+ self.publish(&uri);
+ }
+ }
+ DidChangeTextDocument::METHOD => {
+ if let Ok(params) =
+ extract_notification::<DidChangeTextDocumentParams>(notification)
+ {
+ let uri = params.text_document.uri.clone();
+ // Full sync: the last change carries the whole buffer.
+ if let Some(change) = params.content_changes.into_iter().next_back() {
+ self.lens.did_change(uri.clone(), change.text);
+ }
+ self.publish(&uri);
+ }
+ }
+ DidCloseTextDocument::METHOD => {
+ if let Ok(params) = extract_notification::<DidCloseTextDocumentParams>(notification)
+ {
+ self.lens.did_close(¶ms.text_document.uri);
+ // Clear this document's diagnostics on close.
+ self.publish_list(¶ms.text_document.uri, Vec::new());
+ }
+ }
+ DidSaveTextDocument::METHOD => {
+ if let Ok(params) = extract_notification::<DidSaveTextDocumentParams>(notification)
+ {
+ match self.lens.did_save(¶ms.text_document.uri) {
+ Ok(outcome) => self.apply(outcome),
+ Err(error) => log(&format!("didSave: {error}")),
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// Apply an [`Outcome`]'s side effects: open the compose template and/or
+ /// republish diagnostics for every open document.
+ fn apply(&mut self, outcome: Outcome) {
+ if let Some(path) = outcome.show_document
+ && let Some(uri) = crate::document::file_uri(&path)
+ {
+ self.show_document(uri);
+ }
+ if outcome.refresh {
+ for uri in self.lens.open_documents() {
+ self.publish(&uri);
+ }
+ }
+ }
+
+ /// Compute and publish diagnostics for one document (`lens.diagnostics`).
+ fn publish(&self, uri: &Url) {
+ match self.lens.diagnostics_for(uri) {
+ Ok(diagnostics) => self.publish_list(uri, diagnostics),
+ Err(error) => log(&format!("diagnostics for {uri}: {error}")),
+ }
+ }
+
+ /// Send a `textDocument/publishDiagnostics` notification.
+ fn publish_list(&self, uri: &Url, diagnostics: Vec<lsp_types::Diagnostic>) {
+ let params = PublishDiagnosticsParams {
+ uri: uri.clone(),
+ diagnostics,
+ version: None,
+ };
+ let notification =
+ lsp_server::Notification::new(PublishDiagnostics::METHOD.to_owned(), params);
+ let _ = self
+ .connection
+ .sender
+ .send(Message::Notification(notification));
+ }
+
+ /// Ask the client to open `uri` (`window/showDocument`) — the compose
+ /// template (`lens.compose`).
+ fn show_document(&self, uri: Url) {
+ let params = ShowDocumentParams {
+ uri,
+ external: Some(false),
+ take_focus: Some(true),
+ selection: None,
+ };
+ let request = Request {
+ // A fixed id: the lens never correlates showDocument responses,
+ // and only one is ever in flight per user action.
+ id: RequestId::from("ents-show-document".to_owned()),
+ method: ShowDocument::METHOD.to_owned(),
+ params: serde_json::to_value(params).unwrap_or(serde_json::Value::Null),
+ };
+ let _ = self.connection.sender.send(Message::Request(request));
+ }
+
+ fn respond(&self, response: Response) {
+ let _ = self.connection.sender.send(Message::Response(response));
+ }
+}
+
+/// Build an LSP error response from a lens error, so a failing request gets
+/// a well-formed fault rather than a dropped reply.
+fn error_response(id: RequestId, error: &crate::error::Error) -> Response {
+ Response::new_err(
+ id,
+ lsp_server::ErrorCode::RequestFailed as i32,
+ error.to_string(),
+ )
+}
+
+/// Deserialize a notification's params, returning `Err` on a mismatch or a
+/// malformed payload.
+fn extract_notification<P: serde::de::DeserializeOwned>(
+ notification: lsp_server::Notification,
+) -> Result<P, ()> {
+ serde_json::from_value(notification.params).map_err(|_error| ())
+}
+
+/// Emit a diagnostic line on stderr — the lens's own log channel, since
+/// stdout carries the LSP framing.
+fn log(message: &str) {
+ eprintln!("ents-lens: {message}");
+}
crates/cli/ents-lens/src/signing.rs
@@ -1,0 +1,82 @@
+//! The signing identity the composition root injects into the lens
+//! (`lens.serve`, `roots.web-agnostic`).
+//!
+//! The lens writes new comments through the same signed mutation path
+//! every other frontend uses (`lens.parity`), so it must be handed an
+//! identity to sign with — but, exactly like `ents-web`, it resolves no
+//! key itself and assumes nothing about which editor (if any) is attached.
+//! [`Signing`] is a plain owned value the root builds once and moves in;
+//! there is no second implementation to abstract over the way `ents-web`'s
+//! hosted/local split needs, because a lens only ever serves the local
+//! root (`lens.serve`), so a concrete carrier is enough and no trait is
+//! introduced.
+
+/// A closure that signs a commit's to-be-signed bytes, producing an armored
+/// SSHSIG PEM block — the injected half of [`Signing`].
+pub type SignFn = Box<dyn Fn(&[u8]) -> String>;
+
+/// An owned signing identity: the commit author signature and a closure
+/// that produces an SSHSIG armored block for a commit's bytes, plus the
+/// public key that identifies the acting member.
+///
+/// Built by the composition root from the user's own key (the same
+/// resolution `git ents comment` and `git ents serve` perform) and moved
+/// into the [`crate::Lens`]; the lens never resolves a key path or reads
+/// `user.signingkey` itself.
+///
+/// # Examples
+///
+/// ```
+/// use ents_lens::Signing;
+///
+/// let signing = Signing::new(
+/// gix::actor::Signature {
+/// name: "jdc".into(),
+/// email: "jdc@ents.test".into(),
+/// time: gix::date::Time { seconds: 0, offset: 0 },
+/// },
+/// Box::new(|_payload| "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned()),
+/// "ssh-ed25519 AAAA... jdc".to_owned(),
+/// );
+/// assert_eq!(signing.actor().name, "jdc");
+/// ```
+pub struct Signing {
+ actor: gix::actor::Signature,
+ sign: SignFn,
+ public_openssh: String,
+}
+
+impl Signing {
+ /// Build a signing identity from an already-resolved key: the commit
+ /// `actor` signature, a `sign` closure over the key, and the key's
+ /// `public_openssh` single-line form.
+ #[must_use]
+ pub fn new(actor: gix::actor::Signature, sign: SignFn, public_openssh: String) -> Self {
+ Self {
+ actor,
+ sign,
+ public_openssh,
+ }
+ }
+
+ /// The commit author/committer signature every comment mutation this
+ /// identity signs will carry.
+ #[must_use]
+ pub fn actor(&self) -> gix::actor::Signature {
+ self.actor.clone()
+ }
+
+ /// The public half of this identity's key, in OpenSSH single-line
+ /// form — which enrolled member is acting.
+ #[must_use]
+ pub fn public_openssh(&self) -> &str {
+ &self.public_openssh
+ }
+
+ /// Sign `payload` (a commit's to-be-signed bytes), returning the
+ /// armored SSHSIG PEM block for the commit's `gpgsig` header.
+ #[must_use]
+ pub fn sign(&self, payload: &[u8]) -> String {
+ (self.sign)(payload)
+ }
+}
crates/cli/ents-lens/tests/lens.rs
@@ -1,0 +1,408 @@
+//! Integration coverage for `docs/spec/lens.adoc`, driving the [`Lens`]
+//! request handlers directly against a fixture repository — the strategy
+//! the engineering conventions select for a protocol surface: construct the
+//! server in-process with a real working tree and a comment anchored into
+//! it, then assert each handler's derived LSP value, rather than spawning a
+//! stdio process and parsing frames. The JSON-RPC framing is `lsp-server`'s
+//! own tested concern; what this crate owns is the derivation, so that is
+//! what these tests exercise.
+//!
+//! The seams are `ents-testutil`'s in-memory `MemRefStore`/`ObjectStore`
+//! (the same pair every library crate's tests use) paired with a real
+//! on-disk repository for the working tree the anchors project onto —
+//! `ents_forge::comment::add` embeds the anchored bytes into the object
+//! store, so the two stay consistent even though only one is on disk.
+
+#![allow(
+ clippy::expect_used,
+ clippy::unwrap_used,
+ clippy::indexing_slicing,
+ clippy::panic,
+ reason = "integration test"
+)]
+
+use std::path::Path;
+use std::process::Command;
+
+use ents_forge::comment::{self, NewComment};
+use ents_lens::{CMD_COMPOSE, CMD_RESOLVE, CMD_VIEW, Lens, Signing};
+use ents_receive::{Identity, Mode, NullEventSink};
+use ents_testutil::{Keypair, MemRefStore, ObjectStore};
+use lsp_types::{DiagnosticSeverity, HoverContents, Position, Range, Url};
+use serde_json::json;
+
+/// A fixture repository, its in-memory seams, and a deterministic signing
+/// key — everything a [`Lens`] needs to be wired the way `git ents lsp`
+/// wires it.
+struct Fixture {
+ dir: tempfile::TempDir,
+ refs: MemRefStore,
+ objects: ObjectStore,
+ key: Keypair,
+}
+
+impl Fixture {
+ /// A repository holding `file.txt` with ten numbered lines, committed.
+ fn new() -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init(dir.path()).expect("init");
+ let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect();
+ commit_file(dir.path(), "file.txt", &contents);
+ Self {
+ dir,
+ refs: MemRefStore::default(),
+ objects: ObjectStore::default(),
+ key: Keypair::from_seed(1),
+ }
+ }
+
+ fn uri(&self, rel: &str) -> Url {
+ Url::from_file_path(self.dir.path().join(rel)).expect("file uri")
+ }
+
+ fn actor(&self) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: "jdc".into(),
+ email: "jdc@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+ }
+
+ /// Add a comment through the same library call the CLI makes
+ /// (`lens.parity`), anchored to `lines` of `file.txt` against the
+ /// working tree.
+ fn add_comment(&self, body: &str, lines: Option<&str>) -> String {
+ let new = NewComment {
+ body: body.to_owned(),
+ path: Some("file.txt".to_owned()),
+ lines: lines.map(str::to_owned),
+ rev: "HEAD".to_owned(),
+ worktree: true,
+ context: None,
+ parent: None,
+ };
+ let key = &self.key;
+ let sign = |payload: &[u8]| key.sign(payload);
+ let identity = Identity {
+ actor: self.actor(),
+ sign: &sign,
+ };
+ let (id, _outcome) = comment::add(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ self.dir.path(),
+ new,
+ &identity,
+ Mode::Advisory,
+ )
+ .expect("adds a comment");
+ id
+ }
+
+ /// Consume the fixture into a wired [`Lens`] (the seams move in, exactly
+ /// as `git ents lsp`'s composition root moves `LocalRoot`'s seams in).
+ fn into_lens(self) -> (Lens<ObjectStore>, tempfile::TempDir) {
+ let key = Keypair::from_seed(1);
+ let signing = Signing::new(
+ self.actor(),
+ Box::new(move |payload| key.sign(payload)),
+ self.key.public_openssh(),
+ );
+ let lens = Lens::new(
+ Box::new(self.refs),
+ self.objects,
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ signing,
+ self.dir.path().to_owned(),
+ );
+ (lens, self.dir)
+ }
+}
+
+fn commit_file(dir: &Path, path: &str, contents: &str) {
+ std::fs::write(dir.join(path), contents).expect("write");
+ run_git(dir, &["add", "-A"]);
+ run_git(
+ dir,
+ &[
+ "-c",
+ "user.name=test",
+ "-c",
+ "user.email=test@example.com",
+ "commit",
+ "-q",
+ "-m",
+ "seed",
+ ],
+ );
+}
+
+fn run_git(dir: &Path, args: &[&str]) {
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .status()
+ .expect("git runs");
+ assert!(status.success(), "git {args:?} failed");
+}
+
+/// `lens.lenses`: an open comment whose anchor projects onto the document
+/// surfaces as code lenses at its projected line, identifying the comment
+/// and offering View/Reply/Resolve as commands. `lens.diagnostics`: the
+/// same comment is also a hint-severity diagnostic at the same range.
+#[test]
+// @relation(lens.lenses, lens.diagnostics, scope=function, role=Verifies)
+fn code_lenses_and_hint_diagnostics_surface_an_open_comment() {
+ let fixture = Fixture::new();
+ fixture.add_comment("this looks off by one", Some("5:5"));
+ let uri = fixture.uri("file.txt");
+ let (lens, _dir) = fixture.into_lens();
+
+ let lenses = lens.code_lenses(&uri).expect("code lenses");
+ assert_eq!(lenses.len(), 3, "one View/Reply/Resolve set");
+ // Line 5 is 0-based line 4.
+ assert_eq!(lenses[0].range.start.line, 4);
+ let commands: Vec<&str> = lenses
+ .iter()
+ .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str()))
+ .collect();
+ assert!(commands.contains(&CMD_VIEW));
+ assert!(commands.contains(&"ents.reply"));
+ assert!(commands.contains(&CMD_RESOLVE));
+ assert!(
+ lenses[0]
+ .command
+ .as_ref()
+ .unwrap()
+ .title
+ .contains("off by one")
+ );
+
+ let diagnostics = lens.diagnostics(&uri).expect("diagnostics");
+ assert_eq!(diagnostics.len(), 1);
+ // `lens.diagnostics` is binding: hint severity, never a warning/error.
+ assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
+ assert_eq!(diagnostics[0].range.start.line, 4);
+}
+
+/// `lens.hover`: hovering the anchored range returns the whole thread —
+/// the root comment and its reply, bodies and authorship — as markup.
+#[test]
+// @relation(lens.hover, scope=function, role=Verifies)
+fn hover_returns_the_full_thread() {
+ let fixture = Fixture::new();
+ let root = fixture.add_comment("root remark", Some("5:5"));
+ // A reply, created through the same library the lens uses.
+ let key = Keypair::from_seed(1);
+ let sign = |payload: &[u8]| key.sign(payload);
+ let identity = Identity {
+ actor: fixture.actor(),
+ sign: &sign,
+ };
+ comment::reply(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &root,
+ "a reply body".to_owned(),
+ &identity,
+ Mode::Advisory,
+ )
+ .expect("replies");
+ let uri = fixture.uri("file.txt");
+ let (lens, _dir) = fixture.into_lens();
+
+ let hover = lens
+ .hover(
+ &uri,
+ Position {
+ line: 4,
+ character: 0,
+ },
+ )
+ .expect("hover")
+ .expect("a comment is anchored at line 5");
+ let HoverContents::Markup(markup) = hover.contents else {
+ panic!("hover must be markup");
+ };
+ assert!(markup.value.contains("root remark"));
+ assert!(markup.value.contains("a reply body"));
+ assert!(
+ markup.value.contains("jdc"),
+ "authorship from the commit chain"
+ );
+
+ // Hovering an unrelated line yields nothing.
+ assert!(
+ lens.hover(
+ &uri,
+ Position {
+ line: 0,
+ character: 0
+ }
+ )
+ .expect("hover")
+ .is_none()
+ );
+}
+
+/// `lens.compose`: a code action on a selection offers "Leave an ents
+/// comment", whose command opens the template; running it writes the
+/// template under `.git/` and asks the client to open that file.
+#[test]
+// @relation(lens.compose, scope=function, role=Verifies)
+fn code_action_and_compose_open_the_template() {
+ let fixture = Fixture::new();
+ let uri = fixture.uri("file.txt");
+ let (lens, dir) = fixture.into_lens();
+
+ let range = Range {
+ start: Position {
+ line: 1,
+ character: 0,
+ },
+ end: Position {
+ line: 2,
+ character: 0,
+ },
+ };
+ let actions = lens.code_actions(&uri, range).expect("code actions");
+ assert_eq!(actions.len(), 1);
+ let lsp_types::CodeActionOrCommand::CodeAction(action) = &actions[0] else {
+ panic!("expected a code action");
+ };
+ assert_eq!(action.title, "Leave an ents comment");
+ let command = action.command.as_ref().expect("carries a command");
+ assert_eq!(command.command, CMD_COMPOSE);
+
+ // Running the command writes the template and asks to open it.
+ let outcome = lens
+ .execute_command(
+ CMD_COMPOSE,
+ &[json!({ "path": "file.txt", "lines": "2:2" })],
+ )
+ .expect("compose");
+ let template = outcome.show_document.expect("opens the template");
+ assert_eq!(
+ template,
+ dir.path().join(".git").join("ENTS_COMMENT_EDITMSG")
+ );
+ let written = std::fs::read_to_string(&template).expect("template written");
+ assert!(written.contains("ents-compose-path: file.txt"));
+ assert!(written.contains("Lines starting with '#' are ignored"));
+}
+
+/// `lens.compose` end to end: saving the template with a non-empty body
+/// creates the comment (anchored to the working tree, `lens.working-tree`),
+/// and it then surfaces as a code lens; an empty body aborts.
+#[test]
+// @relation(lens.compose, lens.working-tree, lens.parity, scope=function, role=Verifies)
+fn saving_a_nonempty_body_creates_the_comment_and_empty_aborts() {
+ let fixture = Fixture::new();
+ let uri = fixture.uri("file.txt");
+ let (lens, dir) = fixture.into_lens();
+ let template = dir.path().join(".git").join("ENTS_COMMENT_EDITMSG");
+ let template_uri = Url::from_file_path(&template).unwrap();
+
+ // Start a compose targeting line 3.
+ lens.execute_command(
+ CMD_COMPOSE,
+ &[json!({ "path": "file.txt", "lines": "3:3" })],
+ )
+ .expect("compose");
+
+ // An empty save aborts: no comment, template removed.
+ std::fs::write(
+ &template,
+ "\n# only comments here\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n",
+ )
+ .unwrap();
+ lens.did_save(&template_uri).expect("save");
+ assert!(lens.code_lenses(&uri).expect("lenses").is_empty());
+ assert!(!template.exists(), "aborted compose removes the template");
+
+ // Re-start and save a real body: the comment is created and surfaces.
+ lens.execute_command(
+ CMD_COMPOSE,
+ &[json!({ "path": "file.txt", "lines": "3:3" })],
+ )
+ .expect("compose");
+ std::fs::write(
+ &template,
+ "the third line is wrong\n# ignored\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n",
+ )
+ .unwrap();
+ lens.did_save(&template_uri).expect("save");
+
+ let lenses = lens.code_lenses(&uri).expect("lenses");
+ assert_eq!(lenses.len(), 3, "the composed comment now surfaces");
+ assert_eq!(lenses[0].range.start.line, 2, "anchored at line 3");
+ assert!(
+ lenses[0]
+ .command
+ .as_ref()
+ .unwrap()
+ .title
+ .contains("third line is wrong")
+ );
+}
+
+/// `lens.parity` + `model.comment-state`: View returns the thread, and
+/// Resolve — the same library call the CLI runs — drops the comment from
+/// the next publish, since only open comments surface (`lens.lenses`).
+#[test]
+// @relation(lens.parity, lens.lenses, scope=function, role=Verifies)
+fn view_returns_the_thread_and_resolve_hides_it() {
+ let fixture = Fixture::new();
+ let id = fixture.add_comment("please fix", Some("5:5"));
+ let uri = fixture.uri("file.txt");
+ let (lens, _dir) = fixture.into_lens();
+
+ let view = lens
+ .execute_command(CMD_VIEW, &[json!(id)])
+ .expect("view")
+ .response
+ .expect("view returns the thread");
+ assert!(view.as_str().unwrap().contains("please fix"));
+
+ // Resolve, then the open-only publish no longer shows it.
+ let outcome = lens
+ .execute_command(CMD_RESOLVE, &[json!(id)])
+ .expect("resolve");
+ assert!(outcome.refresh, "a mutation asks for a diagnostics refresh");
+ assert!(lens.code_lenses(&uri).expect("lenses").is_empty());
+ assert!(lens.diagnostics(&uri).expect("diags").is_empty());
+}
+
+/// `lens.working-tree`: the open buffer stands in for disk, so a comment's
+/// range tracks unsaved edits — prepending two lines in the buffer shifts
+/// the projected lens down by two.
+#[test]
+// @relation(lens.working-tree, scope=function, role=Verifies)
+fn the_buffer_overrides_disk_so_ranges_track_unsaved_edits() {
+ let fixture = Fixture::new();
+ fixture.add_comment("watch this line", Some("5:5"));
+ let uri = fixture.uri("file.txt");
+ let (mut lens, _dir) = fixture.into_lens();
+
+ // On disk the anchor is line 5 (0-based 4).
+ let on_disk = lens.code_lenses(&uri).expect("lenses");
+ assert_eq!(on_disk[0].range.start.line, 4);
+
+ // The client sends a buffer with two extra lines prepended, unsaved.
+ let buffer: String = std::iter::once("added a".to_owned())
+ .chain(std::iter::once("added b".to_owned()))
+ .chain((1..=10).map(|n| format!("line {n}")))
+ .collect::<Vec<_>>()
+ .join("\n");
+ lens.did_open(uri.clone(), format!("{buffer}\n"));
+
+ let shifted = lens.code_lenses(&uri).expect("lenses");
+ assert_eq!(shifted[0].range.start.line, 6, "line 5 shifted to line 7");
+}