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 / highlight.gleam
11 kB 467 lines
1//// Live syntax highlighting for the REPL input line (Nushell-style shapes). 2 3import gleam/list 4import gleam/string 5import gleshell/builtins 6import gleshell/color 7 8/// Colorize a (possibly incomplete) input line for the line editor. 9/// Safe to call on every keystroke; never fails. 10pub fn line(source: String) -> String { 11 highlight(color.enabled(), source) 12} 13 14/// Colorize with an explicit on/off switch (tests / `NO_COLOR`). 15pub fn highlight(on: Bool, source: String) -> String { 16 case on { 17 False -> source 18 True -> { 19 let builtins = builtins.names() 20 paint_chars(string.to_graphemes(source), ExpectCommand, builtins, "") 21 } 22 } 23} 24 25type Expect { 26 ExpectCommand 27 ExpectArg 28} 29 30fn paint_chars( 31 chars: List(String), 32 expect: Expect, 33 builtins: List(String), 34 acc: String, 35) -> String { 36 case chars { 37 [] -> acc 38 [" ", ..rest] -> paint_chars(rest, expect, builtins, acc <> " ") 39 ["\t", ..rest] -> paint_chars(rest, expect, builtins, acc <> "\t") 40 ["\r", ..rest] | ["\n", ..rest] -> paint_chars(rest, expect, builtins, acc) 41 ["#", ..rest] -> { 42 let text = "#" <> string.concat(rest) 43 acc <> color.shape_comment(True, text) 44 } 45 ["|", ..rest] -> 46 paint_chars( 47 rest, 48 ExpectCommand, 49 builtins, 50 acc <> color.shape_pipe(True, "|"), 51 ) 52 ["^", ..rest] -> 53 // Force external: next command word is external 54 paint_after_external_mark(rest, builtins, acc <> color.shape_operator(True, "^")) 55 ["$", ..rest] -> paint_variable(rest, expect, builtins, acc) 56 ["\"", ..rest] -> paint_dq_string(rest, expect, builtins, acc) 57 ["'", ..rest] -> paint_sq_string(rest, expect, builtins, acc) 58 ["[", ..rest] -> 59 paint_chars( 60 rest, 61 ExpectArg, 62 builtins, 63 acc <> color.shape_list(True, "["), 64 ) 65 ["]", ..rest] -> 66 paint_chars( 67 rest, 68 ExpectArg, 69 builtins, 70 acc <> color.shape_list(True, "]"), 71 ) 72 ["{", ..rest] -> 73 paint_chars( 74 rest, 75 ExpectArg, 76 builtins, 77 acc <> color.shape_record(True, "{"), 78 ) 79 ["}", ..rest] -> 80 paint_chars( 81 rest, 82 ExpectArg, 83 builtins, 84 acc <> color.shape_record(True, "}"), 85 ) 86 ["(", ..rest] -> 87 paint_chars( 88 rest, 89 ExpectArg, 90 builtins, 91 acc <> color.shape_operator(True, "("), 92 ) 93 [")", ..rest] -> 94 paint_chars( 95 rest, 96 ExpectArg, 97 builtins, 98 acc <> color.shape_operator(True, ")"), 99 ) 100 [":", ..rest] -> 101 paint_chars( 102 rest, 103 ExpectArg, 104 builtins, 105 acc <> color.shape_operator(True, ":"), 106 ) 107 [",", ..rest] -> 108 paint_chars( 109 rest, 110 ExpectArg, 111 builtins, 112 acc <> color.shape_operator(True, ","), 113 ) 114 ["!", "=", ..rest] -> 115 paint_chars( 116 rest, 117 ExpectArg, 118 builtins, 119 acc <> color.shape_operator(True, "!="), 120 ) 121 [">", "=", ..rest] -> 122 paint_chars( 123 rest, 124 ExpectArg, 125 builtins, 126 acc <> color.shape_operator(True, ">="), 127 ) 128 ["<", "=", ..rest] -> 129 paint_chars( 130 rest, 131 ExpectArg, 132 builtins, 133 acc <> color.shape_operator(True, "<="), 134 ) 135 ["=", "=", ..rest] -> 136 paint_chars( 137 rest, 138 ExpectArg, 139 builtins, 140 acc <> color.shape_operator(True, "=="), 141 ) 142 ["=", ..rest] -> 143 paint_chars( 144 rest, 145 ExpectArg, 146 builtins, 147 acc <> color.shape_operator(True, "="), 148 ) 149 [">", ..rest] -> 150 paint_chars( 151 rest, 152 ExpectArg, 153 builtins, 154 acc <> color.shape_operator(True, ">"), 155 ) 156 ["<", ..rest] -> 157 paint_chars( 158 rest, 159 ExpectArg, 160 builtins, 161 acc <> color.shape_operator(True, "<"), 162 ) 163 ["-", "-", ..rest] -> paint_flag(rest, "--", expect, builtins, acc) 164 ["-", d, ..rest] -> 165 case is_digit(d) { 166 True -> paint_number(["-", d, ..rest], expect, builtins, acc) 167 False -> paint_flag([d, ..rest], "-", expect, builtins, acc) 168 } 169 [c, ..] -> 170 case is_digit(c) { 171 True -> paint_number(chars, expect, builtins, acc) 172 False -> 173 case is_ident_start(c) { 174 True -> paint_ident(chars, expect, builtins, acc) 175 False -> 176 // Unknown char — mark as garbage and continue 177 paint_chars( 178 list.drop(chars, 1), 179 expect, 180 builtins, 181 acc <> color.shape_garbage(True, c), 182 ) 183 } 184 } 185 } 186} 187 188fn paint_after_external_mark( 189 chars: List(String), 190 builtins: List(String), 191 acc: String, 192) -> String { 193 case chars { 194 [" ", ..rest] -> paint_after_external_mark(rest, builtins, acc <> " ") 195 ["\t", ..rest] -> paint_after_external_mark(rest, builtins, acc <> "\t") 196 [] -> acc 197 _ -> { 198 let #(word, after) = take_ident(chars) 199 case word { 200 "" -> paint_chars(chars, ExpectArg, builtins, acc) 201 w -> 202 paint_chars( 203 after, 204 ExpectArg, 205 builtins, 206 acc <> color.shape_external(True, w), 207 ) 208 } 209 } 210 } 211} 212 213fn paint_variable( 214 chars: List(String), 215 expect: Expect, 216 builtins: List(String), 217 acc: String, 218) -> String { 219 let #(name, after) = take_ident(chars) 220 let painted = color.shape_variable(True, "$" <> name) 221 paint_chars(after, next_expect(expect), builtins, acc <> painted) 222} 223 224fn paint_dq_string( 225 chars: List(String), 226 expect: Expect, 227 builtins: List(String), 228 acc: String, 229) -> String { 230 let #(body, after, closed) = take_dq_body(chars, "") 231 let painted = case closed { 232 True -> color.shape_string(True, "\"" <> body <> "\"") 233 False -> color.shape_string(True, "\"" <> body) 234 } 235 paint_chars(after, next_expect(expect), builtins, acc <> painted) 236} 237 238fn take_dq_body( 239 chars: List(String), 240 acc: String, 241) -> #(String, List(String), Bool) { 242 case chars { 243 [] -> #(acc, [], False) 244 ["\"", ..rest] -> #(acc, rest, True) 245 ["\\", c, ..rest] -> take_dq_body(rest, acc <> "\\" <> c) 246 ["\\"] -> #(acc <> "\\", [], False) 247 [c, ..rest] -> take_dq_body(rest, acc <> c) 248 } 249} 250 251fn paint_sq_string( 252 chars: List(String), 253 expect: Expect, 254 builtins: List(String), 255 acc: String, 256) -> String { 257 let #(body, after, closed) = take_sq_body(chars, "") 258 let painted = case closed { 259 True -> color.shape_string(True, "'" <> body <> "'") 260 False -> color.shape_string(True, "'" <> body) 261 } 262 paint_chars(after, next_expect(expect), builtins, acc <> painted) 263} 264 265fn take_sq_body( 266 chars: List(String), 267 acc: String, 268) -> #(String, List(String), Bool) { 269 case chars { 270 [] -> #(acc, [], False) 271 ["'", ..rest] -> #(acc, rest, True) 272 ["\\", c, ..rest] -> take_sq_body(rest, acc <> "\\" <> c) 273 ["\\"] -> #(acc <> "\\", [], False) 274 [c, ..rest] -> take_sq_body(rest, acc <> c) 275 } 276} 277 278fn paint_flag( 279 chars: List(String), 280 prefix: String, 281 expect: Expect, 282 builtins: List(String), 283 acc: String, 284) -> String { 285 let #(name, after) = take_ident(chars) 286 let painted = color.shape_flag(True, prefix <> name) 287 paint_chars(after, next_expect(expect), builtins, acc <> painted) 288} 289 290fn paint_number( 291 chars: List(String), 292 expect: Expect, 293 builtins: List(String), 294 acc: String, 295) -> String { 296 let #(num, after, is_float) = take_number(chars) 297 let painted = case is_float { 298 True -> color.shape_float(True, num) 299 False -> color.shape_int(True, num) 300 } 301 paint_chars(after, next_expect(expect), builtins, acc <> painted) 302} 303 304fn paint_ident( 305 chars: List(String), 306 expect: Expect, 307 builtins: List(String), 308 acc: String, 309) -> String { 310 let #(word, after) = take_ident(chars) 311 case word { 312 "true" | "True" | "false" | "False" -> 313 paint_chars( 314 after, 315 next_expect(expect), 316 builtins, 317 acc <> color.shape_bool(True, word), 318 ) 319 "null" | "nothing" | "Nothing" -> 320 paint_chars( 321 after, 322 next_expect(expect), 323 builtins, 324 acc <> color.shape_nothing(True, word), 325 ) 326 "let" -> 327 case expect { 328 ExpectCommand -> 329 paint_chars( 330 after, 331 ExpectArg, 332 builtins, 333 acc <> color.shape_keyword(True, word), 334 ) 335 ExpectArg -> 336 paint_chars( 337 after, 338 ExpectArg, 339 builtins, 340 acc <> color.shape_externalarg(True, word), 341 ) 342 } 343 _ -> 344 case expect { 345 ExpectCommand -> paint_command(word, after, builtins, acc) 346 ExpectArg -> 347 paint_chars( 348 after, 349 ExpectArg, 350 builtins, 351 acc <> color.shape_externalarg(True, word), 352 ) 353 } 354 } 355} 356 357fn paint_command( 358 word: String, 359 after: List(String), 360 builtins: List(String), 361 acc: String, 362) -> String { 363 let painted = case list.contains(builtins, word) { 364 True -> color.shape_internalcall(True, word) 365 False -> color.shape_external(True, word) 366 } 367 paint_chars(after, ExpectArg, builtins, acc <> painted) 368} 369 370fn next_expect(expect: Expect) -> Expect { 371 case expect { 372 ExpectCommand -> ExpectArg 373 ExpectArg -> ExpectArg 374 } 375} 376 377fn take_ident(chars: List(String)) -> #(String, List(String)) { 378 take_ident_loop(chars, "") 379} 380 381fn take_ident_loop(chars: List(String), acc: String) -> #(String, List(String)) { 382 case chars { 383 [c, ..rest] -> 384 case is_ident_continue(c) { 385 True -> take_ident_loop(rest, acc <> c) 386 False -> #(acc, chars) 387 } 388 [] -> #(acc, []) 389 } 390} 391 392fn take_number(chars: List(String)) -> #(String, List(String), Bool) { 393 case chars { 394 ["-", ..rest] -> take_number_loop(rest, "-", False) 395 _ -> take_number_loop(chars, "", False) 396 } 397} 398 399fn take_number_loop( 400 chars: List(String), 401 acc: String, 402 seen_dot: Bool, 403) -> #(String, List(String), Bool) { 404 case chars { 405 [c, ..rest] -> 406 case is_digit(c) { 407 True -> take_number_loop(rest, acc <> c, seen_dot) 408 False -> 409 case c == "." && !seen_dot { 410 True -> take_number_loop(rest, acc <> c, True) 411 False -> #(acc, chars, seen_dot) 412 } 413 } 414 [] -> #(acc, [], seen_dot) 415 } 416} 417 418fn is_digit(c: String) -> Bool { 419 case c { 420 "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 421 _ -> False 422 } 423} 424 425fn is_ident_start(c: String) -> Bool { 426 case c { 427 // Paths: `.jj`, `..`, `./src`, `/tmp`, `~/code` (must match lexer) 428 "_" | "." | "/" | "~" -> True 429 _ -> { 430 let lower = string.lowercase(c) 431 case lower { 432 "a" 433 | "b" 434 | "c" 435 | "d" 436 | "e" 437 | "f" 438 | "g" 439 | "h" 440 | "i" 441 | "j" 442 | "k" 443 | "l" 444 | "m" 445 | "n" 446 | "o" 447 | "p" 448 | "q" 449 | "r" 450 | "s" 451 | "t" 452 | "u" 453 | "v" 454 | "w" 455 | "x" 456 | "y" 457 | "z" -> True 458 _ -> False 459 } 460 } 461 } 462} 463 464fn is_ident_continue(c: String) -> Bool { 465 // Path-ish chars covered by is_ident_start (`.` `/` `~`); keep `-` mid-token. 466 is_ident_start(c) || is_digit(c) || c == "-" 467}