desktop_edit/
desktop.rs

1//! Parser for INI/.desktop style files.
2//!
3//! This parser can be used to parse files in the INI/.desktop format (as specified
4//! by the [freedesktop.org Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/)),
5//! while preserving all whitespace and comments. It is based on
6//! the [rowan] library, which is a lossless parser library for Rust.
7//!
8//! Once parsed, the file can be traversed or modified, and then written back to a file.
9//!
10//! # Example
11//!
12//! ```
13//! use desktop_edit::Desktop;
14//! use std::str::FromStr;
15//!
16//! # let input = r#"[Desktop Entry]
17//! # Name=Example Application
18//! # Type=Application
19//! # Exec=example
20//! # Icon=example.png
21//! # "#;
22//! # let desktop = Desktop::from_str(input).unwrap();
23//! # assert_eq!(desktop.groups().count(), 1);
24//! # let group = desktop.groups().nth(0).unwrap();
25//! # assert_eq!(group.name(), Some("Desktop Entry".to_string()));
26//! ```
27
28use crate::lex::{lex, SyntaxKind};
29use rowan::ast::AstNode;
30use rowan::{GreenNode, GreenNodeBuilder};
31use std::path::Path;
32use std::str::FromStr;
33
34/// A positioned parse error containing location information.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct PositionedParseError {
37    /// The error message
38    pub message: String,
39    /// The text range where the error occurred
40    pub range: rowan::TextRange,
41    /// Optional error code for categorization
42    pub code: Option<String>,
43}
44
45impl std::fmt::Display for PositionedParseError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.message)
48    }
49}
50
51impl std::error::Error for PositionedParseError {}
52
53/// List of encountered syntax errors.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct ParseError(pub Vec<String>);
56
57impl std::fmt::Display for ParseError {
58    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
59        for err in &self.0 {
60            writeln!(f, "{}", err)?;
61        }
62        Ok(())
63    }
64}
65
66impl std::error::Error for ParseError {}
67
68/// Error parsing INI/.desktop files
69#[derive(Debug)]
70pub enum Error {
71    /// A syntax error was encountered while parsing the file.
72    ParseError(ParseError),
73
74    /// An I/O error was encountered while reading the file.
75    IoError(std::io::Error),
76}
77
78impl std::fmt::Display for Error {
79    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
80        match &self {
81            Error::ParseError(err) => write!(f, "{}", err),
82            Error::IoError(err) => write!(f, "{}", err),
83        }
84    }
85}
86
87impl From<ParseError> for Error {
88    fn from(err: ParseError) -> Self {
89        Self::ParseError(err)
90    }
91}
92
93impl From<std::io::Error> for Error {
94    fn from(err: std::io::Error) -> Self {
95        Self::IoError(err)
96    }
97}
98
99impl std::error::Error for Error {}
100
101/// Language definition for rowan
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
103pub enum Lang {}
104
105impl rowan::Language for Lang {
106    type Kind = SyntaxKind;
107
108    fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind {
109        unsafe { std::mem::transmute::<u16, SyntaxKind>(raw.0) }
110    }
111
112    fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind {
113        kind.into()
114    }
115}
116
117/// Internal parse result
118pub(crate) struct Parse {
119    pub(crate) green_node: GreenNode,
120    pub(crate) errors: Vec<String>,
121    pub(crate) positioned_errors: Vec<PositionedParseError>,
122}
123
124/// Parse an INI/.desktop file
125pub(crate) fn parse(text: &str) -> Parse {
126    struct Parser<'a> {
127        tokens: Vec<(SyntaxKind, &'a str)>,
128        builder: GreenNodeBuilder<'static>,
129        errors: Vec<String>,
130        positioned_errors: Vec<PositionedParseError>,
131        pos: usize,
132    }
133
134    impl<'a> Parser<'a> {
135        fn current(&self) -> Option<SyntaxKind> {
136            if self.pos < self.tokens.len() {
137                Some(self.tokens[self.tokens.len() - 1 - self.pos].0)
138            } else {
139                None
140            }
141        }
142
143        fn bump(&mut self) {
144            if self.pos < self.tokens.len() {
145                let (kind, text) = self.tokens[self.tokens.len() - 1 - self.pos];
146                self.builder.token(kind.into(), text);
147                self.pos += 1;
148            }
149        }
150
151        fn skip_ws(&mut self) {
152            while self.current() == Some(SyntaxKind::WHITESPACE) {
153                self.bump();
154            }
155        }
156
157        fn skip_blank_lines(&mut self) {
158            while let Some(kind) = self.current() {
159                match kind {
160                    SyntaxKind::NEWLINE => {
161                        self.builder.start_node(SyntaxKind::BLANK_LINE.into());
162                        self.bump();
163                        self.builder.finish_node();
164                    }
165                    SyntaxKind::WHITESPACE => {
166                        // Check if followed by newline
167                        if self.pos + 1 < self.tokens.len()
168                            && self.tokens[self.tokens.len() - 2 - self.pos].0
169                                == SyntaxKind::NEWLINE
170                        {
171                            self.builder.start_node(SyntaxKind::BLANK_LINE.into());
172                            self.bump(); // whitespace
173                            self.bump(); // newline
174                            self.builder.finish_node();
175                        } else {
176                            break;
177                        }
178                    }
179                    _ => break,
180                }
181            }
182        }
183
184        fn parse_group_header(&mut self) {
185            self.builder.start_node(SyntaxKind::GROUP_HEADER.into());
186
187            // Consume '['
188            if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
189                self.bump();
190            } else {
191                self.errors
192                    .push("expected '[' at start of group header".to_string());
193            }
194
195            // Consume section name (stored as VALUE tokens)
196            if self.current() == Some(SyntaxKind::VALUE) {
197                self.bump();
198            } else {
199                self.errors
200                    .push("expected section name in group header".to_string());
201            }
202
203            // Consume ']'
204            if self.current() == Some(SyntaxKind::RIGHT_BRACKET) {
205                self.bump();
206            } else {
207                self.errors
208                    .push("expected ']' at end of group header".to_string());
209            }
210
211            // Consume newline if present
212            if self.current() == Some(SyntaxKind::NEWLINE) {
213                self.bump();
214            }
215
216            self.builder.finish_node();
217        }
218
219        fn parse_entry(&mut self) {
220            self.builder.start_node(SyntaxKind::ENTRY.into());
221
222            // Handle comment before entry
223            if self.current() == Some(SyntaxKind::COMMENT) {
224                self.bump();
225                if self.current() == Some(SyntaxKind::NEWLINE) {
226                    self.bump();
227                }
228                self.builder.finish_node();
229                return;
230            }
231
232            // Parse key
233            if self.current() == Some(SyntaxKind::KEY) {
234                self.bump();
235            } else {
236                self.errors
237                    .push(format!("expected key, got {:?}", self.current()));
238            }
239
240            self.skip_ws();
241
242            // Check for locale suffix [locale] - note that after KEY, we might get LEFT_BRACKET directly
243            // but the lexer treats [ as in_section_header mode, so we need to handle this differently
244            // Actually, we need to look for [ character in a key-value context
245            // For now, let's check if we have LEFT_BRACKET and handle it as locale
246            if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
247                self.bump();
248                // After [, we should have the locale as VALUE (since lexer is in section header mode)
249                // But we need to handle this edge case
250                self.skip_ws();
251                if self.current() == Some(SyntaxKind::VALUE) {
252                    self.bump();
253                }
254                if self.current() == Some(SyntaxKind::RIGHT_BRACKET) {
255                    self.bump();
256                }
257                self.skip_ws();
258            }
259
260            // Parse '='
261            if self.current() == Some(SyntaxKind::EQUALS) {
262                self.bump();
263            } else {
264                self.errors.push("expected '=' after key".to_string());
265            }
266
267            self.skip_ws();
268
269            // Parse value
270            if self.current() == Some(SyntaxKind::VALUE) {
271                self.bump();
272            }
273
274            // Consume newline if present
275            if self.current() == Some(SyntaxKind::NEWLINE) {
276                self.bump();
277            }
278
279            self.builder.finish_node();
280        }
281
282        fn parse_group(&mut self) {
283            self.builder.start_node(SyntaxKind::GROUP.into());
284
285            // Parse group header
286            self.parse_group_header();
287
288            // Parse entries until we hit another group header or EOF
289            while let Some(kind) = self.current() {
290                match kind {
291                    SyntaxKind::LEFT_BRACKET => break, // Start of next group
292                    SyntaxKind::KEY | SyntaxKind::COMMENT => self.parse_entry(),
293                    SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => {
294                        self.skip_blank_lines();
295                    }
296                    _ => {
297                        self.errors
298                            .push(format!("unexpected token in group: {:?}", kind));
299                        self.bump();
300                    }
301                }
302            }
303
304            self.builder.finish_node();
305        }
306
307        fn parse_file(&mut self) {
308            self.builder.start_node(SyntaxKind::ROOT.into());
309
310            // Skip leading blank lines and comments
311            while let Some(kind) = self.current() {
312                match kind {
313                    SyntaxKind::COMMENT => {
314                        self.builder.start_node(SyntaxKind::ENTRY.into());
315                        self.bump();
316                        if self.current() == Some(SyntaxKind::NEWLINE) {
317                            self.bump();
318                        }
319                        self.builder.finish_node();
320                    }
321                    SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => {
322                        self.skip_blank_lines();
323                    }
324                    _ => break,
325                }
326            }
327
328            // Parse groups
329            while self.current().is_some() {
330                if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
331                    self.parse_group();
332                } else {
333                    self.errors
334                        .push(format!("expected group header, got {:?}", self.current()));
335                    self.bump();
336                }
337            }
338
339            self.builder.finish_node();
340        }
341    }
342
343    let mut tokens: Vec<_> = lex(text).collect();
344    tokens.reverse();
345
346    let mut parser = Parser {
347        tokens,
348        builder: GreenNodeBuilder::new(),
349        errors: Vec::new(),
350        positioned_errors: Vec::new(),
351        pos: 0,
352    };
353
354    parser.parse_file();
355
356    Parse {
357        green_node: parser.builder.finish(),
358        errors: parser.errors,
359        positioned_errors: parser.positioned_errors,
360    }
361}
362
363// Type aliases for convenience
364type SyntaxNode = rowan::SyntaxNode<Lang>;
365
366/// Calculate line and column (both 0-indexed) for the given offset in the tree.
367/// Column is measured in bytes from the start of the line.
368fn line_col_at_offset(node: &SyntaxNode, offset: rowan::TextSize) -> (usize, usize) {
369    let root = node.ancestors().last().unwrap_or_else(|| node.clone());
370    let mut line = 0;
371    let mut last_newline_offset = rowan::TextSize::from(0);
372
373    for element in root.preorder_with_tokens() {
374        if let rowan::WalkEvent::Enter(rowan::NodeOrToken::Token(token)) = element {
375            if token.text_range().start() >= offset {
376                break;
377            }
378
379            // Count newlines and track position of last one
380            for (idx, _) in token.text().match_indices('\n') {
381                line += 1;
382                last_newline_offset =
383                    token.text_range().start() + rowan::TextSize::from((idx + 1) as u32);
384            }
385        }
386    }
387
388    let column: usize = (offset - last_newline_offset).into();
389    (line, column)
390}
391
392/// The root of an INI/.desktop file
393#[derive(Debug, Clone, PartialEq, Eq, Hash)]
394pub struct Desktop(SyntaxNode);
395
396impl Desktop {
397    /// Get all groups in the file
398    pub fn groups(&self) -> impl Iterator<Item = Group> {
399        self.0.children().filter_map(Group::cast)
400    }
401
402    /// Get a specific group by name
403    pub fn get_group(&self, name: &str) -> Option<Group> {
404        self.groups().find(|g| g.name().as_deref() == Some(name))
405    }
406
407    /// Get the raw syntax node
408    pub fn syntax(&self) -> &SyntaxNode {
409        &self.0
410    }
411
412    /// Convert to a string (same as Display::fmt)
413    pub fn text(&self) -> String {
414        self.0.text().to_string()
415    }
416
417    /// Load from a file
418    pub fn from_file(path: &Path) -> Result<Self, Error> {
419        let text = std::fs::read_to_string(path)?;
420        Self::from_str(&text)
421    }
422
423    /// Get the line number (0-indexed) where this node starts.
424    pub fn line(&self) -> usize {
425        line_col_at_offset(&self.0, self.0.text_range().start()).0
426    }
427
428    /// Get the column number (0-indexed, in bytes) where this node starts.
429    pub fn column(&self) -> usize {
430        line_col_at_offset(&self.0, self.0.text_range().start()).1
431    }
432
433    /// Get both line and column (0-indexed) where this node starts.
434    /// Returns (line, column) where column is measured in bytes from the start of the line.
435    pub fn line_col(&self) -> (usize, usize) {
436        line_col_at_offset(&self.0, self.0.text_range().start())
437    }
438}
439
440impl AstNode for Desktop {
441    type Language = Lang;
442
443    fn can_cast(kind: SyntaxKind) -> bool {
444        kind == SyntaxKind::ROOT
445    }
446
447    fn cast(node: SyntaxNode) -> Option<Self> {
448        if node.kind() == SyntaxKind::ROOT {
449            Some(Desktop(node))
450        } else {
451            None
452        }
453    }
454
455    fn syntax(&self) -> &SyntaxNode {
456        &self.0
457    }
458}
459
460impl FromStr for Desktop {
461    type Err = Error;
462
463    fn from_str(s: &str) -> Result<Self, Self::Err> {
464        let parsed = parse(s);
465        if !parsed.errors.is_empty() {
466            return Err(Error::ParseError(ParseError(parsed.errors)));
467        }
468        let node = SyntaxNode::new_root_mut(parsed.green_node);
469        Ok(Desktop::cast(node).expect("root node should be Desktop"))
470    }
471}
472
473impl std::fmt::Display for Desktop {
474    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        write!(f, "{}", self.0.text())
476    }
477}
478
479/// A group/section in an INI/.desktop file (e.g., [Desktop Entry])
480#[derive(Debug, Clone, PartialEq, Eq, Hash)]
481pub struct Group(SyntaxNode);
482
483impl Group {
484    /// Get the name of the group
485    pub fn name(&self) -> Option<String> {
486        let header = self
487            .0
488            .children()
489            .find(|n| n.kind() == SyntaxKind::GROUP_HEADER)?;
490        let value = header
491            .children_with_tokens()
492            .find(|e| e.kind() == SyntaxKind::VALUE)?;
493        Some(value.as_token()?.text().to_string())
494    }
495
496    /// Get all entries in the group
497    pub fn entries(&self) -> impl Iterator<Item = Entry> {
498        self.0.children().filter_map(Entry::cast)
499    }
500
501    /// Get a specific entry by key
502    pub fn get(&self, key: &str) -> Option<String> {
503        self.entries()
504            .find(|e| e.key().as_deref() == Some(key) && e.locale().is_none())
505            .and_then(|e| e.value())
506    }
507
508    /// Get a localized value for a key (e.g., get_locale("Name", "de"))
509    pub fn get_locale(&self, key: &str, locale: &str) -> Option<String> {
510        self.entries()
511            .find(|e| e.key().as_deref() == Some(key) && e.locale().as_deref() == Some(locale))
512            .and_then(|e| e.value())
513    }
514
515    /// Get all locales for a given key
516    pub fn get_locales(&self, key: &str) -> Vec<String> {
517        self.entries()
518            .filter(|e| e.key().as_deref() == Some(key) && e.locale().is_some())
519            .filter_map(|e| e.locale())
520            .collect()
521    }
522
523    /// Get all entries for a key (including localized variants)
524    pub fn get_all(&self, key: &str) -> Vec<(Option<String>, String)> {
525        self.entries()
526            .filter(|e| e.key().as_deref() == Some(key))
527            .filter_map(|e| {
528                let value = e.value()?;
529                Some((e.locale(), value))
530            })
531            .collect()
532    }
533
534    /// Set a value for a key (or add if it doesn't exist)
535    pub fn set(&mut self, key: &str, value: &str) {
536        let new_entry = Entry::new(key, value);
537
538        // Check if the field already exists and replace it
539        for entry in self.entries() {
540            if entry.key().as_deref() == Some(key) && entry.locale().is_none() {
541                self.0.splice_children(
542                    entry.0.index()..entry.0.index() + 1,
543                    vec![new_entry.0.into()],
544                );
545                return;
546            }
547        }
548
549        // Field doesn't exist, append at the end (before the closing of the group)
550        let insertion_index = self.0.children_with_tokens().count();
551        self.0
552            .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]);
553    }
554
555    /// Set a localized value for a key (e.g., set_locale("Name", "de", "Beispiel"))
556    pub fn set_locale(&mut self, key: &str, locale: &str, value: &str) {
557        let new_entry = Entry::new_localized(key, locale, value);
558
559        // Check if the field already exists and replace it
560        for entry in self.entries() {
561            if entry.key().as_deref() == Some(key) && entry.locale().as_deref() == Some(locale) {
562                self.0.splice_children(
563                    entry.0.index()..entry.0.index() + 1,
564                    vec![new_entry.0.into()],
565                );
566                return;
567            }
568        }
569
570        // Field doesn't exist, append at the end (before the closing of the group)
571        let insertion_index = self.0.children_with_tokens().count();
572        self.0
573            .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]);
574    }
575
576    /// Remove an entry by key (non-localized only)
577    pub fn remove(&mut self, key: &str) {
578        // Find and remove the entry with the matching key (non-localized)
579        let entry_to_remove = self.0.children().find_map(|child| {
580            let entry = Entry::cast(child)?;
581            if entry.key().as_deref() == Some(key) && entry.locale().is_none() {
582                Some(entry)
583            } else {
584                None
585            }
586        });
587
588        if let Some(entry) = entry_to_remove {
589            entry.syntax().detach();
590        }
591    }
592
593    /// Remove a localized entry by key and locale
594    pub fn remove_locale(&mut self, key: &str, locale: &str) {
595        // Find and remove the entry with the matching key and locale
596        let entry_to_remove = self.0.children().find_map(|child| {
597            let entry = Entry::cast(child)?;
598            if entry.key().as_deref() == Some(key) && entry.locale().as_deref() == Some(locale) {
599                Some(entry)
600            } else {
601                None
602            }
603        });
604
605        if let Some(entry) = entry_to_remove {
606            entry.syntax().detach();
607        }
608    }
609
610    /// Remove all entries for a key (including all localized variants)
611    pub fn remove_all(&mut self, key: &str) {
612        // Collect all entries to remove first (can't mutate while iterating)
613        let entries_to_remove: Vec<_> = self
614            .0
615            .children()
616            .filter_map(Entry::cast)
617            .filter(|e| e.key().as_deref() == Some(key))
618            .collect();
619
620        for entry in entries_to_remove {
621            entry.syntax().detach();
622        }
623    }
624
625    /// Get the raw syntax node
626    pub fn syntax(&self) -> &SyntaxNode {
627        &self.0
628    }
629
630    /// Get the line number (0-indexed) where this node starts.
631    pub fn line(&self) -> usize {
632        line_col_at_offset(&self.0, self.0.text_range().start()).0
633    }
634
635    /// Get the column number (0-indexed, in bytes) where this node starts.
636    pub fn column(&self) -> usize {
637        line_col_at_offset(&self.0, self.0.text_range().start()).1
638    }
639
640    /// Get both line and column (0-indexed) where this node starts.
641    /// Returns (line, column) where column is measured in bytes from the start of the line.
642    pub fn line_col(&self) -> (usize, usize) {
643        line_col_at_offset(&self.0, self.0.text_range().start())
644    }
645}
646
647impl AstNode for Group {
648    type Language = Lang;
649
650    fn can_cast(kind: SyntaxKind) -> bool {
651        kind == SyntaxKind::GROUP
652    }
653
654    fn cast(node: SyntaxNode) -> Option<Self> {
655        if node.kind() == SyntaxKind::GROUP {
656            Some(Group(node))
657        } else {
658            None
659        }
660    }
661
662    fn syntax(&self) -> &SyntaxNode {
663        &self.0
664    }
665}
666
667/// A key-value entry in a group
668#[derive(Debug, Clone, PartialEq, Eq, Hash)]
669pub struct Entry(SyntaxNode);
670
671impl Entry {
672    /// Create a new entry with key=value
673    pub fn new(key: &str, value: &str) -> Entry {
674        use rowan::GreenNodeBuilder;
675
676        let mut builder = GreenNodeBuilder::new();
677        builder.start_node(SyntaxKind::ENTRY.into());
678        builder.token(SyntaxKind::KEY.into(), key);
679        builder.token(SyntaxKind::EQUALS.into(), "=");
680        builder.token(SyntaxKind::VALUE.into(), value);
681        builder.token(SyntaxKind::NEWLINE.into(), "\n");
682        builder.finish_node();
683        Entry(SyntaxNode::new_root_mut(builder.finish()))
684    }
685
686    /// Create a new localized entry with key[locale]=value
687    pub fn new_localized(key: &str, locale: &str, value: &str) -> Entry {
688        use rowan::GreenNodeBuilder;
689
690        let mut builder = GreenNodeBuilder::new();
691        builder.start_node(SyntaxKind::ENTRY.into());
692        builder.token(SyntaxKind::KEY.into(), key);
693        builder.token(SyntaxKind::LEFT_BRACKET.into(), "[");
694        builder.token(SyntaxKind::VALUE.into(), locale);
695        builder.token(SyntaxKind::RIGHT_BRACKET.into(), "]");
696        builder.token(SyntaxKind::EQUALS.into(), "=");
697        builder.token(SyntaxKind::VALUE.into(), value);
698        builder.token(SyntaxKind::NEWLINE.into(), "\n");
699        builder.finish_node();
700        Entry(SyntaxNode::new_root_mut(builder.finish()))
701    }
702
703    /// Get the key name
704    pub fn key(&self) -> Option<String> {
705        let key_token = self
706            .0
707            .children_with_tokens()
708            .find(|e| e.kind() == SyntaxKind::KEY)?;
709        Some(key_token.as_token()?.text().to_string())
710    }
711
712    /// Get the value
713    pub fn value(&self) -> Option<String> {
714        // Find VALUE after EQUALS
715        let mut found_equals = false;
716        for element in self.0.children_with_tokens() {
717            match element.kind() {
718                SyntaxKind::EQUALS => found_equals = true,
719                SyntaxKind::VALUE if found_equals => {
720                    return Some(element.as_token()?.text().to_string());
721                }
722                _ => {}
723            }
724        }
725        None
726    }
727
728    /// Get the locale suffix if present (e.g., "de_DE" from "Name[de_DE]")
729    pub fn locale(&self) -> Option<String> {
730        // Find VALUE between [ and ] after KEY
731        let mut found_key = false;
732        let mut in_locale = false;
733        for element in self.0.children_with_tokens() {
734            match element.kind() {
735                SyntaxKind::KEY => found_key = true,
736                SyntaxKind::LEFT_BRACKET if found_key && !in_locale => in_locale = true,
737                SyntaxKind::VALUE if in_locale => {
738                    return Some(element.as_token()?.text().to_string());
739                }
740                SyntaxKind::RIGHT_BRACKET if in_locale => in_locale = false,
741                SyntaxKind::EQUALS => break, // Stop if we reach equals without finding locale
742                _ => {}
743            }
744        }
745        None
746    }
747
748    /// Get the raw syntax node
749    pub fn syntax(&self) -> &SyntaxNode {
750        &self.0
751    }
752
753    /// Get the line number (0-indexed) where this node starts.
754    pub fn line(&self) -> usize {
755        line_col_at_offset(&self.0, self.0.text_range().start()).0
756    }
757
758    /// Get the column number (0-indexed, in bytes) where this node starts.
759    pub fn column(&self) -> usize {
760        line_col_at_offset(&self.0, self.0.text_range().start()).1
761    }
762
763    /// Get both line and column (0-indexed) where this node starts.
764    /// Returns (line, column) where column is measured in bytes from the start of the line.
765    pub fn line_col(&self) -> (usize, usize) {
766        line_col_at_offset(&self.0, self.0.text_range().start())
767    }
768}
769
770impl AstNode for Entry {
771    type Language = Lang;
772
773    fn can_cast(kind: SyntaxKind) -> bool {
774        kind == SyntaxKind::ENTRY
775    }
776
777    fn cast(node: SyntaxNode) -> Option<Self> {
778        if node.kind() == SyntaxKind::ENTRY {
779            Some(Entry(node))
780        } else {
781            None
782        }
783    }
784
785    fn syntax(&self) -> &SyntaxNode {
786        &self.0
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    #[test]
795    fn test_parse_simple() {
796        let input = r#"[Desktop Entry]
797Name=Example
798Type=Application
799"#;
800        let desktop = Desktop::from_str(input).unwrap();
801        assert_eq!(desktop.groups().count(), 1);
802
803        let group = desktop.groups().nth(0).unwrap();
804        assert_eq!(group.name(), Some("Desktop Entry".to_string()));
805        assert_eq!(group.get("Name"), Some("Example".to_string()));
806        assert_eq!(group.get("Type"), Some("Application".to_string()));
807    }
808
809    #[test]
810    fn test_parse_with_comments() {
811        let input = r#"# Top comment
812[Desktop Entry]
813# Comment before name
814Name=Example
815Type=Application
816"#;
817        let desktop = Desktop::from_str(input).unwrap();
818        assert_eq!(desktop.groups().count(), 1);
819
820        let group = desktop.groups().nth(0).unwrap();
821        assert_eq!(group.get("Name"), Some("Example".to_string()));
822    }
823
824    #[test]
825    fn test_parse_multiple_groups() {
826        let input = r#"[Desktop Entry]
827Name=Example
828
829[Desktop Action Play]
830Name=Play
831Exec=example --play
832"#;
833        let desktop = Desktop::from_str(input).unwrap();
834        assert_eq!(desktop.groups().count(), 2);
835
836        let group1 = desktop.groups().nth(0).unwrap();
837        assert_eq!(group1.name(), Some("Desktop Entry".to_string()));
838
839        let group2 = desktop.groups().nth(1).unwrap();
840        assert_eq!(group2.name(), Some("Desktop Action Play".to_string()));
841        assert_eq!(group2.get("Name"), Some("Play".to_string()));
842    }
843
844    #[test]
845    fn test_parse_with_spaces() {
846        let input = "[Desktop Entry]\nName = Example Application\n";
847        let desktop = Desktop::from_str(input).unwrap();
848
849        let group = desktop.groups().nth(0).unwrap();
850        assert_eq!(group.get("Name"), Some("Example Application".to_string()));
851    }
852
853    #[test]
854    fn test_entry_locale() {
855        let input = "[Desktop Entry]\nName[de]=Beispiel\n";
856        let desktop = Desktop::from_str(input).unwrap();
857
858        let group = desktop.groups().nth(0).unwrap();
859        let entry = group.entries().nth(0).unwrap();
860        assert_eq!(entry.key(), Some("Name".to_string()));
861        assert_eq!(entry.locale(), Some("de".to_string()));
862        assert_eq!(entry.value(), Some("Beispiel".to_string()));
863    }
864
865    #[test]
866    fn test_lossless_roundtrip() {
867        let input = r#"# Comment
868[Desktop Entry]
869Name=Example
870Type=Application
871
872[Another Section]
873Key=Value
874"#;
875        let desktop = Desktop::from_str(input).unwrap();
876        let output = desktop.text();
877        assert_eq!(input, output);
878    }
879
880    #[test]
881    fn test_localized_query() {
882        let input = r#"[Desktop Entry]
883Name=Example Application
884Name[de]=Beispielanwendung
885Name[fr]=Application exemple
886Type=Application
887"#;
888        let desktop = Desktop::from_str(input).unwrap();
889        let group = desktop.groups().nth(0).unwrap();
890
891        // Test get() returns non-localized value
892        assert_eq!(group.get("Name"), Some("Example Application".to_string()));
893
894        // Test get_locale() returns localized values
895        assert_eq!(
896            group.get_locale("Name", "de"),
897            Some("Beispielanwendung".to_string())
898        );
899        assert_eq!(
900            group.get_locale("Name", "fr"),
901            Some("Application exemple".to_string())
902        );
903        assert_eq!(group.get_locale("Name", "es"), None);
904
905        // Test get_locales() returns all locales for a key
906        let locales = group.get_locales("Name");
907        assert_eq!(locales.len(), 2);
908        assert!(locales.contains(&"de".to_string()));
909        assert!(locales.contains(&"fr".to_string()));
910
911        // Test get_all() returns all variants
912        let all = group.get_all("Name");
913        assert_eq!(all.len(), 3);
914        assert!(all.contains(&(None, "Example Application".to_string())));
915        assert!(all.contains(&(Some("de".to_string()), "Beispielanwendung".to_string())));
916        assert!(all.contains(&(Some("fr".to_string()), "Application exemple".to_string())));
917    }
918
919    #[test]
920    fn test_localized_set() {
921        let input = r#"[Desktop Entry]
922Name=Example
923Name[de]=Beispiel
924Type=Application
925"#;
926        let desktop = Desktop::from_str(input).unwrap();
927        {
928            let mut group = desktop.groups().nth(0).unwrap();
929            // Update localized value
930            group.set_locale("Name", "de", "Neue Beispiel");
931        }
932
933        // Re-fetch the group to check the mutation persisted
934        let group = desktop.groups().nth(0).unwrap();
935        assert_eq!(
936            group.get_locale("Name", "de"),
937            Some("Neue Beispiel".to_string())
938        );
939
940        // Original value should remain unchanged
941        assert_eq!(group.get("Name"), Some("Example".to_string()));
942    }
943
944    #[test]
945    fn test_localized_remove() {
946        let input = r#"[Desktop Entry]
947Name=Example
948Name[de]=Beispiel
949Name[fr]=Exemple
950Type=Application
951"#;
952        let desktop = Desktop::from_str(input).unwrap();
953        let mut group = desktop.groups().nth(0).unwrap();
954
955        // Remove one localized entry
956        group.remove_locale("Name", "de");
957        assert_eq!(group.get_locale("Name", "de"), None);
958        assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string()));
959        assert_eq!(group.get("Name"), Some("Example".to_string()));
960
961        // Remove non-localized entry
962        group.remove("Name");
963        assert_eq!(group.get("Name"), None);
964        assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string()));
965    }
966
967    #[test]
968    fn test_localized_remove_all() {
969        let input = r#"[Desktop Entry]
970Name=Example
971Name[de]=Beispiel
972Name[fr]=Exemple
973Type=Application
974"#;
975        let desktop = Desktop::from_str(input).unwrap();
976        let mut group = desktop.groups().nth(0).unwrap();
977
978        // Remove all Name entries
979        group.remove_all("Name");
980        assert_eq!(group.get("Name"), None);
981        assert_eq!(group.get_locale("Name", "de"), None);
982        assert_eq!(group.get_locale("Name", "fr"), None);
983        assert_eq!(group.get_locales("Name").len(), 0);
984
985        // Type should still be there
986        assert_eq!(group.get("Type"), Some("Application".to_string()));
987    }
988
989    #[test]
990    fn test_get_distinguishes_localized() {
991        let input = r#"[Desktop Entry]
992Name[de]=Beispiel
993Type=Application
994"#;
995        let desktop = Desktop::from_str(input).unwrap();
996        let group = desktop.groups().nth(0).unwrap();
997
998        // get() should not return localized entries
999        assert_eq!(group.get("Name"), None);
1000        assert_eq!(group.get_locale("Name", "de"), Some("Beispiel".to_string()));
1001    }
1002
1003    #[test]
1004    fn test_add_new_entry() {
1005        let input = r#"[Desktop Entry]
1006Name=Example
1007"#;
1008        let desktop = Desktop::from_str(input).unwrap();
1009        {
1010            let mut group = desktop.groups().nth(0).unwrap();
1011            // Add a new entry
1012            group.set("Type", "Application");
1013        }
1014
1015        let group = desktop.groups().nth(0).unwrap();
1016        assert_eq!(group.get("Name"), Some("Example".to_string()));
1017        assert_eq!(group.get("Type"), Some("Application".to_string()));
1018    }
1019
1020    #[test]
1021    fn test_add_new_localized_entry() {
1022        let input = r#"[Desktop Entry]
1023Name=Example
1024"#;
1025        let desktop = Desktop::from_str(input).unwrap();
1026        {
1027            let mut group = desktop.groups().nth(0).unwrap();
1028            // Add new localized entries
1029            group.set_locale("Name", "de", "Beispiel");
1030            group.set_locale("Name", "fr", "Exemple");
1031        }
1032
1033        let group = desktop.groups().nth(0).unwrap();
1034        assert_eq!(group.get("Name"), Some("Example".to_string()));
1035        assert_eq!(group.get_locale("Name", "de"), Some("Beispiel".to_string()));
1036        assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string()));
1037        assert_eq!(group.get_locales("Name").len(), 2);
1038    }
1039
1040    #[test]
1041    fn test_line_col() {
1042        let text = r#"[Desktop Entry]
1043Name=Example Application
1044Type=Application
1045Exec=example
1046
1047[Desktop Action Play]
1048Name=Play
1049Exec=example --play
1050"#;
1051        let desktop = Desktop::from_str(text).unwrap();
1052
1053        // Test desktop root starts at line 0
1054        assert_eq!(desktop.line(), 0);
1055        assert_eq!(desktop.column(), 0);
1056
1057        // Test group line numbers
1058        let groups: Vec<_> = desktop.groups().collect();
1059        assert_eq!(groups.len(), 2);
1060
1061        // First group starts at line 0
1062        assert_eq!(groups[0].line(), 0);
1063        assert_eq!(groups[0].column(), 0);
1064
1065        // Second group starts at line 5 (after empty line)
1066        assert_eq!(groups[1].line(), 5);
1067        assert_eq!(groups[1].column(), 0);
1068
1069        // Test entry line numbers in first group
1070        let entries: Vec<_> = groups[0].entries().collect();
1071        assert_eq!(entries[0].line(), 1); // Name=Example Application
1072        assert_eq!(entries[1].line(), 2); // Type=Application
1073        assert_eq!(entries[2].line(), 3); // Exec=example
1074
1075        // Test column numbers
1076        assert_eq!(entries[0].column(), 0); // Start of line
1077        assert_eq!(entries[1].column(), 0); // Start of line
1078
1079        // Test line_col() method
1080        assert_eq!(groups[1].line_col(), (5, 0));
1081        assert_eq!(entries[0].line_col(), (1, 0));
1082
1083        // Test entries in second group
1084        let second_group_entries: Vec<_> = groups[1].entries().collect();
1085        assert_eq!(second_group_entries[0].line(), 6); // Name=Play
1086        assert_eq!(second_group_entries[1].line(), 7); // Exec=example --play
1087    }
1088}