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.
- checksums.yaml +4 -4
- data/README.md +19 -6
- data/Rakefile +9 -0
- data/docs/lang/design.md +418 -28
- data/docs/lang/implementation.md +238 -0
- data/docs/lang/roadmap.md +20 -57
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +62 -23
- data/docs/user/reference.md +596 -168
- data/docs/user/tutorial.md +32 -29
- data/examples/double.fsn +1 -1
- data/examples/ends.fsn +4 -0
- data/examples/factorial.fsn +2 -2
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/json_test.fsn +4 -0
- data/examples/palindrome.fsn +2 -1
- data/exe/fusion +17 -44
- data/lib/fusion/ast.rb +97 -0
- data/lib/fusion/atom.rb +17 -0
- data/lib/fusion/cli/decoder.rb +84 -0
- data/lib/fusion/cli/encoder.rb +28 -0
- data/lib/fusion/cli/options.rb +212 -0
- data/lib/fusion/cli/parser.rb +38 -0
- data/lib/fusion/cli/repl.rb +78 -0
- data/lib/fusion/cli/serializer.rb +70 -0
- data/lib/fusion/cli.rb +207 -0
- data/lib/fusion/interpreter/builtins.rb +465 -0
- data/lib/fusion/interpreter/env.rb +89 -0
- data/lib/fusion/interpreter/error_val.rb +71 -0
- data/lib/fusion/interpreter/func.rb +22 -0
- data/lib/fusion/interpreter/native_func.rb +22 -0
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +752 -0
- data/lib/fusion/lexer.rb +249 -0
- data/lib/fusion/null.rb +9 -0
- data/lib/fusion/parser.rb +542 -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/all.fsn +13 -0
- data/stdlib/any.fsn +12 -0
- data/stdlib/chars.fsn +5 -0
- data/stdlib/compact.fsn +6 -0
- data/stdlib/concat.fsn +5 -0
- data/stdlib/falsey.fsn +6 -0
- data/stdlib/filter.fsn +12 -0
- data/stdlib/flatten.fsn +7 -0
- data/stdlib/gt.fsn +9 -0
- data/stdlib/gte.fsn +9 -0
- data/stdlib/lt.fsn +9 -0
- data/stdlib/lte.fsn +9 -0
- data/stdlib/map.fsn +6 -2
- data/stdlib/range.fsn +2 -1
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/sanitize.fsn +12 -0
- data/stdlib/truthy.fsn +7 -0
- metadata +41 -2
- data/stdlib/math/square.fsn +0 -1
|
@@ -0,0 +1,212 @@
|
|
|
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, 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"
|
|
14
|
+
|
|
15
|
+
module Fusion
|
|
16
|
+
module CLI
|
|
17
|
+
class Options
|
|
18
|
+
class UsageError < StandardError; end
|
|
19
|
+
|
|
20
|
+
USAGE = <<~TEXT
|
|
21
|
+
usage: fusion [options] <file.fsn>
|
|
22
|
+
fusion [options] -e '<source>'
|
|
23
|
+
fusion --repl
|
|
24
|
+
|
|
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`
|
|
30
|
+
|
|
31
|
+
options:
|
|
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)
|
|
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)
|
|
45
|
+
|
|
46
|
+
modes: unix, bang, array, object
|
|
47
|
+
unix pipe only (default there): plain JSON; output: stdout/exit 0
|
|
48
|
+
for values, stderr/exit 1 for error payloads
|
|
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)
|
|
52
|
+
object {"value": _} marks a value, {"error": _} an error
|
|
53
|
+
TEXT
|
|
54
|
+
|
|
55
|
+
MODES = %w[unix bang array object].freeze
|
|
56
|
+
|
|
57
|
+
attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :jail
|
|
58
|
+
|
|
59
|
+
def initialize(use_case:, input_mode:, output_mode:, inline_source:, program_path:, error_input:, skip_blank_lines:, jail:)
|
|
60
|
+
@use_case = use_case
|
|
61
|
+
@input_mode = input_mode
|
|
62
|
+
@output_mode = output_mode
|
|
63
|
+
@inline_source = inline_source
|
|
64
|
+
@program_path = program_path
|
|
65
|
+
@error_input = error_input
|
|
66
|
+
@skip_blank_lines = skip_blank_lines
|
|
67
|
+
@jail = jail
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def error_input?
|
|
71
|
+
@error_input
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def skip_blank_lines?
|
|
75
|
+
@skip_blank_lines
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.parse(argv)
|
|
79
|
+
pipe = false
|
|
80
|
+
stream = false
|
|
81
|
+
repl = false
|
|
82
|
+
input_modes = []
|
|
83
|
+
output_modes = []
|
|
84
|
+
error_input = false
|
|
85
|
+
skip_blank_lines = false
|
|
86
|
+
inline_source = nil
|
|
87
|
+
jail = nil
|
|
88
|
+
|
|
89
|
+
parser = OptionParser.new do |option|
|
|
90
|
+
option.on("-p", "--pipe") { pipe = true }
|
|
91
|
+
option.on("-s", "--stream") { stream = true }
|
|
92
|
+
option.on("-r", "--repl") { repl = true }
|
|
93
|
+
option.on("-i", "--input MODE") { |mode| input_modes << check_mode!(mode, "--input") }
|
|
94
|
+
option.on("-o", "--output MODE") { |mode| output_modes << check_mode!(mode, "--output") }
|
|
95
|
+
option.on("-e", "--execute SOURCE") { |source| inline_source = source }
|
|
96
|
+
option.on("-j", "--jail DIR") { |dir| jail = dir }
|
|
97
|
+
option.on("-!") { error_input = true }
|
|
98
|
+
option.on("-b", "--skip-blank-lines") { skip_blank_lines = true }
|
|
99
|
+
end
|
|
100
|
+
parser.require_exact = true # no abbreviations: "--s" is not a stand-in for "--stream"
|
|
101
|
+
|
|
102
|
+
# Whatever survives option parsing is positional: the program path.
|
|
103
|
+
positional = run_parser(parser, argv)
|
|
104
|
+
|
|
105
|
+
use_case = resolve_use_case(pipe: pipe, stream: stream, repl: repl, no_arguments: argv.empty?)
|
|
106
|
+
input_mode = resolve_mode(input_modes, "--input")
|
|
107
|
+
output_mode = resolve_mode(output_modes, "--output")
|
|
108
|
+
|
|
109
|
+
validate(use_case, input_mode, output_mode, error_input, skip_blank_lines, inline_source, jail, positional)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Collapse the use-case flags into one use case; more than one is a misuse.
|
|
113
|
+
# With none: a bare `fusion` (no arguments) starts the REPL, while any other
|
|
114
|
+
# invocation is a pipe run.
|
|
115
|
+
def self.resolve_use_case(pipe:, stream:, repl:, no_arguments:)
|
|
116
|
+
selected = [(:pipe if pipe), (:stream if stream), (:repl if repl)].compact
|
|
117
|
+
|
|
118
|
+
case selected.length
|
|
119
|
+
when 0 then no_arguments ? :repl : :pipe
|
|
120
|
+
when 1 then selected.first
|
|
121
|
+
else raise UsageError, "choose one use case: --pipe, --stream, or --repl"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The single mode for one direction, or nil if unset. Repeats of the same
|
|
126
|
+
# mode are fine; two different modes for the same flag are a misuse.
|
|
127
|
+
def self.resolve_mode(modes, flag)
|
|
128
|
+
distinct = modes.uniq
|
|
129
|
+
|
|
130
|
+
case distinct.length
|
|
131
|
+
when 0 then nil
|
|
132
|
+
when 1 then distinct.first
|
|
133
|
+
else raise UsageError, "conflicting #{flag} modes: #{distinct.join(', ')}"
|
|
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 => error
|
|
142
|
+
raise UsageError, "unknown option #{error.args.join(' ')}"
|
|
143
|
+
rescue OptionParser::MissingArgument => error
|
|
144
|
+
raise UsageError, missing_argument_message(error.args.first)
|
|
145
|
+
rescue OptionParser::ParseError => error
|
|
146
|
+
raise UsageError, error.message
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A MODE value -> its symbol, or a UsageError naming the valid modes.
|
|
150
|
+
def self.check_mode!(value, flag)
|
|
151
|
+
return value.to_sym if MODES.include?(value)
|
|
152
|
+
|
|
153
|
+
raise UsageError, "#{flag} expects one of: #{MODES.join(', ')} (got #{value})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Mirror the old per-flag wording when a value-taking option has no value.
|
|
157
|
+
# OptionParser reports whichever alias the user typed, so match both.
|
|
158
|
+
def self.missing_argument_message(flag)
|
|
159
|
+
case flag
|
|
160
|
+
when "-e", "--execute" then "-e/--execute requires a source argument"
|
|
161
|
+
when "-i", "--input" then "--input expects one of: #{MODES.join(', ')} (got nothing)"
|
|
162
|
+
when "-o", "--output" then "--output expects one of: #{MODES.join(', ')} (got nothing)"
|
|
163
|
+
when "-j", "--jail" then "-j/--jail requires a directory argument"
|
|
164
|
+
else "#{flag} requires an argument"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check the flag combination against the use case and fill in defaults.
|
|
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
|
+
|
|
172
|
+
case use_case
|
|
173
|
+
when :repl
|
|
174
|
+
unless input_mode.nil? && output_mode.nil? && !error_input && inline_source.nil? && positional.empty?
|
|
175
|
+
raise UsageError, "--repl takes no program, no input, and no modes"
|
|
176
|
+
end
|
|
177
|
+
program_path = nil
|
|
178
|
+
when :stream
|
|
179
|
+
input_mode ||= :array
|
|
180
|
+
output_mode ||= :array
|
|
181
|
+
raise UsageError, "--stream does not support the unix mode" if input_mode == :unix || output_mode == :unix
|
|
182
|
+
raise UsageError, "-! requires the unix input mode" if error_input
|
|
183
|
+
program_path = inline_source ? nil : positional.shift
|
|
184
|
+
raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
|
|
185
|
+
raise UsageError, "too many positional arguments" unless positional.empty?
|
|
186
|
+
when :pipe
|
|
187
|
+
input_mode ||= :unix
|
|
188
|
+
output_mode ||= :unix
|
|
189
|
+
raise UsageError, "-! requires the unix input mode" if error_input && input_mode != :unix
|
|
190
|
+
program_path = inline_source ? nil : positional.shift
|
|
191
|
+
raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
|
|
192
|
+
raise UsageError, "too many positional arguments" unless positional.empty?
|
|
193
|
+
else
|
|
194
|
+
raise Unreachable, "Unknown use case #{use_case}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
new(
|
|
198
|
+
use_case: use_case,
|
|
199
|
+
input_mode: input_mode,
|
|
200
|
+
output_mode: output_mode,
|
|
201
|
+
inline_source: inline_source,
|
|
202
|
+
program_path: program_path,
|
|
203
|
+
error_input: error_input,
|
|
204
|
+
skip_blank_lines: skip_blank_lines,
|
|
205
|
+
jail: jail
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private_class_method :validate, :resolve_use_case, :resolve_mode, :run_parser, :check_mode!, :missing_argument_message
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
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.from_runtime(
|
|
18
|
+
kind: "syntax_error",
|
|
19
|
+
origin: "input",
|
|
20
|
+
operation: "parsing 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,78 @@
|
|
|
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
|
+
RESET = "\e[0m"
|
|
13
|
+
LIGHT_BLUE = "\e[94m"
|
|
14
|
+
GREEN = "\e[32m"
|
|
15
|
+
RED = "\e[31m"
|
|
16
|
+
|
|
17
|
+
PROMPT = "#{LIGHT_BLUE}fsn> #{RESET}"
|
|
18
|
+
CONTINUATION_PROMPT = "#{LIGHT_BLUE}...> #{RESET}"
|
|
19
|
+
VALUE_MARKER = "#{GREEN}✔ #{RESET}"
|
|
20
|
+
ERROR_MARKER = "#{RED}✗ #{RESET}"
|
|
21
|
+
|
|
22
|
+
# REPL entries report errors with the same site as inline (`-e`) code.
|
|
23
|
+
SITE = { origin: "code", file: "<inline>" }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(root_env:)
|
|
26
|
+
@root_env = root_env
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
CLI.prepare!
|
|
31
|
+
|
|
32
|
+
require "reline"
|
|
33
|
+
Reline.output = $stderr
|
|
34
|
+
Reline.prompt_proc = proc do |lines|
|
|
35
|
+
lines.each_index.map { |i| i.zero? ? PROMPT : CONTINUATION_PROMPT }
|
|
36
|
+
end
|
|
37
|
+
|
|
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)
|
|
41
|
+
|
|
42
|
+
loop do
|
|
43
|
+
buffer = begin
|
|
44
|
+
Reline.readmultiline(PROMPT, true) { complete?(_1) }
|
|
45
|
+
rescue Interrupt
|
|
46
|
+
$stderr.puts("^C") # discard the half-typed entry and re-prompt
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
break if buffer.nil? # Ctrl-D on an empty line ends the session
|
|
51
|
+
next if buffer.strip.empty?
|
|
52
|
+
|
|
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
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# String -> yes/no
|
|
62
|
+
def complete?(buffer)
|
|
63
|
+
return true if buffer.strip.empty?
|
|
64
|
+
|
|
65
|
+
ast = Fusion::Parser.parse_repl(buffer, site: SITE)
|
|
66
|
+
ast.is_a?(AST::Expression) || ast.is_a?(AST::Statement::Assignment)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# String (+ Env) -> String
|
|
70
|
+
def handle(buffer, environment)
|
|
71
|
+
ast = Fusion::Parser.parse_repl(buffer, site: SITE)
|
|
72
|
+
runtime_value = CLI.evaluate(ast, environment)
|
|
73
|
+
wire_pair = Serializer.serialize(runtime_value, lenient: true)
|
|
74
|
+
Encoder.encode(wire_pair, mode: :bang)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
error = runtime_value
|
|
18
|
+
data = convert(error.payload, lenient: lenient || error.runtime?).to_json
|
|
19
|
+
return WirePair.new(status: 1, data: data)
|
|
20
|
+
else
|
|
21
|
+
return WirePair.new(status: 0, data: convert(runtime_value, lenient: lenient).to_json)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
runtime_error = Interpreter::ErrorVal.from_runtime(
|
|
26
|
+
kind: "serialization_error",
|
|
27
|
+
origin: "output",
|
|
28
|
+
operation: "serializing result",
|
|
29
|
+
input: runtime_value,
|
|
30
|
+
message: message
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
serialize(runtime_error, lenient: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Use "lenient: true" only for best-effort serialization of internal errors and the REPL.
|
|
39
|
+
def convert(runtime_value, lenient: false)
|
|
40
|
+
case runtime_value
|
|
41
|
+
when NULL
|
|
42
|
+
nil
|
|
43
|
+
when Float
|
|
44
|
+
return runtime_value if runtime_value.finite?
|
|
45
|
+
throw(:unserializable, "cannot serialize a non-finite number") unless lenient
|
|
46
|
+
|
|
47
|
+
"<#{runtime_value}>" # "<Infinity>" / "<-Infinity>" / "<NaN>"
|
|
48
|
+
when Array
|
|
49
|
+
runtime_value.map { |item| convert(item, lenient:) }
|
|
50
|
+
when Hash
|
|
51
|
+
runtime_value.transform_values { |value| convert(value, lenient:) }
|
|
52
|
+
when Interpreter::Func, Interpreter::NativeFunc
|
|
53
|
+
throw(:unserializable, "cannot serialize a function") unless lenient
|
|
54
|
+
|
|
55
|
+
"<function>"
|
|
56
|
+
when true, false, String, Numeric
|
|
57
|
+
runtime_value
|
|
58
|
+
when Interpreter::ErrorVal
|
|
59
|
+
if lenient
|
|
60
|
+
"!#{convert(runtime_value.payload, lenient:).to_json}"
|
|
61
|
+
else
|
|
62
|
+
raise Unreachable, "ErrorVal should have been handled at the top level of convert"
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
raise Unreachable, "Unhandled type in convert: #{runtime_value.class}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/fusion/cli.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === CLI tools ===
|
|
4
|
+
#
|
|
5
|
+
# Core data types:
|
|
6
|
+
# - WirePair
|
|
7
|
+
# - Fusion runtime value
|
|
8
|
+
|
|
9
|
+
require_relative "wire_pair"
|
|
10
|
+
require_relative "cli/options"
|
|
11
|
+
|
|
12
|
+
require_relative "cli/decoder"
|
|
13
|
+
require_relative "cli/parser"
|
|
14
|
+
require_relative "interpreter"
|
|
15
|
+
require_relative "cli/serializer"
|
|
16
|
+
require_relative "cli/encoder"
|
|
17
|
+
|
|
18
|
+
require_relative "cli/repl"
|
|
19
|
+
|
|
20
|
+
module Fusion
|
|
21
|
+
module CLI
|
|
22
|
+
extend self
|
|
23
|
+
|
|
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
|
+
|
|
32
|
+
def run(options)
|
|
33
|
+
case options.use_case
|
|
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}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_pipe(options)
|
|
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
|
+
|
|
58
|
+
emit_output(serialize(output), output_mode: options.output_mode)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_stream(options)
|
|
62
|
+
prepare!
|
|
63
|
+
|
|
64
|
+
root = root_environment(jail: jail_root(options))
|
|
65
|
+
program = load_program(options, root)
|
|
66
|
+
|
|
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
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
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
|
|
85
|
+
# Doesn't support mode `:unix`
|
|
86
|
+
def decode(string, mode:)
|
|
87
|
+
Decoder.decode(string, mode:)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# WirePair -> runtime value
|
|
91
|
+
def parse(wire_pair)
|
|
92
|
+
Parser.parse(wire_pair)
|
|
93
|
+
end
|
|
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
|
|
119
|
+
# input | function -> output
|
|
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)
|
|
124
|
+
end
|
|
125
|
+
|
|
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
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# runtime value -> WirePair
|
|
144
|
+
# CAUTION: resolves "internal" error status, only use for final output
|
|
145
|
+
def serialize(runtime_value)
|
|
146
|
+
Serializer.serialize(runtime_value)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# WirePair -> String
|
|
150
|
+
# Doesn't support mode `:unix`
|
|
151
|
+
def encode(wire_pair, mode:)
|
|
152
|
+
Encoder.encode(wire_pair, mode:)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# stdin -> WirePair
|
|
158
|
+
def load_input(options)
|
|
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)
|
|
168
|
+
else
|
|
169
|
+
decode(text, mode: options.input_mode)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# WirePair -> stdout/stderr
|
|
174
|
+
def emit_output(wire_pair, output_mode:)
|
|
175
|
+
if output_mode == :unix
|
|
176
|
+
channel = wire_pair.status.zero? ? $stdout : $stderr
|
|
177
|
+
channel.puts(wire_pair.data)
|
|
178
|
+
exit(wire_pair.status)
|
|
179
|
+
else
|
|
180
|
+
$stdout.puts(encode(wire_pair, mode: output_mode))
|
|
181
|
+
exit 0
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# file/inline -> runtime value
|
|
186
|
+
def load_program(options, root_env)
|
|
187
|
+
if options.inline_source
|
|
188
|
+
load_source(options.inline_source, root_env)
|
|
189
|
+
else
|
|
190
|
+
load_file(options.program_path, root_env)
|
|
191
|
+
end
|
|
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
|
|
206
|
+
end
|
|
207
|
+
end
|