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
49 kB 1543 lines
1%% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes. 2-module(gleshell_ffi). 3-include_lib("kernel/include/file.hrl"). 4-export([ 5 get_line/1, 6 parse_line/2, 7 run_as_shell/1, 8 spawn_shell/2, 9 set_cwd/1, 10 get_cwd/0, 11 getenv/1, 12 setenv/2, 13 list_env/0, 14 run_cmd/2, 15 run_cmd_tty/2, 16 which/1, 17 which_all/1, 18 home_dir/0, 19 stdout_isatty/0, 20 println/1, 21 take_output_shown/0, 22 clear_output_shown/0 23]). 24 25-define(ESC, 16#1b). 26-define(CSI_CLEAR_EOL, "\e[K"). 27-define(HISTORY_MAX, 2000). 28 29%% --------------------------------------------------------------------------- 30%% Public: write a line (CRLF in raw TTY mode so multi-line output does not 31%% staircase — raw mode does not map LF → CR+LF the way cooked mode does). 32%% --------------------------------------------------------------------------- 33 34-spec println(binary()) -> nil. 35println(Text) when is_binary(Text) -> 36 case get(gleshell_raw) of 37 true -> 38 io:put_chars([to_crlf(Text), <<"\r\n">>]), 39 nil; 40 _ -> 41 io:put_chars([Text, $\n]), 42 nil 43 end. 44 45%% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 46to_crlf(Bin) when is_binary(Bin) -> 47 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), 48 B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]), 49 binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]). 50 51%% --------------------------------------------------------------------------- 52%% Public: read a line (syntax-highlighted when raw TTY mode is active) 53%% --------------------------------------------------------------------------- 54 55-spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 56get_line(Prompt) when is_binary(Prompt) -> 57 case get(gleshell_raw) of 58 true -> 59 raw_get_line(Prompt); 60 _ -> 61 classic_get_line(Prompt) 62 end. 63 64%% Edlin / get_until path (non-TTY or when raw mode unavailable). 65classic_get_line(Prompt) -> 66 PromptChars = unicode:characters_to_list(Prompt), 67 case io:request( 68 standard_io, 69 {get_until, unicode, PromptChars, ?MODULE, parse_line, []} 70 ) of 71 eof -> 72 {error, <<"eof">>}; 73 {error, interrupted} -> 74 %% Ctrl+C while reading: cancel line, keep the REPL alive. 75 {error, <<"interrupted">>}; 76 {error, _} -> 77 {error, <<"io_error">>}; 78 Line when is_list(Line); is_binary(Line) -> 79 Bin = unicode:characters_to_binary(Line), 80 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 81 {ok, Stripped}; 82 Other -> 83 try 84 Bin = unicode:characters_to_binary(Other), 85 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 86 {ok, Stripped} 87 catch 88 _:_ -> 89 {error, <<"io_error">>} 90 end 91 end. 92 93%% get_until callback: edlin already gathers a full line. 94-spec parse_line(term(), term()) -> 95 {done, eof | string(), list()} | {more, term()}. 96parse_line(_Cont, eof) -> 97 {done, eof, []}; 98parse_line(_Cont, Chars) when is_list(Chars) -> 99 {done, Chars, []}. 100 101%% --------------------------------------------------------------------------- 102%% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. 103%% Falls back to edlin interactive shell when raw is unavailable. 104%% 105%% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit). 106%% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor 107%% (cancel line) instead of killing the shell. See erl(1) +B. 108%% --------------------------------------------------------------------------- 109 110-spec run_as_shell(fun(() -> term())) -> nil. 111run_as_shell(Fun) when is_function(Fun, 0) -> 112 case ensure_plus_bc() of 113 {parent, Port} -> 114 %% Child owns the TTY; we only wait for its exit status. 115 receive 116 {Port, {exit_status, Status}} -> 117 erlang:halt(Status) 118 end; 119 child -> 120 enable_shell_history(), 121 case try_start_raw() of 122 true -> 123 put(gleshell_raw, true), 124 load_line_history(), 125 configure_line_editor(), 126 try 127 Fun() 128 after 129 save_line_history() 130 end, 131 nil; 132 false -> 133 erase(gleshell_raw), 134 Parent = self(), 135 case try_start_interactive(Parent, Fun) of 136 {ok, started} -> 137 receive 138 {gleshell_shell_done, ok} -> 139 nil; 140 {gleshell_shell_done, {error, Class, Reason, Stack}} -> 141 erlang:raise(Class, Reason, Stack) 142 end; 143 {ok, direct} -> 144 configure_line_editor(), 145 Fun(), 146 nil 147 end 148 end 149 end. 150 151%% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort). 152%% Returns `child` when this process should run the REPL, or `{parent, Port}` 153%% when we re-exec'd and should wait on the child. 154-spec ensure_plus_bc() -> child | {parent, port()}. 155ensure_plus_bc() -> 156 case os:getenv("GLESHELL_PLUS_BC") of 157 "1" -> 158 child; 159 _ -> 160 case already_has_plus_bc() of 161 true -> 162 os:putenv("GLESHELL_PLUS_BC", "1"), 163 child; 164 false -> 165 reexec_with_plus_bc() 166 end 167 end. 168 169already_has_plus_bc() -> 170 lists:any( 171 fun(Var) -> 172 case os:getenv(Var) of 173 false -> 174 false; 175 Flags -> 176 string:find(Flags, "+Bc") =/= nomatch 177 end 178 end, 179 ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"] 180 ). 181 182reexec_with_plus_bc() -> 183 os:putenv("GLESHELL_PLUS_BC", "1"), 184 case os:getenv("ERL_AFLAGS") of 185 false -> 186 os:putenv("ERL_AFLAGS", "+Bc"); 187 Flags -> 188 case string:find(Flags, "+Bc") of 189 nomatch -> 190 os:putenv("ERL_AFLAGS", "+Bc " ++ Flags); 191 _ -> 192 ok 193 end 194 end, 195 case os:find_executable("erl") of 196 false -> 197 %% No erl on PATH — continue without +Bc (BREAK menu may appear). 198 child; 199 Erl -> 200 Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()), 201 Extra = init:get_plain_arguments(), 202 Args = 203 ["+Bc", "-noshell"] ++ 204 Pa ++ 205 ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra], 206 try 207 Port = open_port( 208 {spawn_executable, Erl}, 209 [exit_status, nouse_stdio, {args, Args}] 210 ), 211 {parent, Port} 212 catch 213 _:_ -> 214 child 215 end 216 end. 217 218try_start_raw() -> 219 case stdout_isatty() of 220 false -> 221 false; 222 true -> 223 case catch shell:start_interactive({noshell, raw}) of 224 ok -> 225 true; 226 {error, already_started} -> 227 %% Cannot switch an existing interactive shell into raw. 228 false; 229 _ -> 230 false 231 end 232 end. 233 234try_start_interactive(Parent, Fun) -> 235 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 236 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 237 ok -> 238 {ok, started}; 239 {error, already_started} -> 240 {ok, direct}; 241 {error, _} -> 242 {ok, direct} 243 end. 244 245-spec spawn_shell(pid(), fun(() -> term())) -> pid(). 246spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 247 spawn(fun() -> 248 try 249 configure_line_editor(), 250 Fun() 251 of 252 _ -> 253 Parent ! {gleshell_shell_done, ok}, 254 exit(die) 255 catch 256 Class:Reason:Stack -> 257 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, 258 erlang:raise(Class, Reason, Stack) 259 end 260 end). 261 262configure_line_editor() -> 263 _ = io:setopts([{encoding, unicode}, binary]), 264 try 265 io:setopts([{line_history, true}]) 266 catch 267 _:_ -> 268 ok 269 end, 270 ok. 271 272enable_shell_history() -> 273 case application:get_env(kernel, shell_history_path) of 274 {ok, _} -> 275 ok; 276 undefined -> 277 Path = filename:basedir(user_cache, "gleshell-history"), 278 _ = application:set_env( 279 kernel, shell_history_path, Path, [{persistent, true}] 280 ), 281 ok 282 end, 283 case application:get_env(kernel, shell_history) of 284 {ok, _} -> 285 ok; 286 undefined -> 287 _ = application:set_env( 288 kernel, shell_history, enabled, [{persistent, true}] 289 ), 290 ok 291 end. 292 293%% --------------------------------------------------------------------------- 294%% Raw-mode line editor with Nushell-style syntax highlighting 295%% --------------------------------------------------------------------------- 296%% 297%% Buffer model: Left is graphemes before the cursor (reversed), 298%% Right is graphemes after the cursor (normal order). 299%% History is a list of binaries (newest first). 300 301raw_get_line(Prompt) when is_binary(Prompt) -> 302 PromptList = unicode:characters_to_list(Prompt), 303 History = case get(gleshell_history) of 304 L when is_list(L) -> L; 305 _ -> [] 306 end, 307 redraw(PromptList, [], []), 308 raw_loop(PromptList, [], [], History, 0, <<>>). 309 310%% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry. 311%% Saved: buffer saved when first entering history navigation. 312raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> 313 case read_key() of 314 eof -> 315 io:put_chars("\r\n"), 316 {error, <<"eof">>}; 317 {error, _} -> 318 io:put_chars("\r\n"), 319 {error, <<"io_error">>}; 320 enter -> 321 Line = buffer_to_bin(Left, Right), 322 io:put_chars("\r\n"), 323 push_history(Line), 324 {ok, Line}; 325 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 326 %% Printable Unicode codepoint 327 NewLeft = [C | Left], 328 redraw(Prompt, NewLeft, Right), 329 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 330 backspace -> 331 case Left of 332 [] -> 333 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 334 [_ | Rest] -> 335 redraw(Prompt, Rest, Right), 336 raw_loop(Prompt, Rest, Right, History, 0, <<>>) 337 end; 338 delete -> 339 case Right of 340 [] -> 341 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 342 [_ | Rest] -> 343 redraw(Prompt, Left, Rest), 344 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 345 end; 346 left -> 347 case Left of 348 [] -> 349 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 350 [C | Rest] -> 351 redraw(Prompt, Rest, [C | Right]), 352 raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved) 353 end; 354 right -> 355 case Right of 356 [] -> 357 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 358 [C | Rest] -> 359 redraw(Prompt, [C | Left], Rest), 360 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 361 end; 362 home -> 363 NewRight = lists:reverse(Left) ++ Right, 364 redraw(Prompt, [], NewRight), 365 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 366 'end' -> 367 NewLeft = lists:reverse(Right) ++ Left, 368 redraw(Prompt, NewLeft, []), 369 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 370 up -> 371 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 372 down -> 373 hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1); 374 ctrl_a -> 375 NewRight = lists:reverse(Left) ++ Right, 376 redraw(Prompt, [], NewRight), 377 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 378 ctrl_e -> 379 NewLeft = lists:reverse(Right) ++ Left, 380 redraw(Prompt, NewLeft, []), 381 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 382 ctrl_u -> 383 redraw(Prompt, [], Right), 384 raw_loop(Prompt, [], Right, History, 0, <<>>); 385 ctrl_k -> 386 redraw(Prompt, Left, []), 387 raw_loop(Prompt, Left, [], History, 0, <<>>); 388 ctrl_w -> 389 {NewLeft, _} = kill_word(Left), 390 redraw(Prompt, NewLeft, Right), 391 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 392 ctrl_d -> 393 case {Left, Right} of 394 {[], []} -> 395 io:put_chars("\r\n"), 396 {error, <<"eof">>}; 397 {_, []} -> 398 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 399 {_, [_ | Rest]} -> 400 redraw(Prompt, Left, Rest), 401 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 402 end; 403 ctrl_c -> 404 %% Cancel current line (like bash) and return empty. 405 io:put_chars("^C\r\n"), 406 {ok, <<>>}; 407 ctrl_l -> 408 io:put_chars("\e[H\e[2J"), 409 redraw(Prompt, Left, Right), 410 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 411 ctrl_r -> 412 reverse_search(Prompt, History); 413 tab -> 414 tab_complete(Prompt, Left, Right, History, HistPos, Saved); 415 _Other -> 416 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 417 end. 418 419%% --------------------------------------------------------------------------- 420%% Tab: filename completion (path under cursor) 421%% --------------------------------------------------------------------------- 422%% 423%% Completes the token before the cursor as a filesystem path. 424%% One match → insert it (directories get a trailing /). 425%% Several matches → extend the longest common prefix; if that does not 426%% advance the buffer, list candidates under the line and redraw. 427 428tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 429 {PrefixRev, Word} = word_before_cursor(Left), 430 case filename_completions(Word) of 431 [] -> 432 beep(), 433 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 434 [Only] -> 435 NewLeft = apply_completed_word(PrefixRev, Only), 436 redraw(Prompt, NewLeft, Right), 437 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 438 Matches -> 439 Common = longest_common_prefix(Matches), 440 case Common =/= Word andalso length(Common) >= length(Word) of 441 true -> 442 NewLeft = apply_completed_word(PrefixRev, Common), 443 redraw(Prompt, NewLeft, Right), 444 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 445 false -> 446 show_completions(Matches), 447 redraw(Prompt, Left, Right), 448 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 449 end 450 end. 451 452beep() -> 453 io:put_chars([7]). 454 455%% Left is graphemes before the cursor in reverse order. 456%% Returns {PrefixRev, WordForward} where Word is the path token. 457word_before_cursor(Left) -> 458 take_completion_word(Left, []). 459 460%% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward. 461take_completion_word([], Acc) -> 462 {[], Acc}; 463take_completion_word([C | Rest], Acc) -> 464 case is_completion_break(C) of 465 true -> 466 {[C | Rest], Acc}; 467 false -> 468 take_completion_word(Rest, [C | Acc]) 469 end. 470 471is_completion_break(C) when C =:= $\s; C =:= $\t -> 472 true; 473is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& -> 474 true; 475is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] -> 476 true; 477is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> -> 478 true; 479is_completion_break(C) when C =:= $'; C =:= $" -> 480 true; 481is_completion_break(_) -> 482 false. 483 484apply_completed_word(PrefixRev, Word) -> 485 lists:reverse(Word) ++ PrefixRev. 486 487%% Return sorted completion strings (as typed, with ~ preserved; dirs end in /). 488filename_completions(Word) -> 489 {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word), 490 ListDir = expand_home_path(ListDirTyped), 491 case file:list_dir(ListDir) of 492 {ok, Names0} -> 493 Names = lists:sort(Names0), 494 [ 495 InsertPrefix ++ maybe_dir_slash(ListDir, Name) 496 || Name <- Names, 497 lists:prefix(Base, Name), 498 show_dotfile(Base, Name) 499 ]; 500 {error, _} -> 501 [] 502 end. 503 504%% Hide dotfiles unless the partial name already starts with '.'. 505show_dotfile([$. | _], _) -> 506 true; 507show_dotfile(_, [$. | _]) -> 508 false; 509show_dotfile(_, _) -> 510 true. 511 512maybe_dir_slash(ListDir, Name) -> 513 case filelib:is_dir(filename:join(ListDir, Name)) of 514 true -> Name ++ "/"; 515 false -> Name 516 end. 517 518%% Split a path word into {dir_to_list, basename_prefix, insert_prefix}. 519%% insert_prefix is the directory part as the user typed it (incl. trailing /). 520split_completion_word(Word) -> 521 case rsplit_path(Word) of 522 {none, Base} -> 523 {".", Base, ""}; 524 {Dir, Base} -> 525 ListDir = 526 case Dir of 527 "" -> "/"; 528 _ -> Dir 529 end, 530 InsertPrefix = 531 case Dir of 532 "" -> "/"; 533 _ -> Dir ++ "/" 534 end, 535 {ListDir, Base, InsertPrefix} 536 end. 537 538%% Rightmost / splits directory from the partial basename. 539rsplit_path(Word) -> 540 rsplit_path(lists:reverse(Word), []). 541 542rsplit_path([], Acc) -> 543 {none, Acc}; 544rsplit_path([$/ | Rest], Acc) -> 545 {lists:reverse(Rest), Acc}; 546rsplit_path([C | Rest], Acc) -> 547 rsplit_path(Rest, [C | Acc]). 548 549expand_home_path(Path) -> 550 case Path of 551 "~" -> 552 home_path_string(); 553 [$~, $/ | More] -> 554 home_path_string() ++ "/" ++ More; 555 _ -> 556 Path 557 end. 558 559home_path_string() -> 560 case os:getenv("HOME") of 561 false -> "."; 562 Home when is_list(Home) -> Home; 563 Home when is_binary(Home) -> unicode:characters_to_list(Home) 564 end. 565 566longest_common_prefix([]) -> 567 ""; 568longest_common_prefix([H | T]) -> 569 lists:foldl(fun lcp2/2, H, T). 570 571lcp2(A, B) -> 572 lcp2(A, B, []). 573 574lcp2([X | As], [X | Bs], Acc) -> 575 lcp2(As, Bs, [X | Acc]); 576lcp2(_, _, Acc) -> 577 lists:reverse(Acc). 578 579show_completions(Matches) -> 580 io:put_chars("\r\n"), 581 case io:columns() of 582 {ok, Cols} when is_integer(Cols), Cols > 8 -> 583 print_completion_columns(Matches, Cols); 584 _ -> 585 io:put_chars(lists:join(" ", Matches)), 586 io:put_chars("\r\n") 587 end. 588 589print_completion_columns(Matches, Cols) -> 590 MaxLen = lists:max([0 | [length(M) || M <- Matches]]), 591 Width = MaxLen + 2, 592 PerRow = max(1, Cols div Width), 593 print_rows(Matches, PerRow, Width). 594 595print_rows([], _PerRow, _Width) -> 596 ok; 597print_rows(Matches, PerRow, Width) -> 598 {Row, Rest} = take_n(Matches, PerRow, []), 599 Line = [ 600 pad_cell(M, Width) 601 || M <- Row 602 ], 603 io:put_chars([Line, "\r\n"]), 604 print_rows(Rest, PerRow, Width). 605 606take_n(List, 0, Acc) -> 607 {lists:reverse(Acc), List}; 608take_n([], _N, Acc) -> 609 {lists:reverse(Acc), []}; 610take_n([H | T], N, Acc) -> 611 take_n(T, N - 1, [H | Acc]). 612 613pad_cell(S, Width) -> 614 Pad = Width - length(S), 615 case Pad > 0 of 616 true -> S ++ lists:duplicate(Pad, $\s); 617 false -> S ++ " " 618 end. 619 620hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 621 NewPos = HistPos + Delta, 622 Len = length(History), 623 if 624 NewPos < 0 -> 625 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 626 NewPos =:= 0 -> 627 %% Restore saved draft 628 {L, R} = bin_to_buffer(Saved), 629 redraw(Prompt, L, R), 630 raw_loop(Prompt, L, R, History, 0, <<>>); 631 NewPos > Len -> 632 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 633 true -> 634 NewSaved = case HistPos of 635 0 -> buffer_to_bin(Left, Right); 636 _ -> Saved 637 end, 638 Entry = lists:nth(NewPos, History), 639 {L, R} = bin_to_buffer(Entry), 640 redraw(Prompt, L, R), 641 raw_loop(Prompt, L, R, History, NewPos, NewSaved) 642 end. 643 644%% Minimal Ctrl+R reverse-i-search over history. 645reverse_search(Prompt, History) -> 646 reverse_search_loop(Prompt, History, [], match_history(History, [])). 647 648reverse_search_loop(Prompt, History, Query, Match) -> 649 Hint = unicode:characters_to_list( 650 ["(reverse-i-search)`", Query, "': ", Match] 651 ), 652 io:put_chars([$\r, Hint, ?CSI_CLEAR_EOL]), 653 case read_key() of 654 eof -> 655 io:put_chars("\r\n"), 656 {error, <<"eof">>}; 657 enter -> 658 %% Accept match onto the edit line; do not submit (edit first). 659 Line = iolist_to_binary(Match), 660 {L, R} = bin_to_buffer(Line), 661 redraw(Prompt, L, R), 662 raw_loop(Prompt, L, R, History, 0, <<>>); 663 ctrl_c -> 664 io:put_chars("\r\n"), 665 redraw(Prompt, [], []), 666 raw_loop(Prompt, [], [], History, 0, <<>>); 667 ctrl_g -> 668 redraw(Prompt, [], []), 669 raw_loop(Prompt, [], [], History, 0, <<>>); 670 ctrl_r -> 671 %% Find older match 672 NewMatch = match_history_after(History, Query, Match), 673 reverse_search_loop(Prompt, History, Query, NewMatch); 674 backspace -> 675 NewQuery = case Query of 676 [] -> []; 677 [_ | _] -> lists:droplast(Query) 678 end, 679 reverse_search_loop( 680 Prompt, History, NewQuery, match_history(History, NewQuery) 681 ); 682 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 683 NewQuery = Query ++ [C], 684 reverse_search_loop( 685 Prompt, History, NewQuery, match_history(History, NewQuery) 686 ); 687 _ -> 688 reverse_search_loop(Prompt, History, Query, Match) 689 end. 690 691match_history(_History, []) -> 692 ""; 693match_history(History, Query) -> 694 QBin = unicode:characters_to_binary(Query), 695 case first_match(History, QBin) of 696 undefined -> ""; 697 Bin -> unicode:characters_to_list(Bin) 698 end. 699 700match_history_after(History, Query, CurrentMatch) -> 701 QBin = unicode:characters_to_binary(Query), 702 CurBin = unicode:characters_to_binary(CurrentMatch), 703 case skip_until_then_match(History, CurBin, QBin, false) of 704 undefined -> CurrentMatch; 705 Bin -> unicode:characters_to_list(Bin) 706 end. 707 708first_match([], _) -> 709 undefined; 710first_match([H | T], Q) -> 711 case binary:match(H, Q) of 712 nomatch -> first_match(T, Q); 713 _ -> H 714 end. 715 716skip_until_then_match([], _Cur, _Q, _Seen) -> 717 undefined; 718skip_until_then_match([H | T], Cur, Q, false) -> 719 case H =:= Cur of 720 true -> skip_until_then_match(T, Cur, Q, true); 721 false -> skip_until_then_match(T, Cur, Q, false) 722 end; 723skip_until_then_match([H | T], Cur, Q, true) -> 724 case binary:match(H, Q) of 725 nomatch -> skip_until_then_match(T, Cur, Q, true); 726 _ -> H 727 end. 728 729kill_word([]) -> 730 {[], []}; 731kill_word(Left) -> 732 %% Left is reversed: strip trailing spaces then a word. 733 L1 = drop_while_space(Left), 734 drop_while_word(L1). 735 736drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t -> 737 drop_while_space(Rest); 738drop_while_space(L) -> 739 L. 740 741drop_while_word([]) -> 742 {[], []}; 743drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t -> 744 {[C | Rest], []}; 745drop_while_word([_ | Rest]) -> 746 drop_while_word(Rest). 747 748redraw(Prompt, Left, Right) -> 749 Full = lists:reverse(Left) ++ Right, 750 FullBin = unicode:characters_to_binary(Full), 751 Colored = highlight_line(FullBin), 752 io:put_chars([$\r, Prompt, Colored, ?CSI_CLEAR_EOL]), 753 case length(Right) of 754 0 -> 755 ok; 756 N -> 757 io:put_chars(["\e[", integer_to_list(N), $D]) 758 end. 759 760highlight_line(Bin) when is_binary(Bin) -> 761 case get(gleshell_color) of 762 false -> 763 Bin; 764 _ -> 765 try 766 case 'gleshell@highlight':line(Bin) of 767 Out when is_binary(Out) -> Out; 768 Out when is_list(Out) -> unicode:characters_to_binary(Out); 769 _ -> Bin 770 end 771 catch 772 _:_ -> 773 Bin 774 end 775 end. 776 777buffer_to_bin(Left, Right) -> 778 unicode:characters_to_binary(lists:reverse(Left) ++ Right). 779 780bin_to_buffer(Bin) when is_binary(Bin) -> 781 Chars = unicode:characters_to_list(Bin), 782 {lists:reverse(Chars), []}; 783bin_to_buffer(List) when is_list(List) -> 784 {lists:reverse(List), []}. 785 786%% --------------------------------------------------------------------------- 787%% Key reading (raw mode — keys arrive as soon as pressed) 788%% --------------------------------------------------------------------------- 789 790read_key() -> 791 case io:get_chars("", 1) of 792 eof -> 793 eof; 794 {error, Reason} -> 795 {error, Reason}; 796 <<C/utf8>> -> 797 decode_key(C, <<>>); 798 [C] when is_integer(C) -> 799 decode_key(C, <<>>); 800 Bin when is_binary(Bin), byte_size(Bin) > 0 -> 801 case unicode:characters_to_list(Bin) of 802 [C | _] -> decode_key(C, <<>>); 803 _ -> read_key() 804 end; 805 List when is_list(List), List =/= [] -> 806 decode_key(hd(List), <<>>); 807 _ -> 808 read_key() 809 end. 810 811decode_key($\r, _) -> enter; 812decode_key($\n, _) -> enter; 813decode_key($\t, _) -> tab; 814decode_key(127, _) -> backspace; 815decode_key($\b, _) -> backspace; 816decode_key(1, _) -> ctrl_a; 817decode_key(5, _) -> ctrl_e; 818decode_key(4, _) -> ctrl_d; 819decode_key(3, _) -> ctrl_c; 820decode_key(11, _) -> ctrl_k; 821decode_key(21, _) -> ctrl_u; 822decode_key(23, _) -> ctrl_w; 823decode_key(12, _) -> ctrl_l; 824decode_key(18, _) -> ctrl_r; 825decode_key(?ESC, _) -> 826 read_escape(); 827decode_key(C, _) when is_integer(C), C >= 32 -> 828 {char, C}; 829decode_key(_, _) -> 830 other. 831 832read_escape() -> 833 case io:get_chars("", 1) of 834 eof -> 835 other; 836 <<"[">> -> 837 read_csi(); 838 <<$O>> -> 839 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 840 case io:get_chars("", 1) of 841 <<"A">> -> up; 842 <<"B">> -> down; 843 <<"C">> -> right; 844 <<"D">> -> left; 845 <<"H">> -> home; 846 <<"F">> -> 'end'; 847 _ -> other 848 end; 849 _ -> 850 other 851 end. 852 853read_csi() -> 854 read_csi_params([]). 855 856read_csi_params(Acc) -> 857 case io:get_chars("", 1) of 858 eof -> 859 other; 860 <<C/utf8>> when C >= $0, C =< $9 -> 861 read_csi_params([C | Acc]); 862 <<$;>> -> 863 read_csi_params([$; | Acc]); 864 <<$~>> -> 865 Params = lists:reverse(Acc), 866 case Params of 867 "1" -> home; 868 "3" -> delete; 869 "4" -> 'end'; 870 "7" -> home; 871 "8" -> 'end'; 872 _ -> other 873 end; 874 <<"A">> -> 875 up; 876 <<"B">> -> 877 down; 878 <<"C">> -> 879 right; 880 <<"D">> -> 881 left; 882 <<"H">> -> 883 home; 884 <<"F">> -> 885 'end'; 886 _ -> 887 other 888 end. 889 890%% --------------------------------------------------------------------------- 891%% History persistence 892%% --------------------------------------------------------------------------- 893 894history_file() -> 895 case application:get_env(kernel, shell_history_path) of 896 {ok, Path} when is_list(Path) -> 897 filename:join(Path, "lines"); 898 {ok, Path} when is_binary(Path) -> 899 filename:join(unicode:characters_to_list(Path), "lines"); 900 _ -> 901 filename:join( 902 filename:basedir(user_cache, "gleshell-history"), "lines" 903 ) 904 end. 905 906load_line_history() -> 907 File = history_file(), 908 case file:read_file(File) of 909 {ok, Bin} -> 910 Lines = [ 911 L 912 || L <- binary:split(Bin, <<"\n">>, [global]), 913 L =/= <<>> 914 ], 915 %% Newest first 916 put(gleshell_history, lists:reverse(Lines)); 917 _ -> 918 put(gleshell_history, []) 919 end, 920 put(gleshell_color, true), 921 ok. 922 923save_line_history() -> 924 case get(gleshell_history) of 925 Hist when is_list(Hist) -> 926 File = history_file(), 927 _ = filelib:ensure_dir(File), 928 %% Store oldest-first for human readability 929 Body = [[L, $\n] || L <- lists:reverse(lists:sublist(Hist, ?HISTORY_MAX))], 930 _ = file:write_file(File, Body), 931 ok; 932 _ -> 933 ok 934 end. 935 936push_history(<<>>) -> 937 ok; 938push_history(Line) when is_binary(Line) -> 939 Hist = case get(gleshell_history) of 940 L when is_list(L) -> L; 941 _ -> [] 942 end, 943 New = case Hist of 944 [Line | _] -> Hist; 945 _ -> [Line | Hist] 946 end, 947 put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 948 ok. 949 950%% --------------------------------------------------------------------------- 951%% OS / process helpers 952%% --------------------------------------------------------------------------- 953 954-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 955set_cwd(Path) when is_binary(Path) -> 956 case file:set_cwd(unicode:characters_to_list(Path)) of 957 ok -> 958 {ok, nil}; 959 {error, Reason} -> 960 {error, reason_to_bin(Reason)} 961 end. 962 963-spec get_cwd() -> {ok, binary()} | {error, binary()}. 964get_cwd() -> 965 case file:get_cwd() of 966 {ok, Dir} -> 967 {ok, unicode:characters_to_binary(Dir)}; 968 {error, Reason} -> 969 {error, reason_to_bin(Reason)} 970 end. 971 972-spec getenv(binary()) -> {ok, binary()} | {error, nil}. 973getenv(Name) when is_binary(Name) -> 974 case os:getenv(unicode:characters_to_list(Name)) of 975 false -> 976 {error, nil}; 977 Value -> 978 {ok, unicode:characters_to_binary(Value)} 979 end. 980 981-spec setenv(binary(), binary()) -> {ok, nil}. 982setenv(Name, Value) when is_binary(Name), is_binary(Value) -> 983 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 984 {ok, nil}. 985 986%% All process environment variables as a list of {Name, Value} binaries. 987-spec list_env() -> list({binary(), binary()}). 988list_env() -> 989 lists:map( 990 fun(Entry) -> 991 case string:split(Entry, "=", leading) of 992 [K, V] -> 993 {unicode:characters_to_binary(K), unicode:characters_to_binary(V)}; 994 [K] -> 995 {unicode:characters_to_binary(K), <<>>}; 996 _ -> 997 {<<>>, <<>>} 998 end 999 end, 1000 os:getenv() 1001 ). 1002 1003-spec which(binary()) -> {ok, binary()} | {error, nil}. 1004which(Command) when is_binary(Command) -> 1005 case which_all(Command) of 1006 [Path | _] -> 1007 {ok, Path}; 1008 [] -> 1009 {error, nil} 1010 end. 1011 1012%% All matching executables on PATH (or the path itself if absolute/relative). 1013%% Order matches PATH search; duplicates from the same resolved path are dropped. 1014-spec which_all(binary()) -> [binary()]. 1015which_all(Command) when is_binary(Command) -> 1016 Cmd = unicode:characters_to_list(Command), 1017 case Cmd of 1018 [] -> 1019 []; 1020 _ -> 1021 case has_path_sep(Cmd) of 1022 true -> 1023 case is_executable_file(Cmd) of 1024 true -> 1025 [unicode:characters_to_binary(filename:absname(Cmd))]; 1026 false -> 1027 [] 1028 end; 1029 false -> 1030 case os:getenv("PATH") of 1031 false -> 1032 []; 1033 PathStr -> 1034 Dirs = string:tokens(PathStr, path_sep()), 1035 find_all_in_path(Cmd, Dirs, #{}, []) 1036 end 1037 end 1038 end. 1039 1040path_sep() -> 1041 case os:type() of 1042 {win32, _} -> ";"; 1043 _ -> ":" 1044 end. 1045 1046has_path_sep(Cmd) -> 1047 lists:member($/, Cmd) orelse lists:member($\\, Cmd). 1048 1049find_all_in_path(_Cmd, [], _Seen, Acc) -> 1050 lists:reverse(Acc); 1051find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) -> 1052 File = filename:join(Dir, Cmd), 1053 case is_executable_file(File) of 1054 true -> 1055 Abs = filename:absname(File), 1056 Bin = unicode:characters_to_binary(Abs), 1057 case maps:is_key(Abs, Seen) of 1058 true -> 1059 find_all_in_path(Cmd, Rest, Seen, Acc); 1060 false -> 1061 find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc]) 1062 end; 1063 false -> 1064 find_all_in_path(Cmd, Rest, Seen, Acc) 1065 end. 1066 1067is_executable_file(Path) -> 1068 case file:read_file_info(Path) of 1069 {ok, #file_info{type = regular, mode = Mode}} -> 1070 %% Any execute bit (owner/group/other). 1071 (Mode band 8#111) =/= 0; 1072 _ -> 1073 false 1074 end. 1075 1076-spec home_dir() -> {ok, binary()} | {error, binary()}. 1077home_dir() -> 1078 case os:getenv("HOME") of 1079 false -> 1080 {error, <<"HOME not set">>}; 1081 Home -> 1082 {ok, unicode:characters_to_binary(Home)} 1083 end. 1084 1085-spec stdout_isatty() -> boolean(). 1086stdout_isatty() -> 1087 case io:columns() of 1088 {ok, _} -> 1089 true; 1090 _ -> 1091 try 1092 case prim_tty:isatty(stdout) of 1093 true -> true; 1094 _ -> false 1095 end 1096 catch 1097 _:_ -> 1098 false 1099 end 1100 end. 1101 1102%% --------------------------------------------------------------------------- 1103%% External commands 1104%% 1105%% Two modes: 1106%% 1107%% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 1108%% non-TTY). Uses pipes; the child does NOT get a real terminal. 1109%% 1110%% 2. `run_cmd_tty/2` — foreground interactive. The child inherits the real 1111%% stdio FDs (`nouse_stdio`) so pagers (`less`), editors (`vim`), and most 1112%% TUI tools work. BEAM only waits on exit status; keys go straight to the 1113%% child (no broken PTY byte-relay). 1114%% 1115%% Auth tools (`sudo`, `run0`, …) need a *controlling* TTY; erl_child_setup 1116%% calls setsid, so plain inherit is not enough. For those we wrap with 1117%% util-linux `script` to allocate a PTY and relay keys via `io:get_chars` 1118%% (same path as the raw line editor — a competing file:read on /dev/pts 1119%% never sees keypresses while prim_tty owns the device). 1120%% --------------------------------------------------------------------------- 1121 1122-spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 1123run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> 1124 case resolve_cmd(Command, Args) of 1125 {error, _} = E -> 1126 E; 1127 {ok, Path, PortArgs} -> 1128 try 1129 run_cmd_capture(Path, PortArgs) 1130 catch 1131 _:Reason -> 1132 {error, reason_to_bin(Reason)} 1133 end 1134 end. 1135 1136%% Foreground interactive: inherit TTY when possible. 1137-spec run_cmd_tty(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 1138run_cmd_tty(Command, Args) when is_binary(Command), is_list(Args) -> 1139 case resolve_cmd(Command, Args) of 1140 {error, _} = E -> 1141 E; 1142 {ok, Path, PortArgs} -> 1143 try 1144 case stdout_isatty() of 1145 false -> 1146 run_cmd_capture(Path, PortArgs); 1147 true -> 1148 case {needs_controlling_tty(Path), os:find_executable("script"), find_tty_path()} of 1149 {true, Script, {ok, Tty}} when is_list(Script) -> 1150 run_cmd_pty(Script, Path, PortArgs, Tty); 1151 _ -> 1152 run_cmd_inherit(Path, PortArgs) 1153 end 1154 end 1155 catch 1156 _:Reason -> 1157 {error, reason_to_bin(Reason)} 1158 end 1159 end. 1160 1161resolve_cmd(Command, Args) -> 1162 case os:find_executable(unicode:characters_to_list(Command)) of 1163 false -> 1164 {error, <<"command not found: ", Command/binary>>}; 1165 Path -> 1166 PortArgs = [unicode:characters_to_list(A) || A <- Args], 1167 {ok, Path, PortArgs} 1168 end. 1169 1170%% Basename check for tools that need a controlling TTY (not just isatty). 1171needs_controlling_tty(Path) when is_list(Path) -> 1172 Base = filename:basename(Path), 1173 lists:member(Base, ["sudo", "run0", "pkexec", "doas", "su"]). 1174 1175%% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants 1176%% it so tools like `jj` still embed ANSI we can pass through on display. 1177%% 1178%% Stdin is redirected from /dev/null via `sh -c` so programs that read stdin 1179%% (bare `less`, `cat`, `wc`) get EOF immediately instead of hanging on an 1180%% open-but-never-written pipe. (Port option `out` alone breaks exit_status 1181%% delivery on current OTP.) 1182run_cmd_capture(Path, PortArgs) -> 1183 Sh = 1184 case os:find_executable("sh") of 1185 false -> 1186 "/bin/sh"; 1187 S -> 1188 S 1189 end, 1190 %% sh -c 'exec "$0" "$@" < /dev/null' path arg1 arg2 ... 1191 %% $0 = Path; "$@" = remaining args — no shell-quoting of user args. 1192 Port = open_port( 1193 {spawn_executable, Sh}, 1194 [ 1195 binary, 1196 exit_status, 1197 stderr_to_stdout, 1198 use_stdio, 1199 stream, 1200 {env, child_env()}, 1201 {args, ["-c", "exec \"$0\" \"$@\" < /dev/null", Path | PortArgs]} 1202 ] 1203 ), 1204 put(gleshell_output_shown, false), 1205 collect_output(Port, <<>>). 1206 1207%% Inherit real stdio — pagers/editors talk to the terminal directly. 1208%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 1209run_cmd_inherit(Path, PortArgs) -> 1210 Port = open_port( 1211 {spawn_executable, Path}, 1212 [ 1213 exit_status, 1214 nouse_stdio, 1215 {env, child_env()}, 1216 {args, PortArgs} 1217 ] 1218 ), 1219 put(gleshell_output_shown, true), 1220 %% No timeout: less/vim/top may run for a long time. 1221 receive 1222 {Port, {exit_status, Status}} -> 1223 {ok, {Status, <<>>}} 1224 end. 1225 1226%% Controlling-TTY + key relay for sudo/run0/etc. 1227run_cmd_pty(Script, Path, PortArgs, TtyPath) -> 1228 Port = open_port( 1229 {spawn_executable, Script}, 1230 [ 1231 binary, 1232 exit_status, 1233 stderr_to_stdout, 1234 use_stdio, 1235 stream, 1236 {env, child_env()}, 1237 {args, ["-q", "-e", "/dev/null", "--", Path | PortArgs]} 1238 ] 1239 ), 1240 case file:open(TtyPath, [write, raw, binary]) of 1241 {ok, TtyOut} -> 1242 GL = group_leader(), 1243 Reader = spawn(fun() -> 1244 group_leader(GL, self()), 1245 io_to_port(Port) 1246 end), 1247 put(gleshell_output_shown, true), 1248 try 1249 collect_output_relay(Port, TtyOut, <<>>) 1250 after 1251 exit(Reader, kill), 1252 catch file:close(TtyOut) 1253 end; 1254 {error, _} -> 1255 put(gleshell_output_shown, false), 1256 %% Fall back to inherit rather than a silent capture hang. 1257 run_cmd_inherit(Path, PortArgs) 1258 end. 1259 1260%% Relay keypresses from the group leader to the child's PTY (script stdin). 1261io_to_port(Port) -> 1262 case io:get_chars("", 1) of 1263 eof -> 1264 ok; 1265 {error, _} -> 1266 ok; 1267 Data -> 1268 case io_data_to_bin(Data) of 1269 <<>> -> 1270 io_to_port(Port); 1271 Bin -> 1272 catch port_command(Port, Bin), 1273 io_to_port(Port) 1274 end 1275 end. 1276 1277io_data_to_bin(Bin) when is_binary(Bin) -> 1278 Bin; 1279io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F -> 1280 <<C>>; 1281io_data_to_bin(List) when is_list(List) -> 1282 case unicode:characters_to_binary(List) of 1283 Bin when is_binary(Bin) -> 1284 Bin; 1285 _ -> 1286 <<>> 1287 end; 1288io_data_to_bin(_) -> 1289 <<>>. 1290 1291collect_output(Port, Acc) -> 1292 receive 1293 {Port, {data, Data}} when is_binary(Data) -> 1294 collect_output(Port, <<Acc/binary, Data/binary>>); 1295 {Port, {data, Data}} when is_list(Data) -> 1296 Bin = unicode:characters_to_binary(Data), 1297 collect_output(Port, <<Acc/binary, Bin/binary>>); 1298 {Port, {exit_status, Status}} -> 1299 {ok, {Status, Acc}} 1300 after 120_000 -> 1301 catch port_close(Port), 1302 {error, <<"command timed out after 120s">>} 1303 end. 1304 1305%% PTY auth session: no timeout (password prompts, etc.). 1306collect_output_relay(Port, Tty, Acc) -> 1307 receive 1308 {Port, {data, Data}} when is_binary(Data) -> 1309 _ = file:write(Tty, Data), 1310 collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>); 1311 {Port, {data, Data}} when is_list(Data) -> 1312 Bin = unicode:characters_to_binary(Data), 1313 _ = file:write(Tty, Bin), 1314 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 1315 {Port, {exit_status, Status}} -> 1316 {ok, {Status, normalize_pty_output(Acc)}} 1317 end. 1318 1319%% PTY line discipline often emits CR-LF; normalize to LF for structured use. 1320normalize_pty_output(Bin) when is_binary(Bin) -> 1321 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 1322 1323%% Extra env for external commands (merged into the process environment). 1324%% 1325%% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 1326%% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R) 1327%% and exit on short output (-F) without clearing the screen (-X). 1328%% - FORCE_COLOR / CLICOLOR_FORCE when the shell itself wants color and the 1329%% child has no TTY (direct path): tools like jj/git/ripgrep emit ANSI so 1330%% we can show their colors when re-printing the captured string. 1331child_env() -> 1332 Env0 = [{"SHELL", "/bin/sh"}], 1333 Env1 = 1334 case os:getenv("LESS") of 1335 false -> 1336 [{"LESS", "FRX"} | Env0]; 1337 "" -> 1338 [{"LESS", "FRX"} | Env0]; 1339 _ -> 1340 Env0 1341 end, 1342 case want_child_color() of 1343 false -> 1344 Env1; 1345 true -> 1346 Env2 = 1347 case os:getenv("FORCE_COLOR") of 1348 false -> 1349 [{"FORCE_COLOR", "1"} | Env1]; 1350 "0" -> 1351 Env1; 1352 _ -> 1353 Env1 1354 end, 1355 case os:getenv("CLICOLOR_FORCE") of 1356 false -> 1357 [{"CLICOLOR_FORCE", "1"} | Env2]; 1358 "0" -> 1359 Env2; 1360 _ -> 1361 Env2 1362 end 1363 end. 1364 1365%% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY 1366%% or when the parent already forces color. 1367want_child_color() -> 1368 case os:getenv("NO_COLOR") of 1369 L when is_list(L), L =/= "" -> 1370 false; 1371 _ -> 1372 case stdout_isatty() of 1373 true -> 1374 true; 1375 false -> 1376 force_color_set() 1377 end 1378 end. 1379 1380force_color_set() -> 1381 case os:getenv("FORCE_COLOR") of 1382 L when is_list(L), L =/= "", L =/= "0" -> 1383 true; 1384 _ -> 1385 case os:getenv("CLICOLOR_FORCE") of 1386 L when is_list(L), L =/= "", L =/= "0" -> 1387 true; 1388 _ -> 1389 false 1390 end 1391 end. 1392 1393%% True when the last external command already streamed output to the TTY 1394%% (PTY relay). Cleared after being read so the REPL does not double-print. 1395-spec take_output_shown() -> boolean(). 1396take_output_shown() -> 1397 case erase(gleshell_output_shown) of 1398 true -> true; 1399 _ -> false 1400 end. 1401 1402-spec clear_output_shown() -> nil. 1403clear_output_shown() -> 1404 erase(gleshell_output_shown), 1405 nil. 1406 1407%% Find a terminal device attached to this BEAM (or an ancestor). 1408%% Note: os:getpid() returns a string, not an integer. 1409find_tty_path() -> 1410 case catch list_to_integer(os:getpid()) of 1411 Pid when is_integer(Pid) -> 1412 case tty_path_for_pid(Pid) of 1413 {ok, _} = Ok -> 1414 Ok; 1415 error -> 1416 walk_parent_tty(Pid, 12) 1417 end; 1418 _ -> 1419 error 1420 end. 1421 1422walk_parent_tty(_Pid, 0) -> 1423 error; 1424walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 -> 1425 case parent_pid(Pid) of 1426 {ok, Parent} when Parent > 1, Parent =/= Pid -> 1427 case tty_path_for_pid(Parent) of 1428 {ok, _} = Ok -> 1429 Ok; 1430 error -> 1431 walk_parent_tty(Parent, N - 1) 1432 end; 1433 _ -> 1434 error 1435 end; 1436walk_parent_tty(_, _) -> 1437 error. 1438 1439tty_path_for_pid(Pid) when is_integer(Pid) -> 1440 case read_tty_nr(Pid) of 1441 {ok, 0} -> 1442 error; 1443 {ok, TtyNr} -> 1444 tty_nr_to_path(TtyNr); 1445 error -> 1446 error 1447 end. 1448 1449read_tty_nr(Pid) when is_integer(Pid) -> 1450 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 1451 {ok, Bin} -> 1452 case parse_stat_tty_nr(binary_to_list(Bin)) of 1453 {ok, N} -> {ok, N}; 1454 error -> error 1455 end; 1456 _ -> 1457 error 1458 end. 1459%% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..." 1460parse_stat_tty_nr(List) -> 1461 case lists:splitwith(fun(C) -> C =/= $) end, List) of 1462 {_, [$) | Rest0]} -> 1463 Rest = string:trim(Rest0, leading), 1464 Fields = string:tokens(Rest, " "), 1465 %% After ')': state, ppid, pgrp, session, tty_nr → index 5 1466 case length(Fields) >= 5 of 1467 true -> 1468 try 1469 {ok, list_to_integer(lists:nth(5, Fields))} 1470 catch 1471 _:_ -> error 1472 end; 1473 false -> 1474 error 1475 end; 1476 _ -> 1477 error 1478 end. 1479 1480parent_pid(Pid) -> 1481 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 1482 {ok, Bin} -> 1483 case parse_stat_ppid(binary_to_list(Bin)) of 1484 {ok, P} -> {ok, P}; 1485 error -> error 1486 end; 1487 _ -> 1488 error 1489 end. 1490 1491parse_stat_ppid(List) -> 1492 case lists:splitwith(fun(C) -> C =/= $) end, List) of 1493 {_, [$) | Rest0]} -> 1494 Rest = string:trim(Rest0, leading), 1495 Fields = string:tokens(Rest, " "), 1496 %% After ')': state, ppid → index 2 1497 case length(Fields) >= 2 of 1498 true -> 1499 try 1500 {ok, list_to_integer(lists:nth(2, Fields))} 1501 catch 1502 _:_ -> error 1503 end; 1504 false -> 1505 error 1506 end; 1507 _ -> 1508 error 1509 end. 1510 1511%% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path. 1512tty_nr_to_path(0) -> 1513 error; 1514tty_nr_to_path(TtyNr) when is_integer(TtyNr) -> 1515 Major = (TtyNr bsr 8) band 16#ff, 1516 Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8), 1517 Path = 1518 case Major of 1519 136 -> 1520 "/dev/pts/" ++ integer_to_list(Minor); 1521 4 when Minor >= 64 -> 1522 "/dev/ttyS" ++ integer_to_list(Minor - 64); 1523 4 -> 1524 "/dev/tty" ++ integer_to_list(Minor); 1525 _ -> 1526 undefined 1527 end, 1528 case Path of 1529 undefined -> 1530 error; 1531 _ -> 1532 case file:read_file_info(Path) of 1533 {ok, _} -> {ok, Path}; 1534 _ -> error 1535 end 1536 end. 1537 1538reason_to_bin(Reason) when is_atom(Reason) -> 1539 atom_to_binary(Reason, utf8); 1540reason_to_bin(Reason) when is_binary(Reason) -> 1541 Reason; 1542reason_to_bin(Reason) -> 1543 iolist_to_binary(io_lib:format("~p", [Reason])).