fusion-lang 0.0.1.alpha2 → 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 +9 -8
- data/docs/lang/design.md +240 -51
- data/docs/lang/implementation.md +238 -0
- data/docs/lang/roadmap.md +20 -36
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +60 -15
- data/docs/user/reference.md +356 -142
- data/docs/user/tutorial.md +21 -19
- data/examples/double.fsn +1 -1
- data/examples/factorial.fsn +2 -2
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/json_test.fsn +4 -0
- data/examples/palindrome.fsn +1 -1
- data/exe/fusion +10 -10
- data/lib/fusion/ast.rb +2 -1
- data/lib/fusion/cli/decoder.rb +10 -5
- data/lib/fusion/cli/options.rb +130 -60
- data/lib/fusion/cli/parser.rb +3 -3
- data/lib/fusion/cli/repl.rb +30 -25
- data/lib/fusion/cli/serializer.rb +5 -4
- data/lib/fusion/cli.rb +119 -48
- data/lib/fusion/interpreter/builtins.rb +260 -151
- 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 +239 -82
- data/lib/fusion/lexer.rb +69 -3
- data/lib/fusion/parser.rb +189 -51
- data/lib/fusion/version.rb +1 -1
- 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 -4
- data/stdlib/range.fsn +2 -2
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/sanitize.fsn +2 -2
- data/stdlib/truthy.fsn +7 -0
- metadata +18 -4
- data/lib/fusion/interpreter/file_thunk.rb +0 -39
- data/stdlib/mapValues.fsn +0 -5
- data/stdlib/math/square.fsn +0 -4
|
@@ -14,22 +14,23 @@ module Fusion
|
|
|
14
14
|
def serialize(runtime_value, lenient: false)
|
|
15
15
|
message = catch(:unserializable) do
|
|
16
16
|
if runtime_value.is_a?(Interpreter::ErrorVal)
|
|
17
|
-
|
|
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
30
|
message: message
|
|
30
31
|
)
|
|
31
32
|
|
|
32
|
-
serialize(
|
|
33
|
+
serialize(runtime_error, lenient: true)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
private
|
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,22 +154,23 @@ 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
176
|
channel = wire_pair.status.zero? ? $stdout : $stderr
|
|
@@ -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
|