fusion-lang 0.0.1.alpha2 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -8
  3. data/docs/lang/design.md +240 -51
  4. data/docs/lang/implementation.md +238 -0
  5. data/docs/lang/roadmap.md +20 -36
  6. data/docs/user/explanation.md +5 -10
  7. data/docs/user/how-to-guides.md +60 -15
  8. data/docs/user/reference.md +356 -142
  9. data/docs/user/tutorial.md +21 -19
  10. data/examples/double.fsn +1 -1
  11. data/examples/factorial.fsn +2 -2
  12. data/examples/fizzbuzz.fsn +1 -4
  13. data/examples/json_test.fsn +4 -0
  14. data/examples/palindrome.fsn +1 -1
  15. data/exe/fusion +10 -10
  16. data/lib/fusion/ast.rb +2 -1
  17. data/lib/fusion/cli/decoder.rb +10 -5
  18. data/lib/fusion/cli/options.rb +130 -60
  19. data/lib/fusion/cli/parser.rb +3 -3
  20. data/lib/fusion/cli/repl.rb +30 -25
  21. data/lib/fusion/cli/serializer.rb +5 -4
  22. data/lib/fusion/cli.rb +119 -48
  23. data/lib/fusion/interpreter/builtins.rb +260 -151
  24. data/lib/fusion/interpreter/env.rb +42 -12
  25. data/lib/fusion/interpreter/error_val.rb +42 -20
  26. data/lib/fusion/interpreter/thunk.rb +53 -0
  27. data/lib/fusion/interpreter.rb +239 -82
  28. data/lib/fusion/lexer.rb +69 -3
  29. data/lib/fusion/parser.rb +189 -51
  30. data/lib/fusion/version.rb +1 -1
  31. data/stdlib/all.fsn +13 -0
  32. data/stdlib/any.fsn +12 -0
  33. data/stdlib/chars.fsn +5 -0
  34. data/stdlib/compact.fsn +6 -0
  35. data/stdlib/concat.fsn +5 -0
  36. data/stdlib/falsey.fsn +6 -0
  37. data/stdlib/filter.fsn +12 -0
  38. data/stdlib/flatten.fsn +7 -0
  39. data/stdlib/gt.fsn +9 -0
  40. data/stdlib/gte.fsn +9 -0
  41. data/stdlib/lt.fsn +9 -0
  42. data/stdlib/lte.fsn +9 -0
  43. data/stdlib/map.fsn +6 -4
  44. data/stdlib/range.fsn +2 -2
  45. data/stdlib/reduce.fsn +8 -0
  46. data/stdlib/sanitize.fsn +2 -2
  47. data/stdlib/truthy.fsn +7 -0
  48. metadata +18 -4
  49. data/lib/fusion/interpreter/file_thunk.rb +0 -39
  50. data/stdlib/mapValues.fsn +0 -5
  51. data/stdlib/math/square.fsn +0 -4
data/lib/fusion/lexer.rb CHANGED
@@ -15,9 +15,11 @@ module Fusion
15
15
  "[" => :lbracket, "]" => :rbracket,
16
16
  "{" => :lbrace, "}" => :rbrace,
17
17
  "," => :comma, ":" => :colon,
18
- "|" => :pipe, "?" => :question, "." => :dot,
18
+ "?" => :question, "." => :dot,
19
19
  "@" => :at, "/" => :slash,
20
20
  "=" => :equals,
21
+ "+" => :plus, "-" => :minus, "*" => :star,
22
+ "%" => :percent, "~" => :tilde,
21
23
  }.freeze
22
24
 
23
25
  def initialize(src)
@@ -31,6 +33,11 @@ module Fusion
31
33
  loop do
32
34
  t = next_token
33
35
  out << t
36
+ # A file-reference path is one tight token, lexed only right after `@`/`@@`.
37
+ if t.type == :at || t.type == :atat
38
+ p = try_lex_path
39
+ out << p unless p.nil?
40
+ end
34
41
  break if t.type == :eof
