fusion-lang 0.0.1 → 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 (76) 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 +2 -1
  6. data/Rakefile +8 -0
  7. data/docs/lang/design.md +54 -5
  8. data/docs/lang/implementation.md +41 -0
  9. data/docs/user/how-to-guides.md +90 -5
  10. data/docs/user/reference.md +48 -37
  11. data/docs/user/tutorial.md +6 -5
  12. data/examples/double.fsn +4 -1
  13. data/examples/factorial.fsn +6 -3
  14. data/examples/gcd.fsn +9 -0
  15. data/examples/matrix/OP.fsn +2 -0
  16. data/examples/matrix/average.fsn +2 -0
  17. data/examples/matrix/solve.fsn +2 -0
  18. data/exe/fusion +2 -2
  19. data/lib/fusion/ast.rb +76 -28
  20. data/lib/fusion/atom.rb +1 -1
  21. data/lib/fusion/cli/decoder.rb +6 -6
  22. data/lib/fusion/cli/encoder.rb +2 -2
  23. data/lib/fusion/cli/options.rb +11 -8
  24. data/lib/fusion/cli/parser.rb +1 -1
  25. data/lib/fusion/cli/repl.rb +6 -6
  26. data/lib/fusion/cli/serializer.rb +6 -7
  27. data/lib/fusion/cli.rb +1 -1
  28. data/lib/fusion/interpreter/builtins.rb +51 -22
  29. data/lib/fusion/interpreter/thunk.rb +2 -2
  30. data/lib/fusion/interpreter.rb +31 -23
  31. data/lib/fusion/lexer.rb +62 -40
  32. data/lib/fusion/parser.rb +72 -35
  33. data/lib/fusion/version.rb +3 -1
  34. data/lib/fusion.rb +0 -1
  35. data/stdlib/all.fsn +2 -2
  36. data/stdlib/any.fsn +3 -3
  37. data/stdlib/compact.fsn +2 -3
  38. data/stdlib/concat.fsn +4 -3
  39. data/stdlib/entries.fsn +6 -0
  40. data/stdlib/filter.fsn +3 -3
  41. data/stdlib/map.fsn +3 -2
  42. data/stdlib/matrix/Matrix.fsn +8 -0
  43. data/stdlib/matrix/OP.fsn +18 -0
  44. data/stdlib/matrix/add.fsn +7 -0
  45. data/stdlib/matrix/column.fsn +6 -0
  46. data/stdlib/matrix/determinant.fsn +16 -0
  47. data/stdlib/matrix/dimensions.fsn +5 -0
  48. data/stdlib/matrix/identity.fsn +6 -0
  49. data/stdlib/matrix/invert.fsn +26 -0
  50. data/stdlib/matrix/minor.fsn +15 -0
  51. data/stdlib/matrix/multiply.fsn +10 -0
  52. data/stdlib/matrix/negate.fsn +5 -0
  53. data/stdlib/matrix/product.fsn +11 -0
  54. data/stdlib/matrix/rotate.fsn +10 -0
  55. data/stdlib/matrix/row.fsn +6 -0
  56. data/stdlib/matrix/scale.fsn +6 -0
  57. data/stdlib/matrix/subtract.fsn +7 -0
  58. data/stdlib/matrix/sum.fsn +14 -0
  59. data/stdlib/matrix/transpose.fsn +5 -0
  60. data/stdlib/range.fsn +2 -2
  61. data/stdlib/safe.fsn +7 -0
  62. data/stdlib/sanitize.fsn +2 -3
  63. data/stdlib/toObject.fsn +7 -0
  64. data/stdlib/vector/Vector.fsn +7 -0
  65. data/stdlib/vector/add.fsn +6 -0
  66. data/stdlib/vector/cross.fsn +6 -0
  67. data/stdlib/vector/dot.fsn +6 -0
  68. data/stdlib/vector/norm.fsn +6 -0
  69. data/stdlib/vector/scale.fsn +5 -0
  70. data/stdlib/vector/subtract.fsn +6 -0
  71. data/stdlib/zip.fsn +6 -0
  72. metadata +37 -5
  73. data/stdlib/gt.fsn +0 -9
  74. data/stdlib/gte.fsn +0 -9
  75. data/stdlib/lt.fsn +0 -9
  76. data/stdlib/lte.fsn +0 -9
