fusion-lang 0.0.1.alpha1 → 0.0.1.alpha2

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.
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Transformation ===
4
+ #
5
+ # Input: String (source code)
6
+ # Output: Array<Token>
7
+
8
+ require_relative "token"
9
+ require_relative "null"
10
+
11
+ module Fusion
12
+ class Lexer
13
+ PUNCT = {
14
+ "(" => :lparen, ")" => :rparen,
15
+ "[" => :lbracket, "]" => :rbracket,
16
+ "{" => :lbrace, "}" => :rbrace,
17
+ "," => :comma, ":" => :colon,
18
+ "|" => :pipe, "?" => :question, "." => :dot,
19
+ "@" => :at, "/" => :slash,
20
+ "=" => :equals,
21
+ }.freeze
22
+
23
+ def initialize(src)
24
+ @src = src
25
+ @i = 0
26
+ @n = src.length
27
+ end
28
+
29
+ def tokens
30
+ out = []
31
+ loop do
32
+ t = next_token
33
+ out << t
34
+ break if t.type == :eof
35
+ end
36
+ out
37
+ end
38
+
39
+ private
40
+
41
+ def peek(o = 0) = @i + o < @n ? @src[@i + o] : nil
42
+
43
+ def next_token
44
+ skip_trivia
45
+ start = @i
46
+ c = peek
47
+ return Token.new(type: :eof, value: nil, pos: start) if c.nil?
48
+
49
+ # "=>" and "..." handled specially ("#" line comments handled in skip_trivia)
50
+ if c == "=" && peek(1) == ">"
51
+ @i += 2
52
+ return Token.new(type: :arrow, value: "=>", pos: start)
53
+ end
54
+ if c == "." && peek(1) == "." && peek(2) == "."
55
+ @i += 3
56
+ return Token.new(type: :spread, value: "...", pos: start)
57
+ end
58
+ if c == "!"
59
+ @i += 1
60
+ return Token.new(type: :bang, value: "!", pos: start)
61
+ end
62
+ if c == '"'
63
+ return lex_string(start)
64
+ end
65
+ if digit?(c) || (c == "-" && digit?(peek(1)))
66
+ return lex_number(start)
67
+ end
68
+ if ident_start?(c)
69
+ return lex_word(start)
70
+ end
71
+ if (type = PUNCT[c])
72
+ @i += 1
73
+ return Token.new(type: type, value: c, pos: start)
74
+ end
75
+ raise ParseError, "Unexpected character #{c.inspect} at #{start}"
76
+ end
77
+
78
+ def skip_trivia
79
+ loop do
80
+ c = peek
81
+ if c == " " || c == "\t" || c == "\n" || c == "\r"
82
+ @i += 1
83
+ elsif c == "#" && at_line_start?
84
+ # A line is a comment iff its first non-whitespace char is "#".
85
+ # This also covers shebang lines (#!) for free.
86
+ @i += 1 until peek.nil? || peek == "\n"
87
+ else
88
+ break
89
+ end
90
+ end
91
+ end
92
+
93
+ # True when only whitespace precedes @i on the current physical line.
94
+ def at_line_start?
95
+ j = @i - 1
96
+ j -= 1 while j >= 0 && (@src[j] == " " || @src[j] == "\t")
97
+ j < 0 || @src[j] == "\n" || @src[j] == "\r"
98
+ end
99
+
100
+ def lex_string(start)
101
+ @i += 1 # opening quote
102
+ buf = +""
103
+ while (c = peek)
104
+ if c == '"'
105
+ @i += 1
106
+ return Token.new(type: :string, value: buf, pos: start)
107
+ elsif c == "\\"
108
+ @i += 1
109
+ e = peek
110
+ buf << case e
111
+ when '"' then '"'
112
+ when "\\" then "\\"
113
+ when "/" then "/"
114
+ when "n" then "\n"
115
+ when "t" then "\t"
116
+ when "r" then "\r"
117
+ when "b" then "\b"
118
+ when "f" then "\f"
119
+ when "u"
120
+ hex = @src[@i + 1, 4]
121
+ @i += 4
122
+ code_point = hex.to_i(16)
123
+ # Reject surrogates and out-of-range code points up front:
124
+ # pack("U") would otherwise build an invalid-encoding string
125
+ # whose malformed-UTF-8 error surfaces far downstream.
126
+ if code_point.between?(0xD800, 0xDFFF) || code_point > 0x10FFFF
127
+ raise ParseError, "Invalid unicode escape \\u#{hex}"
128
+ end
129
+ [code_point].pack("U")
130
+ else
131
+ raise ParseError, "Bad escape \\#{e}"
132
+ end
133
+ @i += 1
134
+ elsif c == "\n" || c == "\r"
135
+ raise ParseError, "Raw newline in string starting at #{start}; use \\n"
136
+ else
137
+ buf << c
138
+ @i += 1
139
+ end
140
+ end
141
+ raise ParseError, "Unterminated string starting at #{start}"
142
+ end
143
+
144
+ def lex_number(start)
145
+ j = @i
146
+ j += 1 if @src[j] == "-"
147
+ j += 1 while j < @n && digit?(@src[j])
148
+ is_float = false
149
+ if @src[j] == "." && digit?(@src[j + 1])
150
+ is_float = true
151
+ j += 1
152
+ j += 1 while j < @n && digit?(@src[j])
153
+ end
154
+ if (@src[j] == "e" || @src[j] == "E")
155
+ is_float = true
156
+ j += 1
157
+ j += 1 if (@src[j] == "+" || @src[j] == "-")
158
+ j += 1 while j < @n && digit?(@src[j])
159
+ end
160
+ text = @src[@i...j]
161
+ @i = j
162
+ val = is_float ? text.to_f : text.to_i
163
+ Token.new(type: :number, value: val, pos: start)
164
+ end
165
+
166
+ def lex_word(start)
167
+ j = @i
168
+ j += 1 while j < @n && ident_part?(@src[j])
169
+ text = @src[@i...j]
170
+ @i = j
171
+ case text
172
+ when "true" then Token.new(type: :true_kw, value: true, pos: start)
173
+ when "false" then Token.new(type: :false_kw, value: false, pos: start)
174
+ when "null" then Token.new(type: :null_kw, value: NULL, pos: start)
175
+ else Token.new(type: :ident, value: text, pos: start)
176
+ end
177
+ end
178
+
179
+ def digit?(c) = c && c >= "0" && c <= "9"
180
+ def ident_start?(c) = c && (c =~ /[A-Za-z_]/)
181
+ def ident_part?(c) = c && (c =~ /[A-Za-z0-9_]/)
182
+ end
183
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Value ===
4
+ #
5
+ # Runtime null value.
6
+
7
+ module Fusion
8
+ NULL = :null
9
+ end
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Transformation ===
4
+ #
5
+ # Recursive descent parser following the EBNF
6
+ #
7
+ # Input: Array<Token>
8
+ # Output: AST::Expression
9
+
10
+ require_relative "token"
11
+ require_relative "ast"
12
+ require_relative "interpreter/error_val"
13
+
14
+ module Fusion
15
+ class Parser
16
+ include AST
17
+
18
+ def initialize(tokens)
19
+ @toks = tokens
20
+ @i = 0
21
+ end
22
+
23
+ # Parse a complete program. The lexer and parser report failures by raising
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:)
28
+ toks = Lexer.new(src).tokens
29
+ p = new(toks)
30
+ expr = p.parse_expr
31
+ p.expect(:eof)
32
+ expr
33
+ rescue ParseError => err
34
+ Interpreter::ErrorVal.internal(kind: "syntax_error", location: location, operation: "parsing", input: src, message: err.message)
35
+ end
36
+
37
+ # Parse one REPL entry — a statement (`identifier "=" expr`) or a bare
38
+ # expression — returning an AST::Statement::Assignment / AST::Expression, or, like
39
+ # parse_file, a standardized syntax_error value instead of ever raising. The
40
+ # REPL uses the error/non-error distinction to tell "keep editing" (didn't
41
+ # parse yet) from "evaluate now" (a complete statement or expression).
42
+ def self.parse_repl(src, location:)
43
+ toks = Lexer.new(src).tokens
44
+ p = new(toks)
45
+ entry = p.parse_repl_entry
46
+ p.expect(:eof)
47
+ entry
48
+ rescue ParseError => err
49
+ Interpreter::ErrorVal.internal(kind: "syntax_error", location: location, operation: "parsing", input: src, message: err.message)
50
+ end
51
+
52
+ # A leading `identifier =` marks a statement; anything else is an expression.
53
+ # (A bare identifier is itself a valid expression, so the `=` is the decider.)
54
+ def parse_repl_entry
55
+ if at?(:ident) && peek(1)&.type == :equals
56
+ parse_statement
57
+ else
58
+ parse_expr
59
+ end
60
+ end
61
+
62
+ # statement = identifier "=" expr (REPL only; files contain one expr)
63
+ def parse_statement
64
+ name = expect(:ident).value
65
+ expect(:equals)
66
+ AST::Statement::Assignment.new(name: name, expression: parse_expr)
67
+ end
68
+
69
+ def parse_expr
70
+ parse_pipe
71
+ end
72
+
73
+ def parse_pipe
74
+ left = parse_prefix
75
+ while at?(:pipe)
76
+ advance
77
+ right = parse_prefix
78
+ left = Expression::Pipe.new(left: left, right: right)
79
+ end
80
+ left
81
+ end
82
+
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
87
+
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)
93
+ advance
94
+ if PRIMARY_STARTERS.include?(peek.type)
95
+ Expression::ErrLit.new(payload: parse_prefix) # allow !!x to nest
96
+ else
97
+ Expression::ErrLit.new(payload: nil) # bare ! -> !null
98
+ end
99
+ else
100
+ parse_postfix
101
+ end
102
+ end
103
+
104
+ def parse_postfix
105
+ node = parse_primary
106
+ loop do
107
+ if at?(:dot)
108
+ advance
109
+ key = expect(:ident).value
110
+ node = Expression::Member.new(obj: node, key: key)
111
+ elsif at?(:lbracket)
112
+ advance
113
+ idx = parse_expr
114
+ expect(:rbracket)
115
+ node = Expression::Index.new(obj: node, idx: idx)
116
+ else
117
+ break
118
+ end
119
+ end
120
+ node
121
+ end
122
+
123
+ def parse_primary
124
+ t = peek
125
+ case t.type
126
+ when :number, :string then advance; Expression::Lit.new(value: t.value)
127
+ when :true_kw, :false_kw, :null_kw then advance; Expression::Lit.new(value: t.value)
128
+ when :lbracket then parse_array
129
+ when :lbrace then parse_object
130
+ when :lparen then parse_function_or_group
131
+ when :ident then advance; Expression::Ident.new(name: t.value)
132
+ when :at then parse_fileref
133
+ else raise ParseError, "Unexpected token #{t.type} (#{t.value.inspect}) at #{t.pos}"
134
+ end
135
+ end
136
+
137
+ def parse_fileref
138
+ 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("/"))
162
+ end
163
+
164
+ def parse_array
165
+ expect(:lbracket)
166
+ items = []
167
+ until at?(:rbracket)
168
+ if at?(:spread)
169
+ advance
170
+ items << ArraySpread.new(value: parse_expr)
171
+ else
172
+ items << ArrayItem.new(value: parse_expr)
173
+ end
174
+ break unless at?(:comma)
175
+ advance
176
+ end
177
+ expect(:rbracket)
178
+ Expression::ArrLit.new(items: items)
179
+ end
180
+
181
+ # Fixed keys must be distinct (the ObjLit data rule); a repeat is a clean
182
+ # syntax_error. Keys arriving via `...spread` are dynamic and not checked.
183
+ def parse_object
184
+ expect(:lbrace)
185
+ pairs = []
186
+ keys = []
187
+ until at?(:rbrace)
188
+ if at?(:spread)
189
+ advance
190
+ pairs << ObjectSpread.new(value: parse_expr)
191
+ else
192
+ key_tok = expect(:string)
193
+ key = key_tok.value
194
+ raise ParseError, "duplicate key #{key.inspect} (at #{key_tok.pos})" if keys.include?(key)
195
+ keys << key
196
+ expect(:colon)
197
+ pairs << KeyValuePair.new(key: key, value: parse_expr)
198
+ end
199
+ break unless at?(:comma)
200
+ advance
201
+ end
202
+ expect(:rbrace)
203
+ Expression::ObjLit.new(pairs: pairs)
204
+ end
205
+
206
+ # A "(" begins a grouped expression, a function literal, or — when empty —
207
+ # the clause-less function `()`. A function is a comma-separated list of
208
+ # `pattern => expr`; we detect one by scanning for a top-level `=>` before the
209
+ # matching `)`. `()` matches nothing (so it yields null for any normal input
210
+ # and propagates errors).
211
+ def parse_function_or_group
212
+ expect(:lparen)
213
+ if at?(:rparen)
214
+ advance
215
+ return Expression::FuncLit.new(clauses: [])
216
+ end
217
+ if looks_like_function?
218
+ clauses = []
219
+ loop do
220
+ pat = parse_pattern
221
+ expect(:arrow)
222
+ body = parse_expr
223
+ clauses << Clause.new(pattern: pat, body: body)
224
+ if at?(:comma)
225
+ advance
226
+ break if at?(:rparen) # trailing comma
227
+ else
228
+ break
229
+ end
230
+ end
231
+ expect(:rparen)
232
+ Expression::FuncLit.new(clauses: clauses)
233
+ else
234
+ e = parse_expr
235
+ expect(:rparen)
236
+ e
237
+ end
238
+ end
239
+
240
+ # Look ahead from current position (just after "(") to decide if this is a
241
+ # function literal: is there a top-level "=>" before the matching ")"?
242
+ def looks_like_function?
243
+ depth = 0
244
+ j = @i
245
+ while j < @toks.length
246
+ t = @toks[j]
247
+ case t.type
248
+ when :lparen, :lbracket, :lbrace then depth += 1
249
+ when :rparen, :rbracket, :rbrace
250
+ return false if depth.zero? # hit our closing ) first
251
+ depth -= 1
252
+ when :arrow
253
+ return true if depth.zero?
254
+ when :eof
255
+ return false
256
+ end
257
+ j += 1
258
+ end
259
+ false
260
+ end
261
+
262
+ # ---- Patterns ----
263
+ # ---- Pattern grammar (mirrors reference.md §2.5 EBNF) ------------------
264
+ # pattern = p_error | p_guarded
265
+ # p_error = "!" | "!" p_guarded
266
+ # p_guarded = p_core [ "?" predicate ]
267
+ # p_core = p_literal | p_bind | p_wildcard | p_array | p_object
268
+ # Note: `p_core` does NOT include p_error. The "no nested !pat" property
269
+ # falls out of the grammar shape — `p_error` is only reachable from `pattern`
270
+ # (a clause's top level), never from inside arrays, objects, or another
271
+ # error's payload. No flag-threading is needed.
272
+ def parse_pattern
273
+ at?(:bang) ? parse_errpat : parse_guardedpat
274
+ end
275
+
276
+ # Tokens that can begin a `guardedpat` (used to detect whether `!` is
277
+ # followed by a payload pattern or stands alone).
278
+ GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw
279
+ lbracket lbrace ident].freeze
280
+
281
+ def parse_errpat
282
+ expect(:bang)
283
+ if GUARDEDPAT_STARTERS.include?(peek.type)
284
+ Pattern::PErr.new(inner: parse_guardedpat) # "!" guardedpat
285
+ else
286
+ Pattern::PErr.new(inner: Pattern::PWild.new(dummy: nil)) # bare "!" — matches any error, binds nothing
287
+ end
288
+ end
289
+
290
+ def parse_guardedpat
291
+ inner = parse_corepat
292
+ if at?(:question)
293
+ advance
294
+ # A predicate is a full pipe so it may chain functions: `a ? b | c` tests
295
+ # `a | b | c`. It stops at `=>`, `,`, `]`, `}`, `)` like any expression.
296
+ pred = parse_pipe
297
+ Pattern::PGuard.new(inner: inner, pred_expr: pred)
298
+ else
299
+ inner
300
+ end
301
+ end
302
+
303
+ def parse_corepat
304
+ t = peek
305
+ case t.type
306
+ when :number, :string then advance; Pattern::PLit.new(value: t.value)
307
+ when :true_kw, :false_kw, :null_kw then advance; Pattern::PLit.new(value: t.value)
308
+ when :lbracket then parse_arraypat
309
+ when :lbrace then parse_objectpat
310
+ when :ident
311
+ advance
312
+ t.value == "_" ? Pattern::PWild.new(dummy: nil) : Pattern::PBind.new(name: t.value)
313
+ when :bang
314
+ # `!pat` is only valid as a clause's top-level pattern, never inside an
315
+ # array element, object member, or error payload.
316
+ raise ParseError, "`!pat` may only appear as a clause's top-level pattern (at #{t.pos})"
317
+ else
318
+ raise ParseError, "Unexpected token in pattern: #{t.type} at #{t.pos}"
319
+ end
320
+ end
321
+
322
+ # p_array (reference.md §2.5). Items are `p_guarded`s — never error patterns.
323
+ # The grammar's two arms (with / without a rest) become two phases: the loop
324
+ # parses leading items up to an optional single `...rest`; once a rest is
325
+ # consumed, the inner loop parses trailing items only, so a second `...` lands
326
+ # in `parse_guardedpat` as an unexpected token. There is no `seen_rest` flag —
327
+ # "at most one rest" is enforced by the shape of the loop.
328
+ def parse_arraypat
329
+ expect(:lbracket)
330
+ items = []
331
+ until at?(:rbracket)
332
+ if at?(:spread)
333
+ items << parse_pattern_rest
334
+ while at?(:comma)
335
+ advance
336
+ break if at?(:rbracket) # trailing comma
337
+ raise ParseError, "a pattern may contain at most one `...rest` (at #{peek.pos})" if at?(:spread)
338
+ items << PatternItem.new(pattern: parse_guardedpat)
339
+ end
340
+ break
341
+ end
342
+ items << PatternItem.new(pattern: parse_guardedpat)
343
+ break unless at?(:comma)
344
+ advance
345
+ end
346
+ expect(:rbracket)
347
+ Pattern::PArr.new(items: items)
348
+ end
349
+
350
+ # p_object (reference.md §2.5). Leading pairs up to an optional single
351
+ # `...rest`, which must come last — only a trailing comma may follow it. Keys
352
+ # must be distinct (the PObj data rule); a repeat is a clean syntax_error.
353
+ def parse_objectpat
354
+ expect(:lbrace)
355
+ pairs = []
356
+ keys = []
357
+ until at?(:rbrace)
358
+ if at?(:spread)
359
+ pairs << parse_pattern_rest
360
+ advance if at?(:comma) && peek(1)&.type == :rbrace # trailing comma
361
+ unless at?(:rbrace)
362
+ raise ParseError, "in an object pattern, `...rest` must come last (at #{peek.pos})"
363
+ end
364
+ break
365
+ end
366
+ key_pos = peek.pos
367
+ pair = parse_pattern_pair
368
+ raise ParseError, "duplicate key #{pair.key.inspect} (at #{key_pos})" if keys.include?(pair.key)
369
+ keys << pair.key
370
+ pairs << pair
371
+ break unless at?(:comma)
372
+ advance
373
+ end
374
+ expect(:rbrace)
375
+ Pattern::PObj.new(pairs: pairs)
376
+ end
377
+
378
+ # p_rest = "..." [ identifier ] — the single rest binder, shared by array and
379
+ # object patterns. Callers parse it only at a rest position and then continue
380
+ # with items/pairs only, which is what holds a pattern to one rest.
381
+ def parse_pattern_rest
382
+ expect(:spread)
383
+ name = at?(:ident) ? advance.value : nil
384
+ PatternRest.new(name: name)
385
+ end
386
+
387
+ # p_pair = string ":" p_guarded
388
+ def parse_pattern_pair
389
+ key = expect(:string).value
390
+ expect(:colon)
391
+ PatternPair.new(key: key, pattern: parse_guardedpat)
392
+ end
393
+
394
+ # ---- token helpers ----
395
+ def peek(o = 0) = @toks[@i + o]
396
+ def at?(type) = peek.type == type
397
+ def advance = (@toks[@i].tap { @i += 1 })
398
+ def expect(type)
399
+ t = peek
400
+ raise ParseError, "Expected #{type} but got #{t.type} (#{t.value.inspect}) at #{t.pos}" unless t.type == type
401
+ advance
402
+ end
403
+ end
404
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Data Structure ===
4
+ #
5
+ # Array<Token> is
6
+ # - output of the lexer
7
+ # - input of the parser
8
+
9
+ require_relative "typed_data"
10
+ require_relative "atom"
11
+
12
+ module Fusion
13
+ # type: one of the lexer's token-type symbols (:number, :ident, :lparen, ...).
14
+ # value: the token's payload — a scalar for literals/keywords, the matched
15
+ # text for punctuation/identifiers, or nil for :eof.
16
+ # pos: the token's source offset.
17
+ Token = TypedData.define(
18
+ type: Symbol,
19
+ value: ->(v) { Atom === v || v.nil? },
20
+ pos: Integer,
21
+ )
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Utility ===
4
+ #
5
+ # Ruby's `Data` with `===` type per attribute. A type is anything `===`-able:
6
+ # a class (`Integer`), a regexp (`Identifier`), a marker module, or — for
7
+ # anything composite (unions, typed arrays, enums, "optional") — a `proc`.
8
+ # Mini-clone of gem `literal`.
9
+
10
+ module TypedData
11
+ def self.define(**schema)
12
+ Data.define(*schema.keys) do
13
+ define_method(:initialize) do |**kwargs|
14
+ kwargs.each_key do |key|
15
+ unless schema[key] === kwargs[key]
16
+ raise TypeError, "#{key}: expected #{schema[key]}, got #{kwargs[key].inspect} (#{kwargs[key].class})"
17
+ end
18
+ end
19
+ super(**kwargs)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,3 +1,3 @@
1
1
  module Fusion
2
- VERSION = "0.0.1.alpha1"
2
+ VERSION = "0.0.1.alpha2"
3
3
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Value ===
4
+ #
5
+ # The combination of status code and value (JSON string)
6
+
7
+ require_relative "typed_data"
8
+
9
+ module Fusion
10
+ WirePair = TypedData.define(status: ->(v) { Integer === v && [0, 1].include?(v) }, data: String)
11
+ end