35
42
  end
36
43
  out
@@ -55,6 +62,36 @@ module Fusion
55
62
  @i += 3
56
63
  return Token.new(type: :spread, value: "...", pos: start)
57
64
  end
65
+ if c == "@" && peek(1) == "@"
66
+ @i += 2
67
+ return Token.new(type: :atat, value: "@@", pos: start)
68
+ end
69
+ # Two-character operators, matched before their single-char prefixes.
70
+ if c == "=" && peek(1) == "="
71
+ @i += 2
72
+ return Token.new(type: :eqeq, value: "==", pos: start)
73
+ end
74
+ if c == "/" && peek(1) == "/"
75
+ @i += 2
76
+ return Token.new(type: :slashslash, value: "//", pos: start)
77
+ end
78
+ if c == "?" && peek(1) == "?"
79
+ @i += 2
80
+ return Token.new(type: :qq, value: "??", pos: start)
81
+ end
82
+ if c == "&" && peek(1) == "&"
83
+ @i += 2
84
+ return Token.new(type: :andand, value: "&&", pos: start)
85
+ end
86
+ if c == "|"
87
+ 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)
93
+ end
94
+ end
58
95
  if c == "!"
59
96
  @i += 1
60
97
  return Token.new(type: :bang, value: "!", pos: start)
@@ -62,7 +99,7 @@ module Fusion
62
99
  if c == '"'
63
100
  return lex_string(start)
64
101
  end
65
- if digit?(c) || (c == "-" && digit?(peek(1)))
102
+ if digit?(c)
66
103
  return lex_number(start)
67
104
  end
68
105
  if ident_start?(c)
@@ -143,7 +180,6 @@ module Fusion
143
180
 
144
181
  def lex_number(start)
145
182
  j = @i
146
- j += 1 if @src[j] == "-"
147
183
  j += 1 while j < @n && digit?(@src[j])
148
184
  is_float = false
149
185
  if @src[j] == "." && digit?(@src[j + 1])
@@ -176,6 +212,36 @@ module Fusion
176
212
  end
177
213
  end
178
214
 
215
+ # Path lexing — see `tokens`. Only called immediately after `@`/`@@`, with no
216
+ # whitespace skipped, so the path must be tight. Returns nil (position restored)
217
+ # when nothing path-like follows, i.e. a bare `@`/`@@`.
218
+ def try_lex_path
219
+ start = @i
220
+ buf = +""
221
+ while peek == "." && peek(1) == "." && peek(2) == "/"
222
+ buf << "../"
223
+ @i += 3
224
+ end
225
+ unless ident_start?(peek)
226
+ @i = start
227
+ return nil
228
+ end
229
+ buf << lex_ident_text
230
+ while peek == "/" && ident_start?(peek(1))
231
+ @i += 1
232
+ buf << "/" << lex_ident_text
233
+ end
234
+ Token.new(type: :path, value: buf, pos: start)
235
+ end
236
+
237
+ def lex_ident_text
238
+ j = @i
239
+ j += 1 while j < @n && ident_part?(@src[j])
240
+ text = @src[@i...j]
241
+ @i = j
242
+ text
243
+ end
244
+
179
245
  def digit?(c) = c && c >= "0" && c <= "9"
180
246
  def ident_start?(c) = c && (c =~ /[A-Za-z_]/)
181
247
  def ident_part?(c) = c && (c =~ /[A-Za-z0-9_]/)
data/lib/fusion/parser.rb CHANGED
@@ -22,16 +22,16 @@ module Fusion
22
22
 
23
23
  # Parse a complete program. The lexer and parser report failures by raising
24
24
  # ParseError; this single entry point rescues them and returns a standardized
