A structured-data shell in Gleam, inspired by Nushell
1import gleam/string
2import gleeunit
3import gleshell/color
4import gleshell/display
5import gleshell/env
6import gleshell/eval
7import gleshell/highlight
8import gleshell/lexer
9import gleshell/parser
10import gleshell/sys
11import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table}
12
13pub fn main() -> Nil {
14 gleeunit.main()
15}
16
17// --- lexer ---
18
19pub fn lexer_pipeline_test() {
20 let assert Ok(tokens) = lexer.tokenize("ls | where type == file")
21 let assert [lexer.Ident("ls"), lexer.Pipe, lexer.Ident("where"), ..] = tokens
22 Nil
23}
24
25pub fn lexer_string_and_number_test() {
26 let assert Ok(tokens) = lexer.tokenize("echo \"hi\" 42 3.14 true")
27 let assert [
28 lexer.Ident("echo"),
29 lexer.StringLit("hi"),
30 lexer.IntLit(42),
31 lexer.FloatLit(3.14),
32 lexer.BoolLit(True),
33 lexer.Eof,
34 ] = tokens
35 Nil
36}
37
38// --- parser ---
39
40pub fn parse_pipeline_test() {
41 let assert Ok(parser.Expr(parser.Pipeline(cmds))) =
42 parser.parse("ls | first 3")
43 let assert [
44 parser.Command("ls", [], False),
45 parser.Command("first", args, False),
46 ] = cmds
47 let assert [parser.ValueArg(parser.Lit(Int(3)))] = args
48 Nil
49}
50
51pub fn parse_let_test() {
52 let assert Ok(parser.Let(
53 "x",
54 parser.Pipeline([parser.Command("echo", args, False)]),
55 )) = parser.parse("let x = echo 1")
56 let assert [parser.ValueArg(parser.Lit(Int(1)))] = args
57 Nil
58}
59
60pub fn parse_where_ops_test() {
61 let assert Ok(parser.Expr(parser.Pipeline([
62 parser.Command("where", args, False),
63 ]))) = parser.parse("where type == file")
64 let assert [
65 parser.ValueArg(parser.Lit(String("type"))),
66 parser.ValueArg(parser.Lit(String("=="))),
67 parser.ValueArg(parser.Lit(String("file"))),
68 ] = args
69 Nil
70}
71
72pub fn parse_list_and_record_test() {
73 let assert Ok(parser.Expr(parser.Pipeline([
74 parser.Command("echo", args, False),
75 ]))) = parser.parse("echo [1 2] {a: true}")
76 let assert [
77 parser.ValueArg(parser.ListExpr([parser.Lit(Int(1)), parser.Lit(Int(2))])),
78 parser.ValueArg(parser.RecordExpr([#("a", parser.Lit(Bool(True)))])),
79 ] = args
80 Nil
81}
82
83// --- value helpers ---
84
85pub fn table_from_records_test() {
86 let rows = [
87 Record([#("name", String("a")), #("n", Int(1))]),
88 Record([#("name", String("b")), #("n", Int(2))]),
89 ]
90 let assert Table(
91 ["name", "n"],
92 [[String("a"), Int(1)], [String("b"), Int(2)]],
93 ) = value.table_from_records(rows)
94 Nil
95}
96
97// --- eval ---
98
99pub fn eval_echo_and_range_test() {
100 let env = env.new()
101 let assert eval.Continue(_, List([Int(0), Int(1), Int(2)])) =
102 eval.eval_source(env, "range 3")
103 let assert eval.Continue(_, String("hello")) =
104 eval.eval_source(env, "echo hello")
105 Nil
106}
107
108pub fn eval_pipeline_reverse_first_test() {
109 let env = env.new()
110 let assert eval.Continue(_, Int(2)) =
111 eval.eval_source(env, "range 3 | reverse | first")
112 Nil
113}
114
115pub fn eval_let_and_var_test() {
116 let env = env.new()
117 let assert eval.Continue(env2, Int(7)) =
118 eval.eval_source(env, "let n = echo 7")
119 let assert eval.Continue(_, Int(7)) = eval.eval_source(env2, "echo $n")
120 Nil
121}
122
123pub fn eval_env_var_get_test() {
124 // `$env.HOME` and bare `$env.HOME` as pipeline source
125 let env = env.new()
126 let assert Ok(home) = sys.getenv("HOME")
127 let assert eval.Continue(_, String(got)) =
128 eval.eval_source(env, "echo $env.HOME")
129 let assert True = got == home
130 let assert eval.Continue(_, String(got2)) = eval.eval_source(env, "$env.HOME")
131 let assert True = got2 == home
132 Nil
133}
134
135pub fn eval_env_record_test() {
136 let env = env.new()
137 let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env")
138 let assert True = list_has_string_field(fields, "HOME")
139 let assert True = list_has_string_field(fields, "PATH")
140 // `$env | get HOME`
141 let assert eval.Continue(_, String(home)) =
142 eval.eval_source(env, "$env | get HOME")
143 let assert True = string.length(home) > 0
144 Nil
145}
146
147pub fn eval_env_assign_test() {
148 let env = env.new()
149 let assert eval.Continue(env2, String("gleshell-test-val")) =
150 eval.eval_source(env, "$env.GLESHELL_TEST_VAR = gleshell-test-val")
151 let assert eval.Continue(_, String("gleshell-test-val")) =
152 eval.eval_source(env2, "$env.GLESHELL_TEST_VAR")
153 let assert Ok("gleshell-test-val") = sys.getenv("GLESHELL_TEST_VAR")
154 Nil
155}
156
157pub fn parse_env_assign_test() {
158 let assert Ok(parser.EnvAssign("FOO", parser.Pipeline([cmd]))) =
159 parser.parse("$env.FOO = hello")
160 let assert parser.Command(
161 "__value__",
162 [parser.ValueArg(parser.Lit(String("hello")))],
163 False,
164 ) = cmd
165 Nil
166}
167
168fn list_has_string_field(
169 fields: List(#(String, value.Value)),
170 key: String,
171) -> Bool {
172 case fields {
173 [] -> False
174 [#(k, String(_)), ..] if k == key -> True
175 [_, ..rest] -> list_has_string_field(rest, key)
176 }
177}
178
179pub fn eval_where_select_test() {
180 let env = env.new()
181 // build table via records in a list, convert with table
182 let assert eval.Continue(_, result) =
183 eval.eval_source(
184 env,
185 "echo [{name: a, n: 1} {name: b, n: 2} {name: c, n: 3}] | table | where n > 1 | select name",
186 )
187 let assert Table(["name"], rows) = result
188 let assert [[String("b")], [String("c")]] = rows
189 Nil
190}
191
192pub fn eval_from_json_test() {
193 let env = env.new()
194 let assert eval.Continue(_, Record(fields)) =
195 eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from json")
196 let assert True = list_has_field(fields, "x", Int(1))
197 Nil
198}
199
200pub fn eval_to_json_pretty_test() {
201 let env = env.new()
202 // Nushell-style multi-word `to json` — pretty by default
203 let assert eval.Continue(_, String(pretty)) =
204 eval.eval_source(env, "echo [1 2 3] | to json")
205 let assert True = string.contains(pretty, "\n")
206 let assert True = string.contains(pretty, "1")
207 // `--raw` matches Nu: compact, no trailing newline
208 let assert eval.Continue(_, String(raw)) =
209 eval.eval_source(env, "echo [1 2 3] | to json --raw")
210 let assert "[1,2,3]" = raw
211 Nil
212}
213
214pub fn eval_to_json_record_test() {
215 let env = env.new()
216 let assert eval.Continue(_, String(raw)) =
217 eval.eval_source(env, "echo {a: 1, b: true} | to json -r")
218 let assert True = string.contains(raw, "\"a\":1")
219 let assert True = string.contains(raw, "\"b\":true")
220 Nil
221}
222
223fn list_has_field(
224 fields: List(#(String, value.Value)),
225 key: String,
226 expected: value.Value,
227) -> Bool {
228 case fields {
229 [] -> False
230 [#(k, v), ..rest] ->
231 case k == key {
232 True -> value.equals(v, expected)
233 False -> list_has_field(rest, key, expected)
234 }
235 }
236}
237
238pub fn eval_length_test() {
239 let env = env.new()
240 let assert eval.Continue(_, Int(4)) =
241 eval.eval_source(env, "range 4 | length")
242 Nil
243}
244
245pub fn value_nothing_falsey_test() {
246 let assert False = value.is_truthy(Nothing)
247 let assert True = value.is_truthy(Int(1))
248 Nil
249}
250
251// --- display / color ---
252
253pub fn display_plain_no_ansi_test() {
254 let text = display.render_with(False, Bool(True))
255 let assert "true" = text
256 let assert False = string_contains(text, "\u{001b}")
257 Nil
258}
259
260pub fn display_colored_has_ansi_test() {
261 let text = display.render_with(True, Bool(True))
262 let assert True = string_contains(text, "\u{001b}")
263 let assert True = string_contains(text, "true")
264 Nil
265}
266
267pub fn display_table_headers_colored_test() {
268 let text =
269 display.render_with(
270 True,
271 Table(["name", "type"], [[String("src"), String("dir")]]),
272 )
273 // bold green header + bright-blue dir name
274 let assert True = string_contains(text, "\u{001b}[1;32m")
275 let assert True = string_contains(text, "\u{001b}[94m")
276 let assert True = string_contains(text, "src")
277 Nil
278}
279
280pub fn color_visible_length_strips_ansi_test() {
281 let painted = color.paint(True, "\u{001b}[32m", "hi")
282 let assert 2 = color.visible_length(painted)
283 let assert 2 = color.visible_length("hi")
284 Nil
285}
286
287// --- input syntax highlighting ---
288
289pub fn highlight_plain_when_off_test() {
290 let src = "ls | where type == file"
291 let assert True = highlight.highlight(False, src) == src
292 Nil
293}
294
295pub fn highlight_pipeline_has_shapes_test() {
296 let text = highlight.highlight(True, "ls | first 3")
297 // bold cyan internalcall, bold purple pipe, bold purple int
298 let assert True = string_contains(text, "\u{001b}[1;36m")
299 let assert True = string_contains(text, "\u{001b}[1;35m")
300 let assert True = string_contains(text, "ls")
301 let assert True = string_contains(text, "first")
302 let assert True = string_contains(text, "3")
303 // visible text unchanged
304 let assert 12 = color.visible_length(text)
305 Nil
306}
307
308pub fn highlight_string_and_flag_test() {
309 let text = highlight.highlight(True, "echo \"hi\" --raw")
310 let assert True = string_contains(text, "\u{001b}[32m")
311 let assert True = string_contains(text, "\u{001b}[1;34m")
312 let assert True = string_contains(text, "hi")
313 let assert True = string_contains(text, "--raw")
314 Nil
315}
316
317pub fn highlight_variable_and_let_test() {
318 let text = highlight.highlight(True, "let x = $in")
319 let assert True = string_contains(text, "\u{001b}[1;36m")
320 let assert True = string_contains(text, "\u{001b}[35m")
321 let assert True = string_contains(text, "let")
322 let assert True = string_contains(text, "$in")
323 Nil
324}
325
326pub fn highlight_incomplete_string_test() {
327 // Unterminated string while typing should not crash
328 let text = highlight.highlight(True, "echo \"hel")
329 let assert True = string_contains(text, "hel")
330 let assert True = string_contains(text, "\u{001b}[32m")
331 Nil
332}
333
334pub fn highlight_to_json_multiword_test() {
335 let text = highlight.highlight(True, "range 3 | to json")
336 let assert True = string_contains(text, "to json")
337 let assert True = string_contains(text, "\u{001b}[1;36m")
338 Nil
339}
340
341fn string_contains(haystack: String, needle: String) -> Bool {
342 case string.split(haystack, needle) {
343 [_] -> False
344 _ -> True
345 }
346}