From 959471392d5aa0289f979c8898260e0f133d9ae7 Mon Sep 17 00:00:00 2001 From: Thomas Otto Date: Fri, 15 Nov 2024 00:07:10 +0100 Subject: [PATCH] Allow multiple hyperlinks per line Previously only the last commit was linked. Do not link numbers (technically also commits), and stop after finding 12 commits on a line. --- src/features/hyperlinks.rs | 167 +++++++++++++++++++++++++++++-------- 1 file changed, 130 insertions(+), 37 deletions(-) diff --git a/src/features/hyperlinks.rs b/src/features/hyperlinks.rs index ca21eb3f..22ed273d 100644 --- a/src/features/hyperlinks.rs +++ b/src/features/hyperlinks.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::path::Path; use lazy_static::lazy_static; -use regex::{Captures, Regex}; +use regex::{Match, Matches, Regex}; use crate::config::Config; use crate::features::OptionValueFunction; @@ -31,26 +31,62 @@ pub fn remote_from_config(cfg: &Option<&GitConfig>) -> Option { cfg.and_then(GitConfig::get_remote_url) } +lazy_static! { + // note: pure numbers are filtered out later again + static ref COMMIT_HASH_REGEX: Regex = Regex::new(r"\b[0-9a-f]{8,40}\b").unwrap(); +} + pub fn format_commit_line_with_osc8_commit_hyperlink<'a>( line: &'a str, config: &Config, ) -> Cow<'a, str> { - if let Some(commit_link_format) = &config.hyperlinks_commit_link_format { - COMMIT_LINE_REGEX.replace(line, |captures: &Captures| { - let prefix = captures.get(1).map(|m| m.as_str()).unwrap_or(""); - let commit = captures.get(2).map(|m| m.as_str()).unwrap(); - let suffix = captures.get(3).map(|m| m.as_str()).unwrap_or(""); - let formatted_commit = - format_osc8_hyperlink(&commit_link_format.replace("{commit}", commit), commit); - format!("{prefix}{formatted_commit}{suffix}") - }) - } else if let Some(repo) = remote_from_config(&config.git_config()) { - COMMIT_LINE_REGEX.replace(line, |captures: &Captures| { - format_commit_line_captures_with_osc8_commit_hyperlink(captures, &repo) - }) - } else { - Cow::from(line) + // Given matches in a line, m = matches[0] and pos = 0: store line[pos..m.start()] first, then + // store the T(line[m.start()..m.end()]) match transformation, then set pos = m.end(). + // Repeat for matches[1..]. Finally, store line[pos..]. + struct HyperlinkCommits(T) + where + T: Fn(&str) -> String; + impl Fn(&'b str) -> String> HyperlinkCommits { + fn _m(&self, result: &mut String, line: &str, m: &Match, prev_pos: usize) -> usize { + result.push_str(&line[prev_pos..m.start()]); + let commit = &line[m.start()..m.end()]; + // Do not link numbers, require at least one non-decimal: + if commit.contains(|c| matches!(c, 'a'..='f')) { + result.push_str(&format_osc8_hyperlink(&self.0(commit), commit)); + } else { + result.push_str(commit); + } + m.end() + } + fn with_input(&self, line: &str, m0: &Match, matches123: &mut Matches) -> String { + let mut result = String::new(); + let mut pos = self._m(&mut result, line, m0, 0); + // limit number of matches per line, an exhaustive `find_iter` is O(len(line) * len(regex)^2) + for m in matches123.take(12) { + pos = self._m(&mut result, line, &m, pos); + } + result.push_str(&line[pos..]); + result + } } + + if let Some(commit_link_format) = &config.hyperlinks_commit_link_format { + let mut matches = COMMIT_HASH_REGEX.find_iter(line); + if let Some(first_match) = matches.next() { + let result = + HyperlinkCommits(|commit_hash| commit_link_format.replace("{commit}", commit_hash)) + .with_input(line, &first_match, &mut matches); + return Cow::from(result); + } + } else if let Some(repo) = remote_from_config(&config.git_config()) { + let mut matches = COMMIT_HASH_REGEX.find_iter(line); + if let Some(first_match) = matches.next() { + let result = HyperlinkCommits(|commit_hash| repo.format_commit_url(commit_hash)) + .with_input(line, &first_match, &mut matches); + return Cow::from(result); + } + } + Cow::from(line) } /// Create a file hyperlink, displaying `text`. @@ -89,38 +125,95 @@ fn format_osc8_hyperlink(url: &str, text: &str) -> String { ) } -lazy_static! { - static ref COMMIT_LINE_REGEX: Regex = Regex::new("(.* )?([0-9a-f]{8,40})(.*)").unwrap(); -} - -fn format_commit_line_captures_with_osc8_commit_hyperlink( - captures: &Captures, - repo: &GitRemoteRepo, -) -> String { - let commit = captures.get(2).unwrap().as_str(); - format!( - "{prefix}{osc}8;;{url}{st}{commit}{osc}8;;{st}{suffix}", - url = repo.format_commit_url(commit), - commit = commit, - prefix = captures.get(1).map(|m| m.as_str()).unwrap_or(""), - suffix = captures.get(3).unwrap().as_str(), - osc = "\x1b]", - st = "\x1b\\" - ) -} - #[cfg(not(target_os = "windows"))] #[cfg(test)] pub mod tests { use std::iter::FromIterator; use std::path::PathBuf; + use pretty_assertions::assert_eq; + use super::*; + use crate::{ - tests::integration_test_utils::{self, DeltaTest}, + tests::integration_test_utils::{self, make_config_from_args, DeltaTest}, utils, }; + #[test] + fn test_formatted_hyperlinks() { + let config = make_config_from_args(&["--hyperlinks-commit-link-format", "HERE:{commit}"]); + + let line = "001234abcdf"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!( + result, + "\u{1b}]8;;HERE:001234abcdf\u{1b}\\001234abcdf\u{1b}]8;;\u{1b}\\", + ); + + let line = "a2272718f0b398e48652ace17fca85c1962b3fc22"; // length: 41 > 40 + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!(result, "a2272718f0b398e48652ace17fca85c1962b3fc22",); + + let line = "a2272718f0+b398e48652ace17f,ca85c1962b3fc2"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!(result, "\u{1b}]8;;HERE:a2272718f0\u{1b}\\a2272718f0\u{1b}]8;;\u{1b}\\+\u{1b}]8;;\ + HERE:b398e48652ace17f\u{1b}\\b398e48652ace17f\u{1b}]8;;\u{1b}\\,\u{1b}]8;;HERE:ca85c1962b3fc2\ + \u{1b}\\ca85c1962b3fc2\u{1b}]8;;\u{1b}\\"); + + let line = "This 01234abcdf Hash"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!( + result, + "This \u{1b}]8;;HERE:01234abcdf\u{1b}\\01234abcdf\u{1b}]8;;\u{1b}\\ Hash", + ); + + let line = + "Another 01234abcdf hash but also this one: dc623b084ad2dd14fe5d90189cacad5d49bfbfd3!"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!( + result, + "Another \u{1b}]8;;HERE:01234abcdf\u{1b}\\01234abcdf\u{1b}]8;;\u{1b}\\ hash but \ + also this one: \u{1b}]8;;HERE:dc623b084ad2dd14fe5d90189cacad5d49bfbfd3\u{1b}\ + \\dc623b084ad2dd14fe5d90189cacad5d49bfbfd3\u{1b}]8;;\u{1b}\\!" + ); + + let line = "01234abcdf 03043baf30 12abcdef0 12345678"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!( + result, + "\u{1b}]8;;HERE:01234abcdf\u{1b}\\01234abcdf\u{1b}]8;;\u{1b}\\ \u{1b}]8;;\ + HERE:03043baf30\u{1b}\\03043baf30\u{1b}]8;;\u{1b}\\ \u{1b}]8;;HERE:12abcdef0\u{1b}\\\ + 12abcdef0\u{1b}]8;;\u{1b}\\ 12345678" + ); + } + + #[test] + fn test_hyperlinks_to_repo() { + let mut config = make_config_from_args(&["--hyperlinks"]); + config.git_config = GitConfig::for_testing(); + + let line = "This a589ff9debaefdd delta commit"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!( + result, + "This \u{1b}]8;;https://github.com/dandavison/delta/commit/a589ff9debaefdd\u{1b}\ + \\a589ff9debaefdd\u{1b}]8;;\u{1b}\\ delta commit", + ); + + let line = + "Another a589ff9debaefdd hash but also this one: c5696757c0827349a87daa95415656!"; + let result = format_commit_line_with_osc8_commit_hyperlink(line, &config); + assert_eq!( + result, + "Another \u{1b}]8;;https://github.com/dandavison/delta/commit/a589ff9debaefdd\ + \u{1b}\\a589ff9debaefdd\u{1b}]8;;\u{1b}\\ hash but also this one: \u{1b}]8;;\ + https://github.com/dandavison/delta/commit/c5696757c0827349a87daa95415656\u{1b}\ + \\c5696757c0827349a87daa95415656\u{1b}]8;;\ + \u{1b}\\!" + ); + } + #[test] fn test_paths_and_hyperlinks_user_in_repo_root_dir() { // Expectations are uninfluenced by git's --relative and delta's relative_paths options.