ytil_noxi/
dict.rs

1//! Typed dictionary extraction helpers for Nvim objects.
2
3use color_eyre::eyre::Context;
4use color_eyre::eyre::eyre;
5use nvim_oxi::Dictionary;
6use nvim_oxi::ObjectKind;
7
8use crate::extract::OxiExtract;
9
10/// Extension trait for [`Dictionary`] to provide typed getters.
11pub trait DictionaryExt {
12    /// Gets a required typed value from the dictionary using the [`OxiExtract`] trait.
13    ///
14    /// Fails if the key is absent.
15    ///
16    ///
17    /// # Errors
18    /// - The key is missing.
19    /// - The value exists but cannot be converted to the requested type (unexpected kind).
20    fn get_t<T: OxiExtract>(&self, key: &str) -> color_eyre::Result<T::Out>;
21
22    /// Gets an optional typed value from the dictionary using the [`OxiExtract`] trait.
23    ///
24    /// Returns `Ok(None)` if the key is absent instead of treating it as an error.
25    ///
26    ///
27    /// # Errors
28    /// - The value exists but cannot be converted to the requested type (unexpected kind).
29    fn get_opt_t<T: OxiExtract>(&self, key: &str) -> color_eyre::Result<Option<T::Out>>;
30
31    /// Gets an optional nested [`Dictionary`] by traversing a sequence of keys.
32    ///
33    /// Returns `Ok(None)` if any key in the path is absent.
34    ///
35    ///
36    /// # Errors
37    /// - A value is found for an intermediate key but it is not a [`Dictionary`] (unexpected kind).
38    fn get_dict(&self, keys: &[&str]) -> color_eyre::Result<Option<Dictionary>>;
39
40    /// Gets a required nested [`Dictionary`] by traversing a sequence of keys.
41    ///
42    /// Fails if any key in the path is missing.
43    ///
44    ///
45    /// # Errors
46    /// - A key in the path is missing.
47    /// - A value is found for an intermediate key but it is not a [`Dictionary`] (unexpected kind).
48    fn get_required_dict(&self, keys: &[&str]) -> color_eyre::Result<Dictionary>;
49}
50
51/// Implementation of [`DictionaryExt`] for [`Dictionary`] providing typed getters.
52impl DictionaryExt for Dictionary {
53    fn get_t<T: OxiExtract>(&self, key: &str) -> color_eyre::Result<T::Out> {
54        let value = self.get(key).ok_or_else(|| no_value_matching(&[key], self))?;
55        T::extract_from_dict(key, value, self)
56    }
57
58    fn get_opt_t<T: OxiExtract>(&self, key: &str) -> color_eyre::Result<Option<T::Out>> {
59        self.get(key)
60            .map(|value| T::extract_from_dict(key, value, self))
61            .transpose()
62    }
63
64    fn get_dict(&self, keys: &[&str]) -> color_eyre::Result<Option<Dictionary>> {
65        let mut current = self.clone();
66
67        for key in keys {
68            let Some(obj) = current.get(key) else { return Ok(None) };
69            current = Self::try_from(obj.clone()).with_context(|| {
70                crate::extract::unexpected_kind_error_msg(obj, key, &current, ObjectKind::Dictionary)
71            })?;
72        }
73
74        Ok(Some(current))
75    }
76
77    fn get_required_dict(&self, keys: &[&str]) -> color_eyre::Result<Dictionary> {
78        self.get_dict(keys)?.ok_or_else(|| no_value_matching(keys, self))
79    }
80}
81
82/// Creates an error for missing value in [`Dictionary`].
83fn no_value_matching(query: &[&str], dict: &Dictionary) -> color_eyre::eyre::Error {
84    eyre!("missing dict value | query={query:#?} dict={dict:#?}")
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::dict;
91
92    #[test]
93    fn get_t_missing_key_errors() {
94        let d = dict! { other: 1 };
95        assert2::let_assert!(Err(err) = d.get_t::<nvim_oxi::String>("name"));
96        let msg = err.to_string();
97        assert!(msg.starts_with("missing dict value |"), "actual: {msg}");
98        assert!(msg.contains("query=[\n    \"name\",\n]"), "actual: {msg}");
99    }
100
101    #[test]
102    fn get_opt_t_missing_key_ok_none() {
103        let d = dict! { other: 1 };
104        assert2::let_assert!(Ok(v) = d.get_opt_t::<nvim_oxi::String>("name"));
105        assert!(v.is_none());
106    }
107
108    #[test]
109    fn get_dict_missing_intermediate_returns_none() {
110        let d = dict! { root: dict! { level: dict! { value: 1 } } };
111        assert2::let_assert!(Ok(v) = d.get_dict(&["root", "missing", "value"]));
112        assert!(v.is_none());
113    }
114
115    #[test]
116    fn get_dict_intermediate_wrong_type_errors() {
117        let d = dict! { root: dict! { leaf: 1 } };
118        assert2::let_assert!(Err(err) = d.get_dict(&["root", "leaf", "value"]));
119        let msg = err.to_string();
120        assert!(msg.contains(" is Integer but Dictionary was expected"), "actual: {msg}");
121        assert!(msg.contains("key \"leaf\""), "actual: {msg}");
122    }
123
124    #[test]
125    fn get_required_dict_missing_errors() {
126        let d = dict! { root: dict! { leaf: 1 } };
127        assert2::let_assert!(Err(err) = d.get_required_dict(&["root", "branch"]));
128        let msg = err.to_string();
129        assert!(msg.starts_with("missing dict value |"), "actual: {msg}");
130        assert!(
131            msg.contains("query=[\n    \"root\",\n    \"branch\",\n]"),
132            "actual: {msg}"
133        );
134    }
135}