Skip to main content

nvrim/plugins/
ghurlinker.rs

1//! GitHub permalink generation for selected code.
2//!
3//! Exposes a dictionary with a `get_link` function that constructs GitHub URLs for visually selected
4//! code ranges in the current buffer, using the repository's current commit hash for permalinks.
5//! The generated URL is automatically copied to the system clipboard.
6
7use std::fmt::Write as _;
8use std::path::Path;
9
10use nvim_oxi::Dictionary;
11use ytil_git::remote::GitProvider;
12use ytil_noxi::visual_selection::Bound;
13use ytil_noxi::visual_selection::Selection;
14
15/// [`Dictionary`] with GitHub link generation helpers.
16pub fn dict() -> Dictionary {
17    dict! {
18        "get_link": fn_from!(get_link),
19    }
20}
21
22/// Generates a GitHub permalink for the current visual selection and copies it to the clipboard.
23#[allow(clippy::needless_pass_by_value)]
24fn get_link((link_type, open): (String, Option<bool>)) -> Option<()> {
25    let selection = ytil_noxi::visual_selection::get(())?;
26
27    let repo = ytil_git::repo::discover(Path::new("."))
28        .inspect_err(|err| {
29            ytil_noxi::notify::error(err);
30        })
31        .ok()?;
32
33    let cur_buf = nvim_oxi::api::get_current_buf();
34    let abs_buf_path = ytil_noxi::buffer::get_absolute_path(Some(&cur_buf))
35        .ok_or_else(|| {
36            ytil_noxi::notify::error(format!(
37                "error getting absolute path for current_buffer | current_buffer={cur_buf:?}"
38            ));
39        })
40        .ok()?;
41    let current_buffer_path = ytil_git::repo::get_relative_path_to_repo(&abs_buf_path, &repo)
42        .inspect_err(|err| ytil_noxi::notify::error(err))
43        .ok()?;
44
45    let repo_urls = ytil_git::remote::get_https_urls(&repo)
46        .inspect_err(|err| {
47            ytil_noxi::notify::error(format!("error discovering git repo | error={err:#?}"));
48        })
49        .ok()?;
50
51    // FIXME: handle case of multiple remotes
52    let mut repo_url = repo_urls.into_iter().next()?;
53
54    let current_commit_hash = ytil_git::get_current_commit_hash(&repo)
55        .inspect_err(|err| {
56            ytil_noxi::notify::error(format!("error getting current repo commit hash | error={err:#?}"));
57        })
58        .ok()?;
59
60    let git_provider = GitProvider::get(&repo_url)
61        .inspect_err(|err| {
62            ytil_noxi::notify::error(format!(
63                "error getting git provider for url | url={repo_url:#?} error={err:?}"
64            ));
65        })
66        .inspect(|gp| {
67            if gp.is_none() {
68                ytil_noxi::notify::error(format!("error no git provider found for url | url={repo_url:#?}"));
69            }
70        })
71        .ok()??;
72
73    match git_provider {
74        GitProvider::GitHub => build_github_file_url(
75            &mut repo_url,
76            &link_type,
77            &current_commit_hash,
78            &current_buffer_path,
79            &selection,
80        ),
81        GitProvider::GitLab => build_gitlab_file_url(
82            &mut repo_url,
83            &link_type,
84            &current_commit_hash,
85            &current_buffer_path,
86            &selection,
87        ),
88    }
89
90    if open.is_some_and(std::convert::identity) {
91        ytil_sys::open(&repo_url)
92            .inspect_err(|err| {
93                ytil_noxi::notify::error(format!("error opening file URL | repo_url={repo_url:?} error={err:#?}"));
94            })
95            .ok()?;
96    } else {
97        ytil_sys::file::cp_to_system_clipboard(&mut repo_url.as_bytes())
98            .inspect_err(|err| {
99                ytil_noxi::notify::error(format!(
100                    "error copying content to system clipboard | content={repo_url:?} error={err:#?}"
101                ));
102            })
103            .ok()?;
104        nvim_oxi::print!("URL copied to clipboard:\n{repo_url}");
105    }
106
107    Some(())
108}
109
110fn build_github_file_url(
111    repo_url: &mut String,
112    link_type: &str,
113    commit_hash: &str,
114    current_buffer_path: &Path,
115    selection: &Selection,
116) {
117    fn add_github_lnum_and_col_to_url(repo_url: &mut String, bound: &Bound) {
118        repo_url.push('L');
119        append_lnum(repo_url, bound);
120        repo_url.push('C');
121        // write! to String is infallible; avoids intermediate String allocation from .to_string()
122        let _ = write!(repo_url, "{}", bound.col);
123    }
124
125    repo_url.push('/');
126    repo_url.push_str(link_type);
127    repo_url.push('/');
128    repo_url.push_str(commit_hash);
129    repo_url.push('/');
130    repo_url.push_str(current_buffer_path.to_string_lossy().trim_start_matches('/'));
131
132    repo_url.push_str("?plain=1");
133    repo_url.push('#');
134    add_github_lnum_and_col_to_url(repo_url, selection.start());
135    repo_url.push('-');
136    add_github_lnum_and_col_to_url(repo_url, selection.end());
137}
138
139fn build_gitlab_file_url(
140    repo_url: &mut String,
141    link_type: &str,
142    commit_hash: &str,
143    current_buffer_path: &Path,
144    selection: &Selection,
145) {
146    repo_url.push('/');
147    repo_url.push('-');
148
149    repo_url.push('/');
150    repo_url.push_str(link_type);
151    repo_url.push('/');
152    repo_url.push_str(commit_hash);
153    repo_url.push('/');
154    repo_url.push_str(current_buffer_path.to_string_lossy().trim_start_matches('/'));
155
156    repo_url.push('#');
157    repo_url.push('L');
158    append_lnum(repo_url, selection.start());
159    repo_url.push('-');
160    append_lnum(repo_url, selection.end());
161}
162
163fn append_lnum(repo_url: &mut String, bound: &Bound) {
164    // write! to String is infallible; avoids intermediate String allocation from .to_string()
165    let _ = write!(repo_url, "{}", bound.lnum.saturating_add(1));
166}
167
168#[cfg(test)]
169mod tests {
170    use rstest::rstest;
171
172    use super::*;
173
174    #[rstest]
175    #[case::single_line_selection(
176        "https://github.com/user/repo",
177        "blob",
178        "abc123",
179        "/src/main.rs",
180        Bound { lnum: 10, col: 5 },
181        Bound { lnum: 10, col: 10 },
182        "https://github.com/user/repo/blob/abc123/src/main.rs?plain=1#L11C5-L11C10"
183    )]
184    #[case::multi_line_selection(
185        "https://github.com/user/repo",
186        "blob",
187        "def456",
188        "/lib/utils.rs",
189        Bound { lnum: 1, col: 0 },
190        Bound { lnum: 3, col: 20 },
191        "https://github.com/user/repo/blob/def456/lib/utils.rs?plain=1#L2C0-L4C20"
192    )]
193    #[case::root_file(
194        "https://github.com/user/repo",
195        "tree",
196        "ghi789",
197        "/README.md",
198        Bound { lnum: 5, col: 2 },
199        Bound { lnum: 5, col: 2 },
200        "https://github.com/user/repo/tree/ghi789/README.md?plain=1#L6C2-L6C2"
201    )]
202    fn build_github_file_url_works_as_expected(
203        #[case] initial_repo_url: &str,
204        #[case] url_kind: &str,
205        #[case] commit_hash: &str,
206        #[case] file_path: &str,
207        #[case] start: Bound,
208        #[case] end: Bound,
209        #[case] expected: &str,
210    ) {
211        let mut repo_url = initial_repo_url.to_string();
212        let current_buffer_path = Path::new(file_path);
213        let selection = dummy_selection(start, end);
214
215        build_github_file_url(&mut repo_url, url_kind, commit_hash, current_buffer_path, &selection);
216
217        pretty_assertions::assert_eq!(repo_url, expected);
218    }
219
220    #[rstest]
221    #[case::single_line_selection(
222        "https://gitlab.com/user/repo",
223        "blob",
224        "abc123",
225        "/src/main.rs",
226        Bound { lnum: 10, col: 5 },
227        Bound { lnum: 10, col: 10 },
228        "https://gitlab.com/user/repo/-/blob/abc123/src/main.rs#L11-11"
229    )]
230    #[case::multi_line_selection(
231        "https://gitlab.com/user/repo",
232        "blob",
233        "def456",
234        "/lib/utils.rs",
235        Bound { lnum: 1, col: 0 },
236        Bound { lnum: 3, col: 20 },
237        "https://gitlab.com/user/repo/-/blob/def456/lib/utils.rs#L2-4"
238    )]
239    #[case::root_file(
240        "https://gitlab.com/user/repo",
241        "tree",
242        "ghi789",
243        "/README.md",
244        Bound { lnum: 5, col: 2 },
245        Bound { lnum: 5, col: 2 },
246        "https://gitlab.com/user/repo/-/tree/ghi789/README.md#L6-6"
247    )]
248    fn build_gitlab_file_url_works_as_expected(
249        #[case] initial_repo_url: &str,
250        #[case] url_kind: &str,
251        #[case] commit_hash: &str,
252        #[case] file_path: &str,
253        #[case] start: Bound,
254        #[case] end: Bound,
255        #[case] expected: &str,
256    ) {
257        let mut repo_url = initial_repo_url.to_string();
258        let current_buffer_path = Path::new(file_path);
259        let selection = dummy_selection(start, end);
260
261        build_gitlab_file_url(&mut repo_url, url_kind, commit_hash, current_buffer_path, &selection);
262
263        pretty_assertions::assert_eq!(repo_url, expected);
264    }
265
266    fn dummy_selection(start: Bound, end: Bound) -> Selection {
267        use ytil_noxi::visual_selection::SelectionBounds;
268        let bounds = SelectionBounds { buf_id: 1, start, end };
269        Selection::new(bounds, std::iter::empty::<nvim_oxi::String>())
270    }
271}