fusion-lang 0.0.1.alpha2 → 0.0.2
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/.mutant.yml +24 -0
- data/.simplecov +11 -0
- data/CHANGELOG.md +42 -0
- data/README.md +11 -9
- data/Rakefile +8 -0
- data/docs/lang/design.md +289 -51
- data/docs/lang/implementation.md +279 -0
- data/docs/lang/roadmap.md +20 -36
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +145 -15
- data/docs/user/reference.md +365 -140
- data/docs/user/tutorial.md +22 -19
- data/examples/double.fsn +4 -1
- data/examples/factorial.fsn +6 -3
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/gcd.fsn +9 -0
- data/examples/json_test.fsn +4 -0
- data/examples/matrix/OP.fsn +2 -0
- data/examples/matrix/average.fsn +2 -0
- data/examples/matrix/solve.fsn +2 -0
- data/examples/palindrome.fsn +1 -1
- data/exe/fusion +12 -12
- data/lib/fusion/ast.rb +76 -27
- data/lib/fusion/atom.rb +1 -1
- data/lib/fusion/cli/decoder.rb +13 -8
- data/lib/fusion/cli/encoder.rb +2 -2
- data/lib/fusion/cli/options.rb +134 -61
- data/lib/fusion/cli/parser.rb +4 -4
- data/lib/fusion/cli/repl.rb +32 -27
- data/lib/fusion/cli/serializer.rb +11 -11
- data/lib/fusion/cli.rb +120 -49
- data/lib/fusion/interpreter/builtins.rb +298 -160
- data/lib/fusion/interpreter/env.rb +42 -12
- data/lib/fusion/interpreter/error_val.rb +42 -20
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +263 -98
- data/lib/fusion/lexer.rb +125 -37
- data/lib/fusion/parser.rb +245 -70
- data/lib/fusion/version.rb +3 -1
- data/lib/fusion.rb +0 -1
- data/stdlib/all.fsn +13 -0
- data/stdlib/any.fsn +12 -0
- data/stdlib/chars.fsn +5 -0
- data/stdlib/compact.fsn +5 -0
- data/stdlib/concat.fsn +6 -0
- data/stdlib/entries.fsn +6 -0
- data/stdlib/falsey.fsn +6 -0
- data/stdlib/filter.fsn +12 -0
- data/stdlib/flatten.fsn +7 -0
- data/stdlib/map.fsn +7 -4
- data/stdlib/matrix/Matrix.fsn +8 -0
- data/stdlib/matrix/OP.fsn +18 -0
- data/stdlib/matrix/add.fsn +7 -0
- data/stdlib/matrix/column.fsn +6 -0
- data/stdlib/matrix/determinant.fsn +16 -0
- data/stdlib/matrix/dimensions.fsn +5 -0
- data/stdlib/matrix/identity.fsn +6 -0
- data/stdlib/matrix/invert.fsn +26 -0
- data/stdlib/matrix/minor.fsn +15 -0
- data/stdlib/matrix/multiply.fsn +10 -0
- data/stdlib/matrix/negate.fsn +5 -0
- data/stdlib/matrix/product.fsn +11 -0
- data/stdlib/matrix/rotate.fsn +10 -0
- data/stdlib/matrix/row.fsn +6 -0
- data/stdlib/matrix/scale.fsn +6 -0
- data/stdlib/matrix/subtract.fsn +7 -0
- data/stdlib/matrix/sum.fsn +14 -0
- data/stdlib/matrix/transpose.fsn +5 -0
- data/stdlib/range.fsn +2 -2
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/safe.fsn +7 -0
- data/stdlib/sanitize.fsn +2 -3
- data/stdlib/toObject.fsn +7 -0
- data/stdlib/truthy.fsn +7 -0
- data/stdlib/vector/Vector.fsn +7 -0
- data/stdlib/vector/add.fsn +6 -0
- data/stdlib/vector/cross.fsn +6 -0
- data/stdlib/vector/dot.fsn +6 -0
- data/stdlib/vector/norm.fsn +6 -0
- data/stdlib/vector/scale.fsn +5 -0
- data/stdlib/vector/subtract.fsn +6 -0
- data/stdlib/zip.fsn +6 -0
- metadata +50 -4
- data/lib/fusion/interpreter/file_thunk.rb +0 -39
- data/stdlib/mapValues.fsn +0 -5
- data/stdlib/math/square.fsn +0 -4
data/lib/fusion/cli/options.rb
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
# Output: Options (use case, input/output modes, program, input)
|
|
7
7
|
#
|
|
8
8
|
# A misuse of the command line is a UsageError, reported as plain text on
|
|
9
|
-
# stderr by exe/fusion
|
|
10
|
-
#
|
|
9
|
+
# stderr by exe/fusion, never a payloaded Fusion error. Most surface here while
|
|
10
|
+
# parsing options; `-!` with empty stdin is the one caught later, while reading
|
|
11
|
+
# input.
|
|
12
|
+
|
|
13
|
+
require "optparse"
|
|
11
14
|
|
|
12
15
|
module Fusion
|
|
13
16
|
module CLI
|
|
@@ -15,104 +18,181 @@ module Fusion
|
|
|
15
18
|
class UsageError < StandardError; end
|
|
16
19
|
|
|
17
20
|
USAGE = <<~TEXT
|
|
18
|
-
usage: fusion [options] <file.fsn>
|
|
19
|
-
fusion [options] -e '<source>'
|
|
21
|
+
usage: fusion [options] <file.fsn>
|
|
22
|
+
fusion [options] -e '<source>'
|
|
20
23
|
fusion --repl
|
|
21
24
|
|
|
22
|
-
use cases:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
--
|
|
25
|
+
use cases (default: --repl with no arguments, otherwise --pipe):
|
|
26
|
+
-p, --pipe apply the program to stdin; with no input, the
|
|
27
|
+
program's own value is the result
|
|
28
|
+
-s, --stream apply the program to each line of an NDJSON stream
|
|
29
|
+
-r, --repl interactive expressions and `identifier = expression`
|
|
26
30
|
|
|
27
31
|
options:
|
|
28
|
-
-e '<source>'
|
|
29
|
-
|
|
30
|
-
--
|
|
32
|
+
-e, --execute '<source>'
|
|
33
|
+
inline program instead of a file
|
|
34
|
+
-i, --input MODE
|
|
35
|
+
how the input marks an error value
|
|
36
|
+
-o, --output MODE
|
|
37
|
+
how the output marks an error value
|
|
38
|
+
-j, --jail DIR confine @-references to DIR and its subtree
|
|
39
|
+
(default: the program's directory; the stdlib is
|
|
40
|
+
always reachable, stdin never contains @-references;
|
|
41
|
+
use '*' to disable confinement)
|
|
31
42
|
-! treat the input as an error value (unix input mode only)
|
|
43
|
+
-b, --skip-blank-lines
|
|
44
|
+
drop blank input lines instead of echoing them (--stream only)
|
|
32
45
|
|
|
33
46
|
modes: unix, bang, array, object
|
|
34
47
|
unix pipe only (default there): plain JSON; output: stdout/exit 0
|
|
35
48
|
for values, stderr/exit 1 for error payloads
|
|
36
|
-
bang a leading "!" marks an error value
|
|
37
|
-
|
|
49
|
+
bang a leading "!" marks an error value; cheapest encoding, but not
|
|
50
|
+
valid JSON — prefer it only between Fusion programs
|
|
51
|
+
array [0, value] marks a value, [1, payload] an error (default for --stream)
|
|
38
52
|
object {"value": _} marks a value, {"error": _} an error
|
|
39
53
|
TEXT
|
|
40
54
|
|
|
41
|
-
MODES =
|
|
55
|
+
MODES = ['unix', 'bang', 'array', 'object'].freeze
|
|
42
56
|
|
|
43
|
-
attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :
|
|
57
|
+
attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :jail
|
|
44
58
|
|
|
45
|
-
def initialize(use_case:, input_mode:, output_mode:, inline_source:, program_path:,
|
|
59
|
+
def initialize(use_case:, input_mode:, output_mode:, inline_source:, program_path:, error_input:, skip_blank_lines:, jail:)
|
|
46
60
|
@use_case = use_case
|
|
47
61
|
@input_mode = input_mode
|
|
48
62
|
@output_mode = output_mode
|
|
49
63
|
@inline_source = inline_source
|
|
50
64
|
@program_path = program_path
|
|
51
|
-
@explicit_input = explicit_input
|
|
52
65
|
@error_input = error_input
|
|
66
|
+
@skip_blank_lines = skip_blank_lines
|
|
67
|
+
@jail = jail
|
|
53
68
|
end
|
|
54
69
|
|
|
55
70
|
def error_input?
|
|
56
71
|
@error_input
|
|
57
72
|
end
|
|
58
73
|
|
|
74
|
+
def skip_blank_lines?
|
|
75
|
+
@skip_blank_lines
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
def self.parse(argv)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
pipe = false
|
|
80
|
+
stream = false
|
|
81
|
+
repl = false
|
|
82
|
+
input_modes = []
|
|
83
|
+
output_modes = []
|
|
64
84
|
error_input = false
|
|
85
|
+
skip_blank_lines = false
|
|
65
86
|
inline_source = nil
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
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 }
|
|
86
99
|
end
|
|
100
|
+
parser.require_exact = true # no abbreviations: "--s" is not a stand-in for "--stream"
|
|
87
101
|
|
|
88
|
-
|
|
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(', ')}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
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 => e
|
|
142
|
+
raise UsageError, "unknown option #{e.args.join(' ')}"
|
|
143
|
+
rescue OptionParser::MissingArgument => e
|
|
144
|
+
raise UsageError, missing_argument_message(e.args.first)
|
|
145
|
+
rescue OptionParser::ParseError => e
|
|
146
|
+
raise UsageError, e.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
|
-
|
|
177
|
+
|
|
178
|
+
program_path = nil
|
|
99
179
|
when :stream
|
|
100
|
-
input_mode ||= :
|
|
101
|
-
output_mode ||= :
|
|
180
|
+
input_mode ||= :array
|
|
181
|
+
output_mode ||= :array
|
|
102
182
|
raise UsageError, "--stream does not support the unix mode" if input_mode == :unix || output_mode == :unix
|
|
103
183
|
raise UsageError, "-! requires the unix input mode" if error_input
|
|
184
|
+
|
|
104
185
|
program_path = inline_source ? nil : positional.shift
|
|
105
186
|
raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
|
|
106
|
-
raise UsageError, "
|
|
107
|
-
explicit_input = nil
|
|
187
|
+
raise UsageError, "too many positional arguments" unless positional.empty?
|
|
108
188
|
when :pipe
|
|
109
189
|
input_mode ||= :unix
|
|
110
190
|
output_mode ||= :unix
|
|
111
191
|
raise UsageError, "-! requires the unix input mode" if error_input && input_mode != :unix
|
|
192
|
+
|
|
112
193
|
program_path = inline_source ? nil : positional.shift
|
|
113
194
|
raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
|
|
114
|
-
|
|
115
|
-
raise UsageError, "too many arguments: #{positional.join(' ')}" unless positional.empty?
|
|
195
|
+
raise UsageError, "too many positional arguments" unless positional.empty?
|
|
116
196
|
else
|
|
117
197
|
raise Unreachable, "Unknown use case #{use_case}"
|
|
118
198
|
end
|
|
@@ -123,20 +203,13 @@ module Fusion
|
|
|
123
203
|
output_mode: output_mode,
|
|
124
204
|
inline_source: inline_source,
|
|
125
205
|
program_path: program_path,
|
|
126
|
-
|
|
127
|
-
|
|
206
|
+
error_input: error_input,
|
|
207
|
+
skip_blank_lines: skip_blank_lines,
|
|
208
|
+
jail: jail,
|
|
128
209
|
)
|
|
129
210
|
end
|
|
130
211
|
|
|
131
|
-
|
|
132
|
-
mode = arguments.shift
|
|
133
|
-
unless MODES.include?(mode)
|
|
134
|
-
raise UsageError, "#{flag} expects one of: #{MODES.join(', ')} (got #{mode.nil? ? 'nothing' : mode})"
|
|
135
|
-
end
|
|
136
|
-
mode.to_sym
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
private_class_method :validate, :shift_mode
|
|
212
|
+
private_class_method :validate, :resolve_use_case, :resolve_mode, :run_parser, :check_mode!, :missing_argument_message
|
|
140
213
|
end
|
|
141
214
|
end
|
|
142
215
|
end
|
data/lib/fusion/cli/parser.rb
CHANGED
|
@@ -14,12 +14,12 @@ module Fusion
|
|
|
14
14
|
value = convert(JSON.parse(wire_pair.data))
|
|
15
15
|
wire_pair.status == 1 ? Interpreter::ErrorVal.new(value) : value
|
|
16
16
|
rescue JSON::ParserError
|
|
17
|
-
Interpreter::ErrorVal.
|
|
17
|
+
Interpreter::ErrorVal.from_runtime(
|
|
18
18
|
kind: "syntax_error",
|
|
19
|
-
|
|
20
|
-
operation: "parsing
|
|
19
|
+
origin: "input",
|
|
20
|
+
operation: "parsing JSON",
|
|
21
21
|
input: wire_pair.data,
|
|
22
|
-
message: "input is not valid JSON"
|
|
22
|
+
message: "input is not valid JSON",
|
|
23
23
|
)
|
|
24
24
|
end
|
|
25
25
|
|
data/lib/fusion/cli/repl.rb
CHANGED
|
@@ -9,65 +9,70 @@ require_relative "../interpreter/env"
|
|
|
9
9
|
module Fusion
|
|
10
10
|
module CLI
|
|
11
11
|
class Repl
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
RESET = "\e[0m"
|
|
13
|
+
LIGHT_BLUE = "\e[94m"
|
|
14
|
+
GREEN = "\e[32m"
|
|
15
|
+
RED = "\e[31m"
|
|
14
16
|
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
+
PROMPT = "#{LIGHT_BLUE}fsn> #{RESET}".freeze
|
|
18
|
+
CONTINUATION_PROMPT = "#{LIGHT_BLUE}...> #{RESET}".freeze
|
|
19
|
+
VALUE_MARKER = "#{GREEN}✔ #{RESET}".freeze
|
|
20
|
+
ERROR_MARKER = "#{RED}✗ #{RESET}".freeze
|
|
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
|
-
lines.each_index.map { |i| i
|
|
35
|
+
lines.each_index.map { |i| i == 0 ? PROMPT : CONTINUATION_PROMPT }
|
|
24
36
|
end
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
# The session env is a child of the run's root, so it carries the jail;
|
|
39
|
+
# bindings accumulate here while loaded files stay isolated at the root.
|
|
40
|
+
environment = @root_env.child.set_context(:dir, Dir.pwd)
|
|
27
41
|
|
|
28
42
|
loop do
|
|
29
43
|
buffer = begin
|
|
30
44
|
Reline.readmultiline(PROMPT, true) { complete?(_1) }
|
|
31
45
|
rescue Interrupt
|
|
32
|
-
|
|
46
|
+
warn("^C") # discard the half-typed entry and re-prompt
|
|
33
47
|
next
|
|
34
48
|
end
|
|
35
49
|
|
|
36
50
|
break if buffer.nil? # Ctrl-D on an empty line ends the session
|
|
37
51
|
next if buffer.strip.empty?
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
output = handle(buffer, environment)
|
|
54
|
+
|
|
55
|
+
marker = output.start_with?("!") ? ERROR_MARKER : VALUE_MARKER
|
|
56
|
+
$stderr.print(marker) # decoration on stderr
|
|
57
|
+
$stdout.puts(output) # the clean value on stdout
|
|
40
58
|
end
|
|
41
59
|
end
|
|
42
60
|
|
|
61
|
+
# String -> yes/no
|
|
43
62
|
def complete?(buffer)
|
|
44
63
|
return true if buffer.strip.empty?
|
|
45
64
|
|
|
46
|
-
ast = Fusion::Parser.parse_repl(buffer,
|
|
65
|
+
ast = Fusion::Parser.parse_repl(buffer, site: SITE)
|
|
47
66
|
ast.is_a?(AST::Expression) || ast.is_a?(AST::Statement::Assignment)
|
|
48
67
|
end
|
|
49
68
|
|
|
69
|
+
# String (+ Env) -> String
|
|
50
70
|
def handle(buffer, environment)
|
|
51
|
-
ast = Fusion::Parser.parse_repl(buffer,
|
|
52
|
-
runtime_value = evaluate(ast, environment)
|
|
71
|
+
ast = Fusion::Parser.parse_repl(buffer, site: SITE)
|
|
72
|
+
runtime_value = CLI.evaluate(ast, environment)
|
|
53
73
|
wire_pair = Serializer.serialize(runtime_value, lenient: true)
|
|
54
74
|
Encoder.encode(wire_pair, mode: :bang)
|
|
55
75
|
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def evaluate(ast, environment)
|
|
60
|
-
case ast
|
|
61
|
-
when AST::Expression
|
|
62
|
-
Interpreter.safe_evaluate(ast, environment)
|
|
63
|
-
when AST::Statement::Assignment
|
|
64
|
-
value = Interpreter.safe_evaluate(ast.expression, environment)
|
|
65
|
-
environment.define(ast.name, value)
|
|
66
|
-
value
|
|
67
|
-
else
|
|
68
|
-
raise Unreachable, "Unhandled AST node #{ast.class}"
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
76
|
end
|
|
72
77
|
end
|
|
73
78
|
end
|
|
@@ -13,23 +13,24 @@ module Fusion
|
|
|
13
13
|
# Only use "lenient: true" in the REPL!
|
|
14
14
|
def serialize(runtime_value, lenient: false)
|
|
15
15
|
message = catch(:unserializable) do
|
|
16
|
-
if runtime_value.is_a?(Interpreter::ErrorVal)
|
|
17
|
-
|
|
16
|
+
if runtime_value.is_a?(Interpreter::ErrorVal) # rubocop:disable Style/GuardClause
|
|
17
|
+
error = runtime_value
|
|
18
|
+
data = convert(error.payload, lenient: lenient || error.runtime?).to_json
|
|
18
19
|
return WirePair.new(status: 1, data: data)
|
|
19
20
|
else
|
|
20
21
|
return WirePair.new(status: 0, data: convert(runtime_value, lenient: lenient).to_json)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
runtime_error = Interpreter::ErrorVal.from_runtime(
|
|
25
26
|
kind: "serialization_error",
|
|
26
|
-
|
|
27
|
+
origin: "output",
|
|
27
28
|
operation: "serializing result",
|
|
28
29
|
input: runtime_value,
|
|
29
|
-
message: message
|
|
30
|
+
message: message,
|
|
30
31
|
)
|
|
31
32
|
|
|
32
|
-
serialize(
|
|
33
|
+
serialize(runtime_error, lenient: true)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
private
|
|
@@ -41,6 +42,7 @@ module Fusion
|
|
|
41
42
|
nil
|
|
42
43
|
when Float
|
|
43
44
|
return runtime_value if runtime_value.finite?
|
|
45
|
+
|
|
44
46
|
throw(:unserializable, "cannot serialize a non-finite number") unless lenient
|
|
45
47
|
|
|
46
48
|
"<#{runtime_value}>" # "<Infinity>" / "<-Infinity>" / "<NaN>"
|
|
@@ -55,11 +57,9 @@ module Fusion
|
|
|
55
57
|
when true, false, String, Numeric
|
|
56
58
|
runtime_value
|
|
57
59
|
when Interpreter::ErrorVal
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
raise Unreachable, "ErrorVal should have been handled at the top level of convert"
|
|
62
|
-
end
|
|
60
|
+
raise Unreachable, "ErrorVal should have been handled at the top level of convert" unless lenient
|
|
61
|
+
|
|
62
|
+
"!#{convert(runtime_value.payload, lenient:).to_json}"
|
|
63
63
|
else
|
|
64
64
|
raise Unreachable, "Unhandled type in convert: #{runtime_value.class}"
|
|
65
65
|
end
|
data/lib/fusion/cli.rb
CHANGED
|
@@ -21,40 +21,67 @@ module Fusion
|
|
|
21
21
|
module CLI
|
|
22
22
|
extend self
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
def prepare!
|
|
25
|
+
$stdout.sync = true
|
|
26
|
+
$stderr.sync = true
|
|
27
|
+
$stdin.set_encoding(Encoding::UTF_8)
|
|
28
|
+
$stdout.set_encoding(Encoding::UTF_8)
|
|
29
|
+
$stderr.set_encoding(Encoding::UTF_8)
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
def run(options)
|
|
26
33
|
case options.use_case
|
|
27
|
-
when :pipe
|
|
28
|
-
|
|
29
|
-
when :
|
|
30
|
-
|
|
34
|
+
when :pipe
|
|
35
|
+
run_pipe(options)
|
|
36
|
+
when :stream
|
|
37
|
+
run_stream(options)
|
|
38
|
+
when :repl
|
|
39
|
+
run_repl(options)
|
|
40
|
+
else
|
|
41
|
+
raise Unreachable, "Unknown use case #{options.use_case}"
|
|
31
42
|
end
|
|
32
43
|
end
|
|
33
44
|
|
|
34
|
-
# pipe: load the program, pipe one input through it, emit one output.
|
|
35
45
|
def run_pipe(options)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
prepare!
|
|
47
|
+
|
|
48
|
+
root = root_environment(jail: jail_root(options))
|
|
49
|
+
program = load_program(options, root)
|
|
50
|
+
|
|
51
|
+
input = load_input(options)
|
|
52
|
+
output = if input.nil?
|
|
53
|
+
program
|
|
54
|
+
else
|
|
55
|
+
apply(parse(input), program, environment: root)
|
|
56
|
+
end
|
|
57
|
+
|
|
39
58
|
emit_output(serialize(output), output_mode: options.output_mode)
|
|
40
59
|
end
|
|
41
60
|
|
|
42
|
-
# stream: load the program once, then treat stdin/stdout as NDJSON streams —
|
|
43
|
-
# one input per line, one output line per input. Errors stay in-band (the
|
|
44
|
-
# unix mode is unavailable here), so the exit code is always 0.
|
|
45
61
|
def run_stream(options)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
prepare!
|
|
63
|
+
|
|
64
|
+
root = root_environment(jail: jail_root(options))
|
|
65
|
+
program = load_program(options, root)
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
$stdin.each_line do |line|
|
|
68
|
+
record = line.chomp
|
|
69
|
+
|
|
70
|
+
if record.strip.empty?
|
|
71
|
+
$stdout.puts unless options.skip_blank_lines?
|
|
72
|
+
else
|
|
73
|
+
input = decode(record, mode: options.input_mode)
|
|
74
|
+
output = apply(parse(input), program, environment: root)
|
|
75
|
+
$stdout.puts(encode(serialize(output), mode: options.output_mode))
|
|
76
|
+
end
|
|
54
77
|
end
|
|
55
78
|
end
|
|
56
79
|
|
|
57
|
-
|
|
80
|
+
def run_repl(options)
|
|
81
|
+
Repl.new(root_env: root_environment(jail: jail_root(options))).run
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# String (treated as stdin) -> WirePair
|
|
58
85
|
# Doesn't support mode `:unix`
|
|
59
86
|
def decode(string, mode:)
|
|
60
87
|
Decoder.decode(string, mode:)
|
|
@@ -65,18 +92,56 @@ module Fusion
|
|
|
65
92
|
Parser.parse(wire_pair)
|
|
66
93
|
end
|
|
67
94
|
|
|
95
|
+
# A binding-free root environment
|
|
96
|
+
def root_environment(jail: Dir.pwd)
|
|
97
|
+
Interpreter::Env.new.set_context(:jail, jail)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# String (treated as inline source) -> runtime value
|
|
101
|
+
def load_source(inline_source, root_env)
|
|
102
|
+
ast = Fusion::Parser.parse_file(inline_source, site: { origin: "code", file: "<inline>" })
|
|
103
|
+
return ast if ast.is_a?(Fusion::Interpreter::ErrorVal) # a parse error
|
|
104
|
+
|
|
105
|
+
inline_env = root_env.child.set_context(:dir, Dir.pwd)
|
|
106
|
+
Fusion::Interpreter.new(inline_env).evaluate_unit(ast)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# relative path -> runtime_value
|
|
110
|
+
def load_file(rel_path, root_env)
|
|
111
|
+
interp = Fusion::Interpreter.new(root_env)
|
|
112
|
+
abspath = File.expand_path(rel_path)
|
|
113
|
+
# The top-level program file is loaded by the runtime, not via an @-reference:
|
|
114
|
+
# the operation is "loading code", with the path as `input` (which file).
|
|
115
|
+
interp.load_file(abspath).force(operation: "loading code", input: interp.display_path(abspath), site: { origin: "code", file: nil })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# runtime value + runtime value -> runtime value
|
|
68
119
|
# input | function -> output
|
|
69
|
-
|
|
70
|
-
|
|
120
|
+
# Confines @-resolution to the environment's jail.
|
|
121
|
+
# Ignores the environment's bindings. The function carries its own closure.
|
|
122
|
+
def apply(input, function, environment:)
|
|
123
|
+
Interpreter.safe_apply(function, input, environment)
|
|
71
124
|
end
|
|
72
125
|
|
|
73
|
-
# expression -> runtime value
|
|
74
|
-
# Mutates environment
|
|
75
|
-
|
|
76
|
-
|
|
126
|
+
# expression (AST) -> runtime value
|
|
127
|
+
# Mutates environment if given an assignment statement.
|
|
128
|
+
# Confines @-resolution to the environment's jail.
|
|
129
|
+
# Has access to the environment's bindings.
|
|
130
|
+
def evaluate(ast, environment)
|
|
131
|
+
case ast
|
|
132
|
+
when AST::Statement::Assignment
|
|
133
|
+
value = Interpreter.safe_evaluate(ast.expression, environment)
|
|
134
|
+
environment.bind(ast.name, value, checked: false)
|
|
135
|
+
value
|
|
136
|
+
when AST::Expression
|
|
137
|
+
Interpreter.safe_evaluate(ast, environment)
|
|
138
|
+
else
|
|
139
|
+
raise Unreachable, "Unhandled AST node #{ast.class}"
|
|
140
|
+
end
|
|
77
141
|
end
|
|
78
142
|
|
|
79
143
|
# runtime value -> WirePair
|
|
144
|
+
# CAUTION: resolves "internal" error status, only use for final output
|
|
80
145
|
def serialize(runtime_value)
|
|
81
146
|
Serializer.serialize(runtime_value)
|
|
82
147
|
end
|
|
@@ -89,25 +154,26 @@ module Fusion
|
|
|
89
154
|
|
|
90
155
|
private
|
|
91
156
|
|
|
92
|
-
#
|
|
157
|
+
# stdin -> WirePair
|
|
93
158
|
def load_input(options)
|
|
94
|
-
text =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
159
|
+
text = $stdin.tty? ? "" : $stdin.read.strip
|
|
160
|
+
|
|
161
|
+
if text.empty?
|
|
162
|
+
# "-!" promises that stdin carries an error payload. CLI contract violation.
|
|
163
|
+
raise Options::UsageError, "-! requires input to mark as an error, but stdin was empty" if options.error_input?
|
|
164
|
+
|
|
165
|
+
nil
|
|
166
|
+
elsif options.input_mode == :unix
|
|
167
|
+
WirePair.new(status: options.error_input? ? 1 : 0, data: text)
|
|
102
168
|
else
|
|
103
169
|
decode(text, mode: options.input_mode)
|
|
104
170
|
end
|
|
105
171
|
end
|
|
106
172
|
|
|
107
|
-
# WirePair ->
|
|
173
|
+
# WirePair -> stdout/stderr
|
|
108
174
|
def emit_output(wire_pair, output_mode:)
|
|
109
175
|
if output_mode == :unix
|
|
110
|
-
channel = wire_pair.status
|
|
176
|
+
channel = wire_pair.status == 0 ? $stdout : $stderr
|
|
111
177
|
channel.puts(wire_pair.data)
|
|
112
178
|
exit(wire_pair.status)
|
|
113
179
|
else
|
|
@@ -116,21 +182,26 @@ module Fusion
|
|
|
116
182
|
end
|
|
117
183
|
end
|
|
118
184
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
# value flows on as the program value and surfaces when `apply` runs it.
|
|
122
|
-
def load_program(options)
|
|
123
|
-
interpreter = Fusion::Interpreter.new
|
|
185
|
+
# file/inline -> runtime value
|
|
186
|
+
def load_program(options, root_env)
|
|
124
187
|
if options.inline_source
|
|
125
|
-
|
|
126
|
-
return ast if ast.is_a?(Fusion::Interpreter::ErrorVal) # a parse error
|
|
127
|
-
|
|
128
|
-
env = interpreter.root_env.child
|
|
129
|
-
env.define("__dir__", Dir.pwd)
|
|
130
|
-
interpreter.eval_expr(ast, env)
|
|
188
|
+
load_source(options.inline_source, root_env)
|
|
131
189
|
else
|
|
132
|
-
|
|
190
|
+
load_file(options.program_path, root_env)
|
|
133
191
|
end
|
|
134
192
|
end
|
|
193
|
+
|
|
194
|
+
# The jail root for this run: the program's directory by default (cwd for
|
|
195
|
+
# inline `-e` and the REPL), or `--jail DIR` resolved against that base.
|
|
196
|
+
# `--jail '*'` opts out of confinement entirely (nil = unconfined).
|
|
197
|
+
def jail_root(options)
|
|
198
|
+
return nil if options.jail == "*"
|
|
199
|
+
|
|
200
|
+
base = options.program_path ? File.dirname(File.expand_path(options.program_path)) : Dir.pwd
|
|
201
|
+
root = options.jail ? File.expand_path(options.jail, base) : base
|
|
202
|
+
raise Options::UsageError, "jail directory not found: #{options.jail}" unless File.directory?(root)
|
|
203
|
+
|
|
204
|
+
root
|
|
205
|
+
end
|
|
135
206
|
end
|
|
136
207
|
end
|