// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Just enough of a desktop entry file to launch what it describes in a //! terminal: its name, for the window's title, and its `Path`, the //! directory it runs in. //! //! The format is the Desktop Entry Specification's: `[Group]` headers, //! `Key=Value` lines, comments starting with `#`, and the `[Desktop Entry]` //! group first. Localized keys (`Name[fr]`) are skipped: the unlocalized //! one is what every entry has. const std = @import("std"); const Allocator = std.mem.Allocator; pub const Entry = struct { name: ?[]const u8 = null, path: ?[]const u8 = null, }; pub const ParseError = error{ /// Not a desktop entry: no `[Desktop Entry]` group, or one that is not /// first, or a line that is neither a group, a key nor a comment. NotDesktopEntry, } || Allocator.Error; /// The entry's fields, allocated from `arena`. pub fn parse(arena: Allocator, text: []const u8) ParseError!Entry { var entry: Entry = .{}; var group: ?[]const u8 = null; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |raw| { const line = std.mem.trimEnd(u8, raw, "\r"); if (line.len == 0 or line[0] == '#') continue; if (line[0] == '[') { if (line[line.len - 1] != ']') return error.NotDesktopEntry; const name = line[1 .. line.len - 1]; // The first group must be this one. if (group == null and !std.mem.eql(u8, name, "Desktop Entry")) return error.NotDesktopEntry; group = name; continue; } const g = group orelse return error.NotDesktopEntry; const eq = std.mem.findScalar(u8, line, '=') orelse return error.NotDesktopEntry; if (!std.mem.eql(u8, g, "Desktop Entry")) continue; const key = std.mem.trim(u8, line[0..eq], " "); const value = std.mem.trim(u8, line[eq + 1 ..], " "); if (std.mem.eql(u8, key, "Name")) { entry.name = try unescape(arena, value); } else if (std.mem.eql(u8, key, "Path")) { entry.path = try unescape(arena, value); } } if (group == null) return error.NotDesktopEntry; return entry; } /// A `string` value's escapes: `\s`, `\n`, `\t`, `\r` and `\\`. fn unescape(arena: Allocator, value: []const u8) Allocator.Error![]const u8 { if (std.mem.findScalar(u8, value, '\\') == null) return arena.dupe(u8, value); var out: std.ArrayList(u8) = try .initCapacity(arena, value.len); var i: usize = 0; while (i < value.len) : (i += 1) { if (value[i] != '\\' or i + 1 == value.len) { out.appendAssumeCapacity(value[i]); continue; } i += 1; out.appendAssumeCapacity(switch (value[i]) { 's' => ' ', 'n' => '\n', 't' => '\t', 'r' => '\r', else => |c| c, }); } return out.items; } const testing = std.testing; test "the name and the directory, from the spec's own example" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const e = try parse(arena.allocator(), \\[Desktop Entry] \\Version=1.0 \\Type=Application \\Terminal=true \\Name=Editor \\Name[sv]=Redigerare \\Comment=A terminal text editor \\Exec=xdg-editor %f \\Path=/opt/xdg/editor \\ \\[Desktop Action new] \\Name=New \\ ); try testing.expectEqualStrings("Editor", e.name.?); try testing.expectEqualStrings("/opt/xdg/editor", e.path.?); } test "comments first, escapes, and CRLF" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const e = try parse(arena.allocator(), "# made by hand\r\n[Desktop Entry]\r\nName=Two\\sWords\r\n"); try testing.expectEqualStrings("Two Words", e.name.?); try testing.expect(e.path == null); } test "what is not a desktop entry" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); try testing.expectError(error.NotDesktopEntry, parse(a, "")); try testing.expectError(error.NotDesktopEntry, parse(a, "Name=x\n")); try testing.expectError(error.NotDesktopEntry, parse(a, "[Other]\nName=x\n[Desktop Entry]\n")); try testing.expectError(error.NotDesktopEntry, parse(a, "\x7fELF\x02\x01")); }