25
- # syntax_error value, so no caller ever sees a raw Ruby error. `location` is the
26
- # syntax_error's "code X" / "code <inline>" context.
27
- def self.parse_file(src, location:)
25
+ # syntax_error value, so no caller ever sees a raw Ruby error. `site` is the
26
+ # syntax_error's `{origin:, file:}` context.
27
+ def self.parse_file(src, site:)
28
28
  toks = Lexer.new(src).tokens
29
29
  p = new(toks)
30
30
  expr = p.parse_expr
31
31
  p.expect(:eof)
32
32
  expr
33
33
  rescue ParseError => err
34
- Interpreter::ErrorVal.internal(kind: "syntax_error", location: location, operation: "parsing", input: src, message: err.message)
34
+ Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: err.message)
35
35
  end
36
36
 
37
37
  # Parse one REPL entry — a statement (`identifier "=" expr`) or a bare
@@ -39,14 +39,14 @@ module Fusion
39
39
  # parse_file, a standardized syntax_error value instead of ever raising. The
40
40
  # REPL uses the error/non-error distinction to tell "keep editing" (didn't
41
41
  # parse yet) from "evaluate now" (a complete statement or expression).
42
- def self.parse_repl(src, location:)
42
+ def self.parse_repl(src, site:)
43
43
  toks = Lexer.new(src).tokens
44
44
  p = new(toks)
45
45
  entry = p.parse_repl_entry
46
46
  p.expect(:eof)
47
47
  entry
48
48
  rescue ParseError => err
49
- Interpreter::ErrorVal.internal(kind: "syntax_error", location: location, operation: "parsing", input: src, message: err.message)
49
+ Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: err.message)
50
50
  end
51
51
 
52
52
  # A leading `identifier =` marks a statement; anything else is an expression.
@@ -67,35 +67,136 @@ module Fusion
67
67
  end
68
68
 
69
69
  def parse_expr
70
- parse_pipe
70
+ parse_or
71
71
  end
72
72
 
73
- def parse_pipe
74
- left = parse_prefix
75
- while at?(:pipe)
73
+ # --- Operator sugar (reference §2.7) --------------------------------------
74
+ # The ladder below desugars every operator to a pipe into an `@OP.*` member
75
+ # (or, for the map-pipes, a stdlib call). Tightest to loosest:
76
+ # postfix · unary (! - / ~) · pipe (| |: |? |+) · * / % // · + - · ?? · == · && · ||
77
+ # `@OP.*` is a shadowable `:name` reference, so a local `@OP` reskins the operators.
78
+
79
+ def parse_or
80
+ operands = [parse_and]
81
+ while at?(:oror)
76
82
  advance
77
- right = parse_prefix
78
- left = Expression::Pipe.new(left: left, right: right)
83
+ operands << parse_and
79
84
  end
80
- left
85
+ fold_operator(operands, "or")
81
86
  end
82
87
 
83
- # Tokens that can begin a primary expression (used by parse_prefix to decide
84
- # whether `!` is followed by an operand).
85
- PRIMARY_STARTERS = %i[number string true_kw false_kw null_kw bang
86
- lbracket lbrace lparen ident at].freeze
88
+ def parse_and
89
+ operands = [parse_equality]
90
+ while at?(:andand)
91
+ advance
92
+ operands << parse_equality
93
+ end
94
+ fold_operator(operands, "and")
95
+ end
87
96
 
88
- # `!` is a prefix operator that constructs an error from its operand. A bare
89
- # `!` (no operand follows) is shorthand for `!null`. Binds tighter than `|`
90
- # so `!x | f` is `(!x) | f`; looser than postfix so `!x.foo` is `!(x.foo)`.
91
- def parse_prefix
92
- if at?(:bang)
97
+ def parse_equality
98
+ operands = [parse_ordering]
99
+ while at?(:eqeq)
93
100
  advance
