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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +24 -0
  3. data/.simplecov +11 -0
  4. data/CHANGELOG.md +42 -0
  5. data/README.md +11 -9
  6. data/Rakefile +8 -0
  7. data/docs/lang/design.md +289 -51
  8. data/docs/lang/implementation.md +279 -0
  9. data/docs/lang/roadmap.md +20 -36
  10. data/docs/user/explanation.md +5 -10
  11. data/docs/user/how-to-guides.md +145 -15
  12. data/docs/user/reference.md +365 -140
  13. data/docs/user/tutorial.md +22 -19
  14. data/examples/double.fsn +4 -1
  15. data/examples/factorial.fsn +6 -3
  16. data/examples/fizzbuzz.fsn +1 -4
  17. data/examples/gcd.fsn +9 -0
  18. data/examples/json_test.fsn +4 -0
  19. data/examples/matrix/OP.fsn +2 -0
  20. data/examples/matrix/average.fsn +2 -0
  21. data/examples/matrix/solve.fsn +2 -0
  22. data/examples/palindrome.fsn +1 -1
  23. data/exe/fusion +12 -12
  24. data/lib/fusion/ast.rb +76 -27
  25. data/lib/fusion/atom.rb +1 -1
  26. data/lib/fusion/cli/decoder.rb +13 -8
  27. data/lib/fusion/cli/encoder.rb +2 -2
  28. data/lib/fusion/cli/options.rb +134 -61
  29. data/lib/fusion/cli/parser.rb +4 -4
  30. data/lib/fusion/cli/repl.rb +32 -27
  31. data/lib/fusion/cli/serializer.rb +11 -11
  32. data/lib/fusion/cli.rb +120 -49
  33. data/lib/fusion/interpreter/builtins.rb +298 -160
  34. data/lib/fusion/interpreter/env.rb +42 -12
  35. data/lib/fusion/interpreter/error_val.rb +42 -20
  36. data/lib/fusion/interpreter/thunk.rb +53 -0
  37. data/lib/fusion/interpreter.rb +263 -98
  38. data/lib/fusion/lexer.rb +125 -37
  39. data/lib/fusion/parser.rb +245 -70
  40. data/lib/fusion/version.rb +3 -1
  41. data/lib/fusion.rb +0 -1
  42. data/stdlib/all.fsn +13 -0
  43. data/stdlib/any.fsn +12 -0
  44. data/stdlib/chars.fsn +5 -0
  45. data/stdlib/compact.fsn +5 -0
  46. data/stdlib/concat.fsn +6 -0
  47. data/stdlib/entries.fsn +6 -0
  48. data/stdlib/falsey.fsn +6 -0
  49. data/stdlib/filter.fsn +12 -0
  50. data/stdlib/flatten.fsn +7 -0
  51. data/stdlib/map.fsn +7 -4
  52. data/stdlib/matrix/Matrix.fsn +8 -0
  53. data/stdlib/matrix/OP.fsn +18 -0
  54. data/stdlib/matrix/add.fsn +7 -0
  55. data/stdlib/matrix/column.fsn +6 -0
  56. data/stdlib/matrix/determinant.fsn +16 -0
  57. data/stdlib/matrix/dimensions.fsn +5 -0
  58. data/stdlib/matrix/identity.fsn +6 -0
  59. data/stdlib/matrix/invert.fsn +26 -0
  60. data/stdlib/matrix/minor.fsn +15 -0
  61. data/stdlib/matrix/multiply.fsn +10 -0
  62. data/stdlib/matrix/negate.fsn +5 -0
  63. data/stdlib/matrix/product.fsn +11 -0
  64. data/stdlib/matrix/rotate.fsn +10 -0
  65. data/stdlib/matrix/row.fsn +6 -0
  66. data/stdlib/matrix/scale.fsn +6 -0
  67. data/stdlib/matrix/subtract.fsn +7 -0
  68. data/stdlib/matrix/sum.fsn +14 -0
  69. data/stdlib/matrix/transpose.fsn +5 -0
  70. data/stdlib/range.fsn +2 -2
  71. data/stdlib/reduce.fsn +8 -0
  72. data/stdlib/safe.fsn +7 -0
  73. data/stdlib/sanitize.fsn +2 -3
  74. data/stdlib/toObject.fsn +7 -0
  75. data/stdlib/truthy.fsn +7 -0
  76. data/stdlib/vector/Vector.fsn +7 -0
  77. data/stdlib/vector/add.fsn +6 -0
  78. data/stdlib/vector/cross.fsn +6 -0
  79. data/stdlib/vector/dot.fsn +6 -0
  80. data/stdlib/vector/norm.fsn +6 -0
  81. data/stdlib/vector/scale.fsn +5 -0
  82. data/stdlib/vector/subtract.fsn +6 -0
  83. data/stdlib/zip.fsn +6 -0
  84. metadata +50 -4
  85. data/lib/fusion/interpreter/file_thunk.rb +0 -39
  86. data/stdlib/mapValues.fsn +0 -5
  87. data/stdlib/math/square.fsn +0 -4
