fusion-lang 0.0.1.alpha1 → 0.0.1.alpha2

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.
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,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 | @equals => 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
8
  # fusion path/to/main.fsn '<json-input>' # input as an argument
12
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
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
+ options = begin
23
+ Fusion::CLI::Options.parse(ARGV)
24
+ 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.
27
+ abort("fusion: #{err.message}\n\n#{Fusion::CLI::Options::USAGE}")
57
28
  end
29
+
30
+ Fusion::CLI.run(options)
data/lib/fusion/ast.rb ADDED
@@ -0,0 +1,96 @@
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 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
+
56
+ constants.each do |name|
57
+ node = const_get(name)
58
+ node.include(self) if node.is_a?(Class) && node < Data
59
+ end
60
+ end
61
+
62
+ module Pattern
63
+ PLit = TypedData.define(value: Atom) # literal pattern
64
+ PErr = TypedData.define(inner: Pattern) # ! or !pat ; inner=PWild matches any error
65
+ PBind = TypedData.define(name: Identifier) # binds
66
+ PWild = TypedData.define(dummy: NilClass) # _
67
+ PArr = TypedData.define(items: ->(v) { # [PatternItem|PatternRest], at most one rest
68
+ v.is_a?(Array) &&
69
+ v.all? { |e| PatternItem === e || PatternRest === e } &&
70
+ v.count { |e| PatternRest === e } <= 1
71
+ })
72
+ PObj = TypedData.define(pairs: ->(v) { # [PatternPair|PatternRest], one rest, distinct keys
73
+ v.is_a?(Array) &&
74
+ v.all? { |m| PatternPair === m || PatternRest === m } &&
75
+ v.count { |m| PatternRest === m } <= 1 &&
76
+ v.filter_map { |m| m.key if PatternPair === m }.then { |keys| keys.uniq.size == keys.size }
77
+ })
78
+ PGuard = TypedData.define(inner: Pattern, pred_expr: Expression) # inner ? predicate
79
+
80
+ constants.each do |name|
81
+ node = const_get(name)
82
+ node.include(self) if node.is_a?(Class) && node < Data
83
+ end
84
+ end
85
+
86
+ module Statement
87
+ # The only statement. Only allowed in the REPL. `name = expression`.
88
+ Assignment = TypedData.define(name: Identifier, expression: Expression)
89
+
90
+ constants.each do |name|
91
+ node = const_get(name)
92
+ node.include(self) if node.is_a?(Class) && node < Data
93
+ end
94
+ end
95
+ end
96
+ 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,79 @@
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
+ ENVELOPE_SHAPES = {
14
+ array: "[0, _] or [1, _]",
15
+ object: '{"value": _} or {"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
+ WirePair.new(status: 1, data: payload.strip.empty? ? "null" : payload)
54
+ end
55
+
56
+ # array/object: the input is an envelope around the actual value. The block
57
+ # inspects the JSON (raw Ruby, so nulls are nil) and returns [status, inner]
58
+ # or nil for a wrong shape. The inner is re-emitted as JSON text for the
59
+ # pair; invalid JSON falls through to `parse` as a value to fail on, so the
60
+ # syntax_error stays in one place.
61
+ def decode_envelope(text, mode)
62
+ raw = JSON.parse(text)
63
+ status, inner = yield(raw)
64
+ return WirePair.new(status: status, data: JSON.generate(inner)) if status
65
+
66
+ WirePair.new(status: 1, data: JSON.generate(
67
+ "kind" => "argument_error",
68
+ "location" => "input",
69
+ "operation" => "decoding input",
70
+ "input" => raw,
71
+ "message" => "expected #{ENVELOPE_SHAPES.fetch(mode)}"
72
+ ))
73
+ rescue JSON::ParserError
74
+ # TODO: BUG ???
75
+ WirePair.new(status: 0, data: text)
76
+ end
77
+ end
78
+ end
79
+ 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
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === CLI internals ===
4
+ #
5
+ # Input: ARGV
6
+ # Output: Options (use case, input/output modes, program, input)
7
+ #
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.
11
+
12
+ module Fusion
13
+ module CLI
14
+ class Options
15
+ class UsageError < StandardError; end
16
+
17
+ USAGE = <<~TEXT
18
+ usage: fusion [options] <file.fsn> [json-input]
19
+ fusion [options] -e '<source>' [json-input]
20
+ fusion --repl
21
+
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`
26
+
27
+ 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
31
+ -! treat the input as an error value (unix input mode only)
32
+
33
+ modes: unix, bang, array, object
34
+ unix pipe only (default there): plain JSON; output: stdout/exit 0
35
+ 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
38
+ object {"value": _} marks a value, {"error": _} an error
39
+ TEXT
40
+
41
+ MODES = %w[unix bang array object].freeze
42
+
43
+ attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :explicit_input
44
+
45
+ def initialize(use_case:, input_mode:, output_mode:, inline_source:, program_path:, explicit_input:, error_input:)
46
+ @use_case = use_case
47
+ @input_mode = input_mode
48
+ @output_mode = output_mode
49
+ @inline_source = inline_source
50
+ @program_path = program_path
51
+ @explicit_input = explicit_input
52
+ @error_input = error_input
53
+ end
54
+
55
+ def error_input?
56
+ @error_input
57
+ end
58
+
59
+ def self.parse(argv)
60
+ arguments = argv.dup
61
+ use_case = :pipe
62
+ input_mode = nil
63
+ output_mode = nil
64
+ error_input = false
65
+ 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
86
+ end
87
+
88
+ validate(use_case, input_mode, output_mode, error_input, inline_source, positional)
89
+ end
90
+
91
+ # 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)
93
+ case use_case
94
+ when :repl
95
+ unless input_mode.nil? && output_mode.nil? && !error_input && inline_source.nil? && positional.empty?
96
+ raise UsageError, "--repl takes no program, no input, and no modes"
97
+ end
98
+ program_path = explicit_input = nil
99
+ when :stream
100
+ input_mode ||= :bang
101
+ output_mode ||= :bang
102
+ raise UsageError, "--stream does not support the unix mode" if input_mode == :unix || output_mode == :unix
103
+ raise UsageError, "-! requires the unix input mode" if error_input
104
+ program_path = inline_source ? nil : positional.shift
105
+ 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
108
+ when :pipe
109
+ input_mode ||= :unix
110
+ output_mode ||= :unix
111
+ raise UsageError, "-! requires the unix input mode" if error_input && input_mode != :unix
112
+ program_path = inline_source ? nil : positional.shift
113
+ 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?
116
+ else
117
+ raise Unreachable, "Unknown use case #{use_case}"
118
+ end
119
+
120
+ new(
121
+ use_case: use_case,
122
+ input_mode: input_mode,
123
+ output_mode: output_mode,
124
+ inline_source: inline_source,
125
+ program_path: program_path,
126
+ explicit_input: explicit_input,
127
+ error_input: error_input
128
+ )
129
+ end
130
+
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
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === CLI internals ===
4
+
5
+ require "json"
6
+
7
+ module Fusion
8
+ module CLI
9
+ module Parser
10
+ extend self
11
+
12
+ # WirePair -> runtime value
13
+ def parse(wire_pair)
14
+ value = convert(JSON.parse(wire_pair.data))
15
+ wire_pair.status == 1 ? Interpreter::ErrorVal.new(value) : value
16
+ rescue JSON::ParserError
17
+ Interpreter::ErrorVal.internal(
18
+ kind: "syntax_error",
19
+ location: "input",
20
+ operation: "parsing input as JSON",
21
+ input: wire_pair.data,
22
+ message: "input is not valid JSON"
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def convert(ruby_value)
29
+ case ruby_value
30
+ when nil then NULL
31
+ when Array then ruby_value.map { |item| convert(item) }
32
+ when Hash then ruby_value.transform_values { |value| convert(value) }
33
+ else ruby_value
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === CLI internals ===
4
+
5
+ require_relative "serializer"
6
+ require_relative "encoder"
7
+ require_relative "../interpreter/env"
8
+
9
+ module Fusion
10
+ module CLI
11
+ class Repl
12
+ PROMPT = "fsn> "
13
+ CONTINUATION_PROMPT = "...> "
14
+
15
+ # REPL entries report errors with the same location as inline (`-e`) code.
16
+ LOCATION = "code <inline>"
17
+
18
+ def run
19
+ require "reline"
20
+ $stdout.sync = true
21
+ Reline.output = $stderr
22
+ Reline.prompt_proc = proc do |lines|
23
+ lines.each_index.map { |i| i.zero? ? PROMPT : CONTINUATION_PROMPT }
24
+ end
25
+
26
+ environment = Interpreter::Env.new.define("__dir__", Dir.pwd)
27
+
28
+ loop do
29
+ buffer = begin
30
+ Reline.readmultiline(PROMPT, true) { complete?(_1) }
31
+ rescue Interrupt
32
+ $stderr.puts("^C") # discard the half-typed entry and re-prompt
33
+ next
34
+ end
35
+
36
+ break if buffer.nil? # Ctrl-D on an empty line ends the session
37
+ next if buffer.strip.empty?
38
+
39
+ $stdout.puts(handle(buffer, environment))
40
+ end
41
+ end
42
+
43
+ def complete?(buffer)
44
+ return true if buffer.strip.empty?
45
+
46
+ ast = Fusion::Parser.parse_repl(buffer, location: LOCATION)
47
+ ast.is_a?(AST::Expression) || ast.is_a?(AST::Statement::Assignment)
48
+ end
49
+
50
+ def handle(buffer, environment)
51
+ ast = Fusion::Parser.parse_repl(buffer, location: LOCATION)
52
+ runtime_value = evaluate(ast, environment)
53
+ wire_pair = Serializer.serialize(runtime_value, lenient: true)
54
+ Encoder.encode(wire_pair, mode: :bang)
55
+ 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
+ end
72
+ end
73
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === CLI internals ===
4
+
5
+ require_relative "../wire_pair"
6
+
7
+ module Fusion
8
+ module CLI
9
+ module Serializer
10
+ extend self
11
+
12
+ # runtime value -> WirePair
13
+ # Only use "lenient: true" in the REPL!
14
+ def serialize(runtime_value, lenient: false)
15
+ message = catch(:unserializable) do
16
+ if runtime_value.is_a?(Interpreter::ErrorVal)
17
+ data = convert(runtime_value.payload, lenient: lenient || runtime_value.internal_error?).to_json
18
+ return WirePair.new(status: 1, data: data)
19
+ else
20
+ return WirePair.new(status: 0, data: convert(runtime_value, lenient: lenient).to_json)
21
+ end
22
+ end
23
+
24
+ internal_error = Interpreter::ErrorVal.internal(
25
+ kind: "serialization_error",
26
+ location: "output",
27
+ operation: "serializing result",
28
+ input: runtime_value,
29
+ message: message
30
+ )
31
+
32
+ serialize(internal_error, lenient: true)
33
+ end
34
+
35
+ private
36
+
37
+ # Use "lenient: true" only for best-effort serialization of internal errors and the REPL.
38
+ def convert(runtime_value, lenient: false)
39
+ case runtime_value
40
+ when NULL
41
+ nil
42
+ when Float
43
+ return runtime_value if runtime_value.finite?
44
+ throw(:unserializable, "cannot serialize a non-finite number") unless lenient
45
+
46
+ "<#{runtime_value}>" # "<Infinity>" / "<-Infinity>" / "<NaN>"
47
+ when Array
48
+ runtime_value.map { |item| convert(item, lenient:) }
49
+ when Hash
50
+ runtime_value.transform_values { |value| convert(value, lenient:) }
51
+ when Interpreter::Func, Interpreter::NativeFunc
52
+ throw(:unserializable, "cannot serialize a function") unless lenient
53
+
54
+ "<function>"
55
+ when true, false, String, Numeric
56
+ runtime_value
57
+ when Interpreter::ErrorVal
58
+ if lenient
59
+ "!#{convert(runtime_value.payload, lenient:).to_json}"
60
+ else
61
+ raise Unreachable, "ErrorVal should have been handled at the top level of convert"
62
+ end
63
+ else
64
+ raise Unreachable, "Unhandled type in convert: #{runtime_value.class}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end