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
75 kB 2307 lines
1%% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes. 2-module(gleshell_ffi). 3-include_lib("kernel/include/file.hrl"). 4-export([ 5 get_line/1, 6 parse_line/2, 7 run_as_shell/1, 8 spawn_shell/2, 9 set_cwd/1, 10 get_cwd/0, 11 getenv/1, 12 setenv/2, 13 list_env/0, 14 run_cmd/3, 15 run_cmd_tty/3, 16 which/1, 17 which_all/1, 18 home_dir/0, 19 stdout_isatty/0, 20 println/1, 21 take_output_shown/0, 22 clear_output_shown/0, 23 complete_word/2, 24 re_contains/3 25]). 26 27-define(ESC, 16#1b). 28-define(CSI_CLEAR_EOL, "\e[K"). 29-define(HISTORY_MAX, 2000). 30 31%% --------------------------------------------------------------------------- 32%% Public: write a line (CRLF in raw TTY mode so multi-line output does not 33%% staircase — raw mode does not map LF → CR+LF the way cooked mode does). 34%% --------------------------------------------------------------------------- 35 36-spec println(binary()) -> nil. 37println(Text) when is_binary(Text) -> 38 case get(gleshell_raw) of 39 true -> 40 io:put_chars([to_crlf(Text), <<"\r\n">>]), 41 nil; 42 _ -> 43 io:put_chars([Text, $\n]), 44 nil 45 end. 46 47%% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 48to_crlf(Bin) when is_binary(Bin) -> 49 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), 50 B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]), 51 binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]). 52 53%% --------------------------------------------------------------------------- 54%% Public: read a line (syntax-highlighted when raw TTY mode is active) 55%% --------------------------------------------------------------------------- 56 57-spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 58get_line(Prompt) when is_binary(Prompt) -> 59 case get(gleshell_raw) of 60 true -> 61 raw_get_line(Prompt); 62 _ -> 63 classic_get_line(Prompt) 64 end. 65 66%% Edlin / get_until path (non-TTY or when raw mode unavailable). 67classic_get_line(Prompt) -> 68 PromptChars = unicode:characters_to_list(Prompt), 69 case io:request( 70 standard_io, 71 {get_until, unicode, PromptChars, ?MODULE, parse_line, []} 72 ) of 73 eof -> 74 {error, <<"eof">>}; 75 {error, interrupted} -> 76 %% Ctrl+C while reading: cancel line, keep the REPL alive. 77 {error, <<"interrupted">>}; 78 {error, _} -> 79 {error, <<"io_error">>}; 80 Line when is_list(Line); is_binary(Line) -> 81 Bin = unicode:characters_to_binary(Line), 82 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 83 {ok, Stripped}; 84 Other -> 85 try 86 Bin = unicode:characters_to_binary(Other), 87 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 88 {ok, Stripped} 89 catch 90 _:_ -> 91 {error, <<"io_error">>} 92 end 93 end. 94 95%% get_until callback: edlin already gathers a full line. 96-spec parse_line(term(), term()) -> 97 {done, eof | string(), list()} | {more, term()}. 98parse_line(_Cont, eof) -> 99 {done, eof, []}; 100parse_line(_Cont, Chars) when is_list(Chars) -> 101 {done, Chars, []}. 102 103%% --------------------------------------------------------------------------- 104%% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. 105%% Falls back to edlin interactive shell when raw is unavailable. 106%% 107%% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit). 108%% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor 109%% (cancel line) instead of killing the shell. See erl(1) +B. 110%% --------------------------------------------------------------------------- 111 112-spec run_as_shell(fun(() -> term())) -> nil. 113run_as_shell(Fun) when is_function(Fun, 0) -> 114 case ensure_plus_bc() of 115 {parent, Port} -> 116 %% Child owns the TTY; we only wait for its exit status. 117 receive 118 {Port, {exit_status, Status}} -> 119 erlang:halt(Status) 120 end; 121 child -> 122 enable_shell_history(), 123 case try_start_raw() of 124 true -> 125 put(gleshell_raw, true), 126 load_line_history(), 127 configure_line_editor(), 128 try 129 Fun() 130 after 131 save_line_history() 132 end, 133 nil; 134 false -> 135 erase(gleshell_raw), 136 Parent = self(), 137 case try_start_interactive(Parent, Fun) of 138 {ok, started} -> 139 receive 140 {gleshell_shell_done, ok} -> 141 nil; 142 {gleshell_shell_done, {error, Class, Reason, Stack}} -> 143 erlang:raise(Class, Reason, Stack) 144 end; 145 {ok, direct} -> 146 configure_line_editor(), 147 Fun(), 148 nil 149 end 150 end 151 end. 152 153%% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort). 154%% Returns `child` when this process should run the REPL, or `{parent, Port}` 155%% when we re-exec'd and should wait on the child. 156-spec ensure_plus_bc() -> child | {parent, port()}. 157ensure_plus_bc() -> 158 case os:getenv("GLESHELL_PLUS_BC") of 159 "1" -> 160 child; 161 _ -> 162 case already_has_plus_bc() of 163 true -> 164 os:putenv("GLESHELL_PLUS_BC", "1"), 165 child; 166 false -> 167 reexec_with_plus_bc() 168 end 169 end. 170 171already_has_plus_bc() -> 172 lists:any( 173 fun(Var) -> 174 case os:getenv(Var) of 175 false -> 176 false; 177 Flags -> 178 string:find(Flags, "+Bc") =/= nomatch 179 end 180 end, 181 ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"] 182 ). 183 184reexec_with_plus_bc() -> 185 os:putenv("GLESHELL_PLUS_BC", "1"), 186 case os:getenv("ERL_AFLAGS") of 187 false -> 188 os:putenv("ERL_AFLAGS", "+Bc"); 189 Flags -> 190 case string:find(Flags, "+Bc") of 191 nomatch -> 192 os:putenv("ERL_AFLAGS", "+Bc " ++ Flags); 193 _ -> 194 ok 195 end 196 end, 197 case os:find_executable("erl") of 198 false -> 199 %% No erl on PATH — continue without +Bc (BREAK menu may appear). 200 child; 201 Erl -> 202 Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()), 203 Extra = init:get_plain_arguments(), 204 Args = 205 ["+Bc", "-noshell"] ++ 206 Pa ++ 207 ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra], 208 try 209 Port = open_port( 210 {spawn_executable, Erl}, 211 [exit_status, nouse_stdio, {args, Args}] 212 ), 213 {parent, Port} 214 catch 215 _:_ -> 216 child 217 end 218 end. 219 220try_start_raw() -> 221 case stdout_isatty() of 222 false -> 223 false; 224 true -> 225 case catch shell:start_interactive({noshell, raw}) of 226 ok -> 227 true; 228 {error, already_started} -> 229 %% Cannot switch an existing interactive shell into raw. 230 false; 231 _ -> 232 false 233 end 234 end. 235 236try_start_interactive(Parent, Fun) -> 237 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 238 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 239 ok -> 240 {ok, started}; 241 {error, already_started} -> 242 {ok, direct}; 243 {error, _} -> 244 {ok, direct} 245 end. 246 247-spec spawn_shell(pid(), fun(() -> term())) -> pid(). 248spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 249 spawn(fun() -> 250 try 251 configure_line_editor(), 252 Fun() 253 of 254 _ -> 255 Parent ! {gleshell_shell_done, ok}, 256 exit(die) 257 catch 258 Class:Reason:Stack -> 259 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, 260 erlang:raise(Class, Reason, Stack) 261 end 262 end). 263 264configure_line_editor() -> 265 _ = io:setopts([{encoding, unicode}, binary]), 266 try 267 io:setopts([{line_history, true}]) 268 catch 269 _:_ -> 270 ok 271 end, 272 ok. 273 274enable_shell_history() -> 275 case application:get_env(kernel, shell_history_path) of 276 {ok, _} -> 277 ok; 278 undefined -> 279 Path = filename:basedir(user_cache, "gleshell-history"), 280 _ = application:set_env( 281 kernel, shell_history_path, Path, [{persistent, true}] 282 ), 283 ok 284 end, 285 case application:get_env(kernel, shell_history) of 286 {ok, _} -> 287 ok; 288 undefined -> 289 _ = application:set_env( 290 kernel, shell_history, enabled, [{persistent, true}] 291 ), 292 ok 293 end. 294 295%% --------------------------------------------------------------------------- 296%% Raw-mode line editor with Nushell-style syntax highlighting 297%% --------------------------------------------------------------------------- 298%% 299%% Buffer model: Left is graphemes before the cursor (reversed), 300%% Right is graphemes after the cursor (normal order). 301%% History is a list of binaries (newest first). 302 303raw_get_line(Prompt) when is_binary(Prompt) -> 304 PromptList = unicode:characters_to_list(Prompt), 305 History = case get(gleshell_history) of 306 L when is_list(L) -> L; 307 _ -> [] 308 end, 309 redraw(PromptList, [], []), 310 raw_loop(PromptList, [], [], History, 0, <<>>). 311 312%% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry. 313%% Saved: buffer saved when first entering history navigation. 314raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> 315 case read_key() of 316 eof -> 317 io:put_chars("\r\n"), 318 {error, <<"eof">>}; 319 {error, _} -> 320 io:put_chars("\r\n"), 321 {error, <<"io_error">>}; 322 enter -> 323 Line = buffer_to_bin(Left, Right), 324 io:put_chars("\r\n"), 325 push_history(Line), 326 {ok, Line}; 327 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 328 %% Printable Unicode codepoint 329 NewLeft = [C | Left], 330 redraw(Prompt, NewLeft, Right), 331 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 332 backspace -> 333 case Left of 334 [] -> 335 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 336 [_ | Rest] -> 337 redraw(Prompt, Rest, Right), 338 raw_loop(Prompt, Rest, Right, History, 0, <<>>) 339 end; 340 delete -> 341 case Right of 342 [] -> 343 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 344 [_ | Rest] -> 345 redraw(Prompt, Left, Rest), 346 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 347 end; 348 left -> 349 case Left of 350 [] -> 351 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 352 [C | Rest] -> 353 redraw(Prompt, Rest, [C | Right]), 354 raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved) 355 end; 356 right -> 357 case Right of 358 [] -> 359 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 360 [C | Rest] -> 361 redraw(Prompt, [C | Left], Rest), 362 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 363 end; 364 home -> 365 NewRight = lists:reverse(Left) ++ Right, 366 redraw(Prompt, [], NewRight), 367 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 368 'end' -> 369 NewLeft = lists:reverse(Right) ++ Left, 370 redraw(Prompt, NewLeft, []), 371 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 372 up -> 373 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 374 down -> 375 hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1); 376 ctrl_a -> 377 NewRight = lists:reverse(Left) ++ Right, 378 redraw(Prompt, [], NewRight), 379 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 380 ctrl_e -> 381 NewLeft = lists:reverse(Right) ++ Left, 382 redraw(Prompt, NewLeft, []), 383 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 384 ctrl_u -> 385 redraw(Prompt, [], Right), 386 raw_loop(Prompt, [], Right, History, 0, <<>>); 387 ctrl_k -> 388 redraw(Prompt, Left, []), 389 raw_loop(Prompt, Left, [], History, 0, <<>>); 390 ctrl_w -> 391 {NewLeft, _} = kill_word(Left), 392 redraw(Prompt, NewLeft, Right), 393 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 394 ctrl_d -> 395 case {Left, Right} of 396 {[], []} -> 397 io:put_chars("\r\n"), 398 {error, <<"eof">>}; 399 {_, []} -> 400 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 401 {_, [_ | Rest]} -> 402 redraw(Prompt, Left, Rest), 403 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 404 end; 405 ctrl_c -> 406 %% Cancel current line (like bash) and return empty. 407 io:put_chars("^C\r\n"), 408 {ok, <<>>}; 409 ctrl_l -> 410 io:put_chars("\e[H\e[2J"), 411 redraw(Prompt, Left, Right), 412 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 413 ctrl_r -> 414 reverse_search(Prompt, History); 415 tab -> 416 tab_complete(Prompt, Left, Right, History, HistPos, Saved); 417 _Other -> 418 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 419 end. 420 421%% --------------------------------------------------------------------------- 422%% Tab: command + filename completion (token under cursor) 423%% --------------------------------------------------------------------------- 424%% 425%% Command position (start of line / after | ; & =): complete builtins and 426%% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use 427%% filename completion. Elsewhere: filename completion as before. 428%% 429%% One match → insert it (commands get a trailing space; dirs get /). 430%% Several matches → extend the longest common prefix; if that does not 431%% advance the buffer, list candidates under the line and redraw. 432 433tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 434 {PrefixRev, Word} = word_before_cursor(Left), 435 {Matches, Kind} = completions_for(PrefixRev, Word), 436 case Matches of 437 [] -> 438 beep(), 439 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 440 [Only] -> 441 Insert = finalize_completion(Only, Kind), 442 NewLeft = apply_completed_word(PrefixRev, Insert), 443 redraw(Prompt, NewLeft, Right), 444 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 445 _ -> 446 Common = longest_common_prefix(Matches), 447 case Common =/= Word andalso length(Common) >= length(Word) of 448 true -> 449 NewLeft = apply_completed_word(PrefixRev, Common), 450 redraw(Prompt, NewLeft, Right), 451 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 452 false -> 453 show_completions(Matches), 454 redraw(Prompt, Left, Right), 455 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 456 end 457 end. 458 459%% Test/helper: return {Matches, Kind} for a buffer prefix and word. 460%% Prefix is the text *before* the word being completed (not reversed). 461%% Kind is <<"command">> | <<"path">>. 462-spec complete_word(binary(), binary()) -> {list(binary()), binary()}. 463complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) -> 464 Prefix = unicode:characters_to_list(PrefixBin), 465 Word = unicode:characters_to_list(WordBin), 466 PrefixRev = lists:reverse(Prefix), 467 {Matches, Kind} = completions_for(PrefixRev, Word), 468 KindBin = 469 case Kind of 470 command -> <<"command">>; 471 path -> <<"path">> 472 end, 473 { 474 [unicode:characters_to_binary(M) || M <- Matches], 475 KindBin 476 }. 477 478%% Trailing space after a unique command so the user can type args next. 479finalize_completion(Word, command) -> 480 case lists:last(Word) of 481 $/ -> Word; 482 $\s -> Word; 483 _ -> Word ++ " " 484 end; 485finalize_completion(Word, path) -> 486 Word. 487 488completions_for(PrefixRev, Word) -> 489 case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of 490 true -> 491 {command_completions(Word), command}; 492 false -> 493 {filename_completions(Word), path} 494 end. 495 496%% Command position: empty prefix, or last non-space before the word is a 497%% pipeline/statement separator or assignment (`let x = …`). 498is_command_position(PrefixRev) -> 499 Before = string:trim(lists:reverse(PrefixRev), trailing), 500 case Before of 501 [] -> 502 true; 503 _ -> 504 case lists:last(Before) of 505 $| -> true; 506 $; -> true; 507 $& -> true; 508 $= -> true; 509 _ -> false 510 end 511 end. 512 513%% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds. 514is_path_like_word([]) -> 515 false; 516is_path_like_word(Word) -> 517 lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~. 518 519%% Builtins + keywords + PATH executables matching Word as a prefix. 520command_completions(Word) -> 521 Builtins = [ 522 N 523 || N <- builtin_command_names(), 524 lists:prefix(Word, N) 525 ], 526 Keywords = [ 527 N 528 || N <- ["let"], 529 lists:prefix(Word, N) 530 ], 531 PathCmds = 532 case Word of 533 %% Empty prefix: skip PATH dump (can be thousands of names). 534 [] -> 535 []; 536 _ -> 537 path_command_completions(Word) 538 end, 539 lists:usort(Builtins ++ Keywords ++ PathCmds). 540 541%% Prefer live Gleam registry; fall back if the module is not loaded yet. 542builtin_command_names() -> 543 try 544 Names = 'gleshell@builtins':names(), 545 [to_charlist(N) || N <- Names] 546 catch 547 _:_ -> 548 fallback_builtin_names() 549 end. 550 551to_charlist(B) when is_binary(B) -> 552 unicode:characters_to_list(B); 553to_charlist(L) when is_list(L) -> 554 L. 555 556fallback_builtin_names() -> 557 [ 558 "append", "cat", "cd", "columns", "count", "describe", "echo", "env", 559 "exit", "filter", "find", "first", "flatten", "from", "get", "help", 560 "identity", "ignore", "is-empty", "is_empty", "keys", "last", "length", 561 "lines", "ls", "open", "prepend", "print", "pwd", "quit", "range", 562 "reverse", "save", "select", "skip", "sort-by", "sort_by", "sys", 563 "table", "take", "to", "type", "typeof", "uniq", "unwrap", 564 "values", "where", "which", "wrap" 565 ]. 566 567%% Executable basenames on PATH that match Prefix (deduped, sorted). 568path_command_completions(Prefix) -> 569 case os:getenv("PATH") of 570 false -> 571 []; 572 PathStr -> 573 Dirs = string:tokens(PathStr, path_sep()), 574 Acc = lists:foldl( 575 fun(Dir, Seen) -> 576 collect_path_cmds(Dir, Prefix, Seen) 577 end, 578 #{}, 579 Dirs 580 ), 581 lists:sort(maps:keys(Acc)) 582 end. 583 584collect_path_cmds(Dir, Prefix, Seen) -> 585 case file:list_dir(Dir) of 586 {ok, Names} -> 587 lists:foldl( 588 fun(Name, Acc) -> 589 case 590 lists:prefix(Prefix, Name) 591 andalso show_dotfile(Prefix, Name) 592 andalso not maps:is_key(Name, Acc) 593 andalso is_executable_file(filename:join(Dir, Name)) 594 of 595 true -> 596 Acc#{Name => true}; 597 false -> 598 Acc 599 end 600 end, 601 Seen, 602 Names 603 ); 604 {error, _} -> 605 Seen 606 end. 607 608beep() -> 609 io:put_chars([7]). 610 611%% Left is graphemes before the cursor in reverse order. 612%% Returns {PrefixRev, WordForward} where Word is the path token. 613word_before_cursor(Left) -> 614 take_completion_word(Left, []). 615 616%% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward. 617take_completion_word([], Acc) -> 618 {[], Acc}; 619take_completion_word([C | Rest], Acc) -> 620 case is_completion_break(C) of 621 true -> 622 {[C | Rest], Acc}; 623 false -> 624 take_completion_word(Rest, [C | Acc]) 625 end. 626 627is_completion_break(C) when C =:= $\s; C =:= $\t -> 628 true; 629is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& -> 630 true; 631is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] -> 632 true; 633is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> -> 634 true; 635is_completion_break(C) when C =:= $'; C =:= $" -> 636 true; 637is_completion_break(_) -> 638 false. 639 640apply_completed_word(PrefixRev, Word) -> 641 lists:reverse(Word) ++ PrefixRev. 642 643%% Return sorted completion strings (as typed, with ~ preserved; dirs end in /). 644filename_completions(Word) -> 645 {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word), 646 ListDir = expand_home_path(ListDirTyped), 647 case file:list_dir(ListDir) of 648 {ok, Names0} -> 649 Names = lists:sort(Names0), 650 [ 651 InsertPrefix ++ maybe_dir_slash(ListDir, Name) 652 || Name <- Names, 653 lists:prefix(Base, Name), 654 show_dotfile(Base, Name) 655 ]; 656 {error, _} -> 657 [] 658 end. 659 660%% Hide dotfiles unless the partial name already starts with '.'. 661show_dotfile([$. | _], _) -> 662 true; 663show_dotfile(_, [$. | _]) -> 664 false; 665show_dotfile(_, _) -> 666 true. 667 668maybe_dir_slash(ListDir, Name) -> 669 case filelib:is_dir(filename:join(ListDir, Name)) of 670 true -> Name ++ "/"; 671 false -> Name 672 end. 673 674%% Split a path word into {dir_to_list, basename_prefix, insert_prefix}. 675%% insert_prefix is the directory part as the user typed it (incl. trailing /). 676split_completion_word(Word) -> 677 case rsplit_path(Word) of 678 {none, Base} -> 679 {".", Base, ""}; 680 {Dir, Base} -> 681 ListDir = 682 case Dir of 683 "" -> "/"; 684 _ -> Dir 685 end, 686 InsertPrefix = 687 case Dir of 688 "" -> "/"; 689 _ -> Dir ++ "/" 690 end, 691 {ListDir, Base, InsertPrefix} 692 end. 693 694%% Rightmost / splits directory from the partial basename. 695rsplit_path(Word) -> 696 rsplit_path(lists:reverse(Word), []). 697 698rsplit_path([], Acc) -> 699 {none, Acc}; 700rsplit_path([$/ | Rest], Acc) -> 701 {lists:reverse(Rest), Acc}; 702rsplit_path([C | Rest], Acc) -> 703 rsplit_path(Rest, [C | Acc]). 704 705expand_home_path(Path) -> 706 case Path of 707 "~" -> 708 home_path_string(); 709 [$~, $/ | More] -> 710 home_path_string() ++ "/" ++ More; 711 _ -> 712 Path 713 end. 714 715home_path_string() -> 716 case os:getenv("HOME") of 717 false -> "."; 718 Home when is_list(Home) -> Home; 719 Home when is_binary(Home) -> unicode:characters_to_list(Home) 720 end. 721 722longest_common_prefix([]) -> 723 ""; 724longest_common_prefix([H | T]) -> 725 lists:foldl(fun lcp2/2, H, T). 726 727lcp2(A, B) -> 728 lcp2(A, B, []). 729 730lcp2([X | As], [X | Bs], Acc) -> 731 lcp2(As, Bs, [X | Acc]); 732lcp2(_, _, Acc) -> 733 lists:reverse(Acc). 734 735show_completions(Matches) -> 736 io:put_chars("\r\n"), 737 case io:columns() of 738 {ok, Cols} when is_integer(Cols), Cols > 8 -> 739 print_completion_columns(Matches, Cols); 740 _ -> 741 io:put_chars(lists:join(" ", Matches)), 742 io:put_chars("\r\n") 743 end. 744 745print_completion_columns(Matches, Cols) -> 746 MaxLen = lists:max([0 | [length(M) || M <- Matches]]), 747 Width = MaxLen + 2, 748 PerRow = max(1, Cols div Width), 749 print_rows(Matches, PerRow, Width). 750 751print_rows([], _PerRow, _Width) -> 752 ok; 753print_rows(Matches, PerRow, Width) -> 754 {Row, Rest} = take_n(Matches, PerRow, []), 755 Line = [ 756 pad_cell(M, Width) 757 || M <- Row 758 ], 759 io:put_chars([Line, "\r\n"]), 760 print_rows(Rest, PerRow, Width). 761 762take_n(List, 0, Acc) -> 763 {lists:reverse(Acc), List}; 764take_n([], _N, Acc) -> 765 {lists:reverse(Acc), []}; 766take_n([H | T], N, Acc) -> 767 take_n(T, N - 1, [H | Acc]). 768 769pad_cell(S, Width) -> 770 Pad = Width - length(S), 771 case Pad > 0 of 772 true -> S ++ lists:duplicate(Pad, $\s); 773 false -> S ++ " " 774 end. 775 776hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 777 NewPos = HistPos + Delta, 778 Len = length(History), 779 if 780 NewPos < 0 -> 781 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 782 NewPos =:= 0 -> 783 %% Restore saved draft 784 {L, R} = bin_to_buffer(Saved), 785 redraw(Prompt, L, R), 786 raw_loop(Prompt, L, R, History, 0, <<>>); 787 NewPos > Len -> 788 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 789 true -> 790 NewSaved = case HistPos of 791 0 -> buffer_to_bin(Left, Right); 792 _ -> Saved 793 end, 794 Entry = lists:nth(NewPos, History), 795 {L, R} = bin_to_buffer(Entry), 796 redraw(Prompt, L, R), 797 raw_loop(Prompt, L, R, History, NewPos, NewSaved) 798 end. 799 800%% Minimal Ctrl+R reverse-i-search over history. 801reverse_search(Prompt, History) -> 802 reverse_search_loop(Prompt, History, [], match_history(History, [])). 803 804reverse_search_loop(Prompt, History, Query, Match) -> 805 Hint = unicode:characters_to_list( 806 ["(reverse-i-search)`", Query, "': ", Match] 807 ), 808 io:put_chars([$\r, Hint, ?CSI_CLEAR_EOL]), 809 case read_key() of 810 eof -> 811 io:put_chars("\r\n"), 812 {error, <<"eof">>}; 813 enter -> 814 %% Accept match onto the edit line; do not submit (edit first). 815 Line = iolist_to_binary(Match), 816 {L, R} = bin_to_buffer(Line), 817 redraw(Prompt, L, R), 818 raw_loop(Prompt, L, R, History, 0, <<>>); 819 ctrl_c -> 820 io:put_chars("\r\n"), 821 redraw(Prompt, [], []), 822 raw_loop(Prompt, [], [], History, 0, <<>>); 823 ctrl_g -> 824 redraw(Prompt, [], []), 825 raw_loop(Prompt, [], [], History, 0, <<>>); 826 ctrl_r -> 827 %% Find older match 828 NewMatch = match_history_after(History, Query, Match), 829 reverse_search_loop(Prompt, History, Query, NewMatch); 830 backspace -> 831 NewQuery = case Query of 832 [] -> []; 833 [_ | _] -> lists:droplast(Query) 834 end, 835 reverse_search_loop( 836 Prompt, History, NewQuery, match_history(History, NewQuery) 837 ); 838 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 839 NewQuery = Query ++ [C], 840 reverse_search_loop( 841 Prompt, History, NewQuery, match_history(History, NewQuery) 842 ); 843 _ -> 844 reverse_search_loop(Prompt, History, Query, Match) 845 end. 846 847match_history(_History, []) -> 848 ""; 849match_history(History, Query) -> 850 QBin = unicode:characters_to_binary(Query), 851 case first_match(History, QBin) of 852 undefined -> ""; 853 Bin -> unicode:characters_to_list(Bin) 854 end. 855 856match_history_after(History, Query, CurrentMatch) -> 857 QBin = unicode:characters_to_binary(Query), 858 CurBin = unicode:characters_to_binary(CurrentMatch), 859 case skip_until_then_match(History, CurBin, QBin, false) of 860 undefined -> CurrentMatch; 861 Bin -> unicode:characters_to_list(Bin) 862 end. 863 864first_match([], _) -> 865 undefined; 866first_match([H | T], Q) -> 867 case binary:match(H, Q) of 868 nomatch -> first_match(T, Q); 869 _ -> H 870 end. 871 872skip_until_then_match([], _Cur, _Q, _Seen) -> 873 undefined; 874skip_until_then_match([H | T], Cur, Q, false) -> 875 case H =:= Cur of 876 true -> skip_until_then_match(T, Cur, Q, true); 877 false -> skip_until_then_match(T, Cur, Q, false) 878 end; 879skip_until_then_match([H | T], Cur, Q, true) -> 880 case binary:match(H, Q) of 881 nomatch -> skip_until_then_match(T, Cur, Q, true); 882 _ -> H 883 end. 884 885kill_word([]) -> 886 {[], []}; 887kill_word(Left) -> 888 %% Left is reversed: strip trailing spaces then a word. 889 L1 = drop_while_space(Left), 890 drop_while_word(L1). 891 892drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t -> 893 drop_while_space(Rest); 894drop_while_space(L) -> 895 L. 896 897drop_while_word([]) -> 898 {[], []}; 899drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t -> 900 {[C | Rest], []}; 901drop_while_word([_ | Rest]) -> 902 drop_while_word(Rest). 903 904redraw(Prompt, Left, Right) -> 905 Full = lists:reverse(Left) ++ Right, 906 FullBin = unicode:characters_to_binary(Full), 907 Colored = highlight_line(FullBin), 908 io:put_chars([$\r, Prompt, Colored, ?CSI_CLEAR_EOL]), 909 case length(Right) of 910 0 -> 911 ok; 912 N -> 913 io:put_chars(["\e[", integer_to_list(N), $D]) 914 end. 915 916highlight_line(Bin) when is_binary(Bin) -> 917 case get(gleshell_color) of 918 false -> 919 Bin; 920 _ -> 921 try 922 case 'gleshell@highlight':line(Bin) of 923 Out when is_binary(Out) -> Out; 924 Out when is_list(Out) -> unicode:characters_to_binary(Out); 925 _ -> Bin 926 end 927 catch 928 _:_ -> 929 Bin 930 end 931 end. 932 933buffer_to_bin(Left, Right) -> 934 unicode:characters_to_binary(lists:reverse(Left) ++ Right). 935 936bin_to_buffer(Bin) when is_binary(Bin) -> 937 Chars = unicode:characters_to_list(Bin), 938 {lists:reverse(Chars), []}; 939bin_to_buffer(List) when is_list(List) -> 940 {lists:reverse(List), []}. 941 942%% --------------------------------------------------------------------------- 943%% Key reading (raw mode — keys arrive as soon as pressed) 944%% --------------------------------------------------------------------------- 945 946read_key() -> 947 case io:get_chars("", 1) of 948 eof -> 949 eof; 950 {error, Reason} -> 951 {error, Reason}; 952 <<C/utf8>> -> 953 decode_key(C, <<>>); 954 [C] when is_integer(C) -> 955 decode_key(C, <<>>); 956 Bin when is_binary(Bin), byte_size(Bin) > 0 -> 957 case unicode:characters_to_list(Bin) of 958 [C | _] -> decode_key(C, <<>>); 959 _ -> read_key() 960 end; 961 List when is_list(List), List =/= [] -> 962 decode_key(hd(List), <<>>); 963 _ -> 964 read_key() 965 end. 966 967decode_key($\r, _) -> enter; 968decode_key($\n, _) -> enter; 969decode_key($\t, _) -> tab; 970decode_key(127, _) -> backspace; 971decode_key($\b, _) -> backspace; 972decode_key(1, _) -> ctrl_a; 973decode_key(5, _) -> ctrl_e; 974decode_key(4, _) -> ctrl_d; 975decode_key(3, _) -> ctrl_c; 976decode_key(11, _) -> ctrl_k; 977decode_key(21, _) -> ctrl_u; 978decode_key(23, _) -> ctrl_w; 979decode_key(12, _) -> ctrl_l; 980decode_key(18, _) -> ctrl_r; 981decode_key(?ESC, _) -> 982 read_escape(); 983decode_key(C, _) when is_integer(C), C >= 32 -> 984 {char, C}; 985decode_key(_, _) -> 986 other. 987 988read_escape() -> 989 case io:get_chars("", 1) of 990 eof -> 991 other; 992 <<"[">> -> 993 read_csi(); 994 <<$O>> -> 995 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 996 case io:get_chars("", 1) of 997 <<"A">> -> up; 998 <<"B">> -> down; 999 <<"C">> -> right; 1000 <<"D">> -> left; 1001 <<"H">> -> home; 1002 <<"F">> -> 'end'; 1003 _ -> other 1004 end; 1005 _ -> 1006 other 1007 end. 1008 1009read_csi() -> 1010 read_csi_params([]). 1011 1012read_csi_params(Acc) -> 1013 case io:get_chars("", 1) of 1014 eof -> 1015 other; 1016 <<C/utf8>> when C >= $0, C =< $9 -> 1017 read_csi_params([C | Acc]); 1018 <<$;>> -> 1019 read_csi_params([$; | Acc]); 1020 <<$~>> -> 1021 Params = lists:reverse(Acc), 1022 case Params of 1023 "1" -> home; 1024 "3" -> delete; 1025 "4" -> 'end'; 1026 "7" -> home; 1027 "8" -> 'end'; 1028 _ -> other 1029 end; 1030 <<"A">> -> 1031 up; 1032 <<"B">> -> 1033 down; 1034 <<"C">> -> 1035 right; 1036 <<"D">> -> 1037 left; 1038 <<"H">> -> 1039 home; 1040 <<"F">> -> 1041 'end'; 1042 _ -> 1043 other 1044 end. 1045 1046%% --------------------------------------------------------------------------- 1047%% History persistence 1048%% --------------------------------------------------------------------------- 1049 1050history_file() -> 1051 case application:get_env(kernel, shell_history_path) of 1052 {ok, Path} when is_list(Path) -> 1053 filename:join(Path, "lines"); 1054 {ok, Path} when is_binary(Path) -> 1055 filename:join(unicode:characters_to_list(Path), "lines"); 1056 _ -> 1057 filename:join( 1058 filename:basedir(user_cache, "gleshell-history"), "lines" 1059 ) 1060 end. 1061 1062load_line_history() -> 1063 File = history_file(), 1064 case file:read_file(File) of 1065 {ok, Bin} -> 1066 Lines = [ 1067 L 1068 || L <- binary:split(Bin, <<"\n">>, [global]), 1069 L =/= <<>> 1070 ], 1071 %% Newest first 1072 put(gleshell_history, lists:reverse(Lines)); 1073 _ -> 1074 put(gleshell_history, []) 1075 end, 1076 put(gleshell_color, true), 1077 ok. 1078 1079save_line_history() -> 1080 case get(gleshell_history) of 1081 Hist when is_list(Hist) -> 1082 File = history_file(), 1083 _ = filelib:ensure_dir(File), 1084 %% Store oldest-first for human readability 1085 Body = [[L, $\n] || L <- lists:reverse(lists:sublist(Hist, ?HISTORY_MAX))], 1086 _ = file:write_file(File, Body), 1087 ok; 1088 _ -> 1089 ok 1090 end. 1091 1092push_history(<<>>) -> 1093 ok; 1094push_history(Line) when is_binary(Line) -> 1095 Hist = case get(gleshell_history) of 1096 L when is_list(L) -> L; 1097 _ -> [] 1098 end, 1099 New = case Hist of 1100 [Line | _] -> Hist; 1101 _ -> [Line | Hist] 1102 end, 1103 put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 1104 ok. 1105 1106%% --------------------------------------------------------------------------- 1107%% OS / process helpers 1108%% --------------------------------------------------------------------------- 1109 1110-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 1111set_cwd(Path) when is_binary(Path) -> 1112 case file:set_cwd(unicode:characters_to_list(Path)) of 1113 ok -> 1114 {ok, nil}; 1115 {error, Reason} -> 1116 {error, reason_to_bin(Reason)} 1117 end. 1118 1119-spec get_cwd() -> {ok, binary()} | {error, binary()}. 1120get_cwd() -> 1121 case file:get_cwd() of 1122 {ok, Dir} -> 1123 {ok, unicode:characters_to_binary(Dir)}; 1124 {error, Reason} -> 1125 {error, reason_to_bin(Reason)} 1126 end. 1127 1128-spec getenv(binary()) -> {ok, binary()} | {error, nil}. 1129getenv(Name) when is_binary(Name) -> 1130 case os:getenv(unicode:characters_to_list(Name)) of 1131 false -> 1132 {error, nil}; 1133 Value -> 1134 {ok, unicode:characters_to_binary(Value)} 1135 end. 1136 1137-spec setenv(binary(), binary()) -> {ok, nil}. 1138setenv(Name, Value) when is_binary(Name), is_binary(Value) -> 1139 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 1140 {ok, nil}. 1141 1142%% All process environment variables as a list of {Name, Value} binaries. 1143-spec list_env() -> list({binary(), binary()}). 1144list_env() -> 1145 lists:map( 1146 fun(Entry) -> 1147 case string:split(Entry, "=", leading) of 1148 [K, V] -> 1149 {unicode:characters_to_binary(K), unicode:characters_to_binary(V)}; 1150 [K] -> 1151 {unicode:characters_to_binary(K), <<>>}; 1152 _ -> 1153 {<<>>, <<>>} 1154 end 1155 end, 1156 os:getenv() 1157 ). 1158 1159%% Substring/regex search helper for the `find` builtin. 1160%% Returns {ok, true|false} or {error, Message} on invalid pattern. 1161-spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}. 1162re_contains(Text, Pattern, IgnoreCase) 1163 when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) -> 1164 Opts0 = [unicode], 1165 Opts = 1166 case IgnoreCase of 1167 true -> 1168 [caseless | Opts0]; 1169 false -> 1170 Opts0 1171 end, 1172 case re:compile(Pattern, Opts) of 1173 {ok, Re} -> 1174 case re:run(Text, Re, [{capture, none}]) of 1175 match -> 1176 {ok, true}; 1177 nomatch -> 1178 {ok, false} 1179 end; 1180 {error, {Reason, _}} -> 1181 {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; 1182 {error, Reason} -> 1183 {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 1184 end. 1185 1186-spec which(binary()) -> {ok, binary()} | {error, nil}. 1187which(Command) when is_binary(Command) -> 1188 case which_all(Command) of 1189 [Path | _] -> 1190 {ok, Path}; 1191 [] -> 1192 {error, nil} 1193 end. 1194 1195%% All matching executables on PATH (or the path itself if absolute/relative). 1196%% Order matches PATH search; duplicates from the same resolved path are dropped. 1197-spec which_all(binary()) -> [binary()]. 1198which_all(Command) when is_binary(Command) -> 1199 Cmd = unicode:characters_to_list(Command), 1200 case Cmd of 1201 [] -> 1202 []; 1203 _ -> 1204 case has_path_sep(Cmd) of 1205 true -> 1206 case is_executable_file(Cmd) of 1207 true -> 1208 [unicode:characters_to_binary(filename:absname(Cmd))]; 1209 false -> 1210 [] 1211 end; 1212 false -> 1213 case os:getenv("PATH") of 1214 false -> 1215 []; 1216 PathStr -> 1217 Dirs = string:tokens(PathStr, path_sep()), 1218 find_all_in_path(Cmd, Dirs, #{}, []) 1219 end 1220 end 1221 end. 1222 1223path_sep() -> 1224 case os:type() of 1225 {win32, _} -> ";"; 1226 _ -> ":" 1227 end. 1228 1229has_path_sep(Cmd) -> 1230 lists:member($/, Cmd) orelse lists:member($\\, Cmd). 1231 1232find_all_in_path(_Cmd, [], _Seen, Acc) -> 1233 lists:reverse(Acc); 1234find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) -> 1235 File = filename:join(Dir, Cmd), 1236 case is_executable_file(File) of 1237 true -> 1238 Abs = filename:absname(File), 1239 Bin = unicode:characters_to_binary(Abs), 1240 case maps:is_key(Abs, Seen) of 1241 true -> 1242 find_all_in_path(Cmd, Rest, Seen, Acc); 1243 false -> 1244 find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc]) 1245 end; 1246 false -> 1247 find_all_in_path(Cmd, Rest, Seen, Acc) 1248 end. 1249 1250is_executable_file(Path) -> 1251 case file:read_file_info(Path) of 1252 {ok, #file_info{type = regular, mode = Mode}} -> 1253 %% Any execute bit (owner/group/other). 1254 (Mode band 8#111) =/= 0; 1255 _ -> 1256 false 1257 end. 1258 1259-spec home_dir() -> {ok, binary()} | {error, binary()}. 1260home_dir() -> 1261 case os:getenv("HOME") of 1262 false -> 1263 {error, <<"HOME not set">>}; 1264 Home -> 1265 {ok, unicode:characters_to_binary(Home)} 1266 end. 1267 1268-spec stdout_isatty() -> boolean(). 1269stdout_isatty() -> 1270 case io:columns() of 1271 {ok, _} -> 1272 true; 1273 _ -> 1274 try 1275 case prim_tty:isatty(stdout) of 1276 true -> true; 1277 _ -> false 1278 end 1279 catch 1280 _:_ -> 1281 false 1282 end 1283 end. 1284 1285%% --------------------------------------------------------------------------- 1286%% External commands 1287%% 1288%% Two modes: 1289%% 1290%% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 1291%% non-TTY). Uses pipes; the child does NOT get a real terminal. 1292%% 1293%% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` 1294%% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. 1295%% `erl_child_setup` calls setsid, so the child is never in the terminal's 1296%% foreground process group — kernel SIGINT goes to BEAM, not the child. 1297%% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing. 1298%% 1299%% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path 1300%% covers that. Host termios during children: cooked for OPOST/ONLCR (no 1301%% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening 1302%% the Erlang BREAK menu. 1303%% --------------------------------------------------------------------------- 1304 1305-spec run_cmd(binary(), [binary()], binary()) -> 1306 {ok, {integer(), binary()}} | {error, binary()}. 1307run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 1308 case resolve_cmd(Command, Args) of 1309 {error, _} = E -> 1310 E; 1311 {ok, Path, PortArgs} -> 1312 try 1313 run_cmd_capture(Path, PortArgs, Stdin) 1314 catch 1315 _:Reason -> 1316 {error, reason_to_bin(Reason)} 1317 end 1318 end. 1319 1320%% Foreground interactive: inherit TTY when possible. 1321%% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works. 1322%% 1323%% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with 1324%% OPOST/ONLCR off so a bare LF does not return the cursor to column 0. 1325%% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if 1326%% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore 1327%% raw afterwards (same idea as println/1 converting to CRLF for shell text). 1328-spec run_cmd_tty(binary(), [binary()], binary()) -> 1329 {ok, {integer(), binary()}} | {error, binary()}. 1330run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 1331 case resolve_cmd(Command, Args) of 1332 {error, _} = E -> 1333 E; 1334 {ok, Path, PortArgs} -> 1335 try 1336 case stdout_isatty() of 1337 false -> 1338 run_cmd_capture(Path, PortArgs, Stdin); 1339 true -> 1340 with_cooked_tty(fun() -> 1341 %% PTY for all interactive when possible: key relay 1342 %% sees Ctrl+C and can SIGINT the child process group. 1343 case {os:find_executable("script"), find_tty_path()} of 1344 {Script, {ok, Tty}} when is_list(Script) -> 1345 run_cmd_pty(Script, Path, PortArgs, Tty, Stdin); 1346 _ -> 1347 run_cmd_inherit(Path, PortArgs, Stdin) 1348 end 1349 end) 1350 end 1351 catch 1352 _:Reason -> 1353 {error, reason_to_bin(Reason)} 1354 end 1355 end. 1356 1357%% Temporarily put the controlling TTY into cooked output + non-canonical 1358%% input for an external child, then restore previous termios (raw REPL). 1359%% 1360%% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal 1361%% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt 1362%% path. No-op when stty/TTY is unavailable. 1363with_cooked_tty(Fun) when is_function(Fun, 0) -> 1364 case stdout_isatty() of 1365 false -> 1366 Fun(); 1367 true -> 1368 case stty_save() of 1369 undefined -> 1370 %% Still try to apply host flags; restore is best-effort. 1371 stty_sane(), 1372 try 1373 Fun() 1374 after 1375 ok 1376 end; 1377 Saved -> 1378 stty_sane(), 1379 try 1380 Fun() 1381 after 1382 stty_restore(Saved) 1383 end 1384 end 1385 end. 1386 1387stty_save() -> 1388 case stty_run(["-g"]) of 1389 {ok, Out} -> 1390 case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of 1391 "" -> 1392 undefined; 1393 Settings -> 1394 %% stty -g is a single token of colon-separated hex flags. 1395 Settings 1396 end; 1397 _ -> 1398 undefined 1399 end. 1400 1401stty_sane() -> 1402 %% Host TTY while an external runs under the raw REPL: 1403 %% - sane / opost / onlcr: LF→CRLF so children don't staircase 1404 %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu) 1405 %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON 1406 %% left on, Ctrl+C sits in the line buffer until Enter and our key 1407 %% relay never sees it (external freezes; second ^C looks wedged) 1408 %% - -echo: host must not echo keys we relay into the child PTY 1409 _ = stty_run([ 1410 "sane", 1411 "-isig", 1412 "-icanon", 1413 "min", 1414 "1", 1415 "time", 1416 "0", 1417 "-echo" 1418 ]), 1419 ok. 1420 1421stty_restore(Settings) when is_list(Settings) -> 1422 _ = stty_run([Settings]), 1423 ok; 1424stty_restore(_) -> 1425 ok. 1426 1427%% Run stty against the real terminal device (not a pipe). Prefer the pts 1428%% path from /proc (same as sudo/PTY path), then `/dev/tty`. 1429%% 1430%% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node, 1431%% so is_file returns false and would skip stty entirely (fastfetch staircase). 1432stty_run(Args) when is_list(Args) -> 1433 case os:find_executable("stty") of 1434 false -> 1435 {error, no_stty}; 1436 Stty when is_list(Stty) -> 1437 stty_run_on(Stty, Args, stty_devices()) 1438 end. 1439 1440stty_devices() -> 1441 case find_tty_path() of 1442 {ok, Path} -> 1443 %% Prefer the concrete pts; /dev/tty is a fallback alias. 1444 [Path, "/dev/tty"]; 1445 _ -> 1446 ["/dev/tty"] 1447 end. 1448 1449stty_run_on(_Stty, _Args, []) -> 1450 {error, no_tty}; 1451stty_run_on(Stty, Args, [Dev | Rest]) -> 1452 case stty_on_device(Stty, Dev, Args) of 1453 {ok, _} = Ok -> 1454 Ok; 1455 _ -> 1456 stty_run_on(Stty, Args, Rest) 1457 end. 1458 1459stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) -> 1460 try 1461 Port = open_port( 1462 {spawn_executable, Stty}, 1463 [ 1464 binary, 1465 exit_status, 1466 use_stdio, 1467 stderr_to_stdout, 1468 {args, ["-F", Dev | Args]} 1469 ] 1470 ), 1471 %% No interrupt watch — internal helper, must not steal TTY input. 1472 case collect_output_quiet(Port, <<>>, 5000) of 1473 {ok, {0, Bin}} -> 1474 {ok, unicode:characters_to_list(Bin)}; 1475 {ok, {Status, Bin}} -> 1476 {error, {Status, Bin}}; 1477 {error, _} = E -> 1478 E 1479 end 1480 catch 1481 _:_ -> 1482 {error, stty_failed} 1483 end. 1484 1485resolve_cmd(Command, Args) -> 1486 case os:find_executable(unicode:characters_to_list(Command)) of 1487 false -> 1488 {error, <<"command not found: ", Command/binary>>}; 1489 Path -> 1490 PortArgs = [unicode:characters_to_list(A) || A <- Args], 1491 {ok, Path, PortArgs} 1492 end. 1493 1494%% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants 1495%% it so tools like `jj` still embed ANSI we can pass through on display. 1496%% 1497%% Stdin is always redirected via `sh -c` + `$GLESHELL_STDIN` (either a temp 1498%% file with pipeline bytes, or `/dev/null`) so programs never hang on an 1499%% open-but-never-written Erlang port pipe. 1500run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> 1501 with_stdin_file(Stdin, fun(StdinPath) -> 1502 sh_exec(Path, PortArgs, StdinPath, capture) 1503 end). 1504 1505%% Inherit real stdio — pagers/editors talk to the terminal directly. 1506%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 1507%% 1508%% Empty stdin: pure inherit (bare `less` reads the TTY). 1509%% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from 1510%% a temp file so `cat file | less` pages the pipeline data. 1511%% 1512%% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when 1513%% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a 1514%% watcher SIGINTs the child's process group (setsid means kernel SIGINT 1515%% never reaches the child even with ISIG on). 1516run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) -> 1517 case Stdin of 1518 <<>> -> 1519 Port = open_port( 1520 {spawn_executable, Path}, 1521 [ 1522 exit_status, 1523 nouse_stdio, 1524 {env, child_env()}, 1525 {args, PortArgs} 1526 ] 1527 ), 1528 put(gleshell_output_shown, true), 1529 await_port_exit_interruptible(Port); 1530 _ -> 1531 with_stdin_file(Stdin, fun(StdinPath) -> 1532 sh_exec(Path, PortArgs, StdinPath, inherit) 1533 end) 1534 end. 1535 1536%% PTY + key relay (interactive TTY, sudo/run0, …). 1537%% 1538%% util-linux `script` does NOT exec the argv after `--` directly. It runs 1539%% `$SHELL -c "<joined args>"` (see script(1)). Nested 1540%% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and 1541%% the real binary never runs (fastfetch → empty output, exit 0). 1542%% 1543%% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`). 1544%% Non-empty stdin (pipeline → less): write a one-shot runner script that 1545%% redirects and execs, then `script -- /tmp/runner` (single path token). 1546run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> 1547 run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>); 1548run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> 1549 with_stdin_file(Stdin, fun(StdinPath) -> 1550 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 1551 run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin) 1552 end) 1553 end). 1554 1555run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) -> 1556 with_trapped_exits(fun() -> 1557 Port = open_port( 1558 {spawn_executable, Script}, 1559 [ 1560 binary, 1561 exit_status, 1562 stderr_to_stdout, 1563 use_stdio, 1564 stream, 1565 {env, child_env()}, 1566 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 1567 ] 1568 ), 1569 case file:open(TtyPath, [write, raw, binary]) of 1570 {ok, TtyOut} -> 1571 GL = group_leader(), 1572 Reader = spawn(fun() -> 1573 group_leader(GL, self()), 1574 io_to_port(Port) 1575 end), 1576 put(gleshell_output_shown, true), 1577 try 1578 collect_output_relay(Port, TtyOut, <<>>) 1579 after 1580 exit(Reader, kill), 1581 catch file:close(TtyOut), 1582 catch port_close(Port) 1583 end; 1584 {error, _} -> 1585 catch port_close(Port), 1586 put(gleshell_output_shown, false), 1587 run_cmd_inherit(Path, PortArgs, Stdin) 1588 end 1589 end). 1590 1591%% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath. 1592%% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec). 1593with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) -> 1594 case write_runner_script(Path, PortArgs, StdinPath) of 1595 {ok, Runner} -> 1596 try 1597 Fun(Runner) 1598 after 1599 _ = file:delete(Runner) 1600 end; 1601 {error, Reason} -> 1602 {error, reason_to_bin({runner_script, Reason})} 1603 end. 1604 1605write_runner_script(Path, PortArgs, StdinPath) -> 1606 Dir = 1607 case os:getenv("TMPDIR") of 1608 false -> 1609 "/tmp"; 1610 "" -> 1611 "/tmp"; 1612 D -> 1613 D 1614 end, 1615 Name = 1616 filename:join( 1617 Dir, 1618 "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive])) 1619 ), 1620 Body = runner_script_body(Path, PortArgs, StdinPath), 1621 case file:write_file(Name, Body) of 1622 ok -> 1623 case file:change_mode(Name, 8#755) of 1624 ok -> 1625 {ok, Name}; 1626 {error, _} = E -> 1627 _ = file:delete(Name), 1628 E 1629 end; 1630 {error, _} = E -> 1631 E 1632 end. 1633 1634runner_script_body(Path, PortArgs, StdinPath) -> 1635 ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], 1636 [ 1637 "#!/bin/sh\n", 1638 "exec ", 1639 shell_single_quote(Path), 1640 ArgsQ, 1641 " < ", 1642 shell_single_quote(StdinPath), 1643 "\n" 1644 ]. 1645 1646%% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). 1647shell_single_quote(S) when is_list(S) -> 1648 [$' | shell_single_quote_chars(S) ++ "'"]; 1649shell_single_quote(B) when is_binary(B) -> 1650 shell_single_quote(unicode:characters_to_list(B)). 1651 1652shell_single_quote_chars([]) -> 1653 []; 1654shell_single_quote_chars([$' | Rest]) -> 1655 "'\\''" ++ shell_single_quote_chars(Rest); 1656shell_single_quote_chars([C | Rest]) -> 1657 [C | shell_single_quote_chars(Rest)]. 1658 1659find_sh() -> 1660 case os:find_executable("sh") of 1661 false -> 1662 "/bin/sh"; 1663 S -> 1664 S 1665 end. 1666 1667%% Run Path with stdin redirected from StdinPath. 1668%% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err). 1669sh_exec(Path, PortArgs, StdinPath, capture) -> 1670 Port = open_port( 1671 {spawn_executable, find_sh()}, 1672 [ 1673 binary, 1674 exit_status, 1675 stderr_to_stdout, 1676 use_stdio, 1677 stream, 1678 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 1679 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 1680 ] 1681 ), 1682 put(gleshell_output_shown, false), 1683 collect_output(Port, <<>>); 1684sh_exec(Path, PortArgs, StdinPath, inherit) -> 1685 Port = open_port( 1686 {spawn_executable, find_sh()}, 1687 [ 1688 exit_status, 1689 nouse_stdio, 1690 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 1691 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 1692 ] 1693 ), 1694 put(gleshell_output_shown, true), 1695 await_port_exit_interruptible(Port). 1696 1697%% Provide a filesystem path for stdin bytes; clean up temp files afterwards. 1698with_stdin_file(<<>>, Fun) when is_function(Fun, 1) -> 1699 Fun("/dev/null"); 1700with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) -> 1701 case write_stdin_tmp(Data) of 1702 {ok, Path} -> 1703 try 1704 Fun(Path) 1705 after 1706 _ = file:delete(Path) 1707 end; 1708 {error, Reason} -> 1709 {error, reason_to_bin({stdin_tmp, Reason})} 1710 end. 1711 1712write_stdin_tmp(Data) when is_binary(Data) -> 1713 Dir = 1714 case os:getenv("TMPDIR") of 1715 false -> 1716 "/tmp"; 1717 "" -> 1718 "/tmp"; 1719 D -> 1720 D 1721 end, 1722 Name = 1723 filename:join( 1724 Dir, 1725 "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive])) 1726 ), 1727 case file:write_file(Name, Data) of 1728 ok -> 1729 {ok, Name}; 1730 {error, _} = E -> 1731 E 1732 end. 1733 1734%% Relay keypresses from the group leader to the child's PTY (script stdin). 1735%% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the 1736%% byte so the PTY line discipline can deliver SIGINT on the slave side too. 1737io_to_port(Port) -> 1738 case io:get_chars("", 1) of 1739 eof -> 1740 ok; 1741 {error, _} -> 1742 ok; 1743 Data -> 1744 case io_data_to_bin(Data) of 1745 <<>> -> 1746 io_to_port(Port); 1747 <<3>> = Bin -> 1748 signal_port_group(Port, int), 1749 catch port_command(Port, Bin), 1750 io_to_port(Port); 1751 Bin -> 1752 catch port_command(Port, Bin), 1753 io_to_port(Port) 1754 end 1755 end. 1756 1757io_data_to_bin(Bin) when is_binary(Bin) -> 1758 Bin; 1759io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F -> 1760 <<C>>; 1761io_data_to_bin(List) when is_list(List) -> 1762 case unicode:characters_to_binary(List) of 1763 Bin when is_binary(Bin) -> 1764 Bin; 1765 _ -> 1766 <<>> 1767 end; 1768io_data_to_bin(_) -> 1769 <<>>. 1770 1771%% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may 1772%% exit with `epipe` / signal reasons. Without trap_exit the shell process 1773%% dies with "Erlang exit: Epipe" instead of returning to the prompt. 1774with_trapped_exits(Fun) when is_function(Fun, 0) -> 1775 Old = process_flag(trap_exit, true), 1776 try 1777 Fun() 1778 after 1779 process_flag(trap_exit, Old), 1780 drain_exit_msgs() 1781 end. 1782 1783drain_exit_msgs() -> 1784 receive 1785 {'EXIT', _, _} -> 1786 drain_exit_msgs() 1787 after 0 -> 1788 ok 1789 end. 1790 1791%% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group. 1792%% Used when `script`/PTY is unavailable; same kill path as the PTY relay. 1793await_port_exit_interruptible(Port) -> 1794 with_trapped_exits(fun() -> 1795 with_interrupt_watch(Port, fun() -> 1796 await_port_exit_interruptible_loop(Port) 1797 end) 1798 end). 1799 1800await_port_exit_interruptible_loop(Port) -> 1801 receive 1802 {Port, {exit_status, Status}} -> 1803 {ok, {Status, <<>>}}; 1804 {'EXIT', Port, _Reason} -> 1805 {ok, {130, <<>>}}; 1806 {gleshell_interrupt, _} -> 1807 signal_port_group(Port, int), 1808 await_port_exit_after_interrupt(Port, 2000) 1809 end. 1810 1811await_port_exit_after_interrupt(Port, GraceMs) -> 1812 receive 1813 {Port, {exit_status, Status}} -> 1814 {ok, {Status, <<>>}}; 1815 {'EXIT', Port, _Reason} -> 1816 {ok, {130, <<>>}}; 1817 {gleshell_interrupt, _} -> 1818 signal_port_group(Port, kill), 1819 await_port_exit_after_interrupt(Port, 1000) 1820 after GraceMs -> 1821 signal_port_group(Port, kill), 1822 catch port_close(Port), 1823 receive 1824 {Port, {exit_status, Status}} -> 1825 {ok, {Status, <<>>}}; 1826 {'EXIT', Port, _} -> 1827 {ok, {130, <<>>}} 1828 after 1000 -> 1829 {ok, {130, <<>>}} 1830 end 1831 end. 1832 1833collect_output(Port, Acc) -> 1834 with_trapped_exits(fun() -> 1835 with_interrupt_watch(Port, fun() -> 1836 collect_output_loop(Port, Acc, 120_000) 1837 end) 1838 end). 1839 1840collect_output_loop(Port, Acc, Timeout) -> 1841 receive 1842 {Port, {data, Data}} when is_binary(Data) -> 1843 collect_output_loop(Port, <<Acc/binary, Data/binary>>, Timeout); 1844 {Port, {data, Data}} when is_list(Data) -> 1845 Bin = unicode:characters_to_binary(Data), 1846 collect_output_loop(Port, <<Acc/binary, Bin/binary>>, Timeout); 1847 {Port, {exit_status, Status}} -> 1848 {ok, {Status, Acc}}; 1849 {'EXIT', Port, _Reason} -> 1850 {ok, {130, Acc}}; 1851 {gleshell_interrupt, _} -> 1852 signal_port_group(Port, int), 1853 collect_output_after_interrupt(Port, Acc, 2000) 1854 after Timeout -> 1855 signal_port_group(Port, term), 1856 catch port_close(Port), 1857 {error, <<"command timed out after 120s">>} 1858 end. 1859 1860collect_output_after_interrupt(Port, Acc, GraceMs) -> 1861 receive 1862 {Port, {data, Data}} when is_binary(Data) -> 1863 collect_output_after_interrupt( 1864 Port, <<Acc/binary, Data/binary>>, GraceMs 1865 ); 1866 {Port, {data, Data}} when is_list(Data) -> 1867 Bin = unicode:characters_to_binary(Data), 1868 collect_output_after_interrupt( 1869 Port, <<Acc/binary, Bin/binary>>, GraceMs 1870 ); 1871 {Port, {exit_status, Status}} -> 1872 {ok, {Status, Acc}}; 1873 {'EXIT', Port, _Reason} -> 1874 {ok, {130, Acc}}; 1875 {gleshell_interrupt, _} -> 1876 signal_port_group(Port, kill), 1877 collect_output_after_interrupt(Port, Acc, 1000) 1878 after GraceMs -> 1879 signal_port_group(Port, kill), 1880 catch port_close(Port), 1881 receive 1882 {Port, {data, Data}} when is_binary(Data) -> 1883 collect_output_after_interrupt( 1884 Port, <<Acc/binary, Data/binary>>, 500 1885 ); 1886 {Port, {data, Data}} when is_list(Data) -> 1887 Bin = unicode:characters_to_binary(Data), 1888 collect_output_after_interrupt( 1889 Port, <<Acc/binary, Bin/binary>>, 500 1890 ); 1891 {Port, {exit_status, Status}} -> 1892 {ok, {Status, Acc}}; 1893 {'EXIT', Port, _} -> 1894 {ok, {130, Acc}} 1895 after 1000 -> 1896 {ok, {130, Acc}} 1897 end 1898 end. 1899 1900%% PTY session: no timeout (password prompts, long pagers, etc.). 1901%% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs. 1902collect_output_relay(Port, Tty, Acc) -> 1903 receive 1904 {Port, {data, Data}} when is_binary(Data) -> 1905 _ = file:write(Tty, Data), 1906 collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>); 1907 {Port, {data, Data}} when is_list(Data) -> 1908 Bin = unicode:characters_to_binary(Data), 1909 _ = file:write(Tty, Bin), 1910 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 1911 {Port, {exit_status, Status}} -> 1912 {ok, {Status, normalize_pty_output(Acc)}}; 1913 {gleshell_interrupt, _} -> 1914 signal_port_group(Port, int), 1915 collect_output_relay_after_interrupt(Port, Tty, Acc, 2000) 1916 end. 1917 1918collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) -> 1919 receive 1920 {Port, {data, Data}} when is_binary(Data) -> 1921 _ = file:write(Tty, Data), 1922 collect_output_relay_after_interrupt( 1923 Port, Tty, <<Acc/binary, Data/binary>>, GraceMs 1924 ); 1925 {Port, {data, Data}} when is_list(Data) -> 1926 Bin = unicode:characters_to_binary(Data), 1927 _ = file:write(Tty, Bin), 1928 collect_output_relay_after_interrupt( 1929 Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs 1930 ); 1931 {Port, {exit_status, Status}} -> 1932 {ok, {Status, normalize_pty_output(Acc)}}; 1933 {gleshell_interrupt, _} -> 1934 signal_port_group(Port, kill), 1935 collect_output_relay_after_interrupt(Port, Tty, Acc, 1000) 1936 after GraceMs -> 1937 signal_port_group(Port, kill), 1938 catch port_close(Port), 1939 receive 1940 {Port, {exit_status, Status}} -> 1941 {ok, {Status, normalize_pty_output(Acc)}} 1942 after 1000 -> 1943 {ok, {130, normalize_pty_output(Acc)}} 1944 end 1945 end. 1946 1947%% --------------------------------------------------------------------------- 1948%% Ctrl+C / SIGINT forwarding 1949%% 1950%% BEAM's open_port → erl_child_setup → setsid, so the child is not in the 1951%% terminal foreground group. Host ISIG is left off while a child runs; we 1952%% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group. 1953%% --------------------------------------------------------------------------- 1954 1955with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> 1956 Parent = self(), 1957 GL = group_leader(), 1958 Watcher = 1959 case can_watch_interrupt() of 1960 true -> 1961 spawn(fun() -> 1962 group_leader(GL, self()), 1963 interrupt_watch_loop(Parent) 1964 end); 1965 false -> 1966 undefined 1967 end, 1968 try 1969 Fun() 1970 after 1971 case Watcher of 1972 undefined -> 1973 ok; 1974 W -> 1975 exit(W, kill), 1976 drain_interrupt_msgs() 1977 end 1978 end. 1979 1980%% Collect port output without Ctrl+C watching (stty and other helpers). 1981collect_output_quiet(Port, Acc, Timeout) -> 1982 receive 1983 {Port, {data, Data}} when is_binary(Data) -> 1984 collect_output_quiet(Port, <<Acc/binary, Data/binary>>, Timeout); 1985 {Port, {data, Data}} when is_list(Data) -> 1986 Bin = unicode:characters_to_binary(Data), 1987 collect_output_quiet(Port, <<Acc/binary, Bin/binary>>, Timeout); 1988 {Port, {exit_status, Status}} -> 1989 {ok, {Status, Acc}} 1990 after Timeout -> 1991 catch port_close(Port), 1992 {error, <<"command timed out">>} 1993 end. 1994 1995%% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal. 1996can_watch_interrupt() -> 1997 case get(gleshell_raw) of 1998 true -> 1999 true; 2000 _ -> 2001 stdout_isatty() 2002 end. 2003 2004interrupt_watch_loop(Parent) when is_pid(Parent) -> 2005 case catch io:get_chars("", 1) of 2006 eof -> 2007 ok; 2008 {error, _} -> 2009 ok; 2010 {'EXIT', _} -> 2011 ok; 2012 Data -> 2013 case io_data_to_bin(Data) of 2014 <<3>> -> 2015 Parent ! {gleshell_interrupt, self()}, 2016 interrupt_watch_loop(Parent); 2017 _ -> 2018 %% Non-Ctrl+C: discard here (capture mode). Interactive 2019 %% PTY uses io_to_port instead; inherit prefers PTY. 2020 interrupt_watch_loop(Parent) 2021 end 2022 end. 2023 2024drain_interrupt_msgs() -> 2025 receive 2026 {gleshell_interrupt, _} -> 2027 drain_interrupt_msgs() 2028 after 0 -> 2029 ok 2030 end. 2031 2032%% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid). 2033signal_port_group(Port, Sig) when is_port(Port) -> 2034 case erlang:port_info(Port, os_pid) of 2035 {os_pid, Pid} when is_integer(Pid), Pid > 0 -> 2036 kill_os_group(Pid, Sig); 2037 _ -> 2038 ok 2039 end. 2040 2041kill_os_group(Pid, Sig) when is_integer(Pid) -> 2042 Kill = 2043 case os:find_executable("kill") of 2044 false -> 2045 "kill"; 2046 K -> 2047 K 2048 end, 2049 SigArg = 2050 case Sig of 2051 int -> 2052 "-INT"; 2053 term -> 2054 "-TERM"; 2055 kill -> 2056 "-KILL" 2057 end, 2058 %% Negative pid → process group (child is session/group leader after setsid). 2059 Pg = "-" ++ integer_to_list(Pid), 2060 Single = integer_to_list(Pid), 2061 _ = kill_once(Kill, [SigArg, Pg]), 2062 _ = kill_once(Kill, [SigArg, Single]), 2063 ok. 2064 2065kill_once(Kill, Args) -> 2066 try 2067 Port = open_port( 2068 {spawn_executable, Kill}, 2069 [exit_status, nouse_stdio, {args, Args}] 2070 ), 2071 receive 2072 {Port, {exit_status, _}} -> 2073 ok 2074 after 1000 -> 2075 catch port_close(Port), 2076 ok 2077 end 2078 catch 2079 _:_ -> 2080 ok 2081 end. 2082 2083%% PTY line discipline often emits CR-LF; normalize to LF for structured use. 2084normalize_pty_output(Bin) when is_binary(Bin) -> 2085 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 2086 2087%% Extra env for external commands (merged into the process environment). 2088%% 2089%% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 2090%% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R) 2091%% and exit on short output (-F) without clearing the screen (-X). 2092%% - FORCE_COLOR / CLICOLOR_FORCE when the shell itself wants color and the 2093%% child has no TTY (direct path): tools like jj/git/ripgrep emit ANSI so 2094%% we can show their colors when re-printing the captured string. 2095child_env() -> 2096 Env0 = [{"SHELL", "/bin/sh"}], 2097 Env1 = 2098 case os:getenv("LESS") of 2099 false -> 2100 [{"LESS", "FRX"} | Env0]; 2101 "" -> 2102 [{"LESS", "FRX"} | Env0]; 2103 _ -> 2104 Env0 2105 end, 2106 case want_child_color() of 2107 false -> 2108 Env1; 2109 true -> 2110 Env2 = 2111 case os:getenv("FORCE_COLOR") of 2112 false -> 2113 [{"FORCE_COLOR", "1"} | Env1]; 2114 "0" -> 2115 Env1; 2116 _ -> 2117 Env1 2118 end, 2119 case os:getenv("CLICOLOR_FORCE") of 2120 false -> 2121 [{"CLICOLOR_FORCE", "1"} | Env2]; 2122 "0" -> 2123 Env2; 2124 _ -> 2125 Env2 2126 end 2127 end. 2128 2129%% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY 2130%% or when the parent already forces color. 2131want_child_color() -> 2132 case os:getenv("NO_COLOR") of 2133 L when is_list(L), L =/= "" -> 2134 false; 2135 _ -> 2136 case stdout_isatty() of 2137 true -> 2138 true; 2139 false -> 2140 force_color_set() 2141 end 2142 end. 2143 2144force_color_set() -> 2145 case os:getenv("FORCE_COLOR") of 2146 L when is_list(L), L =/= "", L =/= "0" -> 2147 true; 2148 _ -> 2149 case os:getenv("CLICOLOR_FORCE") of 2150 L when is_list(L), L =/= "", L =/= "0" -> 2151 true; 2152 _ -> 2153 false 2154 end 2155 end. 2156 2157%% True when the last external command already streamed output to the TTY 2158%% (PTY relay). Cleared after being read so the REPL does not double-print. 2159-spec take_output_shown() -> boolean(). 2160take_output_shown() -> 2161 case erase(gleshell_output_shown) of 2162 true -> true; 2163 _ -> false 2164 end. 2165 2166-spec clear_output_shown() -> nil. 2167clear_output_shown() -> 2168 erase(gleshell_output_shown), 2169 nil. 2170 2171%% Find a terminal device attached to this BEAM (or an ancestor). 2172%% Note: os:getpid() returns a string, not an integer. 2173find_tty_path() -> 2174 case catch list_to_integer(os:getpid()) of 2175 Pid when is_integer(Pid) -> 2176 case tty_path_for_pid(Pid) of 2177 {ok, _} = Ok -> 2178 Ok; 2179 error -> 2180 walk_parent_tty(Pid, 12) 2181 end; 2182 _ -> 2183 error 2184 end. 2185 2186walk_parent_tty(_Pid, 0) -> 2187 error; 2188walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 -> 2189 case parent_pid(Pid) of 2190 {ok, Parent} when Parent > 1, Parent =/= Pid -> 2191 case tty_path_for_pid(Parent) of 2192 {ok, _} = Ok -> 2193 Ok; 2194 error -> 2195 walk_parent_tty(Parent, N - 1) 2196 end; 2197 _ -> 2198 error 2199 end; 2200walk_parent_tty(_, _) -> 2201 error. 2202 2203tty_path_for_pid(Pid) when is_integer(Pid) -> 2204 case read_tty_nr(Pid) of 2205 {ok, 0} -> 2206 error; 2207 {ok, TtyNr} -> 2208 tty_nr_to_path(TtyNr); 2209 error -> 2210 error 2211 end. 2212 2213read_tty_nr(Pid) when is_integer(Pid) -> 2214 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 2215 {ok, Bin} -> 2216 case parse_stat_tty_nr(binary_to_list(Bin)) of 2217 {ok, N} -> {ok, N}; 2218 error -> error 2219 end; 2220 _ -> 2221 error 2222 end. 2223%% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..." 2224parse_stat_tty_nr(List) -> 2225 case lists:splitwith(fun(C) -> C =/= $) end, List) of 2226 {_, [$) | Rest0]} -> 2227 Rest = string:trim(Rest0, leading), 2228 Fields = string:tokens(Rest, " "), 2229 %% After ')': state, ppid, pgrp, session, tty_nr → index 5 2230 case length(Fields) >= 5 of 2231 true -> 2232 try 2233 {ok, list_to_integer(lists:nth(5, Fields))} 2234 catch 2235 _:_ -> error 2236 end; 2237 false -> 2238 error 2239 end; 2240 _ -> 2241 error 2242 end. 2243 2244parent_pid(Pid) -> 2245 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 2246 {ok, Bin} -> 2247 case parse_stat_ppid(binary_to_list(Bin)) of 2248 {ok, P} -> {ok, P}; 2249 error -> error 2250 end; 2251 _ -> 2252 error 2253 end. 2254 2255parse_stat_ppid(List) -> 2256 case lists:splitwith(fun(C) -> C =/= $) end, List) of 2257 {_, [$) | Rest0]} -> 2258 Rest = string:trim(Rest0, leading), 2259 Fields = string:tokens(Rest, " "), 2260 %% After ')': state, ppid → index 2 2261 case length(Fields) >= 2 of 2262 true -> 2263 try 2264 {ok, list_to_integer(lists:nth(2, Fields))} 2265 catch 2266 _:_ -> error 2267 end; 2268 false -> 2269 error 2270 end; 2271 _ -> 2272 error 2273 end. 2274 2275%% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path. 2276tty_nr_to_path(0) -> 2277 error; 2278tty_nr_to_path(TtyNr) when is_integer(TtyNr) -> 2279 Major = (TtyNr bsr 8) band 16#ff, 2280 Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8), 2281 Path = 2282 case Major of 2283 136 -> 2284 "/dev/pts/" ++ integer_to_list(Minor); 2285 4 when Minor >= 64 -> 2286 "/dev/ttyS" ++ integer_to_list(Minor - 64); 2287 4 -> 2288 "/dev/tty" ++ integer_to_list(Minor); 2289 _ -> 2290 undefined 2291 end, 2292 case Path of 2293 undefined -> 2294 error; 2295 _ -> 2296 case file:read_file_info(Path) of 2297 {ok, _} -> {ok, Path}; 2298 _ -> error 2299 end 2300 end. 2301 2302reason_to_bin(Reason) when is_atom(Reason) -> 2303 atom_to_binary(Reason, utf8); 2304reason_to_bin(Reason) when is_binary(Reason) -> 2305 Reason; 2306reason_to_bin(Reason) -> 2307 iolist_to_binary(io_lib:format("~p", [Reason])).