A structured-data shell in Gleam, inspired by Nushell
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}