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::path::Path;
8
9use nvim_oxi::Dictionary;
10use ytil_git::remote::GitProvider;
11use ytil_noxi::visual_selection::Bound;
12use ytil_noxi::visual_selection::Selection;
13
14/// [`Dictionary`] with GitHub link generation helpers.
15pub fn dict() -> Dictionary {
16    dict! {
17        "get_link": fn_from!(get_link),
18    }
19}
20
21/// Generates a GitHub permalink for the current visual selection and copies it to the clipboard.
22#[allow(clippy::needless_pass_by_value)]
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        repo_url.push_str(&bound.col.to_string());
121    }
122
123    repo_url.push('/');
124    repo_url.push_str(link_type);
125    repo_url.push('/');
126    repo_url.push_str(commit_hash);
127    repo_url.push('/');
128    repo_url.push_str(current_buffer_path.to_string_lossy().trim_start_matches('/'));
129
130    repo_url.push_str("?plain=1");
131    repo_url.push('#');
132    add_github_lnum_and_col_to_url(repo_url, selection.start());
133    repo_url.push('-');
134    add_github_lnum_and_col_to_url(repo_url, selection.end());
135}
136
137fn build_gitlab_file_url(
138    repo_url: &mut String,
139    link_type: &str,
140    commit_hash: &str,
141    current_buffer_path: &Path,
142    selection: &Selection,
143) {
144    repo_url.push('/');
145    repo_url.push('-');
146
147    repo_url.push('/');
148    repo_url.push_str(link_type);
149    repo_url.push('/');
150    repo_url.push_str(commit_hash);
151    repo_url.push('/');
152    repo_url.push_str(current_buffer_path.to_string_lossy().trim_start_matches('/'));
153
154    repo_url.push('#');
155    repo_url.push('L');
156    append_lnum(repo_url, selection.start());
157    repo_url.push('-');
158    append_lnum(repo_url, selection.end());
159}
160
161fn append_lnum(repo_url: &mut String, bound: &Bound) {
162    repo_url.push_str(&bound.lnum.saturating_add(1).to_string());
163}
164
165#[cfg(test)]
166mod tests {
167    use rstest::rstest;
168
169    use super::*;
170
171    #[rstest]
172    #[case::single_line_selection(
173        "https://github.com/user/repo",
174        "blob",
175        "abc123",
176        "/src/main.rs",
177        Bound { lnum: 10, col: 5 },
178        Bound { lnum: 10, col: 10 },
179        "https://github.com/user/repo/blob/abc123/src/main.rs?plain=1#L11C5-L11C10"
180    )]
181    #[case::multi_line_selection(
182        "https://github.com/user/repo",
183        "blob",
184        "def456",
185        "/lib/utils.rs",
186        Bound { lnum: 1, col: 0 },
187        Bound { lnum: 3, col: 20 },
188        "https://github.com/user/repo/blob/def456/lib/utils.rs?plain=1#L2C0-L4C20"
189    )]
190    #[case::root_file(
191        "https://github.com/user/repo",
192        "tree",
193        "ghi789",
194        "/README.md",
195        Bound { lnum: 5, col: 2 },
196        Bound { lnum: 5, col: 2 },
197        "https://github.com/user/repo/tree/ghi789/README.md?plain=1#L6C2-L6C2"
198    )]
199    fn build_github_file_url_works_as_expected(
200        #[case] initial_repo_url: &str,
201        #[case] url_kind: &str,
202        #[case] commit_hash: &str,
203        #[case] file_path: &str,
204        #[case] start: Bound,
205        #[case] end: Bound,
206        #[case] expected: &str,
207    ) {
208        let mut repo_url = initial_repo_url.to_string();
209        let current_buffer_path = Path::new(file_path);
210        let selection = dummy_selection(start, end);
211
212        build_github_file_url(&mut repo_url, url_kind, commit_hash, current_buffer_path, &selection);
213
214        pretty_assertions::assert_eq!(repo_url, expected);
215    }
216
217    #[rstest]
218    #[case::single_line_selection(
219        "https://gitlab.com/user/repo",
220        "blob",
221        "abc123",
222        "/src/main.rs",
223        Bound { lnum: 10, col: 5 },
224        Bound { lnum: 10, col: 10 },
225        "https://gitlab.com/user/repo/-/blob/abc123/src/main.rs#L11-11"
226    )]
227    #[case::multi_line_selection(
228        "https://gitlab.com/user/repo",
229        "blob",
230        "def456",
231        "/lib/utils.rs",
232        Bound { lnum: 1, col: 0 },
233        Bound { lnum: 3, col: 20 },
234        "https://gitlab.com/user/repo/-/blob/def456/lib/utils.rs#L2-4"
235    )]
236    #[case::root_file(
237        "https://gitlab.com/user/repo",
238        "tree",
239        "ghi789",
240        "/README.md",
241        Bound { lnum: 5, col: 2 },
242        Bound { lnum: 5, col: 2 },
243        "https://gitlab.com/user/repo/-/tree/ghi789/README.md#L6-6"
244    )]
245    fn build_gitlab_file_url_works_as_expected(
246        #[case] initial_repo_url: &str,
247        #[case] url_kind: &str,
248        #[case] commit_hash: &str,
249        #[case] file_path: &str,
250        #[case] start: Bound,
251        #[case] end: Bound,
252        #[case] expected: &str,
253    ) {
254        let mut repo_url = initial_repo_url.to_string();
255        let current_buffer_path = Path::new(file_path);
256        let selection = dummy_selection(start, end);
257
258        build_gitlab_file_url(&mut repo_url, url_kind, commit_hash, current_buffer_path, &selection);
259
260        pretty_assertions::assert_eq!(repo_url, expected);
261    }
262
263    fn dummy_selection(start: Bound, end: Bound) -> Selection {
264        use ytil_noxi::visual_selection::SelectionBounds;
265        let bounds = SelectionBounds { buf_id: 1, start, end };
266        Selection::new(bounds, std::iter::empty::<nvim_oxi::String>())
267    }
268}