fusion-lang 0.0.1.alpha1

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.
data/lib/fusion.rb ADDED
@@ -0,0 +1,1140 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Fusion — a proof-of-concept interpreter.
5
+ #
6
+ # This file is the interpreter library; the CLI entrypoint lives in exe/fusion.
7
+ #
8
+ # A file contains exactly one value. A file is "executable" if that value is a
9
+ # function; the runtime computes STDIN | thatFunction and prints the result.
10
+ #
11
+ # Values are represented in Ruby as:
12
+ # null -> :null (we avoid Ruby nil so "absent" is explicit)
13
+ # ! -> ErrorVal (always carries a payload; bare `!` means `!null`)
14
+ # bool -> true / false
15
+ # int -> Integer
16
+ # float -> Float
17
+ # string -> String
18
+ # array -> Array
19
+ # object -> Hash (String keys, insertion-ordered as Ruby preserves)
20
+ # func -> Func (closure over an Env)
21
+
22
+ require_relative "fusion/version"
23
+
24
+ module Fusion
25
+ # ---- Special singletons -------------------------------------------------
26
+ NULL = :null
27
+
28
+ # An error value, always carrying a payload (any JSON-like Fusion value).
29
+ # `mkerr(payload)` constructs one; a bare `!` in source means `!null`.
30
+ class ErrorVal
31
+ attr_reader :payload
32
+ def initialize(payload) @payload = payload end
33
+ def inspect = "!#{payload.inspect}"
34
+ def to_s = inspect
35
+ end
36
+ def self.mkerr(payload = NULL) = ErrorVal.new(payload)
37
+ def self.error?(v) = v.is_a?(ErrorVal)
38
+
39
+ class FusionError < StandardError; end
40
+ class ParseError < FusionError; end
41
+
42
+ # =========================================================================
43
+ # LEXER
44
+ # =========================================================================
45
+ Token = Struct.new(:type, :value, :pos)
46
+
47
+ class Lexer
48
+ PUNCT = {
49
+ "(" => :lparen, ")" => :rparen,
50
+ "[" => :lbracket, "]" => :rbracket,
51
+ "{" => :lbrace, "}" => :rbrace,
52
+ "," => :comma, ":" => :colon,
53
+ "|" => :pipe, "?" => :question, "." => :dot,
54
+ "@" => :at, "/" => :slash,
55
+ }.freeze
56
+
57
+ def initialize(src)
58
+ @src = src
59
+ @i = 0
60
+ @n = src.length
61
+ end
62
+
63
+ def tokens
64
+ out = []
65
+ loop do
66
+ t = next_token
67
+ out << t
68
+ break if t.type == :eof
69
+ end
70
+ out
71
+ end
72
+
73
+ private
74
+
75
+ def peek(o = 0) = @i + o < @n ? @src[@i + o] : nil
76
+
77
+ def next_token
78
+ skip_trivia
79
+ start = @i
80
+ c = peek
81
+ return Token.new(:eof, nil, start) if c.nil?
82
+
83
+ # "=>" and "..." handled specially ("#" line comments handled in skip_trivia)
84
+ if c == "=" && peek(1) == ">"
85
+ @i += 2
86
+ return Token.new(:arrow, "=>", start)
87
+ end
88
+ if c == "." && peek(1) == "." && peek(2) == "."
89
+ @i += 3
90
+ return Token.new(:spread, "...", start)
91
+ end
92
+ if c == "!"
93
+ @i += 1
94
+ return Token.new(:bang, "!", start)
95
+ end
96
+ if c == '"'
97
+ return lex_string(start)
98
+ end
99
+ if digit?(c) || (c == "-" && digit?(peek(1)))
100
+ return lex_number(start)
101
+ end
102
+ if ident_start?(c)
103
+ return lex_word(start)
104
+ end
105
+ if (type = PUNCT[c])
106
+ @i += 1
107
+ return Token.new(type, c, start)
108
+ end
109
+ raise ParseError, "Unexpected character #{c.inspect} at #{start}"
110
+ end
111
+
112
+ def skip_trivia
113
+ loop do
114
+ c = peek
115
+ if c == " " || c == "\t" || c == "\n" || c == "\r"
116
+ @i += 1
117
+ elsif c == "#" && at_line_start?
118
+ # A line is a comment iff its first non-whitespace char is "#".
119
+ # This also covers shebang lines (#!) for free.
120
+ @i += 1 until peek.nil? || peek == "\n"
121
+ else
122
+ break
123
+ end
124
+ end
125
+ end
126
+
127
+ # True when only whitespace precedes @i on the current physical line.
128
+ def at_line_start?
129
+ j = @i - 1
130
+ j -= 1 while j >= 0 && (@src[j] == " " || @src[j] == "\t")
131
+ j < 0 || @src[j] == "\n" || @src[j] == "\r"
132
+ end
133
+
134
+ def lex_string(start)
135
+ @i += 1 # opening quote
136
+ buf = +""
137
+ while (c = peek)
138
+ if c == '"'
139
+ @i += 1
140
+ return Token.new(:string, buf, start)
141
+ elsif c == "\\"
142
+ @i += 1
143
+ e = peek
144
+ buf << case e
145
+ when '"' then '"'
146
+ when "\\" then "\\"
147
+ when "/" then "/"
148
+ when "n" then "\n"
149
+ when "t" then "\t"
150
+ when "r" then "\r"
151
+ when "b" then "\b"
152
+ when "f" then "\f"
153
+ when "u"
154
+ hex = @src[@i + 1, 4]
155
+ @i += 4
156
+ [hex.to_i(16)].pack("U")
157
+ else
158
+ raise ParseError, "Bad escape \\#{e}"
159
+ end
160
+ @i += 1
161
+ elsif c == "\n" || c == "\r"
162
+ raise ParseError, "Raw newline in string starting at #{start}; use \\n"
163
+ else
164
+ buf << c
165
+ @i += 1
166
+ end
167
+ end
168
+ raise ParseError, "Unterminated string starting at #{start}"
169
+ end
170
+
171
+ def lex_number(start)
172
+ j = @i
173
+ j += 1 if @src[j] == "-"
174
+ j += 1 while j < @n && digit?(@src[j])
175
+ is_float = false
176
+ if @src[j] == "." && digit?(@src[j + 1])
177
+ is_float = true
178
+ j += 1
179
+ j += 1 while j < @n && digit?(@src[j])
180
+ end
181
+ if (@src[j] == "e" || @src[j] == "E")
182
+ is_float = true
183
+ j += 1
184
+ j += 1 if (@src[j] == "+" || @src[j] == "-")
185
+ j += 1 while j < @n && digit?(@src[j])
186
+ end
187
+ text = @src[@i...j]
188
+ @i = j
189
+ val = is_float ? text.to_f : text.to_i
190
+ Token.new(:number, val, start)
191
+ end
192
+
193
+ def lex_word(start)
194
+ j = @i
195
+ j += 1 while j < @n && ident_part?(@src[j])
196
+ text = @src[@i...j]
197
+ @i = j
198
+ case text
199
+ when "true" then Token.new(:true_kw, true, start)
200
+ when "false" then Token.new(:false_kw, false, start)
201
+ when "null" then Token.new(:null_kw, NULL, start)
202
+ else Token.new(:ident, text, start)
203
+ end
204
+ end
205
+
206
+ def digit?(c) = c && c >= "0" && c <= "9"
207
+ def ident_start?(c) = c && (c =~ /[A-Za-z_]/)
208
+ def ident_part?(c) = c && (c =~ /[A-Za-z0-9_]/)
209
+ end
210
+
211
+ # =========================================================================
212
+ # AST
213
+ # =========================================================================
214
+ # Expressions
215
+ Lit = Struct.new(:value) # atom literal (incl NULL)
216
+ ErrLit = Struct.new(:payload) # !expr or bare ! (payload nil = !null)
217
+ ArrLit = Struct.new(:elems) # elems: [[:item|:spread, expr], ...]
218
+ ObjLit = Struct.new(:members) # [[:kv, key, expr] | [:spread, expr]]
219
+ FuncLit = Struct.new(:clauses) # [[pattern, expr], ...]
220
+ Ident = Struct.new(:name) # read a builtin/bound name
221
+ FileRef = Struct.new(:variety, :path) # variety: :self|:name|:path
222
+ Pipe = Struct.new(:left, :right) # left | right
223
+ Member = Struct.new(:obj, :key) # obj.key
224
+ Index = Struct.new(:obj, :idx) # obj[expr]
225
+
226
+ # Patterns
227
+ PLit = Struct.new(:value) # literal pattern
228
+ PErr = Struct.new(:inner) # ! or !pat ; inner=nil matches any error
229
+ PBind = Struct.new(:name) # binds
230
+ PWild = Struct.new(:dummy) # _
231
+ PArr = Struct.new(:elems) # [[:pat,p]|[:rest,name_or_nil], ...]
232
+ PObj = Struct.new(:members) # [[:kv,key,pat]|[:rest,name_or_nil]]
233
+ PGuard = Struct.new(:inner, :pred_expr) # inner ? predicate
234
+
235
+ # =========================================================================
236
+ # PARSER (recursive descent following the EBNF)
237
+ # =========================================================================
238
+ class Parser
239
+ def initialize(tokens)
240
+ @toks = tokens
241
+ @i = 0
242
+ end
243
+
244
+ def self.parse_file(src)
245
+ toks = Lexer.new(src).tokens
246
+ p = new(toks)
247
+ expr = p.parse_expr
248
+ p.expect(:eof)
249
+ expr
250
+ end
251
+
252
+ def parse_expr = parse_pipe
253
+
254
+ def parse_pipe
255
+ left = parse_prefix
256
+ while at?(:pipe)
257
+ advance
258
+ right = parse_prefix
259
+ left = Pipe.new(left, right)
260
+ end
261
+ left
262
+ end
263
+
264
+ # Tokens that can begin a primary expression (used by parse_prefix to decide
265
+ # whether `!` is followed by an operand).
266
+ PRIMARY_STARTERS = %i[number string true_kw false_kw null_kw bang
267
+ lbracket lbrace lparen ident at].freeze
268
+
269
+ # `!` is a prefix operator that constructs an error from its operand. A bare
270
+ # `!` (no operand follows) is shorthand for `!null`. Binds tighter than `|`
271
+ # so `!x | f` is `(!x) | f`; looser than postfix so `!x.foo` is `!(x.foo)`.
272
+ def parse_prefix
273
+ if at?(:bang)
274
+ advance
275
+ if PRIMARY_STARTERS.include?(peek.type)
276
+ ErrLit.new(parse_prefix) # allow !!x to nest
277
+ else
278
+ ErrLit.new(nil) # bare ! -> !null
279
+ end
280
+ else
281
+ parse_postfix
282
+ end
283
+ end
284
+
285
+ def parse_postfix
286
+ node = parse_primary
287
+ loop do
288
+ if at?(:dot)
289
+ advance
290
+ key = expect(:ident).value
291
+ node = Member.new(node, key)
292
+ elsif at?(:lbracket)
293
+ advance
294
+ idx = parse_expr
295
+ expect(:rbracket)
296
+ node = Index.new(node, idx)
297
+ else
298
+ break
299
+ end
300
+ end
301
+ node
302
+ end
303
+
304
+ def parse_primary
305
+ t = peek
306
+ case t.type
307
+ when :number, :string then advance; Lit.new(t.value)
308
+ when :true_kw, :false_kw, :null_kw then advance; Lit.new(t.value)
309
+ when :lbracket then parse_array
310
+ when :lbrace then parse_object
311
+ when :lparen then parse_function_or_group
312
+ when :ident then advance; Ident.new(t.value)
313
+ when :at then parse_fileref
314
+ else raise ParseError, "Unexpected token #{t.type} (#{t.value.inspect}) at #{t.pos}"
315
+ end
316
+ end
317
+
318
+ def parse_fileref
319
+ expect(:at)
320
+ # Bare "@" = current file: not followed by something that can begin a path.
321
+ nxt = peek
322
+ starts_path = (nxt.type == :ident) || (nxt.type == :dot && peek(1)&.type == :dot)
323
+ return FileRef.new(:self, nil) unless starts_path
324
+ # refpath: { "../" } segment { "/" segment }
325
+ parts = []
326
+ has_dotdot = false
327
+ while at?(:dot) && peek(1)&.type == :dot
328
+ advance; advance # consume the two dots of ..
329
+ parts << ".."
330
+ expect(:slash)
331
+ has_dotdot = true
332
+ end
333
+ parts << expect(:ident).value
334
+ while at?(:slash)
335
+ advance
336
+ parts << expect(:ident).value
337
+ end
338
+ # A reference is eligible for builtin/stdlib fallback (:name) iff it does NOT
339
+ # contain "../". Downward paths like "dir/a" are still eligible; only "../"
340
+ # (escaping upward) forces pure file-path (:path) resolution.
341
+ bare = !has_dotdot
342
+ FileRef.new(bare ? :name : :path, parts.join("/"))
343
+ end
344
+
345
+ def parse_array
346
+ expect(:lbracket)
347
+ elems = []
348
+ until at?(:rbracket)
349
+ if at?(:spread)
350
+ advance
351
+ elems << [:spread, parse_expr]
352
+ else
353
+ elems << [:item, parse_expr]
354
+ end
355
+ break unless at?(:comma)
356
+ advance
357
+ end
358
+ expect(:rbracket)
359
+ ArrLit.new(elems)
360
+ end
361
+
362
+ def parse_object
363
+ expect(:lbrace)
364
+ members = []
365
+ until at?(:rbrace)
366
+ if at?(:spread)
367
+ advance
368
+ members << [:spread, parse_expr]
369
+ else
370
+ key = expect(:string).value
371
+ expect(:colon)
372
+ members << [:kv, key, parse_expr]
373
+ end
374
+ break unless at?(:comma)
375
+ advance
376
+ end
377
+ expect(:rbrace)
378
+ ObjLit.new(members)
379
+ end
380
+
381
+ # A "(" can begin either a grouped expression or a function literal.
382
+ # Distinguish by trying to parse a clause: a function is a comma-separated
383
+ # list of `pattern => expr`. We detect a function by scanning for `=>`
384
+ # before the matching `)` at depth 0.
385
+ def parse_function_or_group
386
+ expect(:lparen)
387
+ if looks_like_function?
388
+ clauses = []
389
+ loop do
390
+ pat = parse_pattern
391
+ expect(:arrow)
392
+ body = parse_expr
393
+ clauses << [pat, body]
394
+ if at?(:comma)
395
+ advance
396
+ break if at?(:rparen) # trailing comma
397
+ else
398
+ break
399
+ end
400
+ end
401
+ expect(:rparen)
402
+ FuncLit.new(clauses)
403
+ else
404
+ e = parse_expr
405
+ expect(:rparen)
406
+ e
407
+ end
408
+ end
409
+
410
+ # Look ahead from current position (just after "(") to decide if this is a
411
+ # function literal: is there a top-level "=>" before the matching ")"?
412
+ def looks_like_function?
413
+ depth = 0
414
+ j = @i
415
+ while j < @toks.length
416
+ t = @toks[j]
417
+ case t.type
418
+ when :lparen, :lbracket, :lbrace then depth += 1
419
+ when :rparen, :rbracket, :rbrace
420
+ return false if depth.zero? # hit our closing ) first
421
+ depth -= 1
422
+ when :arrow
423
+ return true if depth.zero?
424
+ when :eof
425
+ return false
426
+ end
427
+ j += 1
428
+ end
429
+ false
430
+ end
431
+
432
+ # ---- Patterns ----
433
+ # ---- Pattern grammar (mirrors reference.md §2.5 EBNF) ------------------
434
+ # pattern = errpat | guardedpat
435
+ # errpat = "!" | "!" guardedpat
436
+ # guardedpat = corepat [ "?" predicate ]
437
+ # corepat = literalpat | bindpat | wildcard | arraypat | objectpat
438
+ # Note: `corepat` does NOT include errpat. The "no nested !pat" property
439
+ # falls out of the grammar shape — `errpat` is only reachable from `pattern`
440
+ # (a clause's top level), never from inside arrays, objects, or another
441
+ # error's payload. No flag-threading is needed.
442
+ def parse_pattern
443
+ at?(:bang) ? parse_errpat : parse_guardedpat
444
+ end
445
+
446
+ # Tokens that can begin a `guardedpat` (used to detect whether `!` is
447
+ # followed by a payload pattern or stands alone).
448
+ GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw
449
+ lbracket lbrace ident].freeze
450
+
451
+ def parse_errpat
452
+ expect(:bang)
453
+ if GUARDEDPAT_STARTERS.include?(peek.type)
454
+ PErr.new(parse_guardedpat) # "!" guardedpat
455
+ else
456
+ PErr.new(PWild.new(nil)) # bare "!" — matches any error, binds nothing
457
+ end
458
+ end
459
+
460
+ def parse_guardedpat
461
+ inner = parse_corepat
462
+ if at?(:question)
463
+ advance
464
+ pred = parse_prefix
465
+ PGuard.new(inner, pred)
466
+ else
467
+ inner
468
+ end
469
+ end
470
+
471
+ def parse_corepat
472
+ t = peek
473
+ case t.type
474
+ when :number, :string then advance; PLit.new(t.value)
475
+ when :true_kw, :false_kw, :null_kw then advance; PLit.new(t.value)
476
+ when :lbracket then parse_arraypat
477
+ when :lbrace then parse_objectpat
478
+ when :ident
479
+ advance
480
+ t.value == "_" ? PWild.new(nil) : PBind.new(t.value)
481
+ when :bang
482
+ # `!pat` is only valid as a clause's top-level pattern, never inside an
483
+ # array element, object member, or error payload.
484
+ raise ParseError, "`!pat` may only appear as a clause's top-level pattern (at #{t.pos})"
485
+ else
486
+ raise ParseError, "Unexpected token in pattern: #{t.type} at #{t.pos}"
487
+ end
488
+ end
489
+
490
+ def parse_arraypat
491
+ # Array elements are `guardedpat`s — they cannot be error patterns.
492
+ expect(:lbracket)
493
+ elems = []
494
+ until at?(:rbracket)
495
+ if at?(:spread)
496
+ advance
497
+ name = at?(:ident) ? advance.value : nil
498
+ elems << [:rest, name]
499
+ else
500
+ elems << [:pat, parse_guardedpat]
501
+ end
502
+ break unless at?(:comma)
503
+ advance
504
+ end
505
+ expect(:rbracket)
506
+ PArr.new(elems)
507
+ end
508
+
509
+ def parse_objectpat
510
+ # Object members are `guardedpat`s — they cannot be error patterns.
511
+ expect(:lbrace)
512
+ members = []
513
+ until at?(:rbrace)
514
+ if at?(:spread)
515
+ advance
516
+ name = at?(:ident) ? advance.value : nil
517
+ members << [:rest, name]
518
+ else
519
+ key = expect(:string).value
520
+ expect(:colon)
521
+ members << [:kv, key, parse_guardedpat]
522
+ end
523
+ break unless at?(:comma)
524
+ advance
525
+ end
526
+ expect(:rbrace)
527
+ PObj.new(members)
528
+ end
529
+
530
+ # ---- token helpers ----
531
+ def peek(o = 0) = @toks[@i + o]
532
+ def at?(type) = peek.type == type
533
+ def advance = (@toks[@i].tap { @i += 1 })
534
+ def expect(type)
535
+ t = peek
536
+ raise ParseError, "Expected #{type} but got #{t.type} (#{t.value.inspect}) at #{t.pos}" unless t.type == type
537
+ advance
538
+ end
539
+ end
540
+
541
+ # =========================================================================
542
+ # RUNTIME VALUES
543
+ # =========================================================================
544
+ # A function closes over the environment in which it was defined.
545
+ class Func
546
+ attr_reader :clauses, :env
547
+ def initialize(clauses, env)
548
+ @clauses = clauses # [[pattern, expr_ast], ...]
549
+ @env = env
550
+ end
551
+ def inspect = "<func/#{clauses.length}>"
552
+ end
553
+
554
+ # Lazy, memoized reference to a file's value (a "thunk" / promise).
555
+ class FileThunk
556
+ def initialize(loader, abspath)
557
+ @loader = loader
558
+ @abspath = abspath
559
+ @state = :unforced # :unforced | :forcing | :done
560
+ @value = nil
561
+ end
562
+
563
+ def force
564
+ case @state
565
+ when :done then @value
566
+ when :forcing
567
+ # We are already evaluating this file and were asked for it again
568
+ # without any intervening function boundary => non-productive data cycle.
569
+ Fusion.mkerr({"kind" => "data_cycle", "path" => @abspath})
570
+ else
571
+ @state = :forcing
572
+ @value = @loader.evaluate_file(@abspath)
573
+ @state = :done
574
+ @value
575
+ end
576
+ end
577
+ end
578
+
579
+ # Environment: maps names -> values, with a parent chain. Built-ins live at root.
580
+ class Env
581
+ def initialize(parent = nil)
582
+ @vars = {}
583
+ @parent = parent
584
+ end
585
+
586
+ def define(name, value)
587
+ @vars[name] = value
588
+ self
589
+ end
590
+
591
+ def lookup(name)
592
+ if @vars.key?(name)
593
+ @vars[name]
594
+ elsif @parent
595
+ @parent.lookup(name)
596
+ else
597
+ :__unbound__
598
+ end
599
+ end
600
+
601
+ def child(bindings = {})
602
+ e = Env.new(self)
603
+ bindings.each { |k, v| e.define(k, v) }
604
+ e
605
+ end
606
+ end
607
+
608
+ # =========================================================================
609
+ # EVALUATOR
610
+ # =========================================================================
611
+ class Interpreter
612
+ attr_reader :root_env
613
+
614
+ def initialize(stdlib_dir: nil, env_vars: nil)
615
+ @stdlib_dir = stdlib_dir
616
+ @env_vars = env_vars || ENV.to_h
617
+ @file_cache = {} # abspath -> FileThunk
618
+ @ast_cache = {} # abspath -> AST
619
+ @builtins = {} # name -> NativeFunc (consulted by @name, not via env)
620
+ Builtins.install(@builtins, self)
621
+ @root_env = Env.new # holds no builtins now; bare identifiers are holes only
622
+ end
623
+
624
+ # ---- File loading -----------------------------------------------------
625
+ def load_file(abspath)
626
+ @file_cache[abspath] ||= FileThunk.new(self, abspath)
627
+ end
628
+
629
+ def evaluate_file(abspath)
630
+ ast = (@ast_cache[abspath] ||= begin
631
+ src = File.read(abspath)
632
+ Parser.parse_file(src)
633
+ end)
634
+ # A file's value is evaluated in a fresh env whose parent is root (builtins),
635
+ # plus knowledge of its own directory for resolving @refs.
636
+ env = @root_env.child
637
+ env.define("__dir__", File.dirname(abspath))
638
+ env.define("__file__", abspath)
639
+ eval_expr(ast, env)
640
+ rescue Errno::ENOENT
641
+ warn "[fusion] file not found: #{abspath}" if ENV["FUSION_DEBUG"]
642
+ Fusion.mkerr({"kind" => "file_not_found", "path" => abspath})
643
+ rescue ParseError => err
644
+ warn "[fusion] parse error in #{abspath}: #{err.message}" if ENV["FUSION_DEBUG"]
645
+ Fusion.mkerr({"kind" => "parse_error", "path" => abspath, "message" => err.message})
646
+ end
647
+
648
+ # Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
649
+ def resolve_name(name, dir)
650
+ sib = File.expand_path(name + ".fsn", dir)
651
+ return load_file(sib).force if File.exist?(sib)
652
+ if name == "ENV"
653
+ return @env_vars.dup
654
+ end
655
+ if name == "load"
656
+ # @load is a builtin closure capturing the calling file's directory. It
657
+ # loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
658
+ d = dir
659
+ return NativeFunc.new("load", lambda do |v|
660
+ next Fusion.mkerr({"kind" => "load_bad_arg", "got" => v.class.name}) unless v.is_a?(String)
661
+ target = File.expand_path(v, d)
662
+ next Fusion.mkerr({"kind" => "file_not_found", "path" => target}) unless File.exist?(target)
663
+ load_file(target).force
664
+ end)
665
+ end
666
+ return @builtins[name] if @builtins.key?(name)
667
+ if @stdlib_dir
668
+ std = File.join(@stdlib_dir, name + ".fsn")
669
+ return load_file(std).force if File.exist?(std)
670
+ end
671
+ Fusion.mkerr({"kind" => "unresolved_ref", "name" => name})
672
+ end
673
+
674
+ # Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
675
+ def resolve_path(relpath, dir)
676
+ load_file(File.expand_path(relpath + ".fsn", dir)).force
677
+ end
678
+
679
+ # ---- Expression evaluation -------------------------------------------
680
+ def eval_expr(node, env)
681
+ case node
682
+ when Lit then node.value
683
+ when ErrLit
684
+ # Bare `!` means `!null`; `!expr` wraps expr's value as an error.
685
+ # If the payload expression itself errors, propagate THAT error rather
686
+ # than wrapping it -- prevents accidental error-burying.
687
+ if node.payload.nil?
688
+ Fusion.mkerr(NULL)
689
+ else
690
+ p = eval_expr(node.payload, env)
691
+ Fusion.error?(p) ? p : Fusion.mkerr(p)
692
+ end
693
+ when Ident
694
+ v = env.lookup(node.name)
695
+ v == :__unbound__ ? Fusion.mkerr({"kind" => "unbound", "name" => node.name}) : v
696
+ when FileRef
697
+ dir = env.lookup("__dir__")
698
+ dir = Dir.pwd if dir == :__unbound__
699
+ case node.variety
700
+ when :self
701
+ f = env.lookup("__file__")
702
+ f == :__unbound__ ? Fusion.mkerr({"kind" => "no_current_file"}) : load_file(f).force
703
+ when :name
704
+ resolve_name(node.path, dir)
705
+ else # :path
706
+ resolve_path(node.path, dir)
707
+ end
708
+ when ArrLit then eval_array(node, env)
709
+ when ObjLit then eval_object(node, env)
710
+ when FuncLit then Func.new(node.clauses, env)
711
+ when Pipe then eval_pipe(node, env)
712
+ when Member then eval_member(node, env)
713
+ when Index then eval_index(node, env)
714
+ else raise FusionError, "Cannot evaluate node #{node.class}"
715
+ end
716
+ end
717
+
718
+ # Array/object literals propagate any error encountered during construction.
719
+ # Errors are not first-class: at any point during execution there is either
720
+ # a value or an error in motion, never both.
721
+ def eval_array(node, env)
722
+ out = []
723
+ node.elems.each do |kind, expr|
724
+ v = eval_expr(expr, env)
725
+ return v if Fusion.error?(v)
726
+ if kind == :spread
727
+ return Fusion.mkerr({"kind" => "spread_non_array", "got" => v.class.name}) unless v.is_a?(Array)
728
+ out.concat(v)
729
+ else
730
+ out << v
731
+ end
732
+ end
733
+ out
734
+ end
735
+
736
+ def eval_object(node, env)
737
+ out = {}
738
+ node.members.each do |m|
739
+ if m[0] == :spread
740
+ v = eval_expr(m[1], env)
741
+ return v if Fusion.error?(v)
742
+ return Fusion.mkerr({"kind" => "spread_non_object", "got" => v.class.name}) unless v.is_a?(Hash)
743
+ out.merge!(v)
744
+ else
745
+ _, key, expr = m
746
+ v = eval_expr(expr, env)
747
+ return v if Fusion.error?(v)
748
+ out[key] = v
749
+ end
750
+ end
751
+ out
752
+ end
753
+
754
+ def eval_pipe(node, env)
755
+ v = eval_expr(node.left, env)
756
+ f = eval_expr(node.right, env)
757
+ apply(f, v)
758
+ end
759
+
760
+ def eval_member(node, env)
761
+ obj = eval_expr(node.obj, env)
762
+ return obj if Fusion.error?(obj)
763
+ return Fusion.mkerr({"kind" => "member_on_non_object", "key" => node.key}) unless obj.is_a?(Hash)
764
+ return Fusion.mkerr({"kind" => "missing_key", "key" => node.key}) unless obj.key?(node.key)
765
+ obj[node.key]
766
+ end
767
+
768
+ def eval_index(node, env)
769
+ obj = eval_expr(node.obj, env)
770
+ return obj if Fusion.error?(obj)
771
+ idx = eval_expr(node.idx, env)
772
+ return idx if Fusion.error?(idx)
773
+ if obj.is_a?(Array) && idx.is_a?(Integer)
774
+ i = idx >= 0 ? idx : obj.length + idx
775
+ return obj[i] if i >= 0 && i < obj.length
776
+ return Fusion.mkerr({"kind" => "index_out_of_range", "index" => idx, "length" => obj.length})
777
+ elsif obj.is_a?(Hash) && idx.is_a?(String)
778
+ return obj[idx] if obj.key?(idx)
779
+ return Fusion.mkerr({"kind" => "missing_key", "key" => idx})
780
+ end
781
+ Fusion.mkerr({"kind" => "bad_index", "obj" => obj.class.name, "idx" => idx.class.name})
782
+ end
783
+
784
+ # ---- Application & matching ------------------------------------------
785
+ def apply(f, v)
786
+ # An errored function value propagates as-is (more useful than a generic
787
+ # "applied a non-function" wrapper).
788
+ return f if Fusion.error?(f)
789
+ if f.is_a?(NativeFunc)
790
+ # Uniform propagation: built-ins never receive errors as inputs.
791
+ return v if Fusion.error?(v)
792
+ return f.fn.call(v)
793
+ end
794
+ unless f.is_a?(Func)
795
+ return Fusion.mkerr({"kind" => "apply_non_function", "got" => f.class.name})
796
+ end
797
+ f.clauses.each do |pattern, body|
798
+ bindings = {}
799
+ m = match(pattern, v, bindings, f.env)
800
+ # A `?` predicate raised an error during matching: bubble it up as the
801
+ # function's return value (no further clauses are tried).
802
+ return m if Fusion.error?(m)
803
+ if m
804
+ return eval_expr(body, f.env.child(bindings))
805
+ end
806
+ end
807
+ # No clause matched. If the input was an error, it keeps propagating
808
+ # (an unmatched error must never be silently swallowed). Otherwise the
809
+ # lenient default is `null`.
810
+ Fusion.error?(v) ? v : NULL
811
+ end
812
+
813
+ # Returns true (match), false (no match), or an ErrorVal (predicate errored).
814
+ def match(pattern, value, bindings, env)
815
+ case pattern
816
+ when PLit
817
+ deep_equal?(pattern.value, value)
818
+ when PErr
819
+ # If the value is an error, match the inner pattern against its
820
+ # payload. The inner is always a non-`!` pattern (the parser ensures
821
+ # that), so for a bare `!` we synthesized PWild as the inner.
822
+ return false unless Fusion.error?(value)
823
+ match(pattern.inner, value.payload, bindings, env)
824
+ when PWild
825
+ # `_` matches anything EXCEPT an error value.
826
+ !Fusion.error?(value)
827
+ when PBind
828
+ return false if Fusion.error?(value) # binders never capture an error
829
+ bindings[pattern.name] = value
830
+ true
831
+ when PArr
832
+ match_array(pattern, value, bindings, env)
833
+ when PObj
834
+ match_object(pattern, value, bindings, env)
835
+ when PGuard
836
+ inner_res = match(pattern.inner, value, bindings, env)
837
+ return inner_res if Fusion.error?(inner_res)
838
+ return false unless inner_res
839
+ pred = eval_expr(pattern.pred_expr, env)
840
+ return pred if Fusion.error?(pred) # predicate expression itself errored
841
+ # The predicate sees whatever value reached this PGuard, which is
842
+ # already the right value because `!pat ? pred` parses as
843
+ # PErr(PGuard(pat, pred)) — by the time PGuard runs, the value is
844
+ # already the payload.
845
+ r = apply(pred, value)
846
+ return r if Fusion.error?(r) # predicate raised during application
847
+ r == true
848
+ else
849
+ raise FusionError, "Unknown pattern #{pattern.class}"
850
+ end
851
+ end
852
+
853
+ def match_array(pattern, value, bindings, env)
854
+ return false unless value.is_a?(Array)
855
+ elems = pattern.elems
856
+ rest_index = elems.index { |e| e[0] == :rest }
857
+
858
+ if rest_index.nil?
859
+ return false unless value.length == elems.length
860
+ elems.each_with_index do |(_, p), i|
861
+ r = match(p, value[i], bindings, env)
862
+ return r if Fusion.error?(r)
863
+ return false unless r
864
+ end
865
+ true
866
+ else
867
+ before = elems[0...rest_index]
868
+ after = elems[(rest_index + 1)..]
869
+ return false if value.length < before.length + after.length
870
+ before.each_with_index do |(_, p), i|
871
+ r = match(p, value[i], bindings, env)
872
+ return r if Fusion.error?(r)
873
+ return false unless r
874
+ end
875
+ after.each_with_index do |(_, p), k|
876
+ vi = value.length - after.length + k
877
+ r = match(p, value[vi], bindings, env)
878
+ return r if Fusion.error?(r)
879
+ return false unless r
880
+ end
881
+ rest_name = elems[rest_index][1]
882
+ if rest_name
883
+ mid = value[before.length...(value.length - after.length)]
884
+ bindings[rest_name] = mid
885
+ end
886
+ true
887
+ end
888
+ end
889
+
890
+ def match_object(pattern, value, bindings, env)
891
+ return false unless value.is_a?(Hash)
892
+ matched_keys = []
893
+ rest_name = :__none__
894
+ pattern.members.each do |m|
895
+ if m[0] == :rest
896
+ rest_name = m[1] # may be nil (ignore) or a string
897
+ else
898
+ _, key, p = m
899
+ return false unless value.key?(key)
900
+ r = match(p, value[key], bindings, env)
901
+ return r if Fusion.error?(r)
902
+ return false unless r
903
+ matched_keys << key
904
+ end
905
+ end
906
+ if rest_name != :__none__ && rest_name
907
+ remaining = value.reject { |k, _| matched_keys.include?(k) }
908
+ bindings[rest_name] = remaining
909
+ end
910
+ true
911
+ end
912
+
913
+ # ---- Equality & helpers ----------------------------------------------
914
+ def error?(v) = Fusion.error?(v)
915
+
916
+ def deep_equal?(a, b)
917
+ return true if a.equal?(b)
918
+ return false if a.class != b.class
919
+ case a
920
+ when Array
921
+ a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
922
+ when Hash
923
+ a.length == b.length && a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
924
+ else
925
+ a == b
926
+ end
927
+ end
928
+ end
929
+
930
+ # =========================================================================
931
+ # BUILT-INS (Tier 0 primitives; everything else is written in Fusion)
932
+ # =========================================================================
933
+ module Builtins
934
+ def self.install(table, interp)
935
+ # Helper to construct an informative error from a builtin context.
936
+ err = ->(fn, msg) { Fusion.mkerr("#{fn}: #{msg}") }
937
+ define = ->(name, fn) { table[name] = NativeFunc.new(name, fn) }
938
+
939
+ # --- arithmetic on a pair [a, b] (or unary for negate) ---
940
+ pair_num = lambda do |v|
941
+ return nil unless v.is_a?(Array) && v.length == 2
942
+ a, b = v
943
+ return nil unless a.is_a?(Numeric) && !(a == true || a == false) &&
944
+ b.is_a?(Numeric) && !(b == true || b == false)
945
+ [a, b]
946
+ end
947
+ isnum = ->(x) { x.is_a?(Numeric) && !(x == true || x == false) }
948
+
949
+ define.call("add", ->(v) {
950
+ p = pair_num.call(v); p ? p[0] + p[1] : err.call("add", "expected a pair of numbers")
951
+ })
952
+ define.call("subtract", ->(v) {
953
+ p = pair_num.call(v); p ? p[0] - p[1] : err.call("subtract", "expected a pair of numbers")
954
+ })
955
+ define.call("multiply", ->(v) {
956
+ p = pair_num.call(v); p ? p[0] * p[1] : err.call("multiply", "expected a pair of numbers")
957
+ })
958
+ define.call("divide", lambda do |v|
959
+ p = pair_num.call(v)
960
+ next err.call("divide", "expected a pair of numbers") unless p
961
+ next err.call("divide", "division by zero") if p[1] == 0
962
+ if p[0].is_a?(Integer) && p[1].is_a?(Integer) && (p[0] % p[1] == 0)
963
+ p[0] / p[1]
964
+ else
965
+ p[0].to_f / p[1]
966
+ end
967
+ end)
968
+ define.call("mod", lambda do |v|
969
+ p = pair_num.call(v)
970
+ next err.call("mod", "expected a pair of numbers") unless p
971
+ next err.call("mod", "modulo by zero") if p[1] == 0
972
+ p[0] % p[1]
973
+ end)
974
+ define.call("negate", ->(v) {
975
+ isnum.call(v) ? -v : err.call("negate", "expected a number")
976
+ })
977
+ define.call("floor", ->(v) {
978
+ isnum.call(v) ? v.floor : err.call("floor", "expected a number")
979
+ })
980
+
981
+ # --- comparison ---
982
+ define.call("equals", lambda do |v|
983
+ next err.call("equals", "expected a pair") unless v.is_a?(Array) && v.length == 2
984
+ interp.deep_equal?(v[0], v[1])
985
+ end)
986
+ define.call("lessThan", lambda do |v|
987
+ next err.call("lessThan", "expected two numbers or two strings") unless v.is_a?(Array) && v.length == 2
988
+ a, b = v
989
+ if isnum.call(a) && isnum.call(b) then a < b
990
+ elsif a.is_a?(String) && b.is_a?(String) then a < b
991
+ else err.call("lessThan", "expected two numbers or two strings") end
992
+ end)
993
+
994
+ # --- boolean ---
995
+ define.call("and", lambda do |v|
996
+ unless v.is_a?(Array) && v.length == 2 && v.all? { |x| x == true || x == false }
997
+ next err.call("and", "expected a pair of booleans")
998
+ end
999
+ v[0] && v[1]
1000
+ end)
1001
+ define.call("or", lambda do |v|
1002
+ unless v.is_a?(Array) && v.length == 2 && v.all? { |x| x == true || x == false }
1003
+ next err.call("or", "expected a pair of booleans")
1004
+ end
1005
+ v[0] || v[1]
1006
+ end)
1007
+ define.call("not", ->(v) {
1008
+ (v == true || v == false) ? !v : err.call("not", "expected a boolean")
1009
+ })
1010
+
1011
+ # --- strings / structure bridges ---
1012
+ define.call("length", lambda do |v|
1013
+ case v
1014
+ when String then v.length
1015
+ when Array then v.length
1016
+ when Hash then v.length
1017
+ else err.call("length", "expected a string, array, or object") end
1018
+ end)
1019
+ define.call("concat", lambda do |v|
1020
+ unless v.is_a?(Array) && v.length == 2 && v.all? { |x| x.is_a?(String) }
1021
+ next err.call("concat", "expected a pair of strings")
1022
+ end
1023
+ v[0] + v[1]
1024
+ end)
1025
+ define.call("chars", ->(v) {
1026
+ v.is_a?(String) ? v.chars : err.call("chars", "expected a string")
1027
+ })
1028
+ define.call("join", lambda do |v|
1029
+ next err.call("join", "expected [array-of-strings, separator-string]") unless v.is_a?(Array) && v.length == 2
1030
+ arr, sep = v
1031
+ unless arr.is_a?(Array) && sep.is_a?(String) && arr.all? { |x| x.is_a?(String) }
1032
+ next err.call("join", "expected [array-of-strings, separator-string]")
1033
+ end
1034
+ arr.join(sep)
1035
+ end)
1036
+ define.call("toString", lambda do |v|
1037
+ case v
1038
+ when String then v
1039
+ when Integer, Float then v.to_s
1040
+ when true then "true"
1041
+ when false then "false"
1042
+ when NULL then "null"
1043
+ else err.call("toString", "cannot stringify this value type")
1044
+ end
1045
+ end)
1046
+ define.call("parseNumber", lambda do |v|
1047
+ next err.call("parseNumber", "expected a string") unless v.is_a?(String)
1048
+ if v =~ /\A-?\d+\z/ then v.to_i
1049
+ elsif v =~ /\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/ then v.to_f
1050
+ else err.call("parseNumber", "not a numeric string")
1051
+ end
1052
+ end)
1053
+
1054
+ # --- object key enumeration (Tier 0: patterns can't enumerate unknown keys) ---
1055
+ define.call("keys", ->(v) { v.is_a?(Hash) ? v.keys : err.call("keys", "expected an object") })
1056
+ define.call("values", ->(v) { v.is_a?(Hash) ? v.values : err.call("values", "expected an object") })
1057
+
1058
+ # --- type predicates (return false on any non-matching value; propagate on error like every other builtin) ---
1059
+ define.call("Integer", ->(v) { v.is_a?(Integer) && !(v == true || v == false) })
1060
+ define.call("Float", ->(v) { v.is_a?(Float) })
1061
+ define.call("Number", ->(v) { isnum.call(v) })
1062
+ define.call("String", ->(v) { v.is_a?(String) })
1063
+ define.call("Boolean", ->(v) { v == true || v == false })
1064
+ define.call("Array", ->(v) { v.is_a?(Array) })
1065
+ define.call("Object", ->(v) { v.is_a?(Hash) })
1066
+ define.call("Null", ->(v) { v == NULL })
1067
+ end
1068
+ end
1069
+
1070
+ # A native (Ruby-implemented) function. Apply treats it like a Func.
1071
+ class NativeFunc
1072
+ attr_reader :name, :fn
1073
+ def initialize(name, fn)
1074
+ @name = name
1075
+ @fn = fn
1076
+ end
1077
+ def inspect = "<builtin #{name}>"
1078
+ end
1079
+
1080
+ # =========================================================================
1081
+ # JSON I/O (minimal, with NULL and ErrorVal handling)
1082
+ # =========================================================================
1083
+ module Serializer
1084
+ def self.to_json(v)
1085
+ case v
1086
+ when NULL then "null"
1087
+ when true then "true"
1088
+ when false then "false"
1089
+ when Integer then v.to_s
1090
+ when Float then v.to_s
1091
+ when String then string_json(v)
1092
+ when Array then "[" + v.map { |x| to_json(x) }.join(",") + "]"
1093
+ when Hash then "{" + v.map { |k, x| "#{string_json(k.to_s)}:#{to_json(x)}" }.join(",") + "}"
1094
+ when Func, NativeFunc then '"<function>"'
1095
+ when ErrorVal
1096
+ # Errors render as `!<payload-json>`. This form is NOT valid JSON; the CLI
1097
+ # prints the payload (as JSON) to stderr and nothing to stdout on error.
1098
+ "!" + to_json(v.payload)
1099
+ else
1100
+ v.inspect
1101
+ end
1102
+ end
1103
+
1104
+ def self.string_json(s)
1105
+ out = +'"'
1106
+ s.each_char do |c|
1107
+ out << case c
1108
+ when '"' then '\\"'
1109
+ when "\\" then "\\\\"
1110
+ when "\n" then "\\n"
1111
+ when "\t" then "\\t"
1112
+ when "\r" then "\\r"
1113
+ else c
1114
+ end
1115
+ end
1116
+ out << '"'
1117
+ out
1118
+ end
1119
+ end
1120
+
1121
+ module JsonInput
1122
+ # Parse JSON text into Fusion values (null -> NULL).
1123
+ def self.parse(text)
1124
+ require "json"
1125
+ raw = JSON.parse(text)
1126
+ convert(raw)
1127
+ rescue JSON::ParserError
1128
+ Fusion.mkerr({"kind" => "stdin_not_json"})
1129
+ end
1130
+
1131
+ def self.convert(x)
1132
+ case x
1133
+ when nil then NULL
1134
+ when Array then x.map { |e| convert(e) }
1135
+ when Hash then x.each_with_object({}) { |(k, v), h| h[k] = convert(v) }
1136
+ else x
1137
+ end
1138
+ end
1139
+ end
1140
+ end