fusion-lang 0.0.1.alpha2 → 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 +9 -8
- data/docs/lang/design.md +240 -51
- data/docs/lang/implementation.md +238 -0
- data/docs/lang/roadmap.md +20 -36
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +60 -15
- data/docs/user/reference.md +356 -142
- data/docs/user/tutorial.md +21 -19
- data/examples/double.fsn +1 -1
- data/examples/factorial.fsn +2 -2
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/json_test.fsn +4 -0
- data/examples/palindrome.fsn +1 -1
- data/exe/fusion +10 -10
- data/lib/fusion/ast.rb +2 -1
- data/lib/fusion/cli/decoder.rb +10 -5
- data/lib/fusion/cli/options.rb +130 -60
- data/lib/fusion/cli/parser.rb +3 -3
- data/lib/fusion/cli/repl.rb +30 -25
- data/lib/fusion/cli/serializer.rb +5 -4
- data/lib/fusion/cli.rb +119 -48
- data/lib/fusion/interpreter/builtins.rb +260 -151
- data/lib/fusion/interpreter/env.rb +42 -12
- data/lib/fusion/interpreter/error_val.rb +42 -20
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +239 -82
- data/lib/fusion/lexer.rb +69 -3
- data/lib/fusion/parser.rb +189 -51
- data/lib/fusion/version.rb +1 -1
- 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 -4
- data/stdlib/range.fsn +2 -2
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/sanitize.fsn +2 -2
- data/stdlib/truthy.fsn +7 -0
- metadata +18 -4
- data/lib/fusion/interpreter/file_thunk.rb +0 -39
- data/stdlib/mapValues.fsn +0 -5
- data/stdlib/math/square.fsn +0 -4
data/docs/user/tutorial.md
CHANGED
|
@@ -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,7 +266,7 @@ 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
|
|
@@ -307,7 +309,7 @@ Catching is done with an error pattern:
|
|
|
307
309
|
Here's `safeDivide` that returns `null` instead of failing:
|
|
308
310
|
|
|
309
311
|
```fusion
|
|
310
|
-
(p => p | @divide | (! => null, n => n))
|
|
312
|
+
(p => p | @math.divide | (! => null, n => n))
|
|
311
313
|
```
|
|
312
314
|
|
|
313
315
|
Run `echo '[10, 0]' | fusion safeDivide.fsn` and you get `null` rather than an
|
|
@@ -331,7 +333,7 @@ In about an hour you have used every major feature of the language:
|
|
|
331
333
|
- `if` is a function matching `true`/`false`; a loop is recursion via `@` (a bare
|
|
332
334
|
`@` means "this file").
|
|
333
335
|
- `?` attaches a predicate to refine a match, and predicates double as types.
|
|
334
|
-
- Everything reachable lives in one `@` namespace: built-ins (`@
|
|
336
|
+
- Everything reachable lives in one `@` namespace: built-ins (`@OP`, `@Integer`),
|
|
335
337
|
the standard library (`@map`), sibling files (`@helper`), and the current file
|
|
336
338
|
(`@`). A bare `@name` checks sibling → built-in → standard library, so you can
|
|
337
339
|
locally shadow a built-in or stdlib function per directory.
|
data/examples/double.fsn
CHANGED
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
data/exe/fusion
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Usage:
|
|
7
7
|
# echo '[1,2,3]' | fusion path/to/main.fsn
|
|
8
|
-
# fusion path/to/main.fsn
|
|
9
|
-
# fusion -e '(n => [n,2] | @multiply)'
|
|
10
|
-
# fusion --stream path/to/main.fsn
|
|
11
|
-
# fusion --repl
|
|
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
12
|
#
|
|
13
13
|
# Run `fusion --help` for the input/output modes (--input / --output / -!).
|
|
14
14
|
|
|
@@ -19,12 +19,12 @@ if ARGV.intersect?(["--help", "-h"])
|
|
|
19
19
|
exit 0
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
Fusion::CLI::Options.parse(ARGV)
|
|
22
|
+
begin
|
|
23
|
+
options = Fusion::CLI::Options.parse(ARGV)
|
|
24
|
+
Fusion::CLI.run(options)
|
|
24
25
|
rescue Fusion::CLI::Options::UsageError => err
|
|
25
|
-
# A command-line misuse
|
|
26
|
-
#
|
|
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 "-!".
|
|
27
29
|
abort("fusion: #{err.message}\n\n#{Fusion::CLI::Options::USAGE}")
|
|
28
30
|
end
|
|
29
|
-
|
|
30
|
-
Fusion::CLI.run(options)
|
data/lib/fusion/ast.rb
CHANGED
|
@@ -48,10 +48,11 @@ module Fusion
|
|
|
48
48
|
})
|
|
49
49
|
FuncLit = TypedData.define(clauses: ->(v) { v.is_a?(Array) && v.all? { |c| Clause === c } }) # [] = the empty function
|
|
50
50
|
Ident = TypedData.define(name: Identifier) # read a builtin/bound name
|
|
51
|
-
FileRef = TypedData.define(variety: ->(v) { %i[self name path].include?(v) }, path: ->(v) { String === v || v.nil? })
|
|
51
|
+
FileRef = TypedData.define(variety: ->(v) { %i[self super super_name name path].include?(v) }, path: ->(v) { String === v || v.nil? })
|
|
52
52
|
Pipe = TypedData.define(left: Expression, right: Expression) # left | right
|
|
53
53
|
Member = TypedData.define(obj: Expression, key: Identifier) # obj.key
|
|
54
54
|
Index = TypedData.define(obj: Expression, idx: Expression) # obj[expr]
|
|
55
|
+
IndexSet = TypedData.define(obj: Expression, idx: Expression, value: Expression) # obj[expr = expr]
|
|
55
56
|
|
|
56
57
|
constants.each do |name|
|
|
57
58
|
node = const_get(name)
|
data/lib/fusion/cli/decoder.rb
CHANGED
|
@@ -10,9 +10,9 @@ module Fusion
|
|
|
10
10
|
module Decoder
|
|
11
11
|
extend self
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
array: "[0, _]
|
|
15
|
-
object: '{"value": _}
|
|
13
|
+
EXPECTED_ENVELOPE_SHAPES = {
|
|
14
|
+
array: ["[0, _]", "[1, _]"],
|
|
15
|
+
object: ['{"value": _}', '{"error": _}'],
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
18
|
# String -> WirePair
|
|
@@ -50,6 +50,10 @@ module Fusion
|
|
|
50
50
|
return WirePair.new(status: 0, data: text) unless stripped.start_with?("!")
|
|
51
51
|
|
|
52
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.
|
|
53
57
|
WirePair.new(status: 1, data: payload.strip.empty? ? "null" : payload)
|
|
54
58
|
end
|
|
55
59
|
|
|
@@ -65,10 +69,11 @@ module Fusion
|
|
|
65
69
|
|
|
66
70
|
WirePair.new(status: 1, data: JSON.generate(
|
|
67
71
|
"kind" => "argument_error",
|
|
68
|
-
"
|
|
72
|
+
"origin" => "input",
|
|
69
73
|
"operation" => "decoding input",
|
|
74
|
+
"status" => 0,
|
|
70
75
|
"input" => raw,
|
|
71
|
-
"
|
|
76
|
+
"expected" => EXPECTED_ENVELOPE_SHAPES.fetch(mode)
|
|
72
77
|
))
|
|
73
78
|
rescue JSON::ParserError
|
|
74
79
|
# TODO: BUG ???
|
data/lib/fusion/cli/options.rb
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
# Output: Options (use case, input/output modes, program, input)
|
|
7
7
|
#
|
|
8
8
|
# A misuse of the command line is a UsageError, reported as plain text on
|
|
9
|
-
# stderr by exe/fusion
|
|
10
|
-
#
|
|
9
|
+
# stderr by exe/fusion, never a payloaded Fusion error. Most surface here while
|
|
10
|
+
# parsing options; `-!` with empty stdin is the one caught later, while reading
|
|
11
|
+
# input.
|
|
12
|
+
|
|
13
|
+
require "optparse"
|
|
11
14
|
|
|
12
15
|
module Fusion
|
|
13
16
|
module CLI
|
|
@@ -15,104 +18,178 @@ module Fusion
|
|
|
15
18
|
class UsageError < StandardError; end
|
|
16
19
|
|
|
17
20
|
USAGE = <<~TEXT
|
|
18
|
-
usage: fusion [options] <file.fsn>
|
|
19
|
-
fusion [options] -e '<source>'
|
|
21
|
+
usage: fusion [options] <file.fsn>
|
|
22
|
+
fusion [options] -e '<source>'
|
|
20
23
|
fusion --repl
|
|
21
24
|
|
|
22
|
-
use cases:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
--
|
|
25
|
+
use cases (default: --repl with no arguments, otherwise --pipe):
|
|
26
|
+
-p, --pipe apply the program to stdin; with no input, the
|
|
27
|
+
program's own value is the result
|
|
28
|
+
-s, --stream apply the program to each line of an NDJSON stream
|
|
29
|
+
-r, --repl interactive expressions and `identifier = expression`
|
|
26
30
|
|
|
27
31
|
options:
|
|
28
|
-
-e '<source>'
|
|
29
|
-
|
|
30
|
-
--
|
|
32
|
+
-e, --execute '<source>'
|
|
33
|
+
inline program instead of a file
|
|
34
|
+
-i, --input MODE
|
|
35
|
+
how the input marks an error value
|
|
36
|
+
-o, --output MODE
|
|
37
|
+
how the output marks an error value
|
|
38
|
+
-j, --jail DIR confine @-references to DIR and its subtree
|
|
39
|
+
(default: the program's directory; the stdlib is
|
|
40
|
+
always reachable, stdin never contains @-references;
|
|
41
|
+
use '*' to disable confinement)
|
|
31
42
|
-! treat the input as an error value (unix input mode only)
|
|
43
|
+
-b, --skip-blank-lines
|
|
44
|
+
drop blank input lines instead of echoing them (--stream only)
|
|
32
45
|
|
|
33
46
|
modes: unix, bang, array, object
|
|
34
47
|
unix pipe only (default there): plain JSON; output: stdout/exit 0
|
|
35
48
|
for values, stderr/exit 1 for error payloads
|
|
36
|
-
bang a leading "!" marks an error value
|
|
37
|
-
|
|
49
|
+
bang a leading "!" marks an error value; cheapest encoding, but not
|
|
50
|
+
valid JSON — prefer it only between Fusion programs
|
|
51
|
+
array [0, value] marks a value, [1, payload] an error (default for --stream)
|
|
38
52
|
object {"value": _} marks a value, {"error": _} an error
|
|
39
53
|
TEXT
|
|
40
54
|
|
|
41
55
|
MODES = %w[unix bang array object].freeze
|
|
42
56
|
|
|
43
|
-
attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :
|
|
57
|
+
attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :jail
|
|
44
58
|
|
|
45
|
-
def initialize(use_case:, input_mode:, output_mode:, inline_source:, program_path:,
|
|
59
|
+
def initialize(use_case:, input_mode:, output_mode:, inline_source:, program_path:, error_input:, skip_blank_lines:, jail:)
|
|
46
60
|
@use_case = use_case
|
|
47
61
|
@input_mode = input_mode
|
|
48
62
|
@output_mode = output_mode
|
|
49
63
|
@inline_source = inline_source
|
|
50
64
|
@program_path = program_path
|
|
51
|
-
@explicit_input = explicit_input
|
|
52
65
|
@error_input = error_input
|
|
66
|
+
@skip_blank_lines = skip_blank_lines
|
|
67
|
+
@jail = jail
|
|
53
68
|
end
|
|
54
69
|
|
|
55
70
|
def error_input?
|
|
56
71
|
@error_input
|
|
57
72
|
end
|
|
58
73
|
|
|
74
|
+
def skip_blank_lines?
|
|
75
|
+
@skip_blank_lines
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
def self.parse(argv)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
pipe = false
|
|
80
|
+
stream = false
|
|
81
|
+
repl = false
|
|
82
|
+
input_modes = []
|
|
83
|
+
output_modes = []
|
|
64
84
|
error_input = false
|
|
85
|
+
skip_blank_lines = false
|
|
65
86
|
inline_source = nil
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
jail = nil
|
|
88
|
+
|
|
89
|
+
parser = OptionParser.new do |option|
|
|
90
|
+
option.on("-p", "--pipe") { pipe = true }
|
|
91
|
+
option.on("-s", "--stream") { stream = true }
|
|
92
|
+
option.on("-r", "--repl") { repl = true }
|
|
93
|
+
option.on("-i", "--input MODE") { |mode| input_modes << check_mode!(mode, "--input") }
|
|
94
|
+
option.on("-o", "--output MODE") { |mode| output_modes << check_mode!(mode, "--output") }
|
|
95
|
+
option.on("-e", "--execute SOURCE") { |source| inline_source = source }
|
|
96
|
+
option.on("-j", "--jail DIR") { |dir| jail = dir }
|
|
97
|
+
option.on("-!") { error_input = true }
|
|
98
|
+
option.on("-b", "--skip-blank-lines") { skip_blank_lines = true }
|
|
99
|
+
end
|
|
100
|
+
parser.require_exact = true # no abbreviations: "--s" is not a stand-in for "--stream"
|
|
101
|
+
|
|
102
|
+
# Whatever survives option parsing is positional: the program path.
|
|
103
|
+
positional = run_parser(parser, argv)
|
|
104
|
+
|
|
105
|
+
use_case = resolve_use_case(pipe: pipe, stream: stream, repl: repl, no_arguments: argv.empty?)
|
|
106
|
+
input_mode = resolve_mode(input_modes, "--input")
|
|
107
|
+
output_mode = resolve_mode(output_modes, "--output")
|
|
108
|
+
|
|
109
|
+
validate(use_case, input_mode, output_mode, error_input, skip_blank_lines, inline_source, jail, positional)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Collapse the use-case flags into one use case; more than one is a misuse.
|
|
113
|
+
# With none: a bare `fusion` (no arguments) starts the REPL, while any other
|
|
114
|
+
# invocation is a pipe run.
|
|
115
|
+
def self.resolve_use_case(pipe:, stream:, repl:, no_arguments:)
|
|
116
|
+
selected = [(:pipe if pipe), (:stream if stream), (:repl if repl)].compact
|
|
117
|
+
|
|
118
|
+
case selected.length
|
|
119
|
+
when 0 then no_arguments ? :repl : :pipe
|
|
120
|
+
when 1 then selected.first
|
|
121
|
+
else raise UsageError, "choose one use case: --pipe, --stream, or --repl"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The single mode for one direction, or nil if unset. Repeats of the same
|
|
126
|
+
# mode are fine; two different modes for the same flag are a misuse.
|
|
127
|
+
def self.resolve_mode(modes, flag)
|
|
128
|
+
distinct = modes.uniq
|
|
129
|
+
|
|
130
|
+
case distinct.length
|
|
131
|
+
when 0 then nil
|
|
132
|
+
when 1 then distinct.first
|
|
133
|
+
else raise UsageError, "conflicting #{flag} modes: #{distinct.join(', ')}"
|
|
86
134
|
end
|
|
135
|
+
end
|
|
87
136
|
|
|
88
|
-
|
|
137
|
+
# Run OptionParser, translating its parse errors into our UsageError so
|
|
138
|
+
# exe/fusion reports them as plain usage text (never a payloaded error).
|
|
139
|
+
def self.run_parser(parser, argv)
|
|
140
|
+
parser.parse(argv)
|
|
141
|
+
rescue OptionParser::InvalidOption => error
|
|
142
|
+
raise UsageError, "unknown option #{error.args.join(' ')}"
|
|
143
|
+
rescue OptionParser::MissingArgument => error
|
|
144
|
+
raise UsageError, missing_argument_message(error.args.first)
|
|
145
|
+
rescue OptionParser::ParseError => error
|
|
146
|
+
raise UsageError, error.message
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A MODE value -> its symbol, or a UsageError naming the valid modes.
|
|
150
|
+
def self.check_mode!(value, flag)
|
|
151
|
+
return value.to_sym if MODES.include?(value)
|
|
152
|
+
|
|
153
|
+
raise UsageError, "#{flag} expects one of: #{MODES.join(', ')} (got #{value})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Mirror the old per-flag wording when a value-taking option has no value.
|
|
157
|
+
# OptionParser reports whichever alias the user typed, so match both.
|
|
158
|
+
def self.missing_argument_message(flag)
|
|
159
|
+
case flag
|
|
160
|
+
when "-e", "--execute" then "-e/--execute requires a source argument"
|
|
161
|
+
when "-i", "--input" then "--input expects one of: #{MODES.join(', ')} (got nothing)"
|
|
162
|
+
when "-o", "--output" then "--output expects one of: #{MODES.join(', ')} (got nothing)"
|
|
163
|
+
when "-j", "--jail" then "-j/--jail requires a directory argument"
|
|
164
|
+
else "#{flag} requires an argument"
|
|
165
|
+
end
|
|
89
166
|
end
|
|
90
167
|
|
|
91
168
|
# Check the flag combination against the use case and fill in defaults.
|
|
92
|
-
def self.validate(use_case, input_mode, output_mode, error_input, inline_source, positional)
|
|
169
|
+
def self.validate(use_case, input_mode, output_mode, error_input, skip_blank_lines, inline_source, jail, positional)
|
|
170
|
+
raise UsageError, "--skip-blank-lines is only for --stream" if skip_blank_lines && use_case != :stream
|
|
171
|
+
|
|
93
172
|
case use_case
|
|
94
173
|
when :repl
|
|
95
174
|
unless input_mode.nil? && output_mode.nil? && !error_input && inline_source.nil? && positional.empty?
|
|
96
175
|
raise UsageError, "--repl takes no program, no input, and no modes"
|
|
97
176
|
end
|
|
98
|
-
program_path =
|
|
177
|
+
program_path = nil
|
|
99
178
|
when :stream
|
|
100
|
-
input_mode ||= :
|
|
101
|
-
output_mode ||= :
|
|
179
|
+
input_mode ||= :array
|
|
180
|
+
output_mode ||= :array
|
|
102
181
|
raise UsageError, "--stream does not support the unix mode" if input_mode == :unix || output_mode == :unix
|
|
103
182
|
raise UsageError, "-! requires the unix input mode" if error_input
|
|
104
183
|
program_path = inline_source ? nil : positional.shift
|
|
105
184
|
raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
|
|
106
|
-
raise UsageError, "
|
|
107
|
-
explicit_input = nil
|
|
185
|
+
raise UsageError, "too many positional arguments" unless positional.empty?
|
|
108
186
|
when :pipe
|
|
109
187
|
input_mode ||= :unix
|
|
110
188
|
output_mode ||= :unix
|
|
111
189
|
raise UsageError, "-! requires the unix input mode" if error_input && input_mode != :unix
|
|
112
190
|
program_path = inline_source ? nil : positional.shift
|
|
113
191
|
raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
|
|
114
|
-
|
|
115
|
-
raise UsageError, "too many arguments: #{positional.join(' ')}" unless positional.empty?
|
|
192
|
+
raise UsageError, "too many positional arguments" unless positional.empty?
|
|
116
193
|
else
|
|
117
194
|
raise Unreachable, "Unknown use case #{use_case}"
|
|
118
195
|
end
|
|
@@ -123,20 +200,13 @@ module Fusion
|
|
|
123
200
|
output_mode: output_mode,
|
|
124
201
|
inline_source: inline_source,
|
|
125
202
|
program_path: program_path,
|
|
126
|
-
|
|
127
|
-
|
|
203
|
+
error_input: error_input,
|
|
204
|
+
skip_blank_lines: skip_blank_lines,
|
|
205
|
+
jail: jail
|
|
128
206
|
)
|
|
129
207
|
end
|
|
130
208
|
|
|
131
|
-
|
|
132
|
-
mode = arguments.shift
|
|
133
|
-
unless MODES.include?(mode)
|
|
134
|
-
raise UsageError, "#{flag} expects one of: #{MODES.join(', ')} (got #{mode.nil? ? 'nothing' : mode})"
|
|
135
|
-
end
|
|
136
|
-
mode.to_sym
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
private_class_method :validate, :shift_mode
|
|
209
|
+
private_class_method :validate, :resolve_use_case, :resolve_mode, :run_parser, :check_mode!, :missing_argument_message
|
|
140
210
|
end
|
|
141
211
|
end
|
|
142
212
|
end
|
data/lib/fusion/cli/parser.rb
CHANGED
|
@@ -14,10 +14,10 @@ module Fusion
|
|
|
14
14
|
value = convert(JSON.parse(wire_pair.data))
|
|
15
15
|
wire_pair.status == 1 ? Interpreter::ErrorVal.new(value) : value
|
|
16
16
|
rescue JSON::ParserError
|
|
17
|
-
Interpreter::ErrorVal.
|
|
17
|
+
Interpreter::ErrorVal.from_runtime(
|
|
18
18
|
kind: "syntax_error",
|
|
19
|
-
|
|
20
|
-
operation: "parsing
|
|
19
|
+
origin: "input",
|
|
20
|
+
operation: "parsing JSON",
|
|
21
21
|
input: wire_pair.data,
|
|
22
22
|
message: "input is not valid JSON"
|
|
23
23
|
)
|
data/lib/fusion/cli/repl.rb
CHANGED
|
@@ -9,21 +9,35 @@ require_relative "../interpreter/env"
|
|
|
9
9
|
module Fusion
|
|
10
10
|
module CLI
|
|
11
11
|
class Repl
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
RESET = "\e[0m"
|
|
13
|
+
LIGHT_BLUE = "\e[94m"
|
|
14
|
+
GREEN = "\e[32m"
|
|
15
|
+
RED = "\e[31m"
|
|
14
16
|
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
+
PROMPT = "#{LIGHT_BLUE}fsn> #{RESET}"
|
|
18
|
+
CONTINUATION_PROMPT = "#{LIGHT_BLUE}...> #{RESET}"
|
|
19
|
+
VALUE_MARKER = "#{GREEN}✔ #{RESET}"
|
|
20
|
+
ERROR_MARKER = "#{RED}✗ #{RESET}"
|
|
21
|
+
|
|
22
|
+
# REPL entries report errors with the same site as inline (`-e`) code.
|
|
23
|
+
SITE = { origin: "code", file: "<inline>" }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(root_env:)
|
|
26
|
+
@root_env = root_env
|
|
27
|
+
end
|
|
17
28
|
|
|
18
29
|
def run
|
|
30
|
+
CLI.prepare!
|
|
31
|
+
|
|
19
32
|
require "reline"
|
|
20
|
-
$stdout.sync = true
|
|
21
33
|
Reline.output = $stderr
|
|
22
34
|
Reline.prompt_proc = proc do |lines|
|
|
23
35
|
lines.each_index.map { |i| i.zero? ? PROMPT : CONTINUATION_PROMPT }
|
|
24
36
|
end
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
# The session env is a child of the run's root, so it carries the jail;
|
|
39
|
+
# bindings accumulate here while loaded files stay isolated at the root.
|
|
40
|
+
environment = @root_env.child.set_context(:dir, Dir.pwd)
|
|
27
41
|
|
|
28
42
|
loop do
|
|
29
43
|
buffer = begin
|
|
@@ -36,38 +50,29 @@ module Fusion
|
|
|
36
50
|
break if buffer.nil? # Ctrl-D on an empty line ends the session
|
|
37
51
|
next if buffer.strip.empty?
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
output = handle(buffer, environment)
|
|
54
|
+
|
|
55
|
+
marker = output.start_with?("!") ? ERROR_MARKER : VALUE_MARKER
|
|
56
|
+
$stderr.print(marker) # decoration on stderr
|
|
57
|
+
$stdout.puts(output) # the clean value on stdout
|
|
40
58
|
end
|
|
41
59
|
end
|
|
42
60
|
|
|
61
|
+
# String -> yes/no
|
|
43
62
|
def complete?(buffer)
|
|
44
63
|
return true if buffer.strip.empty?
|
|
45
64
|
|
|
46
|
-
ast = Fusion::Parser.parse_repl(buffer,
|
|
65
|
+
ast = Fusion::Parser.parse_repl(buffer, site: SITE)
|
|
47
66
|
ast.is_a?(AST::Expression) || ast.is_a?(AST::Statement::Assignment)
|
|
48
67
|
end
|
|
49
68
|
|
|
69
|
+
# String (+ Env) -> String
|
|
50
70
|
def handle(buffer, environment)
|
|
51
|
-
ast = Fusion::Parser.parse_repl(buffer,
|
|
52
|
-
runtime_value = evaluate(ast, environment)
|
|
71
|
+
ast = Fusion::Parser.parse_repl(buffer, site: SITE)
|
|
72
|
+
runtime_value = CLI.evaluate(ast, environment)
|
|
53
73
|
wire_pair = Serializer.serialize(runtime_value, lenient: true)
|
|
54
74
|
Encoder.encode(wire_pair, mode: :bang)
|
|
55
75
|
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def evaluate(ast, environment)
|
|
60
|
-
case ast
|
|
61
|
-
when AST::Expression
|
|
62
|
-
Interpreter.safe_evaluate(ast, environment)
|
|
63
|
-
when AST::Statement::Assignment
|
|
64
|
-
value = Interpreter.safe_evaluate(ast.expression, environment)
|
|
65
|
-
environment.define(ast.name, value)
|
|
66
|
-
value
|
|
67
|
-
else
|
|
68
|
-
raise Unreachable, "Unhandled AST node #{ast.class}"
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
76
|
end
|
|
72
77
|
end
|
|
73
78
|
end
|