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

Configure Feed

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

gleshell / src / gleshell / display.gleam
13 kB 420 lines
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}