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 as _;
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
172    use super::*;
173
174    // Test data matches Nvim's real `:ls` format: `%3d%c%c%c%c%c "%s"`
175    // i.e. 5 flag chars (unlisted, current/alt, active/hidden, ro, changed) + space + quoted name.
176    #[rstest]
177    #[case(
178        "1u%a   \"file.txt\"",
179        MruBuffer {
180            id: 1,
181            is_unlisted: true,
182            name: "file.txt".to_string(),
183            kind: BufferKind::Path,
184        }
185    )]
186    #[case(
187        "2  %a  \"another.txt\"",
188        MruBuffer {
189            id: 2,
190            is_unlisted: false,
191            name: "another.txt".to_string(),
192            kind: BufferKind::Path,
193        }
194    )]
195    #[case(
196        "3  %a  \"[No Name]\"",
197        MruBuffer {
198            id: 3,
199            is_unlisted: false,
200            name: "[No Name]".to_string(),
201            kind: BufferKind::NoName,
202        }
203    )]
204    #[case(
205        "4u  a  \"term://bash\"",
206        MruBuffer {
207            id: 4,
208            is_unlisted: true,
209            name: "term://bash".to_string(),
210            kind: BufferKind::Term,
211        }
212    )]
213    #[case(
214        "5  %a  \"Grug FAR results\"",
215        MruBuffer {
216            id: 5,
217            is_unlisted: false,
218            name: "Grug FAR results".to_string(),
219            kind: BufferKind::GrugFar,
220        }
221    )]
222    #[case(
223        "  6  %a  \"trimmed.txt\"  ",
224        MruBuffer {
225            id: 6,
226            is_unlisted: false,
227            name: "trimmed.txt".to_string(),
228            kind: BufferKind::Path,
229        }
230    )]
231    #[case(
232        "10 #h   \"multi_digit.txt\"",
233        MruBuffer {
234            id: 10,
235            is_unlisted: false,
236            name: "multi_digit.txt".to_string(),
237            kind: BufferKind::Path,
238        }
239    )]
240    #[case(
241        "7u  aR  \"term://~//12345:/bin/zsh\"",
242        MruBuffer {
243            id: 7,
244            is_unlisted: true,
245            name: "term://~//12345:/bin/zsh".to_string(),
246            kind: BufferKind::Term,
247        }
248    )]
249    fn from_str_when_valid_input_returns_mru_buffer(#[case] input: &str, #[case] expected: MruBuffer) {
250        let result = MruBuffer::from_str(input);
251        assert2::assert!(let Ok(mru_buffer) = result);
252        pretty_assertions::assert_eq!(mru_buffer, expected);
253    }
254
255    #[rstest]
256    #[case("", "error finding buffer id end")]
257    #[case(" %a  \"file.txt\"", "error parsing buffer id")]
258    #[case("au %a  \"file.txt\"", "error parsing buffer id")]
259    #[case("1u%a  \"file.txt", "error extracting name")]
260    #[case("1u%a  file.txt", "error finding opening quote")]
261    fn from_str_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_err_substr: &str) {
262        let result = MruBuffer::from_str(input);
263        assert2::assert!(let Err(err) = result);
264        assert!(err.to_string().contains(expected_err_substr));
265    }
266}