freedesktop_desktop_entry/
decoder.rs

1// Copyright 2021 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4use std::{
5    fs::{self},
6    path::{Path, PathBuf},
7};
8
9use crate::{DesktopEntry, Group};
10use crate::{Groups, LocaleMap};
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14pub enum DecodeError {
15    #[error("path does not contain a valid app ID")]
16    AppID,
17    #[error(transparent)]
18    Io(#[from] std::io::Error),
19    #[error("MultipleGroupWithSameName")]
20    MultipleGroupWithSameName,
21    #[error("KeyValueWithoutAGroup")]
22    KeyValueWithoutAGroup,
23    #[error("InvalidKey. Accepted: A-Za-z0-9")]
24    InvalidKey,
25    #[error("KeyDoesNotExist, this can happens when a localized key has no default value")]
26    KeyDoesNotExist,
27    #[error("InvalidValue")]
28    InvalidValue,
29}
30
31impl DesktopEntry {
32    pub fn from_str<L>(
33        path: impl Into<PathBuf>,
34        input: &str,
35        locales_filter: Option<&[L]>,
36    ) -> Result<DesktopEntry, DecodeError>
37    where
38        L: AsRef<str>,
39    {
40        let path: PathBuf = path.into();
41
42        let appid = get_app_id(&path)?;
43
44        let mut groups = Groups::default();
45        let mut active_group: Option<ActiveGroup> = None;
46        let mut active_keys: Option<ActiveKeys> = None;
47        let mut ubuntu_gettext_domain = None;
48
49        let locales_filter = locales_filter.map(add_generic_locales);
50
51        for line in input.lines() {
52            process_line(
53                line,
54                &mut groups,
55                &mut active_group,
56                &mut active_keys,
57                &mut ubuntu_gettext_domain,
58                locales_filter.as_deref(),
59            )?
60        }
61
62        if let Some(active_keys) = active_keys.take() {
63            match &mut active_group {
64                Some(active_group) => {
65                    active_group.group.0.insert(
66                        active_keys.key_name,
67                        (active_keys.default_value, active_keys.locales),
68                    );
69                }
70                None => return Err(DecodeError::KeyValueWithoutAGroup),
71            }
72        }
73
74        if let Some(group) = active_group.take() {
75            if groups.0.insert(group.group_name, group.group).is_some() {
76                return Err(DecodeError::MultipleGroupWithSameName);
77            }
78        }
79
80        Ok(DesktopEntry {
81            appid: appid.to_string(),
82            groups,
83            path,
84            ubuntu_gettext_domain,
85        })
86    }
87
88    /// Return an owned [`DesktopEntry`]
89    pub fn from_path<L>(
90        path: impl Into<PathBuf>,
91        locales_filter: Option<&[L]>,
92    ) -> Result<DesktopEntry, DecodeError>
93    where
94        L: AsRef<str>,
95    {
96        let path: PathBuf = path.into();
97        let input = fs::read_to_string(&path)?;
98        Self::from_str(path, &input, locales_filter)
99    }
100}
101
102fn get_app_id<P: AsRef<Path> + ?Sized>(path: &P) -> Result<&str, DecodeError> {
103    let appid = path
104        .as_ref()
105        .file_stem()
106        .ok_or(DecodeError::AppID)?
107        .to_str()
108        .ok_or(DecodeError::AppID)?;
109    Ok(appid)
110}
111
112#[derive(Debug)]
113struct ActiveGroup {
114    group_name: String,
115    group: Group,
116}
117
118#[derive(Debug)]
119struct ActiveKeys {
120    key_name: String,
121    default_value: String,
122    locales: LocaleMap,
123}
124
125#[inline]
126fn process_line<L: AsRef<str>>(
127    line: &str,
128    groups: &mut Groups,
129    active_group: &mut Option<ActiveGroup>,
130    active_keys: &mut Option<ActiveKeys>,
131    ubuntu_gettext_domain: &mut Option<String>,
132    locales_filter: Option<&[L]>,
133) -> Result<(), DecodeError> {
134    if line.trim().is_empty() || line.starts_with('#') {
135        return Ok(());
136    }
137
138    let line_bytes = line.as_bytes();
139
140    if line_bytes[0] == b'[' {
141        if let Some(end) = memchr::memrchr(b']', &line_bytes[1..]) {
142            let group_name = &line[1..end + 1];
143
144            if let Some(active_keys) = active_keys.take() {
145                match active_group {
146                    Some(active_group) => {
147                        active_group.group.0.insert(
148                            active_keys.key_name,
149                            (active_keys.default_value, active_keys.locales),
150                        );
151                    }
152                    None => return Err(DecodeError::KeyValueWithoutAGroup),
153                }
154            }
155
156            if let Some(group) = active_group.take() {
157                if groups.0.insert(group.group_name, group.group).is_some() {
158                    return Err(DecodeError::MultipleGroupWithSameName);
159                }
160            }
161
162            active_group.replace(ActiveGroup {
163                group_name: group_name.to_string(),
164                group: Group::default(),
165            });
166        }
167    } else if let Some(delimiter) = memchr::memchr(b'=', line_bytes) {
168        let key = &line[..delimiter];
169        let value = format_value(&line[delimiter + 1..])?;
170
171        if key.is_empty() {
172            return Err(DecodeError::InvalidKey);
173        }
174
175        // if locale
176        if key.as_bytes()[key.len() - 1] == b']' {
177            if let Some(start) = memchr::memchr(b'[', key.as_bytes()) {
178                // verify that the name is the same of active key ?
179                // or we can assume this is the case
180                // let local_key = &key[..start];
181
182                let locale = &key[start + 1..key.len() - 1];
183
184                match locales_filter {
185                    Some(locales_filter)
186                        if !locales_filter.iter().any(|l| l.as_ref() == locale) =>
187                    {
188                        return Ok(())
189                    }
190                    _ => (),
191                }
192
193                match active_keys {
194                    Some(active_keys) => {
195                        active_keys.locales.insert(locale.to_string(), value);
196                    }
197                    None => return Err(DecodeError::KeyDoesNotExist),
198                }
199
200                return Ok(());
201            }
202        }
203
204        if key == "X-Ubuntu-Gettext-Domain" {
205            *ubuntu_gettext_domain = Some(value.to_string());
206            return Ok(());
207        }
208
209        if let Some(active_keys) = active_keys.take() {
210            match active_group {
211                Some(active_group) => {
212                    active_group.group.0.insert(
213                        active_keys.key_name,
214                        (active_keys.default_value, active_keys.locales),
215                    );
216                }
217                None => return Err(DecodeError::KeyValueWithoutAGroup),
218            }
219        }
220        active_keys.replace(ActiveKeys {
221            // todo: verify that the key only contains A-Za-z0-9 ?
222            key_name: key.trim().to_string(),
223            default_value: value,
224            locales: LocaleMap::default(),
225        });
226    }
227    Ok(())
228}
229
230// https://specifications.freedesktop.org/desktop-entry-spec/latest/value-types.html
231#[inline]
232fn format_value(input: &str) -> Result<String, DecodeError> {
233    let input = if let Some(input) = input.strip_prefix(" ") {
234        input
235    } else {
236        input
237    };
238
239    let mut res = String::with_capacity(input.len());
240
241    let mut last: usize = 0;
242
243    for i in memchr::memchr_iter(b'\\', input.as_bytes()) {
244        // edge case for //
245        if last > i {
246            continue;
247        }
248
249        // when there is an \ at the end
250        if input.len() <= i + 1 {
251            return Err(DecodeError::InvalidValue);
252        }
253
254        if last < i {
255            res.push_str(&input[last..i]);
256        }
257
258        last = i + 2;
259
260        match input.as_bytes()[i + 1] {
261            b's' => res.push(' '),
262            b'n' => res.push('\n'),
263            b't' => res.push('\t'),
264            b'r' => res.push('\r'),
265            b'\\' => res.push('\\'),
266            _ => {
267                return Err(DecodeError::InvalidValue);
268            }
269        }
270    }
271
272    if last < input.len() {
273        res.push_str(&input[last..input.len()]);
274    }
275
276    Ok(res)
277}
278
279/// Ex: if a locale equal fr_FR, add fr
280pub(crate) fn add_generic_locales<L: AsRef<str>>(locales: &[L]) -> Vec<&str> {
281    let mut v = Vec::with_capacity(locales.len() + 1);
282
283    for l in locales {
284        let l = l.as_ref();
285
286        v.push(l);
287
288        if let Some(start) = memchr::memchr(b'_', l.as_bytes()) {
289            v.push(l.split_at(start).0)
290        }
291    }
292
293    v
294}