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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -8
  3. data/docs/lang/design.md +240 -51
  4. data/docs/lang/implementation.md +238 -0
  5. data/docs/lang/roadmap.md +20 -36
  6. data/docs/user/explanation.md +5 -10
  7. data/docs/user/how-to-guides.md +60 -15
  8. data/docs/user/reference.md +356 -142
  9. data/docs/user/tutorial.md +21 -19
  10. data/examples/double.fsn +1 -1
  11. data/examples/factorial.fsn +2 -2
  12. data/examples/fizzbuzz.fsn +1 -4
  13. data/examples/json_test.fsn +4 -0
  14. data/examples/palindrome.fsn +1 -1
  15. data/exe/fusion +10 -10
  16. data/lib/fusion/ast.rb +2 -1
  17. data/lib/fusion/cli/decoder.rb +10 -5
  18. data/lib/fusion/cli/options.rb +130 -60
  19. data/lib/fusion/cli/parser.rb +3 -3
  20. data/lib/fusion/cli/repl.rb +30 -25
  21. data/lib/fusion/cli/serializer.rb +5 -4
  22. data/lib/fusion/cli.rb +119 -48
  23. data/lib/fusion/interpreter/builtins.rb +260 -151
  24. data/lib/fusion/interpreter/env.rb +42 -12
  25. data/lib/fusion/interpreter/error_val.rb +42 -20
  26. data/lib/fusion/interpreter/thunk.rb +53 -0
  27. data/lib/fusion/interpreter.rb +239 -82
  28. data/lib/fusion/lexer.rb +69 -3
  29. data/lib/fusion/parser.rb +189 -51
  30. data/lib/fusion/version.rb +1 -1
  31. data/stdlib/all.fsn +13 -0
  32. data/stdlib/any.fsn +12 -0
  33. data/stdlib/chars.fsn +5 -0
  34. data/stdlib/compact.fsn +6 -0
  35. data/stdlib/concat.fsn +5 -0
  36. data/stdlib/falsey.fsn +6 -0
  37. data/stdlib/filter.fsn +12 -0
  38. data/stdlib/flatten.fsn +7 -0
  39. data/stdlib/gt.fsn +9 -0
  40. data/stdlib/gte.fsn +9 -0
  41. data/stdlib/lt.fsn +9 -0
  42. data/stdlib/lte.fsn +9 -0
  43. data/stdlib/map.fsn +6 -4
  44. data/stdlib/range.fsn +2 -2
  45. data/stdlib/reduce.fsn +8 -0
  46. data/stdlib/sanitize.fsn +2 -2
  47. data/stdlib/truthy.fsn +7 -0
  48. metadata +18 -4
  49. data/lib/fusion/interpreter/file_thunk.rb +0 -39
  50. data/stdlib/mapValues.fsn +0 -5
  51. 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
- data = convert(runtime_value.payload, lenient: lenient || runtime_value.internal_error?).to_json
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
- internal_error = Interpreter::ErrorVal.internal(
25
+ runtime_error = Interpreter::ErrorVal.from_runtime(
25
26
  kind: "serialization_error",
26
- location: "output",
27
+ origin: "output",
27
28
  operation: "serializing result",
28
29
  input: runtime_value,
29
30
  message: message
30
31
  )
31
32
 
32
- serialize(internal_error, lenient: true)
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
- # Run the use case selected on the command line.
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 then run_pipe(options)
28
- when :stream then run_stream(options)
29
- when :repl then Repl.new.run
30
- else raise Unreachable, "Unknown use 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}"
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
- function = load_program(options)
37
- input = parse(load_input(options))
38
- output = apply(input, function)
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
- function = load_program(options)
47
- $stdout.sync = true
48
- $stdin.each_line do |line|
49
- next if line.strip.empty?
62
+ prepare!
63
+
64
+ root = root_environment(jail: jail_root(options))
65
+ program = load_program(options, root)
50
66
 
51
- input = parse(decode(line, mode: options.input_mode))
52
- output = apply(input, function)
53
- $stdout.puts(encode(serialize(output), mode: options.output_mode))
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
- # String -> WirePair
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
- def apply(input, function)
70
- Interpreter.safe_apply(function, input)
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 (REPL variable binding)
75
- def evaluate(expression, environment)
76
- Interpreter.safe_evaluate(expression, environment)
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
- # input -> WirePair
157
+ # stdin -> WirePair
93
158
  def load_input(options)
94
- text = options.explicit_input || ($stdin.tty? ? "" : $stdin.read)
95
- empty = text.strip.empty?
96
-
97
- if options.input_mode == :unix || empty
98
- WirePair.new(
99
- status: options.error_input? ? 1 : 0,
100
- data: empty ? "null" : text
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 -> output
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
- # Load the program (a `.fsn` file or an inline `-e` source) into the runtime
120
- # value it evaluates to — usually a function. A parse error or a non-function
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
- ast = Fusion::Parser.parse_file(options.inline_source, location: "code <inline>")
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
- interpreter.load_file(File.expand_path(options.program_path)).force
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