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
@@ -80,10 +80,10 @@ module Fusion
80
80
  rescue Unreachable
81
81
  # An interpreter bug. Allowed to surface.
82
82
  raise
83
- rescue StandardError => err
83
+ rescue StandardError => e
84
84
  Interpreter::ErrorVal.from_runtime(
85
85
  kind: "internal_error", origin: "interpreter", operation: "running the program",
86
- input: NULL, message: err.message
86
+ input: NULL, message: e.message,
87
87
  )
88
88
  rescue SystemExit
89
89
  # Let exit/abort through.
@@ -91,13 +91,13 @@ module Fusion
91
91
  rescue SystemStackError
92
92
  Interpreter::ErrorVal.from_runtime(
93
93
  kind: "limit_error", origin: "interpreter", operation: "running the program",
94
- input: NULL, message: "stack level too deep"
94
+ input: NULL, message: "stack level too deep",
95
95
  )
96
- rescue Exception => err # rubocop:disable Lint/RescueException
96
+ rescue Exception => e # rubocop:disable Lint/RescueException
97
97
  # Final net: any other escaped Ruby error becomes a payloaded error too.
98
98
  Interpreter::ErrorVal.from_runtime(
99
99
  kind: "internal_error", origin: "interpreter", operation: "running the program",
100
- input: NULL, message: err.message
100
+ input: NULL, message: e.message,
101
101
  )
102
102
  end
103
103
 
@@ -169,9 +169,9 @@ module Fusion
169
169
  end
170
170
  rescue Errno::ENOENT
171
171
  raise Thunk::ReadFailure, "file not found"
172
- rescue SystemCallError => err # EISDIR, EACCES, ... (file-system access failures)
172
+ rescue SystemCallError => e # EISDIR, EACCES, ... (file-system access failures)
173
173
  # Drop Ruby's "@ io_fread - <path>" tail.
174
- raise Thunk::ReadFailure, err.message.split(" @ ").first.downcase
174
+ raise Thunk::ReadFailure, e.message.split(" @ ").first.downcase
175
175
  end
176
176
 
177
177
  # Evaluate a top-level unit that has no file of its own:
@@ -191,7 +191,7 @@ module Fusion
191
191
  # `site` is the `{origin:, file:}` of the referencing code; `reference` is its
192
192
  # own source text (`"@name"`) — the single `operation` every failure reports.
193
193
  def resolve_name(name, dir, site, reference)
194
- sibling_file = File.expand_path(name + ".fsn", dir)
194
+ sibling_file = File.expand_path("#{name}.fsn", dir)
195
195
  if File.exist?(sibling_file)
196
196
  return jail_error(site, reference, NULL) unless within_jail?(sibling_file)
197
197
 
@@ -251,7 +251,7 @@ module Fusion
251
251
  return @builtins[name]
252
252
  end
253
253
 
254
- stdlib_file = File.join(@stdlib_dir, name + ".fsn")
254
+ stdlib_file = File.join(@stdlib_dir, "#{name}.fsn")
255
255
  if File.exist?(stdlib_file)
256
256
  # The reference reports the user's source text, not the internal stdlib path.
257
257
  return load_file(stdlib_file).force(operation: reference, input: NULL, site: site)
@@ -263,7 +263,7 @@ module Fusion
263
263
  # Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
264
264
  # `reference` is the source text (`"@../a"`), the single `operation` reported.
265
265
  def resolve_path(relpath, dir, site, reference)
266
- target = File.expand_path(relpath + ".fsn", dir)
266
+ target = File.expand_path("#{relpath}.fsn", dir)
267
267
  return jail_error(site, reference, NULL) unless within_jail?(target)
268
268
 
269
269
  load_file(target).force(operation: reference, input: NULL, site: site)
@@ -374,11 +374,11 @@ module Fusion
374
374
  when ArrayItem
375
375
  out.append(value)
376
376
  when ArraySpread
377
- if value.is_a?(Array)
378
- out.concat(value)
379
- else
377
+ if !value.is_a?(Array)
380
378
  return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "[...] array spread", input: value, expected: ["_ ? @Array"])
381
379
  end
380
+
381
+ out.concat(value)
382
382
  else
383
383
  raise Unreachable, "Unknown array item #{item.class}"
384
384
  end
@@ -402,11 +402,11 @@ module Fusion
402
402
  when KeyValuePair
403
403
  out[pair.key] = value
