aboutsummaryrefslogtreecommitdiff
path: root/src/xdg_pipe_menu_desktop_entry.erl
diff options
context:
space:
mode:
Diffstat (limited to 'src/xdg_pipe_menu_desktop_entry.erl')
-rw-r--r--src/xdg_pipe_menu_desktop_entry.erl141
1 files changed, 141 insertions, 0 deletions
diff --git a/src/xdg_pipe_menu_desktop_entry.erl b/src/xdg_pipe_menu_desktop_entry.erl
new file mode 100644
index 0000000..62adaf1
--- /dev/null
+++ b/src/xdg_pipe_menu_desktop_entry.erl
@@ -0,0 +1,141 @@
+%%%-------------------------------------------------------------------
+%% @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' and `Categories'. Other groups (e.g.
+%% `[Desktop Action ...]') and localized keys (e.g. `Name[fr]') are
+%% ignored.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(xdg_pipe_menu_desktop_entry).
+
+-include("xdg_pipe_menu.hrl").
+
+-export([parse_file/1, parse/1]).
+
+-define(GROUP, <<"Desktop Entry">>).
+-define(WANTED_KEYS, [
+ <<"Name">>, <<"Exec">>, <<"Icon">>, <<"Categories">>, <<"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),
+ categories = parse_categories(maps:get(<<"Categories">>, Fields, undefined)),
+ hidden = is_hidden(Fields)
+ }}
+ end
+ end.
+
+%% `Categories' is a `;'-separated list, conventionally with a
+%% trailing separator (e.g. `Utility;Development;'); empty segments
+%% from that trailing separator (or repeated ones) are dropped.
+parse_categories(undefined) ->
+ [];
+parse_categories(Bin) ->
+ [C || C <- binary:split(Bin, <<";">>, [global]), C =/= <<>>].
+
+%% 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.