freedesktop_apps/
parser.rs

1use regex::Regex;
2use std::{
3    collections::HashMap,
4    path::{Path, PathBuf},
5    fs::File,
6    io::{BufRead, BufReader},
7};
8
9#[derive(Debug, Clone)]
10pub enum ParseError {
11    IoError(String),
12    InvalidFormat(String),
13    MissingRequiredKey(String),
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum ValueType {
18    String(String),
19    #[allow(dead_code)] // Reserved for future localization features
20    LocaleString(String),
21    #[allow(dead_code)] // Reserved for future icon handling
22    IconString(String),
23    Boolean(bool),
24    Numeric(f64),
25    StringList(Vec<String>),
26    #[allow(dead_code)] // Reserved for future localization features
27    LocaleStringList(Vec<String>),
28}
29
30#[derive(Debug, Clone)]
31pub struct LocalizedKey {
32    pub key: String,
33    pub locale: Option<String>,
34}
35
36impl LocalizedKey {
37    pub fn parse(input: &str) -> Self {
38        if let Some(bracket_start) = input.find('[') {
39            if let Some(bracket_end) = input.find(']') {
40                if bracket_start < bracket_end {
41                    let key = input[..bracket_start].to_string();
42                    let locale = input[bracket_start + 1..bracket_end].to_string();
43                    return Self {
44                        key,
45                        locale: Some(locale),
46                    };
47                }
48            }
49        }
50        Self {
51            key: input.to_string(),
52            locale: None,
53        }
54    }
55}
56
57#[derive(Debug, Default)]
58pub struct DesktopEntryGroup {
59    #[allow(dead_code)] // Reserved for future group name tracking
60    pub name: String,
61    pub fields: HashMap<String, ValueType>,
62    pub localized_fields: HashMap<String, HashMap<String, ValueType>>,
63}
64
65impl DesktopEntryGroup {
66    pub fn new<S: Into<String>>(name: S) -> Self {
67        Self {
68            name: name.into(),
69            fields: HashMap::new(),
70            localized_fields: HashMap::new(),
71        }
72    }
73
74    pub fn insert_field(&mut self, key: &str, value: ValueType) {
75        let localized_key = LocalizedKey::parse(key);
76        
77        if let Some(locale) = localized_key.locale {
78            self.localized_fields
79                .entry(localized_key.key)
80                .or_default()
81                .insert(locale, value);
82        } else {
83            self.fields.insert(localized_key.key, value);
84        }
85    }
86
87    pub fn get_field(&self, key: &str) -> Option<&ValueType> {
88        self.fields.get(key)
89    }
90
91    pub fn get_localized_field(&self, key: &str, locale: Option<&str>) -> Option<&ValueType> {
92        if let Some(locale) = locale {
93            if let Some(localized_map) = self.localized_fields.get(key) {
94                // Try exact match first
95                if let Some(value) = localized_map.get(locale) {
96                    return Some(value);
97                }
98                
99                // Try fallback logic according to spec
100                if let Some(value) = self.try_locale_fallback(localized_map, locale) {
101                    return Some(value);
102                }
103            }
104        }
105        
106        // Fall back to non-localized version
107        self.fields.get(key)
108    }
109
110    fn try_locale_fallback<'a>(&self, localized_map: &'a HashMap<String, ValueType>, locale: &str) -> Option<&'a ValueType> {
111        // Strip encoding part if present (everything after '.')
112        let locale_without_encoding = if let Some(dot_pos) = locale.find('.') {
113            &locale[..dot_pos]
114        } else {
115            locale
116        };
117        
118        // Parse locale components: lang_COUNTRY@MODIFIER
119        let (lang, country, modifier) = Self::parse_locale_components(locale_without_encoding);
120        
121        // Follow the spec fallback order exactly:
122        // For lang_COUNTRY@MODIFIER: try lang_COUNTRY@MODIFIER, lang_COUNTRY, lang@MODIFIER, lang, default
123        // For lang_COUNTRY: try lang_COUNTRY, lang, default  
124        // For lang@MODIFIER: try lang@MODIFIER, lang, default
125        // For lang: try lang, default
126        
127        if let (Some(country), Some(modifier)) = (country, modifier) {
128            // Try lang_COUNTRY@MODIFIER
129            let full_locale = format!("{}_{}{}", lang, country, modifier);
130            if let Some(value) = localized_map.get(&full_locale) {
131                return Some(value);
132            }
133            
134            // Try lang_COUNTRY
135            let lang_country = format!("{}_{}", lang, country);
136            if let Some(value) = localized_map.get(&lang_country) {
137                return Some(value);
138            }
139            
140            // Try lang@MODIFIER
141            let lang_modifier = format!("{}{}", lang, modifier);
142            if let Some(value) = localized_map.get(&lang_modifier) {
143                return Some(value);
144            }
145        } else if let Some(country) = country {
146            // Try lang_COUNTRY
147            let lang_country = format!("{}_{}", lang, country);
148            if let Some(value) = localized_map.get(&lang_country) {
149                return Some(value);
150            }
151        } else if let Some(modifier) = modifier {
152            // Try lang@MODIFIER
153            let lang_modifier = format!("{}{}", lang, modifier);
154            if let Some(value) = localized_map.get(&lang_modifier) {
155                return Some(value);
156            }
157        }
158        
159        // Try just lang
160        localized_map.get(lang)
161    }
162    
163    fn parse_locale_components(locale: &str) -> (&str, Option<&str>, Option<&str>) {
164        let (base, modifier) = if let Some(at_pos) = locale.find('@') {
165            (&locale[..at_pos], Some(&locale[at_pos..]))
166        } else {
167            (locale, None)
168        };
169        
170        let (lang, country) = if let Some(under_pos) = base.find('_') {
171            (&base[..under_pos], Some(&base[under_pos + 1..]))
172        } else {
173            (base, None)
174        };
175        
176        (lang, country, modifier)
177    }
178}
179
180#[derive(Debug, Default)]
181pub struct DesktopEntry {
182    pub path: PathBuf,
183    pub groups: HashMap<String, DesktopEntryGroup>,
184}
185
186impl DesktopEntry {
187    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
188        let file = File::open(path.as_ref())
189            .map_err(|e| ParseError::IoError(format!("Failed to open file: {}", e)))?;
190        let reader = BufReader::new(file);
191        
192        let group_header_regex = Regex::new(r"^\[([^\[\]]+)\]$")
193            .map_err(|e| ParseError::InvalidFormat(format!("Regex error: {}", e)))?;
194
195        let mut current_group: Option<String> = None;
196        let mut entry = DesktopEntry { 
197            path: path.as_ref().to_path_buf(), 
198            ..Default::default() 
199        };
200        
201        for (line_num, line) in reader.lines().enumerate() {
202            let line = line.map_err(|e| ParseError::IoError(format!("Failed to read line {}: {}", line_num + 1, e)))?;
203            let line = line.trim();
204
205            // Skip empty lines and comments
206            if line.is_empty() || line.starts_with('#') {
207                continue;
208            }
209
210            // Check for group header
211            if let Some(captures) = group_header_regex.captures(line) {
212                let group_name = captures[1].to_string();
213                current_group = Some(group_name.clone());
214                entry.groups.entry(group_name.clone())
215                    .or_insert_with(|| DesktopEntryGroup::new(group_name));
216                continue;
217            }
218
219            // Parse key-value pair
220            if let Some(eq_pos) = line.find('=') {
221                let key = line[..eq_pos].trim();
222                let value = line[eq_pos + 1..].trim();
223
224                if key.is_empty() {
225                    continue; // Skip invalid entries
226                }
227
228                if !is_valid_key_name(key) {
229                    return Err(ParseError::InvalidFormat(format!("Invalid key name: {}", key)));
230                }
231
232                if let Some(ref group_name) = current_group {
233                    let parsed_value = parse_value(value)?;
234                    if let Some(group) = entry.groups.get_mut(group_name) {
235                        group.insert_field(key, parsed_value);
236                    }
237                } else {
238                    return Err(ParseError::InvalidFormat("Key-value pair found before any group header".to_string()));
239                }
240            }
241        }
242
243        // Validate required keys
244        entry.validate()?;
245        
246        Ok(entry)
247    }
248
249    fn validate(&self) -> Result<(), ParseError> {
250        let desktop_entry = self.groups.get("Desktop Entry")
251            .ok_or_else(|| ParseError::MissingRequiredKey("Desktop Entry group is required".to_string()))?;
252
253        // Type is required
254        let entry_type = desktop_entry.get_field("Type")
255            .ok_or_else(|| ParseError::MissingRequiredKey("Type key is required".to_string()))?;
256
257        // Name is required
258        desktop_entry.get_field("Name")
259            .ok_or_else(|| ParseError::MissingRequiredKey("Name key is required".to_string()))?;
260
261        // For Application type, Exec is required unless DBusActivatable=true
262        if let ValueType::String(type_val) = entry_type {
263            if type_val == "Application" {
264                let dbus_activatable = desktop_entry.get_field("DBusActivatable")
265                    .and_then(|v| match v {
266                        ValueType::Boolean(b) => Some(*b),
267                        _ => None,
268                    })
269                    .unwrap_or(false);
270
271                if !dbus_activatable {
272                    desktop_entry.get_field("Exec")
273                        .ok_or_else(|| ParseError::MissingRequiredKey("Exec key is required for Application type".to_string()))?;
274                }
275            } else if type_val == "Link" {
276                // URL is required for Link type
277                desktop_entry.get_field("URL")
278                    .ok_or_else(|| ParseError::MissingRequiredKey("URL key is required for Link type".to_string()))?;
279            }
280        }
281
282        Ok(())
283    }
284
285    pub fn get_desktop_entry_group(&self) -> Option<&DesktopEntryGroup> {
286        self.groups.get("Desktop Entry")
287    }
288}
289
290fn is_valid_key_name(key: &str) -> bool {
291    // Remove locale part for validation
292    let base_key = if let Some(bracket_pos) = key.find('[') {
293        &key[..bracket_pos]
294    } else {
295        key
296    };
297    
298    // Only A-Za-z0-9- allowed in key names
299    base_key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
300}
301
302fn parse_value(value: &str) -> Result<ValueType, ParseError> {
303    // Handle escape sequences
304    let unescaped = unescape_value(value);
305    
306    // Try to parse as boolean first
307    match unescaped.to_lowercase().as_str() {
308        "true" => return Ok(ValueType::Boolean(true)),
309        "false" => return Ok(ValueType::Boolean(false)),
310        _ => {}
311    }
312    
313    // Try to parse as numeric
314    if let Ok(num) = unescaped.parse::<f64>() {
315        return Ok(ValueType::Numeric(num));
316    }
317    
318    // Check if it's a list (contains unescaped semicolons)
319    if value.contains(';') {
320        let items = split_semicolon_list(value);
321        return Ok(ValueType::StringList(items));
322    }
323    
324    // Default to string
325    Ok(ValueType::String(unescaped))
326}
327
328fn unescape_value(value: &str) -> String {
329    let mut result = String::new();
330    let mut chars = value.chars();
331    
332    while let Some(ch) = chars.next() {
333        if ch == '\\' {
334            if let Some(next_ch) = chars.next() {
335                match next_ch {
336                    's' => result.push(' '),
337                    'n' => result.push('\n'),
338                    't' => result.push('\t'),
339                    'r' => result.push('\r'),
340                    '\\' => result.push('\\'),
341                    ';' => result.push(';'),  // For escaped semicolons in lists
342                    _ => {
343                        // Unknown escape sequence, keep as-is
344                        result.push('\\');
345                        result.push(next_ch);
346                    }
347                }
348            } else {
349                result.push('\\');
350            }
351        } else {
352            result.push(ch);
353        }
354    }
355    
356    result
357}
358
359fn split_semicolon_list(value: &str) -> Vec<String> {
360    let mut result = Vec::new();
361    let mut current_item = String::new();
362    let mut chars = value.chars().peekable();
363    
364    while let Some(ch) = chars.next() {
365        if ch == '\\' {
366            if let Some(&next_ch) = chars.peek() {
367                if next_ch == ';' {
368                    // Escaped semicolon - add semicolon to current item
369                    current_item.push(';');
370                    chars.next(); // consume the semicolon
371                } else {
372                    // Other escape sequence - handle normally
373                    current_item.push(ch);
374                    if let Some(escaped_ch) = chars.next() {
375                        current_item.push(escaped_ch);
376                    }
377                }
378            } else {
379                current_item.push(ch);
380            }
381        } else if ch == ';' {
382            // Unescaped semicolon - end current item
383            let trimmed = current_item.trim();
384            if !trimmed.is_empty() {
385                result.push(unescape_value(trimmed));
386            }
387            current_item.clear();
388        } else {
389            current_item.push(ch);
390        }
391    }
392    
393    // Add the last item
394    let trimmed = current_item.trim();
395    if !trimmed.is_empty() {
396        result.push(unescape_value(trimmed));
397    }
398    
399    result
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_localized_key_parsing() {
408        let key = LocalizedKey::parse("Name");
409        assert_eq!(key.key, "Name");
410        assert_eq!(key.locale, None);
411
412        let key = LocalizedKey::parse("Name[en_US]");
413        assert_eq!(key.key, "Name");
414        assert_eq!(key.locale, Some("en_US".to_string()));
415    }
416
417    #[test]
418    fn test_value_parsing() {
419        assert_eq!(parse_value("true").unwrap(), ValueType::Boolean(true));
420        assert_eq!(parse_value("false").unwrap(), ValueType::Boolean(false));
421        assert_eq!(parse_value("123.45").unwrap(), ValueType::Numeric(123.45));
422        assert_eq!(parse_value("hello").unwrap(), ValueType::String("hello".to_string()));
423        assert_eq!(
424            parse_value("one;two;three").unwrap(),
425            ValueType::StringList(vec!["one".to_string(), "two".to_string(), "three".to_string()])
426        );
427    }
428
429    #[test]
430    fn test_escape_sequences() {
431        assert_eq!(unescape_value("hello\\sworld"), "hello world");
432        assert_eq!(unescape_value("line1\\nline2"), "line1\nline2");
433        assert_eq!(unescape_value("tab\\there"), "tab\there");
434        assert_eq!(unescape_value("backslash\\\\"), "backslash\\");
435    }
436
437    #[test]
438    fn test_key_validation() {
439        assert!(is_valid_key_name("Name"));
440        assert!(is_valid_key_name("Name[en_US]"));
441        assert!(is_valid_key_name("X-Custom-Key"));
442        assert!(!is_valid_key_name("Invalid Key"));
443        assert!(!is_valid_key_name("Key=Value"));
444    }
445}