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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -8
  3. data/docs/lang/design.md +240 -51
  4. data/docs/lang/implementation.md +238 -0
  5. data/docs/lang/roadmap.md +20 -36
  6. data/docs/user/explanation.md +5 -10
  7. data/docs/user/how-to-guides.md +60 -15
  8. data/docs/user/reference.md +356 -142
  9. data/docs/user/tutorial.md +21 -19
  10. data/examples/double.fsn +1 -1
  11. data/examples/factorial.fsn +2 -2
  12. data/examples/fizzbuzz.fsn +1 -4
  13. data/examples/json_test.fsn +4 -0
  14. data/examples/palindrome.fsn +1 -1
  15. data/exe/fusion +10 -10
  16. data/lib/fusion/ast.rb +2 -1
  17. data/lib/fusion/cli/decoder.rb +10 -5
  18. data/lib/fusion/cli/options.rb +130 -60
  19. data/lib/fusion/cli/parser.rb +3 -3
  20. data/lib/fusion/cli/repl.rb +30 -25
  21. data/lib/fusion/cli/serializer.rb +5 -4
  22. data/lib/fusion/cli.rb +119 -48
  23. data/lib/fusion/interpreter/builtins.rb +260 -151
  24. data/lib/fusion/interpreter/env.rb +42 -12
  25. data/lib/fusion/interpreter/error_val.rb +42 -20
  26. data/lib/fusion/interpreter/thunk.rb +53 -0
  27. data/lib/fusion/interpreter.rb +239 -82
  28. data/lib/fusion/lexer.rb +69 -3
  29. data/lib/fusion/parser.rb +189 -51
  30. data/lib/fusion/version.rb +1 -1
  31. data/stdlib/all.fsn +13 -0
  32. data/stdlib/any.fsn +12 -0
  33. data/stdlib/chars.fsn +5 -0
  34. data/stdlib/compact.fsn +6 -0
  35. data/stdlib/concat.fsn +5 -0
  36. data/stdlib/falsey.fsn +6 -0
  37. data/stdlib/filter.fsn +12 -0
  38. data/stdlib/flatten.fsn +7 -0
  39. data/stdlib/gt.fsn +9 -0
  40. data/stdlib/gte.fsn +9 -0
  41. data/stdlib/lt.fsn +9 -0
  42. data/stdlib/lte.fsn +9 -0
  43. data/stdlib/map.fsn +6 -4
  44. data/stdlib/range.fsn +2 -2
  45. data/stdlib/reduce.fsn +8 -0
  46. data/stdlib/sanitize.fsn +2 -2
  47. data/stdlib/truthy.fsn +7 -0
  48. metadata +18 -4
  49. data/lib/fusion/interpreter/file_thunk.rb +0 -39
  50. data/stdlib/mapValues.fsn +0 -5
  51. data/stdlib/math/square.fsn +0 -4
@@ -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] | @multiply)
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] | @multiply` built a two-element array and piped it into the built-in
36
- `@multiply`. Fusion has no `*` operator (yet); arithmetic is done by piping a pair
37
- into a named function. **Built-ins are reached with an `@` prefix**, just like files
38
- — `@multiply`, `@add`, and so on. (You'll see why `@` is used for both in Step 8.)
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] | @lessThan | (
130
- true => [0, n] | @subtract,
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] | @lessThan` produces `true` or `false`. That
144
- boolean is then piped into a *second, inline function* whose two clauses are the two
145
- branches. **An `if` is just a function with a `true` clause and a `false` clause.**
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 | @ ] | @add
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] | @subtract | @] | @multiply
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] ? @lessThan => [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] | @multiply), "xs": xs} | @map)
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 list `xs`. You just passed a function as a value
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 `@multiply` finds the built-in and `@map` falls through to
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 (`@add`, `@Integer`),
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
@@ -1,4 +1,4 @@
1
1
  (
2
2
  [] => [],
3
- [first, ...rest] => [[first, first] | @add, ...rest | @]
3
+ [first, ...rest] => [2 * first, ...rest|@]
4
4
  )
@@ -1,6 +1,6 @@
1
1
  # Factorial: n! for a non-negative integer.
2
2
  (
3
- _ ? (i ? @Integer => [i, 0] | @lessThan, _ => true) => !"Only non-negative integers, please!",
3
+ _ ? (i ? @Integer => (i ?? 0)|@lt, _ => true) => !"Only non-negative integers, please!",
4
4
  0 => 1,
5
- n => [n, [n, 1] | @subtract | @] | @multiply
5
+ n => n * (n - 1)|@
6
6
  )
@@ -1,10 +1,7 @@
1
1
  # FizzBuzz for a single integer
