git.m455.casa

blep

clone url: git://git.m455.casa/blep


blep.exs

1 #!/usr/bin/env elixir
2
3 # heavily heavily referenced and studied the following material:
4 # - https://norvig.com/lispy.html
5 # - https://github.com/farazhaider/lispex/tree/master/lib
6 # - https://danthedev.com/lisp-in-your-language/
7 # - https://mattbruv.github.io/mplisp/
8 # - https://pages.cs.wisc.edu/~horwitz/CS704-NOTES/4.LISP-INTERP.html
9
10 defmodule Scopes do
11 def core do
12 %{
13 := => fn(args) -> Enum.reduce(args, fn(n, acc) -> acc == n end) end,
14 :num? => fn([arg | _]) -> is_number(arg) end,
15 :empty? => fn([arg | _]) -> Enum.empty?(arg) end,
16 :list? => fn([arg | _]) -> is_list(arg) end,
17 :str? => fn([arg | _]) -> is_binary(arg) end,
18 :zero? => fn([arg | _]) -> arg == 0 end,
19 :inc => fn([arg | _]) -> arg + 1 end,
20 :dec => fn([arg | _]) -> arg - 1 end,
21 :do => fn(args) -> List.last(args) end,
22 :list => fn(args) -> args end,
23 :cons => fn([x, y]) -> [x | y] end,
24 :first => fn([arg | _]) -> hd(arg) end,
25 :rest => fn([arg | _]) -> tl(arg) end,
26 :str => fn(args) ->
27 args_as_strings = Enum.map(args, fn(arg) -> to_string(arg) end)
28 # TODO: maybe an enum.join here instead?
29 Enum.reduce(args_as_strings, fn(str, acc) -> acc <> str end)
30 end,
31 :num => fn([arg | _]) ->
32 try do
33 String.to_integer(arg)
34 rescue
35 ArgumentError -> String.to_float(arg)
36 end
37 end,
38 :print => fn([arg | _]) -> IO.write(arg) end,
39 String.to_atom("str->list") => fn([arg | _]) ->
40 String.graphemes(arg)
41 end,
42 String.to_atom("file->str") => fn([arg | _]) ->
43 case File.read(arg) do
44 {:ok, data} -> data
45 {:error, reason} ->
46 raise("oh shit: #{reason}")
47 end
48 end,
49 String.to_atom("str->data") => fn([arg | _]) ->
50 arg
51 |> String.graphemes()
52 |> Tokenization.tokenize([])
53 |> Reading.read([])
54 end,
55 }
56 end
57
58 def get(scope, key) do
59 case {scope[key], scope[:parent_scope]} do
60 {nil, nil} ->
61 raise("oh shit: couldn't find identifier: #{Atom.to_string(key)}")
62 {nil, parent_scope} -> get(parent_scope, key)
63 {local_value, _} -> local_value
64 end
65 end
66 end
67
68 defmodule Tokenization do
69 defp is_valid_atom(string) do
70 !Enum.member?([")", "(", "]", "[", "\s", "\n", "\r", "\t"], string)
71 end
72
73 defp atomize(string) do
74 case string do
75 "true" -> true
76 "fuck" -> false
77 _ ->
78 try do
79 String.to_integer(string)
80 rescue
81 ArgumentError ->
82 try do
83 String.to_float(string)
84 rescue
85 ArgumentError ->
86 String.to_atom(string)
87 end
88 end
89 end
90 end
91
92 defp ignore_line([char | rest]) do
93 if char !== "\n" and char !== "\r" do
94 ignore_line(rest)
95 else
96 rest
97 end
98 end
99
100 defp build_string([char | rest], string) do
101 if char !== "\"" do
102 build_string(rest, string <> char)
103 else
104 {rest, Macro.unescape_string(string)}
105 end
106 end
107
108 defp build_atom(graphemes, string, mode) do
109 [char | rest] = graphemes
110 if is_valid_atom(char) do
111 build_atom(rest, string <> char, mode)
112 else
113 # don't return rest here, so we don't lose the last character
114 result = if mode == :keyword, do: string, else: atomize(string)
115 {graphemes, result}
116 end
117 end
118
119 def tokenize(graphemes, tokens) do
120 if Enum.empty?(graphemes) do
121 Enum.reverse(tokens)
122 else
123 [char | rest] = graphemes
124
125 case char do
126 # elixir doesn't support definitions in when clauses, only macros, which
127 # i haven't learned yet in elixir lol
128 c when c == "\s" or c == "\n" or c == "\r" or c == "\t" ->
129 tokenize(rest, tokens)
130
131 ";" ->
132 remaining_graphemes = ignore_line(rest)
133 tokenize(remaining_graphemes, tokens)
134
135 "\"" ->
136 {remaining_graphemes, built_string} = build_string(rest, "")
137 tokenize(remaining_graphemes, [built_string | tokens])
138
139 ":" ->
140 {remaining_graphemes, built_literal} = build_atom(rest, "", :keyword)
141 tokenize(remaining_graphemes, [built_literal | tokens])
142
143 "(" ->
144 tokenize(rest, [char | tokens])
145
146 ")" ->
147 tokenize(rest, [char | tokens])
148
149 "[" ->
150 tokenize(rest, [:list, char | tokens])
151
152 "]" ->
153 tokenize(rest, [char | tokens])
154
155 _ ->
156 {remaining_graphemes, built_atom} = build_atom(graphemes, "", :atom)
157 tokenize(remaining_graphemes, [built_atom | tokens])
158 end
159 end
160 end
161 end
162
163 defmodule Reading do
164 def read(tokens, ast) do
165 if Enum.empty?(tokens) do
166 # Reverse this so the program evaluates from top to bottom of code
167 Enum.reverse(ast)
168 else
169 [token | rest] = tokens
170
171 cond do
172 token == "(" ->
173 {remaining_tokens, sub_list} = read(rest, [])
174 read(remaining_tokens, [sub_list | ast])
175
176 token == "[" ->
177 {remaining_tokens, sub_list} = read(rest, [])
178 read(remaining_tokens, [sub_list | ast])
179
180 token == ")" ->
181 {rest, Enum.reverse(ast)}
182
183 token == "]" ->
184 {rest, Enum.reverse(ast)}
185
186 true ->
187 read(rest, [token | ast])
188 end
189 end
190 end
191 end
192
193 defmodule Evaluation do
194 defp eval_args(expression, current_scope) do
195 if Enum.empty?(expression) do
196 []
197 else
198 [first | rest] = expression
199 {result, new_scope} = eval(first, current_scope)
200 [result | eval_args(rest, new_scope)]
201 end
202 end
203
204 defp eval_cond(pairs, current_scope) do
205 if Enum.empty?(pairs) do
206 # instead of returning an error, i return false for the programmer if they
207 # didn't specify a final [true result] condition
208 {false, current_scope}
209 else
210 [pair_cond, pair_result] = hd(pairs)
211 condition_result = eval(pair_cond, current_scope) |> elem(0)
212 if condition_result do
213 eval(pair_result, current_scope)
214 else
215 eval_cond(tl(pairs), current_scope)
216 end
217 end
218 end
219
220 defp is_definition?(x) do
221 is_atom(x) and
222 x != true and
223 x != false and
224 # so we don't try to first look for a :fn key in the global scope
225 x != :fn
226 end
227
228 def eval(input, current_scope) do
229 cond do
230 is_definition?(input) ->
231 {Scopes.get(current_scope, input), current_scope}
232
233 not is_list(input) ->
234 {input, current_scope}
235
236 hd(input) == :quote->
237 {tl(input) |> hd, current_scope}
238
239 hd(input) == :eval->
240 [_, expr] = input
241 # honestly not sure why i need to eval twice lol
242 {result, new_scope} = eval(expr, current_scope)
243 if is_list(expr) and hd(expr) == :quote do
244 # if we are evaluating a quoted expression, the result of the quoted
245 # expression is a literal value, so we need to then evaluate that
246 # result to get it to return a definition's value
247 eval(result, new_scope)
248 else
249 # otherwise, we just evaluate a regular argument
250 # ... maybe i should just error here, since you wouldn't need to
251 # evaluate a non-quoted expression? i dont even know
252 {result, new_scope}
253 end
254
255 hd(input) == :if->
256 [_, user_cond, t_result, f_result] = input
257 cond_result = eval(user_cond, current_scope) |> elem(0)
258 if cond_result do
259 eval(t_result, current_scope)
260 else
261 eval(f_result, current_scope)
262 end
263
264 hd(input) == :cond ->
265 # [:cond
266 # [:list [:> 2 1] :yes]
267 # [:list [:< 2 1] :yes]
268 # [:list true :other]]
269 [_ | pairs] = input
270 # remove preceding :list from each pair
271 pairs_without_prefix = Enum.map(pairs, fn(pair) -> tl(pair) end)
272 eval_cond(pairs_without_prefix, current_scope)
273
274 hd(input) == :import ->
275 [_ | filenames] = input
276 new_scope = Enum.reduce(filenames, current_scope, fn(file, acc_scope) ->
277 case File.read(file) do
278 {:ok, data} ->
279 data
280 |> String.graphemes()
281 |> Tokenization.tokenize([])
282 |> Reading.read([])
283 |> Enum.reduce(acc_scope, fn(expr, s) ->
284 Evaluation.eval(expr, s) |> elem(1) end)
285 {:error, reason} ->
286 raise("oh shit: #{reason}")
287 end
288 end)
289 {:ok, new_scope}
290
291 hd(input) == :def ->
292 [_, symbol, body] = input
293 {body_evaluated, _} = eval(body, current_scope)
294 {:ok, Map.put(current_scope, symbol, body_evaluated)}
295
296 hd(input) == :func ->
297 [_, symbol, parameters | body] = input
298 eval([:def, symbol, [:fn, parameters, body]], current_scope)
299
300 # returns raw ast so we don't try to evaluate parameters that aren't
301 # mapped to arguments yet
302 hd(input) == :fn ->
303 [_, parameters | body] = input
304 # turn [:list, param_a, param_b] into [param_a, param_b]
305 parameters_list_prefix_removed = tl(parameters)
306 {{parameters_list_prefix_removed, body}, current_scope}
307
308 hd(input) == :let ->
309 # [:let [:list [:list a "aaa"]
310 # [:list b "aaa"]]
311 # ...]
312 [_, list_of_pairs | body] = input
313 # map on the tail so we don't include the first :list.
314 # return the tail of each pair so we don't need process the `:list`s
315 pairs = Enum.map(tl(list_of_pairs), fn(pair) -> tl(pair) end)
316 local_scope = Enum.reduce(pairs, %{:parent_scope => current_scope}, fn(pair, acc_scope) ->
317 [symbol, val] = pair
318 eval([:def, symbol, val], acc_scope) |> elem(1)
319 end)
320 result = eval(body, local_scope) |> elem(0)
321 {result, current_scope}
322
323 true ->
324 [head | tail] = input
325 {head_evaluated, _} = eval(head, current_scope)
326 args = eval_args(tail, Map.put(%{}, :parent_scope, current_scope))
327 cond do
328 is_tuple(head_evaluated) ->
329 {parameters, body} = head_evaluated
330 # create something like %{:p1 => arg1, :p2 => arg2}
331 parameters_map = Enum.zip(parameters, args) |> Enum.into(%{})
332 # evaluate the body of the anonymous function using a local scope to
333 # populate the originally undefined values in the anonymous
334 # function's body
335 {body_evaluated, _} = eval(body, Map.put(parameters_map, :parent_scope, current_scope))
336 {body_evaluated, current_scope}
337 is_function(head_evaluated) ->
338 {head_evaluated.(args), current_scope}
339 true ->
340 # this /seems/ to be the return value of a function but i could be
341 # wrong?
342 {head_evaluated, current_scope}
343 end
344 end
345 end
346 end
347
348 defmodule Main do
349 def lisp_string(expression) do
350 cond do
351 expression == false ->
352 "fuck"
353 is_atom(expression) and expression != true ->
354 ":" <> to_string(expression)
355 is_binary(expression) -> "\"" <> expression <> "\""
356 # still unsure about whether or not i want to show :list in a list when
357 # quoted at the repl like so: [:list 1 2 :thing]
358 is_list(expression) ->
359 expression_list_prefix_removed = Enum.filter(expression, fn(expr) ->
360 expr != :list end)
361 list_of_strings = Enum.map(expression_list_prefix_removed, fn (expr) ->
362 lisp_string(expr)
363 end)
364 "[" <> Enum.join(list_of_strings, " ") <> "]"
365 true ->
366 to_string(expression)
367 end
368 end
369
370 defp repl(scope) do
371 input = IO.gets("> ")
372 # lol why evaluate anything when i can do something awful like this
373 cond do
374 input == "exit\n" or
375 input == "(exit)\n" or
376 input == "quit\n" or
377 input == "(quit)\n" or
378 input == "q\n" ->
379 exit(:normal)
380 input == :eof ->
381 IO.puts("")
382 exit(:normal)
383 true ->
384 {result, new_scope} = input
385 |> String.graphemes()
386 |> Tokenization.tokenize([])
387 |> Reading.read([])
388 |> Enum.reduce({nil, scope}, fn(expr, acc_tuple) ->
389 Evaluation.eval(expr, elem(acc_tuple, 1)) end)
390 IO.puts(lisp_string(result))
391 repl(new_scope)
392 end
393 end
394
395 def handle_args(args) do
396 scope = Scopes.core
397 if length(args) == 0 do
398 IO.puts("welcome to blep, the shitty lisp")
399 IO.puts("")
400 repl(scope)
401 else
402 case File.read(hd(args)) do
403 {:ok, data} ->
404 data
405 |> String.graphemes()
406 |> Tokenization.tokenize([])
407 |> Reading.read([])
408 |> Enum.reduce({nil, scope}, fn(expr, acc_tuple) ->
409 Evaluation.eval(expr, elem(acc_tuple, 1)) end)
410 {:error, reason} ->
411 raise("oh shit: #{reason}")
412 end
413 end
414 end
415 end
416
417 Main.handle_args(System.argv())