data/exe/fusion CHANGED
@@ -22,9 +22,9 @@ end
22
22
  begin
23
23
  options = Fusion::CLI::Options.parse(ARGV)
24
24
  Fusion::CLI.run(options)
25
- rescue Fusion::CLI::Options::UsageError => err
25
+ rescue Fusion::CLI::Options::UsageError => e
26
26
  # A command-line misuse is plain text on stderr, not a payloaded Fusion error,
27
27
  # whether caught while parsing options or early during execution, e.g. by
28
28
  # detecting empty stdin plus "-!".
29
- abort("fusion: #{err.message}\n\n#{Fusion::CLI::Options::USAGE}")
29
+ abort("fusion: #{e.message}\n\n#{Fusion::CLI::Options::USAGE}")
30
30
  end
data/lib/fusion/ast.rb CHANGED
@@ -14,7 +14,7 @@ module Fusion
14
14
  # A syntactic identifier: a bound/looked-up name, a `.key`, or a `...rest`
15
15
  # binder. Mirrors the lexer's ident rule (Lexer#ident_start? / #ident_part?).
16
16
  # Object *keys* are arbitrary strings, not identifiers, so they stay `String`.
17
- Identifier = /\A[A-Za-z_][A-Za-z0-9_]*\z/
17
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
18
18
 
19
19
  # Expression and Pattern nodes each form a closed family, declared up front
20
20
  # (empty) so a member of either may reference the other. Each module is
@@ -28,31 +28,66 @@ module Fusion
28
28
  # Auxiliary typed parts: the elements a collection node holds. NOT themselves
29
29
  # expressions or patterns, so they live outside the marker families and never
30
30
  # satisfy an `Expression`/`Pattern` field.
31
- ArrayItem = TypedData.define(value: Expression) # an array element
32
- ArraySpread = TypedData.define(value: Expression) # ...expr inside an array
33
- KeyValuePair = TypedData.define(key: String, value: Expression) # "k": expr inside an object
34
- ObjectSpread = TypedData.define(value: Expression) # ...expr inside an object
35
- Clause = TypedData.define(pattern: Pattern, body: Expression) # one pattern => body of a function
36
- PatternItem = TypedData.define(pattern: Pattern) # a sub-pattern of an array pattern
37
- PatternPair = TypedData.define(key: String, pattern: Pattern) # "k": pat inside an object pattern
38
- PatternRest = TypedData.define(name: ->(v) { Identifier === v || v.nil? }) # ...name (name nil = ignore) in either
31
+
32
+ # An array element
33
+ ArrayItem = TypedData.define(value: Expression)
34
+
35
+ # `...expr` inside an array
36
+ ArraySpread = TypedData.define(value: Expression)
37
+
38
+ # `"k": expr` inside an object
39
+ KeyValuePair = TypedData.define(key: String, value: Expression)
40
+
41
+ # `...expr` inside an object
42
+ ObjectSpread = TypedData.define(value: Expression)
43
+
44
+ # One `pattern => body` of a function
45
+ Clause = TypedData.define(pattern: Pattern, body: Expression)
46
+
47
+ # A sub-pattern of an array pattern
48
+ PatternItem = TypedData.define(pattern: Pattern)
49
+
50
+ # `"k": pat` inside an object pattern
51
+ PatternPair = TypedData.define(key: String, pattern: Pattern)
52
+
53
+ # `...name` in array/object pattern. (name nil = don't bind)
54
+ PatternRest = TypedData.define(name: ->(v) { IDENTIFIER === v || v.nil? })
39
55
 
40
56
  module Expression
