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