94
- if PRIMARY_STARTERS.include?(peek.type)
95
- Expression::ErrLit.new(payload: parse_prefix) # allow !!x to nest
101
+ operands << parse_ordering
102
+ end
103
+ fold_operator(operands, "equal")
104
+ end
105
+
106
+ # `??` (compare) is binary and left-associative — it does not fold.
107
+ def parse_ordering
108
+ node = parse_additive
109
+ while at?(:qq)
110
+ advance
111
+ node = pipe_operator([node, parse_additive], "compare")
112
+ end
113
+ node
114
+ end
115
+
116
+ # A run of `+`/`-` folds into one `@OP.sum`; each `-` term is negated.
117
+ def parse_additive
118
+ terms = [parse_multiplicative]
119
+ folded = false
120
+ while at?(:plus) || at?(:minus)
121
+ negated = at?(:minus)
122
+ advance
123
+ term = parse_multiplicative
124
+ terms << (negated ? negate(term) : term)
125
+ folded = true
126
+ end
127
+ folded ? pipe_operator(terms, "sum") : terms.first
128
+ end
129
+
130
+ # A run of `*`/`/` folds into one `@OP.product` (each `/` term inverted); `%`/`//`
131
+ # are binary and break the run. Standard left-to-right: `a * b % c` is `(a * b) % c`.
132
+ def parse_multiplicative
133
+ node = parse_pipe
134
+ run = nil # accumulating product-run terms, or nil
135
+ loop do
136
+ if at?(:star) || at?(:slash)
137
+ inverted = at?(:slash)
138
+ advance
139
+ term = parse_pipe
140
+ term = pipe_into(term, "invert") if inverted
141
+ if run
142
+ run << term
143
+ else
144
+ run = [node, term]
145
+ end
146
+ elsif at?(:percent) || at?(:slashslash)
147
+ node = pipe_operator(run, "product") if run
148
+ run = nil
149
+ op = at?(:percent) ? "modulo" : "quotient"
150
+ advance
151
+ node = pipe_operator([node, parse_pipe], op)
152
+ else
153
+ break
154
+ end
155
+ end
156
+ run ? pipe_operator(run, "product") : node
157
+ end
158
+
159
+ def parse_pipe
160
+ node = parse_unary
161
+ loop do
162
+ if at?(:pipe)
163
+ advance
164
+ node = Expression::Pipe.new(left: node, right: parse_unary)
165
+ elsif at?(:pipemap) || at?(:pipefilter) || at?(:pipereduce)
166
+ target = { pipemap: "map", pipefilter: "filter", pipereduce: "reduce" }[peek.type]
167
+ advance
168
+ arg = Expression::ObjLit.new(pairs: [
169
+ KeyValuePair.new(key: "c", value: node),
170
+ KeyValuePair.new(key: "f", value: parse_unary),
171
+ ])
172
+ node = Expression::Pipe.new(left: arg, right: name_ref(target))
96
173
  else
97
- Expression::ErrLit.new(payload: nil) # bare ! -> !null
174
+ break
98
175
  end
176
+ end
177
+ node
178
+ end
179
+
180
+ # Tokens that can begin a unary operand — used to decide whether `!` has an
181
+ # 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
184
+
185
+ # Unary prefixes: `!x` builds an error (bare `!` is `!null`); `-x` negates,
186
+ # `/x` inverts, `~x` is logical not. All bind tighter than `|`.
187
+ def parse_unary
188
+ if at?(:bang)
189
+ advance
190
+ PRIMARY_STARTERS.include?(peek.type) ? Expression::ErrLit.new(payload: parse_unary) : Expression::ErrLit.new(payload: nil)
191
+ elsif at?(:minus)
192
+ advance
193
+ negate(parse_unary)
194
+ elsif at?(:slash)
195
+ advance
196
+ pipe_into(parse_unary, "invert")
197
+ elsif at?(:tilde)
198
+ advance
199
+ pipe_into(parse_unary, "not")
99
200
  else
100
201
  parse_postfix
101
202
  end
@@ -111,8 +212,15 @@ module Fusion
111
212
  elsif at?(:lbracket)
