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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -6
  3. data/Rakefile +9 -0
  4. data/docs/lang/design.md +418 -28
  5. data/docs/lang/implementation.md +238 -0
  6. data/docs/lang/roadmap.md +20 -57
  7. data/docs/user/explanation.md +5 -10
  8. data/docs/user/how-to-guides.md +62 -23
  9. data/docs/user/reference.md +596 -168
  10. data/docs/user/tutorial.md +32 -29
  11. data/examples/double.fsn +1 -1
  12. data/examples/ends.fsn +4 -0
  13. data/examples/factorial.fsn +2 -2
  14. data/examples/fizzbuzz.fsn +1 -4
  15. data/examples/json_test.fsn +4 -0
  16. data/examples/palindrome.fsn +2 -1
  17. data/exe/fusion +17 -44
  18. data/lib/fusion/ast.rb +97 -0
  19. data/lib/fusion/atom.rb +17 -0
  20. data/lib/fusion/cli/decoder.rb +84 -0
  21. data/lib/fusion/cli/encoder.rb +28 -0
  22. data/lib/fusion/cli/options.rb +212 -0
  23. data/lib/fusion/cli/parser.rb +38 -0
  24. data/lib/fusion/cli/repl.rb +78 -0
  25. data/lib/fusion/cli/serializer.rb +70 -0
  26. data/lib/fusion/cli.rb +207 -0
  27. data/lib/fusion/interpreter/builtins.rb +465 -0
  28. data/lib/fusion/interpreter/env.rb +89 -0
  29. data/lib/fusion/interpreter/error_val.rb +71 -0
  30. data/lib/fusion/interpreter/func.rb +22 -0
  31. data/lib/fusion/interpreter/native_func.rb +22 -0
  32. data/lib/fusion/interpreter/thunk.rb +53 -0
  33. data/lib/fusion/interpreter.rb +752 -0
  34. data/lib/fusion/lexer.rb +249 -0
  35. data/lib/fusion/null.rb +9 -0
  36. data/lib/fusion/parser.rb +542 -0
  37. data/lib/fusion/token.rb +22 -0
  38. data/lib/fusion/typed_data.rb +23 -0
  39. data/lib/fusion/version.rb +1 -1
  40. data/lib/fusion/wire_pair.rb +11 -0
  41. data/lib/fusion.rb +11 -1122
  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 +6 -0
  46. data/stdlib/concat.fsn +5 -0
  47. data/stdlib/falsey.fsn +6 -0
  48. data/stdlib/filter.fsn +12 -0
  49. data/stdlib/flatten.fsn +7 -0
  50. data/stdlib/gt.fsn +9 -0
  51. data/stdlib/gte.fsn +9 -0
  52. data/stdlib/lt.fsn +9 -0
  53. data/stdlib/lte.fsn +9 -0
  54. data/stdlib/map.fsn +6 -2
  55. data/stdlib/range.fsn +2 -1
  56. data/stdlib/reduce.fsn +8 -0
  57. data/stdlib/sanitize.fsn +12 -0
  58. data/stdlib/truthy.fsn +7 -0
  59. metadata +41 -2
  60. 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