41
- Lit = TypedData.define(value: Atom) # atom literal (incl NULL)
42
- ErrLit = TypedData.define(payload: ->(v) { Expression === v || v.nil? }) # !expr or bare ! (payload nil = !null)
43
- ArrLit = TypedData.define(items: ->(v) { v.is_a?(Array) && v.all? { |e| ArrayItem === e || ArraySpread === e } })
44
- ObjLit = TypedData.define(pairs: ->(v) { # [KeyValuePair|ObjectSpread], distinct fixed keys
57
+ # Atom literal (incl NULL)
58
+ Lit = TypedData.define(value: Atom)
59
+
60
+ # `!expr` or bare `!` (payload nil = !null)
61
+ ErrLit = TypedData.define(payload: ->(v) { Expression === v || v.nil? })
62
+
63
+ ArrLit = TypedData.define(items: ->(v) { v.is_a?(Array) && v.all? { |e| ArrayItem === e || ArraySpread === e } })
64
+
65
+ # [KeyValuePair|ObjectSpread], distinct fixed keys
66
+ ObjLit = TypedData.define(pairs: ->(v) {
45
67
  v.is_a?(Array) &&
46
68
  v.all? { |m| KeyValuePair === m || ObjectSpread === m } &&
47
69
  v.filter_map { |m| m.key if KeyValuePair === m }.then { |keys| keys.uniq.size == keys.size }
48
70
  })
49
- FuncLit = TypedData.define(clauses: ->(v) { v.is_a?(Array) && v.all? { |c| Clause === c } }) # [] = the empty function
50
- Ident = TypedData.define(name: Identifier) # read a builtin/bound name
51
- FileRef = TypedData.define(variety: ->(v) { %i[self super super_name name path].include?(v) }, path: ->(v) { String === v || v.nil? })
52
- Pipe = TypedData.define(left: Expression, right: Expression) # left | right
53
- Member = TypedData.define(obj: Expression, key: Identifier) # obj.key
54
- Index = TypedData.define(obj: Expression, idx: Expression) # obj[expr]
55
- IndexSet = TypedData.define(obj: Expression, idx: Expression, value: Expression) # obj[expr = expr]
71
+
72
+ # [] = the empty function
73
+ FuncLit = TypedData.define(clauses: ->(v) { v.is_a?(Array) && v.all? { |c| Clause === c } })
74
+
75
+ # Read a builtin/bound name
76
+ Ident = TypedData.define(name: IDENTIFIER)
77
+
78
+ FileRef = TypedData.define(variety: ->(v) { [:self, :super, :super_name, :name, :path].include?(v) }, path: ->(v) { String === v || v.nil? })
79
+
80
+ # `left | right`
81
+ Pipe = TypedData.define(left: Expression, right: Expression)
82
+
83
+ # `obj.key`
84
+ Member = TypedData.define(obj: Expression, key: IDENTIFIER)
85
+
86
+ # `obj[expr]`
87
+ Index = TypedData.define(obj: Expression, idx: Expression)
88
+
89
+ # `obj[expr = expr]`
90
+ IndexSet = TypedData.define(obj: Expression, idx: Expression, value: Expression)
56
91
 
57
92
  constants.each do |name|
58
93
  node = const_get(name)
@@ -61,22 +96,35 @@ module Fusion
61
96
  end
62
97
 
63
98
  module Pattern
64
- PLit = TypedData.define(value: Atom) # literal pattern
65
- PErr = TypedData.define(inner: Pattern) # ! or !pat ; inner=PWild matches any error
66
- PBind = TypedData.define(name: Identifier) # binds
67
- PWild = TypedData.define(dummy: NilClass) # _
68
- PArr = TypedData.define(items: ->(v) { # [PatternItem|PatternRest], at most one rest
99
+ # literal pattern
100
+ PLit = TypedData.define(value: Atom)
101
+
102
+ # `!` or `!pat` ; inner=PWild matches any error
103
+ PErr = TypedData.define(inner: Pattern)
104
+
105
+ # binds
106
+ PBind = TypedData.define(name: IDENTIFIER)
107
+
108
+ # `_`
109
+ PWild = TypedData.define(dummy: NilClass)
110
+
111
+ # [PatternItem|PatternRest], at most one rest
112
+ PArr = TypedData.define(items: ->(v) {
69
113
  v.is_a?(Array) &&
70
114
  v.all? { |e| PatternItem === e || PatternRest === e } &&
71
115
  v.count { |e| PatternRest === e } <= 1
72
116
  })
73
- PObj = TypedData.define(pairs: ->(v) { # [PatternPair|PatternRest], one rest, distinct keys
117
+
118
+ # [PatternPair|PatternRest], one rest, distinct keys
119
+ PObj = TypedData.define(pairs: ->(v) {
74
120
  v.is_a?(Array) &&
75
121
  v.all? { |m| PatternPair === m || PatternRest === m } &&
76
122
  v.count { |m| PatternRest === m } <= 1 &&
77
123
  v.filter_map { |m| m.key if PatternPair === m }.then { |keys| keys.uniq.size == keys.size }
78
124
  })
79
- PGuard = TypedData.define(inner: Pattern, pred_expr: Expression) # inner ? predicate
125
+
126
+ # `inner ? predicate`
127
+ PGuard = TypedData.define(inner: Pattern, pred_expr: Expression)
80
128
 
81
129
  constants.each do |name|
82
130
  node = const_get(name)
@@ -86,7 +134,7 @@ module Fusion
86
134
 
87
135
  module Statement
88
136
  # The only statement. Only allowed in the REPL. `name = expression`.
89
- Assignment = TypedData.define(name: Identifier, expression: Expression)
137
+ Assignment = TypedData.define(name: IDENTIFIER, expression: Expression)
90
138
 
91
139
  constants.each do |name|
92
140
  node = const_get(name)
data/lib/fusion/atom.rb CHANGED
@@ -11,7 +11,7 @@ module Fusion
11
11
  # A scalar literal value: the JSON atoms plus NULL (everything the lexer
12
12
  # emits as a token value, see Lexer#lex_number and #lex_word).
13
13
  Atom = ->(v) {
14
- v == NULL || v == true || v == false ||
14
+ v == NULL || v == true || v == false ||
15
15
  v.is_a?(Integer) || v.is_a?(Float) || v.is_a?(String)
16
16
  }
17
17
  end
@@ -26,7 +26,7 @@ module Fusion
26
26
  next unless raw.is_a?(Array) && raw.length == 2 && raw[0].is_a?(Integer)
27
27
 
28
28
  # The tag must be exactly the integer 0 or 1 (no 0.0 — Fusion equality is exact).
29
- [raw[0], raw[1]] if raw[0] == 0 || raw[0] == 1
29
+ [raw[0], raw[1]] if [0, 1].include?(raw[0])
30
30
  end
31
31
  when :object
32
32
  decode_envelope(text, mode) do |raw|
@@ -68,12 +68,12 @@ module Fusion
68
68
  return WirePair.new(status: status, data: JSON.generate(inner)) if status
69
69
 
70
70
  WirePair.new(status: 1, data: JSON.generate(
71
- "kind" => "argument_error",
72
- "origin" => "input",
71
+ "kind" => "argument_error",
72
+ "origin" => "input",
73
73
  "operation" => "decoding input",
74
- "status" => 0,
75
- "input" => raw,
76
- "expected" => EXPECTED_ENVELOPE_SHAPES.fetch(mode)
74
+ "status" => 0,
75
+ "input" => raw,
76
+ "expected" => EXPECTED_ENVELOPE_SHAPES.fetch(mode),
77
77
  ))
78
78
  rescue JSON::ParserError
79
79
  # TODO: BUG ???
@@ -12,12 +12,12 @@ module Fusion
12
12
  def encode(wire_pair, mode:)
13
13
  case mode
14
14
  when :bang
15
- bang = wire_pair.status.zero? ? "" : "!"
15
+ bang = wire_pair.status == 0 ? "" : "!"
16
16
  "#{bang}#{wire_pair.data}"
17
17
  when :array
18
18
  "[#{wire_pair.status},#{wire_pair.data}]"
19
19
  when :object
20
- key = wire_pair.status.zero? ? "value" : "error"
20
+ key = wire_pair.status == 0 ? "value" : "error"
21
21
  "{\"#{key}\":#{wire_pair.data}}"
22
22
  else
23
23
  raise Unreachable, "Unknown output mode #{mode}"
@@ -52,7 +52,7 @@ module Fusion
52
52
  object {"value": _} marks a value, {"error": _} an error
53
53
  TEXT
54
54
 
55
- MODES = %w[unix bang array object].freeze
55
+ MODES = ['unix', 'bang', 'array', 'object'].freeze
56
56
 
57
57
  attr_reader :use_case, :input_mode, :output_mode, :inline_source, :program_path, :jail
58
58
 
@@ -138,12 +138,12 @@ module Fusion
138
138
  # exe/fusion reports them as plain usage text (never a payloaded error).
139
139
  def self.run_parser(parser, argv)
140
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
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
147
  end
148
148
 
149
149
  # A MODE value -> its symbol, or a UsageError naming the valid modes.
@@ -174,12 +174,14 @@ module Fusion
174
174
  unless input_mode.nil? && output_mode.nil? && !error_input && inline_source.nil? && positional.empty?
175
175
  raise UsageError, "--repl takes no program, no input, and no modes"
176
176
  end
177
+
177
178
  program_path = nil
178
179
  when :stream
179
180
  input_mode ||= :array
180
181
  output_mode ||= :array
181
182
  raise UsageError, "--stream does not support the unix mode" if input_mode == :unix || output_mode == :unix
182
183
  raise UsageError, "-! requires the unix input mode" if error_input
184
+
183
185
  program_path = inline_source ? nil : positional.shift
184
186
  raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
185
187
  raise UsageError, "too many positional arguments" unless positional.empty?
@@ -187,6 +189,7 @@ module Fusion
187
189
  input_mode ||= :unix
188
190
  output_mode ||= :unix
189
191
  raise UsageError, "-! requires the unix input mode" if error_input && input_mode != :unix
192
+
190
193
  program_path = inline_source ? nil : positional.shift
191
194
  raise UsageError, "missing program (a .fsn file or -e)" unless inline_source || program_path
192
195
  raise UsageError, "too many positional arguments" unless positional.empty?
@@ -202,7 +205,7 @@ module Fusion
202
205
  program_path: program_path,
203
206
  error_input: error_input,
204
207
  skip_blank_lines: skip_blank_lines,
205
- jail: jail
208
+ jail: jail,
206
209
  )
207
210
  end
208
211
 
@@ -19,7 +19,7 @@ module Fusion
19
19
  origin: "input",
20
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
 
@@ -14,10 +14,10 @@ module Fusion
14
14
  GREEN = "\e[32m"
15
15
  RED = "\e[31m"
16
16
 
17
- PROMPT = "#{LIGHT_BLUE}fsn> #{RESET}"
18
- CONTINUATION_PROMPT = "#{LIGHT_BLUE}...> #{RESET}"
19
- VALUE_MARKER = "#{GREEN}✔ #{RESET}"
20
- ERROR_MARKER = "#{RED}✗ #{RESET}"
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
21
 
22
22
  # REPL entries report errors with the same site as inline (`-e`) code.
23
23
  SITE = { origin: "code", file: "<inline>" }.freeze
@@ -32,7 +32,7 @@ module Fusion
32
32
  require "reline"
33
33
  Reline.output = $stderr
34
34
  Reline.prompt_proc = proc do |lines|
35
- lines.each_index.map { |i| i.zero? ? PROMPT : CONTINUATION_PROMPT }
35
+ lines.each_index.map { |i| i == 0 ? PROMPT : CONTINUATION_PROMPT }
36
36
  end
37
37
 
38
38
  # The session env is a child of the run's root, so it carries the jail;
@@ -43,7 +43,7 @@ module Fusion
43
43
  buffer = begin
44
44
  Reline.readmultiline(PROMPT, true) { complete?(_1) }
45
45
  rescue Interrupt
46
- $stderr.puts("^C") # discard the half-typed entry and re-prompt
46
+ warn("^C") # discard the half-typed entry and re-prompt
47
47
  next
48
48
  end
49
49
 
@@ -13,7 +13,7 @@ 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)
16
+ if runtime_value.is_a?(Interpreter::ErrorVal) # rubocop:disable Style/GuardClause
17
17
  error = runtime_value
18
18
  data = convert(error.payload, lenient: lenient || error.runtime?).to_json
19
19
  return WirePair.new(status: 1, data: data)
@@ -27,7 +27,7 @@ module Fusion
27
27
  origin: "output",
28
28
  operation: "serializing result",
29
29
  input: runtime_value,
30
- message: message
30
+ message: message,
31
31
  )
32
32
 
33
33
  serialize(runtime_error, lenient: true)
@@ -42,6 +42,7 @@ module Fusion
42
42
  nil
43
43
  when Float
44
44
  return runtime_value if runtime_value.finite?
45
+
45
46
  throw(:unserializable, "cannot serialize a non-finite number") unless lenient
46
47
 
47
48
  "<#{runtime_value}>" # "<Infinity>" / "<-Infinity>" / "<NaN>"
@@ -56,11 +57,9 @@ module Fusion
56
57
  when true, false, String, Numeric
57
58
  runtime_value
58
59
  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
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}"
64
63
  else
65
64
  raise Unreachable, "Unhandled type in convert: #{runtime_value.class}"
66
65
  end
data/lib/fusion/cli.rb CHANGED
@@ -173,7 +173,7 @@ module Fusion
173
173
  # WirePair -> stdout/stderr
174
174
  def emit_output(wire_pair, output_mode:)
175
175
  if output_mode == :unix
176
- channel = wire_pair.status.zero? ? $stdout : $stderr
176
+ channel = wire_pair.status == 0 ? $stdout : $stderr
177
177
  channel.puts(wire_pair.data)
178
178
  exit(wire_pair.status)
179
179
  else
@@ -12,9 +12,7 @@ module Fusion
12
12
  define = ->(name, fn) { table[name] = NativeFunc.new(name, fn) }
13
13
 
14
14
  # Irreducible primitives kept as built-ins. The sugar-target operators
15
- # (arithmetic/comparison/boolean) live in `@OP` below; their friendly
16
- # derived forms (`@lt`, `@gt`, `@truthy`, …) are stdlib files that build on
17
- # `@OP.*`, so they follow a per-directory `@OP` override. Numeric functions
15
+ # (arithmetic/comparison/boolean) live in `@OP` below. Numeric functions
18
16
  # (`round`, `divide`, `sin`, …) and constants (`pi`, `e`) live in `@math`.
19
17
  define.call("size", method(:size))
20
18
  define.call("join", method(:join))
@@ -23,7 +21,6 @@ module Fusion
23
21
  define.call("parseNumber", method(:parse_number))
24
22
  define.call("keys", method(:keys))
25
23
  define.call("values", method(:values))
26
- define.call("toObject", method(:to_object))
27
24
 
28
25
  # type predicates: return false on any non-matching value, never an error
29
26
  define.call("Integer", method(:integer?))
@@ -33,6 +30,7 @@ module Fusion
33
30
  define.call("Boolean", method(:boolean?))
34
31
  define.call("Array", method(:array?))
35
32
  define.call("Object", method(:object?))
33
+ define.call("Collection", method(:collection?))
36
34
  define.call("Null", method(:null?))
37
35
  define.call("Function", method(:function?))
38
36
  define.call("NonFinite", method(:non_finite?))
@@ -51,6 +49,10 @@ module Fusion
51
49
  "modulo" => NativeFunc.new("OP.modulo", method(:op_modulo)),
52
50
  "equal" => NativeFunc.new("OP.equal", method(:op_equal)),
53
51
  "compare" => NativeFunc.new("OP.compare", method(:op_compare)),
52
+ "lt" => NativeFunc.new("OP.lt", method(:op_lt)),
53
+ "gt" => NativeFunc.new("OP.gt", method(:op_gt)),
54
+ "lte" => NativeFunc.new("OP.lte", method(:op_lte)),
55
+ "gte" => NativeFunc.new("OP.gte", method(:op_gte)),
54
56
  "and" => NativeFunc.new("OP.and", method(:op_and)),
55
57
  "or" => NativeFunc.new("OP.or", method(:op_or)),
56
58
  "not" => NativeFunc.new("OP.not", method(:op_not)),
@@ -143,7 +145,7 @@ module Fusion
143
145
  def math_rand(v)
144
146
  return v if v.is_a?(ErrorVal)
145
147
  return rand if v == NULL
146
- return rand(v) if integer?(v) && v.positive?
148
+ return rand(v) if integer?(v) && v > 0
147
149
 
148
150
  argument_error("math.rand", v, ["_ ? @Null", '_ ? (n ? @Integer => [0, n] | @OP.compare | (-1 => true))'])
149
151
  end
@@ -197,7 +199,7 @@ module Fusion
197
199
  return argument_error("math.pow", v, NUMBER_PAIR) unless pair?(v) && numeric?(v[0]) && numeric?(v[1])
198
200
 
199
201
  a, b = v
200
- result = a.is_a?(Integer) && b.is_a?(Integer) && !b.negative? ? a**b : a.to_f**b
202
+ result = a.is_a?(Integer) && b.is_a?(Integer) && b >= 0 ? a**b : a.to_f**b
201
203
  return error("math_error", "math.pow", v, "not in domain (complex result)") if result.is_a?(Complex)
202
204
 
203
205
  result
@@ -206,9 +208,8 @@ module Fusion
206
208
  # --- OP: the sugar-target operators (reached as `@OP.sum`, `@OP.and`, …) ---
207
209
  #
208
210
  # The arithmetic, boolean, and equality members take an array of ANY length;
209
- # the unary ones take a single value; `compare` returns -1 / 0 / 1. The
210
- # friendly derived forms (`@lt`, `@gt`, `@truthy`, …) are stdlib files built on
211
- # these.
211
+ # the unary ones take a single value; `compare` returns -1 / 0 / 1, which
212
+ # `lt` / `gt` / `lte` / `gte` then read into a boolean.
212
213
 
213
214
  def op_sum(v)
214
215
  return v if v.is_a?(ErrorVal)
@@ -289,6 +290,26 @@ module Fusion
289
290
  end
290
291
  end
291
292
 
293
+ # The comparison readers interpret an `@OP.compare` result. The infix sugar
294
+ # `a < b` desugars to `[a, b] | @OP.compare | @OP.lt` (likewise `<=` / `>` /
295
+ # `>=`), so an `@OP` override reskins the ordering and its reading together.
296
+
297
+ def op_lt(v)
298
+ read_compare_result("OP.lt", v, [-1])
299
+ end
300
+
301
+ def op_gt(v)
302
+ read_compare_result("OP.gt", v, [1])
303
+ end
304
+
305
+ def op_lte(v)
306
+ read_compare_result("OP.lte", v, [-1, 0])
307
+ end
308
+
309
+ def op_gte(v)
310
+ read_compare_result("OP.gte", v, [0, 1])
311
+ end
312
+
292
313
  def op_and(v)
293
314
  return v if v.is_a?(ErrorVal)
294
315
  return argument_error("OP.and", v, ["_ ? @Array"]) unless v.is_a?(Array)
@@ -313,7 +334,7 @@ module Fusion
313
334
 
314
335
  def size(v)
315
336
  return v if v.is_a?(ErrorVal)
316
- return argument_error("size", v, ["_ ? @String", "_ ? @Array", "_ ? @Object"]) unless v.is_a?(String) || v.is_a?(Array) || v.is_a?(Hash)
337
+ return argument_error("size", v, ["_ ? @String", "_ ? @Collection"]) unless v.is_a?(String) || v.is_a?(Array) || v.is_a?(Hash)
317
338
 
318
339
  v.length
319
340
  end
@@ -322,6 +343,7 @@ module Fusion
322
343
  # is the stdlib pair-case built on this.
323
344
  def join(v)
324
345
  return v if v.is_a?(ErrorVal)
346
+
325
347
  expected = ['[_ ? (xs => {"c": xs, "f": @String} | @all), _ ? @String]']
326
348
  return argument_error("join", v, expected) unless pair?(v)
327
349
 
@@ -339,6 +361,7 @@ module Fusion
339
361
  # stdlib single-string case built on this.
340
362
  def split(v)
341
363
  return v if v.is_a?(ErrorVal)
364
+
342
365
  expected = ["[_ ? @String, _ ? @String]"]
343
366
  return argument_error("split", v, expected) unless pair?(v) && v[0].is_a?(String) && v[1].is_a?(String)
344
367
 
@@ -388,17 +411,6 @@ module Fusion
388
411
  v.values
389
412
  end
390
413
 
391
- def to_object(v)
392
- return v if v.is_a?(ErrorVal)
393
- # Each entry must be a [string, _] pair.
394
- expected = ['_ ? (xs => {"c": xs, "f": ([_ ? @String, _] => true)} | @all)']
395
- unless v.is_a?(Array) && v.all? { |entry| pair?(entry) && entry[0].is_a?(String) }
396
- return argument_error("toObject", v, expected)
397
- end
398
-
399
- v.to_h
400
- end
401
-
402
414
  private
403
415
 
404
416
  # Type predicates, also reused as internal guards.
@@ -420,7 +432,7 @@ module Fusion
420
432
  end
421
433
 
422
434
  def boolean?(v)
423
- v == true || v == false
435
+ [true, false].include?(v)
424
436
  end
425
437
 
426
438
  def array?(v)
@@ -431,6 +443,10 @@ module Fusion
431
443
  v.is_a?(Hash)
432
444
  end
433
445
 
446
+ def collection?(v)
447
+ array?(v) || object?(v)
448
+ end
449
+
434
450
  def null?(v)
435
451
  v == NULL
436
452
  end
@@ -447,6 +463,19 @@ module Fusion
447
463
  v.is_a?(Float) && !v.finite?
448
464
  end
449
465
 
466
+ COMPARE_RESULT = ["-1", "0", "1", "null"].freeze
467
+
468
+ # -1 / 0 / 1 (exact integers) → whether the ordering is in `truthy_results`.
469
+ # A partial order's compare may return null (incomparable); that passes
470
+ # through as null rather than being forced to a boolean.
471
+ def read_compare_result(name, v, truthy_results)
472
+ return v if v.is_a?(ErrorVal)
473
+ return NULL if v == NULL
474
+ return argument_error(name, v, COMPARE_RESULT) unless integer?(v) && [-1, 0, 1].include?(v)
475
+
476
+ truthy_results.include?(v)
477
+ end
478
+
450
479
  # Build a standardized interpreter error carrying a human-readable
451
480
  # `message` (see docs/user/reference.md §6.5). Use this for failures that
452
481
  # aren't an input-shape mismatch (math, conversion, access). `operation`
@@ -33,9 +33,9 @@ module Fusion
33
33
  @state = :forcing
34
34
  begin
35
35
  @value = @compute.call
36
- rescue ReadFailure => failure
36
+ rescue ReadFailure => e
37
37
  # Memoize the Ruby error itself. Turn it into a Fusion runtime error below.
38
- @value = failure
38
+ @value = e
39
39
  end
40
40
  @state = :done
41
41
  @value