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 ytil_wezterm::WeztermPane;
141    use ytil_wezterm::WeztermPaneSize;
142
143    use super::*;
144
145    #[test]
146    fn open_file_cmd_returns_the_expected_cmd_string() {
147        let file = FileToOpen {
148            path: "src/main.rs".into(),
149            line_nbr: 12,
150            column: 5,
151        };
152        assert_eq!(Editor::Hx.open_file_cmd(&file), "':o src/main.rs:12'");
153        assert_eq!(
154            Editor::Nvim.open_file_cmd(&file),
155            ":e src/main.rs | :call cursor(12, 5)"
156        );
157    }
158
159    #[test]
160    fn pane_titles_are_the_expected_ones() {
161        assert_eq!(Editor::Hx.pane_titles(), &["hx"]);
162        assert_eq!(Editor::Nvim.pane_titles(), &["nvim", "nv"]);
163    }
164
165    #[test]
166    fn editor_from_str_works_as_expected() {
167        assert2::assert!(let Ok(Editor::Hx) = Editor::from_str("hx"));
168        assert2::assert!(let Ok(Editor::Nvim) = Editor::from_str("nvim"));
169        assert2::assert!(let Ok(Editor::Nvim) = Editor::from_str("nv"));
170        assert2::assert!(let Err(err) = Editor::from_str("unknown"));
171        assert!(err.to_string().contains("unknown editor"));
172    }
173
174    #[test]
175    fn file_to_open_from_str_works_as_expected() {
176        let root_dir = std::env::current_dir().unwrap();
177        // We should always have a Cargo.toml...
178        let dummy_path = root_dir.join("Cargo.toml").to_string_lossy().into_owned();
179
180        assert2::assert!(let Ok(f0) = FileToOpen::from_str(&dummy_path));
181        let expected = FileToOpen {
182            path: dummy_path.clone(),
183            line_nbr: 0,
184            column: 0,
185        };
186        assert_eq!(f0, expected);
187
188        assert2::assert!(let Ok(f1) = FileToOpen::from_str(&format!("{dummy_path}:3")));
189        let expected = FileToOpen {
190            path: dummy_path.clone(),
191            line_nbr: 3,
192            column: 0,
193        };
194        assert_eq!(f1, expected);
195
196        assert2::assert!(let Ok(f2) = FileToOpen::from_str(&format!("{dummy_path}:3:7")));
197        let expected = FileToOpen {
198            path: dummy_path,
199            line_nbr: 3,
200            column: 7,
201        };
202        assert_eq!(f2, expected);
203    }
204
205    #[test]
206    fn try_from_errors_when_pane_is_missing() {
207        let panes: Vec<WeztermPane> = vec![];
208        assert2::assert!(let Err(err) = FileToOpen::try_from(("README.md", 999, panes.as_slice())));
209        assert!(err.to_string().contains("missing pane"));
210    }
211
212    #[test]
213    fn try_from_errors_when_relative_file_is_missing() {
214        let dir = std::env::current_dir().unwrap();
215        let panes = vec![pane_with(1, 1, &dir)];
216        assert2::assert!(let
217            Err(err) = FileToOpen::try_from(("definitely_missing_12345__file.rs", 1, panes.as_slice()))
218        );
219        assert!(err.to_string().contains("error parsing file to open"));
220    }
221
222    #[test]
223    fn try_from_resolves_relative_existing_file() {
224        let dir = std::env::current_dir().unwrap();
225        let panes = vec![pane_with(7, 1, &dir)];
226        assert2::assert!(let Ok(file) = FileToOpen::try_from(("Cargo.toml", 7, panes.as_slice())));
227        let expected = FileToOpen {
228            path: dir.join("Cargo.toml").to_string_lossy().into_owned(),
229            line_nbr: 0,
230            column: 0,
231        };
232        assert_eq!(file, expected);
233    }
234
235    fn pane_with(pane_id: i64, tab_id: i64, cwd_fs: &std::path::Path) -> WeztermPane {
236        WeztermPane {
237            cursor_shape: "Block".into(),
238            cursor_visibility: "Visible".into(),
239            cursor_x: 0,
240            cursor_y: 0,
241            // Use double-slash host form so absolute_cwd drops the first two components and yields the real filesystem
242            // path.
243            cwd: PathBuf::from(format!("file://host{}", cwd_fs.display())),
244            is_active: true,
245            is_zoomed: false,
246            left_col: 0,
247            pane_id,
248            size: WeztermPaneSize {
249                cols: 80,
250                dpi: 96,
251                pixel_height: 800,
252                pixel_width: 600,
253                rows: 24,
254            },
255            tab_id,
256            tab_title: "tab".into(),
257            title: "hx".into(),
258            top_row: 0,
259            tty_name: "tty".into(),
260            window_id: 1,
261            window_title: "win".into(),
262            workspace: "default".into(),
263        }
264    }
265}