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