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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -6
  3. data/Rakefile +9 -0
  4. data/docs/lang/design.md +418 -28
  5. data/docs/lang/implementation.md +238 -0
  6. data/docs/lang/roadmap.md +20 -57
  7. data/docs/user/explanation.md +5 -10
  8. data/docs/user/how-to-guides.md +62 -23
  9. data/docs/user/reference.md +596 -168
  10. data/docs/user/tutorial.md +32 -29
  11. data/examples/double.fsn +1 -1
  12. data/examples/ends.fsn +4 -0
  13. data/examples/factorial.fsn +2 -2
  14. data/examples/fizzbuzz.fsn +1 -4
  15. data/examples/json_test.fsn +4 -0
  16. data/examples/palindrome.fsn +2 -1
  17. data/exe/fusion +17 -44
  18. data/lib/fusion/ast.rb +97 -0
  19. data/lib/fusion/atom.rb +17 -0
  20. data/lib/fusion/cli/decoder.rb +84 -0
  21. data/lib/fusion/cli/encoder.rb +28 -0
  22. data/lib/fusion/cli/options.rb +212 -0
  23. data/lib/fusion/cli/parser.rb +38 -0
  24. data/lib/fusion/cli/repl.rb +78 -0
  25. data/lib/fusion/cli/serializer.rb +70 -0
  26. data/lib/fusion/cli.rb +207 -0
  27. data/lib/fusion/interpreter/builtins.rb +465 -0
  28. data/lib/fusion/interpreter/env.rb +89 -0
  29. data/lib/fusion/interpreter/error_val.rb +71 -0
  30. data/lib/fusion/interpreter/func.rb +22 -0
  31. data/lib/fusion/interpreter/native_func.rb +22 -0
  32. data/lib/fusion/interpreter/thunk.rb +53 -0
  33. data/lib/fusion/interpreter.rb +752 -0
  34. data/lib/fusion/lexer.rb +249 -0
  35. data/lib/fusion/null.rb +9 -0
  36. data/lib/fusion/parser.rb +542 -0
  37. data/lib/fusion/token.rb +22 -0
  38. data/lib/fusion/typed_data.rb +23 -0
  39. data/lib/fusion/version.rb +1 -1
  40. data/lib/fusion/wire_pair.rb +11 -0
  41. data/lib/fusion.rb +11 -1122
  42. data/stdlib/all.fsn +13 -0
  43. data/stdlib/any.fsn +12 -0
  44. data/stdlib/chars.fsn +5 -0
  45. data/stdlib/compact.fsn +6 -0
  46. data/stdlib/concat.fsn +5 -0
  47. data/stdlib/falsey.fsn +6 -0
  48. data/stdlib/filter.fsn +12 -0
  49. data/stdlib/flatten.fsn +7 -0
  50. data/stdlib/gt.fsn +9 -0
  51. data/stdlib/gte.fsn +9 -0
  52. data/stdlib/lt.fsn +9 -0
  53. data/stdlib/lte.fsn +9 -0
  54. data/stdlib/map.fsn +6 -2
  55. data/stdlib/range.fsn +2 -1
  56. data/stdlib/reduce.fsn +8 -0
  57. data/stdlib/sanitize.fsn +12 -0
  58. data/stdlib/truthy.fsn +7 -0
  59. metadata +41 -2
  60. data/stdlib/math/square.fsn +0 -1
@@ -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` (along with its bundled `stdlib/` folder). All commands below can be run
9
- > from any directory containing your `.fsn` files.
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] | @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,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, a message like `"divide: division by zero"` on
275
- stderr, and the process will exit with status `1`. The result *was* an error, and
276
- the interpreter knows the difference: it routes the error's **payload** to
277
- stderr, leaves stdout empty, and signals failure. That makes Fusion programs
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** any regular JSON value.
281
- The built-ins above produced `!"divide: division by zero"` (an error whose payload
282
- is a string). You can construct your own:
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 (`@add`, `@Integer`),
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
@@ -1,4 +1,4 @@
1
1
  (
2
2
  [] => [],
3
- [first, ...rest] => [[first, first] | @add, ...rest | @]
3
+ [first, ...rest] => [2 * first, ...rest|@]
4
4
  )
data/examples/ends.fsn ADDED
@@ -0,0 +1,4 @@
1
+ (
2
+ [first, ..., last] => [first, last],
3
+ _ => !{ "message": "Requires an array with at least 2 elements!" }
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
+ }
@@ -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, _] ? ([first, ..., last] => [first, last] | @equals) => 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 '<json-input>' # input as an argument
12
- # fusion -e '(n => [n,2] | @multiply)' '21' # inline program
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
- args = ARGV.dup
17
- inline = nil
18
- if args[0] == "-e"
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
- stdlib = File.expand_path("../stdlib", __dir__)
28
- interp = Fusion::Interpreter.new(stdlib_dir: (Dir.exist?(stdlib) ? stdlib : nil))
29
-
30
- # Determine the program function value.
31
- program_value =
32
- if inline
33
- ast = Fusion::Parser.parse_file(inline)
34
- env = interp.root_env.child
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
@@ -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