yghfl/
main.rs

1//! Copy GitHub URL (file/line/col) for the current Helix buffer to clipboard.
2//!
3//! # Usage
4//! ```bash
5//! yghfl # copies https://github.com/<org>/<repo>/tree/<branch>/<path>#L<C> to clipboard
6//! ```
7//!
8//! # Errors
9//! - `WezTerm` pane text retrieval fails.
10//! - Status line extraction or parse fails.
11//! - Git repository root or remote URL resolution fails.
12//! - Multiple or zero GitHub remotes detected.
13//! - Current branch lookup fails.
14//! - Invalid UTF-8 in paths or process output.
15#![feature(exit_status_error)]
16
17use core::str::FromStr;
18use std::path::Component;
19use std::path::Path;
20use std::path::PathBuf;
21use std::process::Command;
22use std::sync::Arc;
23
24use color_eyre::eyre::bail;
25use color_eyre::eyre::eyre;
26use url::Url;
27use ytil_editor::Editor;
28use ytil_hx::HxCursorPosition;
29use ytil_hx::HxStatusLine;
30use ytil_sys::cli::Args;
31use ytil_wezterm::WeztermPane;
32use ytil_wezterm::get_sibling_pane_with_titles;
33
34/// Builds absolute file path for Helix cursor position.
35///
36/// # Errors
37/// - Expanding a home-relative path (starting with `~`) fails because the home directory cannot be determined.
38fn build_hx_cursor_absolute_file_path(
39    hx_cursor_file_path: &Path,
40    hx_pane: &WeztermPane,
41) -> color_eyre::Result<PathBuf> {
42    if let Ok(hx_cursor_file_path) = hx_cursor_file_path.strip_prefix("~") {
43        return ytil_sys::dir::build_home_path(&[hx_cursor_file_path]);
44    }
45
46    let mut components = hx_pane.cwd.components();
47    components.next();
48    components.next();
49
50    Ok(std::iter::once(Component::RootDir)
51        .chain(components)
52        .chain(hx_cursor_file_path.components())
53        .collect())
54}
55
56/// Builds GitHub link pointing to specific file and line.
57///
58/// # Errors
59/// - UTF-8 conversion fails.
60fn build_github_link<'a>(
61    github_repo_url: &'a Url,
62    git_current_branch: &'a str,
63    file_path: &'a Path,
64    hx_cursor_position: &'a HxCursorPosition,
65) -> color_eyre::Result<Url> {
66    let mut file_path_parts = vec![];
67    for component in file_path.components() {
68        file_path_parts.push(
69            component
70                .as_os_str()
71                .to_str()
72                .ok_or_else(|| eyre!("path component invalid utf-8 | component={component:#?}"))?,
73        );
74    }
75
76    let segments = [&["tree", git_current_branch], file_path_parts.as_slice()].concat();
77    let mut github_link = github_repo_url.clone();
78    github_link
79        .path_segments_mut()
80        .map_err(|()| eyre!("cannot extend url with segments | url={github_repo_url} segments={segments:#?}"))?
81        .extend(&segments);
82    github_link.set_fragment(Some(&format!(
83        "L{}C{}",
84        hx_cursor_position.line, hx_cursor_position.column
85    )));
86
87    Ok(github_link)
88}
89
90/// Copy GitHub URL (file/line/col) for the current Helix buffer to clipboard.
91fn main() -> color_eyre::Result<()> {
92    color_eyre::install()?;
93
94    let args = ytil_sys::cli::get();
95    if args.has_help() {
96        println!("{}", include_str!("../help.txt"));
97        return Ok(());
98    }
99
100    let hx_pane = get_sibling_pane_with_titles(
101        &ytil_wezterm::get_all_panes(&[])?,
102        ytil_wezterm::get_current_pane_id()?,
103        Editor::Hx.pane_titles(),
104    )?;
105
106    let wezterm_pane_text = String::from_utf8(
107        Command::new("wezterm")
108            .args(["cli", "get-text", "--pane-id", &hx_pane.pane_id.to_string()])
109            .output()?
110            .stdout,
111    )?;
112
113    let hx_status_line = HxStatusLine::from_str(wezterm_pane_text.lines().nth_back(1).ok_or_else(|| {
114        eyre!(
115            "missing hx status line | pane_id={} text={wezterm_pane_text:#?}",
116            hx_pane.pane_id
117        )
118    })?)?;
119
120    let git_repo_root_path = Arc::new(ytil_git::repo::get_root(&ytil_git::repo::discover(
121        &hx_status_line.file_path,
122    )?));
123
124    let get_git_current_branch =
125        std::thread::spawn(move || -> color_eyre::Result<String> { ytil_git::branch::get_current() });
126
127    let git_repo_root_path_clone = git_repo_root_path.clone();
128    let get_github_repo_url = std::thread::spawn(move || -> color_eyre::Result<Url> {
129        match &ytil_gh::get_repo_urls(&git_repo_root_path_clone)?.as_slice() {
130            &[] => bail!("missing GitHub repo URL | repo_path={git_repo_root_path_clone:#?}"),
131            &[one] => Ok(one.clone()),
132            multi => {
133                bail!("multiple GitHub repo URLs | URLs={multi:#?} repo_path={git_repo_root_path_clone:#?}")
134            }
135        }
136    });
137
138    // `build_file_path_relative_to_git_repo_root` are called before the threads `join` to let them work in the
139    // background as much as possible
140    let hx_cursor_absolute_file_path = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane)?;
141
142    let github_link = build_github_link(
143        &ytil_sys::join(get_github_repo_url)?,
144        &ytil_sys::join(get_git_current_branch)?,
145        hx_cursor_absolute_file_path.strip_prefix(git_repo_root_path.as_ref())?,
146        &hx_status_line.position,
147    )?;
148
149    ytil_sys::file::cp_to_system_clipboard(&mut github_link.as_str().as_bytes())?;
150
151    Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156    use fake::Fake;
157    use fake::Faker;
158
159    use super::*;
160
161    #[test]
162    fn build_hx_cursor_absolute_file_path_works_as_expected_with_file_path_as_relative_to_home_dir() {
163        // Arrange
164        temp_env::with_vars([("HOME", Some("/Users/Foo"))], || {
165            let hx_status_line = HxStatusLine {
166                file_path: Path::new("~/src/bar/baz.rs").into(),
167                ..Faker.fake()
168            };
169            let hx_pane = WeztermPane {
170                cwd: Path::new("file://hostname/Users/Foo/dev").into(),
171                ..Faker.fake()
172            };
173
174            // Act
175            let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane);
176
177            // Assert
178            let expected = Path::new("/Users/Foo/src/bar/baz.rs").to_path_buf();
179            assert_eq!(result.unwrap(), expected);
180        });
181    }
182
183    #[test]
184    fn build_hx_cursor_absolute_file_path_works_as_expected_with_file_path_as_relative_to_hx_root() {
185        // Arrange
186        let hx_status_line = HxStatusLine {
187            file_path: Path::new("src/bar/baz.rs").into(),
188            ..Faker.fake()
189        };
190        let hx_pane = WeztermPane {
191            cwd: Path::new("file://hostname/Users/Foo/dev").into(),
192            ..Faker.fake()
193        };
194
195        // Act
196        let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane).unwrap();
197
198        // Assert
199        let expected = Path::new("/Users/Foo/dev/src/bar/baz.rs").to_path_buf();
200        assert_eq!(expected, result);
201    }
202
203    #[test]
204    fn build_hx_cursor_absolute_file_path_works_as_expected_with_file_path_as_absolute() {
205        // Arrange
206        let hx_status_line = HxStatusLine {
207            file_path: Path::new("/Users/Foo/dev/src/bar/baz.rs").into(),
208            ..Faker.fake()
209        };
210        let hx_pane = WeztermPane {
211            cwd: Path::new("file://hostname/Users/Foo/dev").into(),
212            ..Faker.fake()
213        };
214
215        // Act
216        let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane).unwrap();
217
218        // Assert
219        let expected = Path::new("/Users/Foo/dev/src/bar/baz.rs").to_path_buf();
220        assert_eq!(expected, result);
221    }
222}