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/value.{
8 type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table,
9}
10
11pub 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`).
16pub fn render_with(on: Bool, value: Value) -> String {
17 case value {
18 Nothing -> ""
19 Fail(msg) -> color.error(on, "Error: " <> msg)
20 Table(cols, rows) -> render_table_with(on, cols, rows)
21 List(items) -> {
22 case list.all(items, is_record) {
23 True ->
24 case value.table_from_records(items) {
25 Table(c, r) -> render_table_with(on, c, r)
26 other -> render_value_block(on, other)
27 }
28 False -> render_list(on, items)
29 }
30 }
31 Record(fields) -> render_record(on, fields)
32 other -> color_cell(on, "", other, value.cell_string(other))
33 }
34}
35
36fn is_record(v: Value) -> Bool {
37 case v {
38 Record(_) -> True
39 _ -> False
40 }
41}
42
43fn render_value_block(on: Bool, v: Value) -> String {
44 color_cell(on, "", v, value.as_string(v))
45}
46
47fn render_list(on: Bool, items: List(Value)) -> String {
48 case items {
49 [] -> color.separator(on, "[]")
50 _ -> {
51 let body =
52 items
53 |> list.index_map(fn(item, i) {
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
58 })
59 |> string.join("\n")
60 let top = color.separator(on, "╭──── list ───")
61 let bot = color.separator(on, "╰────────────")
62 top <> "\n" <> body <> "\n" <> bot
63 }
64 }
65}
66
67fn render_record(on: Bool, fields: List(#(String, Value))) -> String {
68 case fields {
69 [] -> color.separator(on, "{}")
70 _ -> {
71 let key_w =
72 fields
73 |> list.map(fn(p) {
74 let #(k, _) = p
75 string.length(k)
76 })
77 |> list.fold(0, int.max)
78
79 let type_hint = case list.key_find(fields, "type") {
80 Ok(String(t)) -> t
81 _ -> ""
82 }
83 let lines =
84 list.map(fields, fn(pair) {
85 let #(k, v) = pair
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
91 })
92 let top = color.separator(on, "╭──── record ───")
93 let bot = color.separator(on, "╰──────────────")
94 top <> "\n" <> string.join(lines, "\n") <> "\n" <> bot
95 }
96 }
97}
98
99pub fn render_table(columns: List(String), rows: List(List(Value))) -> String {
100 render_table_with(color.enabled(), columns, rows)
101}
102
103fn render_table_with(
104 on: Bool,
105 columns: List(String),
106 rows: List(List(Value)),
107) -> String {
108 case columns {
109 [] -> color.nothing(on, "(empty table)")
110 _ -> {
111 let plain_cells: List(List(String)) =
112 list.map(rows, fn(row) { list.map(row, value.cell_string) })
113
114 let widths =
115 list.index_map(columns, fn(col, i) {
116 let header_w = string.length(col)
117 let data_w =
118 plain_cells
119 |> list.map(fn(row) {
120 case list_at(row, i) {
121 Ok(c) -> string.length(c)
122 Error(Nil) -> 0
123 }
124 })
125 |> list.fold(header_w, int.max)
126 int.max(data_w, 1)
127 })
128
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)
134 let body =
135 list.map2(rows, plain_cells, fn(row, plains) {
136 colored_data_line(on, columns, row, plains, widths)
137 })
138 |> string.join("\n")
139
140 case body {
141 "" -> top <> "\n" <> header <> "\n" <> bot
142 _ -> top <> "\n" <> header <> "\n" <> sep <> "\n" <> body <> "\n" <> bot
143 }
144 }
145 }
146}
147
148fn 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
161fn 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.
204fn 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
215fn list_index_of(items: List(String), target: String) -> Result(Int, Nil) {
216 list_index_of_loop(items, target, 0)
217}
218
219fn 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.
235fn color_cell(on: Bool, col: String, value: Value, plain: String) -> String {
236 color_cell_for_column(on, col, value, plain, "")
237}
238
239fn 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
254fn 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
263fn 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
272fn 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
284fn box_line(
285 widths: List(Int),
286 left: String,
287 mid: String,
288 right: String,
289 fill: String,
290) -> String {
291 let segments = list.map(widths, fn(w) { string.repeat(fill, w + 2) })
292 left <> string.join(segments, mid) <> right
293}
294
295fn pad_right(s: String, width: Int) -> String {
296 let len = string.length(s)
297 case len >= width {
298 True -> string.slice(s, 0, width)
299 False -> s <> string.repeat(" ", width - len)
300 }
301}
302
303fn list_at(items: List(a), index: Int) -> Result(a, Nil) {
304 case items, index {
305 [x, ..], 0 -> Ok(x)
306 [_, ..rest], n if n > 0 -> list_at(rest, n - 1)
307 _, _ -> Error(Nil)
308 }
309}
310
311/// Color-friendly error line for the REPL.
312pub fn render_error(msg: String) -> String {
313 color.error(color.enabled(), "✗ " <> msg)
314}