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.
- checksums.yaml +4 -4
- data/README.md +12 -0
- data/Rakefile +9 -0
- data/docs/lang/design.md +204 -3
- data/docs/lang/roadmap.md +1 -22
- data/docs/user/how-to-guides.md +5 -11
- data/docs/user/reference.md +342 -128
- data/docs/user/tutorial.md +11 -10
- data/examples/ends.fsn +4 -0
- data/examples/palindrome.fsn +2 -1
- data/exe/fusion +15 -42
- data/lib/fusion/ast.rb +96 -0
- data/lib/fusion/atom.rb +17 -0
- data/lib/fusion/cli/decoder.rb +79 -0
- data/lib/fusion/cli/encoder.rb +28 -0
- data/lib/fusion/cli/options.rb +142 -0
- data/lib/fusion/cli/parser.rb +38 -0
- data/lib/fusion/cli/repl.rb +73 -0
- data/lib/fusion/cli/serializer.rb +69 -0
- data/lib/fusion/cli.rb +136 -0
- data/lib/fusion/interpreter/builtins.rb +356 -0
- data/lib/fusion/interpreter/env.rb +59 -0
- data/lib/fusion/interpreter/error_val.rb +49 -0
- data/lib/fusion/interpreter/file_thunk.rb +39 -0
- data/lib/fusion/interpreter/func.rb +22 -0
- data/lib/fusion/interpreter/native_func.rb +22 -0
- data/lib/fusion/interpreter.rb +595 -0
- data/lib/fusion/lexer.rb +183 -0
- data/lib/fusion/null.rb +9 -0
- data/lib/fusion/parser.rb +404 -0
- data/lib/fusion/token.rb +22 -0
- data/lib/fusion/typed_data.rb +23 -0
- data/lib/fusion/version.rb +1 -1
- data/lib/fusion/wire_pair.rb +11 -0
- data/lib/fusion.rb +11 -1122
- data/stdlib/map.fsn +3 -1
- data/stdlib/mapValues.fsn +5 -0
- data/stdlib/math/square.fsn +4 -1
- data/stdlib/range.fsn +2 -1
- data/stdlib/sanitize.fsn +12 -0
- metadata +26 -1
data/examples/ends.fsn
ADDED
data/examples/palindrome.fsn
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Check, whether input string or array is a palindrome
|
|
2
2
|
(
|
|
3
|
+
n ? @Number => n | @toString | @,
|
|
3
4
|
s ? @String => s | @chars | @,
|
|
4
5
|
[] => "palindrome",
|
|
5
6
|
[_] => "palindrome",
|
|
6
|
-
[_, ...rest, _] ?
|
|
7
|
+
[_, ...rest, _] ? @ends | @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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
inline = args[1]
|
|
20
|
-
program_path = nil
|
|
21
|
-
explicit_input = args[2]
|
|
22
|
-
else
|
|
23
|
-
program_path = args[0]
|
|
24
|
-
explicit_input = args[1]
|
|
17
|
+
if ARGV.intersect?(["--help", "-h"])
|
|
18
|
+
puts Fusion::CLI::Options::USAGE
|
|
19
|
+
exit 0
|
|
25
20
|
end
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
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
|
data/lib/fusion/atom.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Value ===
|
|
4
|
+
#
|
|
5
|
+
# An atomic runtime value.
|
|
6
|
+
|
|
7
|
+
require_relative "typed_data"
|
|
8
|
+
require_relative "null"
|
|
9
|
+
|
|
10
|
+
module Fusion
|
|
11
|
+
# A scalar literal value: the JSON atoms plus NULL (everything the lexer
|
|
12
|
+
# emits as a token value, see Lexer#lex_number and #lex_word).
|
|
13
|
+
Atom = ->(v) {
|
|
14
|
+
v == NULL || v == true || v == false ||
|
|
15
|
+
v.is_a?(Integer) || v.is_a?(Float) || v.is_a?(String)
|
|
16
|
+
}
|
|
17
|
+
end
|
|
@@ -0,0 +1,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
|