A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

gleshell / src / gleshell_ffi.erl
8.5 kB 261 lines
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])).