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 Nushell-style colors, multi-word JSON commands, and edlin REPL.

Pretty `to json` / `from json`, ANSI table output with NO_COLOR, and
OTP shell history with Ctrl+R reverse-i-search in the interactive REPL.

+802 -85
+22 -3
README.md
··· 31 31 gleam run 32 32 ``` 33 33 34 + ### REPL editing 35 + 36 + The interactive REPL uses Erlang’s line editor (`edlin`): 37 + 38 + | Key | Action | 39 + |-----|--------| 40 + | ↑ / ↓ or Ctrl+P / Ctrl+N | History | 41 + | **Ctrl+R** | Reverse-i-search through history | 42 + | Ctrl+A / Ctrl+E | Beginning / end of line | 43 + | Ctrl+W | Delete previous word | 44 + | Ctrl+C / Ctrl+G | Cancel search / interrupt | 45 + 46 + History is persisted under the user cache as `gleshell-history` (OTP `shell_history`). 47 + 34 48 ## Examples 35 49 36 50 ```nu ··· 43 57 44 58 # records and JSON 45 59 echo {name: "gleshell", cool: true} 46 - echo "{\"a\": 1}" | from-json | get a 60 + echo "{\"a\": 1}" | from json | get a 47 61 open data.json | get users | first 62 + range 3 | to json 48 63 49 64 # variables 50 65 let n = range 3 | length ··· 77 92 78 93 Table/list: `where`/`filter`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 79 94 80 - Data: `echo`, `range`, `lines`, `to-json`, `from-json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` 95 + Data: `echo`, `range`, `lines`, `to json`, `from json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` 81 96 82 97 Unknown command names fall through to external executables on `PATH`. 83 98 ··· 86 101 ```text 87 102 src/ 88 103 gleshell.gleam # entry + REPL 89 - gleshell_ffi.erl # readline, cwd, process spawn 104 + gleshell_ffi.erl # line editor (Ctrl+R), cwd, process spawn 90 105 gleshell/ 91 106 value.gleam # structured Value type 92 107 lexer.gleam / parser.gleam 93 108 eval.gleam # pipeline evaluator 94 109 builtins.gleam # Nu-inspired commands 95 110 display.gleam # table pretty-printer 111 + color.gleam # Nushell-style ANSI colors 96 112 env.gleam / sys.gleam 97 113 ``` 114 + 115 + Output is colorized on a TTY (headers bold green, numbers purple, bools cyan, 116 + dirs blue, errors red, …). Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. 98 117 99 118 ## Status 100 119
+8 -3
src/gleshell.gleam
··· 3 3 import argv 4 4 import gleam/io 5 5 import gleam/string 6 + import gleshell/color 6 7 import gleshell/display 7 8 import gleshell/env 8 9 import gleshell/eval ··· 20 21 io.println_error("gleshell: -c requires a command string") 21 22 halt(2) 22 23 } 23 - [] -> repl(env.new()) 24 + [] -> sys.run_as_shell(fn() { repl(env.new()) }) 24 25 args -> run_once(string.join(args, " ")) 25 26 } 26 27 } ··· 62 63 63 64 fn repl(env: env.Env) -> Nil { 64 65 io.println( 65 - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit)", 66 + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Ctrl+R reverse search)", 66 67 ) 67 68 repl_loop(env) 68 69 } ··· 102 103 103 104 fn prompt_for(env: env.Env) -> String { 104 105 let base = basename(env.cwd) 105 - "gleshell:" <> base <> "> " 106 + let on = color.enabled() 107 + color.prompt_name(on, "gleshell") 108 + <> color.separator(on, ":") 109 + <> color.prompt_path(on, base) 110 + <> color.prompt_mark(on, "> ") 106 111 } 107 112 108 113 fn basename(path: String) -> String {
+124 -25
src/gleshell/builtins.gleam
··· 52 52 #("uniq", cmd_uniq), 53 53 #("wrap", cmd_wrap), 54 54 #("unwrap", cmd_unwrap), 55 - #("to-json", cmd_to_json), 56 - #("to_json", cmd_to_json), 57 - #("from-json", cmd_from_json), 58 - #("from_json", cmd_from_json), 55 + // Nushell multi-word: `to json` / `from json` 56 + #("to json", cmd_to_json), 57 + #("from json", cmd_from_json), 59 58 #("lines", cmd_lines), 60 59 #("typeof", cmd_type), 61 60 #("type", cmd_type), ··· 151 150 #("take", "take <n> — take first n rows"), 152 151 #("skip", "skip <n> — skip first n rows"), 153 152 #("echo", "echo <values>… — emit values (list if multiple)"), 154 - #("to-json", "to-json — convert input to JSON string"), 155 - #("from-json", "from-json — parse JSON string input"), 153 + #( 154 + "to json", 155 + "to json [--raw|-r] [--indent|-i n] — convert input to JSON string (pretty by default)", 156 + ), 157 + #( 158 + "from json", 159 + "from json — parse JSON string input into structured data", 160 + ), 156 161 #("range", "range <end> | range <start> <end> — integer range list"), 157 162 #("sys", "sys — host info record"), 158 163 ]) ··· 709 714 } 710 715 } 711 716 712 - // --- json --- 717 + // --- json (mirrors Nushell `to json` / `from json`) --- 713 718 714 719 fn cmd_to_json( 715 720 env: Env, 716 721 input: Value, 717 722 _args: List(Value), 718 - _flags: dict.Dict(String, Value), 723 + flags: dict.Dict(String, Value), 719 724 ) -> BuiltinResult { 720 - ok(env, String(value_to_json_string(input))) 725 + // Default: pretty-print with 2-space indent (like Nu). `--raw` / `-r` is compact. 726 + let raw = flag_set(flags, "raw") || flag_set(flags, "r") 727 + let indent = case raw { 728 + True -> option.None 729 + False -> 730 + case flag_int(flags, "indent") { 731 + option.Some(n) -> option.Some(n) 732 + option.None -> 733 + case flag_int(flags, "i") { 734 + option.Some(n) -> option.Some(n) 735 + option.None -> option.Some(2) 736 + } 737 + } 738 + } 739 + let body = encode_json(input, indent, 0) 740 + // Nu's default includes a trailing newline; `--raw` omits it. 741 + let text = case indent { 742 + option.None -> body 743 + option.Some(_) -> body <> "\n" 744 + } 745 + ok(env, String(text)) 721 746 } 722 747 723 748 fn cmd_from_json( ··· 736 761 _ -> "" 737 762 } 738 763 case source { 739 - "" -> err(env, "from-json: empty input") 764 + "" -> err(env, "from json: empty input") 740 765 s -> 741 766 case parse_json_value(s) { 742 767 Ok(v) -> ok(env, v) 743 - Error(msg) -> err(env, "from-json: " <> msg) 768 + Error(msg) -> err(env, "from json: " <> msg) 744 769 } 745 770 } 746 771 } 747 772 748 - fn value_to_json_string(v: Value) -> String { 773 + fn flag_set(flags: dict.Dict(String, Value), name: String) -> Bool { 774 + case dict.get(flags, name) { 775 + Ok(Bool(False)) -> False 776 + Ok(Nothing) -> False 777 + Ok(_) -> True 778 + Error(Nil) -> False 779 + } 780 + } 781 + 782 + fn flag_int(flags: dict.Dict(String, Value), name: String) -> option.Option(Int) { 783 + case dict.get(flags, name) { 784 + Ok(Int(n)) -> option.Some(n) 785 + _ -> option.None 786 + } 787 + } 788 + 789 + /// Encode a value as JSON. `indent` is `None` for compact (`--raw`), or 790 + /// `Some(n)` for n-space pretty-print (Nushell default is 2). 791 + fn encode_json(v: Value, indent: option.Option(Int), depth: Int) -> String { 749 792 case v { 750 793 Nothing -> "null" 751 794 Bool(True) -> "true" ··· 753 796 Int(n) -> int.to_string(n) 754 797 Float(f) -> float.to_string(f) 755 798 String(s) -> json_escape(s) 756 - List(items) -> 757 - "[" <> string.join(list.map(items, value_to_json_string), ",") <> "]" 758 - Record(fields) -> 759 - "{" 760 - <> string.join( 761 - list.map(fields, fn(pair) { 762 - let #(k, val) = pair 763 - json_escape(k) <> ":" <> value_to_json_string(val) 764 - }), 765 - ",", 766 - ) 767 - <> "}" 799 + List(items) -> encode_json_array(items, indent, depth) 800 + Record(fields) -> encode_json_object(fields, indent, depth) 768 801 Table(cols, rows) -> { 769 802 let records = list.map(rows, fn(row) { Record(list.zip(cols, row)) }) 770 - value_to_json_string(List(records)) 803 + encode_json(List(records), indent, depth) 771 804 } 772 805 Fail(msg) -> json_escape("error: " <> msg) 806 + } 807 + } 808 + 809 + fn encode_json_array( 810 + items: List(Value), 811 + indent: option.Option(Int), 812 + depth: Int, 813 + ) -> String { 814 + case items { 815 + [] -> "[]" 816 + _ -> 817 + case indent { 818 + option.None -> 819 + "[" 820 + <> string.join(list.map(items, fn(i) { encode_json(i, indent, 0) }), ",") 821 + <> "]" 822 + option.Some(width) -> { 823 + let inner = depth + 1 824 + let pad = string.repeat(" ", width * inner) 825 + let close = string.repeat(" ", width * depth) 826 + let body = 827 + items 828 + |> list.map(fn(i) { pad <> encode_json(i, indent, inner) }) 829 + |> string.join(",\n") 830 + "[\n" <> body <> "\n" <> close <> "]" 831 + } 832 + } 833 + } 834 + } 835 + 836 + fn encode_json_object( 837 + fields: List(#(String, Value)), 838 + indent: option.Option(Int), 839 + depth: Int, 840 + ) -> String { 841 + case fields { 842 + [] -> "{}" 843 + _ -> 844 + case indent { 845 + option.None -> 846 + "{" 847 + <> string.join( 848 + list.map(fields, fn(pair) { 849 + let #(k, val) = pair 850 + json_escape(k) <> ":" <> encode_json(val, indent, 0) 851 + }), 852 + ",", 853 + ) 854 + <> "}" 855 + option.Some(width) -> { 856 + let inner = depth + 1 857 + let pad = string.repeat(" ", width * inner) 858 + let close = string.repeat(" ", width * depth) 859 + let body = 860 + fields 861 + |> list.map(fn(pair) { 862 + let #(k, val) = pair 863 + pad 864 + <> json_escape(k) 865 + <> ": " 866 + <> encode_json(val, indent, inner) 867 + }) 868 + |> string.join(",\n") 869 + "{\n" <> body <> "\n" <> close <> "}" 870 + } 871 + } 773 872 } 774 873 } 775 874
+209
src/gleshell/color.gleam
··· 1 + //// Nushell-inspired ANSI colors for structured values. 2 + 3 + import gleam/string 4 + import gleshell/sys 5 + 6 + const reset = "\u{001b}[0m" 7 + 8 + /// Bold green — table headers, record keys, list indices (Nu `header` / `row_index`). 9 + const bold_green = "\u{001b}[1;32m" 10 + 11 + /// Green — strings (Nu `string` / `shape_string`). 12 + const green = "\u{001b}[32m" 13 + 14 + /// Magenta / purple — ints & floats (Nu `int` / `float`). 15 + const purple = "\u{001b}[35m" 16 + 17 + /// Bright cyan — bools (Nu `bool` / `light_cyan`). 18 + const light_cyan = "\u{001b}[96m" 19 + 20 + /// Dark gray — nothing / empty (Nu `shape_nothing`). 21 + const dark_gray = "\u{001b}[90m" 22 + 23 + /// Cyan — filesizes and similar (Nu `filesize`). 24 + const cyan = "\u{001b}[36m" 25 + 26 + /// Blue — directories (common ls color). 27 + const blue = "\u{001b}[34m" 28 + 29 + /// Bright blue — directory names in tables. 30 + const bright_blue = "\u{001b}[94m" 31 + 32 + /// Bright cyan — symlinks. 33 + const bright_cyan = "\u{001b}[96m" 34 + 35 + /// Bold red — errors. 36 + const bold_red = "\u{001b}[1;31m" 37 + 38 + /// Dim — box-drawing separators. 39 + const dim = "\u{001b}[2m" 40 + 41 + /// Bold — emphasis (prompt name). 42 + const bold = "\u{001b}[1m" 43 + 44 + /// Whether ANSI color should be emitted. 45 + /// 46 + /// - Off when `NO_COLOR` is set to a non-empty value (https://no-color.org). 47 + /// - On when `FORCE_COLOR` / `CLICOLOR_FORCE` is set to a non-empty, non-`0` value. 48 + /// - Otherwise on only when stdout is a terminal. 49 + pub fn enabled() -> Bool { 50 + case sys.getenv("NO_COLOR") { 51 + Ok(v) if v != "" -> False 52 + _ -> 53 + case force_color() { 54 + True -> True 55 + False -> sys.stdout_isatty() 56 + } 57 + } 58 + } 59 + 60 + fn force_color() -> Bool { 61 + case sys.getenv("FORCE_COLOR") { 62 + Ok(v) -> is_force_value(v) 63 + Error(Nil) -> 64 + case sys.getenv("CLICOLOR_FORCE") { 65 + Ok(v) -> is_force_value(v) 66 + Error(Nil) -> False 67 + } 68 + } 69 + } 70 + 71 + fn is_force_value(v: String) -> Bool { 72 + case v { 73 + "" | "0" | "false" | "False" | "no" | "No" -> False 74 + _ -> True 75 + } 76 + } 77 + 78 + /// Wrap `text` in an ANSI code when colors are on. 79 + pub fn paint(on: Bool, code: String, text: String) -> String { 80 + case on { 81 + True -> code <> text <> reset 82 + False -> text 83 + } 84 + } 85 + 86 + pub fn header(on: Bool, text: String) -> String { 87 + paint(on, bold_green, text) 88 + } 89 + 90 + pub fn key(on: Bool, text: String) -> String { 91 + paint(on, bold_green, text) 92 + } 93 + 94 + pub fn index(on: Bool, text: String) -> String { 95 + paint(on, bold_green, text) 96 + } 97 + 98 + pub fn separator(on: Bool, text: String) -> String { 99 + paint(on, dim, text) 100 + } 101 + 102 + pub fn error(on: Bool, text: String) -> String { 103 + paint(on, bold_red, text) 104 + } 105 + 106 + pub fn int_(on: Bool, text: String) -> String { 107 + paint(on, purple, text) 108 + } 109 + 110 + pub fn float_(on: Bool, text: String) -> String { 111 + paint(on, purple, text) 112 + } 113 + 114 + pub fn bool_(on: Bool, text: String) -> String { 115 + paint(on, light_cyan, text) 116 + } 117 + 118 + pub fn string_(on: Bool, text: String) -> String { 119 + paint(on, green, text) 120 + } 121 + 122 + pub fn nothing(on: Bool, text: String) -> String { 123 + paint(on, dark_gray, text) 124 + } 125 + 126 + pub fn filesize(on: Bool, text: String) -> String { 127 + paint(on, cyan, text) 128 + } 129 + 130 + pub fn dir_name(on: Bool, text: String) -> String { 131 + paint(on, bright_blue, text) 132 + } 133 + 134 + pub fn file_name(on: Bool, text: String) -> String { 135 + // Plain files stay default foreground (matches modern Nu); keep green as a 136 + // mild highlight so bare strings still read as "string-like". 137 + paint(on, green, text) 138 + } 139 + 140 + pub fn symlink_name(on: Bool, text: String) -> String { 141 + paint(on, bright_cyan, text) 142 + } 143 + 144 + pub fn type_dir(on: Bool, text: String) -> String { 145 + paint(on, blue, text) 146 + } 147 + 148 + pub fn type_file(on: Bool, text: String) -> String { 149 + paint(on, green, text) 150 + } 151 + 152 + pub fn type_symlink(on: Bool, text: String) -> String { 153 + paint(on, cyan, text) 154 + } 155 + 156 + pub fn prompt_name(on: Bool, text: String) -> String { 157 + paint(on, bold_green, text) 158 + } 159 + 160 + pub fn prompt_path(on: Bool, text: String) -> String { 161 + paint(on, bright_blue, text) 162 + } 163 + 164 + pub fn prompt_mark(on: Bool, text: String) -> String { 165 + paint(on, bold, text) 166 + } 167 + 168 + /// Visible length ignoring ANSI CSI sequences (`ESC [ … final`). 169 + pub fn visible_length(s: String) -> Int { 170 + visible_length_loop(string.to_utf_codepoints(s), 0, AnsiNormal) 171 + } 172 + 173 + type AnsiScan { 174 + AnsiNormal 175 + /// Saw ESC; next byte chooses the sequence kind. 176 + AnsiEsc 177 + /// Inside CSI (`ESC [` … final byte 0x40–0x7E). 178 + AnsiCsi 179 + } 180 + 181 + fn visible_length_loop( 182 + codes: List(UtfCodepoint), 183 + acc: Int, 184 + state: AnsiScan, 185 + ) -> Int { 186 + case codes, state { 187 + [], _ -> acc 188 + [c, ..rest], AnsiNormal -> 189 + case string.utf_codepoint_to_int(c) == 0x1B { 190 + True -> visible_length_loop(rest, acc, AnsiEsc) 191 + False -> visible_length_loop(rest, acc + 1, AnsiNormal) 192 + } 193 + [c, ..rest], AnsiEsc -> 194 + case string.utf_codepoint_to_int(c) { 195 + // CSI introducer `[` — do not treat it as a final byte. 196 + 0x5B -> visible_length_loop(rest, acc, AnsiCsi) 197 + // Other ESC sequences: skip this single following byte. 198 + _ -> visible_length_loop(rest, acc, AnsiNormal) 199 + } 200 + [c, ..rest], AnsiCsi -> { 201 + let n = string.utf_codepoint_to_int(c) 202 + // CSI final byte is in 0x40–0x7E (`@`..`~`). 203 + case n >= 0x40 && n <= 0x7E { 204 + True -> visible_length_loop(rest, acc, AnsiNormal) 205 + False -> visible_length_loop(rest, acc, AnsiCsi) 206 + } 207 + } 208 + } 209 + }
+198 -45
src/gleshell/display.gleam
··· 1 - //// Pretty-print structured values (Nushell-style tables). 1 + //// Pretty-print structured values (Nushell-style tables + colors). 2 2 3 3 import gleam/int 4 4 import gleam/list 5 5 import gleam/string 6 - import gleshell/value.{type Value, Fail, List, Nothing, Record, Table} 6 + import gleshell/color 7 + import gleshell/value.{ 8 + type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, 9 + } 7 10 8 11 pub fn render(value: Value) -> String { 12 + render_with(color.enabled(), value) 13 + } 14 + 15 + /// Render with an explicit color switch (useful for tests / `NO_COLOR`). 16 + pub fn render_with(on: Bool, value: Value) -> String { 9 17 case value { 10 18 Nothing -> "" 11 - Fail(msg) -> "Error: " <> msg 12 - Table(cols, rows) -> render_table(cols, rows) 19 + Fail(msg) -> color.error(on, "Error: " <> msg) 20 + Table(cols, rows) -> render_table_with(on, cols, rows) 13 21 List(items) -> { 14 22 case list.all(items, is_record) { 15 23 True -> 16 24 case value.table_from_records(items) { 17 - Table(c, r) -> render_table(c, r) 18 - other -> render_value_block(other) 25 + Table(c, r) -> render_table_with(on, c, r) 26 + other -> render_value_block(on, other) 19 27 } 20 - False -> render_list(items) 28 + False -> render_list(on, items) 21 29 } 22 30 } 23 - Record(fields) -> render_record(fields) 24 - other -> value.cell_string(other) 31 + Record(fields) -> render_record(on, fields) 32 + other -> color_cell(on, "", other, value.cell_string(other)) 25 33 } 26 34 } 27 35 ··· 32 40 } 33 41 } 34 42 35 - fn render_value_block(v: Value) -> String { 36 - value.as_string(v) 43 + fn render_value_block(on: Bool, v: Value) -> String { 44 + color_cell(on, "", v, value.as_string(v)) 37 45 } 38 46 39 - fn render_list(items: List(Value)) -> String { 47 + fn render_list(on: Bool, items: List(Value)) -> String { 40 48 case items { 41 - [] -> "[]" 49 + [] -> color.separator(on, "[]") 42 50 _ -> { 43 51 let body = 44 52 items 45 53 |> list.index_map(fn(item, i) { 46 - " " <> int.to_string(i) <> " │ " <> value.cell_string(item) 54 + let idx = color.index(on, int.to_string(i)) 55 + let bar = color.separator(on, "│") 56 + let cell = color_cell(on, "", item, value.cell_string(item)) 57 + " " <> idx <> " " <> bar <> " " <> cell 47 58 }) 48 59 |> string.join("\n") 49 - "╭──── list ───\n" <> body <> "\n╰────────────" 60 + let top = color.separator(on, "╭──── list ───") 61 + let bot = color.separator(on, "╰────────────") 62 + top <> "\n" <> body <> "\n" <> bot 50 63 } 51 64 } 52 65 } 53 66 54 - fn render_record(fields: List(#(String, Value))) -> String { 67 + fn render_record(on: Bool, fields: List(#(String, Value))) -> String { 55 68 case fields { 56 - [] -> "{}" 69 + [] -> color.separator(on, "{}") 57 70 _ -> { 58 71 let key_w = 59 72 fields ··· 63 76 }) 64 77 |> list.fold(0, int.max) 65 78 79 + let type_hint = case list.key_find(fields, "type") { 80 + Ok(String(t)) -> t 81 + _ -> "" 82 + } 66 83 let lines = 67 84 list.map(fields, fn(pair) { 68 85 let #(k, v) = pair 69 - " " <> pad_right(k, key_w) <> " │ " <> value.cell_string(v) 86 + let key = color.key(on, pad_right(k, key_w)) 87 + let bar = color.separator(on, "│") 88 + let cell = 89 + color_cell_for_column(on, k, v, value.cell_string(v), type_hint) 90 + " " <> key <> " " <> bar <> " " <> cell 70 91 }) 71 - "╭──── record ───\n" <> string.join(lines, "\n") <> "\n╰──────────────" 92 + let top = color.separator(on, "╭──── record ───") 93 + let bot = color.separator(on, "╰──────────────") 94 + top <> "\n" <> string.join(lines, "\n") <> "\n" <> bot 72 95 } 73 96 } 74 97 } 75 98 76 99 pub fn render_table(columns: List(String), rows: List(List(Value))) -> String { 100 + render_table_with(color.enabled(), columns, rows) 101 + } 102 + 103 + fn render_table_with( 104 + on: Bool, 105 + columns: List(String), 106 + rows: List(List(Value)), 107 + ) -> String { 77 108 case columns { 78 - [] -> "(empty table)" 109 + [] -> color.nothing(on, "(empty table)") 79 110 _ -> { 80 - let cells: List(List(String)) = 111 + let plain_cells: List(List(String)) = 81 112 list.map(rows, fn(row) { list.map(row, value.cell_string) }) 82 113 83 114 let widths = 84 115 list.index_map(columns, fn(col, i) { 85 116 let header_w = string.length(col) 86 117 let data_w = 87 - cells 118 + plain_cells 88 119 |> list.map(fn(row) { 89 120 case list_at(row, i) { 90 121 Ok(c) -> string.length(c) ··· 95 126 int.max(data_w, 1) 96 127 }) 97 128 98 - let top = box_line(widths, "╭", "┬", "╮", "─") 99 - let sep = box_line(widths, "├", "┼", "┤", "─") 100 - let bot = box_line(widths, "╰", "┴", "╯", "─") 101 - let header = data_line(columns, widths) 129 + let top = color.separator(on, box_line(widths, "╭", "┬", "╮", "─")) 130 + let sep = color.separator(on, box_line(widths, "├", "┼", "┤", "─")) 131 + let bot = color.separator(on, box_line(widths, "╰", "┴", "╯", "─")) 132 + let header = 133 + colored_header_line(on, columns, widths) 102 134 let body = 103 - cells 104 - |> list.map(fn(row) { data_line(row, widths) }) 135 + list.map2(rows, plain_cells, fn(row, plains) { 136 + colored_data_line(on, columns, row, plains, widths) 137 + }) 105 138 |> string.join("\n") 106 139 107 140 case body { ··· 112 145 } 113 146 } 114 147 148 + fn colored_header_line( 149 + on: Bool, 150 + columns: List(String), 151 + widths: List(Int), 152 + ) -> String { 153 + let padded = 154 + list.map2(columns, widths, fn(col, w) { 155 + " " <> color.header(on, pad_right(col, w)) <> " " 156 + }) 157 + let bar = color.separator(on, "│") 158 + bar <> string.join(padded, bar) <> bar 159 + } 160 + 161 + fn colored_data_line( 162 + on: Bool, 163 + columns: List(String), 164 + row: List(Value), 165 + plains: List(String), 166 + widths: List(Int), 167 + ) -> String { 168 + let type_hint = row_type_hint(columns, plains) 169 + let cells = 170 + list.index_map(columns, fn(col, i) { 171 + let w = case list_at(widths, i) { 172 + Ok(n) -> n 173 + Error(Nil) -> 1 174 + } 175 + let plain = case list_at(plains, i) { 176 + Ok(p) -> p 177 + Error(Nil) -> "" 178 + } 179 + let val = case list_at(row, i) { 180 + Ok(v) -> v 181 + Error(Nil) -> Nothing 182 + } 183 + // Color the unpadded text, then add trailing spaces outside ANSI codes 184 + // so type/name matchers see exact values ("dir", not "dir "). 185 + let painted = color_cell_for_column(on, col, val, plain, type_hint) 186 + let pad = string.repeat(" ", int.max(0, w - string.length(plain))) 187 + " " <> painted <> pad <> " " 188 + }) 189 + // Extra empty columns if widths longer than columns (shouldn't happen). 190 + let cells = case list.length(cells) < list.length(widths) { 191 + True -> { 192 + let extra = 193 + list.drop(widths, list.length(cells)) 194 + |> list.map(fn(w) { " " <> pad_right("", w) <> " " }) 195 + list.append(cells, extra) 196 + } 197 + False -> cells 198 + } 199 + let bar = color.separator(on, "│") 200 + bar <> string.join(cells, bar) <> bar 201 + } 202 + 203 + /// Look up the `type` column value for ls-style name coloring. 204 + fn row_type_hint(columns: List(String), plains: List(String)) -> String { 205 + case list_index_of(columns, "type") { 206 + Ok(i) -> 207 + case list_at(plains, i) { 208 + Ok(t) -> t 209 + Error(Nil) -> "" 210 + } 211 + Error(Nil) -> "" 212 + } 213 + } 214 + 215 + fn list_index_of(items: List(String), target: String) -> Result(Int, Nil) { 216 + list_index_of_loop(items, target, 0) 217 + } 218 + 219 + fn list_index_of_loop( 220 + items: List(String), 221 + target: String, 222 + i: Int, 223 + ) -> Result(Int, Nil) { 224 + case items { 225 + [] -> Error(Nil) 226 + [x, ..rest] -> 227 + case x == target { 228 + True -> Ok(i) 229 + False -> list_index_of_loop(rest, target, i + 1) 230 + } 231 + } 232 + } 233 + 234 + /// Color a table/list/record cell by value type and optional column name. 235 + fn color_cell(on: Bool, col: String, value: Value, plain: String) -> String { 236 + color_cell_for_column(on, col, value, plain, "") 237 + } 238 + 239 + fn color_cell_for_column( 240 + on: Bool, 241 + col: String, 242 + value: Value, 243 + plain: String, 244 + type_hint: String, 245 + ) -> String { 246 + case col { 247 + "name" -> color_path_name(on, plain, type_hint) 248 + "type" -> color_entry_type(on, plain) 249 + "size" -> color.filesize(on, plain) 250 + _ -> color_by_value(on, value, plain) 251 + } 252 + } 253 + 254 + fn color_path_name(on: Bool, plain: String, type_hint: String) -> String { 255 + case type_hint { 256 + "dir" | "directory" -> color.dir_name(on, plain) 257 + "symlink" | "link" -> color.symlink_name(on, plain) 258 + "file" -> color.file_name(on, plain) 259 + _ -> color.string_(on, plain) 260 + } 261 + } 262 + 263 + fn color_entry_type(on: Bool, plain: String) -> String { 264 + case plain { 265 + "dir" | "directory" -> color.type_dir(on, plain) 266 + "symlink" | "link" -> color.type_symlink(on, plain) 267 + "file" -> color.type_file(on, plain) 268 + _ -> color.string_(on, plain) 269 + } 270 + } 271 + 272 + fn color_by_value(on: Bool, value: Value, plain: String) -> String { 273 + case value { 274 + Nothing -> color.nothing(on, plain) 275 + Bool(_) -> color.bool_(on, plain) 276 + Int(_) -> color.int_(on, plain) 277 + Float(_) -> color.float_(on, plain) 278 + String(_) -> color.string_(on, plain) 279 + Fail(_) -> color.error(on, plain) 280 + List(_) | Record(_) | Table(_, _) -> color.string_(on, plain) 281 + } 282 + } 283 + 115 284 fn box_line( 116 285 widths: List(Int), 117 286 left: String, ··· 123 292 left <> string.join(segments, mid) <> right 124 293 } 125 294 126 - fn data_line(cells: List(String), widths: List(Int)) -> String { 127 - let padded = 128 - list.map2(cells, widths, fn(cell, w) { " " <> pad_right(cell, w) <> " " }) 129 - // if fewer cells than widths, pad 130 - let padded = case list.length(padded) < list.length(widths) { 131 - True -> { 132 - let extra = 133 - list.drop(widths, list.length(padded)) 134 - |> list.map(fn(w) { " " <> pad_right("", w) <> " " }) 135 - list.append(padded, extra) 136 - } 137 - False -> padded 138 - } 139 - "│" <> string.join(padded, "│") <> "│" 140 - } 141 - 142 295 fn pad_right(s: String, width: Int) -> String { 143 296 let len = string.length(s) 144 297 case len >= width { ··· 157 310 158 311 /// Color-friendly error line for the REPL. 159 312 pub fn render_error(msg: String) -> String { 160 - "✗ " <> msg 313 + color.error(color.enabled(), "✗ " <> msg) 161 314 }
+23 -3
src/gleshell/eval.gleam
··· 72 72 case external { 73 73 True -> run_external(env, name, pos) 74 74 False -> 75 - case dict.get(builtins.registry(), name) { 76 - Ok(builtin) -> 77 - case builtin(env, input, pos, flags) { 75 + case resolve_builtin(name, pos) { 76 + Ok(#(builtin, pos2)) -> 77 + case builtin(env, input, pos2, flags) { 78 78 builtins.Exit(code) -> Quit(code) 79 79 builtins.BuiltinResult(env2, value) -> { 80 80 let env2 = case value { ··· 90 90 } 91 91 } 92 92 } 93 + } 94 + } 95 + 96 + /// Look up a builtin, including Nushell-style multi-word names (`to json`). 97 + /// When `name` alone is missing, try consuming a following bare string arg. 98 + fn resolve_builtin( 99 + name: String, 100 + pos: List(Value), 101 + ) -> Result(#(builtins.Builtin, List(Value)), Nil) { 102 + case dict.get(builtins.registry(), name) { 103 + Ok(builtin) -> Ok(#(builtin, pos)) 104 + Error(Nil) -> 105 + case pos { 106 + [String(sub), ..rest] -> 107 + case dict.get(builtins.registry(), name <> " " <> sub) { 108 + Ok(builtin) -> Ok(#(builtin, rest)) 109 + Error(Nil) -> Error(Nil) 110 + } 111 + _ -> Error(Nil) 112 + } 93 113 } 94 114 } 95 115
+8
src/gleshell/sys.gleam
··· 3 3 @external(erlang, "gleshell_ffi", "get_line") 4 4 pub fn get_line(prompt: String) -> Result(String, String) 5 5 6 + /// Run `body` as the OTP interactive shell process so the REPL gets 7 + /// edlin line editing: history (up/down) and Ctrl+R reverse-i-search. 8 + @external(erlang, "gleshell_ffi", "run_as_shell") 9 + pub fn run_as_shell(body: fn() -> Nil) -> Nil 10 + 6 11 @external(erlang, "gleshell_ffi", "set_cwd") 7 12 pub fn set_cwd(path: String) -> Result(Nil, String) 8 13 ··· 26 31 27 32 @external(erlang, "gleshell_ffi", "home_dir") 28 33 pub fn home_dir() -> Result(String, String) 34 + 35 + @external(erlang, "gleshell_ffi", "stdout_isatty") 36 + pub fn stdout_isatty() -> Bool
+140 -5
src/gleshell_ffi.erl
··· 2 2 -module(gleshell_ffi). 3 3 -export([ 4 4 get_line/1, 5 + parse_line/2, 6 + run_as_shell/1, 7 + spawn_shell/2, 5 8 set_cwd/1, 6 9 get_cwd/0, 7 10 getenv/1, 8 11 setenv/2, 9 12 run_cmd/2, 10 13 which/1, 11 - home_dir/0 14 + home_dir/0, 15 + stdout_isatty/0 12 16 ]). 13 17 18 + %% Read a line with edlin history support. 19 + %% 20 + %% Use get_until (not get_line): since OTP 26, io:get_line/1 input is not 21 + %% reliably saved in the shell history buffer; get_until is. See OTP #6896 22 + %% and the custom-shell guide. 14 23 -spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 15 24 get_line(Prompt) when is_binary(Prompt) -> 16 - %% OTP may return a charlist or a UTF-8 binary depending on the 17 - %% standard_io encoding / binary options — accept both. 18 - case io:get_line(unicode:characters_to_list(Prompt)) of 25 + PromptChars = unicode:characters_to_list(Prompt), 26 + case io:request( 27 + standard_io, 28 + {get_until, unicode, PromptChars, ?MODULE, parse_line, []} 29 + ) of 19 30 eof -> 20 31 {error, <<"eof">>}; 21 32 {error, _} -> ··· 23 34 Line when is_list(Line); is_binary(Line) -> 24 35 Bin = unicode:characters_to_binary(Line), 25 36 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 26 - {ok, Stripped} 37 + {ok, Stripped}; 38 + Other -> 39 + %% Unexpected shape from a custom/get_until callback. 40 + try 41 + Bin = unicode:characters_to_binary(Other), 42 + Stripped = string:trim(Bin, trailing, [$\n, $\r]), 43 + {ok, Stripped} 44 + catch 45 + _:_ -> 46 + {error, <<"io_error">>} 47 + end 48 + end. 49 + 50 + %% get_until callback: edlin already gathers a full line (with editing / 51 + %% history navigation); accept it as done. Cont starts as []. 52 + -spec parse_line(term(), term()) -> 53 + {done, eof | string(), list()} | {more, term()}. 54 + parse_line(_Cont, eof) -> 55 + {done, eof, []}; 56 + parse_line(_Cont, Chars) when is_list(Chars) -> 57 + {done, Chars, []}. 58 + 59 + %% Run Fun as the OTP interactive shell process so edlin line editing 60 + %% works: up/down history, Ctrl+R reverse-i-search, word kill, etc. 61 + %% Gleam starts the VM with -noshell, so without this we only get dumb 62 + %% line input and no reverse search. 63 + -spec run_as_shell(fun(() -> term())) -> nil. 64 + run_as_shell(Fun) when is_function(Fun, 0) -> 65 + enable_shell_history(), 66 + Parent = self(), 67 + case try_start_interactive(Parent, Fun) of 68 + {ok, started} -> 69 + receive 70 + {gleshell_shell_done, ok} -> 71 + nil; 72 + {gleshell_shell_done, {error, Class, Reason, Stack}} -> 73 + erlang:raise(Class, Reason, Stack) 74 + end; 75 + {ok, direct} -> 76 + configure_line_editor(), 77 + Fun(), 78 + nil 79 + end. 80 + 81 + try_start_interactive(Parent, Fun) -> 82 + %% Empty slogan so we do not print the Erlang system banner. 83 + _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 84 + case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 85 + ok -> 86 + {ok, started}; 87 + {error, already_started} -> 88 + {ok, direct}; 89 + {error, _} -> 90 + {ok, direct} 91 + end. 92 + 93 + %% MFA entry for user_drv/group: must return the shell pid. Spawned 94 + %% under the group so group_leader is the edlin-enabled group. 95 + -spec spawn_shell(pid(), fun(() -> term())) -> pid(). 96 + spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 97 + spawn(fun() -> 98 + try 99 + configure_line_editor(), 100 + Fun() 101 + of 102 + _ -> 103 + Parent ! {gleshell_shell_done, ok}, 104 + %% Intentional exit reason so user_drv does not print 105 + %% "Shell process terminated!" and restart us. 106 + exit(die) 107 + catch 108 + Class:Reason:Stack -> 109 + Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, 110 + erlang:raise(Class, Reason, Stack) 111 + end 112 + end). 113 + 114 + %% Best-effort: unicode IO + save get_until lines into edlin history. 115 + configure_line_editor() -> 116 + _ = io:setopts([{encoding, unicode}, binary]), 117 + try 118 + io:setopts([{line_history, true}]) 119 + catch 120 + _:_ -> 121 + ok 122 + end, 123 + ok. 124 + 125 + enable_shell_history() -> 126 + case application:get_env(kernel, shell_history_path) of 127 + {ok, _} -> 128 + ok; 129 + undefined -> 130 + Path = filename:basedir(user_cache, "gleshell-history"), 131 + _ = application:set_env( 132 + kernel, shell_history_path, Path, [{persistent, true}] 133 + ), 134 + ok 135 + end, 136 + case application:get_env(kernel, shell_history) of 137 + {ok, _} -> 138 + ok; 139 + undefined -> 140 + _ = application:set_env( 141 + kernel, shell_history, enabled, [{persistent, true}] 142 + ), 143 + ok 27 144 end. 28 145 29 146 -spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. ··· 74 191 {error, <<"HOME not set">>}; 75 192 Home -> 76 193 {ok, unicode:characters_to_binary(Home)} 194 + end. 195 + 196 + %% True when stdout is a terminal (colors are useful). 197 + -spec stdout_isatty() -> boolean(). 198 + stdout_isatty() -> 199 + case io:columns() of 200 + {ok, _} -> 201 + true; 202 + _ -> 203 + try 204 + case prim_tty:isatty(stdout) of 205 + true -> true; 206 + _ -> false 207 + end 208 + catch 209 + _:_ -> 210 + false 211 + end 77 212 end. 78 213 79 214 %% Run an executable with args; capture stdout+stderr and exit status.
+70 -1
test/gleshell_test.gleam
··· 1 + import gleam/string 1 2 import gleeunit 3 + import gleshell/color 4 + import gleshell/display 2 5 import gleshell/env 3 6 import gleshell/eval 4 7 import gleshell/lexer ··· 131 134 pub fn eval_from_json_test() { 132 135 let env = env.new() 133 136 let assert eval.Continue(_, Record(fields)) = 134 - eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from-json") 137 + eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from json") 135 138 let assert True = list_has_field(fields, "x", Int(1)) 136 139 Nil 137 140 } 138 141 142 + pub fn eval_to_json_pretty_test() { 143 + let env = env.new() 144 + // Nushell-style multi-word `to json` — pretty by default 145 + let assert eval.Continue(_, String(pretty)) = 146 + eval.eval_source(env, "echo [1 2 3] | to json") 147 + let assert True = string.contains(pretty, "\n") 148 + let assert True = string.contains(pretty, "1") 149 + // `--raw` matches Nu: compact, no trailing newline 150 + let assert eval.Continue(_, String(raw)) = 151 + eval.eval_source(env, "echo [1 2 3] | to json --raw") 152 + let assert "[1,2,3]" = raw 153 + Nil 154 + } 155 + 156 + pub fn eval_to_json_record_test() { 157 + let env = env.new() 158 + let assert eval.Continue(_, String(raw)) = 159 + eval.eval_source(env, "echo {a: 1, b: true} | to json -r") 160 + let assert True = string.contains(raw, "\"a\":1") 161 + let assert True = string.contains(raw, "\"b\":true") 162 + Nil 163 + } 164 + 139 165 fn list_has_field( 140 166 fields: List(#(String, value.Value)), 141 167 key: String, ··· 163 189 let assert True = value.is_truthy(Int(1)) 164 190 Nil 165 191 } 192 + 193 + // --- display / color --- 194 + 195 + pub fn display_plain_no_ansi_test() { 196 + let text = display.render_with(False, Bool(True)) 197 + let assert "true" = text 198 + let assert False = string_contains(text, "\u{001b}") 199 + Nil 200 + } 201 + 202 + pub fn display_colored_has_ansi_test() { 203 + let text = display.render_with(True, Bool(True)) 204 + let assert True = string_contains(text, "\u{001b}") 205 + let assert True = string_contains(text, "true") 206 + Nil 207 + } 208 + 209 + pub fn display_table_headers_colored_test() { 210 + let text = 211 + display.render_with( 212 + True, 213 + Table(["name", "type"], [[String("src"), String("dir")]]), 214 + ) 215 + // bold green header + bright-blue dir name 216 + let assert True = string_contains(text, "\u{001b}[1;32m") 217 + let assert True = string_contains(text, "\u{001b}[94m") 218 + let assert True = string_contains(text, "src") 219 + Nil 220 + } 221 + 222 + pub fn color_visible_length_strips_ansi_test() { 223 + let painted = color.paint(True, "\u{001b}[32m", "hi") 224 + let assert 2 = color.visible_length(painted) 225 + let assert 2 = color.visible_length("hi") 226 + Nil 227 + } 228 + 229 + fn string_contains(haystack: String, needle: String) -> Bool { 230 + case string.split(haystack, needle) { 231 + [_] -> False 232 + _ -> True 233 + } 234 + }