404
404
  when ObjectSpread
405
- if value.is_a?(Hash)
406
- out.merge!(value)
407
- else
405
+ if !value.is_a?(Hash)
408
406
  return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "{...} object spread", input: value, expected: ["_ ? @Object"])
409
407
  end
408
+
409
+ out.merge!(value)
410
410
  else
411
411
  raise Unreachable, "Unknown object pair #{pair.class}"
412
412
  end
@@ -535,16 +535,21 @@ module Fusion
535
535
  # becomes a payloaded error rather than a raw backtrace on stderr.
536
536
  begin
537
537
  f.fn.call(v)
538
- rescue StandardError => err
538
+ rescue StandardError => e
539
539
  # TODO: move math errors into the builtins. This should become a safety net for unpredicted errors.
540
- kind = (err.is_a?(FloatDomainError) || err.is_a?(ZeroDivisionError) || err.is_a?(Math::DomainError)) ? "math_error" : "internal_error"
541
- ErrorVal.from_runtime(kind: kind, origin: "builtin", operation: "@#{f.name}", input: v, message: err.message)
540
+ kind = case e
541
+ when FloatDomainError, ZeroDivisionError, Math::DomainError
542
+ "math_error"
543
+ else
544
+ "internal_error"
545
+ end
546
+ ErrorVal.from_runtime(kind: kind, origin: "builtin", operation: "@#{f.name}", input: v, message: e.message)
542
547
  end
543
548
  elsif f.is_a?(Func)
544
549
  # Stdlib code has no user file of its own: errors inside it (and in the
545
550
  # built-ins it calls) report the user `call_site` that reached it. User and
546
551
  # inline functions are their own call site (derived lexically from their env).
547
- body_call_site = (code_site(f.env)[:origin] == "stdlib") ? call_site : nil
552
+ body_call_site = code_site(f.env)[:origin] == "stdlib" ? call_site : nil
548
553
 
549
554
  f.clauses.each do |clause|
550
555
  # Bindings are inserted directly into a fresh child env as the pattern
@@ -646,7 +651,7 @@ module Fusion
646
651
  if predicate_result.is_a?(ErrorVal)
647
652
  # An unresolved @-reference, or an error raised while applying the
648
653
  # predicate, becomes the clause's result.
649
- return predicate_result
654
+ predicate_result
650
655
  else
651
656
  # Ruby-style truthiness: the clause matches unless the predicate
652
657
  # yields `false` or `null`.
@@ -672,11 +677,11 @@ module Fusion
672
677
  return r if r.is_a?(ErrorVal)
673
678
  return false unless r
674
679
  end
675
- true
676
680
  else
677
681
  before = items[0...rest_index]
678
682
  after = items[(rest_index + 1)..]
679
683
  return false if value.length < before.length + after.length
684
+
680
685
  before.each_with_index do |item, i|
681
686
  r = match(item.pattern, value[i], env)
682
687
  return r if r.is_a?(ErrorVal)
@@ -693,8 +698,8 @@ module Fusion
693
698
  mid = value[before.length...(value.length - after.length)]
694
699
  env.bind(rest_name, mid)
695
700
  end
696
- true
697
701
  end
702
+ true
698
703
  end
699
704
 
700
705
  def match_object(pattern, value, env)
@@ -708,9 +713,11 @@ module Fusion
708
713
  rest_name = pair.name # may be nil (ignore) or a string
709
714
  when PatternPair
710
715
  return false unless value.key?(pair.key)
716
+
711
717
  r = match(pair.pattern, value[pair.key], env)
712
718
  return r if r.is_a?(ErrorVal)
713
719
  return false unless r
720
+
714
721
  matched_keys << pair.key
715
722
  else
716
723
  raise Unreachable, "Unknown object pattern pair #{pair.class}"
@@ -739,6 +746,7 @@ module Fusion
739
746
  def deep_equal?(a, b)
740
747
  return true if a.equal?(b)
741
748
  return false if a.class != b.class
749
+
742
750
  case a
743
751
  when Array
744
752
  a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
data/lib/fusion/lexer.rb CHANGED
@@ -20,6 +20,7 @@ module Fusion
20
20
  "=" => :equals,
21
21
  "+" => :plus, "-" => :minus, "*" => :star,
22
22
  "%" => :percent, "~" => :tilde,
23
+ "<" => :lt, ">" => :gt,
23
24
  }.freeze
