crates/cli/ents-lens/src/compose.rs
compose.rshistorycomment on this file
| 1 | //! The editor-file compose flow (`lens.compose`): building the template |
| 2 | //! git-style commit-message file the editor opens, and parsing it back |
| 3 | //! once the user saves. |
| 4 | //! |
| 5 | //! This module is pure — string in, string out, no IO and no git — so the |
| 6 | //! exact template grammar and the "an empty body aborts, `#` lines are |
| 7 | //! ignored" rule are unit-testable on their own, and [`crate::Lens`] owns |
| 8 | //! only the filesystem and mutation halves. |
| 9 | //! |
| 10 | //! # The mechanism, precisely |
| 11 | //! |
| 12 | //! `lens.compose` requires composing to work through a file "the way git |
| 13 | //! itself takes a commit message", using no client-specific extension. The |
| 14 | //! flow the lens drives, using only standard LSP a plain client provides |
| 15 | //! (`workspace/executeCommand`, `window/showDocument`, and |
| 16 | //! `textDocument/didSave`): |
| 17 | //! |
| 18 | //! 1. A `textDocument/codeAction` on the selection returns the |
| 19 | //! `ents.compose` command. |
| 20 | //! 2. The client runs it via `workspace/executeCommand`; the lens writes |
| 21 | //! [`template_text`] to `.git/ENTS_COMMENT_EDITMSG` and asks the client |
| 22 | //! to open it with `window/showDocument`. |
| 23 | //! 3. The user edits the body and saves. The lens's `textDocument/didSave` |
| 24 | //! handler reads the file, [`parse`]s it, and — if the body is |
| 25 | //! non-empty — creates the comment through `ents_forge::comment::add` |
| 26 | //! (the same call the CLI makes, `lens.parity`), anchoring to the |
| 27 | //! working tree (`lens.working-tree`). An empty body aborts. |
| 28 | //! |
| 29 | //! The template is self-describing: the anchor target (path, lines, |
| 30 | //! working-tree flag, and an optional reply parent) rides in `#`-prefixed |
| 31 | //! metadata lines, so [`parse`] recovers it from the saved file alone and |
| 32 | //! the lens keeps no per-compose state of its own ("owning no state of its |
| 33 | //! own", the lens's whole premise). Because those lines start with `#` |
| 34 | //! they are ignored for the body exactly as any other comment line is, so |
| 35 | //! the metadata can never leak into the comment text. |
| 36 | |
| 37 | /// The prefix every machine-readable metadata line in the template carries, |
| 38 | /// after the `#` comment marker: `# ents-compose-<key>: <value>`. |
| 39 | const META_PREFIX: &str = "# ents-compose-"; |
| 40 | |
| 41 | /// What a compose targets: an anchor (a path, optional `<start>:<end>` |
| 42 | /// lines) captured against the working tree, and/or a reply parent. |
| 43 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 44 | pub struct Target { |
| 45 | /// Repository-relative path to anchor to, or `None` for a reply that |
| 46 | /// inherits its aboutness from its parent. |
| 47 | pub path: Option<String>, |
| 48 | /// Lines to anchor, as `<start>[:<end>]`. |
| 49 | pub lines: Option<String>, |
| 50 | /// Id of the comment being replied to, when this compose is a reply. |
| 51 | pub parent: Option<String>, |
| 52 | } |
| 53 | |
| 54 | /// A parsed, saved template: the body (with `#` lines and surrounding |
| 55 | /// blank lines stripped) and the anchor [`Target`] its metadata named. |
| 56 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 57 | pub struct Composed { |
| 58 | /// The comment body the user typed. Empty (after trimming) means the |
| 59 | /// compose was aborted (`lens.compose`). |
| 60 | pub body: String, |
| 61 | /// Where the comment anchors / who it replies to. |
| 62 | pub target: Target, |
| 63 | } |
| 64 | |
| 65 | impl Composed { |
| 66 | /// Whether the saved template aborts the compose: an empty body once |
| 67 | /// `#` lines and surrounding whitespace are stripped (`lens.compose`). |
| 68 | #[must_use] |
| 69 | pub fn is_abort(&self) -> bool { |
| 70 | self.body.trim().is_empty() |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// The initial template text for a new anchored comment on `path`/`lines` |
| 75 | /// against the working tree, or a reply when `target.parent` is set — a |
| 76 | /// blank body followed by git-style `#` guidance and the machine-readable |
| 77 | /// metadata [`parse`] reads back. |
| 78 | #[must_use] |
| 79 | pub fn template_text(target: &Target) -> String { |
| 80 | let mut out = String::new(); |
| 81 | // One blank line for the body; the user types above the guidance. |
| 82 | out.push('\n'); |
| 83 | out.push_str("# Leave an ents comment. Lines starting with '#' are ignored;\n"); |
| 84 | out.push_str("# an empty message aborts. Save this file to create the comment.\n"); |
| 85 | out.push_str("#\n"); |
| 86 | match (&target.parent, &target.path) { |
| 87 | (Some(parent), _) => { |
| 88 | out.push_str(&format!("# Replying to comment {parent}.\n")); |
| 89 | } |
| 90 | (None, Some(path)) => match &target.lines { |
| 91 | Some(lines) => out.push_str(&format!("# On: {path} lines {lines} (working tree).\n")), |
| 92 | None => out.push_str(&format!("# On: {path} (working tree).\n")), |
| 93 | }, |
| 94 | (None, None) => {} |
| 95 | } |
| 96 | // Machine-readable metadata: one value per line, so a path containing |
| 97 | // spaces round-trips without any escaping. |
| 98 | if let Some(path) = &target.path { |
| 99 | out.push_str(&format!("{META_PREFIX}path: {path}\n")); |
| 100 | } |
| 101 | if let Some(lines) = &target.lines { |
| 102 | out.push_str(&format!("{META_PREFIX}lines: {lines}\n")); |
| 103 | } |
| 104 | if let Some(parent) = &target.parent { |
| 105 | out.push_str(&format!("{META_PREFIX}parent: {parent}\n")); |
| 106 | } |
| 107 | out |
| 108 | } |
| 109 | |
| 110 | /// Parse a saved template back into its body and [`Target`] |
| 111 | /// (`lens.compose`): every line starting with `#` is dropped from the body, |
| 112 | /// and the `# ents-compose-<key>: <value>` metadata lines reconstruct the |
| 113 | /// anchor target the compose was started with. |
| 114 | #[must_use] |
| 115 | pub fn parse(content: &str) -> Composed { |
| 116 | let mut target = Target::default(); |
| 117 | let mut body_lines: Vec<&str> = Vec::new(); |
| 118 | for line in content.lines() { |
| 119 | if let Some(rest) = line.strip_prefix(META_PREFIX) { |
| 120 | if let Some((key, value)) = rest.split_once(':') { |
| 121 | let value = value.trim().to_owned(); |
| 122 | match key { |
| 123 | "path" => target.path = Some(value), |
| 124 | "lines" => target.lines = Some(value), |
| 125 | "parent" => target.parent = Some(value), |
| 126 | _ => {} |
| 127 | } |
| 128 | } |
| 129 | continue; |
| 130 | } |
| 131 | if line.starts_with('#') { |
| 132 | continue; |
| 133 | } |
| 134 | body_lines.push(line); |
| 135 | } |
| 136 | let body = body_lines.join("\n").trim().to_owned(); |
| 137 | Composed { body, target } |
| 138 | } |