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
8.8 kB 314 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/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}