24
25
 
25
26
  def initialize(src)
@@ -34,7 +35,7 @@ module Fusion
34
35
  t = next_token
35
36
  out << t
36
37
  # A file-reference path is one tight token, lexed only right after `@`/`@@`.
37
- if t.type == :at || t.type == :atat
38
+ if [:at, :atat].include?(t.type)
38
39
  p = try_lex_path
39
40
  out << p unless p.nil?
40
41
  end
@@ -79,17 +80,35 @@ module Fusion
79
80
  @i += 2
80
81
  return Token.new(type: :qq, value: "??", pos: start)
81
82
  end
83
+ if c == "<" && peek(1) == "="
84
+ @i += 2
85
+ return Token.new(type: :lte, value: "<=", pos: start)
86
+ end
87
+ if c == ">" && peek(1) == "="
88
+ @i += 2
89
+ return Token.new(type: :gte, value: ">=", pos: start)
90
+ end
82
91
  if c == "&" && peek(1) == "&"
83
92
  @i += 2
84
93
  return Token.new(type: :andand, value: "&&", pos: start)
85
94
  end
86
95
  if c == "|"
87
96
  case peek(1)
88
- when "|" then @i += 2; return Token.new(type: :oror, value: "||", pos: start)
89
- when ":" then @i += 2; return Token.new(type: :pipemap, value: "|:", pos: start)
90
- when "?" then @i += 2; return Token.new(type: :pipefilter, value: "|?", pos: start)
91
- when "+" then @i += 2; return Token.new(type: :pipereduce, value: "|+", pos: start)
92
- else @i += 1; return Token.new(type: :pipe, value: "|", pos: start)
97
+ when "|"
98
+ @i += 2
99
+ return Token.new(type: :oror, value: "||", pos: start)
100
+ when ":"
101
+ @i += 2
102
+ return Token.new(type: :pipemap, value: "|:", pos: start)
103
+ when "?"
104
+ @i += 2
105
+ return Token.new(type: :pipefilter, value: "|?", pos: start)
106
+ when "+"
107
+ @i += 2
108
+ return Token.new(type: :pipereduce, value: "|+", pos: start)
109
+ else
110
+ @i += 1
111
+ return Token.new(type: :pipe, value: "|", pos: start)
93
112
  end
94
113
  end
95
114
  if c == "!"
@@ -105,6 +124,7 @@ module Fusion
105
124
  if ident_start?(c)
106
125
  return lex_word(start)
107
126
  end
127
+
108
128
  if (type = PUNCT[c])
109
129
  @i += 1
110
130
  return Token.new(type: type, value: c, pos: start)
@@ -114,13 +134,13 @@ module Fusion
114
134
 
115
135
  def skip_trivia
116
136
  loop do
117
- c = peek
118
- if c == " " || c == "\t" || c == "\n" || c == "\r"
137
+ case peek
138
+ when " ", "\t", "\n", "\r"
119
139
  @i += 1
120
- elsif c == "#" && at_line_start?
121
- # A line is a comment iff its first non-whitespace char is "#".
122
- # This also covers shebang lines (#!) for free.
123
- @i += 1 until peek.nil? || peek == "\n"
140
+ when "#"
141
+ break if !at_line_start?
142
+
143
+ @i += 1 until [nil, "\n"].include?(peek)
124
144
  else
125
145
  break
126
146
  end
@@ -130,7 +150,7 @@ module Fusion
130
150
  # True when only whitespace precedes @i on the current physical line.
131
151
  def at_line_start?
132
152
  j = @i - 1
133
- j -= 1 while j >= 0 && (@src[j] == " " || @src[j] == "\t")
153
+ j -= 1 while j >= 0 && [" ", "\t"].include?(@src[j])
134
154
  j < 0 || @src[j] == "\n" || @src[j] == "\r"
135
155
  end
136
156
 
@@ -138,37 +158,39 @@ module Fusion
138
158
  @i += 1 # opening quote
139
159
  buf = +""
140
160
  while (c = peek)
141
- if c == '"'
161
+ case c
162
+ when '"'
142
163
  @i += 1
143
164
  return Token.new(type: :string, value: buf, pos: start)
144
- elsif c == "\\"
165
+ when "\\"
145
166
  @i += 1
146
167
  e = peek
147
168
  buf << case e