2
2
  (
3
3
  n =>
4
- [
5
- [n, 3] | @mod,
6
- [n, 5] | @mod,
7
- ]
4
+ [n % 3, n % 5]
8
5
  |
9
6
  (
10
7
  [0, 0] => "FizzBuzz",
@@ -0,0 +1,4 @@
1
+ # JSON with comments, trailing commas and inline computation.
2
+ {
3
+ "duration": 24 * 60,
4
+ }
@@ -4,6 +4,6 @@
4
4
  s ? @String => s | @chars | @,
5
5
  [] => "palindrome",
6
6
  [_] => "palindrome",
7
- [_, ...rest, _] ? @ends | @equals => rest | @,
7
+ [_, ...rest, _] ? @ends | @OP.equal => rest | @,
8
8
  _ => "not a palindrome"
9
9
  )
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 '<json-input>' # input as an argument
9
- # fusion -e '(n => [n,2] | @multiply)' '21' # inline program
10
- # fusion --stream path/to/main.fsn # NDJSON in, NDJSON out
11
- # fusion --repl # interactive expressions/statements
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
- options = begin
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 happens before the input/output contract begins, so
26
- # it is plain text on stderr, not a payloaded error.
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)
@@ -10,9 +10,9 @@ module Fusion
10
10
  module Decoder
11
11
  extend self
12
12
 
13
- ENVELOPE_SHAPES = {
14
- array: "[0, _] or [1, _]",
15
- object: '{"value": _} or {"error": _}',
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
- "location" => "input",
72
+ "origin" => "input",
69
73
  "operation" => "decoding input",
74
+ "status" => 0,
70
75
  "input" => raw,
71
- "message" => "expected #{ENVELOPE_SHAPES.fetch(mode)}"
76
+ "expected" => EXPECTED_ENVELOPE_SHAPES.fetch(mode)
72
77
  ))
73
78
  rescue JSON::ParserError
74
79
  # TODO: BUG ???
@@ -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 it happens before the input/output contract begins,
10
- # so it is not a payloaded Fusion error.
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> [json-input]
19
- fusion [options] -e '<source>' [json-input]
21
+ usage: fusion [options] <file.fsn>
22
+ fusion [options] -e '<source>'
20
23
  fusion --repl
21
24
 
22
- use cases:
23
- (default) pipe: apply the program to one input
24
- --stream apply the program to each line of an NDJSON stream
25
- --repl interactive expressions and `identifier = expression`
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>' inline program instead of a file
29
- --input MODE how the input marks an error value
30
- --output MODE how the output marks an error value
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 (default for --stream)
37
- array [0, value] marks a value, [1, payload] an error
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, :explicit_input
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:, explicit_input:, error_input:)
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
- arguments = argv.dup
61
- use_case = :pipe
62
- input_mode = nil
63
- output_mode = nil
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
- positional = []
67
-
68
- until arguments.empty?
69
- argument = arguments.shift
70
- case argument
71
- when "--stream" then use_case = :stream
72
- when "--repl" then use_case = :repl
73
- when "--input" then input_mode = shift_mode(arguments, "--input")
74
- when "--output" then output_mode = shift_mode(arguments, "--output")
75
- when "-!" then error_input = true
76
- when "-e"
77
- inline_source = arguments.shift
78
- raise UsageError, "-e requires a source argument" if inline_source.nil?
79
- when /\A--/
80
- raise UsageError, "unknown option #{argument}"
81
- else
82
- # Anything else is positional. A single leading "-" stays positional
83
- # so negative numbers work as the json-input argument: fusion f.fsn -5
84
- positional << argument
85
- end
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
- validate(use_case, input_mode, output_mode, error_input, inline_source, positional)
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 = explicit_input = nil
177
+ program_path = nil
99
178
  when :stream
100
- input_mode ||= :bang
101
- output_mode ||= :bang
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, "--stream reads its input from stdin, not an argument" unless positional.empty?
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
- explicit_input = positional.shift
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
- explicit_input: explicit_input,
127
- error_input: error_input
203
+ error_input: error_input,
204
+ skip_blank_lines: skip_blank_lines,
205
+ jail: jail
128
206
  )
129
207
  end
130
208
 
131
- def self.shift_mode(arguments, flag)
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
@@ -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.internal(
17
+ Interpreter::ErrorVal.from_runtime(
18
18
  kind: "syntax_error",
19
- location: "input",
20
- operation: "parsing input as JSON",
19
+ origin: "input",
20
+ operation: "parsing JSON",
21
21
  input: wire_pair.data,
22
22
  message: "input is not valid JSON"
23
23
  )
@@ -9,21 +9,35 @@ require_relative "../interpreter/env"
9
9
  module Fusion
10
10
  module CLI
11
11
  class Repl
12
- PROMPT = "fsn> "
13
- CONTINUATION_PROMPT = "...> "
12
+ RESET = "\e[0m"
13
+ LIGHT_BLUE = "\e[94m"
14
+ GREEN = "\e[32m"
15
+ RED = "\e[31m"
14
16
 
15
- # REPL entries report errors with the same location as inline (`-e`) code.
16
- LOCATION = "code <inline>"
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
- environment = Interpreter::Env.new.define("__dir__", Dir.pwd)
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
- $stdout.puts(handle(buffer, environment))
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, location: LOCATION)
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, location: LOCATION)
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