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