148
- when '"' then '"'
149
- when "\\" then "\\"
150
- when "/" then "/"
151
- when "n" then "\n"
152
- when "t" then "\t"
153
- when "r" then "\r"
154
- when "b" then "\b"
155
- when "f" then "\f"
156
- when "u"
157
- hex = @src[@i + 1, 4]
158
- @i += 4
159
- code_point = hex.to_i(16)
160
- # Reject surrogates and out-of-range code points up front:
161
- # pack("U") would otherwise build an invalid-encoding string
162
- # whose malformed-UTF-8 error surfaces far downstream.
163
- if code_point.between?(0xD800, 0xDFFF) || code_point > 0x10FFFF
164
- raise ParseError, "Invalid unicode escape \\u#{hex}"
165
- end
166
- [code_point].pack("U")
167
- else
168
- raise ParseError, "Bad escape \\#{e}"
169
- end
169
+ when '"' then '"'
170
+ when "\\" then "\\"
171
+ when "/" then "/"
172
+ when "n" then "\n"
173
+ when "t" then "\t"
174
+ when "r" then "\r"
175
+ when "b" then "\b"
176
+ when "f" then "\f"
177
+ when "u"
178
+ hex = @src[@i + 1, 4]
179
+ @i += 4
180
+ code_point = hex.to_i(16)
181
+ # Reject surrogates and out-of-range code points up front:
182
+ # pack("U") would otherwise build an invalid-encoding string
183
+ # whose malformed-UTF-8 error surfaces far downstream.
184
+ if code_point.between?(0xD800, 0xDFFF) || code_point > 0x10FFFF
185
+ raise ParseError, "Invalid unicode escape \\u#{hex}"
186
+ end
187
+
188
+ [code_point].pack("U")
189
+ else
190
+ raise ParseError, "Bad escape \\#{e}"
191
+ end
170
192
  @i += 1
171
- elsif c == "\n" || c == "\r"
193
+ when "\n", "\r"
172
194
  raise ParseError, "Raw newline in string starting at #{start}; use \\n"
173
195
  else
174
196
  buf << c
@@ -187,10 +209,10 @@ module Fusion
187
209
  j += 1
188
210
  j += 1 while j < @n && digit?(@src[j])
189
211
  end
190
- if (@src[j] == "e" || @src[j] == "E")
212
+ if ["e", "E"].include?(@src[j])
191
213
  is_float = true
192
214
  j += 1
193
- j += 1 if (@src[j] == "+" || @src[j] == "-")
215
+ j += 1 if ["+", "-"].include?(@src[j])
194
216
  j += 1 while j < @n && digit?(@src[j])
195
217
  end
196
218
  text = @src[@i...j]
data/lib/fusion/parser.rb CHANGED
@@ -30,8 +30,8 @@ module Fusion
30
30
  expr = p.parse_expr
31
31
  p.expect(:eof)
32
32
  expr
33
- rescue ParseError => err
34
- Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: err.message)
33
+ rescue ParseError => e
34
+ Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: e.message)
35
35
  end
36
36
 
37
37
  # Parse one REPL entry — a statement (`identifier "=" expr`) or a bare
@@ -45,8 +45,8 @@ module Fusion
45
45
  entry = p.parse_repl_entry
46
46
  p.expect(:eof)
47
47
  entry
48
- rescue ParseError => err
49
- Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: err.message)
48
+ rescue ParseError => e
49
+ Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: e.message)
50
50
  end
51
51
 
52
52
  # A leading `identifier =` marks a statement; anything else is an expression.
@@ -73,7 +73,7 @@ module Fusion
73
73
  # --- Operator sugar (reference §2.7) --------------------------------------
74
74
  # The ladder below desugars every operator to a pipe into an `@OP.*` member
75
75
  # (or, for the map-pipes, a stdlib call). Tightest to loosest:
76
- # postfix · unary (! - / ~) · pipe (| |: |? |+) · * / % // · + - · ?? · == · && · ||
76
+ # postfix · unary (! - / ~) · pipe (| |: |? |+) · * / % // · + - · ?? < <= > >= · == · && · ||
77
77
  # `@OP.*` is a shadowable `:name` reference, so a local `@OP` reskins the operators.
78
78
 
79
79
  def parse_or
@@ -103,12 +103,24 @@ module Fusion
103
103
  fold_operator(operands, "equal")
104
104
  end
105
105
 
