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
171    use super::*;
172
173    #[rstest]
174    #[case::single_line_selection(
175        "https://github.com/user/repo",
176        "blob",
177        "abc123",
178        "/src/main.rs",
179        Bound { lnum: 10, col: 5 },
180        Bound { lnum: 10, col: 10 },
181        "https://github.com/user/repo/blob/abc123/src/main.rs?plain=1#L11C5-L11C10"
182    )]
183    #[case::multi_line_selection(
184        "https://github.com/user/repo",
185        "blob",
186        "def456",
187        "/lib/utils.rs",
188        Bound { lnum: 1, col: 0 },
189        Bound { lnum: 3, col: 20 },
190        "https://github.com/user/repo/blob/def456/lib/utils.rs?plain=1#L2C0-L4C20"
191    )]
192    #[case::root_file(
193        "https://github.com/user/repo",
194        "tree",
195        "ghi789",
196        "/README.md",
197        Bound { lnum: 5, col: 2 },
198        Bound { lnum: 5, col: 2 },
199        "https://github.com/user/repo/tree/ghi789/README.md?plain=1#L6C2-L6C2"
200    )]
201    fn build_github_file_url_works_as_expected(
202        #[case] initial_repo_url: &str,
203        #[case] url_kind: &str,
204        #[case] commit_hash: &str,
205        #[case] file_path: &str,
206        #[case] start: Bound,
207        #[case] end: Bound,
208        #[case] expected: &str,
209    ) {
210        let mut repo_url = initial_repo_url.to_string();
211        let current_buffer_path = Path::new(file_path);
212        let selection = dummy_selection(start, end);
213
214        build_github_file_url(&mut repo_url, url_kind, commit_hash, current_buffer_path, &selection);
215
216        pretty_assertions::assert_eq!(repo_url, expected);
217    }
218
219    #[rstest]
220    #[case::single_line_selection(
221        "https://gitlab.com/user/repo",
222        "blob",
223        "abc123",
224        "/src/main.rs",
225        Bound { lnum: 10, col: 5 },
226        Bound { lnum: 10, col: 10 },
227        "https://gitlab.com/user/repo/-/blob/abc123/src/main.rs#L11-11"
228    )]
229    #[case::multi_line_selection(
230        "https://gitlab.com/user/repo",
231        "blob",
232        "def456",
233        "/lib/utils.rs",
234        Bound { lnum: 1, col: 0 },
235        Bound { lnum: 3, col: 20 },
236        "https://gitlab.com/user/repo/-/blob/def456/lib/utils.rs#L2-4"
237    )]
238    #[case::root_file(
239        "https://gitlab.com/user/repo",
240        "tree",
241        "ghi789",
242        "/README.md",
243        Bound { lnum: 5, col: 2 },
244        Bound { lnum: 5, col: 2 },
245        "https://gitlab.com/user/repo/-/tree/ghi789/README.md#L6-6"
246    )]
247    fn build_gitlab_file_url_works_as_expected(
248        #[case] initial_repo_url: &str,
249        #[case] url_kind: &str,
250        #[case] commit_hash: &str,
251        #[case] file_path: &str,
252        #[case] start: Bound,
253        #[case] end: Bound,
254        #[case] expected: &str,
255    ) {
256        let mut repo_url = initial_repo_url.to_string();
257        let current_buffer_path = Path::new(file_path);
258        let selection = dummy_selection(start, end);
259
260        build_gitlab_file_url(&mut repo_url, url_kind, commit_hash, current_buffer_path, &selection);
261
262        pretty_assertions::assert_eq!(repo_url, expected);
263    }
264
265    fn dummy_selection(start: Bound, end: Bound) -> Selection {
266        use ytil_noxi::visual_selection::SelectionBounds;
267        let bounds = SelectionBounds { buf_id: 1, start, end };
268        Selection::new(bounds, std::iter::empty::<nvim_oxi::String>())
269    }
270}