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