106
- # `??` (compare) is binary and left-associative it does not fold.
106
+ # The comparisons desugar to a compare piped into its reading member:
107
+ # `a < b` → `[a, b] | @OP.compare | @OP.lt`.
108
+ COMPARISON_MEMBERS = { lt: "lt", lte: "lte", gt: "gt", gte: "gte" }.freeze
109
+
110
+ # `??` (compare) and the comparisons are binary and left-associative — they
111
+ # do not fold, and `a < b < c` does not chain (it compares a boolean).
107
112
  def parse_ordering
108
113
  node = parse_additive
109
- while at?(:qq)
110
- advance
111
- node = pipe_operator([node, parse_additive], "compare")
114
+ loop do
115
+ if at?(:qq)
116
+ advance
117
+ node = pipe_operator([node, parse_additive], "compare")
118
+ elsif COMPARISON_MEMBERS.key?(peek.type)
119
+ member = COMPARISON_MEMBERS[advance.type]
120
+ node = pipe_into(pipe_operator([node, parse_additive], "compare"), member)
121
+ else
122
+ break
123
+ end
112
124
  end
113
125
  node
114
126
  end
@@ -131,7 +143,7 @@ module Fusion
131
143
  # are binary and break the run. Standard left-to-right: `a * b % c` is `(a * b) % c`.
132
144
  def parse_multiplicative
133
145
  node = parse_pipe
134
- run = nil # accumulating product-run terms, or nil
146
+ run = nil # accumulating product-run terms, or nil
135
147
  loop do
136
148
  if at?(:star) || at?(:slash)
137
149
  inverted = at?(:slash)
@@ -165,10 +177,12 @@ module Fusion
165
177
  elsif at?(:pipemap) || at?(:pipefilter) || at?(:pipereduce)
166
178
  target = { pipemap: "map", pipefilter: "filter", pipereduce: "reduce" }[peek.type]
167
179
  advance
168
- arg = Expression::ObjLit.new(pairs: [
169
- KeyValuePair.new(key: "c", value: node),
170
- KeyValuePair.new(key: "f", value: parse_unary),
171
- ])
180
+ arg = Expression::ObjLit.new(
181
+ pairs: [
182
+ KeyValuePair.new(key: "c", value: node),
183
+ KeyValuePair.new(key: "f", value: parse_unary),
184
+ ],
185
+ )
172
186
  node = Expression::Pipe.new(left: arg, right: name_ref(target))
173
187
  else
174
188
  break
@@ -179,8 +193,7 @@ module Fusion
179
193
 
180
194
  # Tokens that can begin a unary operand — used to decide whether `!` has an
181
195
  # operand or is the bare `!null`.
182
- PRIMARY_STARTERS = %i[number string true_kw false_kw null_kw bang minus slash tilde
183
- lbracket lbrace lparen ident at atat].freeze
196
+ PRIMARY_STARTERS = [:number, :string, :true_kw, :false_kw, :null_kw, :bang, :minus, :slash, :tilde, :lbracket, :lbrace, :lparen, :ident, :at, :atat].freeze
184
197
 
185
198
  # Unary prefixes: `!x` builds an error (bare `!` is `!null`); `-x` negates,
186
199
  # `/x` inverts, `~x` is logical not. All bind tighter than `|`.
@@ -261,12 +274,15 @@ module Fusion
261
274
  def parse_primary
262
275
  t = peek
263
276
  case t.type
264
- when :number, :string then advance; Expression::Lit.new(value: t.value)
265
- when :true_kw, :false_kw, :null_kw then advance; Expression::Lit.new(value: t.value)
277
+ when :number, :string, :true_kw, :false_kw, :null_kw
278
+ advance
279
+ Expression::Lit.new(value: t.value)
266
280
  when :lbracket then parse_array
267
281
  when :lbrace then parse_object
268
282
  when :lparen then parse_function_or_group
269
- when :ident then advance; Expression::Ident.new(name: t.value)
283
+ when :ident
284
+ advance
285
+ Expression::Ident.new(name: t.value)
270
286
  when :at then parse_fileref
271
287
  when :atat then parse_superref
272
288
  else raise ParseError, "Unexpected token #{t.type} (#{t.value.inspect}) at #{t.pos}"
@@ -284,6 +300,7 @@ module Fusion
284
300
 
285
301
  tok = advance
286
302
  raise ParseError, "`@@` cannot take an upward path (at #{tok.pos})" if tok.value.include?("..")
