From e8eaa3c22df900e93f57ef581a4aec558f58e021 Mon Sep 17 00:00:00 2001 From: nandi <78769380+codegod100@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:17:03 -0700 Subject: [PATCH] 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. --- README.md | 25 +++- src/gleshell.gleam | 11 +- src/gleshell/builtins.gleam | 149 ++++++++++++++++++---- src/gleshell/color.gleam | 209 +++++++++++++++++++++++++++++++ src/gleshell/display.gleam | 243 +++++++++++++++++++++++++++++------- src/gleshell/eval.gleam | 26 +++- src/gleshell/sys.gleam | 8 ++ src/gleshell_ffi.erl | 145 ++++++++++++++++++++- test/gleshell_test.gleam | 71 ++++++++++- 9 files changed, 802 insertions(+), 85 deletions(-) create mode 100644 src/gleshell/color.gleam diff --git a/README.md b/README.md index 1ce3093..7b71d27 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,20 @@ nix run . -- -c 'ls | first 3' gleam run ``` +### REPL editing + +The interactive REPL uses Erlang’s line editor (`edlin`): + +| Key | Action | +|-----|--------| +| ↑ / ↓ or Ctrl+P / Ctrl+N | History | +| **Ctrl+R** | Reverse-i-search through history | +| Ctrl+A / Ctrl+E | Beginning / end of line | +| Ctrl+W | Delete previous word | +| Ctrl+C / Ctrl+G | Cancel search / interrupt | + +History is persisted under the user cache as `gleshell-history` (OTP `shell_history`). + ## Examples ```nu @@ -43,8 +57,9 @@ range 5 | length # records and JSON echo {name: "gleshell", cool: true} -echo "{\"a\": 1}" | from-json | get a +echo "{\"a\": 1}" | from json | get a open data.json | get users | first +range 3 | to json # variables let n = range 3 | length @@ -77,7 +92,7 @@ Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` Table/list: `where`/`filter`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` -Data: `echo`, `range`, `lines`, `to-json`, `from-json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` +Data: `echo`, `range`, `lines`, `to json`, `from json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` Unknown command names fall through to external executables on `PATH`. @@ -86,16 +101,20 @@ Unknown command names fall through to external executables on `PATH`. ```text src/ gleshell.gleam # entry + REPL - gleshell_ffi.erl # readline, cwd, process spawn + gleshell_ffi.erl # line editor (Ctrl+R), cwd, process spawn gleshell/ value.gleam # structured Value type lexer.gleam / parser.gleam eval.gleam # pipeline evaluator builtins.gleam # Nu-inspired commands display.gleam # table pretty-printer + color.gleam # Nushell-style ANSI colors env.gleam / sys.gleam ``` +Output is colorized on a TTY (headers bold green, numbers purple, bools cyan, +dirs blue, errors red, …). Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. + ## Status Early but usable: core pipeline model, tables, filters, JSON, and external commands work. Not a full Nushell clone (no closures/plugins yet). Contributions and experiments welcome. diff --git a/src/gleshell.gleam b/src/gleshell.gleam index 9a5a53d..1ce6c7b 100644 --- a/src/gleshell.gleam +++ b/src/gleshell.gleam @@ -3,6 +3,7 @@ import argv import gleam/io import gleam/string +import gleshell/color import gleshell/display import gleshell/env import gleshell/eval @@ -20,7 +21,7 @@ pub fn main() -> Nil { io.println_error("gleshell: -c requires a command string") halt(2) } - [] -> repl(env.new()) + [] -> sys.run_as_shell(fn() { repl(env.new()) }) args -> run_once(string.join(args, " ")) } } @@ -62,7 +63,7 @@ fn run_once(code: String) -> Nil { fn repl(env: env.Env) -> Nil { io.println( - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit)", + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Ctrl+R reverse search)", ) repl_loop(env) } @@ -102,7 +103,11 @@ fn repl_loop(env: env.Env) -> Nil { fn prompt_for(env: env.Env) -> String { let base = basename(env.cwd) - "gleshell:" <> base <> "> " + let on = color.enabled() + color.prompt_name(on, "gleshell") + <> color.separator(on, ":") + <> color.prompt_path(on, base) + <> color.prompt_mark(on, "> ") } fn basename(path: String) -> String { diff --git a/src/gleshell/builtins.gleam b/src/gleshell/builtins.gleam index 975433e..5411ce8 100644 --- a/src/gleshell/builtins.gleam +++ b/src/gleshell/builtins.gleam @@ -52,10 +52,9 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("uniq", cmd_uniq), #("wrap", cmd_wrap), #("unwrap", cmd_unwrap), - #("to-json", cmd_to_json), - #("to_json", cmd_to_json), - #("from-json", cmd_from_json), - #("from_json", cmd_from_json), + // Nushell multi-word: `to json` / `from json` + #("to json", cmd_to_json), + #("from json", cmd_from_json), #("lines", cmd_lines), #("typeof", cmd_type), #("type", cmd_type), @@ -151,8 +150,14 @@ fn help_text() -> dict.Dict(String, String) { #("take", "take — take first n rows"), #("skip", "skip — skip first n rows"), #("echo", "echo … — emit values (list if multiple)"), - #("to-json", "to-json — convert input to JSON string"), - #("from-json", "from-json — parse JSON string input"), + #( + "to json", + "to json [--raw|-r] [--indent|-i n] — convert input to JSON string (pretty by default)", + ), + #( + "from json", + "from json — parse JSON string input into structured data", + ), #("range", "range | range — integer range list"), #("sys", "sys — host info record"), ]) @@ -709,15 +714,35 @@ fn cmd_unwrap( } } -// --- json --- +// --- json (mirrors Nushell `to json` / `from json`) --- fn cmd_to_json( env: Env, input: Value, _args: List(Value), - _flags: dict.Dict(String, Value), + flags: dict.Dict(String, Value), ) -> BuiltinResult { - ok(env, String(value_to_json_string(input))) + // Default: pretty-print with 2-space indent (like Nu). `--raw` / `-r` is compact. + let raw = flag_set(flags, "raw") || flag_set(flags, "r") + let indent = case raw { + True -> option.None + False -> + case flag_int(flags, "indent") { + option.Some(n) -> option.Some(n) + option.None -> + case flag_int(flags, "i") { + option.Some(n) -> option.Some(n) + option.None -> option.Some(2) + } + } + } + let body = encode_json(input, indent, 0) + // Nu's default includes a trailing newline; `--raw` omits it. + let text = case indent { + option.None -> body + option.Some(_) -> body <> "\n" + } + ok(env, String(text)) } fn cmd_from_json( @@ -736,16 +761,34 @@ fn cmd_from_json( _ -> "" } case source { - "" -> err(env, "from-json: empty input") + "" -> err(env, "from json: empty input") s -> case parse_json_value(s) { Ok(v) -> ok(env, v) - Error(msg) -> err(env, "from-json: " <> msg) + Error(msg) -> err(env, "from json: " <> msg) } } } -fn value_to_json_string(v: Value) -> String { +fn flag_set(flags: dict.Dict(String, Value), name: String) -> Bool { + case dict.get(flags, name) { + Ok(Bool(False)) -> False + Ok(Nothing) -> False + Ok(_) -> True + Error(Nil) -> False + } +} + +fn flag_int(flags: dict.Dict(String, Value), name: String) -> option.Option(Int) { + case dict.get(flags, name) { + Ok(Int(n)) -> option.Some(n) + _ -> option.None + } +} + +/// Encode a value as JSON. `indent` is `None` for compact (`--raw`), or +/// `Some(n)` for n-space pretty-print (Nushell default is 2). +fn encode_json(v: Value, indent: option.Option(Int), depth: Int) -> String { case v { Nothing -> "null" Bool(True) -> "true" @@ -753,26 +796,82 @@ fn value_to_json_string(v: Value) -> String { Int(n) -> int.to_string(n) Float(f) -> float.to_string(f) String(s) -> json_escape(s) - List(items) -> - "[" <> string.join(list.map(items, value_to_json_string), ",") <> "]" - Record(fields) -> - "{" - <> string.join( - list.map(fields, fn(pair) { - let #(k, val) = pair - json_escape(k) <> ":" <> value_to_json_string(val) - }), - ",", - ) - <> "}" + List(items) -> encode_json_array(items, indent, depth) + Record(fields) -> encode_json_object(fields, indent, depth) Table(cols, rows) -> { let records = list.map(rows, fn(row) { Record(list.zip(cols, row)) }) - value_to_json_string(List(records)) + encode_json(List(records), indent, depth) } Fail(msg) -> json_escape("error: " <> msg) } } +fn encode_json_array( + items: List(Value), + indent: option.Option(Int), + depth: Int, +) -> String { + case items { + [] -> "[]" + _ -> + case indent { + option.None -> + "[" + <> string.join(list.map(items, fn(i) { encode_json(i, indent, 0) }), ",") + <> "]" + option.Some(width) -> { + let inner = depth + 1 + let pad = string.repeat(" ", width * inner) + let close = string.repeat(" ", width * depth) + let body = + items + |> list.map(fn(i) { pad <> encode_json(i, indent, inner) }) + |> string.join(",\n") + "[\n" <> body <> "\n" <> close <> "]" + } + } + } +} + +fn encode_json_object( + fields: List(#(String, Value)), + indent: option.Option(Int), + depth: Int, +) -> String { + case fields { + [] -> "{}" + _ -> + case indent { + option.None -> + "{" + <> string.join( + list.map(fields, fn(pair) { + let #(k, val) = pair + json_escape(k) <> ":" <> encode_json(val, indent, 0) + }), + ",", + ) + <> "}" + option.Some(width) -> { + let inner = depth + 1 + let pad = string.repeat(" ", width * inner) + let close = string.repeat(" ", width * depth) + let body = + fields + |> list.map(fn(pair) { + let #(k, val) = pair + pad + <> json_escape(k) + <> ": " + <> encode_json(val, indent, inner) + }) + |> string.join(",\n") + "{\n" <> body <> "\n" <> close <> "}" + } + } + } +} + fn json_escape(s: String) -> String { let escaped = s diff --git a/src/gleshell/color.gleam b/src/gleshell/color.gleam new file mode 100644 index 0000000..09c37bb --- /dev/null +++ b/src/gleshell/color.gleam @@ -0,0 +1,209 @@ +//// Nushell-inspired ANSI colors for structured values. + +import gleam/string +import gleshell/sys + +const reset = "\u{001b}[0m" + +/// Bold green — table headers, record keys, list indices (Nu `header` / `row_index`). +const bold_green = "\u{001b}[1;32m" + +/// Green — strings (Nu `string` / `shape_string`). +const green = "\u{001b}[32m" + +/// Magenta / purple — ints & floats (Nu `int` / `float`). +const purple = "\u{001b}[35m" + +/// Bright cyan — bools (Nu `bool` / `light_cyan`). +const light_cyan = "\u{001b}[96m" + +/// Dark gray — nothing / empty (Nu `shape_nothing`). +const dark_gray = "\u{001b}[90m" + +/// Cyan — filesizes and similar (Nu `filesize`). +const cyan = "\u{001b}[36m" + +/// Blue — directories (common ls color). +const blue = "\u{001b}[34m" + +/// Bright blue — directory names in tables. +const bright_blue = "\u{001b}[94m" + +/// Bright cyan — symlinks. +const bright_cyan = "\u{001b}[96m" + +/// Bold red — errors. +const bold_red = "\u{001b}[1;31m" + +/// Dim — box-drawing separators. +const dim = "\u{001b}[2m" + +/// Bold — emphasis (prompt name). +const bold = "\u{001b}[1m" + +/// Whether ANSI color should be emitted. +/// +/// - Off when `NO_COLOR` is set to a non-empty value (https://no-color.org). +/// - On when `FORCE_COLOR` / `CLICOLOR_FORCE` is set to a non-empty, non-`0` value. +/// - Otherwise on only when stdout is a terminal. +pub fn enabled() -> Bool { + case sys.getenv("NO_COLOR") { + Ok(v) if v != "" -> False + _ -> + case force_color() { + True -> True + False -> sys.stdout_isatty() + } + } +} + +fn force_color() -> Bool { + case sys.getenv("FORCE_COLOR") { + Ok(v) -> is_force_value(v) + Error(Nil) -> + case sys.getenv("CLICOLOR_FORCE") { + Ok(v) -> is_force_value(v) + Error(Nil) -> False + } + } +} + +fn is_force_value(v: String) -> Bool { + case v { + "" | "0" | "false" | "False" | "no" | "No" -> False + _ -> True + } +} + +/// Wrap `text` in an ANSI code when colors are on. +pub fn paint(on: Bool, code: String, text: String) -> String { + case on { + True -> code <> text <> reset + False -> text + } +} + +pub fn header(on: Bool, text: String) -> String { + paint(on, bold_green, text) +} + +pub fn key(on: Bool, text: String) -> String { + paint(on, bold_green, text) +} + +pub fn index(on: Bool, text: String) -> String { + paint(on, bold_green, text) +} + +pub fn separator(on: Bool, text: String) -> String { + paint(on, dim, text) +} + +pub fn error(on: Bool, text: String) -> String { + paint(on, bold_red, text) +} + +pub fn int_(on: Bool, text: String) -> String { + paint(on, purple, text) +} + +pub fn float_(on: Bool, text: String) -> String { + paint(on, purple, text) +} + +pub fn bool_(on: Bool, text: String) -> String { + paint(on, light_cyan, text) +} + +pub fn string_(on: Bool, text: String) -> String { + paint(on, green, text) +} + +pub fn nothing(on: Bool, text: String) -> String { + paint(on, dark_gray, text) +} + +pub fn filesize(on: Bool, text: String) -> String { + paint(on, cyan, text) +} + +pub fn dir_name(on: Bool, text: String) -> String { + paint(on, bright_blue, text) +} + +pub fn file_name(on: Bool, text: String) -> String { + // Plain files stay default foreground (matches modern Nu); keep green as a + // mild highlight so bare strings still read as "string-like". + paint(on, green, text) +} + +pub fn symlink_name(on: Bool, text: String) -> String { + paint(on, bright_cyan, text) +} + +pub fn type_dir(on: Bool, text: String) -> String { + paint(on, blue, text) +} + +pub fn type_file(on: Bool, text: String) -> String { + paint(on, green, text) +} + +pub fn type_symlink(on: Bool, text: String) -> String { + paint(on, cyan, text) +} + +pub fn prompt_name(on: Bool, text: String) -> String { + paint(on, bold_green, text) +} + +pub fn prompt_path(on: Bool, text: String) -> String { + paint(on, bright_blue, text) +} + +pub fn prompt_mark(on: Bool, text: String) -> String { + paint(on, bold, text) +} + +/// Visible length ignoring ANSI CSI sequences (`ESC [ … final`). +pub fn visible_length(s: String) -> Int { + visible_length_loop(string.to_utf_codepoints(s), 0, AnsiNormal) +} + +type AnsiScan { + AnsiNormal + /// Saw ESC; next byte chooses the sequence kind. + AnsiEsc + /// Inside CSI (`ESC [` … final byte 0x40–0x7E). + AnsiCsi +} + +fn visible_length_loop( + codes: List(UtfCodepoint), + acc: Int, + state: AnsiScan, +) -> Int { + case codes, state { + [], _ -> acc + [c, ..rest], AnsiNormal -> + case string.utf_codepoint_to_int(c) == 0x1B { + True -> visible_length_loop(rest, acc, AnsiEsc) + False -> visible_length_loop(rest, acc + 1, AnsiNormal) + } + [c, ..rest], AnsiEsc -> + case string.utf_codepoint_to_int(c) { + // CSI introducer `[` — do not treat it as a final byte. + 0x5B -> visible_length_loop(rest, acc, AnsiCsi) + // Other ESC sequences: skip this single following byte. + _ -> visible_length_loop(rest, acc, AnsiNormal) + } + [c, ..rest], AnsiCsi -> { + let n = string.utf_codepoint_to_int(c) + // CSI final byte is in 0x40–0x7E (`@`..`~`). + case n >= 0x40 && n <= 0x7E { + True -> visible_length_loop(rest, acc, AnsiNormal) + False -> visible_length_loop(rest, acc, AnsiCsi) + } + } + } +} diff --git a/src/gleshell/display.gleam b/src/gleshell/display.gleam index 96d53eb..91c2af5 100644 --- a/src/gleshell/display.gleam +++ b/src/gleshell/display.gleam @@ -1,27 +1,35 @@ -//// Pretty-print structured values (Nushell-style tables). +//// Pretty-print structured values (Nushell-style tables + colors). import gleam/int import gleam/list import gleam/string -import gleshell/value.{type Value, Fail, List, Nothing, Record, Table} +import gleshell/color +import gleshell/value.{ + type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, +} pub fn render(value: Value) -> String { + render_with(color.enabled(), value) +} + +/// Render with an explicit color switch (useful for tests / `NO_COLOR`). +pub fn render_with(on: Bool, value: Value) -> String { case value { Nothing -> "" - Fail(msg) -> "Error: " <> msg - Table(cols, rows) -> render_table(cols, rows) + Fail(msg) -> color.error(on, "Error: " <> msg) + Table(cols, rows) -> render_table_with(on, cols, rows) List(items) -> { case list.all(items, is_record) { True -> case value.table_from_records(items) { - Table(c, r) -> render_table(c, r) - other -> render_value_block(other) + Table(c, r) -> render_table_with(on, c, r) + other -> render_value_block(on, other) } - False -> render_list(items) + False -> render_list(on, items) } } - Record(fields) -> render_record(fields) - other -> value.cell_string(other) + Record(fields) -> render_record(on, fields) + other -> color_cell(on, "", other, value.cell_string(other)) } } @@ -32,28 +40,33 @@ fn is_record(v: Value) -> Bool { } } -fn render_value_block(v: Value) -> String { - value.as_string(v) +fn render_value_block(on: Bool, v: Value) -> String { + color_cell(on, "", v, value.as_string(v)) } -fn render_list(items: List(Value)) -> String { +fn render_list(on: Bool, items: List(Value)) -> String { case items { - [] -> "[]" + [] -> color.separator(on, "[]") _ -> { let body = items |> list.index_map(fn(item, i) { - " " <> int.to_string(i) <> " │ " <> value.cell_string(item) + let idx = color.index(on, int.to_string(i)) + let bar = color.separator(on, "│") + let cell = color_cell(on, "", item, value.cell_string(item)) + " " <> idx <> " " <> bar <> " " <> cell }) |> string.join("\n") - "╭──── list ───\n" <> body <> "\n╰────────────" + let top = color.separator(on, "╭──── list ───") + let bot = color.separator(on, "╰────────────") + top <> "\n" <> body <> "\n" <> bot } } } -fn render_record(fields: List(#(String, Value))) -> String { +fn render_record(on: Bool, fields: List(#(String, Value))) -> String { case fields { - [] -> "{}" + [] -> color.separator(on, "{}") _ -> { let key_w = fields @@ -63,28 +76,46 @@ fn render_record(fields: List(#(String, Value))) -> String { }) |> list.fold(0, int.max) + let type_hint = case list.key_find(fields, "type") { + Ok(String(t)) -> t + _ -> "" + } let lines = list.map(fields, fn(pair) { let #(k, v) = pair - " " <> pad_right(k, key_w) <> " │ " <> value.cell_string(v) + let key = color.key(on, pad_right(k, key_w)) + let bar = color.separator(on, "│") + let cell = + color_cell_for_column(on, k, v, value.cell_string(v), type_hint) + " " <> key <> " " <> bar <> " " <> cell }) - "╭──── record ───\n" <> string.join(lines, "\n") <> "\n╰──────────────" + let top = color.separator(on, "╭──── record ───") + let bot = color.separator(on, "╰──────────────") + top <> "\n" <> string.join(lines, "\n") <> "\n" <> bot } } } pub fn render_table(columns: List(String), rows: List(List(Value))) -> String { + render_table_with(color.enabled(), columns, rows) +} + +fn render_table_with( + on: Bool, + columns: List(String), + rows: List(List(Value)), +) -> String { case columns { - [] -> "(empty table)" + [] -> color.nothing(on, "(empty table)") _ -> { - let cells: List(List(String)) = + let plain_cells: List(List(String)) = list.map(rows, fn(row) { list.map(row, value.cell_string) }) let widths = list.index_map(columns, fn(col, i) { let header_w = string.length(col) let data_w = - cells + plain_cells |> list.map(fn(row) { case list_at(row, i) { Ok(c) -> string.length(c) @@ -95,13 +126,15 @@ pub fn render_table(columns: List(String), rows: List(List(Value))) -> String { int.max(data_w, 1) }) - let top = box_line(widths, "╭", "┬", "╮", "─") - let sep = box_line(widths, "├", "┼", "┤", "─") - let bot = box_line(widths, "╰", "┴", "╯", "─") - let header = data_line(columns, widths) + let top = color.separator(on, box_line(widths, "╭", "┬", "╮", "─")) + let sep = color.separator(on, box_line(widths, "├", "┼", "┤", "─")) + let bot = color.separator(on, box_line(widths, "╰", "┴", "╯", "─")) + let header = + colored_header_line(on, columns, widths) let body = - cells - |> list.map(fn(row) { data_line(row, widths) }) + list.map2(rows, plain_cells, fn(row, plains) { + colored_data_line(on, columns, row, plains, widths) + }) |> string.join("\n") case body { @@ -112,31 +145,151 @@ pub fn render_table(columns: List(String), rows: List(List(Value))) -> String { } } -fn box_line( +fn colored_header_line( + on: Bool, + columns: List(String), widths: List(Int), - left: String, - mid: String, - right: String, - fill: String, ) -> String { - let segments = list.map(widths, fn(w) { string.repeat(fill, w + 2) }) - left <> string.join(segments, mid) <> right + let padded = + list.map2(columns, widths, fn(col, w) { + " " <> color.header(on, pad_right(col, w)) <> " " + }) + let bar = color.separator(on, "│") + bar <> string.join(padded, bar) <> bar } -fn data_line(cells: List(String), widths: List(Int)) -> String { - let padded = - list.map2(cells, widths, fn(cell, w) { " " <> pad_right(cell, w) <> " " }) - // if fewer cells than widths, pad - let padded = case list.length(padded) < list.length(widths) { +fn colored_data_line( + on: Bool, + columns: List(String), + row: List(Value), + plains: List(String), + widths: List(Int), +) -> String { + let type_hint = row_type_hint(columns, plains) + let cells = + list.index_map(columns, fn(col, i) { + let w = case list_at(widths, i) { + Ok(n) -> n + Error(Nil) -> 1 + } + let plain = case list_at(plains, i) { + Ok(p) -> p + Error(Nil) -> "" + } + let val = case list_at(row, i) { + Ok(v) -> v + Error(Nil) -> Nothing + } + // Color the unpadded text, then add trailing spaces outside ANSI codes + // so type/name matchers see exact values ("dir", not "dir "). + let painted = color_cell_for_column(on, col, val, plain, type_hint) + let pad = string.repeat(" ", int.max(0, w - string.length(plain))) + " " <> painted <> pad <> " " + }) + // Extra empty columns if widths longer than columns (shouldn't happen). + let cells = case list.length(cells) < list.length(widths) { True -> { let extra = - list.drop(widths, list.length(padded)) + list.drop(widths, list.length(cells)) |> list.map(fn(w) { " " <> pad_right("", w) <> " " }) - list.append(padded, extra) + list.append(cells, extra) } - False -> padded + False -> cells + } + let bar = color.separator(on, "│") + bar <> string.join(cells, bar) <> bar +} + +/// Look up the `type` column value for ls-style name coloring. +fn row_type_hint(columns: List(String), plains: List(String)) -> String { + case list_index_of(columns, "type") { + Ok(i) -> + case list_at(plains, i) { + Ok(t) -> t + Error(Nil) -> "" + } + Error(Nil) -> "" + } +} + +fn list_index_of(items: List(String), target: String) -> Result(Int, Nil) { + list_index_of_loop(items, target, 0) +} + +fn list_index_of_loop( + items: List(String), + target: String, + i: Int, +) -> Result(Int, Nil) { + case items { + [] -> Error(Nil) + [x, ..rest] -> + case x == target { + True -> Ok(i) + False -> list_index_of_loop(rest, target, i + 1) + } + } +} + +/// Color a table/list/record cell by value type and optional column name. +fn color_cell(on: Bool, col: String, value: Value, plain: String) -> String { + color_cell_for_column(on, col, value, plain, "") +} + +fn color_cell_for_column( + on: Bool, + col: String, + value: Value, + plain: String, + type_hint: String, +) -> String { + case col { + "name" -> color_path_name(on, plain, type_hint) + "type" -> color_entry_type(on, plain) + "size" -> color.filesize(on, plain) + _ -> color_by_value(on, value, plain) + } +} + +fn color_path_name(on: Bool, plain: String, type_hint: String) -> String { + case type_hint { + "dir" | "directory" -> color.dir_name(on, plain) + "symlink" | "link" -> color.symlink_name(on, plain) + "file" -> color.file_name(on, plain) + _ -> color.string_(on, plain) + } +} + +fn color_entry_type(on: Bool, plain: String) -> String { + case plain { + "dir" | "directory" -> color.type_dir(on, plain) + "symlink" | "link" -> color.type_symlink(on, plain) + "file" -> color.type_file(on, plain) + _ -> color.string_(on, plain) } - "│" <> string.join(padded, "│") <> "│" +} + +fn color_by_value(on: Bool, value: Value, plain: String) -> String { + case value { + Nothing -> color.nothing(on, plain) + Bool(_) -> color.bool_(on, plain) + Int(_) -> color.int_(on, plain) + Float(_) -> color.float_(on, plain) + String(_) -> color.string_(on, plain) + Fail(_) -> color.error(on, plain) + List(_) | Record(_) | Table(_, _) -> color.string_(on, plain) + } +} + +fn box_line( + widths: List(Int), + left: String, + mid: String, + right: String, + fill: String, +) -> String { + let segments = list.map(widths, fn(w) { string.repeat(fill, w + 2) }) + left <> string.join(segments, mid) <> right } fn pad_right(s: String, width: Int) -> String { @@ -157,5 +310,5 @@ fn list_at(items: List(a), index: Int) -> Result(a, Nil) { /// Color-friendly error line for the REPL. pub fn render_error(msg: String) -> String { - "✗ " <> msg + color.error(color.enabled(), "✗ " <> msg) } diff --git a/src/gleshell/eval.gleam b/src/gleshell/eval.gleam index 4aaa989..3e46572 100644 --- a/src/gleshell/eval.gleam +++ b/src/gleshell/eval.gleam @@ -72,9 +72,9 @@ fn eval_command(env: Env, cmd: Command, input: Value) -> EvalResult { case external { True -> run_external(env, name, pos) False -> - case dict.get(builtins.registry(), name) { - Ok(builtin) -> - case builtin(env, input, pos, flags) { + case resolve_builtin(name, pos) { + Ok(#(builtin, pos2)) -> + case builtin(env, input, pos2, flags) { builtins.Exit(code) -> Quit(code) builtins.BuiltinResult(env2, value) -> { let env2 = case value { @@ -93,6 +93,26 @@ fn eval_command(env: Env, cmd: Command, input: Value) -> EvalResult { } } +/// Look up a builtin, including Nushell-style multi-word names (`to json`). +/// When `name` alone is missing, try consuming a following bare string arg. +fn resolve_builtin( + name: String, + pos: List(Value), +) -> Result(#(builtins.Builtin, List(Value)), Nil) { + case dict.get(builtins.registry(), name) { + Ok(builtin) -> Ok(#(builtin, pos)) + Error(Nil) -> + case pos { + [String(sub), ..rest] -> + case dict.get(builtins.registry(), name <> " " <> sub) { + Ok(builtin) -> Ok(#(builtin, rest)) + Error(Nil) -> Error(Nil) + } + _ -> Error(Nil) + } + } +} + fn eval_args( env: Env, args: List(Arg), diff --git a/src/gleshell/sys.gleam b/src/gleshell/sys.gleam index adcab94..725d76d 100644 --- a/src/gleshell/sys.gleam +++ b/src/gleshell/sys.gleam @@ -3,6 +3,11 @@ @external(erlang, "gleshell_ffi", "get_line") pub fn get_line(prompt: String) -> Result(String, String) +/// Run `body` as the OTP interactive shell process so the REPL gets +/// edlin line editing: history (up/down) and Ctrl+R reverse-i-search. +@external(erlang, "gleshell_ffi", "run_as_shell") +pub fn run_as_shell(body: fn() -> Nil) -> Nil + @external(erlang, "gleshell_ffi", "set_cwd") pub fn set_cwd(path: String) -> Result(Nil, String) @@ -26,3 +31,6 @@ pub fn which(command: String) -> Result(String, Nil) @external(erlang, "gleshell_ffi", "home_dir") pub fn home_dir() -> Result(String, String) + +@external(erlang, "gleshell_ffi", "stdout_isatty") +pub fn stdout_isatty() -> Bool diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index 3931d43..48a2fa3 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -2,20 +2,31 @@ -module(gleshell_ffi). -export([ get_line/1, + parse_line/2, + run_as_shell/1, + spawn_shell/2, set_cwd/1, get_cwd/0, getenv/1, setenv/2, run_cmd/2, which/1, - home_dir/0 + home_dir/0, + stdout_isatty/0 ]). +%% Read a line with edlin history support. +%% +%% Use get_until (not get_line): since OTP 26, io:get_line/1 input is not +%% reliably saved in the shell history buffer; get_until is. See OTP #6896 +%% and the custom-shell guide. -spec get_line(binary()) -> {ok, binary()} | {error, binary()}. get_line(Prompt) when is_binary(Prompt) -> - %% OTP may return a charlist or a UTF-8 binary depending on the - %% standard_io encoding / binary options — accept both. - case io:get_line(unicode:characters_to_list(Prompt)) of + PromptChars = unicode:characters_to_list(Prompt), + case io:request( + standard_io, + {get_until, unicode, PromptChars, ?MODULE, parse_line, []} + ) of eof -> {error, <<"eof">>}; {error, _} -> @@ -23,7 +34,113 @@ get_line(Prompt) when is_binary(Prompt) -> Line when is_list(Line); is_binary(Line) -> Bin = unicode:characters_to_binary(Line), Stripped = string:trim(Bin, trailing, [$\n, $\r]), - {ok, Stripped} + {ok, Stripped}; + Other -> + %% Unexpected shape from a custom/get_until callback. + try + Bin = unicode:characters_to_binary(Other), + Stripped = string:trim(Bin, trailing, [$\n, $\r]), + {ok, Stripped} + catch + _:_ -> + {error, <<"io_error">>} + end + end. + +%% get_until callback: edlin already gathers a full line (with editing / +%% history navigation); accept it as done. Cont starts as []. +-spec parse_line(term(), term()) -> + {done, eof | string(), list()} | {more, term()}. +parse_line(_Cont, eof) -> + {done, eof, []}; +parse_line(_Cont, Chars) when is_list(Chars) -> + {done, Chars, []}. + +%% Run Fun as the OTP interactive shell process so edlin line editing +%% works: up/down history, Ctrl+R reverse-i-search, word kill, etc. +%% Gleam starts the VM with -noshell, so without this we only get dumb +%% line input and no reverse search. +-spec run_as_shell(fun(() -> term())) -> nil. +run_as_shell(Fun) when is_function(Fun, 0) -> + enable_shell_history(), + Parent = self(), + case try_start_interactive(Parent, Fun) of + {ok, started} -> + receive + {gleshell_shell_done, ok} -> + nil; + {gleshell_shell_done, {error, Class, Reason, Stack}} -> + erlang:raise(Class, Reason, Stack) + end; + {ok, direct} -> + configure_line_editor(), + Fun(), + nil + end. + +try_start_interactive(Parent, Fun) -> + %% Empty slogan so we do not print the Erlang system banner. + _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), + case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of + ok -> + {ok, started}; + {error, already_started} -> + {ok, direct}; + {error, _} -> + {ok, direct} + end. + +%% MFA entry for user_drv/group: must return the shell pid. Spawned +%% under the group so group_leader is the edlin-enabled group. +-spec spawn_shell(pid(), fun(() -> term())) -> pid(). +spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> + spawn(fun() -> + try + configure_line_editor(), + Fun() + of + _ -> + Parent ! {gleshell_shell_done, ok}, + %% Intentional exit reason so user_drv does not print + %% "Shell process terminated!" and restart us. + exit(die) + catch + Class:Reason:Stack -> + Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, + erlang:raise(Class, Reason, Stack) + end + end). + +%% Best-effort: unicode IO + save get_until lines into edlin history. +configure_line_editor() -> + _ = io:setopts([{encoding, unicode}, binary]), + try + io:setopts([{line_history, true}]) + catch + _:_ -> + ok + end, + ok. + +enable_shell_history() -> + case application:get_env(kernel, shell_history_path) of + {ok, _} -> + ok; + undefined -> + Path = filename:basedir(user_cache, "gleshell-history"), + _ = application:set_env( + kernel, shell_history_path, Path, [{persistent, true}] + ), + ok + end, + case application:get_env(kernel, shell_history) of + {ok, _} -> + ok; + undefined -> + _ = application:set_env( + kernel, shell_history, enabled, [{persistent, true}] + ), + ok end. -spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. @@ -76,6 +193,24 @@ home_dir() -> {ok, unicode:characters_to_binary(Home)} end. +%% True when stdout is a terminal (colors are useful). +-spec stdout_isatty() -> boolean(). +stdout_isatty() -> + case io:columns() of + {ok, _} -> + true; + _ -> + try + case prim_tty:isatty(stdout) of + true -> true; + _ -> false + end + catch + _:_ -> + false + end + end. + %% Run an executable with args; capture stdout+stderr and exit status. %% Returns {ok, {Status :: integer(), Output :: binary()}} | {error, binary()}. -spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index df76378..2608d5a 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -1,4 +1,7 @@ +import gleam/string import gleeunit +import gleshell/color +import gleshell/display import gleshell/env import gleshell/eval import gleshell/lexer @@ -131,11 +134,34 @@ pub fn eval_where_select_test() { pub fn eval_from_json_test() { let env = env.new() let assert eval.Continue(_, Record(fields)) = - eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from-json") + eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from json") let assert True = list_has_field(fields, "x", Int(1)) Nil } +pub fn eval_to_json_pretty_test() { + let env = env.new() + // Nushell-style multi-word `to json` — pretty by default + let assert eval.Continue(_, String(pretty)) = + eval.eval_source(env, "echo [1 2 3] | to json") + let assert True = string.contains(pretty, "\n") + let assert True = string.contains(pretty, "1") + // `--raw` matches Nu: compact, no trailing newline + let assert eval.Continue(_, String(raw)) = + eval.eval_source(env, "echo [1 2 3] | to json --raw") + let assert "[1,2,3]" = raw + Nil +} + +pub fn eval_to_json_record_test() { + let env = env.new() + let assert eval.Continue(_, String(raw)) = + eval.eval_source(env, "echo {a: 1, b: true} | to json -r") + let assert True = string.contains(raw, "\"a\":1") + let assert True = string.contains(raw, "\"b\":true") + Nil +} + fn list_has_field( fields: List(#(String, value.Value)), key: String, @@ -163,3 +189,46 @@ pub fn value_nothing_falsey_test() { let assert True = value.is_truthy(Int(1)) Nil } + +// --- display / color --- + +pub fn display_plain_no_ansi_test() { + let text = display.render_with(False, Bool(True)) + let assert "true" = text + let assert False = string_contains(text, "\u{001b}") + Nil +} + +pub fn display_colored_has_ansi_test() { + let text = display.render_with(True, Bool(True)) + let assert True = string_contains(text, "\u{001b}") + let assert True = string_contains(text, "true") + Nil +} + +pub fn display_table_headers_colored_test() { + let text = + display.render_with( + True, + Table(["name", "type"], [[String("src"), String("dir")]]), + ) + // bold green header + bright-blue dir name + let assert True = string_contains(text, "\u{001b}[1;32m") + let assert True = string_contains(text, "\u{001b}[94m") + let assert True = string_contains(text, "src") + Nil +} + +pub fn color_visible_length_strips_ansi_test() { + let painted = color.paint(True, "\u{001b}[32m", "hi") + let assert 2 = color.visible_length(painted) + let assert 2 = color.visible_length("hi") + Nil +} + +fn string_contains(haystack: String, needle: String) -> Bool { + case string.split(haystack, needle) { + [_] -> False + _ -> True + } +} -- 2.51.2