From 548899d7b129c5466584a0a30dd597ce4744ffd9 Mon Sep 17 00:00:00 2001
From: Nik Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Date: Wed, 19 Mar 2025 17:22:21 +0000
Subject: [PATCH] feat: allow more customizability for inline blame

---
 book/src/editor.md         |  27 +++++++++-
 helix-vcs/src/git/blame.rs | 101 ++++++++++++++++++++++++++++---------
 2 files changed, 104 insertions(+), 24 deletions(-)

diff --git a/book/src/editor.md b/book/src/editor.md
index 393a7b76..8c38efbf 100644
--- a/book/src/editor.md
+++ b/book/src/editor.md
@@ -164,7 +164,32 @@ The following statusline elements can be configured:
 
 | Key     | Description                                | Default |
 | ------- | ------------------------------------------ | ------- |
-| `blame` | Show git blame output for the current line | `false` |
+| `inline-blame` | Show git blame output for the current line | `false` |
+| `inline-blame-format` | The format in which to show the inline blame | `"{author}, {date} • {message} • {commit}"` |
+
+For `inline-blame-format`, you can use specific variables like so: `{variable}`.
+
+These are the available variables:
+
+- `author`: The author of the commit
+- `date`: When the commit was made
+- `message`: The message of the commit, excluding the body
+- `body`: The body of the commit
+- `commit`: The short hex SHA1 hash of the commit
+- `email`: The email of the author of the commit
+
+Any of the variables can potentially be empty.
+In this case, the content before the variable will not be included in the string.
+If the variable is at the beginning of the string, the content after the variable will not be included.
+
+Some examples, using the default value `inline-blame-format` value:
+
+- If `author` is empty: `"{date} • {message} • {commit}"`
+- If `date` is empty: `"{author} • {message} • {commit}"`
+- If `message` is empty: `"{author}, {date} • {commit}"`
+- If `commit` is empty: `"{author}, {date} • {message}"`
+- If `date` and `message` is empty: `"{author} • {commit}"`
+- If `author` and `message` is empty: `"{date} • {commit}"`
 
 ### `[editor.cursor-shape]` Section
 
diff --git a/helix-vcs/src/git/blame.rs b/helix-vcs/src/git/blame.rs
index 7debae99..3e02c3cb 100644
--- a/helix-vcs/src/git/blame.rs
+++ b/helix-vcs/src/git/blame.rs
@@ -5,17 +5,19 @@ use std::{collections::HashMap, ops::Range, path::Path};
 use super::{get_repo_dir, open_repo};
 
 pub struct BlameInformation {
-    pub commit_hash: String,
-    pub author_name: String,
-    pub author_email: String,
-    pub commit_date: String,
-    pub commit_message: String,
+    pub commit_hash: Option<String>,
+    pub author_name: Option<String>,
+    pub author_email: Option<String>,
+    pub commit_date: Option<String>,
+    pub commit_message: Option<String>,
+    pub commit_body: Option<String>,
 }
 
 impl BlameInformation {
     /// Parse the user's blame format
     pub fn parse_format(&self, format: &str) -> String {
-        let mut formatted = String::with_capacity(format.len() * 2);
+        let mut formatted = String::new();
+        let mut content_before_variable = String::new();
 
         let variables = HashMap::from([
             ("commit", &self.commit_hash),
@@ -23,17 +25,21 @@ impl BlameInformation {
             ("date", &self.commit_date),
             ("message", &self.commit_message),
             ("email", &self.author_email),
+            ("body", &self.commit_body),
         ]);
 
         let mut chars = format.chars().peekable();
+        // in all cases, when any of the variables is empty we exclude the content before the variable
+        // However, if the variable is the first and it is empty - then exclude the content after the variable
+        let mut exclude_content_after_variable = false;
         while let Some(ch) = chars.next() {
             // "{{" => '{'
             if ch == '{' && chars.next_if_eq(&'{').is_some() {
-                formatted.push('{');
+                content_before_variable.push('{');
             }
             // "}}" => '}'
             else if ch == '}' && chars.next_if_eq(&'}').is_some() {
-                formatted.push('}');
+                content_before_variable.push('}');
             } else if ch == '{' {
                 let mut variable = String::new();
                 // eat all characters until the end
@@ -42,10 +48,16 @@ impl BlameInformation {
                 }
                 // eat the '}' if it was found
                 let has_closing = chars.next().is_some();
-                let res = variables
-                    .get(variable.as_str())
-                    .map(|s| s.to_string())
-                    .unwrap_or_else(|| {
+
+                #[derive(PartialEq, Eq, PartialOrd, Ord)]
+                enum Variable {
+                    Valid(String),
+                    Invalid(String),
+                    Empty,
+                }
+
+                let variable_value = variables.get(variable.as_str()).map_or_else(
+                    || {
                         // Invalid variable. So just add whatever we parsed before
                         let mut result = String::with_capacity(variable.len() + 2);
                         result.push('{');
@@ -53,12 +65,49 @@ impl BlameInformation {
                         if has_closing {
                             result.push('}');
                         }
-                        result
-                    });
+                        Variable::Invalid(result)
+                    },
+                    |s| {
+                        s.as_ref()
+                            .map(|s| Variable::Valid(s.to_string()))
+                            .unwrap_or(Variable::Empty)
+                    },
+                );
 
-                formatted.push_str(&res);
+                match variable_value {
+                    Variable::Valid(value) => {
+                        if exclude_content_after_variable {
+                            // don't push anything.
+                            exclude_content_after_variable = false;
+                        } else {
+                            formatted.push_str(&content_before_variable);
+                        }
+                        formatted.push_str(&value);
+                    }
+                    Variable::Invalid(value) => {
+                        if exclude_content_after_variable {
+                            // don't push anything.
+                            exclude_content_after_variable = false;
+                        } else {
+                            formatted.push_str(&content_before_variable);
+                        }
+                        formatted.push_str(&value);
+                    }
+                    Variable::Empty => {
+                        if formatted.is_empty() {
+                            // exclude content AFTER this variable (at next iteration of the loop,
+                            // we'll exclude the content before a valid variable)
+                            exclude_content_after_variable = true;
+                        } else {
+                            // exclude content BEFORE this variable
+                            // also just don't add anything.
+                        }
+                    }
+                }
+
+                content_before_variable.drain(..);
             } else {
-                formatted.push(ch);
+                content_before_variable.push(ch);
             }
         }
 
@@ -125,14 +174,20 @@ pub fn blame(
     .context("No commits found")?
     .commit_id;
 
-    let commit = repo.find_commit(latest_commit_id)?;
-    let author = commit.author()?;
+    let commit = repo.find_commit(latest_commit_id).ok();
+    let message = commit.as_ref().and_then(|c| c.message().ok());
+    let author = commit.as_ref().and_then(|c| c.author().ok());
 
     Ok(BlameInformation {
-        commit_hash: commit.short_id()?.to_string(),
-        author_name: author.name.to_string(),
-        author_email: author.email.to_string(),
-        commit_date: author.time.format(gix::date::time::format::SHORT),
-        commit_message: commit.message()?.title.to_string(),
+        commit_hash: commit
+            .as_ref()
+            .and_then(|c| c.short_id().map(|id| id.to_string()).ok()),
+        author_name: author.map(|a| a.name.to_string()),
+        author_email: author.map(|a| a.email.to_string()),
+        commit_date: author.map(|a| a.time.format(gix::date::time::format::SHORT)),
+        commit_message: message.as_ref().map(|msg| msg.title.to_string()),
+        commit_body: message
+            .as_ref()
+            .and_then(|msg| msg.body.map(|body| body.to_string())),
     })
 }