ytil_editor/
lib.rs

1//! Parse `path:line[:column]` specs and build editor open commands for Helix / Nvim panes.
2//!
3//! Supports absolute or relative paths (resolved against a pane's cwd) and returns shell snippets
4//! to open a file and place the cursor at the requested position.
5
6use core::str::FromStr;
7use std::path::Path;
8
9use color_eyre::eyre::WrapErr;
10use color_eyre::eyre::bail;
11use color_eyre::eyre::eyre;
12use ytil_wezterm::WeztermPane;
13
14/// Supported text editors for file operations.
15pub enum Editor {
16    /// Helix editor.
17    Hx,
18    /// Nvim editor.
19    Nvim,
20}
21
22impl Editor {
23    /// Generates a command string to open the specified [`FileToOpen`] in the [`Editor`].
24    pub fn open_file_cmd(&self, file_to_open: &FileToOpen) -> String {
25        let path = file_to_open.path.as_str();
26        let line_nbr = file_to_open.line_nbr;
27        let column = file_to_open.column;
28
29        match self {
30            Self::Hx => format!("':o {path}:{line_nbr}'"),
31            Self::Nvim => format!(":e {path} | :call cursor({line_nbr}, {column})"),
32        }
33    }
34
35    /// Returns the pane titles associated with the [`Editor`] variant.
36    pub const fn pane_titles(&self) -> &[&str] {
37        match self {
38            Self::Hx => &["hx"],
39            Self::Nvim => &["nvim", "nv"],
40        }
41    }
42}
43
44/// Parses an [`Editor`] from a string representation.
45impl FromStr for Editor {
46    type Err = color_eyre::eyre::Error;
47
48    fn from_str(value: &str) -> Result<Self, Self::Err> {
49        match value {
50            "hx" => Ok(Self::Hx),
51            "nvim" | "nv" => Ok(Self::Nvim),
52            unknown => Err(eyre!("unknown editor | value={unknown}")),
53        }
54    }
55}
56
57/// Represents a file to be opened in an editor with optional line and column positioning.
58#[derive(Debug, Eq, PartialEq)]
59pub struct FileToOpen {
60    /// The column number to position the cursor (0-based, defaults to 0).
61    pub column: i64,
62    /// The line number to position the cursor (0-based, defaults to 0).
63    pub line_nbr: i64,
64    /// The filesystem path to the file.
65    pub path: String,
66}
67
68/// Attempts to create a [`FileToOpen`] from a file path, pane ID, and list of panes.
69impl TryFrom<(&str, i64, &[WeztermPane])> for FileToOpen {
70    type Error = color_eyre::eyre::Error;
71
72    fn try_from((file_to_open, pane_id, panes): (&str, i64, &[WeztermPane])) -> Result<Self, Self::Error> {
73        if Path::new(file_to_open).is_absolute() {
74            return Self::from_str(file_to_open);
75        }
76
77        let mut source_pane_absolute_cwd = panes
78            .iter()
79            .find(|pane| pane.pane_id == pane_id)
80            .ok_or_else(|| eyre!("missing pane | pane_id={pane_id} panes={panes:#?}"))?
81            .absolute_cwd();
82
83        source_pane_absolute_cwd.push(file_to_open);
84
85        Self::from_str(
86            source_pane_absolute_cwd
87                .to_str()
88                .ok_or_else(|| eyre!("cannot get path str | path={source_pane_absolute_cwd:#?}"))?,
89        )
90        .wrap_err_with(|| eyre!("error parsing file to open | file_to_open={file_to_open} pane_id={pane_id}"))
91    }
92}
93
94/// Parses a [`FileToOpen`] from a string in the format "path:line:column".
95impl FromStr for FileToOpen {
96    type Err = color_eyre::eyre::Error;
97
98    fn from_str(s: &str) -> Result<Self, Self::Err> {
99        let mut parts = s.split(':');
100        let path = parts.next().ok_or_else(|| eyre!("file path missing | str={s}"))?;
101        let line_nbr = parts
102            .next()
103            .map(str::parse::<i64>)
104            .transpose()
105            .wrap_err_with(|| eyre!("invalid line number | str={s:?}"))?
106            .unwrap_or_default();
107        let column = parts
108            .next()
109            .map(str::parse::<i64>)
110            .transpose()
111            .wrap_err_with(|| eyre!("invalid column number | str={s:?}"))?
112            .unwrap_or_default();
113        if !Path::new(path)
114            .try_exists()
115            .wrap_err_with(|| eyre!("error checking if file exists | path={path:?}"))?
116        {
117            bail!("file missing | path={path}")
118        }
119
120        Ok(Self {
121            path: path.into(),
122            line_nbr,
123            column,
124        })
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use std::path::PathBuf;
131
132    use ytil_wezterm::WeztermPane;
133    use ytil_wezterm::WeztermPaneSize;
134
135    use super::*;
136
137    #[test]
138    fn open_file_cmd_returns_the_expected_cmd_string() {
139        let file = FileToOpen {
140            path: "src/main.rs".into(),
141            line_nbr: 12,
142            column: 5,
143        };
144        assert_eq!(Editor::Hx.open_file_cmd(&file), "':o src/main.rs:12'");
145        assert_eq!(
146            Editor::Nvim.open_file_cmd(&file),
147            ":e src/main.rs | :call cursor(12, 5)"
148        );
149    }
150
151    #[test]
152    fn pane_titles_are_the_expected_ones() {
153        assert_eq!(Editor::Hx.pane_titles(), &["hx"]);
154        assert_eq!(Editor::Nvim.pane_titles(), &["nvim", "nv"]);
155    }
156
157    #[test]
158    fn editor_from_str_works_as_expected() {
159        assert2::let_assert!(Ok(Editor::Hx) = Editor::from_str("hx"));
160        assert2::let_assert!(Ok(Editor::Nvim) = Editor::from_str("nvim"));
161        assert2::let_assert!(Ok(Editor::Nvim) = Editor::from_str("nv"));
162        assert2::let_assert!(Err(err) = Editor::from_str("unknown"));
163        assert!(err.to_string().contains("unknown editor"));
164    }
165
166    #[test]
167    fn file_to_open_from_str_works_as_expected() {
168        let root_dir = std::env::current_dir().unwrap();
169        // We should always have a Cargo.toml...
170        let dummy_path = root_dir.join("Cargo.toml").to_string_lossy().to_string();
171
172        assert2::let_assert!(Ok(f0) = FileToOpen::from_str(&dummy_path));
173        let expected = FileToOpen {
174            path: dummy_path.clone(),
175            line_nbr: 0,
176            column: 0,
177        };
178        assert_eq!(f0, expected);
179
180        assert2::let_assert!(Ok(f1) = FileToOpen::from_str(&format!("{dummy_path}:3")));
181        let expected = FileToOpen {
182            path: dummy_path.clone(),
183            line_nbr: 3,
184            column: 0,
185        };
186        assert_eq!(f1, expected);
187
188        assert2::let_assert!(Ok(f2) = FileToOpen::from_str(&format!("{dummy_path}:3:7")));
189        let expected = FileToOpen {
190            path: dummy_path,
191            line_nbr: 3,
192            column: 7,
193        };
194        assert_eq!(f2, expected);
195    }
196
197    #[test]
198    fn try_from_errors_when_pane_is_missing() {
199        let panes: Vec<WeztermPane> = vec![];
200        assert2::let_assert!(Err(err) = FileToOpen::try_from(("README.md", 999, panes.as_slice())));
201        assert!(err.to_string().contains("missing pane"));
202    }
203
204    #[test]
205    fn try_from_errors_when_relative_file_is_missing() {
206        let dir = std::env::current_dir().unwrap();
207        let panes = vec![pane_with(1, 1, &dir)];
208        assert2::let_assert!(
209            Err(err) = FileToOpen::try_from(("definitely_missing_12345__file.rs", 1, panes.as_slice()))
210        );
211        assert!(err.to_string().contains("error parsing file to open"));
212    }
213
214    #[test]
215    fn try_from_resolves_relative_existing_file() {
216        let dir = std::env::current_dir().unwrap();
217        let panes = vec![pane_with(7, 1, &dir)];
218        assert2::let_assert!(Ok(file) = FileToOpen::try_from(("Cargo.toml", 7, panes.as_slice())));
219        let expected = FileToOpen {
220            path: dir.join("Cargo.toml").to_string_lossy().to_string(),
221            line_nbr: 0,
222            column: 0,
223        };
224        assert_eq!(file, expected);
225    }
226
227    fn pane_with(pane_id: i64, tab_id: i64, cwd_fs: &std::path::Path) -> WeztermPane {
228        WeztermPane {
229            cursor_shape: "Block".into(),
230            cursor_visibility: "Visible".into(),
231            cursor_x: 0,
232            cursor_y: 0,
233            // Use double-slash host form so absolute_cwd drops the first two components and yields the real filesystem
234            // path.
235            cwd: PathBuf::from(format!("file://host{}", cwd_fs.display())),
236            is_active: true,
237            is_zoomed: false,
238            left_col: 0,
239            pane_id,
240            size: WeztermPaneSize {
241                cols: 80,
242                dpi: 96,
243                pixel_height: 800,
244                pixel_width: 600,
245                rows: 24,
246            },
247            tab_id,
248            tab_title: "tab".into(),
249            title: "hx".into(),
250            top_row: 0,
251            tty_name: "tty".into(),
252            window_id: 1,
253            window_title: "win".into(),
254            workspace: "default".into(),
255        }
256    }
257}