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
106 kB 3233 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 realpath/1, 19 home_dir/0, 20 stdout_isatty/0, 21 println/1, 22 write/1, 23 term_size/0, 24 read_key_name/0, 25 with_key_mode/1, 26 take_output_shown/0, 27 clear_output_shown/0, 28 complete_word/2, 29 history_hint/2, 30 history_search/2, 31 re_contains/3, 32 format_unix_local/1 33]). 34 35-define(ESC, 16#1b). 36-define(CSI_CLEAR_EOL, "\e[K"). 37-define(HISTORY_MAX, 2000). 38%% Max rows of history matches shown in the Ctrl+R picker (stinkpot-style). 39-define(SEARCH_MAX_ROWS, 12). 40 41%% --------------------------------------------------------------------------- 42%% Public: write a line (CRLF in raw TTY mode so multi-line output does not 43%% staircase — raw mode does not map LF → CR+LF the way cooked mode does). 44%% --------------------------------------------------------------------------- 45 46-spec println(binary()) -> nil. 47println(Text) when is_binary(Text) -> 48 case get(gleshell_raw) of 49 true -> 50 io:put_chars([to_crlf(Text), <<"\r\n">>]), 51 nil; 52 _ -> 53 io:put_chars([Text, $\n]), 54 nil 55 end. 56 57%% Write text without an automatic trailing newline. ANSI sequences are passed 58%% through unchanged. In raw TTY mode, bare LFs become CRLF so lines do not 59%% staircase (same as println/1). 60-spec write(binary()) -> nil. 61write(Text) when is_binary(Text) -> 62 case get(gleshell_raw) of 63 true -> 64 io:put_chars(to_crlf(Text)), 65 nil; 66 _ -> 67 io:put_chars(Text), 68 nil 69 end. 70 71%% Terminal size when stdout is a TTY. Defaults to 24×80 if the driver omits a 72%% dimension. Error when stdout is not a terminal (pager should dump instead). 73-spec term_size() -> {ok, {integer(), integer()}} | {error, nil}. 74term_size() -> 75 case stdout_isatty() of 76 false -> 77 {error, nil}; 78 true -> 79 Rows = 80 case io:rows() of 81 {ok, R} when is_integer(R), R > 0 -> 82 R; 83 _ -> 84 24 85 end, 86 Cols = 87 case io:columns() of 88 {ok, C} when is_integer(C), C > 0 -> 89 C; 90 _ -> 91 80 92 end, 93 {ok, {Rows, Cols}} 94 end. 95 96%% Run Fun with the TTY in single-key (non-canonical, no-echo) mode when the 97%% REPL is not already raw. Restores termios afterwards. Prefer wrapping a 98%% whole pager session rather than each keystroke. 99-spec with_key_mode(fun(() -> term())) -> term(). 100with_key_mode(Fun) when is_function(Fun, 0) -> 101 case get(gleshell_raw) of 102 true -> 103 Fun(); 104 _ -> 105 case stdout_isatty() of 106 false -> 107 Fun(); 108 true -> 109 case stty_save() of 110 {ok, Saved} -> 111 _ = stty_run(["-icanon", "-echo", "min", "1", "time", "0"]), 112 try 113 Fun() 114 after 115 stty_restore(Saved) 116 end; 117 _ -> 118 Fun() 119 end 120 end 121 end. 122 123%% Blocking single-key read for the builtin pager. Returns a short name: 124%% "up" | "down" | "left" | "right" | "home" | "end" | "page_up" | "page_down" 125%% | "enter" | "backspace" | "tab" | "ctrl_c" | "ctrl_l" | "space" | "eof" 126%% | single printable grapheme ("q", "j", "G", …) | "other". 127%% 128%% Call inside `with_key_mode/1` when not already in the raw REPL, so cooked 129%% TTYs (e.g. `gleshell -c '… | less'`) still deliver keys one at a time. 130-spec read_key_name() -> {ok, binary()} | {error, binary()}. 131read_key_name() -> 132 try 133 {ok, key_to_name(read_key())} 134 catch 135 _:Reason -> 136 {error, reason_to_bin(Reason)} 137 end. 138 139key_to_name(eof) -> 140 <<"eof">>; 141key_to_name(enter) -> 142 <<"enter">>; 143key_to_name(tab) -> 144 <<"tab">>; 145key_to_name(backspace) -> 146 <<"backspace">>; 147key_to_name(delete) -> 148 <<"delete">>; 149key_to_name(up) -> 150 <<"up">>; 151key_to_name(down) -> 152 <<"down">>; 153key_to_name(left) -> 154 <<"left">>; 155key_to_name(right) -> 156 <<"right">>; 157key_to_name(home) -> 158 <<"home">>; 159key_to_name('end') -> 160 <<"end">>; 161key_to_name(page_up) -> 162 <<"page_up">>; 163key_to_name(page_down) -> 164 <<"page_down">>; 165key_to_name(ctrl_c) -> 166 <<"ctrl_c">>; 167key_to_name(ctrl_l) -> 168 <<"ctrl_l">>; 169key_to_name(ctrl_a) -> 170 <<"ctrl_a">>; 171key_to_name(ctrl_e) -> 172 <<"ctrl_e">>; 173key_to_name(ctrl_d) -> 174 <<"ctrl_d">>; 175key_to_name(ctrl_k) -> 176 <<"ctrl_k">>; 177key_to_name(ctrl_u) -> 178 <<"ctrl_u">>; 179key_to_name(ctrl_w) -> 180 <<"ctrl_w">>; 181key_to_name(ctrl_r) -> 182 <<"ctrl_r">>; 183key_to_name(ctrl_f) -> 184 <<"ctrl_f">>; 185key_to_name(ctrl_p) -> 186 <<"ctrl_p">>; 187key_to_name(ctrl_n) -> 188 <<"ctrl_n">>; 189key_to_name(alt_f) -> 190 <<"alt_f">>; 191key_to_name(ctrl_g) -> 192 <<"ctrl_g">>; 193key_to_name(esc) -> 194 <<"esc">>; 195key_to_name({char, 32}) -> 196 <<"space">>; 197key_to_name({char, C}) when is_integer(C), C >= 32 -> 198 unicode:characters_to_binary([C]); 199key_to_name({error, _}) -> 200 <<"eof">>; 201key_to_name(_) -> 202 <<"other">>. 203 204%% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 205to_crlf(Bin) when is_binary(Bin) -> 206 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), 207 B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]), 208 binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]). 209 210%% --------------------------------------------------------------------------- 211%% Public: read a line (syntax-highlighted when raw TTY mode is active) 212%% --------------------------------------------------------------------------- 213 214-spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 215get_line(Prompt) when is_binary(Prompt) -> 216 case get(gleshell_raw) of 217 true -> 218 raw_get_line(Prompt); 219 _ -> 220 classic_get_line(Prompt) 221 end. 222 223%% Edlin / get_until path (non-TTY or when raw mode unavailable). 224classic_get_line(Prompt) -> 225 PromptChars = unicode:characters_to_list(Prompt), 226 case io:request( 227 standard_io, 228 {get_until, unicode, PromptChars, ?MODULE, parse_line, []} 229 ) of 230 eof -> 231 {error, <<"eof">>}; 232 {error, interrupted} -> 233 %% Ctrl+C while reading: cancel line, keep the REPL alive. 234 {error, <<"interrupted">>}; 235 {error, _} -> 236 {error, <<"io_error">>}; 237 Line when is_list(Line); is_binary(Line) -> 238 Bin = unicode:characters_to_binary(Line), 239 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 240 {ok, Stripped}; 241 Other -> 242 try 243 Bin = unicode:characters_to_binary(Other), 244 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 245 {ok, Stripped} 246 catch 247 _:_ -> 248 {error, <<"io_error">>} 249 end 250 end. 251 252%% get_until callback: edlin already gathers a full line. 253-spec parse_line(term(), term()) -> 254 {done, eof | string(), list()} | {more, term()}. 255parse_line(_Cont, eof) -> 256 {done, eof, []}; 257parse_line(_Cont, Chars) when is_list(Chars) -> 258 {done, Chars, []}. 259 260%% --------------------------------------------------------------------------- 261%% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. 262%% Falls back to edlin interactive shell when raw is unavailable. 263%% 264%% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit). 265%% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor 266%% (cancel line) instead of killing the shell. See erl(1) +B. 267%% --------------------------------------------------------------------------- 268 269-spec run_as_shell(fun(() -> term())) -> nil. 270run_as_shell(Fun) when is_function(Fun, 0) -> 271 case ensure_plus_bc() of 272 {parent, Port} -> 273 %% Child owns the TTY; we only wait for its exit status. 274 receive 275 {Port, {exit_status, Status}} -> 276 erlang:halt(Status) 277 end; 278 child -> 279 enable_shell_history(), 280 case try_start_raw() of 281 true -> 282 put(gleshell_raw, true), 283 load_line_history(), 284 configure_line_editor(), 285 try 286 Fun() 287 after 288 save_line_history() 289 end, 290 nil; 291 false -> 292 erase(gleshell_raw), 293 Parent = self(), 294 case try_start_interactive(Parent, Fun) of 295 {ok, started} -> 296 receive 297 {gleshell_shell_done, ok} -> 298 nil; 299 {gleshell_shell_done, {error, Class, Reason, Stack}} -> 300 erlang:raise(Class, Reason, Stack) 301 end; 302 {ok, direct} -> 303 configure_line_editor(), 304 Fun(), 305 nil 306 end 307 end 308 end. 309 310%% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort). 311%% Returns `child` when this process should run the REPL, or `{parent, Port}` 312%% when we re-exec'd and should wait on the child. 313-spec ensure_plus_bc() -> child | {parent, port()}. 314ensure_plus_bc() -> 315 case os:getenv("GLESHELL_PLUS_BC") of 316 "1" -> 317 child; 318 _ -> 319 case already_has_plus_bc() of 320 true -> 321 os:putenv("GLESHELL_PLUS_BC", "1"), 322 child; 323 false -> 324 reexec_with_plus_bc() 325 end 326 end. 327 328already_has_plus_bc() -> 329 lists:any( 330 fun(Var) -> 331 case os:getenv(Var) of 332 false -> 333 false; 334 Flags -> 335 string:find(Flags, "+Bc") =/= nomatch 336 end 337 end, 338 ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"] 339 ). 340 341reexec_with_plus_bc() -> 342 os:putenv("GLESHELL_PLUS_BC", "1"), 343 case os:getenv("ERL_AFLAGS") of 344 false -> 345 os:putenv("ERL_AFLAGS", "+Bc"); 346 Flags -> 347 case string:find(Flags, "+Bc") of 348 nomatch -> 349 os:putenv("ERL_AFLAGS", "+Bc " ++ Flags); 350 _ -> 351 ok 352 end 353 end, 354 case os:find_executable("erl") of 355 false -> 356 %% No erl on PATH — continue without +Bc (BREAK menu may appear). 357 child; 358 Erl -> 359 Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()), 360 Extra = init:get_plain_arguments(), 361 Args = 362 ["+Bc", "-noshell"] ++ 363 Pa ++ 364 ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra], 365 try 366 Port = open_port( 367 {spawn_executable, Erl}, 368 [exit_status, nouse_stdio, {args, Args}] 369 ), 370 {parent, Port} 371 catch 372 _:_ -> 373 child 374 end 375 end. 376 377try_start_raw() -> 378 case stdout_isatty() of 379 false -> 380 false; 381 true -> 382 case catch shell:start_interactive({noshell, raw}) of 383 ok -> 384 true; 385 {error, already_started} -> 386 %% Cannot switch an existing interactive shell into raw. 387 false; 388 _ -> 389 false 390 end 391 end. 392 393try_start_interactive(Parent, Fun) -> 394 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 395 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 396 ok -> 397 {ok, started}; 398 {error, already_started} -> 399 {ok, direct}; 400 {error, _} -> 401 {ok, direct} 402 end. 403 404-spec spawn_shell(pid(), fun(() -> term())) -> pid(). 405spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 406 spawn(fun() -> 407 try 408 configure_line_editor(), 409 Fun() 410 of 411 _ -> 412 Parent ! {gleshell_shell_done, ok}, 413 exit(die) 414 catch 415 Class:Reason:Stack -> 416 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, 417 erlang:raise(Class, Reason, Stack) 418 end 419 end). 420 421configure_line_editor() -> 422 _ = io:setopts([{encoding, unicode}, binary]), 423 try 424 io:setopts([{line_history, true}]) 425 catch 426 _:_ -> 427 ok 428 end, 429 ok. 430 431enable_shell_history() -> 432 case application:get_env(kernel, shell_history_path) of 433 {ok, _} -> 434 ok; 435 undefined -> 436 Path = filename:basedir(user_cache, "gleshell-history"), 437 _ = application:set_env( 438 kernel, shell_history_path, Path, [{persistent, true}] 439 ), 440 ok 441 end, 442 case application:get_env(kernel, shell_history) of 443 {ok, _} -> 444 ok; 445 undefined -> 446 _ = application:set_env( 447 kernel, shell_history, enabled, [{persistent, true}] 448 ), 449 ok 450 end. 451 452%% --------------------------------------------------------------------------- 453%% Raw-mode line editor with Nushell-style syntax highlighting 454%% --------------------------------------------------------------------------- 455%% 456%% Buffer model: Left is graphemes before the cursor (reversed), 457%% Right is graphemes after the cursor (normal order). 458%% History is a list of binaries (newest first). 459 460raw_get_line(Prompt) when is_binary(Prompt) -> 461 PromptList = unicode:characters_to_list(Prompt), 462 %% Densify every prompt: drop blanks so ↑ never lands on an empty slot 463 %% even if an older session or bug left one in the process dict. 464 History = sanitize_history( 465 case get(gleshell_history) of 466 L when is_list(L) -> L; 467 _ -> [] 468 end 469 ), 470 put(gleshell_history, History), 471 put(gleshell_input_rows, 1), 472 redraw(PromptList, [], []), 473 raw_loop(PromptList, [], [], History, 0, <<>>). 474 475%% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry. 476%% Saved: buffer saved when first entering history navigation. 477raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> 478 case read_key() of 479 eof -> 480 io:put_chars("\r\n"), 481 {error, <<"eof">>}; 482 {error, _} -> 483 io:put_chars("\r\n"), 484 {error, <<"io_error">>}; 485 enter -> 486 Line = buffer_to_bin(Left, Right), 487 io:put_chars("\r\n"), 488 push_history(Line), 489 {ok, Line}; 490 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 491 %% Printable Unicode codepoint 492 NewLeft = [C | Left], 493 redraw(Prompt, NewLeft, Right), 494 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 495 backspace -> 496 case Left of 497 [] -> 498 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 499 [_ | Rest] -> 500 redraw(Prompt, Rest, Right), 501 raw_loop(Prompt, Rest, Right, History, 0, <<>>) 502 end; 503 delete -> 504 case Right of 505 [] -> 506 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 507 [_ | Rest] -> 508 redraw(Prompt, Left, Rest), 509 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 510 end; 511 left -> 512 case Left of 513 [] -> 514 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 515 [C | Rest] -> 516 redraw(Prompt, Rest, [C | Right]), 517 raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved) 518 end; 519 right -> 520 case Right of 521 [] -> 522 %% At end of line: accept greyed-out history hint (Nu/fish). 523 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 524 [C | Rest] -> 525 redraw(Prompt, [C | Left], Rest), 526 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 527 end; 528 home -> 529 NewRight = lists:reverse(Left) ++ Right, 530 redraw(Prompt, [], NewRight), 531 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 532 'end' -> 533 case Right of 534 [] -> 535 %% Already at end: accept full history hint if present. 536 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 537 _ -> 538 NewLeft = lists:reverse(Right) ++ Left, 539 redraw(Prompt, NewLeft, []), 540 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved) 541 end; 542 up -> 543 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 544 down -> 545 hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1); 546 ctrl_a -> 547 NewRight = lists:reverse(Left) ++ Right, 548 redraw(Prompt, [], NewRight), 549 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 550 ctrl_e -> 551 case Right of 552 [] -> 553 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 554 _ -> 555 NewLeft = lists:reverse(Right) ++ Left, 556 redraw(Prompt, NewLeft, []), 557 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved) 558 end; 559 ctrl_f -> 560 %% Emacs-style forward-char; at EOL accepts history hint (like Nu). 561 case Right of 562 [] -> 563 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 564 [C | Rest] -> 565 redraw(Prompt, [C | Left], Rest), 566 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 567 end; 568 alt_f -> 569 %% Accept one word of the history hint (Nu Alt+F). 570 accept_history_hint_word(Prompt, Left, Right, History, HistPos, Saved); 571 ctrl_u -> 572 redraw(Prompt, [], Right), 573 raw_loop(Prompt, [], Right, History, 0, <<>>); 574 ctrl_k -> 575 redraw(Prompt, Left, []), 576 raw_loop(Prompt, Left, [], History, 0, <<>>); 577 ctrl_w -> 578 {NewLeft, _} = kill_word(Left), 579 redraw(Prompt, NewLeft, Right), 580 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 581 ctrl_d -> 582 case {Left, Right} of 583 {[], []} -> 584 io:put_chars("\r\n"), 585 {error, <<"eof">>}; 586 {_, []} -> 587 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 588 {_, [_ | Rest]} -> 589 redraw(Prompt, Left, Rest), 590 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 591 end; 592 ctrl_c -> 593 %% Cancel current line (like bash) and return empty. 594 io:put_chars("^C\r\n"), 595 {ok, <<>>}; 596 ctrl_l -> 597 io:put_chars("\e[H\e[2J"), 598 redraw(Prompt, Left, Right), 599 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 600 ctrl_r -> 601 reverse_search(Prompt, Left, Right, History); 602 tab -> 603 tab_complete(Prompt, Left, Right, History, HistPos, Saved); 604 _Other -> 605 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 606 end. 607 608%% --------------------------------------------------------------------------- 609%% Tab: command + filename completion (token under cursor) 610%% --------------------------------------------------------------------------- 611%% 612%% Command position (start of line / after | ; & =): complete builtins and 613%% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use 614%% filename completion. Elsewhere: filename completion as before. 615%% 616%% One match → insert it (commands get a trailing space; dirs get /). 617%% Several matches → extend the longest common prefix; if that does not 618%% advance the buffer, list candidates under the line and redraw. 619 620tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 621 {PrefixRev, Word} = word_before_cursor(Left), 622 {Matches, Kind} = completions_for(PrefixRev, Word), 623 case Matches of 624 [] -> 625 beep(), 626 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 627 [Only] -> 628 Insert = finalize_completion(Only, Kind), 629 NewLeft = apply_completed_word(PrefixRev, Insert), 630 redraw(Prompt, NewLeft, Right), 631 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 632 _ -> 633 Common = longest_common_prefix(Matches), 634 case Common =/= Word andalso length(Common) >= length(Word) of 635 true -> 636 NewLeft = apply_completed_word(PrefixRev, Common), 637 redraw(Prompt, NewLeft, Right), 638 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 639 false -> 640 show_completions(Matches), 641 redraw(Prompt, Left, Right), 642 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 643 end 644 end. 645 646%% Test/helper: return {Matches, Kind} for a buffer prefix and word. 647%% Prefix is the text *before* the word being completed (not reversed). 648%% Kind is <<"command">> | <<"path">>. 649-spec complete_word(binary(), binary()) -> {list(binary()), binary()}. 650complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) -> 651 Prefix = unicode:characters_to_list(PrefixBin), 652 Word = unicode:characters_to_list(WordBin), 653 PrefixRev = lists:reverse(Prefix), 654 {Matches, Kind} = completions_for(PrefixRev, Word), 655 KindBin = 656 case Kind of 657 command -> <<"command">>; 658 path -> <<"path">> 659 end, 660 { 661 [unicode:characters_to_binary(M) || M <- Matches], 662 KindBin 663 }. 664 665%% Trailing space after a unique command so the user can type args next. 666finalize_completion(Word, command) -> 667 case lists:last(Word) of 668 $/ -> Word; 669 $\s -> Word; 670 _ -> Word ++ " " 671 end; 672finalize_completion(Word, path) -> 673 Word. 674 675completions_for(PrefixRev, Word) -> 676 case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of 677 true -> 678 {command_completions(Word), command}; 679 false -> 680 {filename_completions(Word), path} 681 end. 682 683%% Command position: empty prefix, or last non-space before the word is a 684%% pipeline/statement separator or assignment (`let x = …`). 685is_command_position(PrefixRev) -> 686 Before = string:trim(lists:reverse(PrefixRev), trailing), 687 case Before of 688 [] -> 689 true; 690 _ -> 691 case lists:last(Before) of 692 $| -> true; 693 $; -> true; 694 $& -> true; 695 $= -> true; 696 _ -> false 697 end 698 end. 699 700%% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds. 701is_path_like_word([]) -> 702 false; 703is_path_like_word(Word) -> 704 lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~. 705 706%% Builtins + keywords + PATH executables matching Word as a prefix. 707command_completions(Word) -> 708 Builtins = [ 709 N 710 || N <- builtin_command_names(), 711 lists:prefix(Word, N) 712 ], 713 Keywords = [ 714 N 715 || N <- ["let"], 716 lists:prefix(Word, N) 717 ], 718 PathCmds = 719 case Word of 720 %% Empty prefix: skip PATH dump (can be thousands of names). 721 [] -> 722 []; 723 _ -> 724 path_command_completions(Word) 725 end, 726 lists:usort(Builtins ++ Keywords ++ PathCmds). 727 728%% Prefer live Gleam registry; fall back if the module is not loaded yet. 729builtin_command_names() -> 730 try 731 Names = 'gleshell@builtins':names(), 732 [to_charlist(N) || N <- Names] 733 catch 734 _:_ -> 735 fallback_builtin_names() 736 end. 737 738to_charlist(B) when is_binary(B) -> 739 unicode:characters_to_list(B); 740to_charlist(L) when is_list(L) -> 741 L. 742 743fallback_builtin_names() -> 744 [ 745 "about", "append", "cat", "cd", "columns", "count", "describe", "echo", 746 "env", "exit", "filter", "find", "first", "flatten", "from", "get", 747 "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", 748 "length", "less", "lines", "ls", "open", "prepend", "print", "pwd", 749 "quit", "range", "reverse", "save", "select", "skip", "sort-by", 750 "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", 751 "unwrap", "values", "where", "which", "wrap" 752 ]. 753 754%% Executable basenames on PATH that match Prefix (deduped, sorted). 755path_command_completions(Prefix) -> 756 case os:getenv("PATH") of 757 false -> 758 []; 759 PathStr -> 760 Dirs = string:tokens(PathStr, path_sep()), 761 Acc = lists:foldl( 762 fun(Dir, Seen) -> 763 collect_path_cmds(Dir, Prefix, Seen) 764 end, 765 #{}, 766 Dirs 767 ), 768 lists:sort(maps:keys(Acc)) 769 end. 770 771collect_path_cmds(Dir, Prefix, Seen) -> 772 case file:list_dir(Dir) of 773 {ok, Names} -> 774 lists:foldl( 775 fun(Name, Acc) -> 776 case 777 lists:prefix(Prefix, Name) 778 andalso show_dotfile(Prefix, Name) 779 andalso not maps:is_key(Name, Acc) 780 andalso is_executable_file(filename:join(Dir, Name)) 781 of 782 true -> 783 Acc#{Name => true}; 784 false -> 785 Acc 786 end 787 end, 788 Seen, 789 Names 790 ); 791 {error, _} -> 792 Seen 793 end. 794 795beep() -> 796 io:put_chars([7]). 797 798%% Left is graphemes before the cursor in reverse order. 799%% Returns {PrefixRev, WordForward} where Word is the path token. 800word_before_cursor(Left) -> 801 take_completion_word(Left, []). 802 803%% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward. 804take_completion_word([], Acc) -> 805 {[], Acc}; 806take_completion_word([C | Rest], Acc) -> 807 case is_completion_break(C) of 808 true -> 809 {[C | Rest], Acc}; 810 false -> 811 take_completion_word(Rest, [C | Acc]) 812 end. 813 814is_completion_break(C) when C =:= $\s; C =:= $\t -> 815 true; 816is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& -> 817 true; 818is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] -> 819 true; 820is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> -> 821 true; 822is_completion_break(C) when C =:= $'; C =:= $" -> 823 true; 824is_completion_break(_) -> 825 false. 826 827apply_completed_word(PrefixRev, Word) -> 828 lists:reverse(Word) ++ PrefixRev. 829 830%% Return sorted completion strings (as typed, with ~ preserved; dirs end in /). 831filename_completions(Word) -> 832 {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word), 833 ListDir = expand_home_path(ListDirTyped), 834 case file:list_dir(ListDir) of 835 {ok, Names0} -> 836 Names = lists:sort(Names0), 837 [ 838 InsertPrefix ++ maybe_dir_slash(ListDir, Name) 839 || Name <- Names, 840 lists:prefix(Base, Name), 841 show_dotfile(Base, Name) 842 ]; 843 {error, _} -> 844 [] 845 end. 846 847%% Hide dotfiles unless the partial name already starts with '.'. 848show_dotfile([$. | _], _) -> 849 true; 850show_dotfile(_, [$. | _]) -> 851 false; 852show_dotfile(_, _) -> 853 true. 854 855maybe_dir_slash(ListDir, Name) -> 856 case filelib:is_dir(filename:join(ListDir, Name)) of 857 true -> Name ++ "/"; 858 false -> Name 859 end. 860 861%% Split a path word into {dir_to_list, basename_prefix, insert_prefix}. 862%% insert_prefix is the directory part as the user typed it (incl. trailing /). 863split_completion_word(Word) -> 864 case rsplit_path(Word) of 865 {none, Base} -> 866 {".", Base, ""}; 867 {Dir, Base} -> 868 ListDir = 869 case Dir of 870 "" -> "/"; 871 _ -> Dir 872 end, 873 InsertPrefix = 874 case Dir of 875 "" -> "/"; 876 _ -> Dir ++ "/" 877 end, 878 {ListDir, Base, InsertPrefix} 879 end. 880 881%% Rightmost / splits directory from the partial basename. 882rsplit_path(Word) -> 883 rsplit_path(lists:reverse(Word), []). 884 885rsplit_path([], Acc) -> 886 {none, Acc}; 887rsplit_path([$/ | Rest], Acc) -> 888 {lists:reverse(Rest), Acc}; 889rsplit_path([C | Rest], Acc) -> 890 rsplit_path(Rest, [C | Acc]). 891 892expand_home_path(Path) -> 893 case Path of 894 "~" -> 895 home_path_string(); 896 [$~, $/ | More] -> 897 home_path_string() ++ "/" ++ More; 898 _ -> 899 Path 900 end. 901 902home_path_string() -> 903 case os:getenv("HOME") of 904 false -> "."; 905 Home when is_list(Home) -> Home; 906 Home when is_binary(Home) -> unicode:characters_to_list(Home) 907 end. 908 909longest_common_prefix([]) -> 910 ""; 911longest_common_prefix([H | T]) -> 912 lists:foldl(fun lcp2/2, H, T). 913 914lcp2(A, B) -> 915 lcp2(A, B, []). 916 917lcp2([X | As], [X | Bs], Acc) -> 918 lcp2(As, Bs, [X | Acc]); 919lcp2(_, _, Acc) -> 920 lists:reverse(Acc). 921 922show_completions(Matches) -> 923 io:put_chars("\r\n"), 924 case io:columns() of 925 {ok, Cols} when is_integer(Cols), Cols > 8 -> 926 print_completion_columns(Matches, Cols); 927 _ -> 928 io:put_chars(lists:join(" ", Matches)), 929 io:put_chars("\r\n") 930 end. 931 932print_completion_columns(Matches, Cols) -> 933 MaxLen = lists:max([0 | [length(M) || M <- Matches]]), 934 Width = MaxLen + 2, 935 PerRow = max(1, Cols div Width), 936 print_rows(Matches, PerRow, Width). 937 938print_rows([], _PerRow, _Width) -> 939 ok; 940print_rows(Matches, PerRow, Width) -> 941 {Row, Rest} = take_n(Matches, PerRow, []), 942 Line = [ 943 pad_cell(M, Width) 944 || M <- Row 945 ], 946 io:put_chars([Line, "\r\n"]), 947 print_rows(Rest, PerRow, Width). 948 949take_n(List, 0, Acc) -> 950 {lists:reverse(Acc), List}; 951take_n([], _N, Acc) -> 952 {lists:reverse(Acc), []}; 953take_n([H | T], N, Acc) -> 954 take_n(T, N - 1, [H | Acc]). 955 956pad_cell(S, Width) -> 957 Pad = Width - length(S), 958 case Pad > 0 of 959 true -> S ++ lists:duplicate(Pad, $\s); 960 false -> S ++ " " 961 end. 962 963hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 964 case hist_seek(History, HistPos, Delta) of 965 stay -> 966 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 967 draft -> 968 %% Restore the buffer from before history navigation. 969 {L, R} = bin_to_buffer(Saved), 970 redraw(Prompt, L, R), 971 raw_loop(Prompt, L, R, History, 0, <<>>); 972 {NewPos, Entry} -> 973 NewSaved = case HistPos of 974 0 -> buffer_to_bin(Left, Right); 975 _ -> Saved 976 end, 977 %% Belt-and-suspenders: never paint a blank recall (dense History 978 %% should already exclude these). 979 case history_blank(Entry) of 980 true -> 981 hist_nav(Prompt, Left, Right, History, NewPos, NewSaved, Delta); 982 false -> 983 {L, R} = bin_to_buffer(Entry), 984 redraw(Prompt, L, R), 985 raw_loop(Prompt, L, R, History, NewPos, NewSaved) 986 end 987 end. 988 989%% Walk history in `Delta` direction, skipping blank/whitespace-only entries. 990%% Delta > 0 = older (↑); Delta < 0 = newer (↓). Position 0 is the live draft. 991%% History is expected newest-first and already densified (no blanks), but we 992%% still skip blanks so a stale list cannot surface an empty ↑ recall. 993hist_seek(_History, 0, Delta) when Delta < 0 -> 994 stay; 995hist_seek(History, HistPos, Delta) when Delta > 0 -> 996 hist_seek_loop(History, HistPos + 1, 1, length(History)); 997hist_seek(History, HistPos, Delta) when Delta < 0 -> 998 hist_seek_loop(History, HistPos - 1, -1, length(History)). 999 1000hist_seek_loop(_History, Pos, Step, _Len) when Step < 0, Pos =< 0 -> 1001 draft; 1002hist_seek_loop(_History, Pos, Step, Len) when Step > 0, Pos > Len -> 1003 stay; 1004hist_seek_loop(History, Pos, Step, Len) when Pos >= 1, Pos =< Len -> 1005 Entry = lists:nth(Pos, History), 1006 case history_blank(Entry) of 1007 true -> 1008 hist_seek_loop(History, Pos + Step, Step, Len); 1009 false -> 1010 {Pos, Entry} 1011 end; 1012hist_seek_loop(_History, _Pos, _Step, _Len) -> 1013 stay. 1014 1015%% --------------------------------------------------------------------------- 1016%% Ctrl+R reverse history search — stinkpot-style multi-match TUI 1017%% (https://tangled.org/oppi.li/stinkpot) 1018%% 1019%% Fuzzy filter over newest-first history, list up to 12 rows, ↑/↓ move, 1020%% Enter/Tab accept onto the line (does not execute), Esc/Ctrl+C/Ctrl+G cancel. 1021%% Runs on the alternate screen so the REPL is restored cleanly. 1022%% --------------------------------------------------------------------------- 1023 1024reverse_search(Prompt, Left, Right, History) -> 1025 %% Seed query from the current buffer (like stinkpot's READLINE_LINE). 1026 QueryChars = 1027 case unicode:characters_to_list(lists:reverse(Left) ++ Right) of 1028 L when is_list(L) -> L; 1029 _ -> [] 1030 end, 1031 Candidates = uniq_history(History), 1032 Filtered = history_search_filter(Candidates, QueryChars), 1033 enter_alt_screen(), 1034 Result = reverse_search_loop(Candidates, QueryChars, Filtered, 0), 1035 leave_alt_screen(), 1036 put(gleshell_input_rows, 1), 1037 case Result of 1038 {ok, Selected} when is_binary(Selected) -> 1039 reverse_search_accept(Prompt, History, Selected); 1040 cancel -> 1041 redraw(Prompt, Left, Right), 1042 raw_loop(Prompt, Left, Right, History, 0, <<>>); 1043 eof -> 1044 io:put_chars("\r\n"), 1045 {error, <<"eof">>}; 1046 _ -> 1047 redraw(Prompt, Left, Right), 1048 raw_loop(Prompt, Left, Right, History, 0, <<>>) 1049 end. 1050 1051reverse_search_accept(Prompt, History, Selected) -> 1052 case history_blank(Selected) of 1053 true -> 1054 redraw(Prompt, [], []), 1055 raw_loop(Prompt, [], [], History, 0, <<>>); 1056 false -> 1057 {L, R} = bin_to_buffer(Selected), 1058 redraw(Prompt, L, R), 1059 raw_loop(Prompt, L, R, History, 0, <<>>) 1060 end. 1061 1062reverse_search_loop(All, Query, Filtered, Cursor0) -> 1063 Cursor = clamp_cursor(Cursor0, length(Filtered)), 1064 draw_search_ui(Query, Filtered, Cursor), 1065 case read_key() of 1066 eof -> 1067 eof; 1068 {error, _} -> 1069 eof; 1070 enter -> 1071 pick_filtered(Filtered, Cursor); 1072 tab -> 1073 pick_filtered(Filtered, Cursor); 1074 esc -> 1075 cancel; 1076 ctrl_c -> 1077 cancel; 1078 ctrl_g -> 1079 cancel; 1080 up -> 1081 reverse_search_loop(All, Query, Filtered, max(0, Cursor - 1)); 1082 ctrl_p -> 1083 reverse_search_loop(All, Query, Filtered, max(0, Cursor - 1)); 1084 down -> 1085 reverse_search_loop( 1086 All, Query, Filtered, min(length(Filtered) - 1, Cursor + 1) 1087 ); 1088 ctrl_n -> 1089 reverse_search_loop( 1090 All, Query, Filtered, min(length(Filtered) - 1, Cursor + 1) 1091 ); 1092 backspace -> 1093 NewQuery = 1094 case Query of 1095 [] -> []; 1096 [_ | _] -> lists:droplast(Query) 1097 end, 1098 NewFiltered = history_search_filter(All, NewQuery), 1099 reverse_search_loop(All, NewQuery, NewFiltered, 0); 1100 delete -> 1101 reverse_search_loop(All, Query, Filtered, Cursor); 1102 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 1103 NewQuery = Query ++ [C], 1104 NewFiltered = history_search_filter(All, NewQuery), 1105 reverse_search_loop(All, NewQuery, NewFiltered, 0); 1106 _ -> 1107 reverse_search_loop(All, Query, Filtered, Cursor) 1108 end. 1109 1110pick_filtered([], _Cursor) -> 1111 cancel; 1112pick_filtered(Filtered, Cursor) when Cursor >= 0, Cursor < length(Filtered) -> 1113 Cmd = lists:nth(Cursor + 1, Filtered), 1114 Bin = 1115 case Cmd of 1116 B when is_binary(B) -> B; 1117 L when is_list(L) -> unicode:characters_to_binary(L); 1118 _ -> <<>> 1119 end, 1120 {ok, Bin}; 1121pick_filtered(_, _) -> 1122 cancel. 1123 1124clamp_cursor(_C, 0) -> 1125 0; 1126clamp_cursor(C, _Len) when C < 0 -> 1127 0; 1128clamp_cursor(C, Len) when C >= Len -> 1129 Len - 1; 1130clamp_cursor(C, _Len) -> 1131 C. 1132 1133enter_alt_screen() -> 1134 io:put_chars("\e[?1049h\e[H\e[2J"). 1135 1136leave_alt_screen() -> 1137 io:put_chars("\e[?1049l"). 1138 1139draw_search_ui(Query, Filtered, Cursor) -> 1140 %% Full repaint from home — alternate screen, so wipe + rewrite is fine. 1141 io:put_chars("\e[H\e[J"), 1142 QueryLine = search_query_line(Query), 1143 io:put_chars([QueryLine, "\r\n"]), 1144 Len = length(Filtered), 1145 Start = 1146 case Cursor >= ?SEARCH_MAX_ROWS of 1147 true -> Cursor - ?SEARCH_MAX_ROWS + 1; 1148 false -> 0 1149 end, 1150 End = min(Start + ?SEARCH_MAX_ROWS, Len), 1151 draw_search_rows(Filtered, Start, End, Cursor), 1152 Footer = search_footer(Len), 1153 io:put_chars(Footer), 1154 %% Park the cursor at the end of the query line. 1155 Col = 2 + length(Query), 1156 io:put_chars(["\e[H", "\e[", integer_to_list(Col + 1), $G]). 1157 1158search_query_line([]) -> 1159 case get(gleshell_color) of 1160 false -> 1161 "> search history..."; 1162 _ -> 1163 ["> ", "\e[2m", "search history...", "\e[0m"] 1164 end; 1165search_query_line(Query) when is_list(Query) -> 1166 ["> ", Query]. 1167 1168draw_search_rows(_Filtered, Start, End, _Cursor) when Start >= End -> 1169 ok; 1170draw_search_rows(Filtered, Start, End, Cursor) -> 1171 draw_search_rows_loop(Filtered, Start, End, Cursor). 1172 1173draw_search_rows_loop(_Filtered, I, End, _Cursor) when I >= End -> 1174 ok; 1175draw_search_rows_loop(Filtered, I, End, Cursor) -> 1176 Cmd0 = lists:nth(I + 1, Filtered), 1177 CmdList = 1178 case Cmd0 of 1179 Bin when is_binary(Bin) -> 1180 case unicode:characters_to_list(Bin) of 1181 L when is_list(L) -> L; 1182 _ -> [] 1183 end; 1184 L when is_list(L) -> L; 1185 _ -> [] 1186 end, 1187 %% Flatten newlines so a multi-line history entry stays one row. 1188 Line = [case C of $\n -> $\s; $\r -> $\s; _ -> C end || C <- CmdList], 1189 case I =:= Cursor of 1190 true -> 1191 case get(gleshell_color) of 1192 false -> 1193 io:put_chars([" ", Line, "\r\n"]); 1194 _ -> 1195 %% Bold blue — stinkpot styleCursor (lipgloss color "4"). 1196 io:put_chars([" ", "\e[1;34m", Line, "\e[0m", "\r\n"]) 1197 end; 1198 false -> 1199 io:put_chars([" ", Line, "\r\n"]) 1200 end, 1201 draw_search_rows_loop(Filtered, I + 1, End, Cursor). 1202 1203search_footer(N) -> 1204 %% Unicode arrows match stinkpot's footer copy. 1205 Text = [ 1206 " ", 1207 integer_to_list(N), 1208 " matches · ↑/↓ move · enter accept · esc cancel" 1209 ], 1210 case get(gleshell_color) of 1211 false -> 1212 Text; 1213 _ -> 1214 ["\e[2m", Text, "\e[0m"] 1215 end. 1216 1217%% Dedupe history preserving newest-first order (stinkpot stores unique cmds). 1218uniq_history(Hist) when is_list(Hist) -> 1219 uniq_history(Hist, #{}, []); 1220uniq_history(_) -> 1221 []. 1222 1223uniq_history([], _Seen, Acc) -> 1224 lists:reverse(Acc); 1225uniq_history([H | T], Seen, Acc) -> 1226 Key = 1227 case H of 1228 B when is_binary(B) -> B; 1229 L when is_list(L) -> unicode:characters_to_binary(L); 1230 _ -> <<>> 1231 end, 1232 case history_blank(Key) orelse maps:is_key(Key, Seen) of 1233 true -> 1234 uniq_history(T, Seen, Acc); 1235 false -> 1236 uniq_history(T, Seen#{Key => true}, [Key | Acc]) 1237 end. 1238 1239%% Test helper / filter: history (newest first binaries) + query → filtered list. 1240-spec history_search(list(binary()), binary()) -> list(binary()). 1241history_search(History, Query) when is_list(History), is_binary(Query) -> 1242 QList = 1243 case unicode:characters_to_list(Query) of 1244 L when is_list(L) -> L; 1245 _ -> [] 1246 end, 1247 history_search_filter(uniq_history(History), QList); 1248history_search(_, _) -> 1249 []. 1250 1251%% Empty / whitespace-only query → all candidates (newest first). 1252%% Otherwise fuzzy subsequence match (case-insensitive), best scores first; 1253%% ties keep newest-first order via stable index. 1254history_search_filter(Candidates, Query) when is_list(Candidates), is_list(Query) -> 1255 case string:trim(Query) of 1256 [] -> 1257 Candidates; 1258 QTrim -> 1259 QLower = to_lower_chars(QTrim), 1260 Scored = score_candidates(Candidates, QLower, 0, []), 1261 Sorted = lists:sort( 1262 fun({Sa, Ia, _}, {Sb, Ib, _}) -> 1263 case Sa =:= Sb of 1264 true -> Ia =< Ib; 1265 false -> Sa > Sb 1266 end 1267 end, 1268 Scored 1269 ), 1270 [Cmd || {_S, _I, Cmd} <- Sorted] 1271 end; 1272history_search_filter(_, _) -> 1273 []. 1274 1275score_candidates([], _Q, _I, Acc) -> 1276 Acc; 1277score_candidates([Cmd | Rest], Q, I, Acc) -> 1278 CmdList = 1279 case Cmd of 1280 Bin when is_binary(Bin) -> 1281 case unicode:characters_to_list(Bin) of 1282 L when is_list(L) -> L; 1283 _ -> [] 1284 end; 1285 L when is_list(L) -> L; 1286 _ -> [] 1287 end, 1288 case fuzzy_score(Q, to_lower_chars(CmdList)) of 1289 nomatch -> 1290 score_candidates(Rest, Q, I + 1, Acc); 1291 Score when is_integer(Score) -> 1292 score_candidates(Rest, Q, I + 1, [{Score, I, Cmd} | Acc]) 1293 end. 1294 1295%% Case-insensitive subsequence fuzzy score (inspired by sahilm/fuzzy used by 1296%% stinkpot). Higher is better. Consecutive matches and early matches score up. 1297fuzzy_score([], _Cand) -> 1298 0; 1299fuzzy_score(Query, Cand) -> 1300 fuzzy_score(Query, Cand, 0, 0, -2, 0). 1301 1302fuzzy_score([], _Cand, Score, _Idx, _Last, _Run) -> 1303 Score; 1304fuzzy_score([_ | _], [], _Score, _Idx, _Last, _Run) -> 1305 nomatch; 1306fuzzy_score([Q | Qs], [C | Cs], Score, Idx, Last, Run) when Q =:= C -> 1307 Consecutive = 1308 case Idx =:= Last + 1 of 1309 true -> Run + 1; 1310 false -> 0 1311 end, 1312 %% Base hit + bonus for runs; slight preference for earlier positions. 1313 Bonus = Consecutive * 4, 1314 Early = max(0, 32 - Idx), 1315 fuzzy_score(Qs, Cs, Score + 16 + Bonus + Early, Idx + 1, Idx, Consecutive); 1316fuzzy_score(Q, [_ | Cs], Score, Idx, Last, _Run) -> 1317 fuzzy_score(Q, Cs, Score, Idx + 1, Last, 0). 1318 1319to_lower_chars(List) when is_list(List) -> 1320 [ 1321 case C of 1322 X when is_integer(X), X >= $A, X =< $Z -> X + 32; 1323 X -> X 1324 end 1325 || C <- List 1326 ]; 1327to_lower_chars(_) -> 1328 []. 1329 1330kill_word([]) -> 1331 {[], []}; 1332kill_word(Left) -> 1333 %% Left is reversed: strip trailing spaces then a word. 1334 L1 = drop_while_space(Left), 1335 drop_while_word(L1). 1336 1337drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t -> 1338 drop_while_space(Rest); 1339drop_while_space(L) -> 1340 L. 1341 1342drop_while_word([]) -> 1343 {[], []}; 1344drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1345 {[C | Rest], []}; 1346drop_while_word([_ | Rest]) -> 1347 drop_while_word(Rest). 1348 1349redraw(Prompt, Left, Right) -> 1350 Full = lists:reverse(Left) ++ Right, 1351 FullBin = 1352 case unicode:characters_to_binary(Full) of 1353 Bin when is_binary(Bin) -> Bin; 1354 _ -> <<>> 1355 end, 1356 Colored = highlight_line(FullBin), 1357 %% Fish/Nu-style ghost text: grey suffix of the newest history match. 1358 %% Only when the cursor is at end of the buffer (Right == []). 1359 HintChars = 1360 case Right of 1361 [] -> history_hint_suffix(Full); 1362 _ -> [] 1363 end, 1364 HintPainted = paint_history_hint(HintChars), 1365 HintBin = 1366 case unicode:characters_to_binary(HintChars) of 1367 HB when is_binary(HB) -> HB; 1368 _ -> <<>> 1369 end, 1370 %% Clear every physical row the previous render occupied. A longer history 1371 %% entry can soft-wrap; `\e[2K` alone only erases the current row, so a 1372 %% shorter recall (or empty draft) used to leave a blank-looking row and 1373 %% stale wrap residue that felt like an empty ↑ slot. 1374 clear_input_rows(), 1375 io:put_chars([Prompt, Colored, HintPainted]), 1376 %% Include hint width so multi-line wrap clears correctly on next redraw. 1377 Rows = count_input_rows(Prompt, <<FullBin/binary, HintBin/binary>>), 1378 put(gleshell_input_rows, Rows), 1379 %% Cursor sits after typed text, before the grey hint (and before Right). 1380 CursorBack = length(HintChars) + length(Right), 1381 case CursorBack of 1382 0 -> 1383 ok; 1384 N -> 1385 %% Best-effort: codepoint count ≈ columns for ASCII-heavy input. 1386 io:put_chars(["\e[", integer_to_list(N), $D]) 1387 end. 1388 1389%% Accept the full greyed-out history suggestion into the buffer. 1390accept_history_hint(Prompt, Left, Right, History, HistPos, Saved) -> 1391 case Right of 1392 [] -> 1393 case history_hint_suffix(lists:reverse(Left)) of 1394 [] -> 1395 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1396 Suffix -> 1397 %% Left is reversed; append Suffix at end of buffer. 1398 NewLeft = lists:reverse(Suffix) ++ Left, 1399 redraw(Prompt, NewLeft, []), 1400 raw_loop(Prompt, NewLeft, [], History, 0, <<>>) 1401 end; 1402 _ -> 1403 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 1404 end. 1405 1406%% Accept the next word of the history suggestion (leading space + word). 1407accept_history_hint_word(Prompt, Left, Right, History, HistPos, Saved) -> 1408 case Right of 1409 [] -> 1410 case history_hint_suffix(lists:reverse(Left)) of 1411 [] -> 1412 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1413 Suffix -> 1414 Word = take_hint_word(Suffix), 1415 case Word of 1416 [] -> 1417 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1418 _ -> 1419 NewLeft = lists:reverse(Word) ++ Left, 1420 redraw(Prompt, NewLeft, []), 1421 raw_loop(Prompt, NewLeft, [], History, 0, <<>>) 1422 end 1423 end; 1424 _ -> 1425 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 1426 end. 1427 1428%% First token of a hint: optional leading whitespace + following non-space run. 1429take_hint_word([]) -> 1430 []; 1431take_hint_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1432 [C | take_hint_nonspace(Rest)]; 1433take_hint_word(Rest) -> 1434 take_hint_nonspace(Rest). 1435 1436take_hint_nonspace([]) -> 1437 []; 1438take_hint_nonspace([C | _Rest]) when C =:= $\s; C =:= $\t -> 1439 []; 1440take_hint_nonspace([C | Rest]) -> 1441 [C | take_hint_nonspace(Rest)]. 1442 1443%% Newest-first history: suffix of the first entry that has Buffer as a proper prefix. 1444history_hint_suffix([]) -> 1445 []; 1446history_hint_suffix(Buffer) when is_list(Buffer) -> 1447 Hist = 1448 case get(gleshell_history) of 1449 L when is_list(L) -> L; 1450 _ -> [] 1451 end, 1452 history_hint_suffix(Hist, Buffer). 1453 1454history_hint_suffix([], _Buffer) -> 1455 []; 1456%% Empty buffer: never ghost the whole previous command (Nu default). 1457history_hint_suffix(_History, []) -> 1458 []; 1459history_hint_suffix([H | T], Buffer) -> 1460 HList = 1461 case H of 1462 Bin when is_binary(Bin) -> unicode:characters_to_list(Bin); 1463 List when is_list(List) -> List; 1464 _ -> [] 1465 end, 1466 case is_list(HList) andalso lists:prefix(Buffer, HList) of 1467 true when length(HList) > length(Buffer) -> 1468 lists:nthtail(length(Buffer), HList); 1469 _ -> 1470 history_hint_suffix(T, Buffer) 1471 end. 1472 1473paint_history_hint([]) -> 1474 []; 1475paint_history_hint(Chars) -> 1476 case get(gleshell_color) of 1477 false -> 1478 Chars; 1479 _ -> 1480 %% Dark gray — same as Nu `color_config.hints` / shape_nothing. 1481 ["\e[90m", Chars, "\e[0m"] 1482 end. 1483 1484%% Test helper: given history (newest first) and buffer, return grey suffix binary. 1485-spec history_hint(list(binary()), binary()) -> binary(). 1486history_hint(History, Buffer) when is_list(History), is_binary(Buffer) -> 1487 BufList = 1488 case unicode:characters_to_list(Buffer) of 1489 L when is_list(L) -> L; 1490 _ -> [] 1491 end, 1492 case history_hint_suffix(History, BufList) of 1493 [] -> 1494 <<>>; 1495 Suffix -> 1496 case unicode:characters_to_binary(Suffix) of 1497 Bin when is_binary(Bin) -> Bin; 1498 _ -> <<>> 1499 end 1500 end. 1501 1502%% Move to the start of the previous input block and erase its rows. 1503clear_input_rows() -> 1504 Rows0 = 1505 case get(gleshell_input_rows) of 1506 N when is_integer(N), N > 0 -> N; 1507 _ -> 1 1508 end, 1509 %% Cap so a bad columns() estimate cannot wipe the path line above the prompt. 1510 Rows = min(Rows0, 16), 1511 %% Cursor sits on the last physical row of the previous draw when Right=[]. 1512 case Rows of 1513 1 -> 1514 io:put_chars([$\r, "\e[2K"]); 1515 _ -> 1516 Up = Rows - 1, 1517 io:put_chars([$\r, "\e[", integer_to_list(Up), $A, "\e[J"]) 1518 end. 1519 1520%% How many terminal rows does prompt+line occupy (ANSI-aware, 1-col glyphs). 1521count_input_rows(Prompt, FullBin) -> 1522 Cols = 1523 case io:columns() of 1524 {ok, C} when is_integer(C), C >= 8 -> C; 1525 {ok, C} when is_integer(C), C > 0 -> 8; 1526 _ -> 80 1527 end, 1528 PW = visible_width(Prompt), 1529 FW = visible_width(FullBin), 1530 Total = PW + FW, 1531 case Total =< 0 of 1532 true -> 1; 1533 false -> min(16, (Total + Cols - 1) div Cols) 1534 end. 1535 1536%% Visible column count ignoring CSI (ESC [ … final). Wide glyphs count as 1 1537%% (same approximation as the pager); good enough to clear wrap residue. 1538visible_width(Data) when is_binary(Data) -> 1539 visible_width(unicode:characters_to_list(Data)); 1540visible_width(List) when is_list(List) -> 1541 visible_width_loop(List, 0, normal); 1542visible_width(_) -> 1543 0. 1544 1545visible_width_loop([], Acc, _) -> 1546 Acc; 1547visible_width_loop([16#1b | Rest], Acc, normal) -> 1548 visible_width_loop(Rest, Acc, esc); 1549visible_width_loop([_C | Rest], Acc, normal) -> 1550 visible_width_loop(Rest, Acc + 1, normal); 1551visible_width_loop([$[ | Rest], Acc, esc) -> 1552 visible_width_loop(Rest, Acc, csi); 1553visible_width_loop([_ | Rest], Acc, esc) -> 1554 visible_width_loop(Rest, Acc, normal); 1555visible_width_loop([C | Rest], Acc, csi) when C >= 16#40, C =< 16#7e -> 1556 visible_width_loop(Rest, Acc, normal); 1557visible_width_loop([_ | Rest], Acc, csi) -> 1558 visible_width_loop(Rest, Acc, csi). 1559 1560highlight_line(Bin) when is_binary(Bin) -> 1561 case get(gleshell_color) of 1562 false -> 1563 Bin; 1564 _ -> 1565 try 1566 case 'gleshell@highlight':line(Bin) of 1567 Out when is_binary(Out) -> Out; 1568 Out when is_list(Out) -> unicode:characters_to_binary(Out); 1569 _ -> Bin 1570 end 1571 catch 1572 _:_ -> 1573 Bin 1574 end 1575 end. 1576 1577buffer_to_bin(Left, Right) -> 1578 unicode:characters_to_binary(lists:reverse(Left) ++ Right). 1579 1580bin_to_buffer(Bin) when is_binary(Bin) -> 1581 Chars = unicode:characters_to_list(Bin), 1582 {lists:reverse(Chars), []}; 1583bin_to_buffer(List) when is_list(List) -> 1584 {lists:reverse(List), []}. 1585 1586%% --------------------------------------------------------------------------- 1587%% Key reading (raw mode — keys arrive as soon as pressed) 1588%% --------------------------------------------------------------------------- 1589 1590read_key() -> 1591 case io:get_chars("", 1) of 1592 eof -> 1593 eof; 1594 {error, Reason} -> 1595 {error, Reason}; 1596 <<C/utf8>> -> 1597 decode_key(C, <<>>); 1598 [C] when is_integer(C) -> 1599 decode_key(C, <<>>); 1600 Bin when is_binary(Bin), byte_size(Bin) > 0 -> 1601 case unicode:characters_to_list(Bin) of 1602 [C | _] -> decode_key(C, <<>>); 1603 _ -> read_key() 1604 end; 1605 List when is_list(List), List =/= [] -> 1606 decode_key(hd(List), <<>>); 1607 _ -> 1608 read_key() 1609 end. 1610 1611decode_key($\r, _) -> enter; 1612decode_key($\n, _) -> enter; 1613decode_key($\t, _) -> tab; 1614decode_key(127, _) -> backspace; 1615decode_key($\b, _) -> backspace; 1616decode_key(1, _) -> ctrl_a; 1617decode_key(5, _) -> ctrl_e; 1618decode_key(4, _) -> ctrl_d; 1619decode_key(3, _) -> ctrl_c; 1620decode_key(11, _) -> ctrl_k; 1621decode_key(21, _) -> ctrl_u; 1622decode_key(23, _) -> ctrl_w; 1623decode_key(12, _) -> ctrl_l; 1624decode_key(18, _) -> ctrl_r; 1625decode_key(6, _) -> ctrl_f; 1626decode_key(16, _) -> ctrl_p; 1627decode_key(14, _) -> ctrl_n; 1628decode_key(7, _) -> ctrl_g; 1629decode_key(?ESC, _) -> 1630 read_escape(); 1631decode_key(C, _) when is_integer(C), C >= 32 -> 1632 {char, C}; 1633decode_key(_, _) -> 1634 other. 1635 1636read_escape() -> 1637 case io:get_chars("", 1) of 1638 eof -> 1639 %% Lone ESC (no follow-up) — treat as Escape. 1640 esc; 1641 <<?ESC>> -> 1642 %% ESC ESC 1643 esc; 1644 <<"[">> -> 1645 read_csi(); 1646 <<$O>> -> 1647 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 1648 case io:get_chars("", 1) of 1649 <<"A">> -> up; 1650 <<"B">> -> down; 1651 <<"C">> -> right; 1652 <<"D">> -> left; 1653 <<"H">> -> home; 1654 <<"F">> -> 'end'; 1655 _ -> other 1656 end; 1657 %% Alt+letter arrives as ESC then the letter (meta). 1658 <<$f>> -> 1659 alt_f; 1660 <<$F>> -> 1661 alt_f; 1662 _ -> 1663 %% Unknown ESC sequence — Escape is the usual cancel key in TUIs. 1664 esc 1665 end. 1666 1667read_csi() -> 1668 read_csi_params([]). 1669 1670read_csi_params(Acc) -> 1671 case io:get_chars("", 1) of 1672 eof -> 1673 other; 1674 <<C/utf8>> when C >= $0, C =< $9 -> 1675 read_csi_params([C | Acc]); 1676 <<$;>> -> 1677 read_csi_params([$; | Acc]); 1678 <<$~>> -> 1679 Params = lists:reverse(Acc), 1680 case Params of 1681 "1" -> home; 1682 "3" -> delete; 1683 "4" -> 'end'; 1684 "5" -> page_up; 1685 "6" -> page_down; 1686 "7" -> home; 1687 "8" -> 'end'; 1688 _ -> other 1689 end; 1690 <<"A">> -> 1691 up; 1692 <<"B">> -> 1693 down; 1694 <<"C">> -> 1695 right; 1696 <<"D">> -> 1697 left; 1698 <<"H">> -> 1699 home; 1700 <<"F">> -> 1701 'end'; 1702 _ -> 1703 other 1704 end. 1705 1706%% --------------------------------------------------------------------------- 1707%% History persistence 1708%% --------------------------------------------------------------------------- 1709 1710history_file() -> 1711 case application:get_env(kernel, shell_history_path) of 1712 {ok, Path} when is_list(Path) -> 1713 filename:join(Path, "lines"); 1714 {ok, Path} when is_binary(Path) -> 1715 filename:join(unicode:characters_to_list(Path), "lines"); 1716 _ -> 1717 filename:join( 1718 filename:basedir(user_cache, "gleshell-history"), "lines" 1719 ) 1720 end. 1721 1722load_line_history() -> 1723 File = history_file(), 1724 case file:read_file(File) of 1725 {ok, Bin} -> 1726 Lines0 = [ 1727 string:trim(L, trailing, [$\r]) 1728 || L <- binary:split(Bin, <<"\n">>, [global]) 1729 ], 1730 %% Newest first; sanitize drops blanks / ANSI-only / ZWSP-only. 1731 put(gleshell_history, sanitize_history(lists:reverse(Lines0))); 1732 _ -> 1733 put(gleshell_history, []) 1734 end, 1735 put(gleshell_color, true), 1736 ok. 1737 1738save_line_history() -> 1739 case get(gleshell_history) of 1740 Hist when is_list(Hist) -> 1741 File = history_file(), 1742 _ = filelib:ensure_dir(File), 1743 %% Store oldest-first for human readability; drop blanks. 1744 Kept = lists:sublist(sanitize_history(Hist), ?HISTORY_MAX), 1745 Body = [[L, $\n] || L <- lists:reverse(Kept)], 1746 _ = file:write_file(File, Body), 1747 ok; 1748 _ -> 1749 ok 1750 end. 1751 1752%% Drop blank entries; keep order (newest first). 1753sanitize_history(Hist) when is_list(Hist) -> 1754 [L || L <- Hist, not history_blank(L)]; 1755sanitize_history(_) -> 1756 []. 1757 1758%% Empty / whitespace-only / ANSI-only / zero-width-only lines look blank when 1759%% recalled with ↑ — never store or navigate to them. 1760history_blank(Bin) when is_binary(Bin) -> 1761 history_blank_visible(string:trim(Bin)); 1762history_blank(List) when is_list(List) -> 1763 case unicode:characters_to_binary(List) of 1764 Bin when is_binary(Bin) -> 1765 history_blank_visible(string:trim(Bin)); 1766 _ -> 1767 true 1768 end; 1769history_blank(_) -> 1770 true. 1771 1772history_blank_visible(Bin) when is_binary(Bin) -> 1773 case strip_ansi_bin(Bin) of 1774 <<>> -> 1775 true; 1776 Visible -> 1777 case unicode:characters_to_list(Visible) of 1778 List when is_list(List), List =/= [] -> 1779 %% ZWSP / ZWNJ / ZWJ / BOM / soft hyphen — render as empty. 1780 lists:all( 1781 fun(C) -> 1782 C =:= 16#200B orelse C =:= 16#200C orelse 1783 C =:= 16#200D orelse C =:= 16#FEFF orelse 1784 C =:= 16#00AD 1785 end, 1786 List 1787 ); 1788 _ -> 1789 false 1790 end 1791 end. 1792 1793%% Strip CSI sequences (ESC [ … final) for blank detection. 1794strip_ansi_bin(Bin) when is_binary(Bin) -> 1795 case unicode:characters_to_list(Bin) of 1796 List when is_list(List) -> 1797 unicode:characters_to_binary(strip_ansi_list(List, normal, [])); 1798 _ -> 1799 Bin 1800 end. 1801 1802strip_ansi_list([], _State, Acc) -> 1803 lists:reverse(Acc); 1804strip_ansi_list([16#1b | Rest], normal, Acc) -> 1805 strip_ansi_list(Rest, esc, Acc); 1806strip_ansi_list([C | Rest], normal, Acc) -> 1807 strip_ansi_list(Rest, normal, [C | Acc]); 1808strip_ansi_list([$[ | Rest], esc, Acc) -> 1809 strip_ansi_list(Rest, csi, Acc); 1810strip_ansi_list([_ | Rest], esc, Acc) -> 1811 strip_ansi_list(Rest, normal, Acc); 1812strip_ansi_list([C | Rest], csi, Acc) when C >= 16#40, C =< 16#7e -> 1813 strip_ansi_list(Rest, normal, Acc); 1814strip_ansi_list([_ | Rest], csi, Acc) -> 1815 strip_ansi_list(Rest, csi, Acc). 1816 1817push_history(Line) when is_binary(Line) -> 1818 %% Trim so " ls " does not become a near-blank distinct from "ls". 1819 Trimmed = string:trim(Line), 1820 case history_blank(Trimmed) of 1821 true -> 1822 ok; 1823 false -> 1824 Hist = sanitize_history( 1825 case get(gleshell_history) of 1826 L when is_list(L) -> L; 1827 _ -> [] 1828 end 1829 ), 1830 New = case Hist of 1831 [Trimmed | _] -> Hist; 1832 _ -> [Trimmed | Hist] 1833 end, 1834 put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 1835 ok 1836 end; 1837push_history(_) -> 1838 ok. 1839 1840%% --------------------------------------------------------------------------- 1841%% OS / process helpers 1842%% --------------------------------------------------------------------------- 1843 1844-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 1845set_cwd(Path) when is_binary(Path) -> 1846 case file:set_cwd(unicode:characters_to_list(Path)) of 1847 ok -> 1848 {ok, nil}; 1849 {error, Reason} -> 1850 {error, reason_to_bin(Reason)} 1851 end. 1852 1853-spec get_cwd() -> {ok, binary()} | {error, binary()}. 1854get_cwd() -> 1855 case file:get_cwd() of 1856 {ok, Dir} -> 1857 {ok, unicode:characters_to_binary(Dir)}; 1858 {error, Reason} -> 1859 {error, reason_to_bin(Reason)} 1860 end. 1861 1862-spec getenv(binary()) -> {ok, binary()} | {error, nil}. 1863getenv(Name) when is_binary(Name) -> 1864 case os:getenv(unicode:characters_to_list(Name)) of 1865 false -> 1866 {error, nil}; 1867 Value -> 1868 {ok, unicode:characters_to_binary(Value)} 1869 end. 1870 1871-spec setenv(binary(), binary()) -> {ok, nil}. 1872setenv(Name, Value) when is_binary(Name), is_binary(Value) -> 1873 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 1874 {ok, nil}. 1875 1876%% All process environment variables as a list of {Name, Value} binaries. 1877-spec list_env() -> list({binary(), binary()}). 1878list_env() -> 1879 lists:map( 1880 fun(Entry) -> 1881 case string:split(Entry, "=", leading) of 1882 [K, V] -> 1883 {unicode:characters_to_binary(K), unicode:characters_to_binary(V)}; 1884 [K] -> 1885 {unicode:characters_to_binary(K), <<>>}; 1886 _ -> 1887 {<<>>, <<>>} 1888 end 1889 end, 1890 os:getenv() 1891 ). 1892 1893%% Substring/regex search helper for the `find` builtin. 1894%% Returns {ok, true|false} or {error, Message} on invalid pattern. 1895-spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}. 1896re_contains(Text, Pattern, IgnoreCase) 1897 when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) -> 1898 Opts0 = [unicode], 1899 Opts = 1900 case IgnoreCase of 1901 true -> 1902 [caseless | Opts0]; 1903 false -> 1904 Opts0 1905 end, 1906 case re:compile(Pattern, Opts) of 1907 {ok, Re} -> 1908 case re:run(Text, Re, [{capture, none}]) of 1909 match -> 1910 {ok, true}; 1911 nomatch -> 1912 {ok, false} 1913 end; 1914 {error, {Reason, _}} -> 1915 {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; 1916 {error, Reason} -> 1917 {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 1918 end. 1919 1920-spec which(binary()) -> {ok, binary()} | {error, nil}. 1921which(Command) when is_binary(Command) -> 1922 case which_all(Command) of 1923 [Path | _] -> 1924 {ok, Path}; 1925 [] -> 1926 {error, nil} 1927 end. 1928 1929%% All matching executables on PATH (or the path itself if absolute/relative). 1930%% Order matches PATH search; duplicates from the same resolved path are dropped. 1931-spec which_all(binary()) -> [binary()]. 1932which_all(Command) when is_binary(Command) -> 1933 Cmd = unicode:characters_to_list(Command), 1934 case Cmd of 1935 [] -> 1936 []; 1937 _ -> 1938 case has_path_sep(Cmd) of 1939 true -> 1940 case is_executable_file(Cmd) of 1941 true -> 1942 [unicode:characters_to_binary(filename:absname(Cmd))]; 1943 false -> 1944 [] 1945 end; 1946 false -> 1947 case os:getenv("PATH") of 1948 false -> 1949 []; 1950 PathStr -> 1951 Dirs = string:tokens(PathStr, path_sep()), 1952 find_all_in_path(Cmd, Dirs, #{}, []) 1953 end 1954 end 1955 end. 1956 1957path_sep() -> 1958 case os:type() of 1959 {win32, _} -> ";"; 1960 _ -> ":" 1961 end. 1962 1963has_path_sep(Cmd) -> 1964 lists:member($/, Cmd) orelse lists:member($\\, Cmd). 1965 1966find_all_in_path(_Cmd, [], _Seen, Acc) -> 1967 lists:reverse(Acc); 1968find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) -> 1969 File = filename:join(Dir, Cmd), 1970 case is_executable_file(File) of 1971 true -> 1972 Abs = filename:absname(File), 1973 Bin = unicode:characters_to_binary(Abs), 1974 case maps:is_key(Abs, Seen) of 1975 true -> 1976 find_all_in_path(Cmd, Rest, Seen, Acc); 1977 false -> 1978 find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc]) 1979 end; 1980 false -> 1981 find_all_in_path(Cmd, Rest, Seen, Acc) 1982 end. 1983 1984is_executable_file(Path) -> 1985 case file:read_file_info(Path) of 1986 {ok, #file_info{type = regular, mode = Mode}} -> 1987 %% Any execute bit (owner/group/other). 1988 (Mode band 8#111) =/= 0; 1989 _ -> 1990 false 1991 end. 1992 1993%% Canonical absolute path: resolve `.`/`..` and follow symlinks (like realpath(3)). 1994%% On failure (missing path, loop, etc.) returns {error, nil}. 1995-spec realpath(binary()) -> {ok, binary()} | {error, nil}. 1996realpath(Path) when is_binary(Path) -> 1997 case realpath_loop(unicode:characters_to_list(Path), #{}) of 1998 {ok, Resolved} -> 1999 {ok, unicode:characters_to_binary(Resolved)}; 2000 {error, _} -> 2001 {error, nil} 2002 end. 2003 2004realpath_loop(Path, Seen) -> 2005 Abs = filename:absname(Path), 2006 case maps:is_key(Abs, Seen) of 2007 true -> 2008 {error, eloop}; 2009 false -> 2010 case file:read_link(Abs) of 2011 {ok, Target} -> 2012 Next = 2013 case filename:pathtype(Target) of 2014 absolute -> 2015 Target; 2016 _ -> 2017 filename:absname(filename:join(filename:dirname(Abs), Target)) 2018 end, 2019 realpath_loop(Next, Seen#{Abs => true}); 2020 {error, einval} -> 2021 %% Not a symlink — Abs is the final path. 2022 case file:read_file_info(Abs) of 2023 {ok, _} -> 2024 {ok, Abs}; 2025 {error, Reason} -> 2026 {error, Reason} 2027 end; 2028 {error, Reason} -> 2029 {error, Reason} 2030 end 2031 end. 2032 2033-spec home_dir() -> {ok, binary()} | {error, binary()}. 2034home_dir() -> 2035 case os:getenv("HOME") of 2036 false -> 2037 {error, <<"HOME not set">>}; 2038 Home -> 2039 {ok, unicode:characters_to_binary(Home)} 2040 end. 2041 2042-spec stdout_isatty() -> boolean(). 2043stdout_isatty() -> 2044 case io:columns() of 2045 {ok, _} -> 2046 true; 2047 _ -> 2048 try 2049 case prim_tty:isatty(stdout) of 2050 true -> true; 2051 _ -> false 2052 end 2053 catch 2054 _:_ -> 2055 false 2056 end 2057 end. 2058 2059%% --------------------------------------------------------------------------- 2060%% External commands 2061%% 2062%% Two modes: 2063%% 2064%% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 2065%% non-TTY display). Prefers a throwaway PTY when color is wanted so tools 2066%% emit ANSI for `cmd | less`; falls back to pipes. 2067%% 2068%% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` 2069%% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. 2070%% `erl_child_setup` calls setsid, so the child is never in the terminal's 2071%% foreground process group — kernel SIGINT goes to BEAM, not the child. 2072%% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing. 2073%% 2074%% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path 2075%% covers that. Host termios during children: cooked for OPOST/ONLCR (no 2076%% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening 2077%% the Erlang BREAK menu. 2078%% --------------------------------------------------------------------------- 2079 2080-spec run_cmd(binary(), [binary()], binary()) -> 2081 {ok, {integer(), binary()}} | {error, binary()}. 2082run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 2083 case resolve_cmd(Command, Args) of 2084 {error, _} = E -> 2085 E; 2086 {ok, Path, PortArgs} -> 2087 try 2088 run_cmd_capture(Path, PortArgs, Stdin) 2089 catch 2090 _:Reason -> 2091 {error, reason_to_bin(Reason)} 2092 end 2093 end. 2094 2095%% Foreground interactive: inherit TTY when possible. 2096%% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works. 2097%% 2098%% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with 2099%% OPOST/ONLCR off so a bare LF does not return the cursor to column 0. 2100%% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if 2101%% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore 2102%% raw afterwards (same idea as println/1 converting to CRLF for shell text). 2103-spec run_cmd_tty(binary(), [binary()], binary()) -> 2104 {ok, {integer(), binary()}} | {error, binary()}. 2105run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 2106 case resolve_cmd(Command, Args) of 2107 {error, _} = E -> 2108 E; 2109 {ok, Path, PortArgs} -> 2110 try 2111 case stdout_isatty() of 2112 false -> 2113 run_cmd_capture(Path, PortArgs, Stdin); 2114 true -> 2115 with_cooked_tty(fun() -> 2116 %% PTY for all interactive when possible: key relay 2117 %% sees Ctrl+C and can SIGINT the child process group. 2118 case {os:find_executable("script"), find_tty_path()} of 2119 {Script, {ok, Tty}} when is_list(Script) -> 2120 run_cmd_pty(Script, Path, PortArgs, Tty, Stdin); 2121 _ -> 2122 run_cmd_inherit(Path, PortArgs, Stdin) 2123 end 2124 end) 2125 end 2126 catch 2127 _:Reason -> 2128 {error, reason_to_bin(Reason)} 2129 end 2130 end. 2131 2132%% Temporarily put the controlling TTY into cooked output + non-canonical 2133%% input for an external child, then restore previous termios (raw REPL). 2134%% 2135%% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal 2136%% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt 2137%% path. No-op when stty/TTY is unavailable. 2138with_cooked_tty(Fun) when is_function(Fun, 0) -> 2139 case stdout_isatty() of 2140 false -> 2141 Fun(); 2142 true -> 2143 case stty_save() of 2144 undefined -> 2145 %% Still try to apply host flags; restore is best-effort. 2146 stty_sane(), 2147 try 2148 Fun() 2149 after 2150 ok 2151 end; 2152 Saved -> 2153 stty_sane(), 2154 try 2155 Fun() 2156 after 2157 stty_restore(Saved) 2158 end 2159 end 2160 end. 2161 2162stty_save() -> 2163 case stty_run(["-g"]) of 2164 {ok, Out} -> 2165 case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of 2166 "" -> 2167 undefined; 2168 Settings -> 2169 %% stty -g is a single token of colon-separated hex flags. 2170 Settings 2171 end; 2172 _ -> 2173 undefined 2174 end. 2175 2176stty_sane() -> 2177 %% Host TTY while an external runs under the raw REPL: 2178 %% - sane / opost / onlcr: LF→CRLF so children don't staircase 2179 %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu) 2180 %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON 2181 %% left on, Ctrl+C sits in the line buffer until Enter and our key 2182 %% relay never sees it (external freezes; second ^C looks wedged) 2183 %% - -echo: host must not echo keys we relay into the child PTY 2184 _ = stty_run([ 2185 "sane", 2186 "-isig", 2187 "-icanon", 2188 "min", 2189 "1", 2190 "time", 2191 "0", 2192 "-echo" 2193 ]), 2194 ok. 2195 2196stty_restore(Settings) when is_list(Settings) -> 2197 _ = stty_run([Settings]), 2198 ok; 2199stty_restore(_) -> 2200 ok. 2201 2202%% Run stty against the real terminal device (not a pipe). Prefer the pts 2203%% path from /proc (same as sudo/PTY path), then `/dev/tty`. 2204%% 2205%% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node, 2206%% so is_file returns false and would skip stty entirely (fastfetch staircase). 2207stty_run(Args) when is_list(Args) -> 2208 case os:find_executable("stty") of 2209 false -> 2210 {error, no_stty}; 2211 Stty when is_list(Stty) -> 2212 stty_run_on(Stty, Args, stty_devices()) 2213 end. 2214 2215stty_devices() -> 2216 case find_tty_path() of 2217 {ok, Path} -> 2218 %% Prefer the concrete pts; /dev/tty is a fallback alias. 2219 [Path, "/dev/tty"]; 2220 _ -> 2221 ["/dev/tty"] 2222 end. 2223 2224stty_run_on(_Stty, _Args, []) -> 2225 {error, no_tty}; 2226stty_run_on(Stty, Args, [Dev | Rest]) -> 2227 case stty_on_device(Stty, Dev, Args) of 2228 {ok, _} = Ok -> 2229 Ok; 2230 _ -> 2231 stty_run_on(Stty, Args, Rest) 2232 end. 2233 2234stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) -> 2235 try 2236 Port = open_port( 2237 {spawn_executable, Stty}, 2238 [ 2239 binary, 2240 exit_status, 2241 use_stdio, 2242 stderr_to_stdout, 2243 {args, ["-F", Dev | Args]} 2244 ] 2245 ), 2246 %% No interrupt watch — internal helper, must not steal TTY input. 2247 case collect_output_quiet(Port, <<>>, 5000) of 2248 {ok, {0, Bin}} -> 2249 {ok, unicode:characters_to_list(Bin)}; 2250 {ok, {Status, Bin}} -> 2251 {error, {Status, Bin}}; 2252 {error, _} = E -> 2253 E 2254 end 2255 catch 2256 _:_ -> 2257 {error, stty_failed} 2258 end. 2259 2260resolve_cmd(Command, Args) -> 2261 case os:find_executable(unicode:characters_to_list(Command)) of 2262 false -> 2263 {error, <<"command not found: ", Command/binary>>}; 2264 Path -> 2265 PortArgs = [unicode:characters_to_list(A) || A <- Args], 2266 {ok, Path, PortArgs} 2267 end. 2268 2269%% Capture mode: collect stdout/stderr into a binary (pipelines, `let x =`). 2270%% 2271%% When the shell wants color, prefer a throwaway PTY (`script`) so tools that 2272%% only colorize on a TTY (`jj`, `git`, …) still emit ANSI for `cmd | less` — 2273%% without tool-specific env hacks. Nested pagers are forced to `cat` so the 2274%% child cannot hang waiting for interactive `less`. Falls back to plain pipes 2275%% (+ FORCE_COLOR / git overlays) when `script` is missing. 2276%% 2277%% Stdin is redirected via temp file / `$GLESHELL_STDIN` so programs never hang 2278%% on an open-but-never-written Erlang port pipe. 2279run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2280 case want_child_color() of 2281 true -> 2282 case os:find_executable("script") of 2283 Script when is_list(Script) -> 2284 run_cmd_capture_pty(Script, Path, PortArgs, Stdin); 2285 _ -> 2286 run_cmd_capture_pipe(Path, PortArgs, Stdin) 2287 end; 2288 false -> 2289 run_cmd_capture_pipe(Path, PortArgs, Stdin) 2290 end. 2291 2292run_cmd_capture_pipe(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2293 with_stdin_file(Stdin, fun(StdinPath) -> 2294 sh_exec(Path, PortArgs, StdinPath, capture) 2295 end). 2296 2297%% PTY capture: child sees a TTY (colors, auto decorations) but we only collect 2298%% output — nothing is relayed to the user's terminal. 2299run_cmd_capture_pty(Script, Path, PortArgs, <<>>) -> 2300 run_cmd_capture_pty_argv(Script, [Path | PortArgs]); 2301run_cmd_capture_pty(Script, Path, PortArgs, Stdin) when is_binary(Stdin) -> 2302 with_stdin_file(Stdin, fun(StdinPath) -> 2303 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 2304 run_cmd_capture_pty_argv(Script, [Runner]) 2305 end) 2306 end). 2307 2308run_cmd_capture_pty_argv(Script, Argv) when is_list(Argv) -> 2309 with_trapped_exits(fun() -> 2310 Port = open_port( 2311 {spawn_executable, Script}, 2312 [ 2313 binary, 2314 exit_status, 2315 stderr_to_stdout, 2316 use_stdio, 2317 stream, 2318 {env, child_env_capture_pty()}, 2319 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 2320 ] 2321 ), 2322 put(gleshell_output_shown, false), 2323 try 2324 case collect_output(Port, <<>>) of 2325 {ok, {Status, Acc}} -> 2326 {ok, {Status, normalize_pty_output(Acc)}}; 2327 Other -> 2328 Other 2329 end 2330 after 2331 catch port_close(Port) 2332 end 2333 end). 2334 2335%% Inherit real stdio — pagers/editors talk to the terminal directly. 2336%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 2337%% 2338%% Empty stdin: pure inherit (bare `less` reads the TTY). 2339%% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from 2340%% a temp file so `cat file | less` pages the pipeline data. 2341%% 2342%% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when 2343%% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a 2344%% watcher SIGINTs the child's process group (setsid means kernel SIGINT 2345%% never reaches the child even with ISIG on). 2346run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2347 case Stdin of 2348 <<>> -> 2349 Port = open_port( 2350 {spawn_executable, Path}, 2351 [ 2352 exit_status, 2353 nouse_stdio, 2354 {env, child_env()}, 2355 {args, PortArgs} 2356 ] 2357 ), 2358 put(gleshell_output_shown, true), 2359 await_port_exit_interruptible(Port); 2360 _ -> 2361 with_stdin_file(Stdin, fun(StdinPath) -> 2362 sh_exec(Path, PortArgs, StdinPath, inherit) 2363 end) 2364 end. 2365 2366%% PTY + key relay (interactive TTY, sudo/run0, …). 2367%% 2368%% util-linux `script` does NOT exec the argv after `--` directly. It runs 2369%% `$SHELL -c "<joined args>"` (see script(1)). Nested 2370%% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and 2371%% the real binary never runs (fastfetch → empty output, exit 0). 2372%% 2373%% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`). 2374%% Non-empty stdin (pipeline → less): write a one-shot runner script that 2375%% redirects and execs, then `script -- /tmp/runner` (single path token). 2376run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> 2377 run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>); 2378run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> 2379 with_stdin_file(Stdin, fun(StdinPath) -> 2380 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 2381 run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin) 2382 end) 2383 end). 2384 2385run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) -> 2386 with_trapped_exits(fun() -> 2387 Port = open_port( 2388 {spawn_executable, Script}, 2389 [ 2390 binary, 2391 exit_status, 2392 stderr_to_stdout, 2393 use_stdio, 2394 stream, 2395 {env, child_env()}, 2396 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 2397 ] 2398 ), 2399 case file:open(TtyPath, [write, raw, binary]) of 2400 {ok, TtyOut} -> 2401 GL = group_leader(), 2402 Reader = spawn(fun() -> 2403 group_leader(GL, self()), 2404 io_to_port(Port) 2405 end), 2406 put(gleshell_output_shown, true), 2407 try 2408 collect_output_relay(Port, TtyOut, <<>>) 2409 after 2410 exit(Reader, kill), 2411 catch file:close(TtyOut), 2412 catch port_close(Port) 2413 end; 2414 {error, _} -> 2415 catch port_close(Port), 2416 put(gleshell_output_shown, false), 2417 run_cmd_inherit(Path, PortArgs, Stdin) 2418 end 2419 end). 2420 2421%% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath. 2422%% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec). 2423with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) -> 2424 case write_runner_script(Path, PortArgs, StdinPath) of 2425 {ok, Runner} -> 2426 try 2427 Fun(Runner) 2428 after 2429 _ = file:delete(Runner) 2430 end; 2431 {error, Reason} -> 2432 {error, reason_to_bin({runner_script, Reason})} 2433 end. 2434 2435write_runner_script(Path, PortArgs, StdinPath) -> 2436 Dir = 2437 case os:getenv("TMPDIR") of 2438 false -> 2439 "/tmp"; 2440 "" -> 2441 "/tmp"; 2442 D -> 2443 D 2444 end, 2445 Name = 2446 filename:join( 2447 Dir, 2448 "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive])) 2449 ), 2450 Body = runner_script_body(Path, PortArgs, StdinPath), 2451 case file:write_file(Name, Body) of 2452 ok -> 2453 case file:change_mode(Name, 8#755) of 2454 ok -> 2455 {ok, Name}; 2456 {error, _} = E -> 2457 _ = file:delete(Name), 2458 E 2459 end; 2460 {error, _} = E -> 2461 E 2462 end. 2463 2464runner_script_body(Path, PortArgs, StdinPath) -> 2465 ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], 2466 [ 2467 "#!/bin/sh\n", 2468 "exec ", 2469 shell_single_quote(Path), 2470 ArgsQ, 2471 " < ", 2472 shell_single_quote(StdinPath), 2473 "\n" 2474 ]. 2475 2476%% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). 2477shell_single_quote(S) when is_list(S) -> 2478 [$' | shell_single_quote_chars(S) ++ "'"]; 2479shell_single_quote(B) when is_binary(B) -> 2480 shell_single_quote(unicode:characters_to_list(B)). 2481 2482shell_single_quote_chars([]) -> 2483 []; 2484shell_single_quote_chars([$' | Rest]) -> 2485 "'\\''" ++ shell_single_quote_chars(Rest); 2486shell_single_quote_chars([C | Rest]) -> 2487 [C | shell_single_quote_chars(Rest)]. 2488 2489find_sh() -> 2490 case os:find_executable("sh") of 2491 false -> 2492 "/bin/sh"; 2493 S -> 2494 S 2495 end. 2496 2497%% Run Path with stdin redirected from StdinPath. 2498%% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err). 2499sh_exec(Path, PortArgs, StdinPath, capture) -> 2500 Port = open_port( 2501 {spawn_executable, find_sh()}, 2502 [ 2503 binary, 2504 exit_status, 2505 stderr_to_stdout, 2506 use_stdio, 2507 stream, 2508 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 2509 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 2510 ] 2511 ), 2512 put(gleshell_output_shown, false), 2513 collect_output(Port, <<>>); 2514sh_exec(Path, PortArgs, StdinPath, inherit) -> 2515 Port = open_port( 2516 {spawn_executable, find_sh()}, 2517 [ 2518 exit_status, 2519 nouse_stdio, 2520 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 2521 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 2522 ] 2523 ), 2524 put(gleshell_output_shown, true), 2525 await_port_exit_interruptible(Port). 2526 2527%% Provide a filesystem path for stdin bytes; clean up temp files afterwards. 2528with_stdin_file(<<>>, Fun) when is_function(Fun, 1) -> 2529 Fun("/dev/null"); 2530with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) -> 2531 case write_stdin_tmp(Data) of 2532 {ok, Path} -> 2533 try 2534 Fun(Path) 2535 after 2536 _ = file:delete(Path) 2537 end; 2538 {error, Reason} -> 2539 {error, reason_to_bin({stdin_tmp, Reason})} 2540 end. 2541 2542write_stdin_tmp(Data) when is_binary(Data) -> 2543 Dir = 2544 case os:getenv("TMPDIR") of 2545 false -> 2546 "/tmp"; 2547 "" -> 2548 "/tmp"; 2549 D -> 2550 D 2551 end, 2552 Name = 2553 filename:join( 2554 Dir, 2555 "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive])) 2556 ), 2557 case file:write_file(Name, Data) of 2558 ok -> 2559 {ok, Name}; 2560 {error, _} = E -> 2561 E 2562 end. 2563 2564%% Relay keypresses from the group leader to the child's PTY (script stdin). 2565%% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the 2566%% byte so the PTY line discipline can deliver SIGINT on the slave side too. 2567io_to_port(Port) -> 2568 case io:get_chars("", 1) of 2569 eof -> 2570 ok; 2571 {error, _} -> 2572 ok; 2573 Data -> 2574 case io_data_to_bin(Data) of 2575 <<>> -> 2576 io_to_port(Port); 2577 <<3>> = Bin -> 2578 signal_port_group(Port, int), 2579 catch port_command(Port, Bin), 2580 io_to_port(Port); 2581 Bin -> 2582 catch port_command(Port, Bin), 2583 io_to_port(Port) 2584 end 2585 end. 2586 2587io_data_to_bin(Bin) when is_binary(Bin) -> 2588 Bin; 2589io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F -> 2590 <<C>>; 2591io_data_to_bin(List) when is_list(List) -> 2592 case unicode:characters_to_binary(List) of 2593 Bin when is_binary(Bin) -> 2594 Bin; 2595 _ -> 2596 <<>> 2597 end; 2598io_data_to_bin(_) -> 2599 <<>>. 2600 2601%% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may 2602%% exit with `epipe` / signal reasons. Without trap_exit the shell process 2603%% dies with "Erlang exit: Epipe" instead of returning to the prompt. 2604with_trapped_exits(Fun) when is_function(Fun, 0) -> 2605 Old = process_flag(trap_exit, true), 2606 try 2607 Fun() 2608 after 2609 process_flag(trap_exit, Old), 2610 drain_exit_msgs() 2611 end. 2612 2613drain_exit_msgs() -> 2614 receive 2615 {'EXIT', _, _} -> 2616 drain_exit_msgs() 2617 after 0 -> 2618 ok 2619 end. 2620 2621%% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group. 2622%% Used when `script`/PTY is unavailable; same kill path as the PTY relay. 2623await_port_exit_interruptible(Port) -> 2624 with_trapped_exits(fun() -> 2625 with_interrupt_watch(Port, fun() -> 2626 await_port_exit_interruptible_loop(Port) 2627 end) 2628 end). 2629 2630await_port_exit_interruptible_loop(Port) -> 2631 receive 2632 {Port, {exit_status, Status}} -> 2633 {ok, {Status, <<>>}}; 2634 {'EXIT', Port, _Reason} -> 2635 {ok, {130, <<>>}}; 2636 {gleshell_interrupt, _} -> 2637 signal_port_group(Port, int), 2638 await_port_exit_after_interrupt(Port, 2000) 2639 end. 2640 2641await_port_exit_after_interrupt(Port, GraceMs) -> 2642 receive 2643 {Port, {exit_status, Status}} -> 2644 {ok, {Status, <<>>}}; 2645 {'EXIT', Port, _Reason} -> 2646 {ok, {130, <<>>}}; 2647 {gleshell_interrupt, _} -> 2648 signal_port_group(Port, kill), 2649 await_port_exit_after_interrupt(Port, 1000) 2650 after GraceMs -> 2651 signal_port_group(Port, kill), 2652 catch port_close(Port), 2653 receive 2654 {Port, {exit_status, Status}} -> 2655 {ok, {Status, <<>>}}; 2656 {'EXIT', Port, _} -> 2657 {ok, {130, <<>>}} 2658 after 1000 -> 2659 {ok, {130, <<>>}} 2660 end 2661 end. 2662 2663collect_output(Port, Acc) -> 2664 with_trapped_exits(fun() -> 2665 with_interrupt_watch(Port, fun() -> 2666 collect_output_loop(Port, Acc, 120_000) 2667 end) 2668 end). 2669 2670collect_output_loop(Port, Acc, Timeout) -> 2671 receive 2672 {Port, {data, Data}} when is_binary(Data) -> 2673 collect_output_loop(Port, <<Acc/binary, Data/binary>>, Timeout); 2674 {Port, {data, Data}} when is_list(Data) -> 2675 Bin = unicode:characters_to_binary(Data), 2676 collect_output_loop(Port, <<Acc/binary, Bin/binary>>, Timeout); 2677 {Port, {exit_status, Status}} -> 2678 {ok, {Status, Acc}}; 2679 {'EXIT', Port, _Reason} -> 2680 {ok, {130, Acc}}; 2681 {gleshell_interrupt, _} -> 2682 signal_port_group(Port, int), 2683 collect_output_after_interrupt(Port, Acc, 2000) 2684 after Timeout -> 2685 signal_port_group(Port, term), 2686 catch port_close(Port), 2687 {error, <<"command timed out after 120s">>} 2688 end. 2689 2690collect_output_after_interrupt(Port, Acc, GraceMs) -> 2691 receive 2692 {Port, {data, Data}} when is_binary(Data) -> 2693 collect_output_after_interrupt( 2694 Port, <<Acc/binary, Data/binary>>, GraceMs 2695 ); 2696 {Port, {data, Data}} when is_list(Data) -> 2697 Bin = unicode:characters_to_binary(Data), 2698 collect_output_after_interrupt( 2699 Port, <<Acc/binary, Bin/binary>>, GraceMs 2700 ); 2701 {Port, {exit_status, Status}} -> 2702 {ok, {Status, Acc}}; 2703 {'EXIT', Port, _Reason} -> 2704 {ok, {130, Acc}}; 2705 {gleshell_interrupt, _} -> 2706 signal_port_group(Port, kill), 2707 collect_output_after_interrupt(Port, Acc, 1000) 2708 after GraceMs -> 2709 signal_port_group(Port, kill), 2710 catch port_close(Port), 2711 receive 2712 {Port, {data, Data}} when is_binary(Data) -> 2713 collect_output_after_interrupt( 2714 Port, <<Acc/binary, Data/binary>>, 500 2715 ); 2716 {Port, {data, Data}} when is_list(Data) -> 2717 Bin = unicode:characters_to_binary(Data), 2718 collect_output_after_interrupt( 2719 Port, <<Acc/binary, Bin/binary>>, 500 2720 ); 2721 {Port, {exit_status, Status}} -> 2722 {ok, {Status, Acc}}; 2723 {'EXIT', Port, _} -> 2724 {ok, {130, Acc}} 2725 after 1000 -> 2726 {ok, {130, Acc}} 2727 end 2728 end. 2729 2730%% PTY session: no timeout (password prompts, long pagers, etc.). 2731%% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs. 2732%% Caller must run under with_trapped_exits/1 so port epipe is not fatal. 2733collect_output_relay(Port, Tty, Acc) -> 2734 receive 2735 {Port, {data, Data}} when is_binary(Data) -> 2736 _ = file:write(Tty, Data), 2737 collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>); 2738 {Port, {data, Data}} when is_list(Data) -> 2739 Bin = unicode:characters_to_binary(Data), 2740 _ = file:write(Tty, Bin), 2741 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 2742 {Port, {exit_status, Status}} -> 2743 {ok, {Status, normalize_pty_output(Acc)}}; 2744 {'EXIT', Port, _Reason} -> 2745 {ok, {130, normalize_pty_output(Acc)}}; 2746 {gleshell_interrupt, _} -> 2747 signal_port_group(Port, int), 2748 collect_output_relay_after_interrupt(Port, Tty, Acc, 2000) 2749 end. 2750 2751collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) -> 2752 receive 2753 {Port, {data, Data}} when is_binary(Data) -> 2754 _ = file:write(Tty, Data), 2755 collect_output_relay_after_interrupt( 2756 Port, Tty, <<Acc/binary, Data/binary>>, GraceMs 2757 ); 2758 {Port, {data, Data}} when is_list(Data) -> 2759 Bin = unicode:characters_to_binary(Data), 2760 _ = file:write(Tty, Bin), 2761 collect_output_relay_after_interrupt( 2762 Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs 2763 ); 2764 {Port, {exit_status, Status}} -> 2765 {ok, {Status, normalize_pty_output(Acc)}}; 2766 {'EXIT', Port, _Reason} -> 2767 {ok, {130, normalize_pty_output(Acc)}}; 2768 {gleshell_interrupt, _} -> 2769 signal_port_group(Port, kill), 2770 collect_output_relay_after_interrupt(Port, Tty, Acc, 1000) 2771 after GraceMs -> 2772 signal_port_group(Port, kill), 2773 catch port_close(Port), 2774 receive 2775 {Port, {exit_status, Status}} -> 2776 {ok, {Status, normalize_pty_output(Acc)}}; 2777 {'EXIT', Port, _} -> 2778 {ok, {130, normalize_pty_output(Acc)}} 2779 after 1000 -> 2780 {ok, {130, normalize_pty_output(Acc)}} 2781 end 2782 end. 2783 2784%% --------------------------------------------------------------------------- 2785%% Ctrl+C / SIGINT forwarding 2786%% 2787%% BEAM's open_port → erl_child_setup → setsid, so the child is not in the 2788%% terminal foreground group. Host ISIG is left off while a child runs; we 2789%% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group. 2790%% --------------------------------------------------------------------------- 2791 2792with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> 2793 Parent = self(), 2794 GL = group_leader(), 2795 Watcher = 2796 case can_watch_interrupt() of 2797 true -> 2798 spawn(fun() -> 2799 group_leader(GL, self()), 2800 interrupt_watch_loop(Parent) 2801 end); 2802 false -> 2803 undefined 2804 end, 2805 try 2806 Fun() 2807 after 2808 case Watcher of 2809 undefined -> 2810 ok; 2811 W -> 2812 exit(W, kill), 2813 drain_interrupt_msgs() 2814 end 2815 end. 2816 2817%% Collect port output without Ctrl+C watching (stty and other helpers). 2818collect_output_quiet(Port, Acc, Timeout) -> 2819 receive 2820 {Port, {data, Data}} when is_binary(Data) -> 2821 collect_output_quiet(Port, <<Acc/binary, Data/binary>>, Timeout); 2822 {Port, {data, Data}} when is_list(Data) -> 2823 Bin = unicode:characters_to_binary(Data), 2824 collect_output_quiet(Port, <<Acc/binary, Bin/binary>>, Timeout); 2825 {Port, {exit_status, Status}} -> 2826 {ok, {Status, Acc}} 2827 after Timeout -> 2828 catch port_close(Port), 2829 {error, <<"command timed out">>} 2830 end. 2831 2832%% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal. 2833can_watch_interrupt() -> 2834 case get(gleshell_raw) of 2835 true -> 2836 true; 2837 _ -> 2838 stdout_isatty() 2839 end. 2840 2841interrupt_watch_loop(Parent) when is_pid(Parent) -> 2842 case catch io:get_chars("", 1) of 2843 eof -> 2844 ok; 2845 {error, _} -> 2846 ok; 2847 {'EXIT', _} -> 2848 ok; 2849 Data -> 2850 case io_data_to_bin(Data) of 2851 <<3>> -> 2852 Parent ! {gleshell_interrupt, self()}, 2853 interrupt_watch_loop(Parent); 2854 _ -> 2855 %% Non-Ctrl+C: discard here (capture mode). Interactive 2856 %% PTY uses io_to_port instead; inherit prefers PTY. 2857 interrupt_watch_loop(Parent) 2858 end 2859 end. 2860 2861drain_interrupt_msgs() -> 2862 receive 2863 {gleshell_interrupt, _} -> 2864 drain_interrupt_msgs() 2865 after 0 -> 2866 ok 2867 end. 2868 2869%% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid). 2870signal_port_group(Port, Sig) when is_port(Port) -> 2871 case erlang:port_info(Port, os_pid) of 2872 {os_pid, Pid} when is_integer(Pid), Pid > 0 -> 2873 kill_os_group(Pid, Sig); 2874 _ -> 2875 ok 2876 end. 2877 2878kill_os_group(Pid, Sig) when is_integer(Pid) -> 2879 Kill = 2880 case os:find_executable("kill") of 2881 false -> 2882 "kill"; 2883 K -> 2884 K 2885 end, 2886 SigArg = 2887 case Sig of 2888 int -> 2889 "-INT"; 2890 term -> 2891 "-TERM"; 2892 kill -> 2893 "-KILL" 2894 end, 2895 %% Negative pid → process group (child is session/group leader after setsid). 2896 Pg = "-" ++ integer_to_list(Pid), 2897 Single = integer_to_list(Pid), 2898 _ = kill_once(Kill, [SigArg, Pg]), 2899 _ = kill_once(Kill, [SigArg, Single]), 2900 ok. 2901 2902kill_once(Kill, Args) -> 2903 try 2904 Port = open_port( 2905 {spawn_executable, Kill}, 2906 [exit_status, nouse_stdio, {args, Args}] 2907 ), 2908 receive 2909 {Port, {exit_status, _}} -> 2910 ok 2911 after 1000 -> 2912 catch port_close(Port), 2913 ok 2914 end 2915 catch 2916 _:_ -> 2917 ok 2918 end. 2919 2920%% PTY line discipline often emits CR-LF; normalize to LF for structured use. 2921normalize_pty_output(Bin) when is_binary(Bin) -> 2922 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 2923 2924%% Extra env for external commands (merged into the process environment). 2925%% 2926%% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 2927%% - LESS=FRX when unset: external pagers pass through ANSI (-R) and exit on 2928%% short output (-F) without clearing the screen (-X). 2929%% - When the shell wants color and the child has no real TTY (pipe capture 2930%% fallback): FORCE_COLOR / CLICOLOR_FORCE, plus git GIT_CONFIG_* overlays 2931%% (git ignores FORCE_COLOR; decorate=auto drops ref names on pipes). 2932%% Prefer PTY capture (`child_env_capture_pty`) so jj/git/etc. colorize 2933%% naturally for `cmd | less` without per-tool config. 2934child_env() -> 2935 Env0 = child_env_base(), 2936 case want_child_color() of 2937 false -> 2938 Env0; 2939 true -> 2940 force_git_tty_env(force_color_env(Env0)) 2941 end. 2942 2943%% PTY capture env: color via TTY detection; never nest an interactive pager. 2944child_env_capture_pty() -> 2945 disable_nested_pagers(force_color_env(child_env_base())). 2946 2947child_env_base() -> 2948 Env0 = [{"SHELL", "/bin/sh"}], 2949 case os:getenv("LESS") of 2950 false -> 2951 [{"LESS", "FRX"} | Env0]; 2952 "" -> 2953 [{"LESS", "FRX"} | Env0]; 2954 _ -> 2955 Env0 2956 end. 2957 2958%% FORCE_COLOR / CLICOLOR_FORCE for children that honor them. 2959force_color_env(Env) -> 2960 Env1 = 2961 case os:getenv("FORCE_COLOR") of 2962 false -> 2963 [{"FORCE_COLOR", "1"} | Env]; 2964 "0" -> 2965 Env; 2966 _ -> 2967 Env 2968 end, 2969 case os:getenv("CLICOLOR_FORCE") of 2970 false -> 2971 [{"CLICOLOR_FORCE", "1"} | Env1]; 2972 "0" -> 2973 Env1; 2974 _ -> 2975 Env1 2976 end. 2977 2978%% Capture must not hang inside the child's own pager (git/jj → less). 2979%% Always override for PTY capture; interactive `run_cmd_tty` uses child_env/0. 2980disable_nested_pagers(Env) -> 2981 [ 2982 {"PAGER", "cat"}, 2983 {"GIT_PAGER", "cat"}, 2984 {"JJ_PAGER", "cat"}, 2985 {"SYSTEMD_PAGER", "cat"}, 2986 {"MANPAGER", "cat"} 2987 | Env 2988 ]. 2989 2990%% Pipe-capture fallback only (git ≥ 2.31 GIT_CONFIG_*). Skipped when the 2991%% caller already set GIT_CONFIG_COUNT so we do not clobber. 2992%% 2993%% - color.ui=always: git ignores FORCE_COLOR 2994%% - log.decorate=short: default auto hides decorations on non-TTY stdout 2995force_git_tty_env(Env) -> 2996 case os:getenv("GIT_CONFIG_COUNT") of 2997 false -> 2998 [ 2999 {"GIT_CONFIG_COUNT", "2"}, 3000 {"GIT_CONFIG_KEY_0", "color.ui"}, 3001 {"GIT_CONFIG_VALUE_0", "always"}, 3002 {"GIT_CONFIG_KEY_1", "log.decorate"}, 3003 {"GIT_CONFIG_VALUE_1", "short"} 3004 | Env 3005 ]; 3006 _ -> 3007 Env 3008 end. 3009 3010%% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY 3011%% or when the parent already forces color. 3012want_child_color() -> 3013 case os:getenv("NO_COLOR") of 3014 L when is_list(L), L =/= "" -> 3015 false; 3016 _ -> 3017 case stdout_isatty() of 3018 true -> 3019 true; 3020 false -> 3021 force_color_set() 3022 end 3023 end. 3024 3025force_color_set() -> 3026 case os:getenv("FORCE_COLOR") of 3027 L when is_list(L), L =/= "", L =/= "0" -> 3028 true; 3029 _ -> 3030 case os:getenv("CLICOLOR_FORCE") of 3031 L when is_list(L), L =/= "", L =/= "0" -> 3032 true; 3033 _ -> 3034 false 3035 end 3036 end. 3037 3038%% True when the last external command already streamed output to the TTY 3039%% (PTY relay). Cleared after being read so the REPL does not double-print. 3040-spec take_output_shown() -> boolean(). 3041take_output_shown() -> 3042 case erase(gleshell_output_shown) of 3043 true -> true; 3044 _ -> false 3045 end. 3046 3047-spec clear_output_shown() -> nil. 3048clear_output_shown() -> 3049 erase(gleshell_output_shown), 3050 nil. 3051 3052%% Find a terminal device attached to this BEAM (or an ancestor). 3053%% Note: os:getpid() returns a string, not an integer. 3054find_tty_path() -> 3055 case catch list_to_integer(os:getpid()) of 3056 Pid when is_integer(Pid) -> 3057 case tty_path_for_pid(Pid) of 3058 {ok, _} = Ok -> 3059 Ok; 3060 error -> 3061 walk_parent_tty(Pid, 12) 3062 end; 3063 _ -> 3064 error 3065 end. 3066 3067walk_parent_tty(_Pid, 0) -> 3068 error; 3069walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 -> 3070 case parent_pid(Pid) of 3071 {ok, Parent} when Parent > 1, Parent =/= Pid -> 3072 case tty_path_for_pid(Parent) of 3073 {ok, _} = Ok -> 3074 Ok; 3075 error -> 3076 walk_parent_tty(Parent, N - 1) 3077 end; 3078 _ -> 3079 error 3080 end; 3081walk_parent_tty(_, _) -> 3082 error. 3083 3084tty_path_for_pid(Pid) when is_integer(Pid) -> 3085 case read_tty_nr(Pid) of 3086 {ok, 0} -> 3087 error; 3088 {ok, TtyNr} -> 3089 tty_nr_to_path(TtyNr); 3090 error -> 3091 error 3092 end. 3093 3094read_tty_nr(Pid) when is_integer(Pid) -> 3095 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 3096 {ok, Bin} -> 3097 case parse_stat_tty_nr(binary_to_list(Bin)) of 3098 {ok, N} -> {ok, N}; 3099 error -> error 3100 end; 3101 _ -> 3102 error 3103 end. 3104%% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..." 3105parse_stat_tty_nr(List) -> 3106 case lists:splitwith(fun(C) -> C =/= $) end, List) of 3107 {_, [$) | Rest0]} -> 3108 Rest = string:trim(Rest0, leading), 3109 Fields = string:tokens(Rest, " "), 3110 %% After ')': state, ppid, pgrp, session, tty_nr → index 5 3111 case length(Fields) >= 5 of 3112 true -> 3113 try 3114 {ok, list_to_integer(lists:nth(5, Fields))} 3115 catch 3116 _:_ -> error 3117 end; 3118 false -> 3119 error 3120 end; 3121 _ -> 3122 error 3123 end. 3124 3125parent_pid(Pid) -> 3126 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 3127 {ok, Bin} -> 3128 case parse_stat_ppid(binary_to_list(Bin)) of 3129 {ok, P} -> {ok, P}; 3130 error -> error 3131 end; 3132 _ -> 3133 error 3134 end. 3135 3136parse_stat_ppid(List) -> 3137 case lists:splitwith(fun(C) -> C =/= $) end, List) of 3138 {_, [$) | Rest0]} -> 3139 Rest = string:trim(Rest0, leading), 3140 Fields = string:tokens(Rest, " "), 3141 %% After ')': state, ppid → index 2 3142 case length(Fields) >= 2 of 3143 true -> 3144 try 3145 {ok, list_to_integer(lists:nth(2, Fields))} 3146 catch 3147 _:_ -> error 3148 end; 3149 false -> 3150 error 3151 end; 3152 _ -> 3153 error 3154 end. 3155 3156%% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path. 3157tty_nr_to_path(0) -> 3158 error; 3159tty_nr_to_path(TtyNr) when is_integer(TtyNr) -> 3160 Major = (TtyNr bsr 8) band 16#ff, 3161 Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8), 3162 Path = 3163 case Major of 3164 136 -> 3165 "/dev/pts/" ++ integer_to_list(Minor); 3166 4 when Minor >= 64 -> 3167 "/dev/ttyS" ++ integer_to_list(Minor - 64); 3168 4 -> 3169 "/dev/tty" ++ integer_to_list(Minor); 3170 _ -> 3171 undefined 3172 end, 3173 case Path of 3174 undefined -> 3175 error; 3176 _ -> 3177 case file:read_file_info(Path) of 3178 {ok, _} -> {ok, Path}; 3179 _ -> error 3180 end 3181 end. 3182 3183reason_to_bin(Reason) when is_atom(Reason) -> 3184 atom_to_binary(Reason, utf8); 3185reason_to_bin(Reason) when is_binary(Reason) -> 3186 Reason; 3187reason_to_bin(Reason) -> 3188 iolist_to_binary(io_lib:format("~p", [Reason])). 3189 3190%% --------------------------------------------------------------------------- 3191%% Format Unix epoch seconds as local calendar time: 3192%% "Jul 3 2026 9:39:40 PM" (abbreviated month, 12-hour clock). 3193%% Used for `ls` modified column display (data stays as raw Int). 3194%% --------------------------------------------------------------------------- 3195 3196-spec format_unix_local(integer()) -> binary(). 3197format_unix_local(Seconds) when is_integer(Seconds) -> 3198 try 3199 {{Y, Mo, D}, {H, Mi, S}} = 3200 calendar:system_time_to_local_time(Seconds, second), 3201 {H12, AmPm} = to_12h(H), 3202 iolist_to_binary( 3203 io_lib:format( 3204 "~s ~B ~4..0B ~B:~2..0B:~2..0B ~s", 3205 [month_abbr(Mo), D, Y, H12, Mi, S, AmPm] 3206 ) 3207 ) 3208 catch 3209 _:_ -> 3210 integer_to_binary(Seconds) 3211 end; 3212format_unix_local(_) -> 3213 <<"0">>. 3214 3215%% 0 → 12 AM, 1–11 → AM, 12 → 12 PM, 13–23 → 1–11 PM 3216to_12h(0) -> {12, "AM"}; 3217to_12h(H) when H < 12 -> {H, "AM"}; 3218to_12h(12) -> {12, "PM"}; 3219to_12h(H) -> {H - 12, "PM"}. 3220 3221month_abbr(1) -> "Jan"; 3222month_abbr(2) -> "Feb"; 3223month_abbr(3) -> "Mar"; 3224month_abbr(4) -> "Apr"; 3225month_abbr(5) -> "May"; 3226month_abbr(6) -> "Jun"; 3227month_abbr(7) -> "Jul"; 3228month_abbr(8) -> "Aug"; 3229month_abbr(9) -> "Sep"; 3230month_abbr(10) -> "Oct"; 3231month_abbr(11) -> "Nov"; 3232month_abbr(12) -> "Dec"; 3233month_abbr(_) -> "???".