hyprparser/
lib.rs

1#![doc(
2    html_favicon_url = "https://raw.githubusercontent.com/hyprutils/hyprparser/refs/heads/main/hyprparser.png"
3)]
4#![doc(
5    html_logo_url = "https://raw.githubusercontent.com/hyprutils/hyprparser/refs/heads/main/hyprparser.png"
6)]
7
8//! A parser for [Hyprland](https://hyprland.org)'s configuration files 🚀
9//!
10//! [Hyprland's documentation](https://wiki.hyprland.org/Configuring)
11//!
12//! # Example usage
13//! ```rust,ignore
14//! use hyprparser::parse_config;
15//! use std::{env, fs, path::Path};
16//!
17//! let config_path = Path::new(&env::var("XDG_CONFIG_HOME").unwrap()).join("hypr/hyprland.conf");
18//! let config_str = fs::read_to_string(&config_path).expect("Failed to read the file");
19//!
20//! let mut parsed_config = parse_config(&config_str);
21//!
22//! parsed_config.add_entry("decoration", "rounding = 10");
23//! parsed_config.add_entry("decoration.blur", "enabled = true");
24//! parsed_config.add_entry("decoration.blur", "size = 10");
25//! parsed_config.add_entry_headless("$terminal", "kitty");
26//!
27//! let updated_config_str = parsed_config.to_string();
28//!
29//! fs::write(&config_path, updated_config_str).expect("Failed to write the file");
30//! ```
31
32use std::collections::HashMap;
33use std::{env, fmt, fs};
34
35/// Core structure of the config
36#[derive(Debug, Default)]
37pub struct HyprlandConfig {
38    pub content: Vec<String>,
39    pub sections: HashMap<String, (usize, usize)>,
40    pub sourced_content: Vec<Vec<String>>,
41    pub sourced_sections: HashMap<String, (usize, usize)>,
42    pub sourced_paths: Vec<String>,
43}
44
45impl HyprlandConfig {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Parse one configuration file
51    pub fn parse(&mut self, config_str: &str, sourced: bool) {
52        let mut section_stack = Vec::new();
53        let mut sourced_content: Vec<String> = Vec::new();
54        let source_index = if sourced {
55            self.sourced_content.len()
56        } else {
57            0
58        };
59
60        let mut env_vars = HashMap::new();
61        let home = env::var("HOME").unwrap_or_default();
62        env_vars.insert("HOME".to_string(), home.clone());
63
64        println!("Parsing env vars from config:");
65        for line in config_str.lines() {
66            let trimmed = line.trim();
67            if let Some((var, val)) = trimmed
68                .split_once('=')
69                .map(|(v, p)| (v.trim(), p.split('#').next().unwrap_or(p).trim()))
70            {
71                if let Some(stripped) = var.strip_prefix('$') {
72                    println!("Found env var: {} = {}", var, val);
73                    let mut expanded_val = val.to_string();
74                    for (existing_var, existing_val) in &env_vars {
75                        expanded_val =
76                            expanded_val.replace(&format!("${}", existing_var), existing_val);
77                    }
78                    env_vars.insert(stripped.to_string(), expanded_val);
79                    continue;
80                }
81            }
82        }
83        println!("Collected env vars: {:?}", env_vars);
84
85        for (i, line) in config_str.lines().enumerate() {
86            let trimmed = line.trim();
87
88            if trimmed.starts_with("source") && !sourced {
89                if let Some(path) = trimmed
90                    .split_once('=')
91                    .map(|(_, p)| p.split('#').next().unwrap_or(p).trim())
92                {
93                    println!("Processing source path: {}", path);
94                    let mut expanded_path = path.to_string();
95
96                    for (var, val) in &env_vars {
97                        let var_pattern = format!("${}", var);
98                        println!("Replacing {} with {}", var_pattern, val);
99                        expanded_path = expanded_path.replace(&var_pattern, val);
100                    }
101                    println!("After env var expansion: {}", expanded_path);
102
103                    if !expanded_path.starts_with('/') && !expanded_path.starts_with('~') {
104                        expanded_path = format!("{}/.config/hypr/{}", home, expanded_path);
105                    } else {
106                        expanded_path = expanded_path.replacen("~", &home, 1);
107                    }
108                    println!("Final expanded path: {}", expanded_path);
109
110                    match fs::read_to_string(&expanded_path) {
111                        Ok(content) => {
112                            println!("Successfully read sourced file");
113                            self.parse(&content, true);
114                            self.sourced_paths.push(expanded_path);
115                        }
116                        Err(e) => println!("Failed to read file: {}", e),
117                    }
118                }
119            } else if trimmed.ends_with('{') {
120                let section_name = trimmed.trim_end_matches('{').trim().to_string();
121                section_stack.push((section_name, i));
122            } else if trimmed == "}" && !section_stack.is_empty() {
123                let (name, start) = section_stack.pop().unwrap();
124                let full_name = section_stack
125                    .iter()
126                    .map(|(n, _)| n.as_str())
127                    .chain(std::iter::once(name.as_str()))
128                    .collect::<Vec<_>>()
129                    .join(".");
130                if sourced {
131                    self.sourced_sections
132                        .insert(format!("{}_{}", full_name, source_index), (start, i));
133                } else {
134                    self.sections.insert(full_name, (start, i));
135                }
136            }
137            if sourced {
138                sourced_content.push(line.to_string());
139            } else {
140                self.content.push(line.to_string());
141            }
142        }
143        if sourced {
144            self.sourced_content.push(sourced_content);
145        }
146    }
147
148    /// Add an entry to a mutable `HyprlandConfig`
149    pub fn add_entry(&mut self, category: &str, entry: &str) {
150        let parts: Vec<&str> = category.split('.').collect();
151        let parent_category = if parts.len() > 1 {
152            parts[..parts.len() - 1].join(".")
153        } else {
154            category.to_string()
155        };
156
157        if let Some((source_index, _)) = self.find_sourced_section(&parent_category) {
158            let section_key = format!("{}_{}", parent_category, source_index);
159            let (start, mut end) = *self.sourced_sections.get(&section_key).unwrap();
160            let depth = parent_category.matches('.').count();
161            let key = entry.split('=').next().unwrap().trim();
162
163            let mut should_update_sections = false;
164            let mut content_updated = String::new();
165
166            if let Some(sourced_content) = self.sourced_content.get_mut(source_index) {
167                let subcategory_key = format!("{}_{}", category, source_index);
168
169                if parts.len() > 1 && !self.sourced_sections.contains_key(&subcategory_key) {
170                    let last_part = parts.last().unwrap();
171                    let section_start = format!("{}{} {{", "    ".repeat(depth + 1), last_part);
172                    let section_end = format!("{}}}", "    ".repeat(depth + 1));
173
174                    if end > 0
175                        && end <= sourced_content.len()
176                        && !sourced_content[end - 1].trim().is_empty()
177                    {
178                        sourced_content.insert(end, String::new());
179                        end += 1;
180                    }
181
182                    sourced_content.insert(end, section_start);
183                    sourced_content
184                        .insert(end + 1, format!("{}{}", "    ".repeat(depth + 2), entry));
185                    sourced_content.insert(end + 2, section_end);
186
187                    self.sourced_sections
188                        .insert(subcategory_key, (end + 1, end + 1));
189                    should_update_sections = true;
190                } else if let Some(&(sub_start, sub_end)) =
191                    self.sourced_sections.get(&subcategory_key)
192                {
193                    let parent_category = if parts.len() > 1 {
194                        parts[..parts.len()].join(".")
195                    } else {
196                        category.to_string()
197                    };
198                    let depth = parent_category.matches('.').count();
199
200                    let formatted_entry = format!("{}{}", "    ".repeat(depth + 1), entry);
201                    let existing_line = sourced_content[sub_start..=sub_end]
202                        .iter()
203                        .position(|line| line.trim().starts_with(key));
204
205                    match existing_line {
206                        Some(line_num) => {
207                            sourced_content[sub_start + line_num] = formatted_entry;
208                        }
209                        None => {
210                            sourced_content.insert(sub_end, formatted_entry);
211                            should_update_sections = true;
212                        }
213                    }
214                } else {
215                    let formatted_entry = format!("{}{}", "    ".repeat(depth + 1), entry);
216                    let existing_line = sourced_content[start..=end]
217                        .iter()
218                        .position(|line| line.trim().starts_with(key));
219
220                    match existing_line {
221                        Some(line_num) => {
222                            sourced_content[start + line_num] = formatted_entry;
223                        }
224                        None => {
225                            sourced_content.insert(end, formatted_entry);
226                            should_update_sections = true;
227                        }
228                    }
229                }
230
231                content_updated = sourced_content.join("\n");
232            }
233
234            if should_update_sections {
235                self.update_sourced_sections(source_index, end, 1);
236            }
237
238            if let Some(sourced_path) = self.sourced_paths.get(source_index) {
239                if !sourced_path.is_empty() {
240                    if let Err(e) = fs::write(sourced_path, content_updated) {
241                        eprintln!("Failed to write to sourced file {}: {}", sourced_path, e);
242                    }
243                }
244            }
245            return;
246        }
247
248        let parts: Vec<&str> = category.split('.').collect();
249        let mut current_section = String::new();
250        let mut insert_pos = self.content.len();
251
252        for (depth, (i, part)) in parts.iter().enumerate().enumerate() {
253            if i > 0 {
254                current_section.push('.');
255            }
256            current_section.push_str(part);
257
258            if !self.sections.contains_key(&current_section) {
259                self.create_category(&current_section, depth, &mut insert_pos);
260            }
261
262            let &(start, end) = self.sections.get(&current_section).unwrap();
263            insert_pos = end;
264
265            if i == parts.len() - 1 {
266                let key = entry.split('=').next().unwrap().trim();
267                let existing_line = self.content[start..=end]
268                    .iter()
269                    .position(|line| line.trim().starts_with(key))
270                    .map(|pos| start + pos);
271
272                let formatted_entry = format!("{}{}", "    ".repeat(depth + 1), entry);
273
274                match existing_line {
275                    Some(line_num) => {
276                        self.content[line_num] = formatted_entry;
277                    }
278                    None => {
279                        self.content.insert(end, formatted_entry);
280                        self.update_sections(end, 1);
281                    }
282                }
283                return;
284            }
285        }
286    }
287
288    /// Add a headless entry to a mutable `HyprlandConfig`
289    ///
290    /// Example of a headless entry in Hyprland's configuration:
291    /// ```conf
292    /// windowrulev2 = float,class:^(hyprutils.hyprwall)$
293    /// ```
294    pub fn add_entry_headless(&mut self, key: &str, value: &str) {
295        if key.is_empty() && value.is_empty() {
296            self.content.push(String::new());
297        } else {
298            let entry = format!("{} = {}", key, value);
299            if !self.content.iter().any(|line| line.trim() == entry.trim()) {
300                self.content.push(entry);
301            }
302        }
303    }
304
305    /// Add a [sourced config file](https://wiki.hyprland.org/Configuring/Keywords/#sourcing-multi-file)
306    pub fn add_sourced(&mut self, config: Vec<String>) {
307        self.sourced_content.push(config);
308        self.sourced_paths.push(String::new());
309    }
310
311    fn update_sections(&mut self, pos: usize, offset: usize) {
312        for (start, end) in self.sections.values_mut() {
313            if *start >= pos {
314                *start += offset;
315                *end += offset;
316            } else if *end >= pos {
317                *end += offset;
318            }
319        }
320    }
321
322    fn update_sourced_sections(&mut self, source_index: usize, pos: usize, offset: usize) {
323        for ((_, (start, end)), sourced_path) in self
324            .sourced_sections
325            .iter_mut()
326            .filter(|(_, (start, _))| *start >= pos)
327            .zip(self.sourced_paths.iter().skip(source_index))
328        {
329            if !sourced_path.is_empty() {
330                if *start >= pos {
331                    *start += offset;
332                    *end += offset;
333                } else if *end >= pos {
334                    *end += offset;
335                }
336            }
337        }
338    }
339
340    /// Parse a color from Hyprland's config into float RGBA values
341    ///
342    /// Examples:
343    /// ```rust,ignore
344    /// let config = HyprlandConfig::new();
345    ///
346    /// let rgba = config.parse_color("rgba(1E4632FF)");
347    /// let rgb = config.parse_color("rgb(1E4632)");
348    /// let argb = config.parse_color("0xFF1E4632");
349    ///
350    /// let expected = Some((0.11764706, 0.27450982, 0.19607843, 1.0));
351    ///
352    /// assert_eq!(expected, rgba);
353    /// assert_eq!(expected, rgb);
354    /// assert_eq!(expected, argb);
355    /// ```
356    pub fn parse_color(&self, color_str: &str) -> Option<(f32, f32, f32, f32)> {
357        if color_str.starts_with("rgba(") {
358            let rgba = color_str.trim_start_matches("rgba(").trim_end_matches(')');
359            let rgba = u32::from_str_radix(rgba, 16).ok()?;
360            Some((
361                ((rgba >> 24) & 0xFF) as f32 / 255.0,
362                ((rgba >> 16) & 0xFF) as f32 / 255.0,
363                ((rgba >> 8) & 0xFF) as f32 / 255.0,
364                (rgba & 0xFF) as f32 / 255.0,
365            ))
366        } else if color_str.starts_with("rgb(") {
367            let rgb = color_str.trim_start_matches("rgb(").trim_end_matches(')');
368            let rgb = u32::from_str_radix(rgb, 16).ok()?;
369            Some((
370                ((rgb >> 16) & 0xFF) as f32 / 255.0,
371                ((rgb >> 8) & 0xFF) as f32 / 255.0,
372                (rgb & 0xFF) as f32 / 255.0,
373                1.0,
374            ))
375        } else if let Some(stripped) = color_str.strip_prefix("0x") {
376            let argb = u32::from_str_radix(stripped, 16).ok()?;
377            Some((
378                ((argb >> 16) & 0xFF) as f32 / 255.0,
379                ((argb >> 8) & 0xFF) as f32 / 255.0,
380                (argb & 0xFF) as f32 / 255.0,
381                ((argb >> 24) & 0xFF) as f32 / 255.0,
382            ))
383        } else {
384            None
385        }
386    }
387
388    /// Format a float RGBA color into Hyprland's RGBA
389    pub fn format_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> String {
390        format!(
391            "rgba({:02x}{:02x}{:02x}{:02x})",
392            (red * 255.0) as u8,
393            (green * 255.0) as u8,
394            (blue * 255.0) as u8,
395            (alpha * 255.0) as u8
396        )
397    }
398
399    fn create_category(&mut self, category: &str, depth: usize, insert_pos: &mut usize) {
400        let part = category.split('.').last().unwrap();
401        let new_section = format!("{}{} {{", "    ".repeat(depth), part);
402
403        let mut lines_added = 0;
404        if *insert_pos > 0 && !self.content[*insert_pos - 1].trim().is_empty() {
405            self.content.insert(*insert_pos, String::new());
406            *insert_pos += 1;
407            lines_added += 1;
408        }
409
410        self.content.insert(*insert_pos, new_section);
411        *insert_pos += 1;
412        self.content
413            .insert(*insert_pos, format!("{}}}", "    ".repeat(depth)));
414        *insert_pos += 1;
415        self.content.insert(*insert_pos, String::new());
416        *insert_pos += 1;
417
418        self.update_sections(*insert_pos - 3 - lines_added, 3 + lines_added);
419        self.sections.insert(
420            category.to_string(),
421            (*insert_pos - 3 - lines_added, *insert_pos - 2),
422        );
423    }
424
425    fn find_sourced_section(&self, category: &str) -> Option<(usize, (usize, usize))> {
426        for (idx, _) in self.sourced_content.iter().enumerate() {
427            let section_key = format!("{}_{}", category, idx);
428            if let Some(&section) = self.sourced_sections.get(&section_key) {
429                if self.sourced_paths.get(idx).map_or(false, |p| !p.is_empty()) {
430                    return Some((idx, section));
431                }
432            }
433        }
434        None
435    }
436}
437
438/// Automatically parse the whole configuration from str
439pub fn parse_config(config_str: &str) -> HyprlandConfig {
440    let mut config = HyprlandConfig::new();
441    config.parse(config_str, false);
442    config
443}
444
445impl fmt::Display for HyprlandConfig {
446    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
447        for (i, line) in self.content.iter().enumerate() {
448            if i == self.content.len() - 1 {
449                write!(f, "{}", line)?;
450            } else {
451                writeln!(f, "{}", line)?;
452            }
453        }
454        Ok(())
455    }
456}
457
458impl PartialEq for HyprlandConfig {
459    fn eq(&self, other: &Self) -> bool {
460        self.content == other.content
461    }
462}