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