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.
- checksums.yaml +4 -4
- data/README.md +12 -0
- data/Rakefile +9 -0
- data/docs/lang/design.md +204 -3
- data/docs/lang/roadmap.md +1 -22
- data/docs/user/how-to-guides.md +5 -11
- data/docs/user/reference.md +342 -128
- data/docs/user/tutorial.md +11 -10
- data/examples/ends.fsn +4 -0
- data/examples/palindrome.fsn +2 -1
- data/exe/fusion +15 -42
- data/lib/fusion/ast.rb +96 -0
- data/lib/fusion/atom.rb +17 -0
- data/lib/fusion/cli/decoder.rb +79 -0
- data/lib/fusion/cli/encoder.rb +28 -0
- data/lib/fusion/cli/options.rb +142 -0
- data/lib/fusion/cli/parser.rb +38 -0
- data/lib/fusion/cli/repl.rb +73 -0
- data/lib/fusion/cli/serializer.rb +69 -0
- data/lib/fusion/cli.rb +136 -0
- data/lib/fusion/interpreter/builtins.rb +356 -0
- data/lib/fusion/interpreter/env.rb +59 -0
- data/lib/fusion/interpreter/error_val.rb +49 -0
- data/lib/fusion/interpreter/file_thunk.rb +39 -0
- data/lib/fusion/interpreter/func.rb +22 -0
- data/lib/fusion/interpreter/native_func.rb +22 -0
- data/lib/fusion/interpreter.rb +595 -0
- data/lib/fusion/lexer.rb +183 -0
- data/lib/fusion/null.rb +9 -0
- data/lib/fusion/parser.rb +404 -0
- data/lib/fusion/token.rb +22 -0
- data/lib/fusion/typed_data.rb +23 -0
- data/lib/fusion/version.rb +1 -1
- data/lib/fusion/wire_pair.rb +11 -0
- data/lib/fusion.rb +11 -1122
- data/stdlib/map.fsn +3 -1
- data/stdlib/mapValues.fsn +5 -0
- data/stdlib/math/square.fsn +4 -1
- data/stdlib/range.fsn +2 -1
- data/stdlib/sanitize.fsn +12 -0
- metadata +26 -1
data/lib/fusion/lexer.rb
ADDED
|
@@ -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
|
data/lib/fusion/null.rb
ADDED
|
@@ -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
|
data/lib/fusion/token.rb
ADDED
|
@@ -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
|
data/lib/fusion/version.rb
CHANGED
|
@@ -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
|