112
213
  advance
113
214
  idx = parse_expr
114
- expect(:rbracket)
115
- node = Expression::Index.new(obj: node, idx: idx)
215
+ if at?(:equals)
216
+ advance
217
+ value = parse_expr
218
+ expect(:rbracket)
219
+ node = Expression::IndexSet.new(obj: node, idx: idx, value: value)
220
+ else
221
+ expect(:rbracket)
222
+ node = Expression::Index.new(obj: node, idx: idx)
223
+ end
116
224
  else
117
225
  break
118
226
  end
@@ -120,6 +228,36 @@ module Fusion
120
228
  node
121
229
  end
122
230
 
231
+ # --- Desugaring helpers ---------------------------------------------------
232
+
233
+ def name_ref(path) = Expression::FileRef.new(variety: :name, path: path)
234
+
235
+ # `@OP.member`, a shadowable reference.
236
+ def op_member(member) = Expression::Member.new(obj: name_ref("OP"), key: member)
237
+
238
+ # `expr | @OP.member`
239
+ def pipe_into(expr, member) = Expression::Pipe.new(left: expr, right: op_member(member))
240
+
241
+ # `[operands...] | @OP.member`
242
+ def pipe_operator(operands, member)
243
+ arr = Expression::ArrLit.new(items: operands.map { |e| ArrayItem.new(value: e) })
244
+ Expression::Pipe.new(left: arr, right: op_member(member))
245
+ end
246
+
247
+ # A single-operand run collapses to that operand; otherwise fold n-ary.
248
+ def fold_operator(operands, member)
249
+ operands.length == 1 ? operands.first : pipe_operator(operands, member)
250
+ end
251
+
252
+ # `-x`: a numeric literal folds to a negative literal; anything else negates.
253
+ def negate(expr)
254
+ if expr.is_a?(Expression::Lit) && expr.value.is_a?(Numeric)
255
+ Expression::Lit.new(value: -expr.value)
256
+ else
257
+ pipe_into(expr, "negate")
258
+ end
259
+ end
260
+
123
261
  def parse_primary
124
262
  t = peek
125
263
  case t.type
@@ -130,35 +268,34 @@ module Fusion
130
268
  when :lparen then parse_function_or_group
131
269
  when :ident then advance; Expression::Ident.new(name: t.value)
132
270
  when :at then parse_fileref
271
+ when :atat then parse_superref
133
272
  else raise ParseError, "Unexpected token #{t.type} (#{t.value.inspect}) at #{t.pos}"
134
273
  end
135
274
  end
136
275
 
276
+ # `@@` is super. Bare `@@` is super of the current file's own name; `@@name`
277
+ # (or a downward `@@dir/name`) is a *stable* reference to that name — it skips
278
+ # the sibling `name.fsn`, resolving builtin → stdlib, so a user's local shadow
279
+ # can't intercept it. `@@../…` is meaningless (super of an upward path) and
280
+ # falls through to a parse error.
281
+ def parse_superref
282
+ expect(:atat)
283
+ return Expression::FileRef.new(variety: :super, path: nil) unless at?(:path)
284
+
285
+ tok = advance
286
+ raise ParseError, "`@@` cannot take an upward path (at #{tok.pos})" if tok.value.include?("..")
287
+ Expression::FileRef.new(variety: :super_name, path: tok.value)
288
+ end
289
+
137
290
  def parse_fileref
138
291
  expect(:at)
