A structured-data shell in Gleam, inspired by Nushell
1%% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes.
2-module(gleshell_ffi).
3-export([
4 get_line/1,
5 parse_line/2,
6 run_as_shell/1,
7 spawn_shell/2,
8 set_cwd/1,
9 get_cwd/0,
10 getenv/1,
11 setenv/2,
12 run_cmd/2,
13 which/1,
14 home_dir/0,
15 stdout_isatty/0
16]).
17
18%% Read a line with edlin history support.
19%%
20%% Use get_until (not get_line): since OTP 26, io:get_line/1 input is not
21%% reliably saved in the shell history buffer; get_until is. See OTP #6896
22%% and the custom-shell guide.
23-spec get_line(binary()) -> {ok, binary()} | {error, binary()}.
24get_line(Prompt) when is_binary(Prompt) ->
25 PromptChars = unicode:characters_to_list(Prompt),
26 case io:request(
27 standard_io,
28 {get_until, unicode, PromptChars, ?MODULE, parse_line, []}
29 ) of
30 eof ->
31 {error, <<"eof">>};
32 {error, _} ->
33 {error, <<"io_error">>};
34 Line when is_list(Line); is_binary(Line) ->
35 Bin = unicode:characters_to_binary(Line),
36 Stripped = string:trim(Bin, trailing, [$\n, $\r]),
37 {ok, Stripped};
38 Other ->
39 %% Unexpected shape from a custom/get_until callback.
40 try
41 Bin = unicode:characters_to_binary(Other),
42 Stripped = string:trim(Bin, trailing, [$\n, $\r]),
43 {ok, Stripped}
44 catch
45 _:_ ->
46 {error, <<"io_error">>}
47 end
48 end.
49
50%% get_until callback: edlin already gathers a full line (with editing /
51%% history navigation); accept it as done. Cont starts as [].
52-spec parse_line(term(), term()) ->
53 {done, eof | string(), list()} | {more, term()}.
54parse_line(_Cont, eof) ->
55 {done, eof, []};
56parse_line(_Cont, Chars) when is_list(Chars) ->
57 {done, Chars, []}.
58
59%% Run Fun as the OTP interactive shell process so edlin line editing
60%% works: up/down history, Ctrl+R reverse-i-search, word kill, etc.
61%% Gleam starts the VM with -noshell, so without this we only get dumb
62%% line input and no reverse search.
63-spec run_as_shell(fun(() -> term())) -> nil.
64run_as_shell(Fun) when is_function(Fun, 0) ->
65 enable_shell_history(),
66 Parent = self(),
67 case try_start_interactive(Parent, Fun) of
68 {ok, started} ->
69 receive
70 {gleshell_shell_done, ok} ->
71 nil;
72 {gleshell_shell_done, {error, Class, Reason, Stack}} ->
73 erlang:raise(Class, Reason, Stack)
74 end;
75 {ok, direct} ->
76 configure_line_editor(),
77 Fun(),
78 nil
79 end.
80
81try_start_interactive(Parent, Fun) ->
82 %% Empty slogan so we do not print the Erlang system banner.
83 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]),
84 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of
85 ok ->
86 {ok, started};
87 {error, already_started} ->
88 {ok, direct};
89 {error, _} ->
90 {ok, direct}
91 end.
92
93%% MFA entry for user_drv/group: must return the shell pid. Spawned
94%% under the group so group_leader is the edlin-enabled group.
95-spec spawn_shell(pid(), fun(() -> term())) -> pid().
96spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) ->
97 spawn(fun() ->
98 try
99 configure_line_editor(),
100 Fun()
101 of
102 _ ->
103 Parent ! {gleshell_shell_done, ok},
104 %% Intentional exit reason so user_drv does not print
105 %% "Shell process terminated!" and restart us.
106 exit(die)
107 catch
108 Class:Reason:Stack ->
109 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}},
110 erlang:raise(Class, Reason, Stack)
111 end
112 end).
113
114%% Best-effort: unicode IO + save get_until lines into edlin history.
115configure_line_editor() ->
116 _ = io:setopts([{encoding, unicode}, binary]),
117 try
118 io:setopts([{line_history, true}])
119 catch
120 _:_ ->
121 ok
122 end,
123 ok.
124
125enable_shell_history() ->
126 case application:get_env(kernel, shell_history_path) of
127 {ok, _} ->
128 ok;
129 undefined ->
130 Path = filename:basedir(user_cache, "gleshell-history"),
131 _ = application:set_env(
132 kernel, shell_history_path, Path, [{persistent, true}]
133 ),
134 ok
135 end,
136 case application:get_env(kernel, shell_history) of
137 {ok, _} ->
138 ok;
139 undefined ->
140 _ = application:set_env(
141 kernel, shell_history, enabled, [{persistent, true}]
142 ),
143 ok
144 end.
145
146-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}.
147set_cwd(Path) when is_binary(Path) ->
148 case file:set_cwd(unicode:characters_to_list(Path)) of
149 ok ->
150 {ok, nil};
151 {error, Reason} ->
152 {error, reason_to_bin(Reason)}
153 end.
154
155-spec get_cwd() -> {ok, binary()} | {error, binary()}.
156get_cwd() ->
157 case file:get_cwd() of
158 {ok, Dir} ->
159 {ok, unicode:characters_to_binary(Dir)};
160 {error, Reason} ->
161 {error, reason_to_bin(Reason)}
162 end.
163
164-spec getenv(binary()) -> {ok, binary()} | {error, nil}.
165getenv(Name) when is_binary(Name) ->
166 case os:getenv(unicode:characters_to_list(Name)) of
167 false ->
168 {error, nil};
169 Value ->
170 {ok, unicode:characters_to_binary(Value)}
171 end.
172
173-spec setenv(binary(), binary()) -> {ok, nil}.
174setenv(Name, Value) when is_binary(Name), is_binary(Value) ->
175 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)),
176 {ok, nil}.
177
178-spec which(binary()) -> {ok, binary()} | {error, nil}.
179which(Command) when is_binary(Command) ->
180 case os:find_executable(unicode:characters_to_list(Command)) of
181 false ->
182 {error, nil};
183 Path ->
184 {ok, unicode:characters_to_binary(Path)}
185 end.
186
187-spec home_dir() -> {ok, binary()} | {error, binary()}.
188home_dir() ->
189 case os:getenv("HOME") of
190 false ->
191 {error, <<"HOME not set">>};
192 Home ->
193 {ok, unicode:characters_to_binary(Home)}
194 end.
195
196%% True when stdout is a terminal (colors are useful).
197-spec stdout_isatty() -> boolean().
198stdout_isatty() ->
199 case io:columns() of
200 {ok, _} ->
201 true;
202 _ ->
203 try
204 case prim_tty:isatty(stdout) of
205 true -> true;
206 _ -> false
207 end
208 catch
209 _:_ ->
210 false
211 end
212 end.
213
214%% Run an executable with args; capture stdout+stderr and exit status.
215%% Returns {ok, {Status :: integer(), Output :: binary()}} | {error, binary()}.
216-spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}.
217run_cmd(Command, Args) when is_binary(Command), is_list(Args) ->
218 case os:find_executable(unicode:characters_to_list(Command)) of
219 false ->
220 {error, <<"command not found: ", Command/binary>>};
221 Path ->
222 PortArgs = [unicode:characters_to_list(A) || A <- Args],
223 try
224 Port = open_port(
225 {spawn_executable, Path},
226 [
227 binary,
228 exit_status,
229 stderr_to_stdout,
230 use_stdio,
231 stream,
232 {args, PortArgs}
233 ]
234 ),
235 collect_output(Port, <<>>)
236 catch
237 _:Reason ->
238 {error, reason_to_bin(Reason)}
239 end
240 end.
241
242collect_output(Port, Acc) ->
243 receive
244 {Port, {data, Data}} when is_binary(Data) ->
245 collect_output(Port, <<Acc/binary, Data/binary>>);
246 {Port, {data, Data}} when is_list(Data) ->
247 Bin = unicode:characters_to_binary(Data),
248 collect_output(Port, <<Acc/binary, Bin/binary>>);
249 {Port, {exit_status, Status}} ->
250 {ok, {Status, Acc}}
251 after 120_000 ->
252 catch port_close(Port),
253 {error, <<"command timed out after 120s">>}
254 end.
255
256reason_to_bin(Reason) when is_atom(Reason) ->
257 atom_to_binary(Reason, utf8);
258reason_to_bin(Reason) when is_binary(Reason) ->
259 Reason;
260reason_to_bin(Reason) ->
261 iolist_to_binary(io_lib:format("~p", [Reason])).