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;
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.
23fn get_link((link_type, open): (String, Option<bool>)) -> Option<()> {
24    let selection = ytil_noxi::visual_selection::get(())?;
25
26    let repo = ytil_git::repo::discover(Path::new("."))
27        .inspect_err(|err| {
28            ytil_noxi::notify::error(err);
29        })
30        .ok()?;
31
32    let cur_buf = nvim_oxi::api::get_current_buf();
33    let abs_buf_path = ytil_noxi::buffer::get_absolute_path(Some(&cur_buf))
34        .ok_or_else(|| {
35            ytil_noxi::notify::error(format!(
36                "error getting absolute path for current_buffer | current_buffer={cur_buf:?}"
37            ));
38        })
39        .ok()?;
40    let current_buffer_path = ytil_git::repo::get_relative_path_to_repo(&abs_buf_path, &repo)
41        .inspect_err(|err| ytil_noxi::notify::error(err))
42        .ok()?;
43
44    let repo_urls = ytil_git::remote::get_https_urls(&repo)
45        .inspect_err(|err| {
46            ytil_noxi::notify::error(format!("error discovering git repo | error={err:#?}"));
47        })
48        .ok()?;
49
50    // FIXME: handle case of multiple remotes
51    let mut repo_url = repo_urls.into_iter().next()?;
52
53    let current_commit_hash = ytil_git::get_current_commit_hash(&repo)
54        .inspect_err(|err| {
55            ytil_noxi::notify::error(format!("error getting current repo commit hash | error={err:#?}"));
56        })
57        .ok()?;
58
59    let git_provider = GitProvider::get(&repo_url)
60        .inspect_err(|err| {
61            ytil_noxi::notify::error(format!(
62                "error getting git provider for url | url={repo_url:#?} error={err:?}"
63            ));
64        })
65        .inspect(|gp| {
66            if gp.is_none() {
67                ytil_noxi::notify::error(format!("error no git provider found for url | url={repo_url:#?}"));
68            }
69        })
70        .ok()??;
71
72    match git_provider {
73        GitProvider::GitHub => build_github_file_url(
74            &mut repo_url,
75            &link_type,
76            &current_commit_hash,
77            &current_buffer_path,
78            &selection,
79        ),
80        GitProvider::GitLab => build_gitlab_file_url(
81            &mut repo_url,
82            &link_type,
83            &current_commit_hash,
84            &current_buffer_path,
85            &selection,
86        ),
87    }
88
89    if open.is_some_and(std::convert::identity) {
90        ytil_sys::open(&repo_url)
91            .inspect_err(|err| {
92                ytil_noxi::notify::error(format!("error opening file URL | repo_url={repo_url:?} error={err:#?}"));
93            })
94            .ok()?;
95    } else {
96        ytil_sys::file::cp_to_system_clipboard(&mut repo_url.as_bytes())
97            .inspect_err(|err| {
98                ytil_noxi::notify::error(format!(
99                    "error copying content to system clipboard | content={repo_url:?} error={err:#?}"
100                ));
101            })
102            .ok()?;
103        nvim_oxi::print!("URL copied to clipboard:\n{repo_url}");
104    }
105
106    Some(())
107}
108
109fn build_github_file_url(
110    repo_url: &mut String,
111    link_type: &str,
112    commit_hash: &str,
113    current_buffer_path: &Path,
114    selection: &Selection,
115) {
116    fn add_github_lnum_and_col_to_url(repo_url: &mut String, bound: &Bound) {
117        repo_url.push('L');
118        append_lnum(repo_url, bound);
119        repo_url.push('C');
120        // write! to String is infallible; avoids intermediate String allocation from .to_string()
121        let _ = write!(repo_url, "{}", bound.col);
122    }
123
124    repo_url.push('/');
125    repo_url.push_str(link_type);
126    repo_url.push('/');
127    repo_url.push_str(commit_hash);
128    repo_url.push('/');
129    repo_url.push_str(current_buffer_path.to_string_lossy().trim_start_matches('/'));
130
131    repo_url.push_str("?plain=1");
132    repo_url.push('#');
133    add_github_lnum_and_col_to_url(repo_url, selection.start());
134    repo_url.push('-');
135    add_github_lnum_and_col_to_url(repo_url, selection.end());
136}
137
138fn build_gitlab_file_url(
139    repo_url: &mut String,
140    link_type: &str,
141    commit_hash: &str,
142    current_buffer_path: &Path,
143    selection: &Selection,
144) {
145    repo_url.push('/');
146    repo_url.push('-');
147
148    repo_url.push('/');
149    repo_url.push_str(link_type);
150    repo_url.push('/');
151    repo_url.push_str(commit_hash);
152    repo_url.push('/');
153    repo_url.push_str(current_buffer_path.to_string_lossy().trim_start_matches('/'));
154
155    repo_url.push('#');
156    repo_url.push('L');
157    append_lnum(repo_url, selection.start());
158    repo_url.push('-');
159    append_lnum(repo_url, selection.end());
160}
161
162fn append_lnum(repo_url: &mut String, bound: &Bound) {
163    // write! to String is infallible; avoids intermediate String allocation from .to_string()
164    let _ = write!(repo_url, "{}", bound.lnum.saturating_add(1));
165}
166
167#[cfg(test)]
168mod tests {
169    use rstest::rstest;
170    use test_that::prelude::*;
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        assert_that!(repo_url, eq(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        assert_that!(repo_url, eq(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}