139
- # Bare "@" = current file: not followed by something that can begin a path.
140
- nxt = peek
141
- starts_path = (nxt.type == :ident) || (nxt.type == :dot && peek(1)&.type == :dot)
142
- return Expression::FileRef.new(variety: :self, path: nil) unless starts_path
143
- # refpath: { "../" } segment { "/" segment }
144
- parts = []
145
- has_dotdot = false
146
- while at?(:dot) && peek(1)&.type == :dot
147
- advance; advance # consume the two dots of ..
148
- parts << ".."
149
- expect(:slash)
150
- has_dotdot = true
151
- end
152
- parts << expect(:ident).value
153
- while at?(:slash)
154
- advance
155
- parts << expect(:ident).value
156
- end
157
- # A reference is eligible for builtin/stdlib fallback (:name) iff it does NOT
158
- # contain "../". Downward paths like "dir/a" are still eligible; only "../"
159
- # (escaping upward) forces pure file-path (:path) resolution.
160
- bare = !has_dotdot
161
- Expression::FileRef.new(variety: bare ? :name : :path, path: parts.join("/"))
292
+ return Expression::FileRef.new(variety: :self, path: nil) unless at?(:path)
293
+
294
+ # A reference is eligible for builtin/stdlib fallback (:name) iff it has no
295
+ # "../"; downward paths stay eligible, only "../" forces file-only (:path).
296
+ # The lexer produced the whole path as one tight token (see Lexer#try_lex_path).
297
+ path = advance.value
298
+ Expression::FileRef.new(variety: path.include?("..") ? :path : :name, path: path)
162
299
  end
163
300
 
164
301
  def parse_array
@@ -275,7 +412,7 @@ module Fusion
275
412
 
276
413
  # Tokens that can begin a `guardedpat` (used to detect whether `!` is
277
414
  # followed by a payload pattern or stands alone).
278
- GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw
415
+ GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw minus
279
416
  lbracket lbrace ident].freeze
280
417
 
281
418
  def parse_errpat
@@ -305,6 +442,7 @@ module Fusion
305
442
  case t.type
306
443
  when :number, :string then advance; Pattern::PLit.new(value: t.value)
307
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
308
446
  when :lbracket then parse_arraypat
309
447
  when :lbrace then parse_objectpat
310
448
  when :ident
@@ -1,3 +1,3 @@
1
1
  module Fusion
2
- VERSION = "0.0.1.alpha2"
2
+ VERSION = "0.0.1"
3
3
  end
