git-ents.gitmain
⌘K
foforge
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.

Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1178,6 +1178,29 @@ "tempfile", ] +[[package]] +name = "ents-lens" +version = "0.0.0" +dependencies = [ + "ents-anchor", + "ents-forge", + "ents-model", + "ents-receive", + "ents-testutil", + "facet-git-tree", + "gix", + "gix-hash", + "gix-object", + "gix-ref-store", + "lsp-server", + "lsp-types", + "rstest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "ents-model" version = "0.0.0" @@ -3169,6 +3192,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lsp-server" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e55c013e58520c3471c904b6a4b95367acb73f20e718e1a0026d4297c9fc8e" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "matchit" version = "0.8.4" @@ -4027,6 +4076,17 @@ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4668,6 +4728,7 @@ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]]
Cargo.toml @@ -9,6 +9,7 @@ "crates/kernel/ents-receive", "crates/kernel/ents-sync", "crates/kernel/ents-testutil", + "crates/cli/ents-lens", "crates/cli/ents-web", "crates/cli/git-ents", "crates/forge/ents-forge", @@ -31,6 +32,7 @@ ents-forge = { path = "crates/forge/ents-forge" } ents-gate = { path = "crates/kernel/ents-gate" } ents-kiln = { path = "crates/kiln/ents-kiln" } +ents-lens = { path = "crates/cli/ents-lens" } ents-model = { path = "crates/kernel/ents-model" } ents-query = { path = "crates/kernel/ents-query" } ents-receive = { path = "crates/kernel/ents-receive" } @@ -80,6 +82,8 @@ gix-odb = "0.81" gix-pack = "0.71" iddqd = "0.4" +lsp-server = "0.9" +lsp-types = "0.94" object_store = { version = "0.14", default-features = false, features = [ "aws", ] } @@ -89,6 +93,7 @@ rayon = "1" rstest = "0.26" semver = "1" +serde_json = "1" spdx = "0.13" ssh-key = { version = "0.6", features = ["ed25519"] } target-lexicon = "0.13"
crates/cli/ents-lens/Cargo.toml @@ -1,0 +1,30 @@ +[package] +name = "ents-lens" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ents-anchor = { workspace = true } +ents-forge = { workspace = true } +ents-model = { workspace = true } +ents-receive = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +gix-ref-store = { workspace = true } +lsp-server = { workspace = true } +lsp-types = { workspace = true } +serde = "1" +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +ents-testutil = { workspace = true } +rstest = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true
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(&params.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(&params.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(&params.command, &params.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(&params.text_document.uri); + // Clear this document's diagnostics on close. + self.publish_list(&params.text_document.uri, Vec::new()); + } + } + DidSaveTextDocument::METHOD => { + if let Ok(params) = extract_notification::<DidSaveTextDocumentParams>(notification) + { + match self.lens.did_save(&params.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"); +}