git-ents.gitmain
⌘K
foforge
compose.rs138 lines · 5.8 KB · rusthistorycomment 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>`.
39const 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)]
44pub 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)]
57pub 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
65impl 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]
79pub 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]
115pub 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}