ytil_noxi/
mru_buffers.rs

1//! Most recently used (MRU) buffers parsing from Nvim's buffer list.
2
3use std::str::FromStr;
4
5use color_eyre::eyre::Context as _;
6use color_eyre::eyre::eyre;
7use nvim_oxi::api::Buffer;
8
9/// Represents a most recently used buffer with its metadata.
10#[derive(Debug)]
11#[cfg_attr(test, derive(Eq, PartialEq))]
12pub struct MruBuffer {
13    /// The buffer ID.
14    pub id: i32,
15    /// Whether the buffer is unlisted.
16    pub is_unlisted: bool,
17    /// The buffer name.
18    pub name: String,
19    /// The kind of buffer based on its name.
20    pub kind: BufferKind,
21}
22
23impl MruBuffer {
24    pub const fn is_term(&self) -> bool {
25        match self.kind {
26            BufferKind::Term => true,
27            BufferKind::GrugFar | BufferKind::Path | BufferKind::NoName => false,
28        }
29    }
30}
31
32impl From<&MruBuffer> for Buffer {
33    fn from(value: &MruBuffer) -> Self {
34        Self::from(value.id)
35    }
36}
37
38/// Categorizes buffers by their type based on name patterns.
39#[derive(Debug)]
40#[cfg_attr(test, derive(Eq, PartialEq))]
41pub enum BufferKind {
42    /// Terminal buffers starting with "term://".
43    Term,
44    /// Grug FAR results buffers.
45    GrugFar,
46    /// Regular file path buffers.
47    Path,
48    /// No name buffers.
49    NoName,
50}
51
52impl<T: AsRef<str>> From<T> for BufferKind {
53    fn from(value: T) -> Self {
54        let str = value.as_ref();
55        if str.starts_with("term://") {
56            Self::Term
57        } else if str.starts_with("Grug FAR") {
58            Self::GrugFar
59        } else if str.starts_with("[No Name]") {
60            Self::NoName
61        } else {
62            Self::Path
63        }
64    }
65}
66
67/// Parses a line from Nvim's buffer list output into an [`MruBuffer`].
68///
69/// # Errors
70/// - Parsing the buffer ID fails.
71/// - Extracting the unlisted flag fails.
72/// - Extracting the name fails.
73impl FromStr for MruBuffer {
74    type Err = color_eyre::eyre::Error;
75
76    fn from_str(mru_buffer_line: &str) -> Result<Self, Self::Err> {
77        let mru_buffer_line = mru_buffer_line.trim();
78
79        let is_unlisted_idx = mru_buffer_line
80            .char_indices()
81            .find_map(|(idx, c)| if c.is_numeric() { None } else { Some(idx) })
82            .ok_or_else(|| eyre!("error finding buffer id end | mru_buffer_line={mru_buffer_line:?}"))?;
83
84        let id: i32 = {
85            let id = mru_buffer_line
86                .get(..is_unlisted_idx)
87                .ok_or_else(|| eyre!("error extracting buffer id | mru_buffer_line={mru_buffer_line:?}"))?;
88            id.parse()
89                .wrap_err_with(|| format!("error parsing buffer id | id={id:?} mru_buffer_line={mru_buffer_line:?}"))?
90        };
91
92        let is_unlisted = mru_buffer_line.get(is_unlisted_idx..=is_unlisted_idx).ok_or_else(|| {
93            eyre!("error extracting is_unlisted by idx | idx={is_unlisted_idx} mru_buffer_line={mru_buffer_line:?}")
94        })? == "u";
95
96        // Skip entirely the other flags and the first '"' char.
97        let name_idx = is_unlisted_idx.saturating_add(7);
98
99        let rest = mru_buffer_line.get(name_idx..).ok_or_else(|| {
100            eyre!("error extracting name part by idx | idx={name_idx} mru_buffer_line={mru_buffer_line:?}")
101        })?;
102
103        let (name, _) = rest
104            .split_once('"')
105            .ok_or_else(|| eyre!("error extracting name | rest={rest:?} mru_buffer_line={mru_buffer_line:?}"))?;
106
107        Ok(Self {
108            id,
109            is_unlisted,
110            name: name.to_string(),
111            kind: BufferKind::from(name),
112        })
113    }
114}
115
116/// Retrieves the list of most recently used buffers from Nvim.
117///
118/// Calls Nvim's "execute" function with "ls t" to get the buffer list output,
119/// then parses it into a vector of [`MruBuffer`]. Errors during execution or parsing
120/// are notified to the user and result in [`None`] being returned.
121pub fn get() -> Option<Vec<MruBuffer>> {
122    let Ok(mru_buffers_output) = nvim_oxi::api::call_function::<_, String>("execute", ("ls t",))
123        .inspect_err(|err| crate::notify::error(format!("error getting mru buffers | error={err:?}")))
124    else {
125        return None;
126    };
127
128    parse_mru_buffers_output(&mru_buffers_output)
129        .inspect_err(|err| {
130            crate::notify::error(format!(
131                "error parsing mru buffers output | mru_buffers_output={mru_buffers_output:?} error={err:?}"
132            ));
133        })
134        .ok()
135}
136
137/// Parses the output of Nvim's "ls t" command into a vector of [`MruBuffer`].
138///
139/// # Errors
140/// - Parsing any individual buffer line fails.
141fn parse_mru_buffers_output(mru_buffers_output: &str) -> color_eyre::Result<Vec<MruBuffer>> {
142    if mru_buffers_output.is_empty() {
143        return Ok(vec![]);
144    }
145    let mut out = vec![];
146    for mru_buffer_line in mru_buffers_output.lines() {
147        if mru_buffer_line.is_empty() {
148            continue;
149        }
150        out.push(MruBuffer::from_str(mru_buffer_line)?);
151    }
152    Ok(out)
153}
154
155#[cfg(test)]
156mod tests {
157    use rstest::rstest;
158
159    use super::*;
160
161    #[rstest]
162    #[case(
163        "1u %a \"file.txt\"",
164        MruBuffer {
165            id: 1,
166            is_unlisted: true,
167            name: "ile.txt".to_string(),
168            kind: BufferKind::Path,
169        }
170    )]
171    #[case(
172        "2  %a \"another.txt\"",
173        MruBuffer {
174            id: 2,
175            is_unlisted: false,
176            name: "nother.txt".to_string(),
177            kind: BufferKind::Path,
178        }
179    )]
180    #[case(
181        "3  %a \"[No Name]\"",
182        MruBuffer {
183            id: 3,
184            is_unlisted: false,
185            name: "No Name]".to_string(),
186            kind: BufferKind::Path,
187        }
188    )]
189    #[case(
190        "4  %a \"term://bash\"",
191        MruBuffer {
192            id: 4,
193            is_unlisted: false,
194            name: "erm://bash".to_string(),
195            kind: BufferKind::Path,
196        }
197    )]
198    #[case(
199        "5  %a \"Grug FAR results\"",
200        MruBuffer {
201            id: 5,
202            is_unlisted: false,
203            name: "rug FAR results".to_string(),
204            kind: BufferKind::Path,
205        }
206    )]
207    #[case(
208        "  6  %a \"trimmed.txt\"  ",
209        MruBuffer {
210            id: 6,
211            is_unlisted: false,
212            name: "rimmed.txt".to_string(),
213            kind: BufferKind::Path,
214        }
215    )]
216    fn from_str_when_valid_input_returns_mru_buffer(#[case] input: &str, #[case] expected: MruBuffer) {
217        let result = MruBuffer::from_str(input);
218        assert2::let_assert!(Ok(mru_buffer) = result);
219        pretty_assertions::assert_eq!(mru_buffer, expected);
220    }
221
222    #[rstest]
223    #[case("", "error finding buffer id end")]
224    #[case(" %a \"file.txt\"", "error parsing buffer id")]
225    #[case("au %a \"file.txt\"", "error parsing buffer id")]
226    #[case("1u %a \"file.txt", "error extracting name")]
227    #[case("1u %a file.txt", "error extracting name")]
228    fn from_str_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_err_substr: &str) {
229        let result = MruBuffer::from_str(input);
230        assert2::let_assert!(Err(err) = result);
231        assert!(err.to_string().contains(expected_err_substr));
232    }
233}