@@ -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 it happens before the input/output contract begins,
10
- # so it is not a payloaded Fusion error.
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> [json-input]
19
- fusion [options] -e '<source>' [json-input]
21
+ usage: fusion [options] <file.fsn>
22
+ fusion [options] -e '<source>'
20
23
  fusion --repl
21
24
 
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`
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>' 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
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 (default for --stream)
37
- array [0, value] marks a value, [1, payload] an error
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 = %w[unix bang array object].freeze
55
+ MODES = ['unix', 'bang', 'array', 'object'].freeze
42
56
 
43
- attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :explicit_input
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:, explicit_input:, error_input:)
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
- arguments = argv.dup
61
- use_case = :pipe
62
- input_mode = nil
63
- output_mode = nil
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
- 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
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
- validate(use_case, input_mode, output_mode, error_input, inline_source, positional)
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
- program_path = explicit_input = nil
177
+
178
+ program_path = nil
99
179
  when :stream
100
- input_mode ||= :bang
101
- output_mode ||= :bang
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, "--stream reads its input from stdin, not an argument" unless positional.empty?
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
- explicit_input = positional.shift
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
- explicit_input: explicit_input,
127
- error_input: error_input
206
+ error_input: error_input,
207
+ skip_blank_lines: skip_blank_lines,
208
+ jail: jail,
128
209
  )
129
210
  end
130
211
 
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
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
@@ -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.internal(
17
+ Interpreter::ErrorVal.from_runtime(
18
18
  kind: "syntax_error",
19
- location: "input",
20
- operation: "parsing input as JSON",
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
 
@@ -9,65 +9,70 @@ require_relative "../interpreter/env"
9
9
  module Fusion
10
10
  module CLI
11
11
  class Repl
12
- PROMPT = "fsn> "
13
- CONTINUATION_PROMPT = "...> "
12
+ RESET = "\e[0m"
13
+ LIGHT_BLUE = "\e[94m"
14
+ GREEN = "\e[32m"
15
+ RED = "\e[31m"
14
16
 
15
- # REPL entries report errors with the same location as inline (`-e`) code.
16
- LOCATION = "code <inline>"
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.zero? ? PROMPT : CONTINUATION_PROMPT }
35
+ lines.each_index.map { |i| i == 0 ? PROMPT : CONTINUATION_PROMPT }
24
36
  end
25
37
 
26
- environment = Interpreter::Env.new.define("__dir__", Dir.pwd)
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
- $stderr.puts("^C") # discard the half-typed entry and re-prompt
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
- $stdout.puts(handle(buffer, environment))
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, location: LOCATION)
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, location: LOCATION)
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
- data = convert(runtime_value.payload, lenient: lenient || runtime_value.internal_error?).to_json
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
- 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
- message: message
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
@@ -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
- 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
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
- # 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,25 +154,26 @@ 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
- channel = wire_pair.status.zero? ? $stdout : $stderr
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
- # 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