303
+
287
304
  Expression::FileRef.new(variety: :super_name, path: tok.value)
288
305
  end
289
306
 
@@ -309,6 +326,7 @@ module Fusion
309
326
  items << ArrayItem.new(value: parse_expr)
310
327
  end
311
328
  break unless at?(:comma)
329
+
312
330
  advance
313
331
  end
314
332
  expect(:rbracket)
@@ -329,11 +347,13 @@ module Fusion
329
347
  key_tok = expect(:string)
330
348
  key = key_tok.value
331
349
  raise ParseError, "duplicate key #{key.inspect} (at #{key_tok.pos})" if keys.include?(key)
350
+
332
351
  keys << key
333
352
  expect(:colon)
334
353
  pairs << KeyValuePair.new(key: key, value: parse_expr)
335
354
  end
336
355
  break unless at?(:comma)
356
+
337
357
  advance
338
358
  end
339
359
  expect(:rbrace)
@@ -358,12 +378,11 @@ module Fusion
358
378
  expect(:arrow)
359
379
  body = parse_expr
360
380
  clauses << Clause.new(pattern: pat, body: body)
361
- if at?(:comma)
362
- advance
363
- break if at?(:rparen) # trailing comma
364
- else
365
- break
366
- end
381
+
382
+ break if !at?(:comma)
383
+
384
+ advance
385
+ break if at?(:rparen) # trailing comma
367
386
  end
368
387
  expect(:rparen)
369
388
  Expression::FuncLit.new(clauses: clauses)
@@ -384,10 +403,11 @@ module Fusion
384
403
  case t.type
385
404
  when :lparen, :lbracket, :lbrace then depth += 1
386
405
  when :rparen, :rbracket, :rbrace
387
- return false if depth.zero? # hit our closing ) first
406
+ return false if depth == 0 # hit our closing ) first
407
+
388
408
  depth -= 1
389
409
  when :arrow
390
- return true if depth.zero?
410
+ return true if depth == 0
391
411
  when :eof
392
412
  return false
393
413
  end
@@ -412,8 +432,7 @@ module Fusion
412
432
 
413
433
  # Tokens that can begin a `guardedpat` (used to detect whether `!` is
414
434
  # followed by a payload pattern or stands alone).
415
- GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw minus
416
- lbracket lbrace ident].freeze
435
+ GUARDEDPAT_STARTERS = [:number, :string, :true_kw, :false_kw, :null_kw, :minus, :lbracket, :lbrace, :ident].freeze
417
436
 
418
437
  def parse_errpat
419
438
  expect(:bang)
@@ -440,9 +459,12 @@ module Fusion
440
459
  def parse_corepat
441
460
  t = peek
442
461
  case t.type
443
- when :number, :string then advance; Pattern::PLit.new(value: t.value)
444
- when :true_kw, :false_kw, :null_kw then advance; Pattern::PLit.new(value: t.value)
445
- when :minus then advance; Pattern::PLit.new(value: -expect(:number).value) # negative literal
462
+ when :number, :string, :true_kw, :false_kw, :null_kw
463
+ advance
464
+ Pattern::PLit.new(value: t.value)
465
+ when :minus
466
+ advance
467
+ Pattern::PLit.new(value: -expect(:number).value) # negative literal
446
468
  when :lbracket then parse_arraypat
447
469
  when :lbrace then parse_objectpat
448
470
  when :ident
@@ -473,12 +495,14 @@ module Fusion
473
495
  advance
474
496
  break if at?(:rbracket) # trailing comma
475
497
  raise ParseError, "a pattern may contain at most one `...rest` (at #{peek.pos})" if at?(:spread)
498
+
476
499
  items << PatternItem.new(pattern: parse_guardedpat)
477
500
  end
478
501
  break
479
502
  end
480
503
  items << PatternItem.new(pattern: parse_guardedpat)
481
504
  break unless at?(:comma)
505
+
482
506
  advance
483
507
  end
484
508
  expect(:rbracket)
@@ -499,14 +523,17 @@ module Fusion
499
523
  unless at?(:rbrace)
500
524
  raise ParseError, "in an object pattern, `...rest` must come last (at #{peek.pos})"
501
525
  end
526
+
502
527
  break
503
528
  end
504
529
  key_pos = peek.pos
505
530
  pair = parse_pattern_pair
506
531
  raise ParseError, "duplicate key #{pair.key.inspect} (at #{key_pos})" if keys.include?(pair.key)