data/stdlib/all.fsn ADDED
@@ -0,0 +1,13 @@
1
+ # Return true if every item satisfies the predicate "f" (a truthy result) — over an
2
+ # array's elements or an object's values. Input: {"f": predicate, "c": array-or-object}.
3
+ # Short-circuits: the first item whose predicate is falsey yields false without testing
4
+ # the rest.
5
+ (
6
+ {"f": _ ? @Function, "c": []} => true,
7
+ {"f": f ? @Function, "c": [x, ...rest]} => (x | f) | (
8
+ _ ? @falsey => false,
9
+ _ => {"f": f, "c": rest} | @,
10
+ ),
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}"]},
13
+ )
data/stdlib/any.fsn ADDED
@@ -0,0 +1,12 @@
1
+ # True if any item satisfies the predicate — over an array's elements or an object's
2
+ # values. Input: {"f": predicate, "c": array-or-object}. Short-circuits on the first
3
+ # truthy result.
4
+ (
5
+ {"f": _ ? @Function, "c": []} => false,
6
+ {"f": f ? @Function, "c": [x, ...rest]} => x | f | (
7
+ _ ? @falsey => {"f": f, "c": rest} | @,
8
+ _ => true,
9
+ ),
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}"]},
12
+ )
data/stdlib/chars.fsn ADDED
@@ -0,0 +1,5 @@
1
+ # Split a string into its characters. Built on @split with an empty separator.
2
+ (
3
+ s ? @String => [s, ""] | @split,
4
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@chars", "status": 0, "input": x, "expected": ["_ ? @String"]}
5
+ )
@@ -0,0 +1,6 @@
1
+ # Drop null entries from an array (by element) or an object (by value). Built on @filter.
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"]}
6
+ )
data/stdlib/concat.fsn ADDED
@@ -0,0 +1,5 @@
1
+ # Concatenate two strings. Input: [a, b]. Built on @join with an empty separator.
2
+ (
3
+ [a ? @String, b ? @String] => [[a, b], ""] | @join,
4
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@concat", "status": 0, "input": x, "expected": ["[_ ? @String, _ ? @String]"]}
5
+ )
data/stdlib/falsey.fsn ADDED
@@ -0,0 +1,6 @@
1
+ # True for false and null — Fusion's own falsiness, decided by pattern matching.
2
+ (
3
+ false => true,
4
+ null => true,
5
+ _ => false,
6
+ )
data/stdlib/filter.fsn ADDED
@@ -0,0 +1,12 @@
1
+ # Keep the array elements (or object values) for which f(x) is truthy. Input:
2
+ # {"f": predicate, "c": array-or-object}. Like @map, it is polymorphic on c.
3
+ (
4
+ {"f": _ ? @Function, "c": []} => [],
5
+ {"f": f ? @Function, "c": [x, ...rest]} => x | f | (
6
+ _ ? @falsey => {"f": f, "c": rest} | @,
7
+ _ => [x, ...({"f": f, "c": rest} | @)],
8
+ ),
9
+ {"f": f ? @Function, "c": obj ? @Object} =>
10
+ {"f": (e => e | ([_, v] => v | f)), "c": {"f": (k => [k, obj[k]]), "c": obj | @keys} | @map} | @ | @toObject,
11
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@filter", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": _ ? @Array}", "{\"f\": _ ? @Function, \"c\": _ ? @Object}"]}
12
+ )
@@ -0,0 +1,7 @@
1
+ # Recursively flatten nested arrays into one flat array.
2
+ (
3
+ [] => [],
4
+ [x ? @Array, ...rest] => [...(x | @), ...(rest | @)],
5
+ [x, ...rest] => [x, ...(rest | @)],
6
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@flatten", "status": 0, "input": x, "expected": ["_ ? @Array"]}
7
+ )
data/stdlib/gt.fsn ADDED
@@ -0,0 +1,9 @@
1
+ # Strictly greater: interprets an @OP.compare result. Use as `[a, b] | @OP.compare | @gt`.
2
+ # A partial order's compare may return null (incomparable); that propagates as null.
3
+ (
4
+ -1 => false,
5
+ 0 => false,
6
+ 1 => true,
7
+ null => null,
8
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@gt", "status": 0, "input": x, "expected": ["-1", "0", "1", "null"]},
9
+ )
data/stdlib/gte.fsn ADDED
@@ -0,0 +1,9 @@
1
+ # Greater-or-equal: interprets an @OP.compare result. Use as `[a, b] | @OP.compare | @gte`.
2
+ # A partial order's compare may return null (incomparable); that propagates as null.
3
+ (
4
+ -1 => false,
5
+ 0 => true,
6
+ 1 => true,
7
+ null => null,
8
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@gte", "status": 0, "input": x, "expected": ["-1", "0", "1", "null"]},
9
+ )
data/stdlib/lt.fsn ADDED
@@ -0,0 +1,9 @@
1
+ # Strictly less: interprets an @OP.compare result. Use as `[a, b] | @OP.compare | @lt`.
2
+ # A partial order's compare may return null (incomparable); that propagates as null.
3
+ (
4
+ -1 => true,
5
+ 0 => false,
6
+ 1 => false,
7
+ null => null,
8
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@lt", "status": 0, "input": x, "expected": ["-1", "0", "1", "null"]},
9
+ )
data/stdlib/lte.fsn ADDED
@@ -0,0 +1,9 @@
1
+ # Less-or-equal: interprets an @OP.compare result. Use as `[a, b] | @OP.compare | @lte`.
2
+ # A partial order's compare may return null (incomparable); that propagates as null.
3
+ (
4
+ -1 => true,
5
+ 0 => true,
6
+ 1 => false,
7
+ null => null,
8
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@lte", "status": 0, "input": x, "expected": ["-1", "0", "1", "null"]},
9
+ )
data/stdlib/map.fsn CHANGED
@@ -1,6 +1,8 @@
1
+ # Apply f to each element of an array, or to each value of an object (keeping the
2
+ # keys). Input: {"f": fn, "c": array-or-object}.
1
3
  (
2
- {"f": _, "xs": []} => [],
3
- {"f": f, "xs": [x, ...rest]} => [x | f, ...({"f": f, "xs": rest} | @map)],
4
- {"f": _, "xs": xs} => !{"kind": "type_error", "location": "stdlib map.fsn", "operation": "map", "input": xs | @sanitize, "message": "expected an array"},
5
- x => !{"kind": "argument_error", "location": "stdlib map.fsn", "operation": "map", "input": x | @sanitize, "message": "expected {\"f\": _, \"xs\": _}"}
4
+ {"f": _ ? @Function, "c": []} => [],
5
+ {"f": f ? @Function, "c": [x, ...rest]} => [x | f, ...({"f": f, "c": rest} | @)],
6
+ {"f": f ? @Function, "c": obj ? @Object} => {"f": (k => [k, obj[k] | f]), "c": obj | @keys} | @ | @toObject,
7
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@map", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": _ ? @Array}", "{\"f\": _ ? @Function, \"c\": _ ? @Object}"]}
6
8
  )
