aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-20 22:48:29 -0400
committerChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-20 22:48:29 -0400
commit13491ac0c68e24e6aeb5d47e8b89777979ad6320 (patch)
treedb06703461321b95c08a2979c26a564cec6700cd
parent810cb6fdd86452b75e520fc17daec40ba36eba13 (diff)
Add .desktop file parser
Parses the [Desktop Entry] group of a .desktop file into a #desktop_entry{} record with name, exec, icon, and a derived hidden flag (set by NoDisplay, Hidden, or a non-Application Type). Exec field codes (%f, %u, etc.) are stripped since a pipemenu has no file/URI context to expand them with. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--include/er_xdg_pipe_menu.hrl6
-rw-r--r--src/er_xdg_pipe_menu_desktop_entry.erl131
-rw-r--r--test/er_xdg_pipe_menu_desktop_entry_tests.erl158
3 files changed, 295 insertions, 0 deletions
diff --git a/include/er_xdg_pipe_menu.hrl b/include/er_xdg_pipe_menu.hrl
new file mode 100644
index 0000000..2cee607
--- /dev/null
+++ b/include/er_xdg_pipe_menu.hrl
@@ -0,0 +1,6 @@
+-record(desktop_entry, {
+ name :: binary(),
+ exec :: binary(),
+ icon :: binary() | undefined,
+ hidden :: boolean()
+}).
diff --git a/src/er_xdg_pipe_menu_desktop_entry.erl b/src/er_xdg_pipe_menu_desktop_entry.erl
new file mode 100644
index 0000000..bbd2594
--- /dev/null
+++ b/src/er_xdg_pipe_menu_desktop_entry.erl
@@ -0,0 +1,131 @@
+%%%-------------------------------------------------------------------
+%% @doc Parser for XDG .desktop files.
+%%
+%% Reads the `[Desktop Entry]' group of a desktop entry file and
+%% extracts the fields needed to build a menu item: `Name', `Exec'
+%% and (optionally) `Icon'. Other groups (e.g. `[Desktop Action ...]')
+%% and localized keys (e.g. `Name[fr]') are ignored.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(er_xdg_pipe_menu_desktop_entry).
+
+-include("er_xdg_pipe_menu.hrl").
+
+-export([parse_file/1, parse/1]).
+
+-define(GROUP, <<"Desktop Entry">>).
+-define(WANTED_KEYS, [
+ <<"Name">>, <<"Exec">>, <<"Icon">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">>
+]).
+-define(FIELD_CODES, "fFuUdDnNickvm").
+
+-spec parse_file(file:filename_all()) -> {ok, #desktop_entry{}} | {error, term()}.
+parse_file(Path) ->
+ case file:read_file(Path) of
+ {ok, Bin} -> parse(Bin);
+ {error, Reason} -> {error, Reason}
+ end.
+
+-spec parse(binary()) -> {ok, #desktop_entry{}} | {error, missing_name | missing_exec}.
+parse(Bin) ->
+ to_entry(scan(Bin)).
+
+%% Fold every line into a map of the fields we care about, tracking
+%% which `[Group]' we're currently inside.
+scan(Bin) ->
+ Lines = binary:split(Bin, [<<"\r\n">>, <<"\n">>], [global]),
+ {_Group, Fields} = lists:foldl(fun scan_line/2, {undefined, #{}}, Lines),
+ Fields.
+
+scan_line(Line, {Group, Fields}) ->
+ case classify(string:trim(Line)) of
+ skip ->
+ {Group, Fields};
+ {section, Name} ->
+ {Name, Fields};
+ {kv, Key, Value} ->
+ case Group =:= ?GROUP andalso lists:member(Key, ?WANTED_KEYS) of
+ true -> {Group, maps:put(Key, unescape(Value), Fields)};
+ false -> {Group, Fields}
+ end
+ end.
+
+classify(<<>>) ->
+ skip;
+classify(<<"#", _/binary>>) ->
+ skip;
+classify(<<"[", Rest/binary>>) ->
+ Size = byte_size(Rest) - 1,
+ case Rest of
+ <<Name:Size/binary, "]">> -> {section, string:trim(Name)};
+ _ -> skip
+ end;
+classify(Line) ->
+ case binary:split(Line, <<"=">>) of
+ [Key, Value] -> {kv, string:trim(Key), string:trim(Value)};
+ [_] -> skip
+ end.
+
+%% Desktop entry escape sequences: \\ \s \n \t \r
+unescape(Bin) ->
+ unescape(Bin, <<>>).
+
+unescape(<<>>, Acc) ->
+ Acc;
+unescape(<<$\\, $\\, Rest/binary>>, Acc) ->
+ unescape(Rest, <<Acc/binary, $\\>>);
+unescape(<<$\\, $s, Rest/binary>>, Acc) ->
+ unescape(Rest, <<Acc/binary, $\s>>);
+unescape(<<$\\, $n, Rest/binary>>, Acc) ->
+ unescape(Rest, <<Acc/binary, $\n>>);
+unescape(<<$\\, $t, Rest/binary>>, Acc) ->
+ unescape(Rest, <<Acc/binary, $\t>>);
+unescape(<<$\\, $r, Rest/binary>>, Acc) ->
+ unescape(Rest, <<Acc/binary, $\r>>);
+unescape(<<C, Rest/binary>>, Acc) ->
+ unescape(Rest, <<Acc/binary, C>>).
+
+to_entry(Fields) ->
+ case maps:find(<<"Name">>, Fields) of
+ error ->
+ {error, missing_name};
+ {ok, Name} ->
+ case maps:find(<<"Exec">>, Fields) of
+ error ->
+ {error, missing_exec};
+ {ok, Exec} ->
+ {ok, #desktop_entry{
+ name = Name,
+ exec = strip_field_codes(Exec),
+ icon = maps:get(<<"Icon">>, Fields, undefined),
+ hidden = is_hidden(Fields)
+ }}
+ end
+ end.
+
+%% Niche variations (e.g. a Type-less override dropped into
+%% ~/.local/share/applications) are left to be special-cased there
+%% rather than accounted for here.
+is_hidden(Fields) ->
+ maps:get(<<"NoDisplay">>, Fields, <<"false">>) =:= <<"true">> orelse
+ maps:get(<<"Hidden">>, Fields, <<"false">>) =:= <<"true">> orelse
+ case maps:find(<<"Type">>, Fields) of
+ {ok, Type} -> Type =/= <<"Application">>;
+ error -> false
+ end.
+
+%% Field codes (%f, %u, %i, ...) are expanded by launchers that pass
+%% files/URIs on the command line; a pipemenu has none to pass, so
+%% each code is dropped along with the whitespace around it. `%%' is
+%% then unescaped to a literal `%'.
+strip_field_codes(Exec) ->
+ Tokens = binary:split(Exec, <<" ">>, [global]),
+ Kept = [T || T <- Tokens, T =/= <<>>, not is_field_code(T)],
+ Joined = iolist_to_binary(lists:join(<<" ">>, Kept)),
+ binary:replace(Joined, <<"%%">>, <<"%">>, [global]).
+
+is_field_code(<<"%", C>>) ->
+ lists:member(C, ?FIELD_CODES);
+is_field_code(_) ->
+ false.
diff --git a/test/er_xdg_pipe_menu_desktop_entry_tests.erl b/test/er_xdg_pipe_menu_desktop_entry_tests.erl
new file mode 100644
index 0000000..399e3a5
--- /dev/null
+++ b/test/er_xdg_pipe_menu_desktop_entry_tests.erl
@@ -0,0 +1,158 @@
+-module(er_xdg_pipe_menu_desktop_entry_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+-include("er_xdg_pipe_menu.hrl").
+
+basic_entry_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Type=Application\n"
+ "Name=Firefox\n"
+ "Exec=firefox %u\n"
+ "Icon=firefox\n"
+ >>,
+ ?assertEqual(
+ {ok, #desktop_entry{
+ name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, hidden = false
+ }},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+icon_is_optional_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo\n"
+ >>,
+ ?assertEqual(
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+missing_name_test() ->
+ Bin = <<"[Desktop Entry]\nExec=foo\n">>,
+ ?assertEqual({error, missing_name}, er_xdg_pipe_menu_desktop_entry:parse(Bin)).
+
+missing_exec_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\n">>,
+ ?assertEqual({error, missing_exec}, er_xdg_pipe_menu_desktop_entry:parse(Bin)).
+
+ignores_other_groups_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo\n"
+ "\n"
+ "[Desktop Action new-window]\n"
+ "Name=New Window\n"
+ "Exec=foo --new-window\n"
+ >>,
+ ?assertEqual(
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+ignores_comments_and_blank_lines_test() ->
+ Bin = <<
+ "# a comment\n"
+ "\n"
+ "[Desktop Entry]\n"
+ "# another comment\n"
+ "Name=Foo\n"
+ "\n"
+ "Exec=foo\n"
+ >>,
+ ?assertEqual(
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+strips_multiple_field_codes_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo %f --flag %i %c\n"
+ >>,
+ ?assertEqual(
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, hidden = false}},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+literal_percent_is_unescaped_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo --progress=%%\n"
+ >>,
+ ?assertEqual(
+ {ok, #desktop_entry{
+ name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, hidden = false
+ }},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+unescapes_value_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\\sBar\nExec=foo\n">>,
+ ?assertEqual(
+ {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ er_xdg_pipe_menu_desktop_entry:parse(Bin)
+ ).
+
+no_display_is_hidden_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nNoDisplay=true\n">>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual(true, Entry#desktop_entry.hidden).
+
+hidden_key_is_hidden_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nHidden=true\n">>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual(true, Entry#desktop_entry.hidden).
+
+non_application_type_is_hidden_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nType=Link\n">>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual(true, Entry#desktop_entry.hidden).
+
+application_type_is_not_hidden_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nType=Application\n">>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual(false, Entry#desktop_entry.hidden).
+
+missing_type_is_not_hidden_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual(false, Entry#desktop_entry.hidden).
+
+false_values_are_not_hidden_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo\n"
+ "NoDisplay=false\n"
+ "Hidden=false\n"
+ "Type=Application\n"
+ >>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual(false, Entry#desktop_entry.hidden).
+
+parse_file_test() ->
+ Path = filename:join(test_tmp_dir(), "eunit-desktop-entry-test.desktop"),
+ ok = file:write_file(Path, <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>),
+ Result = er_xdg_pipe_menu_desktop_entry:parse_file(Path),
+ ok = file:delete(Path),
+ ?assertEqual(
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ Result
+ ).
+
+parse_file_missing_test() ->
+ ?assertEqual(
+ {error, enoent},
+ er_xdg_pipe_menu_desktop_entry:parse_file("/nonexistent/does-not-exist.desktop")
+ ).
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.