A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

Add fuzzy Ctrl+R history search, which -f, and ls modified times.

Replace reverse-i-search with a stinkpot-style multi-match picker, add
which --follow via realpath, show local datetimes for ls modified, and
cover the new behavior with tests.

author
nandi
date (Jul 26, 2026, 2:27 AM -0700) commit 5117c3b6 parent ff5e6e5e change-id nonqmsww
+701 -104
+4 -3
README.md
··· 70 70 | **Tab** | Command completion (builtins + `PATH`) at the start of a pipeline stage; filename completion for arguments (common prefix; list matches if ambiguous) | 71 71 | **→ / Ctrl+F / End / Ctrl+E** (at end of line) | Accept greyed-out history suggestion (full) | 72 72 | **Alt+F** (at end of line) | Accept one word of the history suggestion | 73 - | **Ctrl+R** | Reverse-i-search through history (Enter accepts onto the line) | 73 + | **Ctrl+R** | Fuzzy history search (stinkpot-style list; ↑/↓ move, Enter/Tab accept onto the line, Esc cancel) | 74 74 | Ctrl+A / Ctrl+E | Beginning / end of line (Ctrl+E at end also accepts a history hint) | 75 75 | Ctrl+W | Delete previous word | 76 76 | Ctrl+U / Ctrl+K | Kill to start / end of line | ··· 117 117 ^uname -a 118 118 which ls 119 119 which -a ls 120 + which -f sh 120 121 ``` 121 122 122 123 ## Language sketch ··· 134 135 | Env | `$env`, `$env.HOME`, `$env.FOO = value` | 135 136 | Flags | `--flag` / `--flag value` | 136 137 | Force external | `^command args…` | 137 - | Comments | `# …` | 138 + | Comments | `# …` (word-boundary only; mid-token `#` is fine — `nixpkgs#pkg`) | 138 139 139 140 ## Built-ins 140 141 ··· 162 163 ```text 163 164 src/ 164 165 gleshell.gleam # entry + REPL 165 - gleshell_ffi.erl # line editor (Ctrl+R), cwd, process spawn 166 + gleshell_ffi.erl # line editor (Ctrl+R fuzzy history), cwd, process spawn 166 167 gleshell/ 167 168 value.gleam # structured Value type 168 169 lexer.gleam / parser.gleam
+65
devenv.lock
··· 1 + { 2 + "nodes": { 3 + "devenv": { 4 + "locked": { 5 + "dir": "src/modules", 6 + "lastModified": 1785001160, 7 + "narHash": "sha256-iyjX3d/q7lqYP9wkJR4k9xI2J01BJGGyfVamP0v2LXU=", 8 + "owner": "cachix", 9 + "repo": "devenv", 10 + "rev": "126fa142775d8a6d89c97d69297f7095b6585e4e", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "dir": "src/modules", 15 + "owner": "cachix", 16 + "repo": "devenv", 17 + "type": "github" 18 + } 19 + }, 20 + "nixpkgs": { 21 + "inputs": { 22 + "nixpkgs-src": "nixpkgs-src" 23 + }, 24 + "locked": { 25 + "lastModified": 1783345554, 26 + "narHash": "sha256-LZhOm4kqjHUnwP4CNHpaqqEVcumGNd1PvOEOad8LkS0=", 27 + "owner": "cachix", 28 + "repo": "devenv-nixpkgs", 29 + "rev": "6004ea8c229fe9d41b21c6f4c76bf6c2e10771dd", 30 + "type": "github" 31 + }, 32 + "original": { 33 + "owner": "cachix", 34 + "ref": "rolling", 35 + "repo": "devenv-nixpkgs", 36 + "type": "github" 37 + } 38 + }, 39 + "nixpkgs-src": { 40 + "flake": false, 41 + "locked": { 42 + "lastModified": 1783279667, 43 + "narHash": "sha256-/NAkDSsve+GNM0Bt6tleJdCGfsTlK89nPjkVOzZMo0s=", 44 + "owner": "NixOS", 45 + "repo": "nixpkgs", 46 + "rev": "f205b5574fd0cb7da5b702a2da51507b7f4fdd1b", 47 + "type": "github" 48 + }, 49 + "original": { 50 + "owner": "NixOS", 51 + "ref": "nixpkgs-unstable", 52 + "repo": "nixpkgs", 53 + "type": "github" 54 + } 55 + }, 56 + "root": { 57 + "inputs": { 58 + "devenv": "devenv", 59 + "nixpkgs": "nixpkgs" 60 + } 61 + } 62 + }, 63 + "root": "root", 64 + "version": 7 65 + }
+1 -1
src/gleshell.gleam
··· 65 65 66 66 fn repl(env: env.Env) -> Nil { 67 67 sys.println( 68 - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes, grey history hints, Ctrl+R search)", 68 + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes, grey history hints, Ctrl+R fuzzy history)", 69 69 ) 70 70 repl_loop(env) 71 71 }
+38 -21
src/gleshell/builtins.gleam
··· 284 284 #("help", "help [command] — list builtins, or show help for one command"), 285 285 #("echo", "echo <values>… — emit values (list if multiple)"), 286 286 #("print", "print <values>… — alias for echo"), 287 - #("ls", "ls [path] — list directory entries as a table"), 287 + #( 288 + "ls", 289 + "ls [path] — list directory entries as a table (name, type, size, modified)", 290 + ), 288 291 #("pwd", "pwd — print working directory"), 289 292 #("cd", "cd [path] — change directory (~ supported)"), 290 293 #( ··· 341 344 ), 342 345 #( 343 346 "which", 344 - "which [-a|--all] <name> — path of command (builtin or on PATH); -a lists all matches", 347 + "which [-a|--all] [-f|--follow] <name> — path of command (builtin or on PATH); -a all matches, -f follow symlinks", 345 348 ), 346 349 #("exit", "exit [code] — leave the shell (default code 0)"), 347 350 #("quit", "quit [code] — alias for exit"), ··· 467 470 #("name", String(name)), 468 471 #("type", String("unknown")), 469 472 #("size", Int(0)), 473 + #("modified", Int(0)), 470 474 ]) 471 475 Ok(info) -> { 472 476 let ftype = case simplifile.file_info_type(info) { ··· 479 483 #("name", String(name)), 480 484 #("type", String(ftype)), 481 485 #("size", Int(info.size)), 486 + #("modified", Int(info.mtime_seconds)), 482 487 ]) 483 488 } 484 489 } ··· 2061 2066 args: List(Value), 2062 2067 flags: dict.Dict(String, Value), 2063 2068 ) -> BuiltinResult { 2064 - // `which -a name` is parsed as FlagArg("a", Some("name")) because the 2065 - // generic flag parser attaches the next expression as a flag value. 2066 - // Accept both `which -a name` and `which name -a` / `which --all name`. 2067 - let #(all, name_opt) = case args { 2068 - [String(n)] -> #( 2069 - flag_set(flags, "a") || flag_set(flags, "all"), 2070 - option.Some(n), 2071 - ) 2072 - [] -> 2073 - case dict.get(flags, "a"), dict.get(flags, "all") { 2074 - Ok(String(n)), _ -> #(True, option.Some(n)) 2075 - _, Ok(String(n)) -> #(True, option.Some(n)) 2076 - Ok(Bool(True)), _ | _, Ok(Bool(True)) -> #(True, option.None) 2077 - _, _ -> #(False, option.None) 2078 - } 2079 - _ -> #(False, option.None) 2069 + // Boolean flags may steal the next word (`which -a name` → flag a = "name"). 2070 + // Accept `which -a|-f name`, `which name -a|-f`, and long forms. 2071 + let #(all, stolen_a) = find_bool_flag(flags, ["a", "all"]) 2072 + let #(follow, stolen_f) = find_bool_flag(flags, ["f", "follow"]) 2073 + let name_opt = case list.append(args, list.append(stolen_a, stolen_f)) { 2074 + [String(n)] -> option.Some(n) 2075 + [other] -> option.Some(value.as_string(other)) 2076 + _ -> option.None 2080 2077 } 2081 2078 case name_opt { 2082 2079 option.Some(name) -> { 2083 2080 let is_builtin = dict.has_key(registry(), name) 2084 2081 case all { 2085 2082 True -> { 2086 - let paths = list.map(sys.which_all(name), String) 2083 + let paths = 2084 + list.map(sys.which_all(name), fn(p) { 2085 + String(which_maybe_follow(follow, p)) 2086 + }) 2087 2087 let matches = case is_builtin { 2088 2088 True -> [String("builtin: " <> name), ..paths] 2089 2089 False -> paths ··· 2099 2099 True -> ok(env, String("builtin: " <> name)) 2100 2100 False -> 2101 2101 case sys.which(name) { 2102 - Ok(path) -> ok(env, String(path)) 2102 + Ok(path) -> ok(env, String(which_maybe_follow(follow, path))) 2103 2103 Error(Nil) -> err(env, "which: " <> name <> " not found") 2104 2104 } 2105 2105 } 2106 2106 } 2107 2107 } 2108 - option.None -> err(env, "which: expected name (try `which [-a] <name>`)") 2108 + option.None -> 2109 + err( 2110 + env, 2111 + "which: expected name (try `which [-a] [-f] <name>`)", 2112 + ) 2113 + } 2114 + } 2115 + 2116 + /// With `-f`/`--follow`, resolve symlinks to a canonical absolute path. 2117 + /// On failure (broken link, loop), keep the original which path. 2118 + fn which_maybe_follow(follow: Bool, path: String) -> String { 2119 + case follow { 2120 + False -> path 2121 + True -> 2122 + case sys.realpath(path) { 2123 + Ok(resolved) -> resolved 2124 + Error(Nil) -> path 2125 + } 2109 2126 } 2110 2127 } 2111 2128
+5
src/gleshell/color.gleam
··· 143 143 paint(on, cyan, text) 144 144 } 145 145 146 + /// Datetimes / modified timestamps (dim — secondary metadata). 147 + pub fn datetime(on: Bool, text: String) -> String { 148 + paint(on, dark_gray, text) 149 + } 150 + 146 151 pub fn dir_name(on: Bool, text: String) -> String { 147 152 paint(on, bright_blue, text) 148 153 }
+10 -1
src/gleshell/display.gleam
··· 4 4 import gleam/list 5 5 import gleam/string 6 6 import gleshell/color 7 + import gleshell/sys 7 8 import gleshell/value.{ 8 9 type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, 9 10 } ··· 255 256 } 256 257 257 258 /// Plain cell text before coloring. Keeps pipeline data as raw ints; only 258 - /// display converts `size` byte counts to KB/MB/…. 259 + /// display converts `size` byte counts to KB/MB/… and `modified` epoch 260 + /// seconds to a local datetime. 259 261 fn cell_plain(col: String, value: Value) -> String { 260 262 case col, value { 261 263 "size", Int(n) -> format_filesize(n) 264 + "modified", Int(n) -> format_datetime(n) 262 265 _, _ -> value.cell_string(value) 263 266 } 267 + } 268 + 269 + /// Format Unix epoch seconds as local `Jul 3 2026 9:39:40 PM` (12-hour). 270 + pub fn format_datetime(unix_seconds: Int) -> String { 271 + sys.format_unix_local(unix_seconds) 264 272 } 265 273 266 274 /// Format a byte count for display: B below 1 KiB, then KB / MB / GB / TB ··· 315 323 "name" -> color_path_name(on, plain, type_hint) 316 324 "type" -> color_entry_type(on, plain) 317 325 "size" -> color.filesize(on, plain) 326 + "modified" -> color.datetime(on, plain) 318 327 _ -> color_by_value(on, value, plain) 319 328 } 320 329 }
+2 -1
src/gleshell/highlight.gleam
··· 463 463 464 464 fn is_ident_continue(c: String) -> Bool { 465 465 // Path-ish chars covered by is_ident_start (`.` `/` `~`); keep `-` mid-token. 466 - is_ident_start(c) || is_digit(c) || c == "-" 466 + // `#` mid-token for flake refs (must match lexer); bare `#` is still a comment. 467 + is_ident_start(c) || is_digit(c) || c == "-" || c == "#" 467 468 }
+3 -1
src/gleshell/lexer.gleam
··· 284 284 285 285 fn is_ident_continue(c: String) -> Bool { 286 286 // Path-ish chars: letters/digits already covered; keep `.` `/` `-` `~` mid-token. 287 - is_ident_start(c) || is_digit(c) || c == "-" 287 + // `#` mid-token for flake refs (`nixpkgs#hello`, `.#package`); bare `#` still 288 + // starts a comment at a word boundary (handled in do_tokenize). 289 + is_ident_start(c) || is_digit(c) || c == "-" || c == "#" 288 290 }
+16
src/gleshell/sys.gleam
··· 88 88 @external(erlang, "gleshell_ffi", "which_all") 89 89 pub fn which_all(command: String) -> List(String) 90 90 91 + /// Canonical absolute path: resolve `.`/`..` and follow all symlinks. 92 + /// Error if the path does not exist or the link chain loops / is broken. 93 + @external(erlang, "gleshell_ffi", "realpath") 94 + pub fn realpath(path: String) -> Result(String, Nil) 95 + 91 96 /// True if `text` matches Erlang regex `pattern`. `ignore_case` enables caseless. 92 97 /// Error string on invalid pattern. 93 98 @external(erlang, "gleshell_ffi", "re_contains") ··· 107 112 @external(erlang, "gleshell_ffi", "history_hint") 108 113 pub fn history_hint(history: List(String), buffer: String) -> String 109 114 115 + /// Fuzzy history filter for Ctrl+R reverse search (stinkpot-style). 116 + /// `history` is newest-first; empty/whitespace `query` returns all unique 117 + /// entries in that order. Non-empty query returns subsequence fuzzy matches, 118 + /// best scores first (ties keep newest-first order). 119 + @external(erlang, "gleshell_ffi", "history_search") 120 + pub fn history_search(history: List(String), query: String) -> List(String) 121 + 110 122 @external(erlang, "gleshell_ffi", "home_dir") 111 123 pub fn home_dir() -> Result(String, String) 112 124 113 125 @external(erlang, "gleshell_ffi", "stdout_isatty") 114 126 pub fn stdout_isatty() -> Bool 127 + 128 + /// Format Unix epoch seconds as local `Jul 3 2026 9:39:40 PM` (12-hour). 129 + @external(erlang, "gleshell_ffi", "format_unix_local") 130 + pub fn format_unix_local(seconds: Int) -> String
+401 -76
src/gleshell_ffi.erl
··· 15 15 run_cmd_tty/3, 16 16 which/1, 17 17 which_all/1, 18 + realpath/1, 18 19 home_dir/0, 19 20 stdout_isatty/0, 20 21 println/1, ··· 26 27 clear_output_shown/0, 27 28 complete_word/2, 28 29 history_hint/2, 29 - re_contains/3 30 + history_search/2, 31 + re_contains/3, 32 + format_unix_local/1 30 33 ]). 31 34 32 35 -define(ESC, 16#1b). 33 36 -define(CSI_CLEAR_EOL, "\e[K"). 34 37 -define(HISTORY_MAX, 2000). 38 + %% Max rows of history matches shown in the Ctrl+R picker (stinkpot-style). 39 + -define(SEARCH_MAX_ROWS, 12). 35 40 36 41 %% --------------------------------------------------------------------------- 37 42 %% Public: write a line (CRLF in raw TTY mode so multi-line output does not ··· 177 182 <<"ctrl_r">>; 178 183 key_to_name(ctrl_f) -> 179 184 <<"ctrl_f">>; 185 + key_to_name(ctrl_p) -> 186 + <<"ctrl_p">>; 187 + key_to_name(ctrl_n) -> 188 + <<"ctrl_n">>; 180 189 key_to_name(alt_f) -> 181 190 <<"alt_f">>; 182 191 key_to_name(ctrl_g) -> 183 192 <<"ctrl_g">>; 193 + key_to_name(esc) -> 194 + <<"esc">>; 184 195 key_to_name({char, 32}) -> 185 196 <<"space">>; 186 197 key_to_name({char, C}) when is_integer(C), C >= 32 -> ··· 587 598 redraw(Prompt, Left, Right), 588 599 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 589 600 ctrl_r -> 590 - reverse_search(Prompt, History); 601 + reverse_search(Prompt, Left, Right, History); 591 602 tab -> 592 603 tab_complete(Prompt, Left, Right, History, HistPos, Saved); 593 604 _Other -> ··· 1001 1012 hist_seek_loop(_History, _Pos, _Step, _Len) -> 1002 1013 stay. 1003 1014 1004 - %% Minimal Ctrl+R reverse-i-search over history. 1005 - reverse_search(Prompt, History) -> 1006 - reverse_search_loop(Prompt, History, [], match_history(History, [])). 1015 + %% --------------------------------------------------------------------------- 1016 + %% Ctrl+R reverse history search — stinkpot-style multi-match TUI 1017 + %% (https://tangled.org/oppi.li/stinkpot) 1018 + %% 1019 + %% Fuzzy filter over newest-first history, list up to 12 rows, ↑/↓ move, 1020 + %% Enter/Tab accept onto the line (does not execute), Esc/Ctrl+C/Ctrl+G cancel. 1021 + %% Runs on the alternate screen so the REPL is restored cleanly. 1022 + %% --------------------------------------------------------------------------- 1007 1023 1008 - reverse_search_loop(Prompt, History, Query, Match) -> 1009 - Hint = unicode:characters_to_list( 1010 - ["(reverse-i-search)`", Query, "': ", Match] 1011 - ), 1012 - io:put_chars([$\r, Hint, ?CSI_CLEAR_EOL]), 1013 - case read_key() of 1024 + reverse_search(Prompt, Left, Right, History) -> 1025 + %% Seed query from the current buffer (like stinkpot's READLINE_LINE). 1026 + QueryChars = 1027 + case unicode:characters_to_list(lists:reverse(Left) ++ Right) of 1028 + L when is_list(L) -> L; 1029 + _ -> [] 1030 + end, 1031 + Candidates = uniq_history(History), 1032 + Filtered = history_search_filter(Candidates, QueryChars), 1033 + enter_alt_screen(), 1034 + Result = reverse_search_loop(Candidates, QueryChars, Filtered, 0), 1035 + leave_alt_screen(), 1036 + put(gleshell_input_rows, 1), 1037 + case Result of 1038 + {ok, Selected} when is_binary(Selected) -> 1039 + reverse_search_accept(Prompt, History, Selected); 1040 + cancel -> 1041 + redraw(Prompt, Left, Right), 1042 + raw_loop(Prompt, Left, Right, History, 0, <<>>); 1014 1043 eof -> 1015 1044 io:put_chars("\r\n"), 1016 1045 {error, <<"eof">>}; 1017 - enter -> 1018 - %% Accept match onto the edit line; do not submit (edit first). 1019 - %% Empty match (no query / no hit) — return to an empty draft 1020 - %% rather than "accepting" a blank line that later looks like a 1021 - %% phantom history entry when browsing with ↑. 1022 - Line = iolist_to_binary(Match), 1023 - case history_blank(Line) of 1024 - true -> 1025 - redraw(Prompt, [], []), 1026 - raw_loop(Prompt, [], [], History, 0, <<>>); 1027 - false -> 1028 - {L, R} = bin_to_buffer(Line), 1029 - redraw(Prompt, L, R), 1030 - raw_loop(Prompt, L, R, History, 0, <<>>) 1031 - end; 1032 - ctrl_c -> 1033 - io:put_chars("\r\n"), 1046 + _ -> 1047 + redraw(Prompt, Left, Right), 1048 + raw_loop(Prompt, Left, Right, History, 0, <<>>) 1049 + end. 1050 + 1051 + reverse_search_accept(Prompt, History, Selected) -> 1052 + case history_blank(Selected) of 1053 + true -> 1034 1054 redraw(Prompt, [], []), 1035 1055 raw_loop(Prompt, [], [], History, 0, <<>>); 1056 + false -> 1057 + {L, R} = bin_to_buffer(Selected), 1058 + redraw(Prompt, L, R), 1059 + raw_loop(Prompt, L, R, History, 0, <<>>) 1060 + end. 1061 + 1062 + reverse_search_loop(All, Query, Filtered, Cursor0) -> 1063 + Cursor = clamp_cursor(Cursor0, length(Filtered)), 1064 + draw_search_ui(Query, Filtered, Cursor), 1065 + case read_key() of 1066 + eof -> 1067 + eof; 1068 + {error, _} -> 1069 + eof; 1070 + enter -> 1071 + pick_filtered(Filtered, Cursor); 1072 + tab -> 1073 + pick_filtered(Filtered, Cursor); 1074 + esc -> 1075 + cancel; 1076 + ctrl_c -> 1077 + cancel; 1036 1078 ctrl_g -> 1037 - redraw(Prompt, [], []), 1038 - raw_loop(Prompt, [], [], History, 0, <<>>); 1039 - ctrl_r -> 1040 - %% Find older match 1041 - NewMatch = match_history_after(History, Query, Match), 1042 - reverse_search_loop(Prompt, History, Query, NewMatch); 1043 - backspace -> 1044 - NewQuery = case Query of 1045 - [] -> []; 1046 - [_ | _] -> lists:droplast(Query) 1047 - end, 1079 + cancel; 1080 + up -> 1081 + reverse_search_loop(All, Query, Filtered, max(0, Cursor - 1)); 1082 + ctrl_p -> 1083 + reverse_search_loop(All, Query, Filtered, max(0, Cursor - 1)); 1084 + down -> 1085 + reverse_search_loop( 1086 + All, Query, Filtered, min(length(Filtered) - 1, Cursor + 1) 1087 + ); 1088 + ctrl_n -> 1048 1089 reverse_search_loop( 1049 - Prompt, History, NewQuery, match_history(History, NewQuery) 1090 + All, Query, Filtered, min(length(Filtered) - 1, Cursor + 1) 1050 1091 ); 1092 + backspace -> 1093 + NewQuery = 1094 + case Query of 1095 + [] -> []; 1096 + [_ | _] -> lists:droplast(Query) 1097 + end, 1098 + NewFiltered = history_search_filter(All, NewQuery), 1099 + reverse_search_loop(All, NewQuery, NewFiltered, 0); 1100 + delete -> 1101 + reverse_search_loop(All, Query, Filtered, Cursor); 1051 1102 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 1052 1103 NewQuery = Query ++ [C], 1053 - reverse_search_loop( 1054 - Prompt, History, NewQuery, match_history(History, NewQuery) 1055 - ); 1104 + NewFiltered = history_search_filter(All, NewQuery), 1105 + reverse_search_loop(All, NewQuery, NewFiltered, 0); 1056 1106 _ -> 1057 - reverse_search_loop(Prompt, History, Query, Match) 1107 + reverse_search_loop(All, Query, Filtered, Cursor) 1058 1108 end. 1059 1109 1060 - match_history(_History, []) -> 1061 - ""; 1062 - match_history(History, Query) -> 1063 - QBin = unicode:characters_to_binary(Query), 1064 - case first_match(History, QBin) of 1065 - undefined -> ""; 1066 - Bin -> unicode:characters_to_list(Bin) 1110 + pick_filtered([], _Cursor) -> 1111 + cancel; 1112 + pick_filtered(Filtered, Cursor) when Cursor >= 0, Cursor < length(Filtered) -> 1113 + Cmd = lists:nth(Cursor + 1, Filtered), 1114 + Bin = 1115 + case Cmd of 1116 + B when is_binary(B) -> B; 1117 + L when is_list(L) -> unicode:characters_to_binary(L); 1118 + _ -> <<>> 1119 + end, 1120 + {ok, Bin}; 1121 + pick_filtered(_, _) -> 1122 + cancel. 1123 + 1124 + clamp_cursor(_C, 0) -> 1125 + 0; 1126 + clamp_cursor(C, _Len) when C < 0 -> 1127 + 0; 1128 + clamp_cursor(C, Len) when C >= Len -> 1129 + Len - 1; 1130 + clamp_cursor(C, _Len) -> 1131 + C. 1132 + 1133 + enter_alt_screen() -> 1134 + io:put_chars("\e[?1049h\e[H\e[2J"). 1135 + 1136 + leave_alt_screen() -> 1137 + io:put_chars("\e[?1049l"). 1138 + 1139 + draw_search_ui(Query, Filtered, Cursor) -> 1140 + %% Full repaint from home — alternate screen, so wipe + rewrite is fine. 1141 + io:put_chars("\e[H\e[J"), 1142 + QueryLine = search_query_line(Query), 1143 + io:put_chars([QueryLine, "\r\n"]), 1144 + Len = length(Filtered), 1145 + Start = 1146 + case Cursor >= ?SEARCH_MAX_ROWS of 1147 + true -> Cursor - ?SEARCH_MAX_ROWS + 1; 1148 + false -> 0 1149 + end, 1150 + End = min(Start + ?SEARCH_MAX_ROWS, Len), 1151 + draw_search_rows(Filtered, Start, End, Cursor), 1152 + Footer = search_footer(Len), 1153 + io:put_chars(Footer), 1154 + %% Park the cursor at the end of the query line. 1155 + Col = 2 + length(Query), 1156 + io:put_chars(["\e[H", "\e[", integer_to_list(Col + 1), $G]). 1157 + 1158 + search_query_line([]) -> 1159 + case get(gleshell_color) of 1160 + false -> 1161 + "> search history..."; 1162 + _ -> 1163 + ["> ", "\e[2m", "search history...", "\e[0m"] 1164 + end; 1165 + search_query_line(Query) when is_list(Query) -> 1166 + ["> ", Query]. 1167 + 1168 + draw_search_rows(_Filtered, Start, End, _Cursor) when Start >= End -> 1169 + ok; 1170 + draw_search_rows(Filtered, Start, End, Cursor) -> 1171 + draw_search_rows_loop(Filtered, Start, End, Cursor). 1172 + 1173 + draw_search_rows_loop(_Filtered, I, End, _Cursor) when I >= End -> 1174 + ok; 1175 + draw_search_rows_loop(Filtered, I, End, Cursor) -> 1176 + Cmd0 = lists:nth(I + 1, Filtered), 1177 + CmdList = 1178 + case Cmd0 of 1179 + Bin when is_binary(Bin) -> 1180 + case unicode:characters_to_list(Bin) of 1181 + L when is_list(L) -> L; 1182 + _ -> [] 1183 + end; 1184 + L when is_list(L) -> L; 1185 + _ -> [] 1186 + end, 1187 + %% Flatten newlines so a multi-line history entry stays one row. 1188 + Line = [case C of $\n -> $\s; $\r -> $\s; _ -> C end || C <- CmdList], 1189 + case I =:= Cursor of 1190 + true -> 1191 + case get(gleshell_color) of 1192 + false -> 1193 + io:put_chars([" ", Line, "\r\n"]); 1194 + _ -> 1195 + %% Bold blue — stinkpot styleCursor (lipgloss color "4"). 1196 + io:put_chars([" ", "\e[1;34m", Line, "\e[0m", "\r\n"]) 1197 + end; 1198 + false -> 1199 + io:put_chars([" ", Line, "\r\n"]) 1200 + end, 1201 + draw_search_rows_loop(Filtered, I + 1, End, Cursor). 1202 + 1203 + search_footer(N) -> 1204 + %% Unicode arrows match stinkpot's footer copy. 1205 + Text = [ 1206 + " ", 1207 + integer_to_list(N), 1208 + " matches · ↑/↓ move · enter accept · esc cancel" 1209 + ], 1210 + case get(gleshell_color) of 1211 + false -> 1212 + Text; 1213 + _ -> 1214 + ["\e[2m", Text, "\e[0m"] 1067 1215 end. 1068 1216 1069 - match_history_after(History, Query, CurrentMatch) -> 1070 - QBin = unicode:characters_to_binary(Query), 1071 - CurBin = unicode:characters_to_binary(CurrentMatch), 1072 - case skip_until_then_match(History, CurBin, QBin, false) of 1073 - undefined -> CurrentMatch; 1074 - Bin -> unicode:characters_to_list(Bin) 1217 + %% Dedupe history preserving newest-first order (stinkpot stores unique cmds). 1218 + uniq_history(Hist) when is_list(Hist) -> 1219 + uniq_history(Hist, #{}, []); 1220 + uniq_history(_) -> 1221 + []. 1222 + 1223 + uniq_history([], _Seen, Acc) -> 1224 + lists:reverse(Acc); 1225 + uniq_history([H | T], Seen, Acc) -> 1226 + Key = 1227 + case H of 1228 + B when is_binary(B) -> B; 1229 + L when is_list(L) -> unicode:characters_to_binary(L); 1230 + _ -> <<>> 1231 + end, 1232 + case history_blank(Key) orelse maps:is_key(Key, Seen) of 1233 + true -> 1234 + uniq_history(T, Seen, Acc); 1235 + false -> 1236 + uniq_history(T, Seen#{Key => true}, [Key | Acc]) 1075 1237 end. 1076 1238 1077 - first_match([], _) -> 1078 - undefined; 1079 - first_match([H | T], Q) -> 1080 - case binary:match(H, Q) of 1081 - nomatch -> first_match(T, Q); 1082 - _ -> H 1083 - end. 1239 + %% Test helper / filter: history (newest first binaries) + query → filtered list. 1240 + -spec history_search(list(binary()), binary()) -> list(binary()). 1241 + history_search(History, Query) when is_list(History), is_binary(Query) -> 1242 + QList = 1243 + case unicode:characters_to_list(Query) of 1244 + L when is_list(L) -> L; 1245 + _ -> [] 1246 + end, 1247 + history_search_filter(uniq_history(History), QList); 1248 + history_search(_, _) -> 1249 + []. 1084 1250 1085 - skip_until_then_match([], _Cur, _Q, _Seen) -> 1086 - undefined; 1087 - skip_until_then_match([H | T], Cur, Q, false) -> 1088 - case H =:= Cur of 1089 - true -> skip_until_then_match(T, Cur, Q, true); 1090 - false -> skip_until_then_match(T, Cur, Q, false) 1251 + %% Empty / whitespace-only query → all candidates (newest first). 1252 + %% Otherwise fuzzy subsequence match (case-insensitive), best scores first; 1253 + %% ties keep newest-first order via stable index. 1254 + history_search_filter(Candidates, Query) when is_list(Candidates), is_list(Query) -> 1255 + case string:trim(Query) of 1256 + [] -> 1257 + Candidates; 1258 + QTrim -> 1259 + QLower = to_lower_chars(QTrim), 1260 + Scored = score_candidates(Candidates, QLower, 0, []), 1261 + Sorted = lists:sort( 1262 + fun({Sa, Ia, _}, {Sb, Ib, _}) -> 1263 + case Sa =:= Sb of 1264 + true -> Ia =< Ib; 1265 + false -> Sa > Sb 1266 + end 1267 + end, 1268 + Scored 1269 + ), 1270 + [Cmd || {_S, _I, Cmd} <- Sorted] 1091 1271 end; 1092 - skip_until_then_match([H | T], Cur, Q, true) -> 1093 - case binary:match(H, Q) of 1094 - nomatch -> skip_until_then_match(T, Cur, Q, true); 1095 - _ -> H 1272 + history_search_filter(_, _) -> 1273 + []. 1274 + 1275 + score_candidates([], _Q, _I, Acc) -> 1276 + Acc; 1277 + score_candidates([Cmd | Rest], Q, I, Acc) -> 1278 + CmdList = 1279 + case Cmd of 1280 + Bin when is_binary(Bin) -> 1281 + case unicode:characters_to_list(Bin) of 1282 + L when is_list(L) -> L; 1283 + _ -> [] 1284 + end; 1285 + L when is_list(L) -> L; 1286 + _ -> [] 1287 + end, 1288 + case fuzzy_score(Q, to_lower_chars(CmdList)) of 1289 + nomatch -> 1290 + score_candidates(Rest, Q, I + 1, Acc); 1291 + Score when is_integer(Score) -> 1292 + score_candidates(Rest, Q, I + 1, [{Score, I, Cmd} | Acc]) 1096 1293 end. 1294 + 1295 + %% Case-insensitive subsequence fuzzy score (inspired by sahilm/fuzzy used by 1296 + %% stinkpot). Higher is better. Consecutive matches and early matches score up. 1297 + fuzzy_score([], _Cand) -> 1298 + 0; 1299 + fuzzy_score(Query, Cand) -> 1300 + fuzzy_score(Query, Cand, 0, 0, -2, 0). 1301 + 1302 + fuzzy_score([], _Cand, Score, _Idx, _Last, _Run) -> 1303 + Score; 1304 + fuzzy_score([_ | _], [], _Score, _Idx, _Last, _Run) -> 1305 + nomatch; 1306 + fuzzy_score([Q | Qs], [C | Cs], Score, Idx, Last, Run) when Q =:= C -> 1307 + Consecutive = 1308 + case Idx =:= Last + 1 of 1309 + true -> Run + 1; 1310 + false -> 0 1311 + end, 1312 + %% Base hit + bonus for runs; slight preference for earlier positions. 1313 + Bonus = Consecutive * 4, 1314 + Early = max(0, 32 - Idx), 1315 + fuzzy_score(Qs, Cs, Score + 16 + Bonus + Early, Idx + 1, Idx, Consecutive); 1316 + fuzzy_score(Q, [_ | Cs], Score, Idx, Last, _Run) -> 1317 + fuzzy_score(Q, Cs, Score, Idx + 1, Last, 0). 1318 + 1319 + to_lower_chars(List) when is_list(List) -> 1320 + [ 1321 + case C of 1322 + X when is_integer(X), X >= $A, X =< $Z -> X + 32; 1323 + X -> X 1324 + end 1325 + || C <- List 1326 + ]; 1327 + to_lower_chars(_) -> 1328 + []. 1097 1329 1098 1330 kill_word([]) -> 1099 1331 {[], []}; ··· 1391 1623 decode_key(12, _) -> ctrl_l; 1392 1624 decode_key(18, _) -> ctrl_r; 1393 1625 decode_key(6, _) -> ctrl_f; 1626 + decode_key(16, _) -> ctrl_p; 1627 + decode_key(14, _) -> ctrl_n; 1628 + decode_key(7, _) -> ctrl_g; 1394 1629 decode_key(?ESC, _) -> 1395 1630 read_escape(); 1396 1631 decode_key(C, _) when is_integer(C), C >= 32 -> ··· 1401 1636 read_escape() -> 1402 1637 case io:get_chars("", 1) of 1403 1638 eof -> 1404 - other; 1639 + %% Lone ESC (no follow-up) — treat as Escape. 1640 + esc; 1641 + <<?ESC>> -> 1642 + %% ESC ESC 1643 + esc; 1405 1644 <<"[">> -> 1406 1645 read_csi(); 1407 1646 <<$O>> -> ··· 1421 1660 <<$F>> -> 1422 1661 alt_f; 1423 1662 _ -> 1424 - other 1663 + %% Unknown ESC sequence — Escape is the usual cancel key in TUIs. 1664 + esc 1425 1665 end. 1426 1666 1427 1667 read_csi() -> ··· 1748 1988 (Mode band 8#111) =/= 0; 1749 1989 _ -> 1750 1990 false 1991 + end. 1992 + 1993 + %% Canonical absolute path: resolve `.`/`..` and follow symlinks (like realpath(3)). 1994 + %% On failure (missing path, loop, etc.) returns {error, nil}. 1995 + -spec realpath(binary()) -> {ok, binary()} | {error, nil}. 1996 + realpath(Path) when is_binary(Path) -> 1997 + case realpath_loop(unicode:characters_to_list(Path), #{}) of 1998 + {ok, Resolved} -> 1999 + {ok, unicode:characters_to_binary(Resolved)}; 2000 + {error, _} -> 2001 + {error, nil} 2002 + end. 2003 + 2004 + realpath_loop(Path, Seen) -> 2005 + Abs = filename:absname(Path), 2006 + case maps:is_key(Abs, Seen) of 2007 + true -> 2008 + {error, eloop}; 2009 + false -> 2010 + case file:read_link(Abs) of 2011 + {ok, Target} -> 2012 + Next = 2013 + case filename:pathtype(Target) of 2014 + absolute -> 2015 + Target; 2016 + _ -> 2017 + filename:absname(filename:join(filename:dirname(Abs), Target)) 2018 + end, 2019 + realpath_loop(Next, Seen#{Abs => true}); 2020 + {error, einval} -> 2021 + %% Not a symlink — Abs is the final path. 2022 + case file:read_file_info(Abs) of 2023 + {ok, _} -> 2024 + {ok, Abs}; 2025 + {error, Reason} -> 2026 + {error, Reason} 2027 + end; 2028 + {error, Reason} -> 2029 + {error, Reason} 2030 + end 1751 2031 end. 1752 2032 1753 2033 -spec home_dir() -> {ok, binary()} | {error, binary()}. ··· 2906 3186 Reason; 2907 3187 reason_to_bin(Reason) -> 2908 3188 iolist_to_binary(io_lib:format("~p", [Reason])). 3189 + 3190 + %% --------------------------------------------------------------------------- 3191 + %% Format Unix epoch seconds as local calendar time: 3192 + %% "Jul 3 2026 9:39:40 PM" (abbreviated month, 12-hour clock). 3193 + %% Used for `ls` modified column display (data stays as raw Int). 3194 + %% --------------------------------------------------------------------------- 3195 + 3196 + -spec format_unix_local(integer()) -> binary(). 3197 + format_unix_local(Seconds) when is_integer(Seconds) -> 3198 + try 3199 + {{Y, Mo, D}, {H, Mi, S}} = 3200 + calendar:system_time_to_local_time(Seconds, second), 3201 + {H12, AmPm} = to_12h(H), 3202 + iolist_to_binary( 3203 + io_lib:format( 3204 + "~s ~B ~4..0B ~B:~2..0B:~2..0B ~s", 3205 + [month_abbr(Mo), D, Y, H12, Mi, S, AmPm] 3206 + ) 3207 + ) 3208 + catch 3209 + _:_ -> 3210 + integer_to_binary(Seconds) 3211 + end; 3212 + format_unix_local(_) -> 3213 + <<"0">>. 3214 + 3215 + %% 0 → 12 AM, 1–11 → AM, 12 → 12 PM, 13–23 → 1–11 PM 3216 + to_12h(0) -> {12, "AM"}; 3217 + to_12h(H) when H < 12 -> {H, "AM"}; 3218 + to_12h(12) -> {12, "PM"}; 3219 + to_12h(H) -> {H - 12, "PM"}. 3220 + 3221 + month_abbr(1) -> "Jan"; 3222 + month_abbr(2) -> "Feb"; 3223 + month_abbr(3) -> "Mar"; 3224 + month_abbr(4) -> "Apr"; 3225 + month_abbr(5) -> "May"; 3226 + month_abbr(6) -> "Jun"; 3227 + month_abbr(7) -> "Jul"; 3228 + month_abbr(8) -> "Aug"; 3229 + month_abbr(9) -> "Sep"; 3230 + month_abbr(10) -> "Oct"; 3231 + month_abbr(11) -> "Nov"; 3232 + month_abbr(12) -> "Dec"; 3233 + month_abbr(_) -> "???".
+156
test/gleshell_test.gleam
··· 81 81 Nil 82 82 } 83 83 84 + pub fn lexer_flake_ref_hash_test() { 85 + // Mid-token `#` is part of the bareword (nix flake refs), not a comment. 86 + let assert Ok(tokens) = lexer.tokenize("nix run nixpkgs#hello .#package") 87 + let assert [ 88 + lexer.Ident("nix"), 89 + lexer.Ident("run"), 90 + lexer.Ident("nixpkgs#hello"), 91 + lexer.Ident(".#package"), 92 + lexer.Eof, 93 + ] = tokens 94 + // Leading `#` at a word boundary still starts a line comment. 95 + let assert Ok(tokens2) = lexer.tokenize("echo hi # not parsed") 96 + let assert [ 97 + lexer.Ident("echo"), 98 + lexer.Ident("hi"), 99 + lexer.Eof, 100 + ] = tokens2 101 + Nil 102 + } 103 + 104 + pub fn parse_flake_ref_hash_test() { 105 + let assert Ok(parser.Expr(parser.Pipeline([ 106 + parser.Command("nix", args, False), 107 + ]))) = parser.parse("nix shell nixpkgs#cowsay") 108 + let assert [ 109 + parser.ValueArg(parser.Lit(String("shell"))), 110 + parser.ValueArg(parser.Lit(String("nixpkgs#cowsay"))), 111 + ] = args 112 + Nil 113 + } 114 + 84 115 pub fn parse_bare_double_dash_test() { 85 116 let assert Ok(parser.Expr(parser.Pipeline([ 86 117 parser.Command("nix", args, False), ··· 614 645 Nil 615 646 } 616 647 648 + pub fn eval_which_follow_test() { 649 + let env = env.new() 650 + // `-f` follows symlinks to a canonical path (NixOS: sh → bash store path). 651 + let assert eval.Continue(_, String(plain)) = eval.eval_source(env, "which sh") 652 + let assert eval.Continue(_, String(followed)) = 653 + eval.eval_source(env, "which -f sh") 654 + // Followed path is absolute and exists; if plain was a symlink, they differ. 655 + let assert True = string.starts_with(followed, "/") 656 + let assert True = string.length(followed) > 0 657 + // Combined with -a: first entry for a non-builtin is still a real path. 658 + let assert eval.Continue(_, result) = eval.eval_source(env, "which -a -f sh") 659 + case result { 660 + String(p) -> { 661 + let assert True = string.starts_with(p, "/") 662 + Nil 663 + } 664 + List([String(p), ..]) -> { 665 + let assert True = string.starts_with(p, "/") 666 + Nil 667 + } 668 + other -> { 669 + let _ = other 670 + panic as "which -a -f sh should yield a path" 671 + } 672 + } 673 + let _ = plain 674 + let _ = followed 675 + Nil 676 + } 677 + 617 678 pub fn value_nothing_falsey_test() { 618 679 let assert False = value.is_truthy(Nothing) 619 680 let assert True = value.is_truthy(Int(1)) ··· 688 749 Nil 689 750 } 690 751 752 + pub fn format_datetime_shape_test() { 753 + // Local 12-hour with abbr month: "Nov 14 2023 2:13:20 PM" (example shape). 754 + let text = display.format_datetime(1_700_000_000) 755 + let assert True = string_contains(text, ":") 756 + let assert True = 757 + string_contains(text, " AM") || string_contains(text, " PM") 758 + let assert True = 759 + string_contains(text, "Jan") 760 + || string_contains(text, "Feb") 761 + || string_contains(text, "Mar") 762 + || string_contains(text, "Apr") 763 + || string_contains(text, "May") 764 + || string_contains(text, "Jun") 765 + || string_contains(text, "Jul") 766 + || string_contains(text, "Aug") 767 + || string_contains(text, "Sep") 768 + || string_contains(text, "Oct") 769 + || string_contains(text, "Nov") 770 + || string_contains(text, "Dec") 771 + Nil 772 + } 773 + 774 + pub fn display_modified_column_humanized_test() { 775 + // Data stays as Unix seconds; only display shows a local 12-hour datetime. 776 + let text = 777 + display.render_with( 778 + False, 779 + Table(["name", "modified"], [[String("a"), Int(1_700_000_000)]]), 780 + ) 781 + let assert False = string_contains(text, "1700000000") 782 + let assert True = string_contains(text, ":") 783 + let assert True = 784 + string_contains(text, " AM") || string_contains(text, " PM") 785 + Nil 786 + } 787 + 788 + pub fn ls_includes_modified_column_test() { 789 + let env = env.new() 790 + // `first` collapses a single row to a record. 791 + let assert eval.Continue(_, Record(fields)) = 792 + eval.eval_source(env, "ls | first 1") 793 + let assert True = list_has_key(fields, "name") 794 + let assert True = list_has_key(fields, "type") 795 + let assert True = list_has_key(fields, "size") 796 + let assert True = list_has_key(fields, "modified") 797 + // Pipeline data stays as Int (epoch seconds); display formats it. 798 + case list_find_field(fields, "modified") { 799 + Ok(Int(n)) if n >= 0 -> Nil 800 + _ -> panic as "ls modified should be non-negative Unix epoch seconds" 801 + } 802 + } 803 + 691 804 pub fn color_visible_length_strips_ansi_test() { 692 805 let painted = color.paint(True, "\u{001b}[32m", "hi") 693 806 let assert 2 = color.visible_length(painted) ··· 1212 1325 let assert "" = sys.history_hint(hist, "ls") 1213 1326 // Empty buffer never suggests 1214 1327 let assert "" = sys.history_hint(hist, "") 1328 + Nil 1329 + } 1330 + 1331 + // --- Ctrl+R reverse search filter (stinkpot-style fuzzy) --- 1332 + 1333 + pub fn history_search_empty_query_returns_all_test() { 1334 + let hist = ["ls | first 3", "echo hi", "cd src"] 1335 + let assert ["ls | first 3", "echo hi", "cd src"] = sys.history_search(hist, "") 1336 + let assert ["ls | first 3", "echo hi", "cd src"] = 1337 + sys.history_search(hist, " ") 1338 + Nil 1339 + } 1340 + 1341 + pub fn history_search_dedupes_newest_first_test() { 1342 + let hist = ["echo a", "ls", "echo a", "cd"] 1343 + let assert ["echo a", "ls", "cd"] = sys.history_search(hist, "") 1344 + Nil 1345 + } 1346 + 1347 + pub fn history_search_substring_and_fuzzy_test() { 1348 + let hist = [ 1349 + "gleam test", 1350 + "ls | where type == file", 1351 + "git status", 1352 + "echo hello", 1353 + ] 1354 + let hits = sys.history_search(hist, "gleam") 1355 + let assert True = list_contains(hits, "gleam test") 1356 + let assert False = list_contains(hits, "git status") 1357 + 1358 + // Subsequence: "gts" matches "git status" 1359 + let fuzzy = sys.history_search(hist, "gts") 1360 + let assert True = list_contains(fuzzy, "git status") 1361 + 1362 + // Case-insensitive 1363 + let assert True = 1364 + list_contains(sys.history_search(hist, "GLEAM"), "gleam test") 1365 + Nil 1366 + } 1367 + 1368 + pub fn history_search_no_match_test() { 1369 + let hist = ["ls", "echo hi"] 1370 + let assert [] = sys.history_search(hist, "zzzz-nope") 1215 1371 Nil 1216 1372 } 1217 1373