diff --git a/README.md b/README.md index 93db1c3..79f254f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,11 @@ echo $env.MY_VAR which ls which -a ls which -f sh + +# processes (Nushell-style table) +ps +ps | sort-by mem | last 5 +ps --long | where name == beam.smp ``` ## Language sketch diff --git a/src/gleshell/builtins.gleam b/src/gleshell/builtins.gleam index 0a5eb0a..ba0b520 100644 --- a/src/gleshell/builtins.gleam +++ b/src/gleshell/builtins.gleam @@ -88,6 +88,7 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("values", cmd_values), #("keys", cmd_keys), #("sys", cmd_sys), + #("ps", cmd_ps), #("about", cmd_about), #("less", cmd_less), ]) @@ -275,6 +276,28 @@ fn help_for(name: String) -> Result(String, Nil) { ], "\n", )) + "ps" -> + Ok(string.join( + [ + "ps [-l|--long] — view system processes as a table", + "", + "Inspired by Nushell `ps`. Default columns: pid, ppid, name, status,", + "cpu, mem, virtual. With --long, also: command, start_time, user_id,", + "process_group_id, session_id, priority, process_threads, working,", + "paged, cwd.", + "", + "Flags:", + " -l, --long include all available columns", + "", + "Examples:", + " ps", + " ps | sort-by mem | last 5", + " ps | sort-by cpu | last 3", + " ps --long | where name == beam.smp", + " ps | where pid == 1 | get name", + ], + "\n", + )) _ -> help_line(name) } } @@ -364,6 +387,10 @@ fn help_text() -> dict.Dict(String, String) { #("values", "values — list of values from a record"), #("keys", "keys — list of keys from a record"), #("sys", "sys — host info record (cwd, home, shell, last_exit)"), + #( + "ps", + "ps [-l|--long] — system processes table (pid, name, cpu, mem, …)", + ), #("about", "about — authorship, ATProto handle, and a little sparkle"), #( "less", @@ -2328,6 +2355,50 @@ fn cmd_sys( ) } +// --- ps (Nushell-style process table) --- + +fn cmd_ps( + env: Env, + _input: Value, + _args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + let #(long, _) = find_bool_flag(flags, ["l", "long"]) + let records = + list.map(sys.list_processes(), fn(p) { process_to_record(p, long) }) + ok(env, value.table_from_records(records)) +} + +fn process_to_record(p: sys.ProcessInfo, long: Bool) -> Value { + let base = [ + #("pid", Int(p.pid)), + #("ppid", Int(p.ppid)), + #("name", String(p.name)), + #("status", String(p.status)), + #("cpu", Float(p.cpu)), + #("mem", Int(p.mem)), + #("virtual", Int(p.virtual)), + ] + case long { + False -> Record(base) + True -> + Record( + list.append(base, [ + #("command", String(p.command)), + #("start_time", Int(p.start_time)), + #("user_id", Int(p.user_id)), + #("process_group_id", Int(p.process_group_id)), + #("session_id", Int(p.session_id)), + #("priority", Int(p.priority)), + #("process_threads", Int(p.process_threads)), + #("working", Int(p.working)), + #("paged", Int(p.paged)), + #("cwd", String(p.cwd)), + ]), + ) + } +} + // --- less (color-aware pager) --- fn cmd_less( diff --git a/src/gleshell/display.gleam b/src/gleshell/display.gleam index 9d6c3d0..710588b 100644 --- a/src/gleshell/display.gleam +++ b/src/gleshell/display.gleam @@ -261,7 +261,12 @@ fn list_index_of_loop( fn cell_plain(col: String, value: Value) -> String { case col, value { "size", Int(n) -> format_filesize(n) + "mem", Int(n) -> format_filesize(n) + "virtual", Int(n) -> format_filesize(n) + "working", Int(n) -> format_filesize(n) + "paged", Int(n) -> format_filesize(n) "modified", Int(n) -> format_datetime(n) + "start_time", Int(n) -> format_datetime(n) _, _ -> value.cell_string(value) } } @@ -322,8 +327,9 @@ fn color_cell_for_column( case col { "name" -> color_path_name(on, plain, type_hint) "type" -> color_entry_type(on, plain) - "size" -> color.filesize(on, plain) - "modified" -> color.datetime(on, plain) + "size" | "mem" | "virtual" | "working" | "paged" -> + color.filesize(on, plain) + "modified" | "start_time" -> color.datetime(on, plain) _ -> color_by_value(on, value, plain) } } diff --git a/src/gleshell/sys.gleam b/src/gleshell/sys.gleam index 42138fe..ad23ce2 100644 --- a/src/gleshell/sys.gleam +++ b/src/gleshell/sys.gleam @@ -128,3 +128,32 @@ pub fn stdout_isatty() -> Bool /// Format Unix epoch seconds as local `Jul 3 2026 9:39:40 PM` (12-hour). @external(erlang, "gleshell_ffi", "format_unix_local") pub fn format_unix_local(seconds: Int) -> String + +/// One process row from `list_processes` (Nushell `ps` columns). +/// Memory fields are bytes; `start_time` is Unix epoch seconds (0 if unknown). +pub type ProcessInfo { + ProcessInfo( + pid: Int, + ppid: Int, + name: String, + status: String, + cpu: Float, + mem: Int, + virtual: Int, + command: String, + start_time: Int, + user_id: Int, + process_group_id: Int, + session_id: Int, + priority: Int, + process_threads: Int, + working: Int, + paged: Int, + cwd: String, + ) +} + +/// System processes (Linux `/proc`; empty list on unsupported OS). +/// Samples CPU over ~100ms like Nushell `ps`. +@external(erlang, "gleshell_ffi", "list_processes") +pub fn list_processes() -> List(ProcessInfo) diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index 4dffc5d..eb97716 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -29,7 +29,8 @@ history_hint/2, history_search/2, re_contains/3, - format_unix_local/1 + format_unix_local/1, + list_processes/0 ]). -define(ESC, 16#1b). @@ -745,8 +746,8 @@ fallback_builtin_names() -> "about", "append", "cat", "cd", "columns", "count", "describe", "echo", "env", "exit", "filter", "find", "first", "flatten", "from", "get", "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", - "length", "less", "lines", "ls", "open", "prepend", "print", "pwd", - "quit", "range", "reverse", "save", "select", "skip", "sort-by", + "length", "less", "lines", "ls", "open", "prepend", "print", "ps", + "pwd", "quit", "range", "reverse", "save", "select", "skip", "sort-by", "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", "unwrap", "values", "where", "which", "wrap" ]. @@ -3231,3 +3232,382 @@ month_abbr(10) -> "Oct"; month_abbr(11) -> "Nov"; month_abbr(12) -> "Dec"; month_abbr(_) -> "???". + +%% --------------------------------------------------------------------------- +%% ps: list system processes (Nushell-compatible columns, Linux /proc) +%% --------------------------------------------------------------------------- +%% +%% Returns a list of 17-tuples, one per process (numeric /proc entries that +%% can still be read after a short CPU sample interval): +%% +%% {Pid, Ppid, Name, Status, Cpu, Mem, Virtual, Command, StartTime, +%% UserId, ProcessGroupId, SessionId, Priority, ProcessThreads, +%% Working, Paged, Cwd} +%% +%% Integers are bytes for mem/virtual/working/paged; StartTime is Unix +%% seconds (0 if unknown). Cpu is percent of one core over ~100ms, like Nu. +%% +-spec list_processes() -> + list({ + integer(), + integer(), + binary(), + binary(), + float(), + integer(), + integer(), + binary(), + integer(), + integer(), + integer(), + integer(), + integer(), + integer(), + integer(), + integer(), + binary() + }). +list_processes() -> + case os:type() of + {unix, linux} -> + list_processes_linux(); + _ -> + %% Other OSes: empty table rather than crash; caller still gets + %% a valid table shape from the Gleam side when needed. + [] + end. + +list_processes_linux() -> + Ticks = clk_tck(), + PageSize = page_size(), + Boot = boot_time_seconds(), + Base = snapshot_cpu_times(), + timer:sleep(100), + IntervalMs = 100.0, + case file:list_dir("/proc") of + {ok, Entries} -> + lists:filtermap( + fun(Entry) -> + case is_pid_name(Entry) of + false -> + false; + true -> + Pid = list_to_integer(Entry), + case read_process(Pid, Ticks, PageSize, Boot, Base, IntervalMs) of + {ok, Row} -> + {true, Row}; + error -> + false + end + end + end, + Entries + ); + {error, _} -> + [] + end. + +is_pid_name([]) -> + false; +is_pid_name(Name) -> + lists:all(fun(C) -> C >= $0 andalso C =< $9 end, Name). + +%% Map pid -> total jiffies (utime+stime) from first snapshot. +snapshot_cpu_times() -> + case file:list_dir("/proc") of + {ok, Entries} -> + lists:foldl( + fun(Entry, Acc) -> + case is_pid_name(Entry) of + false -> + Acc; + true -> + Pid = list_to_integer(Entry), + case read_stat_cpu(Pid) of + {ok, Total} -> + Acc#{Pid => Total}; + error -> + Acc + end + end + end, + #{}, + Entries + ); + {error, _} -> + #{} + end. + +read_stat_cpu(Pid) -> + case read_stat_fields(Pid) of + {ok, Fields} -> + U = maps:get(utime, Fields, 0), + S = maps:get(stime, Fields, 0), + {ok, U + S}; + error -> + error + end. + +read_process(Pid, Ticks, PageSize, Boot, Base, IntervalMs) -> + case read_stat_fields(Pid) of + {ok, Fields} -> + U = maps:get(utime, Fields, 0), + S = maps:get(stime, Fields, 0), + Total = U + S, + Prev = maps:get(Pid, Base, Total), + Delta = max(0, Total - Prev), + %% usage_ms = delta_jiffies * 1000 / ticks; percent of one core + UsageMs = + case Ticks > 0 of + true -> + Delta * 1000 / Ticks; + false -> + 0.0 + end, + Cpu = + case IntervalMs > 0.0 of + true -> + UsageMs * 100.0 / IntervalMs; + false -> + 0.0 + end, + Status = status_name(maps:get(state, Fields, $?)), + Name = maps:get(comm, Fields, <<>>), + Ppid = maps:get(ppid, Fields, 0), + Pgrp = maps:get(pgrp, Fields, 0), + Session = maps:get(session, Fields, 0), + Priority = maps:get(priority, Fields, 0), + Threads = maps:get(num_threads, Fields, 0), + Vsize = maps:get(vsize, Fields, 0), + RssPages = maps:get(rss, Fields, 0), + MemFromStat = RssPages * PageSize, + StartJiffies = maps:get(starttime, Fields, 0), + StartTime = + case Boot > 0 andalso Ticks > 0 of + true -> + Boot + StartJiffies div Ticks; + false -> + 0 + end, + {Mem, Working, Paged, Virtual, UserId} = read_status_mem(Pid, MemFromStat, Vsize), + Command = read_cmdline(Pid, Name), + Cwd = read_cwd(Pid), + %% Gleam `ProcessInfo` constructor → Erlang `{process_info, ...}`. + {ok, + {process_info, Pid, Ppid, Name, Status, float(Cpu), Mem, Virtual, + Command, StartTime, UserId, Pgrp, Session, Priority, Threads, + Working, Paged, Cwd}}; + error -> + error + end. + +%% Parse /proc//stat. Comm is between the first '(' and the matching ") ". +read_stat_fields(Pid) -> + Path = "/proc/" ++ integer_to_list(Pid) ++ "/stat", + case file:read_file(Path) of + {ok, Bin0} -> + Bin = string:trim(Bin0, trailing, [$\n]), + case binary:split(Bin, <<"(">>) of + [_PidBin, Rest] -> + case binary:match(Rest, <<") ">>) of + {Pos, 2} -> + Comm = binary:part(Rest, 0, Pos), + After = binary:part(Rest, Pos + 2, byte_size(Rest) - Pos - 2), + Fs = binary:split(After, <<" ">>, [global]), + %% Indices after state (0-based): see proc(5) + %% 0 state, 1 ppid, 2 pgrp, 3 session, + %% 11 utime, 12 stime, 15 priority, 17 num_threads, + %% 19 starttime, 20 vsize, 21 rss + try + StateBin = nth_bin(Fs, 1), + State = + case StateBin of + <> -> + C; + <<>> -> + $?; + _ -> + $? + end, + {ok, #{ + comm => Comm, + state => State, + ppid => nth_int(Fs, 2), + pgrp => nth_int(Fs, 3), + session => nth_int(Fs, 4), + utime => nth_int(Fs, 12), + stime => nth_int(Fs, 13), + priority => nth_int(Fs, 16), + num_threads => nth_int(Fs, 18), + starttime => nth_int(Fs, 20), + vsize => nth_int(Fs, 21), + rss => nth_int(Fs, 22) + }} + catch + _:_ -> + error + end; + nomatch -> + error + end; + _ -> + error + end; + {error, _} -> + error + end. + +nth_bin(List, N) when N >= 1 -> + case length(List) >= N of + true -> + lists:nth(N, List); + false -> + <<>> + end. + +nth_int(List, N) -> + case nth_bin(List, N) of + <<>> -> + 0; + Bin -> + try + binary_to_integer(Bin) + catch + _:_ -> + 0 + end + end. + +status_name($S) -> <<"Sleeping">>; +status_name($R) -> <<"Running">>; +status_name($D) -> <<"Disk sleep">>; +status_name($Z) -> <<"Zombie">>; +status_name($T) -> <<"Stopped">>; +status_name($t) -> <<"Tracing">>; +status_name($X) -> <<"Dead">>; +status_name($x) -> <<"Dead">>; +status_name($K) -> <<"Wakekill">>; +status_name($W) -> <<"Waking">>; +status_name($P) -> <<"Parked">>; +status_name($I) -> <<"Idle">>; +status_name(_) -> <<"Unknown">>. + +%% Prefer VmRSS / VmSize / VmSwap (kB) and Uid from status; fall back to stat. +read_status_mem(Pid, MemFromStat, VsizeFromStat) -> + Path = "/proc/" ++ integer_to_list(Pid) ++ "/status", + case file:read_file(Path) of + {ok, Bin} -> + Lines = binary:split(Bin, <<"\n">>, [global]), + Mem = kb_field(Lines, <<"VmRSS:">>, MemFromStat), + Virtual = kb_field(Lines, <<"VmSize:">>, VsizeFromStat), + Paged = kb_field(Lines, <<"VmSwap:">>, 0), + UserId = uid_field(Lines), + {Mem, Mem, Paged, Virtual, UserId}; + {error, _} -> + {MemFromStat, MemFromStat, 0, VsizeFromStat, 0} + end. + +kb_field(Lines, Key, Default) -> + case find_status_line(Lines, Key) of + {ok, Rest} -> + %% " 1234 kB" + case re:run(Rest, <<"([0-9]+)">>, [{capture, all_but_first, binary}]) of + {match, [Num]} -> + binary_to_integer(Num) * 1024; + _ -> + Default + end; + error -> + Default + end. + +uid_field(Lines) -> + case find_status_line(Lines, <<"Uid:">>) of + {ok, Rest} -> + case re:run(Rest, <<"([0-9]+)">>, [{capture, all_but_first, binary}]) of + {match, [Num]} -> + binary_to_integer(Num); + _ -> + 0 + end; + error -> + 0 + end. + +find_status_line([], _Key) -> + error; +find_status_line([Line | Rest], Key) -> + Klen = byte_size(Key), + case Line of + <> -> + {ok, RestLine}; + _ -> + find_status_line(Rest, Key) + end. + +read_cmdline(Pid, FallbackName) -> + Path = "/proc/" ++ integer_to_list(Pid) ++ "/cmdline", + case file:read_file(Path) of + {ok, <<>>} -> + FallbackName; + {ok, Bin} -> + Parts = [P || P <- binary:split(Bin, <<0>>, [global]), P =/= <<>>], + case Parts of + [] -> + FallbackName; + _ -> + Joined = iolist_to_binary(lists:join(<<" ">>, Parts)), + %% Nu collapses newlines/tabs in the display command. + re:replace(Joined, <<"[\n\t]">>, <<" ">>, [global, {return, binary}]) + end; + {error, _} -> + FallbackName + end. + +read_cwd(Pid) -> + Path = "/proc/" ++ integer_to_list(Pid) ++ "/cwd", + case file:read_link(Path) of + {ok, Target} -> + unicode:characters_to_binary(Target); + {error, _} -> + <<>> + end. + +clk_tck() -> + case getconf_int("CLK_TCK") of + {ok, N} when N > 0 -> + N; + _ -> + 100 + end. + +page_size() -> + case getconf_int("PAGE_SIZE") of + {ok, N} when N > 0 -> + N; + _ -> + 4096 + end. + +getconf_int(Name) -> + Cmd = "getconf " ++ Name, + try + Out = string:trim(os:cmd(Cmd)), + {ok, list_to_integer(Out)} + catch + _:_ -> + error + end. + +boot_time_seconds() -> + case file:read_file("/proc/stat") of + {ok, Bin} -> + case re:run(Bin, <<"btime ([0-9]+)">>, [{capture, all_but_first, binary}]) of + {match, [Num]} -> + binary_to_integer(Num); + _ -> + 0 + end; + {error, _} -> + 0 + end. diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index 21b2178..db14aab 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -450,6 +450,43 @@ pub fn http_subcommand_errors_test() { Nil } +pub fn eval_ps_test() { + let env = env.new() + // Default columns match Nushell short `ps`. + let assert eval.Continue(_, Table(cols, rows)) = eval.eval_source(env, "ps") + let assert [ + "pid", + "ppid", + "name", + "status", + "cpu", + "mem", + "virtual", + ] = cols + let assert True = rows != [] + + // pid 1 exists on Linux + let assert eval.Continue(_, Table(_, pid1_rows)) = + eval.eval_source(env, "ps | where pid == 1") + let assert True = pid1_rows != [] + + // --long adds the extra Nu columns + let assert eval.Continue(_, Table(long_cols, _)) = + eval.eval_source(env, "ps --long") + let assert True = list.contains(long_cols, "command") + let assert True = list.contains(long_cols, "start_time") + let assert True = list.contains(long_cols, "cwd") + let assert True = list.length(long_cols) > list.length(cols) + + // which / help + let assert eval.Continue(_, String("builtin: ps")) = + eval.eval_source(env, "which ps") + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help ps") + let assert True = string.contains(help_out, "--long") + Nil +} + pub fn http_get_live_test() { // Live request against postman-echo (JSON). Skip gracefully if offline. let env = env.new()