fusion-lang 0.0.1.alpha1 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +19 -6
- data/Rakefile +9 -0
- data/docs/lang/design.md +418 -28
- data/docs/lang/implementation.md +238 -0
- data/docs/lang/roadmap.md +20 -57
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +62 -23
- data/docs/user/reference.md +596 -168
- data/docs/user/tutorial.md +32 -29
- data/examples/double.fsn +1 -1
- data/examples/ends.fsn +4 -0
- data/examples/factorial.fsn +2 -2
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/json_test.fsn +4 -0
- data/examples/palindrome.fsn +2 -1
- data/exe/fusion +17 -44
- data/lib/fusion/ast.rb +97 -0
- data/lib/fusion/atom.rb +17 -0
- data/lib/fusion/cli/decoder.rb +84 -0
- data/lib/fusion/cli/encoder.rb +28 -0
- data/lib/fusion/cli/options.rb +212 -0
- data/lib/fusion/cli/parser.rb +38 -0
- data/lib/fusion/cli/repl.rb +78 -0
- data/lib/fusion/cli/serializer.rb +70 -0
- data/lib/fusion/cli.rb +207 -0
- data/lib/fusion/interpreter/builtins.rb +465 -0
- data/lib/fusion/interpreter/env.rb +89 -0
- data/lib/fusion/interpreter/error_val.rb +71 -0
- data/lib/fusion/interpreter/func.rb +22 -0
- data/lib/fusion/interpreter/native_func.rb +22 -0
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +752 -0
- data/lib/fusion/lexer.rb +249 -0
- data/lib/fusion/null.rb +9 -0
- data/lib/fusion/parser.rb +542 -0
- data/lib/fusion/token.rb +22 -0
- data/lib/fusion/typed_data.rb +23 -0
- data/lib/fusion/version.rb +1 -1
- data/lib/fusion/wire_pair.rb +11 -0
- data/lib/fusion.rb +11 -1122
- data/stdlib/all.fsn +13 -0
- data/stdlib/any.fsn +12 -0
- data/stdlib/chars.fsn +5 -0
- data/stdlib/compact.fsn +6 -0
- data/stdlib/concat.fsn +5 -0
- data/stdlib/falsey.fsn +6 -0
- data/stdlib/filter.fsn +12 -0
- data/stdlib/flatten.fsn +7 -0
- data/stdlib/gt.fsn +9 -0
- data/stdlib/gte.fsn +9 -0
- data/stdlib/lt.fsn +9 -0
- data/stdlib/lte.fsn +9 -0
- data/stdlib/map.fsn +6 -2
- data/stdlib/range.fsn +2 -1
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/sanitize.fsn +12 -0
- data/stdlib/truthy.fsn +7 -0
- metadata +41 -2
- data/stdlib/math/square.fsn +0 -1
data/docs/user/tutorial.md
CHANGED
|
@@ -5,8 +5,8 @@ every example. By the end you will have written a recursive program and understo
|
|
|
5
5
|
Fusion's pieces fit together.*
|
|
6
6
|
|
|
7
7
|
> **What you need:** Ruby installed and the `fusion` interpreter available on your
|
|
8
|
-
> `PATH
|
|
9
|
-
>
|
|
8
|
+
> `PATH`. All commands below can be run from any directory containing your `.fsn`
|
|
9
|
+
> files.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ Fusion programs read JSON on standard input and write JSON on standard output. L
|
|
|
16
16
|
create our first program. Create a file `lesson.fsn` containing exactly:
|
|
17
17
|
|
|
18
18
|
```fusion
|
|
19
|
-
(n => [n, 2] | @
|
|
19
|
+
(n => [n, 2] | @OP.product)
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Now run it:
|
|
@@ -32,10 +32,11 @@ being told:
|
|
|
32
32
|
statements. The file *is* the program.
|
|
33
33
|
- The input `21` was piped *into* the function. That is what `|` means: **`value |
|
|
34
34
|
function`** applies the function to the value.
|
|
35
|
-
- `[n, 2] | @
|
|
36
|
-
`@
|
|
37
|
-
into a
|
|
38
|
-
— `@
|
|
35
|
+
- `[n, 2] | @OP.product` built a two-element array and piped it into `@OP.product`, a
|
|
36
|
+
member of the built-in operator object `@OP`. Fusion has no `*` operator (yet);
|
|
37
|
+
arithmetic is done by piping a pair into a built-in. **Built-ins are reached with an
|
|
38
|
+
`@` prefix**, just like files — `@OP.product`, `@OP.sum`, and so on. (You'll see why
|
|
39
|
+
`@` is used for both in Step 8.)
|
|
39
40
|
|
|
40
41
|
Note: on the right side of `=>` you can use regular parentheses `()` to group
|
|
41
42
|
expressions and influence execution order.
|
|
@@ -126,8 +127,8 @@ number's absolute value:
|
|
|
126
127
|
|
|
127
128
|
```fusion
|
|
128
129
|
(n =>
|
|
129
|
-
[n, 0] | @
|
|
130
|
-
true =>
|
|
130
|
+
[n, 0] | @OP.compare | @lt | (
|
|
131
|
+
true => n | @OP.negate,
|
|
131
132
|
false => n
|
|
132
133
|
)
|
|
133
134
|
)
|
|
@@ -140,9 +141,10 @@ echo '-5' | fusion lesson.fsn # => 5
|
|
|
140
141
|
echo '5' | fusion lesson.fsn # => 5
|
|
141
142
|
```
|
|
142
143
|
|
|
143
|
-
Read the middle line carefully: `[n, 0] | @
|
|
144
|
-
boolean is then piped into a *second,
|
|
145
|
-
branches. **An `if` is just a function
|
|
144
|
+
Read the middle line carefully: `[n, 0] | @OP.compare` orders the pair as `-1`/`0`/`1`,
|
|
145
|
+
and `@lt` turns that into `true`/`false`. That boolean is then piped into a *second,
|
|
146
|
+
inline function* whose two clauses are the two branches. **An `if` is just a function
|
|
147
|
+
with a `true` clause and a `false` clause.**
|
|
146
148
|
|
|
147
149
|
Note: you don't need to restrict yourself to the two values `true` and `false` as
|
|
148
150
|
an intermediate result. Don't use it purely as an `if / else`. Use it like a `case`
|
|
@@ -161,7 +163,7 @@ To make recursion easier, `@` always means *the current file*. Create a new file
|
|
|
161
163
|
```fusion
|
|
162
164
|
(
|
|
163
165
|
[] => 0,
|
|
164
|
-
[x, ...rest] => [x, rest | @ ] | @
|
|
166
|
+
[x, ...rest] => [x, rest | @ ] | @OP.sum
|
|
165
167
|
)
|
|
166
168
|
```
|
|
167
169
|
|
|
@@ -198,7 +200,7 @@ Let's compute the factorial:
|
|
|
198
200
|
```fusion
|
|
199
201
|
(
|
|
200
202
|
0 => 1,
|
|
201
|
-
n ? @Integer => [n, [n, 1] | @
|
|
203
|
+
n ? @Integer => [n, [n, -1] | @OP.sum | @] | @OP.product
|
|
202
204
|
)
|
|
203
205
|
```
|
|
204
206
|
|
|
@@ -214,7 +216,7 @@ Create a function that sorts a pair of values:
|
|
|
214
216
|
|
|
215
217
|
```fusion
|
|
216
218
|
(
|
|
217
|
-
[a, b] ? @
|
|
219
|
+
[a, b] ? (p => p | @OP.compare | @lt) => [a, b],
|
|
218
220
|
[a, b] => [b, a]
|
|
219
221
|
)
|
|
220
222
|
```
|
|
@@ -232,7 +234,7 @@ standard library and are reached with a plain `@name` — the same `@map` you'd
|
|
|
232
234
|
a sibling file. The classic `map` is in the standard library. Create `doubler.fsn`:
|
|
233
235
|
|
|
234
236
|
```fusion
|
|
235
|
-
(xs => {"f": (n => [n, 2] | @
|
|
237
|
+
(xs => {"f": (n => [n, 2] | @OP.product), "c": xs} | @map)
|
|
236
238
|
```
|
|
237
239
|
|
|
238
240
|
```sh
|
|
@@ -240,7 +242,7 @@ echo '[1, 2, 3]' | fusion doubler.fsn # => [2, 4, 6]
|
|
|
240
242
|
```
|
|
241
243
|
|
|
242
244
|
Because every Fusion function takes exactly one argument, `map` takes an *object*
|
|
243
|
-
bundling the function `f` and the
|
|
245
|
+
bundling the function `f` and the collection `c`. You just passed a function as a value
|
|
244
246
|
nested within an object — functions are values like any other.
|
|
245
247
|
|
|
246
248
|
Now the payoff for using `@` everywhere. A bare `@name` is resolved in the following
|
|
@@ -249,7 +251,7 @@ order:
|
|
|
249
251
|
2. A **built-in** called `name`.
|
|
250
252
|
3. A **standard-library** file `name.fsn`.
|
|
251
253
|
|
|
252
|
-
The first match wins. So `@
|
|
254
|
+
The first match wins. So `@OP` finds the built-in and `@map` falls through to
|
|
253
255
|
the standard library. And if *you* put a `map.fsn` next to your program, *your* `map`
|
|
254
256
|
shadows the standard one — but only for files in that directory.
|
|
255
257
|
|
|
@@ -264,22 +266,23 @@ So far you have written programs that succeed. What happens when something goes
|
|
|
264
266
|
wrong? Try dividing by zero. Save as `boom.fsn`:
|
|
265
267
|
|
|
266
268
|
```fusion
|
|
267
|
-
(n => [n, 0] | @divide)
|
|
269
|
+
(n => [n, 0] | @math.divide)
|
|
268
270
|
```
|
|
269
271
|
|
|
270
272
|
```sh
|
|
271
273
|
echo '5' | fusion boom.fsn
|
|
272
274
|
```
|
|
273
275
|
|
|
274
|
-
You will see no output on stdout,
|
|
275
|
-
stderr, and the process
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
well-behaved Unix filters.
|
|
276
|
+
You will see no output on stdout, an error payload like
|
|
277
|
+
`{"kind":"math_error",…,"message":"division by zero"}` on stderr, and the process
|
|
278
|
+
will exit with status `1`. The result *was* an error, and the interpreter knows
|
|
279
|
+
the difference: it routes the error's **payload** to stderr, leaves stdout empty,
|
|
280
|
+
and signals failure. That makes Fusion programs well-behaved Unix filters.
|
|
279
281
|
|
|
280
|
-
An error is always written as `!` followed by a **payload
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
An error is always written as `!` followed by a **payload**. Errors the
|
|
283
|
+
interpreter produces (like the one above) use a standardized payload shape —
|
|
284
|
+
see [reference §6.5](./reference.md#65-the-standardized-error-payload). You can
|
|
285
|
+
also construct your own, with any payload you like:
|
|
283
286
|
|
|
284
287
|
| Example | Meaning |
|
|
285
288
|
| ----------------------------------- | ------------------------ |
|
|
@@ -306,7 +309,7 @@ Catching is done with an error pattern:
|
|
|
306
309
|
Here's `safeDivide` that returns `null` instead of failing:
|
|
307
310
|
|
|
308
311
|
```fusion
|
|
309
|
-
(p => p | @divide | (! => null, n => n))
|
|
312
|
+
(p => p | @math.divide | (! => null, n => n))
|
|
310
313
|
```
|
|
311
314
|
|
|
312
315
|
Run `echo '[10, 0]' | fusion safeDivide.fsn` and you get `null` rather than an
|
|
@@ -330,7 +333,7 @@ In about an hour you have used every major feature of the language:
|
|
|
330
333
|
- `if` is a function matching `true`/`false`; a loop is recursion via `@` (a bare
|
|
331
334
|
`@` means "this file").
|
|
332
335
|
- `?` attaches a predicate to refine a match, and predicates double as types.
|
|
333
|
-
- Everything reachable lives in one `@` namespace: built-ins (`@
|
|
336
|
+
- Everything reachable lives in one `@` namespace: built-ins (`@OP`, `@Integer`),
|
|
334
337
|
the standard library (`@map`), sibling files (`@helper`), and the current file
|
|
335
338
|
(`@`). A bare `@name` checks sibling → built-in → standard library, so you can
|
|
336
339
|
locally shadow a built-in or stdlib function per directory.
|
data/examples/double.fsn
CHANGED
data/examples/ends.fsn
ADDED
data/examples/factorial.fsn
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Factorial: n! for a non-negative integer.
|
|
2
2
|
(
|
|
3
|
-
_ ? (i ? @Integer =>
|
|
3
|
+
_ ? (i ? @Integer => (i ?? 0)|@lt, _ => true) => !"Only non-negative integers, please!",
|
|
4
4
|
0 => 1,
|
|
5
|
-
n =>
|
|
5
|
+
n => n * (n - 1)|@
|
|
6
6
|
)
|
data/examples/fizzbuzz.fsn
CHANGED
data/examples/palindrome.fsn
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Check, whether input string or array is a palindrome
|
|
2
2
|
(
|
|
3
|
+
n ? @Number => n | @toString | @,
|
|
3
4
|
s ? @String => s | @chars | @,
|
|
4
5
|
[] => "palindrome",
|
|
5
6
|
[_] => "palindrome",
|
|
6
|
-
[_, ...rest, _] ?
|
|
7
|
+
[_, ...rest, _] ? @ends | @OP.equal => rest | @,
|
|
7
8
|
_ => "not a palindrome"
|
|
8
9
|
)
|
data/exe/fusion
CHANGED
|
@@ -3,55 +3,28 @@
|
|
|
3
3
|
|
|
4
4
|
# Fusion CLI entrypoint.
|
|
5
5
|
#
|
|
6
|
-
# A file contains exactly one value. A file is "executable" if that value is a
|
|
7
|
-
# function; the runtime computes STDIN | thatFunction and prints the result.
|
|
8
|
-
#
|
|
9
6
|
# Usage:
|
|
10
7
|
# echo '[1,2,3]' | fusion path/to/main.fsn
|
|
11
|
-
# fusion path/to/main.fsn
|
|
12
|
-
# fusion -e '(n => [n,2] | @multiply)'
|
|
8
|
+
# fusion path/to/main.fsn # no input: the file's value is the result
|
|
9
|
+
# echo '21' | fusion -e '(n => [n,2] | @multiply)' # inline program
|
|
10
|
+
# fusion --stream path/to/main.fsn # NDJSON in, NDJSON out
|
|
11
|
+
# fusion --repl # interactive expressions/statements
|
|
12
|
+
#
|
|
13
|
+
# Run `fusion --help` for the input/output modes (--input / --output / -!).
|
|
13
14
|
|
|
14
15
|
require_relative "../lib/fusion"
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
inline = args[1]
|
|
20
|
-
program_path = nil
|
|
21
|
-
explicit_input = args[2]
|
|
22
|
-
else
|
|
23
|
-
program_path = args[0]
|
|
24
|
-
explicit_input = args[1]
|
|
17
|
+
if ARGV.intersect?(["--help", "-h"])
|
|
18
|
+
puts Fusion::CLI::Options::USAGE
|
|
19
|
+
exit 0
|
|
25
20
|
end
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
env.define("__dir__", Dir.pwd)
|
|
36
|
-
interp.eval_expr(ast, env)
|
|
37
|
-
else
|
|
38
|
-
abort("usage: fusion <file.fsn> [json-input] or fusion -e '<src>' [json-input]") unless program_path
|
|
39
|
-
interp.load_file(File.expand_path(program_path)).force
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Read input: explicit arg wins, else stdin.
|
|
43
|
-
input_text = explicit_input || ($stdin.tty? ? "" : $stdin.read)
|
|
44
|
-
input_text = "null" if input_text.nil? || input_text.strip.empty?
|
|
45
|
-
input_value = Fusion::JsonInput.parse(input_text)
|
|
46
|
-
|
|
47
|
-
result = interp.apply(program_value, input_value)
|
|
48
|
-
|
|
49
|
-
if Fusion.error?(result)
|
|
50
|
-
# Print only the payload (as JSON) to stderr; stdout stays empty so that
|
|
51
|
-
# shell pipelines can rely on "stdout has the result, or there isn't one".
|
|
52
|
-
$stderr.puts Fusion::Serializer.to_json(result.payload)
|
|
53
|
-
exit 1
|
|
54
|
-
else
|
|
55
|
-
puts Fusion::Serializer.to_json(result)
|
|
56
|
-
exit 0
|
|
22
|
+
begin
|
|
23
|
+
options = Fusion::CLI::Options.parse(ARGV)
|
|
24
|
+
Fusion::CLI.run(options)
|
|
25
|
+
rescue Fusion::CLI::Options::UsageError => err
|
|
26
|
+
# A command-line misuse is plain text on stderr, not a payloaded Fusion error,
|
|
27
|
+
# whether caught while parsing options or early during execution, e.g. by
|
|
28
|
+
# detecting empty stdin plus "-!".
|
|
29
|
+
abort("fusion: #{err.message}\n\n#{Fusion::CLI::Options::USAGE}")
|
|
57
30
|
end
|
data/lib/fusion/ast.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Data Structure ===
|
|
4
|
+
#
|
|
5
|
+
# An AST::Expression is
|
|
6
|
+
# - output of the parser
|
|
7
|
+
# - input of the interpreter
|
|
8
|
+
|
|
9
|
+
require_relative "typed_data"
|
|
10
|
+
require_relative "atom"
|
|
11
|
+
|
|
12
|
+
module Fusion
|
|
13
|
+
module AST
|
|
14
|
+
# A syntactic identifier: a bound/looked-up name, a `.key`, or a `...rest`
|
|
15
|
+
# binder. Mirrors the lexer's ident rule (Lexer#ident_start? / #ident_part?).
|
|
16
|
+
# Object *keys* are arbitrary strings, not identifiers, so they stay `String`.
|
|
17
|
+
Identifier = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
18
|
+
|
|
19
|
+
# Expression and Pattern nodes each form a closed family, declared up front
|
|
20
|
+
# (empty) so a member of either may reference the other. Each module is
|
|
21
|
+
# mixed into all of its members at the bottom of its body, so the module
|
|
22
|
+
# doubles as a marker: a field typed `Expression` accepts any expression
|
|
23
|
+
# node, `Pattern` any pattern node.
|
|
24
|
+
module Expression; end
|
|
25
|
+
module Pattern; end
|
|
26
|
+
module Statement; end
|
|
27
|
+
|
|
28
|
+
# Auxiliary typed parts: the elements a collection node holds. NOT themselves
|
|
29
|
+
# expressions or patterns, so they live outside the marker families and never
|
|
30
|
+
# satisfy an `Expression`/`Pattern` field.
|
|
31
|
+
ArrayItem = TypedData.define(value: Expression) # an array element
|
|
32
|
+
ArraySpread = TypedData.define(value: Expression) # ...expr inside an array
|
|
33
|
+
KeyValuePair = TypedData.define(key: String, value: Expression) # "k": expr inside an object
|
|
34
|
+
ObjectSpread = TypedData.define(value: Expression) # ...expr inside an object
|
|
35
|
+
Clause = TypedData.define(pattern: Pattern, body: Expression) # one pattern => body of a function
|
|
36
|
+
PatternItem = TypedData.define(pattern: Pattern) # a sub-pattern of an array pattern
|
|
37
|
+
PatternPair = TypedData.define(key: String, pattern: Pattern) # "k": pat inside an object pattern
|
|
38
|
+
PatternRest = TypedData.define(name: ->(v) { Identifier === v || v.nil? }) # ...name (name nil = ignore) in either
|
|
39
|
+
|
|
40
|
+
module Expression
|
|
41
|
+
Lit = TypedData.define(value: Atom) # atom literal (incl NULL)
|
|
42
|
+
ErrLit = TypedData.define(payload: ->(v) { Expression === v || v.nil? }) # !expr or bare ! (payload nil = !null)
|
|
43
|
+
ArrLit = TypedData.define(items: ->(v) { v.is_a?(Array) && v.all? { |e| ArrayItem === e || ArraySpread === e } })
|
|
44
|
+
ObjLit = TypedData.define(pairs: ->(v) { # [KeyValuePair|ObjectSpread], distinct fixed keys
|
|
45
|
+
v.is_a?(Array) &&
|
|
46
|
+
v.all? { |m| KeyValuePair === m || ObjectSpread === m } &&
|
|
47
|
+
v.filter_map { |m| m.key if KeyValuePair === m }.then { |keys| keys.uniq.size == keys.size }
|
|
48
|
+
})
|
|
49
|
+
FuncLit = TypedData.define(clauses: ->(v) { v.is_a?(Array) && v.all? { |c| Clause === c } }) # [] = the empty function
|
|
50
|
+
Ident = TypedData.define(name: Identifier) # read a builtin/bound name
|
|
51
|
+
FileRef = TypedData.define(variety: ->(v) { %i[self super super_name name path].include?(v) }, path: ->(v) { String === v || v.nil? })
|
|
52
|
+
Pipe = TypedData.define(left: Expression, right: Expression) # left | right
|
|
53
|
+
Member = TypedData.define(obj: Expression, key: Identifier) # obj.key
|
|
54
|
+
Index = TypedData.define(obj: Expression, idx: Expression) # obj[expr]
|
|
55
|
+
IndexSet = TypedData.define(obj: Expression, idx: Expression, value: Expression) # obj[expr = expr]
|
|
56
|
+
|
|
57
|
+
constants.each do |name|
|
|
58
|
+
node = const_get(name)
|
|
59
|
+
node.include(self) if node.is_a?(Class) && node < Data
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
module Pattern
|
|
64
|
+
PLit = TypedData.define(value: Atom) # literal pattern
|
|
65
|
+
PErr = TypedData.define(inner: Pattern) # ! or !pat ; inner=PWild matches any error
|
|
66
|
+
PBind = TypedData.define(name: Identifier) # binds
|
|
67
|
+
PWild = TypedData.define(dummy: NilClass) # _
|
|
68
|
+
PArr = TypedData.define(items: ->(v) { # [PatternItem|PatternRest], at most one rest
|
|
69
|
+
v.is_a?(Array) &&
|
|
70
|
+
v.all? { |e| PatternItem === e || PatternRest === e } &&
|
|
71
|
+
v.count { |e| PatternRest === e } <= 1
|
|
72
|
+
})
|
|
73
|
+
PObj = TypedData.define(pairs: ->(v) { # [PatternPair|PatternRest], one rest, distinct keys
|
|
74
|
+
v.is_a?(Array) &&
|
|
75
|
+
v.all? { |m| PatternPair === m || PatternRest === m } &&
|
|
76
|
+
v.count { |m| PatternRest === m } <= 1 &&
|
|
77
|
+
v.filter_map { |m| m.key if PatternPair === m }.then { |keys| keys.uniq.size == keys.size }
|
|
78
|
+
})
|
|
79
|
+
PGuard = TypedData.define(inner: Pattern, pred_expr: Expression) # inner ? predicate
|
|
80
|
+
|
|
81
|
+
constants.each do |name|
|
|
82
|
+
node = const_get(name)
|
|
83
|
+
node.include(self) if node.is_a?(Class) && node < Data
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
module Statement
|
|
88
|
+
# The only statement. Only allowed in the REPL. `name = expression`.
|
|
89
|
+
Assignment = TypedData.define(name: Identifier, expression: Expression)
|
|
90
|
+
|
|
91
|
+
constants.each do |name|
|
|
92
|
+
node = const_get(name)
|
|
93
|
+
node.include(self) if node.is_a?(Class) && node < Data
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/fusion/atom.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Value ===
|
|
4
|
+
#
|
|
5
|
+
# An atomic runtime value.
|
|
6
|
+
|
|
7
|
+
require_relative "typed_data"
|
|
8
|
+
require_relative "null"
|
|
9
|
+
|
|
10
|
+
module Fusion
|
|
11
|
+
# A scalar literal value: the JSON atoms plus NULL (everything the lexer
|
|
12
|
+
# emits as a token value, see Lexer#lex_number and #lex_word).
|
|
13
|
+
Atom = ->(v) {
|
|
14
|
+
v == NULL || v == true || v == false ||
|
|
15
|
+
v.is_a?(Integer) || v.is_a?(Float) || v.is_a?(String)
|
|
16
|
+
}
|
|
17
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === CLI internals ===
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "../wire_pair"
|
|
7
|
+
|
|
8
|
+
module Fusion
|
|
9
|
+
module CLI
|
|
10
|
+
module Decoder
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
EXPECTED_ENVELOPE_SHAPES = {
|
|
14
|
+
array: ["[0, _]", "[1, _]"],
|
|
15
|
+
object: ['{"value": _}', '{"error": _}'],
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# String -> WirePair
|
|
19
|
+
# Doesn't support mode `:unix`
|
|
20
|
+
def decode(text, mode:)
|
|
21
|
+
case mode
|
|
22
|
+
when :bang
|
|
23
|
+
decode_bang(text)
|
|
24
|
+
when :array
|
|
25
|
+
decode_envelope(text, mode) do |raw|
|
|
26
|
+
next unless raw.is_a?(Array) && raw.length == 2 && raw[0].is_a?(Integer)
|
|
27
|
+
|
|
28
|
+
# The tag must be exactly the integer 0 or 1 (no 0.0 — Fusion equality is exact).
|
|
29
|
+
[raw[0], raw[1]] if raw[0] == 0 || raw[0] == 1
|
|
30
|
+
end
|
|
31
|
+
when :object
|
|
32
|
+
decode_envelope(text, mode) do |raw|
|
|
33
|
+
next unless raw.is_a?(Hash) && raw.size == 1
|
|
34
|
+
|
|
35
|
+
if raw.key?("value") then [0, raw["value"]]
|
|
36
|
+
elsif raw.key?("error") then [1, raw["error"]]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
raise Unreachable, "Unknown input mode #{mode}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# bang: a leading "!" marks an error value; its payload is the JSON after
|
|
47
|
+
# the "!". A lone "!" is the error !null, mirroring the language's bare !.
|
|
48
|
+
def decode_bang(text)
|
|
49
|
+
stripped = text.strip
|
|
50
|
+
return WirePair.new(status: 0, data: text) unless stripped.start_with?("!")
|
|
51
|
+
|
|
52
|
+
payload = stripped.delete_prefix("!")
|
|
53
|
+
# TODO: BUG? should we really convert "" to "null"?
|
|
54
|
+
# Feels inconsistent with '-!' flag, but might be slightly different case.
|
|
55
|
+
# Also, definitely inconsistent with non-error case above.
|
|
56
|
+
# Also, the "!" == "!null" leniency holds only in code, not in input/output JSON.
|
|
57
|
+
WirePair.new(status: 1, data: payload.strip.empty? ? "null" : payload)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# array/object: the input is an envelope around the actual value. The block
|
|
61
|
+
# inspects the JSON (raw Ruby, so nulls are nil) and returns [status, inner]
|
|
62
|
+
# or nil for a wrong shape. The inner is re-emitted as JSON text for the
|
|
63
|
+
# pair; invalid JSON falls through to `parse` as a value to fail on, so the
|
|
64
|
+
# syntax_error stays in one place.
|
|
65
|
+
def decode_envelope(text, mode)
|
|
66
|
+
raw = JSON.parse(text)
|
|
67
|
+
status, inner = yield(raw)
|
|
68
|
+
return WirePair.new(status: status, data: JSON.generate(inner)) if status
|
|
69
|
+
|
|
70
|
+
WirePair.new(status: 1, data: JSON.generate(
|
|
71
|
+
"kind" => "argument_error",
|
|
72
|
+
"origin" => "input",
|
|
73
|
+
"operation" => "decoding input",
|
|
74
|
+
"status" => 0,
|
|
75
|
+
"input" => raw,
|
|
76
|
+
"expected" => EXPECTED_ENVELOPE_SHAPES.fetch(mode)
|
|
77
|
+
))
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
# TODO: BUG ???
|
|
80
|
+
WirePair.new(status: 0, data: text)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === CLI internals ===
|
|
4
|
+
|
|
5
|
+
module Fusion
|
|
6
|
+
module CLI
|
|
7
|
+
module Encoder
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
# WirePair -> String
|
|
11
|
+
# Doesn't support mode `:unix`
|
|
12
|
+
def encode(wire_pair, mode:)
|
|
13
|
+
case mode
|
|
14
|
+
when :bang
|
|
15
|
+
bang = wire_pair.status.zero? ? "" : "!"
|
|
16
|
+
"#{bang}#{wire_pair.data}"
|
|
17
|
+
when :array
|
|
18
|
+
"[#{wire_pair.status},#{wire_pair.data}]"
|
|
19
|
+
when :object
|
|
20
|
+
key = wire_pair.status.zero? ? "value" : "error"
|
|
21
|
+
"{\"#{key}\":#{wire_pair.data}}"
|
|
22
|
+
else
|
|
23
|
+
raise Unreachable, "Unknown output mode #{mode}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|