hyprshell_core_lib/
ini.rs

1use std::collections::HashMap;
2use std::collections::hash_map::Entry;
3use std::fmt::Write;
4use std::path::Path;
5use tracing::{debug_span, warn};
6
7#[derive(Debug, Default)]
8pub struct Section<'a> {
9    entries: HashMap<&'a str, Vec<&'a str>>,
10}
11
12impl<'a> Section<'a> {
13    #[must_use]
14    pub fn new() -> Self {
15        Self::default()
16    }
17
18    #[must_use]
19    pub fn get_all(&self, key: &str) -> Option<&Vec<&'a str>> {
20        self.entries.get(key)
21    }
22
23    #[must_use]
24    pub fn get_first(&self, key: &str) -> Option<&'a str> {
25        self.entries.get(key)?.first().copied()
26    }
27
28    #[must_use]
29    pub fn get_all_as_boxed(&self, key: &str) -> Option<Vec<Box<str>>> {
30        self.get_all(key)
31            .map(|vec| vec.iter().copied().map(Box::from).collect::<Vec<_>>())
32    }
33
34    #[must_use]
35    pub fn get_first_as_boxed(&self, key: &str) -> Option<Box<str>> {
36        self.get_first(key).map(Box::from)
37    }
38    #[must_use]
39    pub fn get_first_as_path_boxed(&self, key: &str) -> Option<Box<Path>> {
40        self.get_first(key).map(Path::new).map(Box::from)
41    }
42
43    #[must_use]
44    pub fn get_first_as_boolean(&self, key: &str) -> Option<bool> {
45        self.get_first(key).map(|s| s == "true")
46    }
47}
48
49impl<'a> Section<'a> {
50    pub fn insert_item(&mut self, key: &'a str, desktop_file: &'a str) {
51        self.entries.entry(key).or_default().push(desktop_file);
52    }
53    pub fn insert_item_at_front(&mut self, key: &'a str, desktop_file: &'a str) {
54        self.entries.entry(key).or_default().insert(0, desktop_file);
55    }
56    pub fn insert_items(&mut self, key: &'a str, mut desktop_files: Vec<&'a str>) {
57        self.entries
58            .entry(key)
59            .or_default()
60            .append(&mut desktop_files);
61    }
62    pub fn set_items(&mut self, key: &'a str, mut desktop_files: Vec<&'a str>) {
63        self.entries
64            .entry(key)
65            .and_modify(|e| {
66                e.clear();
67                e.append(&mut desktop_files);
68            })
69            .or_insert_with(|| desktop_files);
70    }
71}
72
73#[derive(Debug, Default)]
74pub struct IniFile<'a> {
75    sections: HashMap<&'a str, Section<'a>>,
76}
77
78impl IniFile<'_> {
79    #[allow(clippy::should_implement_trait)]
80    pub fn from_str(content: &str) -> IniFile<'_> {
81        let _span = debug_span!("from_str").entered();
82
83        let mut sections = HashMap::new();
84        let mut current_section = sections.entry("").or_insert_with(Section::default);
85
86        for line in content.lines() {
87            let line = line.trim();
88            if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
89                continue;
90            }
91
92            if line.starts_with('[') && line.ends_with(']') {
93                let current_section_name = &line[1..line.len() - 1];
94                current_section = sections
95                    .entry(current_section_name.trim())
96                    .or_insert_with(Section::default);
97                continue;
98            }
99
100            if let Some((key, value)) = line.split_once('=') {
101                let key = key.trim();
102                let value = value.trim();
103
104                // Skip localized entries (containing [...])
105                if key.contains('[') {
106                    continue;
107                }
108                let values = value
109                    .split(';')
110                    .map(str::trim)
111                    .filter(|s| !s.is_empty())
112                    .collect::<Vec<_>>();
113                current_section.insert_items(key, values);
114            } else {
115                warn!("malformed line: {line}");
116            }
117        }
118
119        IniFile { sections }
120    }
121}
122
123impl<'a> IniFile<'a> {
124    #[must_use]
125    pub fn get_section(&'a self, section_name: &str) -> Option<&'a Section<'a>> {
126        self.sections.get(section_name)
127    }
128
129    #[must_use]
130    pub const fn sections(&self) -> &HashMap<&'a str, Section<'a>> {
131        &self.sections
132    }
133
134    #[must_use]
135    pub fn format(&self) -> String {
136        let mut str = String::with_capacity(self.into_iter().count() * 20); // 20 chars per line should be good
137        let mut sections = self.sections().iter().collect::<Vec<_>>();
138        sections.sort_by_key(|&(name, _)| name);
139        for (name, section) in sections {
140            if !name.is_empty() {
141                if str.is_empty() {
142                    let _ = str.write_str(&format!("[{name}]\n"));
143                } else {
144                    let _ = str.write_str(&format!("\n[{name}]\n"));
145                }
146            }
147            let mut section = section.into_iter().collect::<Vec<_>>();
148            section.sort_by_key(|(key, _)| *key);
149            for (key, values) in section {
150                let _ = str.write_str(&format!("{key}={}\n", values.join(";")));
151            }
152        }
153        str
154    }
155}
156
157impl<'a> IniFile<'a> {
158    pub fn get_section_mut<'b>(&'b mut self, section_name: &str) -> Option<&'b mut Section<'a>>
159    where
160        'a: 'b,
161    {
162        self.sections.get_mut(section_name)
163    }
164
165    pub fn section_entry<'b>(&'b mut self, section_name: &'a str) -> Entry<'b, &'a str, Section<'a>>
166    where
167        'a: 'b,
168    {
169        self.sections.entry(section_name)
170    }
171    pub fn insert_section(&mut self, name: &'a str, section: Section<'a>) {
172        self.sections.insert(name, section);
173    }
174}
175
176impl<'a> IniFile<'a> {
177    #[allow(dead_code)]
178    fn iter(&'a self) -> Box<dyn Iterator<Item = <&'a Self as IntoIterator>::Item> + 'a> {
179        <&Self as IntoIterator>::into_iter(self)
180    }
181}
182
183impl<'a> IntoIterator for &'a IniFile<'a> {
184    type Item = (&'a str, &'a str, &'a Vec<&'a str>);
185    type IntoIter = Box<dyn Iterator<Item = Self::Item> + 'a>;
186
187    fn into_iter(self) -> Self::IntoIter {
188        let iter = self.sections.iter().flat_map(|(section_name, section)| {
189            section
190                .into_iter()
191                .map(move |(key, values)| (*section_name, key, values))
192        });
193        Box::new(iter)
194    }
195}
196
197impl<'a> Section<'a> {
198    #[allow(dead_code)]
199    fn iter(&'a self) -> Box<dyn Iterator<Item = <&'a Self as IntoIterator>::Item> + 'a> {
200        <&Self as IntoIterator>::into_iter(self)
201    }
202}
203
204impl<'a> IntoIterator for &'a Section<'a> {
205    type Item = (&'a str, &'a Vec<&'a str>);
206    type IntoIter = Box<dyn Iterator<Item = Self::Item> + 'a>;
207
208    fn into_iter(self) -> Self::IntoIter {
209        let iter = self.entries.iter().map(|(key, value)| (*key, value));
210        Box::new(iter)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_parse_ini() {
220        let content = r"[Section1]
221key1=value1
222key2=value2
223
224[Section2]
225foo=bar
226baz=qux
227
228; Comment
229# Another comment
230[Empty Section]
231
232[Section With Spaces]
233key with spaces=value with spaces; and more values
234";
235
236        let ini = IniFile::from_str(content);
237
238        assert_eq!(
239            ini.get_section("Section1").unwrap().get_first("key1"),
240            Some("value1")
241        );
242        assert_eq!(
243            ini.get_section("Section2").unwrap().get_first("foo"),
244            Some("bar")
245        );
246
247        assert!(ini.get_section("Empty Section").is_some());
248        assert_ne!(
249            ini.get_section("Section With Spaces")
250                .unwrap()
251                .get_all("key with spaces"),
252            Some(&vec!["value with spaces"])
253        );
254        assert_ne!(
255            ini.get_section("Section With Spaces")
256                .unwrap()
257                .get_all("key with spaces"),
258            Some(&vec!["value with spaces"])
259        );
260        assert_eq!(
261            ini.get_section("Section With Spaces")
262                .unwrap()
263                .get_all("key with spaces"),
264            Some(&vec!["value with spaces", "and more values"])
265        );
266
267        assert!(ini.get_section("NonExistent").is_none());
268        assert_eq!(
269            ini.get_section("Section1")
270                .unwrap()
271                .get_first("nonexistent"),
272            None
273        );
274    }
275
276    #[test]
277    fn test_empty_ini() {
278        let content = "";
279        let ini = IniFile::from_str(content);
280        assert_eq!(ini.sections().len(), 1);
281    }
282
283    #[test]
284    fn test_no_sections() {
285        let content = "key=value";
286        let ini = IniFile::from_str(content);
287        assert_eq!(ini.get_section("").unwrap().get_first("key"), Some("value"));
288    }
289
290    #[test]
291    fn test_values_iterator() {
292        let content = r"
293    [Section1]
294    key1=value1
295    key2=value2;values3
296
297    [Section2]
298    foo=bar
299    ";
300        let ini = IniFile::from_str(content);
301        let mut values: Vec<_> = ini.into_iter().collect();
302        values.sort_by_key(|&(section, key, _)| (section, key));
303        let mut iter = values.iter();
304        assert_eq!(iter.next(), Some(&("Section1", "key1", &vec!["value1"])));
305        assert_eq!(
306            iter.next(),
307            Some(&("Section1", "key2", &vec!["value2", "values3"]))
308        );
309        assert_eq!(iter.next(), Some(&("Section2", "foo", &vec!["bar"])));
310        assert_eq!(values.len(), 3);
311    }
312
313    #[test]
314    fn test_values_iterator_2() {
315        let content = r"
316    [Section1]
317    key1=value1
318    key2=value2
319
320    [Section2]
321    foo=bar
322    ";
323        let ini = IniFile::from_str(content);
324        let mut count = 0;
325        for (section, name, value) in &ini {
326            assert!(!section.is_empty(), "Item should not be empty");
327            assert!(!name.is_empty(), "Item should not be empty");
328            assert!(!value.is_empty(), "Item should not be empty");
329            count += 1;
330        }
331        assert_eq!(count, 3, "There should be 3 items in the iterator");
332    }
333
334    #[test]
335    fn test_format_empty() {
336        let content = "test=test";
337        let ini = IniFile::from_str(content);
338        assert_eq!(ini.format(), "test=test\n");
339    }
340
341    #[test]
342    fn test_format_multiple_sections() {
343        let content = r"[B]
344key1=value1
345key2=value2;value3
346
347[A]
348foo=bar
349";
350        let content2 = r"[A]
351foo=bar
352
353[B]
354key1=value1
355key2=value2;value3
356";
357        let ini = IniFile::from_str(content);
358        assert_eq!(ini.format(), content2);
359    }
360}