desktop-edit-0.1.0/.cargo_vcs_info.json0000644000000001360000000000100134260ustar { "git": { "sha1": "1b5cac8d648fddb6bb99648ba6749cba39ca50b7" }, "path_in_vcs": "" }desktop-edit-0.1.0/.gitignore000064400000000000000000000000101046102023000141750ustar 00000000000000/target desktop-edit-0.1.0/Cargo.lock0000644000000023050000000000100114010ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "countme" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "desktop-edit" version = "0.1.0" dependencies = [ "rowan", ] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "rowan" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ "countme", "hashbrown", "rustc-hash", "text-size", ] [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "text-size" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" desktop-edit-0.1.0/Cargo.toml0000644000000021600000000000100114230ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "desktop-edit" version = "0.1.0" authors = ["Jelmer Vernooij "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "A lossless parser and editor for .desktop files" homepage = "https://github.com/jelmer/desktop-edit-rs" readme = "README.md" keywords = [ "desktop", "freedesktop", "parser", "lossless", "xdg", ] categories = ["parser-implementations"] license = "Apache-2.0" [lib] name = "desktop_edit" path = "src/lib.rs" [[example]] name = "test_parse" path = "examples/test_parse.rs" [dependencies.rowan] version = "0.16" desktop-edit-0.1.0/Cargo.toml.orig000064400000000000000000000006151046102023000151070ustar 00000000000000[package] name = "desktop-edit" version = "0.1.0" edition = "2021" authors = ["Jelmer Vernooij "] license = "Apache-2.0" description = "A lossless parser and editor for .desktop files" keywords = ["desktop", "freedesktop", "parser", "lossless", "xdg"] categories = ["parser-implementations"] homepage = "https://github.com/jelmer/desktop-edit-rs" [dependencies] rowan = "0.16" desktop-edit-0.1.0/README.md000064400000000000000000000017661046102023000135070ustar 00000000000000# desktop-edit A lossless parser and editor for .desktop files as specified by [freedesktop.org Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/). This library preserves all whitespace, comments, and formatting while providing a structured way to read and modify .desktop files. ## Features - **Lossless parsing**: All whitespace, comments, and formatting are preserved - **FreeDesktop .desktop file support**: Full support for the freedesktop.org Desktop Entry specification - **Locale support**: Handle localized keys like `Name[de]=...` ## Example ```rust use desktop_edit::Desktop; use std::str::FromStr; # let input = r#"[Desktop Entry] # Name=Example Application # Type=Application # Exec=example # Icon=example.png # "#; # let desktop = Desktop::from_str(input).unwrap(); # assert_eq!(desktop.groups().count(), 1); # let group = desktop.groups().nth(0).unwrap(); # assert_eq!(group.name(), Some("Desktop Entry".to_string())); ``` ## License Apache-2.0 desktop-edit-0.1.0/examples/test_parse.rs000064400000000000000000000011241046102023000165510ustar 00000000000000use desktop_edit::Desktop; use std::str::FromStr; fn main() { let input = r###"[Desktop Entry] Name=Example Application Type=Application Exec=example # This is a comment Icon=example.png "###; println!("Input:\n{}", input); println!("\nParsing..."); match Desktop::from_str(input) { Ok(desktop) => { println!("Success! Groups: {}", desktop.groups().count()); for group in desktop.groups() { println!("Group: {:?}", group.name()); } } Err(e) => { println!("Error: {}", e); } } } desktop-edit-0.1.0/src/desktop.rs000064400000000000000000000734451046102023000150410ustar 00000000000000//! Parser for INI/.desktop style files. //! //! This parser can be used to parse files in the INI/.desktop format (as specified //! by the [freedesktop.org Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/)), //! while preserving all whitespace and comments. It is based on //! the [rowan] library, which is a lossless parser library for Rust. //! //! Once parsed, the file can be traversed or modified, and then written back to a file. //! //! # Example //! //! ``` //! use desktop_edit::Desktop; //! use std::str::FromStr; //! //! # let input = r#"[Desktop Entry] //! # Name=Example Application //! # Type=Application //! # Exec=example //! # Icon=example.png //! # "#; //! # let desktop = Desktop::from_str(input).unwrap(); //! # assert_eq!(desktop.groups().count(), 1); //! # let group = desktop.groups().nth(0).unwrap(); //! # assert_eq!(group.name(), Some("Desktop Entry".to_string())); //! ``` use crate::lex::{lex, SyntaxKind}; use rowan::ast::AstNode; use rowan::{GreenNode, GreenNodeBuilder}; use std::path::Path; use std::str::FromStr; /// A positioned parse error containing location information. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PositionedParseError { /// The error message pub message: String, /// The text range where the error occurred pub range: rowan::TextRange, /// Optional error code for categorization pub code: Option, } impl std::fmt::Display for PositionedParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for PositionedParseError {} /// List of encountered syntax errors. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ParseError(pub Vec); impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { for err in &self.0 { writeln!(f, "{}", err)?; } Ok(()) } } impl std::error::Error for ParseError {} /// Error parsing INI/.desktop files #[derive(Debug)] pub enum Error { /// A syntax error was encountered while parsing the file. ParseError(ParseError), /// An I/O error was encountered while reading the file. IoError(std::io::Error), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match &self { Error::ParseError(err) => write!(f, "{}", err), Error::IoError(err) => write!(f, "{}", err), } } } impl From for Error { fn from(err: ParseError) -> Self { Self::ParseError(err) } } impl From for Error { fn from(err: std::io::Error) -> Self { Self::IoError(err) } } impl std::error::Error for Error {} /// Language definition for rowan #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Lang {} impl rowan::Language for Lang { type Kind = SyntaxKind; fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind { unsafe { std::mem::transmute::(raw.0) } } fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind { kind.into() } } /// Internal parse result pub(crate) struct Parse { pub(crate) green_node: GreenNode, pub(crate) errors: Vec, pub(crate) positioned_errors: Vec, } /// Parse an INI/.desktop file pub(crate) fn parse(text: &str) -> Parse { struct Parser<'a> { tokens: Vec<(SyntaxKind, &'a str)>, builder: GreenNodeBuilder<'static>, errors: Vec, positioned_errors: Vec, pos: usize, } impl<'a> Parser<'a> { fn current(&self) -> Option { if self.pos < self.tokens.len() { Some(self.tokens[self.tokens.len() - 1 - self.pos].0) } else { None } } fn bump(&mut self) { if self.pos < self.tokens.len() { let (kind, text) = self.tokens[self.tokens.len() - 1 - self.pos]; self.builder.token(kind.into(), text); self.pos += 1; } } fn skip_ws(&mut self) { while self.current() == Some(SyntaxKind::WHITESPACE) { self.bump(); } } fn skip_blank_lines(&mut self) { while let Some(kind) = self.current() { match kind { SyntaxKind::NEWLINE => { self.builder.start_node(SyntaxKind::BLANK_LINE.into()); self.bump(); self.builder.finish_node(); } SyntaxKind::WHITESPACE => { // Check if followed by newline if self.pos + 1 < self.tokens.len() && self.tokens[self.tokens.len() - 2 - self.pos].0 == SyntaxKind::NEWLINE { self.builder.start_node(SyntaxKind::BLANK_LINE.into()); self.bump(); // whitespace self.bump(); // newline self.builder.finish_node(); } else { break; } } _ => break, } } } fn parse_group_header(&mut self) { self.builder.start_node(SyntaxKind::GROUP_HEADER.into()); // Consume '[' if self.current() == Some(SyntaxKind::LEFT_BRACKET) { self.bump(); } else { self.errors .push("expected '[' at start of group header".to_string()); } // Consume section name (stored as VALUE tokens) if self.current() == Some(SyntaxKind::VALUE) { self.bump(); } else { self.errors .push("expected section name in group header".to_string()); } // Consume ']' if self.current() == Some(SyntaxKind::RIGHT_BRACKET) { self.bump(); } else { self.errors .push("expected ']' at end of group header".to_string()); } // Consume newline if present if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); } fn parse_entry(&mut self) { self.builder.start_node(SyntaxKind::ENTRY.into()); // Handle comment before entry if self.current() == Some(SyntaxKind::COMMENT) { self.bump(); if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); return; } // Parse key if self.current() == Some(SyntaxKind::KEY) { self.bump(); } else { self.errors .push(format!("expected key, got {:?}", self.current())); } self.skip_ws(); // Check for locale suffix [locale] - note that after KEY, we might get LEFT_BRACKET directly // but the lexer treats [ as in_section_header mode, so we need to handle this differently // Actually, we need to look for [ character in a key-value context // For now, let's check if we have LEFT_BRACKET and handle it as locale if self.current() == Some(SyntaxKind::LEFT_BRACKET) { self.bump(); // After [, we should have the locale as VALUE (since lexer is in section header mode) // But we need to handle this edge case self.skip_ws(); if self.current() == Some(SyntaxKind::VALUE) { self.bump(); } if self.current() == Some(SyntaxKind::RIGHT_BRACKET) { self.bump(); } self.skip_ws(); } // Parse '=' if self.current() == Some(SyntaxKind::EQUALS) { self.bump(); } else { self.errors.push("expected '=' after key".to_string()); } self.skip_ws(); // Parse value if self.current() == Some(SyntaxKind::VALUE) { self.bump(); } // Consume newline if present if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); } fn parse_group(&mut self) { self.builder.start_node(SyntaxKind::GROUP.into()); // Parse group header self.parse_group_header(); // Parse entries until we hit another group header or EOF while let Some(kind) = self.current() { match kind { SyntaxKind::LEFT_BRACKET => break, // Start of next group SyntaxKind::KEY | SyntaxKind::COMMENT => self.parse_entry(), SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => { self.skip_blank_lines(); } _ => { self.errors .push(format!("unexpected token in group: {:?}", kind)); self.bump(); } } } self.builder.finish_node(); } fn parse_file(&mut self) { self.builder.start_node(SyntaxKind::ROOT.into()); // Skip leading blank lines and comments while let Some(kind) = self.current() { match kind { SyntaxKind::COMMENT => { self.builder.start_node(SyntaxKind::ENTRY.into()); self.bump(); if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); } SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => { self.skip_blank_lines(); } _ => break, } } // Parse groups while self.current().is_some() { if self.current() == Some(SyntaxKind::LEFT_BRACKET) { self.parse_group(); } else { self.errors .push(format!("expected group header, got {:?}", self.current())); self.bump(); } } self.builder.finish_node(); } } let mut tokens: Vec<_> = lex(text).collect(); tokens.reverse(); let mut parser = Parser { tokens, builder: GreenNodeBuilder::new(), errors: Vec::new(), positioned_errors: Vec::new(), pos: 0, }; parser.parse_file(); Parse { green_node: parser.builder.finish(), errors: parser.errors, positioned_errors: parser.positioned_errors, } } // Type aliases for convenience type SyntaxNode = rowan::SyntaxNode; /// The root of an INI/.desktop file #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Desktop(SyntaxNode); impl Desktop { /// Get all groups in the file pub fn groups(&self) -> impl Iterator { self.0.children().filter_map(Group::cast) } /// Get a specific group by name pub fn get_group(&self, name: &str) -> Option { self.groups().find(|g| g.name().as_deref() == Some(name)) } /// Get the raw syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } /// Convert to a string (same as Display::fmt) pub fn text(&self) -> String { self.0.text().to_string() } /// Load from a file pub fn from_file(path: &Path) -> Result { let text = std::fs::read_to_string(path)?; Self::from_str(&text) } } impl AstNode for Desktop { type Language = Lang; fn can_cast(kind: SyntaxKind) -> bool { kind == SyntaxKind::ROOT } fn cast(node: SyntaxNode) -> Option { if node.kind() == SyntaxKind::ROOT { Some(Desktop(node)) } else { None } } fn syntax(&self) -> &SyntaxNode { &self.0 } } impl FromStr for Desktop { type Err = Error; fn from_str(s: &str) -> Result { let parsed = parse(s); if !parsed.errors.is_empty() { return Err(Error::ParseError(ParseError(parsed.errors))); } let node = SyntaxNode::new_root_mut(parsed.green_node); Ok(Desktop::cast(node).expect("root node should be Desktop")) } } impl std::fmt::Display for Desktop { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0.text()) } } /// A group/section in an INI/.desktop file (e.g., [Desktop Entry]) #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Group(SyntaxNode); impl Group { /// Get the name of the group pub fn name(&self) -> Option { let header = self .0 .children() .find(|n| n.kind() == SyntaxKind::GROUP_HEADER)?; let value = header .children_with_tokens() .find(|e| e.kind() == SyntaxKind::VALUE)?; Some(value.as_token()?.text().to_string()) } /// Get all entries in the group pub fn entries(&self) -> impl Iterator { self.0.children().filter_map(Entry::cast) } /// Get a specific entry by key pub fn get(&self, key: &str) -> Option { self.entries() .find(|e| e.key().as_deref() == Some(key) && e.locale().is_none()) .and_then(|e| e.value()) } /// Get a localized value for a key (e.g., get_locale("Name", "de")) pub fn get_locale(&self, key: &str, locale: &str) -> Option { self.entries() .find(|e| e.key().as_deref() == Some(key) && e.locale().as_deref() == Some(locale)) .and_then(|e| e.value()) } /// Get all locales for a given key pub fn get_locales(&self, key: &str) -> Vec { self.entries() .filter(|e| e.key().as_deref() == Some(key) && e.locale().is_some()) .filter_map(|e| e.locale()) .collect() } /// Get all entries for a key (including localized variants) pub fn get_all(&self, key: &str) -> Vec<(Option, String)> { self.entries() .filter(|e| e.key().as_deref() == Some(key)) .filter_map(|e| { let value = e.value()?; Some((e.locale(), value)) }) .collect() } /// Set a value for a key (or add if it doesn't exist) pub fn set(&mut self, key: &str, value: &str) { let new_entry = Entry::new(key, value); // Check if the field already exists and replace it for entry in self.entries() { if entry.key().as_deref() == Some(key) && entry.locale().is_none() { self.0.splice_children( entry.0.index()..entry.0.index() + 1, vec![new_entry.0.into()], ); return; } } // Field doesn't exist, append at the end (before the closing of the group) let insertion_index = self.0.children_with_tokens().count(); self.0 .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]); } /// Set a localized value for a key (e.g., set_locale("Name", "de", "Beispiel")) pub fn set_locale(&mut self, key: &str, locale: &str, value: &str) { let new_entry = Entry::new_localized(key, locale, value); // Check if the field already exists and replace it for entry in self.entries() { if entry.key().as_deref() == Some(key) && entry.locale().as_deref() == Some(locale) { self.0.splice_children( entry.0.index()..entry.0.index() + 1, vec![new_entry.0.into()], ); return; } } // Field doesn't exist, append at the end (before the closing of the group) let insertion_index = self.0.children_with_tokens().count(); self.0 .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]); } /// Remove an entry by key (non-localized only) pub fn remove(&mut self, key: &str) { // Find and remove the entry with the matching key (non-localized) let entry_to_remove = self.0.children().find_map(|child| { let entry = Entry::cast(child)?; if entry.key().as_deref() == Some(key) && entry.locale().is_none() { Some(entry) } else { None } }); if let Some(entry) = entry_to_remove { entry.syntax().detach(); } } /// Remove a localized entry by key and locale pub fn remove_locale(&mut self, key: &str, locale: &str) { // Find and remove the entry with the matching key and locale let entry_to_remove = self.0.children().find_map(|child| { let entry = Entry::cast(child)?; if entry.key().as_deref() == Some(key) && entry.locale().as_deref() == Some(locale) { Some(entry) } else { None } }); if let Some(entry) = entry_to_remove { entry.syntax().detach(); } } /// Remove all entries for a key (including all localized variants) pub fn remove_all(&mut self, key: &str) { // Collect all entries to remove first (can't mutate while iterating) let entries_to_remove: Vec<_> = self .0 .children() .filter_map(Entry::cast) .filter(|e| e.key().as_deref() == Some(key)) .collect(); for entry in entries_to_remove { entry.syntax().detach(); } } /// Get the raw syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } } impl AstNode for Group { type Language = Lang; fn can_cast(kind: SyntaxKind) -> bool { kind == SyntaxKind::GROUP } fn cast(node: SyntaxNode) -> Option { if node.kind() == SyntaxKind::GROUP { Some(Group(node)) } else { None } } fn syntax(&self) -> &SyntaxNode { &self.0 } } /// A key-value entry in a group #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Entry(SyntaxNode); impl Entry { /// Create a new entry with key=value pub fn new(key: &str, value: &str) -> Entry { use rowan::GreenNodeBuilder; let mut builder = GreenNodeBuilder::new(); builder.start_node(SyntaxKind::ENTRY.into()); builder.token(SyntaxKind::KEY.into(), key); builder.token(SyntaxKind::EQUALS.into(), "="); builder.token(SyntaxKind::VALUE.into(), value); builder.token(SyntaxKind::NEWLINE.into(), "\n"); builder.finish_node(); Entry(SyntaxNode::new_root_mut(builder.finish())) } /// Create a new localized entry with key[locale]=value pub fn new_localized(key: &str, locale: &str, value: &str) -> Entry { use rowan::GreenNodeBuilder; let mut builder = GreenNodeBuilder::new(); builder.start_node(SyntaxKind::ENTRY.into()); builder.token(SyntaxKind::KEY.into(), key); builder.token(SyntaxKind::LEFT_BRACKET.into(), "["); builder.token(SyntaxKind::VALUE.into(), locale); builder.token(SyntaxKind::RIGHT_BRACKET.into(), "]"); builder.token(SyntaxKind::EQUALS.into(), "="); builder.token(SyntaxKind::VALUE.into(), value); builder.token(SyntaxKind::NEWLINE.into(), "\n"); builder.finish_node(); Entry(SyntaxNode::new_root_mut(builder.finish())) } /// Get the key name pub fn key(&self) -> Option { let key_token = self .0 .children_with_tokens() .find(|e| e.kind() == SyntaxKind::KEY)?; Some(key_token.as_token()?.text().to_string()) } /// Get the value pub fn value(&self) -> Option { // Find VALUE after EQUALS let mut found_equals = false; for element in self.0.children_with_tokens() { match element.kind() { SyntaxKind::EQUALS => found_equals = true, SyntaxKind::VALUE if found_equals => { return Some(element.as_token()?.text().to_string()); } _ => {} } } None } /// Get the locale suffix if present (e.g., "de_DE" from "Name[de_DE]") pub fn locale(&self) -> Option { // Find VALUE between [ and ] after KEY let mut found_key = false; let mut in_locale = false; for element in self.0.children_with_tokens() { match element.kind() { SyntaxKind::KEY => found_key = true, SyntaxKind::LEFT_BRACKET if found_key && !in_locale => in_locale = true, SyntaxKind::VALUE if in_locale => { return Some(element.as_token()?.text().to_string()); } SyntaxKind::RIGHT_BRACKET if in_locale => in_locale = false, SyntaxKind::EQUALS => break, // Stop if we reach equals without finding locale _ => {} } } None } /// Get the raw syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } } impl AstNode for Entry { type Language = Lang; fn can_cast(kind: SyntaxKind) -> bool { kind == SyntaxKind::ENTRY } fn cast(node: SyntaxNode) -> Option { if node.kind() == SyntaxKind::ENTRY { Some(Entry(node)) } else { None } } fn syntax(&self) -> &SyntaxNode { &self.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple() { let input = r#"[Desktop Entry] Name=Example Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); assert_eq!(desktop.groups().count(), 1); let group = desktop.groups().nth(0).unwrap(); assert_eq!(group.name(), Some("Desktop Entry".to_string())); assert_eq!(group.get("Name"), Some("Example".to_string())); assert_eq!(group.get("Type"), Some("Application".to_string())); } #[test] fn test_parse_with_comments() { let input = r#"# Top comment [Desktop Entry] # Comment before name Name=Example Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); assert_eq!(desktop.groups().count(), 1); let group = desktop.groups().nth(0).unwrap(); assert_eq!(group.get("Name"), Some("Example".to_string())); } #[test] fn test_parse_multiple_groups() { let input = r#"[Desktop Entry] Name=Example [Desktop Action Play] Name=Play Exec=example --play "#; let desktop = Desktop::from_str(input).unwrap(); assert_eq!(desktop.groups().count(), 2); let group1 = desktop.groups().nth(0).unwrap(); assert_eq!(group1.name(), Some("Desktop Entry".to_string())); let group2 = desktop.groups().nth(1).unwrap(); assert_eq!(group2.name(), Some("Desktop Action Play".to_string())); assert_eq!(group2.get("Name"), Some("Play".to_string())); } #[test] fn test_parse_with_spaces() { let input = "[Desktop Entry]\nName = Example Application\n"; let desktop = Desktop::from_str(input).unwrap(); let group = desktop.groups().nth(0).unwrap(); assert_eq!(group.get("Name"), Some("Example Application".to_string())); } #[test] fn test_entry_locale() { let input = "[Desktop Entry]\nName[de]=Beispiel\n"; let desktop = Desktop::from_str(input).unwrap(); let group = desktop.groups().nth(0).unwrap(); let entry = group.entries().nth(0).unwrap(); assert_eq!(entry.key(), Some("Name".to_string())); assert_eq!(entry.locale(), Some("de".to_string())); assert_eq!(entry.value(), Some("Beispiel".to_string())); } #[test] fn test_lossless_roundtrip() { let input = r#"# Comment [Desktop Entry] Name=Example Type=Application [Another Section] Key=Value "#; let desktop = Desktop::from_str(input).unwrap(); let output = desktop.text(); assert_eq!(input, output); } #[test] fn test_localized_query() { let input = r#"[Desktop Entry] Name=Example Application Name[de]=Beispielanwendung Name[fr]=Application exemple Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); let group = desktop.groups().nth(0).unwrap(); // Test get() returns non-localized value assert_eq!(group.get("Name"), Some("Example Application".to_string())); // Test get_locale() returns localized values assert_eq!( group.get_locale("Name", "de"), Some("Beispielanwendung".to_string()) ); assert_eq!( group.get_locale("Name", "fr"), Some("Application exemple".to_string()) ); assert_eq!(group.get_locale("Name", "es"), None); // Test get_locales() returns all locales for a key let locales = group.get_locales("Name"); assert_eq!(locales.len(), 2); assert!(locales.contains(&"de".to_string())); assert!(locales.contains(&"fr".to_string())); // Test get_all() returns all variants let all = group.get_all("Name"); assert_eq!(all.len(), 3); assert!(all.contains(&(None, "Example Application".to_string()))); assert!(all.contains(&(Some("de".to_string()), "Beispielanwendung".to_string()))); assert!(all.contains(&(Some("fr".to_string()), "Application exemple".to_string()))); } #[test] fn test_localized_set() { let input = r#"[Desktop Entry] Name=Example Name[de]=Beispiel Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); { let mut group = desktop.groups().nth(0).unwrap(); // Update localized value group.set_locale("Name", "de", "Neue Beispiel"); } // Re-fetch the group to check the mutation persisted let group = desktop.groups().nth(0).unwrap(); assert_eq!( group.get_locale("Name", "de"), Some("Neue Beispiel".to_string()) ); // Original value should remain unchanged assert_eq!(group.get("Name"), Some("Example".to_string())); } #[test] fn test_localized_remove() { let input = r#"[Desktop Entry] Name=Example Name[de]=Beispiel Name[fr]=Exemple Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); let mut group = desktop.groups().nth(0).unwrap(); // Remove one localized entry group.remove_locale("Name", "de"); assert_eq!(group.get_locale("Name", "de"), None); assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string())); assert_eq!(group.get("Name"), Some("Example".to_string())); // Remove non-localized entry group.remove("Name"); assert_eq!(group.get("Name"), None); assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string())); } #[test] fn test_localized_remove_all() { let input = r#"[Desktop Entry] Name=Example Name[de]=Beispiel Name[fr]=Exemple Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); let mut group = desktop.groups().nth(0).unwrap(); // Remove all Name entries group.remove_all("Name"); assert_eq!(group.get("Name"), None); assert_eq!(group.get_locale("Name", "de"), None); assert_eq!(group.get_locale("Name", "fr"), None); assert_eq!(group.get_locales("Name").len(), 0); // Type should still be there assert_eq!(group.get("Type"), Some("Application".to_string())); } #[test] fn test_get_distinguishes_localized() { let input = r#"[Desktop Entry] Name[de]=Beispiel Type=Application "#; let desktop = Desktop::from_str(input).unwrap(); let group = desktop.groups().nth(0).unwrap(); // get() should not return localized entries assert_eq!(group.get("Name"), None); assert_eq!(group.get_locale("Name", "de"), Some("Beispiel".to_string())); } #[test] fn test_add_new_entry() { let input = r#"[Desktop Entry] Name=Example "#; let desktop = Desktop::from_str(input).unwrap(); { let mut group = desktop.groups().nth(0).unwrap(); // Add a new entry group.set("Type", "Application"); } let group = desktop.groups().nth(0).unwrap(); assert_eq!(group.get("Name"), Some("Example".to_string())); assert_eq!(group.get("Type"), Some("Application".to_string())); } #[test] fn test_add_new_localized_entry() { let input = r#"[Desktop Entry] Name=Example "#; let desktop = Desktop::from_str(input).unwrap(); { let mut group = desktop.groups().nth(0).unwrap(); // Add new localized entries group.set_locale("Name", "de", "Beispiel"); group.set_locale("Name", "fr", "Exemple"); } let group = desktop.groups().nth(0).unwrap(); assert_eq!(group.get("Name"), Some("Example".to_string())); assert_eq!(group.get_locale("Name", "de"), Some("Beispiel".to_string())); assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string())); assert_eq!(group.get_locales("Name").len(), 2); } } desktop-edit-0.1.0/src/lex.rs000064400000000000000000000227561046102023000141570ustar 00000000000000//! Lexer for INI/.desktop files /// Token types for INI/.desktop files #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[allow(non_camel_case_types)] #[repr(u16)] pub enum SyntaxKind { /// Left bracket: `[` LEFT_BRACKET = 0, /// Right bracket: `]` RIGHT_BRACKET, /// Equals sign: `=` EQUALS, /// Key name (e.g., "Name", "Type") KEY, /// Section name (e.g., "Desktop Entry") SECTION_NAME, /// Locale suffix (e.g., "[de_DE]" in "Name[de_DE]") LOCALE, /// Value part of key=value VALUE, /// Comment starting with `#` COMMENT, /// Newline: `\n` or `\r\n` NEWLINE, /// Whitespace: spaces and tabs WHITESPACE, /// Error token ERROR, /// Root node: the entire file ROOT, /// Group node: a section with its entries GROUP, /// Group header node: `[Section Name]` GROUP_HEADER, /// Entry node: `Key=Value` or `Key[locale]=Value` ENTRY, /// Blank line node BLANK_LINE, } /// Convert our `SyntaxKind` into the rowan `SyntaxKind`. impl From for rowan::SyntaxKind { fn from(kind: SyntaxKind) -> Self { Self(kind as u16) } } /// Check if a character is valid at the start of a key name #[inline] fn is_valid_initial_key_char(c: char) -> bool { // Keys must start with A-Za-z0-9 c.is_ascii_alphanumeric() } /// Check if a character is valid in a key name #[inline] fn is_valid_key_char(c: char) -> bool { // Keys can contain A-Za-z0-9- c.is_ascii_alphanumeric() || c == '-' } /// Check if a character is a newline #[inline] fn is_newline(c: char) -> bool { c == '\n' || c == '\r' } /// Check if a character is whitespace (space or tab) #[inline] fn is_whitespace(c: char) -> bool { c == ' ' || c == '\t' } /// Lexer implementation fn lex_impl(input: &str) -> impl Iterator + '_ { let mut remaining = input; let mut at_line_start = true; let mut in_section_header = false; let mut in_locale = false; std::iter::from_fn(move || { if remaining.is_empty() { return None; } let c = remaining.chars().next()?; match c { // Newline _ if is_newline(c) => { let char_len = c.len_utf8(); // Handle \r\n as a single newline if c == '\r' && remaining.get(1..2) == Some("\n") { let (token, rest) = remaining.split_at(2); remaining = rest; at_line_start = true; in_section_header = false; in_locale = false; Some((SyntaxKind::NEWLINE, token)) } else { let (token, rest) = remaining.split_at(char_len); remaining = rest; at_line_start = true; in_section_header = false; in_locale = false; Some((SyntaxKind::NEWLINE, token)) } } // Comment (# at start of line or after whitespace) '#' if at_line_start => { let end = remaining.find(is_newline).unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::COMMENT, token)) } // Section header [Section Name] '[' if at_line_start => { remaining = &remaining[1..]; // consume '[' at_line_start = false; in_section_header = true; Some((SyntaxKind::LEFT_BRACKET, "[")) } // Left bracket in key-value context (for locale like Name[de]) '[' => { remaining = &remaining[1..]; // consume '[' in_locale = true; Some((SyntaxKind::LEFT_BRACKET, "[")) } ']' => { remaining = &remaining[1..]; // consume ']' in_section_header = false; in_locale = false; Some((SyntaxKind::RIGHT_BRACKET, "]")) } // Whitespace at start of line - could be blank line _ if is_whitespace(c) && at_line_start => { let end = remaining .find(|c| !is_whitespace(c)) .unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; // Check if this is followed by newline or EOF (blank line) // Otherwise it's just leading whitespace before a key Some((SyntaxKind::WHITESPACE, token)) } // Whitespace (not at line start) _ if is_whitespace(c) => { let end = remaining .find(|c| !is_whitespace(c)) .unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::WHITESPACE, token)) } // Equals sign '=' => { remaining = &remaining[1..]; Some((SyntaxKind::EQUALS, "=")) } // Key name (starts with alphanumeric) _ if is_valid_initial_key_char(c) && at_line_start => { let end = remaining .find(|c: char| !is_valid_key_char(c)) .unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; at_line_start = false; Some((SyntaxKind::KEY, token)) } // Locale identifier or section name (between [ and ]) _ if in_section_header || in_locale => { // Inside brackets - read until ] let end = remaining.find(']').unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::VALUE, token)) } // Value (everything else on a line) _ if !at_line_start => { // Everything else on the line is a value let end = remaining.find(is_newline).unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::VALUE, token)) } // Error: unexpected character at line start _ => { let char_len = c.len_utf8(); let (token, rest) = remaining.split_at(char_len); remaining = rest; at_line_start = false; Some((SyntaxKind::ERROR, token)) } } }) } /// Lex an INI/.desktop file into tokens pub(crate) fn lex(input: &str) -> impl Iterator { lex_impl(input) } #[cfg(test)] mod tests { use super::SyntaxKind::*; use super::*; #[test] fn test_empty() { assert_eq!(lex("").collect::>(), vec![]); } #[test] fn test_simple_section() { let input = "[Desktop Entry]\n"; assert_eq!( lex(input).collect::>(), vec![ (LEFT_BRACKET, "["), (VALUE, "Desktop Entry"), (RIGHT_BRACKET, "]"), (NEWLINE, "\n"), ] ); } #[test] fn test_key_value() { let input = "Name=Example\n"; assert_eq!( lex(input).collect::>(), vec![ (KEY, "Name"), (EQUALS, "="), (VALUE, "Example"), (NEWLINE, "\n"), ] ); } #[test] fn test_key_value_with_spaces() { let input = "Name = Example Application\n"; assert_eq!( lex(input).collect::>(), vec![ (KEY, "Name"), (WHITESPACE, " "), (EQUALS, "="), (WHITESPACE, " "), (VALUE, "Example Application"), (NEWLINE, "\n"), ] ); } #[test] fn test_comment() { let input = "# This is a comment\n"; assert_eq!( lex(input).collect::>(), vec![(COMMENT, "# This is a comment"), (NEWLINE, "\n"),] ); } #[test] fn test_full_desktop_file() { let input = r#"[Desktop Entry] Name=Example Type=Application Exec=example # Comment Icon=example.png [Desktop Action Play] Name=Play Exec=example --play "#; let tokens: Vec<_> = lex(input).collect(); // Verify we get the expected token types assert_eq!(tokens[0].0, LEFT_BRACKET); assert_eq!(tokens[1].0, VALUE); // "Desktop Entry" assert_eq!(tokens[2].0, RIGHT_BRACKET); assert_eq!(tokens[3].0, NEWLINE); // Find and verify "Name=Example" let name_idx = tokens .iter() .position(|(k, t)| *k == KEY && *t == "Name") .unwrap(); assert_eq!(tokens[name_idx + 1].0, EQUALS); assert_eq!(tokens[name_idx + 2].0, VALUE); assert_eq!(tokens[name_idx + 2].1, "Example"); } #[test] fn test_blank_lines() { let input = "Key=Value\n\nKey2=Value2\n"; let tokens: Vec<_> = lex(input).collect(); // Should have two newlines in sequence let first_newline = tokens.iter().position(|(k, _)| *k == NEWLINE).unwrap(); assert_eq!(tokens[first_newline + 1].0, NEWLINE); } } desktop-edit-0.1.0/src/lib.rs000064400000000000000000000012521046102023000141210ustar 00000000000000#![deny(missing_docs)] #![allow(clippy::type_complexity)] #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] //! A lossless .desktop file parser and editor. //! //! This library provides a lossless parser for .desktop files as specified //! by the [freedesktop.org Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/). //! It preserves all whitespace, comments, and formatting. //! It is based on the [rowan] library. mod desktop; mod lex; mod parse; pub use desktop::{Desktop, Entry, Error, Group, Lang, ParseError, PositionedParseError}; pub use lex::SyntaxKind; pub use parse::Parse; pub use rowan::TextRange; desktop-edit-0.1.0/src/parse.rs000064400000000000000000000101161046102023000144640ustar 00000000000000//! Parse wrapper type following rust-analyzer's pattern for thread-safe storage in Salsa. use crate::desktop::{Desktop, ParseError, PositionedParseError}; use rowan::ast::AstNode; use rowan::{GreenNode, SyntaxNode}; use std::marker::PhantomData; /// The result of parsing: a syntax tree and a collection of errors. /// /// This type is designed to be stored in Salsa databases as it contains /// the thread-safe `GreenNode` instead of the non-thread-safe `SyntaxNode`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Parse { green: GreenNode, errors: Vec, positioned_errors: Vec, _ty: PhantomData, } impl Parse { /// Create a new Parse result from a GreenNode and errors pub fn new(green: GreenNode, errors: Vec) -> Self { Parse { green, errors, positioned_errors: Vec::new(), _ty: PhantomData, } } /// Create a new Parse result from a GreenNode, errors, and positioned errors pub fn new_with_positioned_errors( green: GreenNode, errors: Vec, positioned_errors: Vec, ) -> Self { Parse { green, errors, positioned_errors, _ty: PhantomData, } } /// Get the green node (thread-safe representation) pub fn green(&self) -> &GreenNode { &self.green } /// Get the syntax errors pub fn errors(&self) -> &[String] { &self.errors } /// Get parse errors with position information pub fn positioned_errors(&self) -> &[PositionedParseError] { &self.positioned_errors } /// Get parse errors as strings (for backward compatibility if needed) pub fn error_messages(&self) -> Vec { self.positioned_errors .iter() .map(|e| e.message.clone()) .collect() } /// Check if there are any errors pub fn ok(&self) -> bool { self.errors.is_empty() } /// Convert to a Result, returning the tree if there are no errors pub fn to_result(self) -> Result where T: AstNode, { if self.errors.is_empty() { let node = SyntaxNode::new_root_mut(self.green); Ok(T::cast(node).expect("root node has wrong type")) } else { Err(ParseError(self.errors)) } } /// Get the parsed syntax tree, panicking if there are errors pub fn tree(&self) -> T where T: AstNode, { assert!( self.errors.is_empty(), "tried to get tree with errors: {:?}", self.errors ); let node = SyntaxNode::new_root_mut(self.green.clone()); T::cast(node).expect("root node has wrong type") } /// Get the syntax node pub fn syntax_node(&self) -> SyntaxNode { SyntaxNode::new_root_mut(self.green.clone()) } } // Implement Send + Sync since GreenNode is thread-safe unsafe impl Send for Parse {} unsafe impl Sync for Parse {} impl Parse { /// Parse INI/.desktop text, returning a Parse result pub fn parse_desktop(text: &str) -> Self { let parsed = crate::desktop::parse(text); Parse::new_with_positioned_errors( parsed.green_node, parsed.errors, parsed.positioned_errors, ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_success() { let input = "[Desktop Entry]\nName=Example\n"; let parsed = Parse::::parse_desktop(input); assert!(parsed.ok()); assert!(parsed.errors().is_empty()); let desktop = parsed.tree(); assert_eq!(desktop.groups().count(), 1); } #[test] fn test_parse_with_errors() { let input = "Invalid line without section\n[Desktop Entry]\n"; let parsed = Parse::::parse_desktop(input); assert!(!parsed.ok()); assert!(!parsed.errors().is_empty()); } }