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