Skip to main content

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