1use 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
15pub fn dict() -> Dictionary {
17 dict! {
18 "get_link": fn_from!(get_link),
19 }
20}
21
22#[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 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 ¤t_commit_hash,
78 ¤t_buffer_path,
79 &selection,
80 ),
81 GitProvider::GitLab => build_gitlab_file_url(
82 &mut repo_url,
83 &link_type,
84 ¤t_commit_hash,
85 ¤t_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 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 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}