532
+
507
533
  keys << pair.key
508
534
  pairs << pair
509
535
  break unless at?(:comma)
536
+
510
537
  advance
511
538
  end
512
539
  expect(:rbrace)
@@ -530,12 +557,22 @@ module Fusion
530
557
  end
531
558
 
532
559
  # ---- token helpers ----
533
- def peek(o = 0) = @toks[@i + o]
534
- def at?(type) = peek.type == type
535
- def advance = (@toks[@i].tap { @i += 1 })
560
+ def peek(o = 0)
561
+ @toks[@i + o]
562
+ end
563
+
564
+ def at?(type)
565
+ peek.type == type
566
+ end
567
+
568
+ def advance
569
+ @toks[@i].tap { @i += 1 }
570
+ end
571
+
536
572
  def expect(type)
537
573
  t = peek
538
574
  raise ParseError, "Expected #{type} but got #{t.type} (#{t.value.inspect}) at #{t.pos}" unless t.type == type
575
+
539
576
  advance
540
577
  end
541
578
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Fusion
2
- VERSION = "0.0.1"
4
+ VERSION = "0.0.2"
3
5
  end
data/lib/fusion.rb CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env ruby
2
1
  # frozen_string_literal: true
3
2
 
4
3
  # Fusion — a proof-of-concept interpreter.
data/stdlib/all.fsn CHANGED
@@ -4,10 +4,10 @@
4
4
  # the rest.
5
5
  (
6
6
  {"f": _ ? @Function, "c": []} => true,
7
- {"f": f ? @Function, "c": [x, ...rest]} => (x | f) | (
7
+ {"f": f ? @Function, "c": [x, ...rest]} => x | f | (
8
8
  _ ? @falsey => false,
9
9
  _ => {"f": f, "c": rest} | @,
10
10
  ),
11
11
  {"f": f ? @Function, "c": obj ? @Object} => {"f": f, "c": obj | @values} | @,
12
- x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@all", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": _ ? @Array}", "{\"f\": _ ? @Function, \"c\": _ ? @Object}"]},
12
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@all", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": _ ? @Collection}"]},
13
13
  )
data/stdlib/any.fsn CHANGED
@@ -4,9 +4,9 @@
4
4
  (
5
5
  {"f": _ ? @Function, "c": []} => false,
6
6
  {"f": f ? @Function, "c": [x, ...rest]} => x | f | (
7
- _ ? @falsey => {"f": f, "c": rest} | @,
8
- _ => true,
7
+ _ ? @truthy => true,
8
+ _ => {"f": f, "c": rest} | @,
9
9
  ),
10
10
  {"f": f ? @Function, "c": obj ? @Object} => {"f": f, "c": obj | @values} | @,
11
- x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@any", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": _ ? @Array}", "{\"f\": _ ? @Function, \"c\": _ ? @Object}"]},
11
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@any", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": _ ? @Collection}"]},
12
12
  )
data/stdlib/compact.fsn CHANGED
@@ -1,6 +1,5 @@
1
1
  # Drop null entries from an array (by element) or an object (by value). Built on @filter.
2
2
  (
3
- xs ? @Array => {"f": (null => false, _ => true), "c": xs} | @filter,
4
- obj ? @Object => {"f": (null => false, _ => true), "c": obj} | @filter,
5
- x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@compact", "status": 0, "input": x, "expected": ["_ ? @Array", "_ ? @Object"]}
3
+ c ? @Collection => c |? (null => false, _ => true),
4
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@compact", "status": 0, "input": x, "expected": ["_ ? @Collection"]}
6
5
  )
data/stdlib/concat.fsn CHANGED
@@ -1,5 +1,6 @@
1
- # Concatenate two strings. Input: [a, b]. Built on @join with an empty separator.
1
+ # Concatenate an array of strings, of any length. Built on @join with an empty
2
+ # separator.
2
3
  (
3
- [a ? @String, b ? @String] => [[a, b], ""] | @join,
4
- x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@concat", "status": 0, "input": x, "expected": ["[_ ? @String, _ ? @String]"]}
4
+ strings ? (xs ? @Array => {"c": xs, "f": @String} | @all) => [strings, ""] | @join,
5
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@concat", "status": 0, "input": x, "expected": ["_ ? (xs ? @Array => {\"c\": xs, \"f\": @String} | @all)"]}
5
6
  )