Skip to main content

ytil_noxi/
mru_buffers.rs

1//! Most recently used (MRU) buffers parsing from Nvim's buffer list.
2
3use std::str::FromStr;
4
5use nvim_oxi::api::Buffer;
6use rootcause::prelude::ResultExt;
7use rootcause::report;
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 = rootcause::Report;
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(|| report!("error finding buffer id end"))
83            .attach_with(|| format!("mru_buffer_line={mru_buffer_line:?}"))?;
84
85        let id: i32 = {
86            let id = mru_buffer_line
87                .get(..is_unlisted_idx)
88                .ok_or_else(|| report!("error extracting buffer id"))
89                .attach_with(|| format!("mru_buffer_line={mru_buffer_line:?}"))?;
90            id.parse()
91                .context("error parsing buffer id")
92                .attach_with(|| format!("id={id:?} mru_buffer_line={mru_buffer_line:?}"))?
93        };
94
95        let is_unlisted = mru_buffer_line
96            .get(is_unlisted_idx..=is_unlisted_idx)
97            .ok_or_else(|| report!("error extracting is_unlisted by idx"))
98            .attach_with(|| format!("idx={is_unlisted_idx} mru_buffer_line={mru_buffer_line:?}"))?
99            == "u";
100
101        // Find the opening '"' after the flags and extract the name between the quotes.
102        // Nvim's `:ls` format is `%3d%c%c%c%c%c "%s"` (5 flag chars + space + quoted name),
103        // but we locate the quote dynamically to be resilient to format changes.
104        let name_idx = mru_buffer_line
105            .get(is_unlisted_idx..)
106            .and_then(|s| s.find('"').map(|i| is_unlisted_idx.saturating_add(i).saturating_add(1)))
107            .ok_or_else(|| report!("error finding opening quote"))
108            .attach_with(|| format!("mru_buffer_line={mru_buffer_line:?}"))?;
109
110        let rest = mru_buffer_line
111            .get(name_idx..)
112            .ok_or_else(|| report!("error extracting name part by idx"))
113            .attach_with(|| format!("idx={name_idx} mru_buffer_line={mru_buffer_line:?}"))?;
114
115        let (name, _) = rest
116            .split_once('"')
117            .ok_or_else(|| report!("error extracting name"))
118            .attach_with(|| format!("rest={rest:?} mru_buffer_line={mru_buffer_line:?}"))?;
119
120        Ok(Self {
121            id,
122            is_unlisted,
123            name: name.to_string(),
124            kind: BufferKind::from(name),
125        })
126    }
127}
128
129/// Retrieves the list of most recently used buffers from Nvim.
130///
131/// Calls Nvim's "execute" function with "ls t" to get the buffer list output,
132/// then parses it into a vector of [`MruBuffer`]. Errors during execution or parsing
133/// are notified to the user and result in [`None`] being returned.
134pub fn get() -> Option<Vec<MruBuffer>> {
135    let Ok(mru_buffers_output) = nvim_oxi::api::call_function::<_, String>("execute", ("ls t",))
136        .inspect_err(|err| crate::notify::error(format!("error getting mru buffers | error={err:?}")))
137    else {
138        return None;
139    };
140
141    parse_mru_buffers_output(&mru_buffers_output)
142        .inspect_err(|err| {
143            crate::notify::error(format!(
144                "error parsing mru buffers output | mru_buffers_output={mru_buffers_output:?} error={err:?}"
145            ));
146        })
147        .ok()
148}
149
150/// Parses the output of Nvim's "ls t" command into a vector of [`MruBuffer`].
151///
152/// # Errors
153/// - Parsing any individual buffer line fails.
154fn parse_mru_buffers_output(mru_buffers_output: &str) -> rootcause::Result<Vec<MruBuffer>> {
155    if mru_buffers_output.is_empty() {
156        return Ok(vec![]);
157    }
158    let mut out = vec![];
159    for mru_buffer_line in mru_buffers_output.lines() {
160        if mru_buffer_line.is_empty() {
161            continue;
162        }
163        out.push(MruBuffer::from_str(mru_buffer_line)?);
164    }
165    Ok(out)
166}
167
168#[cfg(test)]
169mod tests {
170    use rstest::rstest;
171    use test_that::prelude::*;
172
173    use super::*;
174
175    // Test data matches Nvim's real `:ls` format: `%3d%c%c%c%c%c "%s"`
176    // i.e. 5 flag chars (unlisted, current/alt, active/hidden, ro, changed) + space + quoted name.
177    #[rstest]
178    #[case(
179        "1u%a   \"file.txt\"",
180        MruBuffer {
181            id: 1,
182            is_unlisted: true,
183            name: "file.txt".to_string(),
184            kind: BufferKind::Path,
185        }
186    )]
187    #[case(
188        "2  %a  \"another.txt\"",
189        MruBuffer {
190            id: 2,
191            is_unlisted: false,
192            name: "another.txt".to_string(),
193            kind: BufferKind::Path,
194        }
195    )]
196    #[case(
197        "3  %a  \"[No Name]\"",
198        MruBuffer {
199            id: 3,
200            is_unlisted: false,
201            name: "[No Name]".to_string(),
202            kind: BufferKind::NoName,
203        }
204    )]
205    #[case(
206        "4u  a  \"term://bash\"",
207        MruBuffer {
208            id: 4,
209            is_unlisted: true,
210            name: "term://bash".to_string(),
211            kind: BufferKind::Term,
212        }
213    )]
214    #[case(
215        "5  %a  \"Grug FAR results\"",
216        MruBuffer {
217            id: 5,
218            is_unlisted: false,
219            name: "Grug FAR results".to_string(),
220            kind: BufferKind::GrugFar,
221        }
222    )]
223    #[case(
224        "  6  %a  \"trimmed.txt\"  ",
225        MruBuffer {
226            id: 6,
227            is_unlisted: false,
228            name: "trimmed.txt".to_string(),
229            kind: BufferKind::Path,
230        }
231    )]
232    #[case(
233        "10 #h   \"multi_digit.txt\"",
234        MruBuffer {
235            id: 10,
236            is_unlisted: false,
237            name: "multi_digit.txt".to_string(),
238            kind: BufferKind::Path,
239        }
240    )]
241    #[case(
242        "7u  aR  \"term://~//12345:/bin/zsh\"",
243        MruBuffer {
244            id: 7,
245            is_unlisted: true,
246            name: "term://~//12345:/bin/zsh".to_string(),
247            kind: BufferKind::Term,
248        }
249    )]
250    fn from_str_when_valid_input_returns_mru_buffer(#[case] input: &str, #[case] expected: MruBuffer) {
251        assert_that!(MruBuffer::from_str(input), ok(eq(expected)));
252    }
253
254    #[rstest]
255    #[case("", "error finding buffer id end")]
256    #[case(" %a  \"file.txt\"", "error parsing buffer id")]
257    #[case("au %a  \"file.txt\"", "error parsing buffer id")]
258    #[case("1u%a  \"file.txt", "error extracting name")]
259    #[case("1u%a  file.txt", "error finding opening quote")]
260    fn test_from_str_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_err_substr: &str) {
261        let result = MruBuffer::from_str(input);
262        assert_that!(
263            (result).map(|_| ()),
264            err(displays_as(contains_substring(expected_err_substr)))
265        );
266    }
267}