data/stdlib/range.fsn CHANGED
@@ -1,5 +1,5 @@
1
1
  (
2
2
  0 => [],
3
- n ? (m ? @Integer => [-1, m] | @lessThan, _ => false) => [...([n, 1] | @subtract | @range), [n, 1] | @subtract],
4
- x => !{"kind": "type_error", "location": "stdlib range.fsn", "operation": "range", "input": x | @sanitize, "message": "expected a non-negative integer"}
3
+ n ? (m ? @Integer => [m, 0] | @OP.compare | @gt) => [...([n, -1] | @OP.sum | @), [n, -1] | @OP.sum],
4
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@range", "status": 0, "input": x, "expected": ["_ ? (m ? @Integer => [m, 0] | @OP.compare | @gte)"]}
5
5
  )
data/stdlib/reduce.fsn ADDED
@@ -0,0 +1,8 @@
1
+ # Left fold over a NON-EMPTY array with no seed: combine the first two elements
2
+ # with f, then fold that result with the rest. Input: {"f": binaryFn, "c": array}.
3
+ # `f` receives the pair [acc, element]. An empty list is an error.
4
+ (
5
+ {"f": _ ? @Function, "c": [x]} => x,
6
+ {"f": f ? @Function, "c": [x, y, ...rest]} => {"f": f, "c": [[x, y] | f, ...rest]} | @,
7
+ x => !{"kind": "argument_error", "origin": "stdlib", "operation": "@reduce", "status": 0, "input": x, "expected": ["{\"f\": _ ? @Function, \"c\": [_, ...]}"]}
8
+ )
data/stdlib/sanitize.fsn CHANGED
@@ -6,7 +6,7 @@
6
6
  (
7
7
  v ? @Function => "<function>",
8
8
  v ? @NonFinite => [["<", v | @toString] | @concat, ">"] | @concat,
9
- v ? @Array => {"f": @sanitize, "xs": v} | @map,
10
- v ? @Object => {"f": @sanitize, "object": v} | @mapValues,
9
+ v ? @Array => {"f": @sanitize, "c": v} | @map,
10
+ v ? @Object => {"f": @sanitize, "c": v} | @map,
11
11
  v => v
12
12
  )
data/stdlib/truthy.fsn ADDED
@@ -0,0 +1,7 @@
1
+ # True for every value except false and null — Fusion's own truthiness, decided by
2
+ # pattern matching, so it is independent of any @OP override.
3
+ (
4
+ false => false,
5
+ null => false,
6
+ _ => true,
7
+ )