A structured-data shell in Gleam, inspired by Nushell
1//// Pretty-print structured values (Nushell-style tables + colors).
2
3import gleam/int
4import gleam/list
5import gleam/string
6import gleshell/color
7import gleshell/sys
8import gleshell/value.{
9 type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table,
10}
11
12pub fn render(value: Value) -> String {
13 render_with(color.enabled(), value)
14}
15
16/// Render with an explicit color switch (useful for tests / `NO_COLOR`).
17pub fn render_with(on: Bool, value: Value) -> String {
18 case value {
19 Nothing -> ""
20 Fail(msg) -> color.error(on, "Error: " <> msg)
21 // Opaque text from external tools (jj, git, …): keep their ANSI as-is.
22 // Multi-line output is almost always command text — do not paint it green.
23 String(s) -> render_string(on, s)
24 Table(cols, rows) -> render_table_with(on, cols, rows)
25 List(items) -> {
26 case list.all(items, is_record) {
27 True ->
28 case value.table_from_records(items) {
29 Table(c, r) -> render_table_with(on, c, r)
30 other -> render_value_block(on, other)
31 }
32 False -> render_list(on, items)
33 }
34 }
35 Record(fields) -> render_record(on, fields)
36 // Bare epoch seconds (e.g. `now`, `ls | get modified` on one row): print
37 // like the `modified` column — local 12-hour datetime, not a raw integer.
38 Int(n) -> render_bare_int(on, n)
39 other -> color_cell(on, "", other, value.cell_string(other))
40 }
41}
42
43/// Root-level ints that look like Unix timestamps display as local datetimes
44/// (same form as `ls` modified). Small ints and non-epoch values stay numeric.
45fn render_bare_int(on: Bool, n: Int) -> String {
46 case is_epoch_seconds(n) {
47 True -> color.datetime(on, format_datetime(n))
48 False -> color.int_(on, int.to_string(n))
49 }
50}
51
52/// Plausible wall-clock epoch seconds (2001-09-09 .. 2100-01-01).
53/// Used only for bare-value display so `now` and extracted mtimes read as times.
54fn is_epoch_seconds(n: Int) -> Bool {
55 n >= 1_000_000_000 && n < 4_102_444_800
56}
57
58/// External programs often embed their own colors. Pass those through.
59/// Short plain strings still get Nu-style string coloring.
60fn render_string(on: Bool, s: String) -> String {
61 case color.contains_ansi(s) || string.contains(s, "\n") {
62 True -> s
63 False -> color.string_(on, s)
64 }
65}
66
67fn is_record(v: Value) -> Bool {
68 case v {
69 Record(_) -> True
70 _ -> False
71 }
72}
73
74fn render_value_block(on: Bool, v: Value) -> String {
75 color_cell(on, "", v, value.as_string(v))
76}
77
78fn render_list(on: Bool, items: List(Value)) -> String {
79 case items {
80 [] -> color.separator(on, "[]")
81 _ -> {
82 let body =
83 items
84 |> list.index_map(fn(item, i) {
85 let idx = color.index(on, int.to_string(i))
86 let bar = color.separator(on, "│")
87 let cell = color_cell(on, "", item, value.cell_string(item))
88 " " <> idx <> " " <> bar <> " " <> cell
89 })
90 |> string.join("\n")
91 let top = color.separator(on, "╭──── list ───")
92 let bot = color.separator(on, "╰────────────")
93 top <> "\n" <> body <> "\n" <> bot
94 }
95 }
96}
97
98fn render_record(on: Bool, fields: List(#(String, Value))) -> String {
99 case fields {
100 [] -> color.separator(on, "{}")
101 _ -> {
102 let key_w =
103 fields
104 |> list.map(fn(p) {
105 let #(k, _) = p
106 string.length(k)
107 })
108 |> list.fold(0, int.max)
109
110 let type_hint = case list.key_find(fields, "type") {
111 Ok(String(t)) -> t
112 _ -> ""
113 }
114 let lines =
115 list.map(fields, fn(pair) {
116 let #(k, v) = pair
117 let key = color.key(on, pad_right(k, key_w))
118 let bar = color.separator(on, "│")
119 let plain = cell_plain(k, v)
120 let cell = color_cell_for_column(on, k, v, plain, type_hint)
121 " " <> key <> " " <> bar <> " " <> cell
122 })
123 let top = color.separator(on, "╭──── record ───")
124 let bot = color.separator(on, "╰──────────────")
125 top <> "\n" <> string.join(lines, "\n") <> "\n" <> bot
126 }
127 }
128}
129
130pub fn render_table(columns: List(String), rows: List(List(Value))) -> String {
131 render_table_with(color.enabled(), columns, rows)
132}
133
134fn render_table_with(
135 on: Bool,
136 columns: List(String),
137 rows: List(List(Value)),
138) -> String {
139 case columns {
140 [] -> color.nothing(on, "(empty table)")
141 _ -> {
142 // Column-aware plain text (e.g. size → KB/MB) for widths and coloring.
143 let plain_cells: List(List(String)) =
144 list.map(rows, fn(row) {
145 list.index_map(columns, fn(col, i) {
146 case list_at(row, i) {
147 Ok(v) -> cell_plain(col, v)
148 Error(Nil) -> ""
149 }
150 })
151 })
152
153 // Widths use visible length so cells with ANSI (from external tools)
154 // do not inflate the table.
155 let widths =
156 list.index_map(columns, fn(col, i) {
157 let header_w = color.visible_length(col)
158 let data_w =
159 plain_cells
160 |> list.map(fn(row) {
161 case list_at(row, i) {
162 Ok(c) -> color.visible_length(c)
163 Error(Nil) -> 0
164 }
165 })
166 |> list.fold(header_w, int.max)
167 int.max(data_w, 1)
168 })
169
170 let top = color.separator(on, box_line(widths, "╭", "┬", "╮", "─"))
171 let sep = color.separator(on, box_line(widths, "├", "┼", "┤", "─"))
172 let bot = color.separator(on, box_line(widths, "╰", "┴", "╯", "─"))
173 let header =
174 colored_header_line(on, columns, widths)
175 let body =
176 list.map2(rows, plain_cells, fn(row, plains) {
177 colored_data_line(on, columns, row, plains, widths)
178 })
179 |> string.join("\n")
180
181 case body {
182 "" -> top <> "\n" <> header <> "\n" <> bot
183 _ -> top <> "\n" <> header <> "\n" <> sep <> "\n" <> body <> "\n" <> bot
184 }
185 }
186 }
187}
188
189fn colored_header_line(
190 on: Bool,
191 columns: List(String),
192 widths: List(Int),
193) -> String {
194 let padded =
195 list.map2(columns, widths, fn(col, w) {
196 " " <> color.header(on, pad_right(col, w)) <> " "
197 })
198 let bar = color.separator(on, "│")
199 bar <> string.join(padded, bar) <> bar
200}
201
202fn colored_data_line(
203 on: Bool,
204 columns: List(String),
205 row: List(Value),
206 plains: List(String),
207 widths: List(Int),
208) -> String {
209 let type_hint = row_type_hint(columns, plains)
210 let cells =
211 list.index_map(columns, fn(col, i) {
212 let w = case list_at(widths, i) {
213 Ok(n) -> n
214 Error(Nil) -> 1
215 }
216 let plain = case list_at(plains, i) {
217 Ok(p) -> p
218 Error(Nil) -> ""
219 }
220 let val = case list_at(row, i) {
221 Ok(v) -> v
222 Error(Nil) -> Nothing
223 }
224 // Color the unpadded text, then add trailing spaces outside ANSI codes
225 // so type/name matchers see exact values ("dir", not "dir ").
226 let painted = color_cell_for_column(on, col, val, plain, type_hint)
227 let pad =
228 string.repeat(" ", int.max(0, w - color.visible_length(plain)))
229 " " <> painted <> pad <> " "
230 })
231 // Extra empty columns if widths longer than columns (shouldn't happen).
232 let cells = case list.length(cells) < list.length(widths) {
233 True -> {
234 let extra =
235 list.drop(widths, list.length(cells))
236 |> list.map(fn(w) { " " <> pad_right("", w) <> " " })
237 list.append(cells, extra)
238 }
239 False -> cells
240 }
241 let bar = color.separator(on, "│")
242 bar <> string.join(cells, bar) <> bar
243}
244
245/// Look up the `type` column value for ls-style name coloring.
246fn row_type_hint(columns: List(String), plains: List(String)) -> String {
247 case list_index_of(columns, "type") {
248 Ok(i) ->
249 case list_at(plains, i) {
250 Ok(t) -> t
251 Error(Nil) -> ""
252 }
253 Error(Nil) -> ""
254 }
255}
256
257fn list_index_of(items: List(String), target: String) -> Result(Int, Nil) {
258 list_index_of_loop(items, target, 0)
259}
260
261fn list_index_of_loop(
262 items: List(String),
263 target: String,
264 i: Int,
265) -> Result(Int, Nil) {
266 case items {
267 [] -> Error(Nil)
268 [x, ..rest] ->
269 case x == target {
270 True -> Ok(i)
271 False -> list_index_of_loop(rest, target, i + 1)
272 }
273 }
274}
275
276/// Plain cell text before coloring. Keeps pipeline data as raw ints; only
277/// display converts `size` byte counts to KB/MB/… and `modified` epoch
278/// seconds to a local datetime.
279fn cell_plain(col: String, value: Value) -> String {
280 case col, value {
281 "size", Int(n) -> format_filesize(n)
282 "mem", Int(n) -> format_filesize(n)
283 "virtual", Int(n) -> format_filesize(n)
284 "working", Int(n) -> format_filesize(n)
285 "paged", Int(n) -> format_filesize(n)
286 "modified", Int(n) -> format_datetime(n)
287 "start_time", Int(n) -> format_datetime(n)
288 _, _ -> value.cell_string(value)
289 }
290}
291
292/// Format Unix epoch seconds as local `Jul 3 2026 9:39:40 PM` (12-hour).
293pub fn format_datetime(unix_seconds: Int) -> String {
294 sys.format_unix_local(unix_seconds)
295}
296
297/// Format a byte count for display: B below 1 KiB, then KB / MB / GB / TB
298/// (1024-based). One decimal place when the fractional part is non-zero.
299pub fn format_filesize(bytes: Int) -> String {
300 let n = case bytes < 0 {
301 True -> 0
302 False -> bytes
303 }
304 case n < 1024 {
305 True -> int.to_string(n) <> " B"
306 False ->
307 case n < 1_048_576 {
308 True -> scale_unit(n, 1024, " KB")
309 False ->
310 case n < 1_073_741_824 {
311 True -> scale_unit(n, 1_048_576, " MB")
312 False ->
313 case n < 1_099_511_627_776 {
314 True -> scale_unit(n, 1_073_741_824, " GB")
315 False -> scale_unit(n, 1_099_511_627_776, " TB")
316 }
317 }
318 }
319 }
320}
321
322fn scale_unit(n: Int, unit: Int, suffix: String) -> String {
323 // tenths with half-up rounding: (n * 10 + unit/2) / unit
324 let tenths = { n * 10 + unit / 2 } / unit
325 let whole = tenths / 10
326 let frac = tenths % 10
327 case frac {
328 0 -> int.to_string(whole) <> suffix
329 _ -> int.to_string(whole) <> "." <> int.to_string(frac) <> suffix
330 }
331}
332
333/// Color a table/list/record cell by value type and optional column name.
334fn color_cell(on: Bool, col: String, value: Value, plain: String) -> String {
335 color_cell_for_column(on, col, value, plain, "")
336}
337
338fn color_cell_for_column(
339 on: Bool,
340 col: String,
341 value: Value,
342 plain: String,
343 type_hint: String,
344) -> String {
345 case col {
346 "name" -> color_path_name(on, plain, type_hint)
347 "type" -> color_entry_type(on, plain)
348 "size" | "mem" | "virtual" | "working" | "paged" ->
349 color.filesize(on, plain)
350 "modified" | "start_time" -> color.datetime(on, plain)
351 _ -> color_by_value(on, value, plain)
352 }
353}
354
355fn color_path_name(on: Bool, plain: String, type_hint: String) -> String {
356 case type_hint {
357 "dir" | "directory" -> color.dir_name(on, plain)
358 "symlink" | "link" -> color.symlink_name(on, plain)
359 "file" -> color.file_name(on, plain)
360 _ -> color.string_(on, plain)
361 }
362}
363
364fn color_entry_type(on: Bool, plain: String) -> String {
365 case plain {
366 "dir" | "directory" -> color.type_dir(on, plain)
367 "symlink" | "link" -> color.type_symlink(on, plain)
368 "file" -> color.type_file(on, plain)
369 _ -> color.string_(on, plain)
370 }
371}
372
373fn color_by_value(on: Bool, value: Value, plain: String) -> String {
374 case value {
375 Nothing -> color.nothing(on, plain)
376 Bool(_) -> color.bool_(on, plain)
377 Int(_) -> color.int_(on, plain)
378 Float(_) -> color.float_(on, plain)
379 // Preserve pre-colored external text inside tables/lists/records.
380 String(_) ->
381 case color.contains_ansi(plain) {
382 True -> plain
383 False -> color.string_(on, plain)
384 }
385 Fail(_) -> color.error(on, plain)
386 List(_) | Record(_) | Table(_, _) -> color.string_(on, plain)
387 }
388}
389
390fn box_line(
391 widths: List(Int),
392 left: String,
393 mid: String,
394 right: String,
395 fill: String,
396) -> String {
397 let segments = list.map(widths, fn(w) { string.repeat(fill, w + 2) })
398 left <> string.join(segments, mid) <> right
399}
400
401fn pad_right(s: String, width: Int) -> String {
402 let len = string.length(s)
403 case len >= width {
404 True -> string.slice(s, 0, width)
405 False -> s <> string.repeat(" ", width - len)
406 }
407}
408
409fn list_at(items: List(a), index: Int) -> Result(a, Nil) {
410 case items, index {
411 [x, ..], 0 -> Ok(x)
412 [_, ..rest], n if n > 0 -> list_at(rest, n - 1)
413 _, _ -> Error(Nil)
414 }
415}
416
417/// Color-friendly error line for the REPL.
418pub fn render_error(msg: String) -> String {
419 color.error(color.enabled(), "✗ " <> msg)
420}