rjq 0.1.0

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/rjq/lexer.rb ADDED
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rjq
4
+ Token = Struct.new(:type, :value, :line, :column, :start_offset, :end_offset, :filename, keyword_init: true)
5
+ SourceFragment = Struct.new(:source, :filename, :line, :column, :start_offset, keyword_init: true)
6
+
7
+ class Lexer
8
+ KEYWORDS = %w[
9
+ if then elif else end as def reduce foreach try catch label import include module
10
+ null true false and or not break
11
+ ].freeze
12
+
13
+ TWO_CHAR_OPERATORS = ['//', '==', '!=', '<=', '>=', '|=', '+=', '-=', '*=', '/=', '%=', '..'].freeze
14
+ THREE_CHAR_OPERATORS = ['//='].freeze
15
+ SINGLE_CHAR_TOKENS = {
16
+ '.' => :dot,
17
+ '|' => :pipe,
18
+ ',' => :comma,
19
+ '(' => :lparen,
20
+ ')' => :rparen,
21
+ '[' => :lbracket,
22
+ ']' => :rbracket,
23
+ '{' => :lbrace,
24
+ '}' => :rbrace,
25
+ ':' => :colon,
26
+ ';' => :semicolon,
27
+ '?' => :question,
28
+ '+' => :operator,
29
+ '-' => :operator,
30
+ '*' => :operator,
31
+ '/' => :operator,
32
+ '%' => :operator,
33
+ '=' => :operator,
34
+ '<' => :operator,
35
+ '>' => :operator
36
+ }.freeze
37
+
38
+ attr_reader :tokens
39
+
40
+ def initialize(source, allow_comments: true, source_name: '<top-level>', initial_line: 1, initial_column: 1,
41
+ start_offset: 0)
42
+ @source = source.to_s
43
+ @allow_comments = allow_comments
44
+ @source_name = source_name
45
+ @offset_base = start_offset
46
+ @index = 0
47
+ @line = initial_line
48
+ @column = initial_column
49
+ @tokens = []
50
+ end
51
+
52
+ def tokenize
53
+ until eof?
54
+ skip_space
55
+ break if eof?
56
+
57
+ start_line = @line
58
+ start_column = @column
59
+ start_offset = @index
60
+ char = current
61
+ token =
62
+ if char == '#'
63
+ unless @allow_comments
64
+ raise ParseError,
65
+ "comments are disabled at line #{start_line}, column #{start_column}"
66
+ end
67
+
68
+ skip_comment
69
+ next
70
+ elsif char == '"'
71
+ Token.new(type: :string, value: read_string, line: start_line, column: start_column)
72
+ elsif char == '$'
73
+ advance
74
+ Token.new(type: :variable, value: read_identifier, line: start_line, column: start_column)
75
+ elsif char == '@'
76
+ advance
77
+ Token.new(type: :format, value: read_identifier, line: start_line, column: start_column)
78
+ elsif number_start?(char) || (char == '.' && digit?(@source[@index + 1]))
79
+ Token.new(type: :number, value: read_number, line: start_line, column: start_column)
80
+ elsif identifier_start?(char)
81
+ identifier = read_identifier
82
+ type = KEYWORDS.include?(identifier) ? :keyword : :identifier
83
+ Token.new(type: type, value: identifier, line: start_line, column: start_column)
84
+ else
85
+ read_operator_or_punctuation(start_line, start_column)
86
+ end
87
+ if token
88
+ token.start_offset = @offset_base + start_offset
89
+ token.end_offset = @offset_base + @index
90
+ token.filename = @source_name
91
+ @tokens << token
92
+ end
93
+ end
94
+ offset = @offset_base + @index
95
+ @tokens << Token.new(type: :eof, value: nil, line: @line, column: @column, start_offset: offset,
96
+ end_offset: offset, filename: @source_name)
97
+ @tokens
98
+ end
99
+
100
+ private
101
+
102
+ def read_operator_or_punctuation(line, column)
103
+ three = @source[@index, 3]
104
+ if THREE_CHAR_OPERATORS.include?(three)
105
+ 3.times { advance }
106
+ return Token.new(type: :operator, value: three, line: line, column: column)
107
+ end
108
+
109
+ two = @source[@index, 2]
110
+ if TWO_CHAR_OPERATORS.include?(two)
111
+ 2.times { advance }
112
+ return Token.new(type: :operator, value: two, line: line, column: column)
113
+ end
114
+
115
+ type = SINGLE_CHAR_TOKENS[current]
116
+ raise ParseError, "unexpected character #{current.inspect} at line #{line}, column #{column}" unless type
117
+
118
+ value = current
119
+ advance
120
+ Token.new(type: type, value: value, line: line, column: column)
121
+ end
122
+
123
+ def read_string
124
+ expect('"')
125
+ segments = []
126
+ buffer = +''
127
+ until eof?
128
+ char = advance
129
+ if char == '"'
130
+ return segments.empty? ? buffer : segments_with_buffer(segments, buffer)
131
+ end
132
+
133
+ if char == '\\'
134
+ if current == '('
135
+ advance
136
+ segments << [:text, buffer] unless buffer.empty?
137
+ buffer = +''
138
+ fragment_line = @line
139
+ fragment_column = @column
140
+ fragment_offset = @offset_base + @index
141
+ fragment = SourceFragment.new(
142
+ source: read_interpolation,
143
+ filename: @source_name,
144
+ line: fragment_line,
145
+ column: fragment_column,
146
+ start_offset: fragment_offset
147
+ )
148
+ segments << [:expr, fragment]
149
+ else
150
+ buffer << read_escape
151
+ end
152
+ else
153
+ raise parse_error('unescaped control character in string') if char.ord < 0x20
154
+
155
+ buffer << char
156
+ end
157
+ end
158
+ raise parse_error('unterminated string')
159
+ end
160
+
161
+ def segments_with_buffer(segments, buffer)
162
+ segments << [:text, buffer] unless buffer.empty?
163
+ segments
164
+ end
165
+
166
+ def read_interpolation
167
+ frames = [{ type: :expression, depth: 1 }]
168
+ start = @index
169
+
170
+ until eof?
171
+ char = advance
172
+ frame = frames.last
173
+ case frame.fetch(:type)
174
+ when :comment
175
+ frames.pop if char == "\n"
176
+ when :string
177
+ if char == '\\'
178
+ if current == '('
179
+ advance
180
+ frames << { type: :expression, depth: 1 }
181
+ else
182
+ advance unless eof?
183
+ end
184
+ elsif char == frame.fetch(:quote)
185
+ frames.pop
186
+ end
187
+ when :expression
188
+ case char
189
+ when '#'
190
+ frames << { type: :comment }
191
+ when '"', "'"
192
+ frames << { type: :string, quote: char }
193
+ when '('
194
+ frame[:depth] += 1
195
+ when ')'
196
+ frame[:depth] -= 1
197
+ if frame.fetch(:depth).zero?
198
+ frames.pop
199
+ return @source[start...(@index - 1)] if frames.empty?
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ raise parse_error('unterminated interpolation')
206
+ end
207
+
208
+ def read_escape
209
+ raise parse_error('unterminated escape') if eof?
210
+
211
+ char = advance
212
+ case char
213
+ when '"', '\\', '/'
214
+ char
215
+ when 'b'
216
+ "\b"
217
+ when 'f'
218
+ "\f"
219
+ when 'n'
220
+ "\n"
221
+ when 'r'
222
+ "\r"
223
+ when 't'
224
+ "\t"
225
+ when 'u'
226
+ read_unicode_escape
227
+ else
228
+ raise parse_error("invalid escape \\#{char}")
229
+ end
230
+ end
231
+
232
+ def read_unicode_escape
233
+ codepoint = read_hex4
234
+ if codepoint.between?(0xD800, 0xDBFF)
235
+ raise parse_error('missing low surrogate') unless @source[@index, 2] == '\\u'
236
+
237
+ 2.times { advance }
238
+ low = read_hex4
239
+ raise parse_error('invalid low surrogate') unless low.between?(0xDC00, 0xDFFF)
240
+
241
+ codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (low - 0xDC00)
242
+ elsif codepoint.between?(0xDC00, 0xDFFF)
243
+ raise parse_error('unexpected low surrogate')
244
+ end
245
+ [codepoint].pack('U')
246
+ end
247
+
248
+ def read_hex4
249
+ text = @source[@index, 4]
250
+ raise parse_error('invalid unicode escape') unless text&.match?(/\A[0-9a-fA-F]{4}\z/)
251
+
252
+ 4.times { advance }
253
+ text.to_i(16)
254
+ end
255
+
256
+ def read_number
257
+ start = @index
258
+ leading_decimal = current == '.'
259
+ advance if leading_decimal
260
+ advance while digit?(current)
261
+ if !leading_decimal && current == '.'
262
+ advance
263
+ advance while digit?(current)
264
+ end
265
+ if %w[e E].include?(current)
266
+ advance
267
+ advance if ['+', '-'].include?(current)
268
+ advance while digit?(current)
269
+ end
270
+ text = @source[start...@index]
271
+ text = "0#{text}" if leading_decimal
272
+ Number.parse(text)
273
+ rescue ArgumentError
274
+ raise parse_error('invalid number')
275
+ end
276
+
277
+ def read_identifier
278
+ start = @index
279
+ raise parse_error('expected identifier') unless identifier_start?(current)
280
+
281
+ advance while identifier_part?(current)
282
+ @source[start...@index]
283
+ end
284
+
285
+ def skip_space
286
+ loop do
287
+ advance while current&.match?(/[ \t\r\n]/)
288
+ break unless @allow_comments && current == '#'
289
+
290
+ skip_comment
291
+ end
292
+ end
293
+
294
+ def skip_comment
295
+ advance until eof? || current == "\n"
296
+ end
297
+
298
+ def number_start?(char)
299
+ digit?(char)
300
+ end
301
+
302
+ def identifier_start?(char)
303
+ !char.nil? && char.match?(/[A-Za-z_]/)
304
+ end
305
+
306
+ def identifier_part?(char)
307
+ !char.nil? && char.match?(/[A-Za-z0-9_]/)
308
+ end
309
+
310
+ def digit?(char)
311
+ !char.nil? && char >= '0' && char <= '9'
312
+ end
313
+
314
+ def expect(char)
315
+ raise parse_error("expected #{char}") unless current == char
316
+
317
+ advance
318
+ end
319
+
320
+ def current
321
+ @source[@index]
322
+ end
323
+
324
+ def eof?
325
+ @index >= @source.length
326
+ end
327
+
328
+ def advance
329
+ char = @source[@index]
330
+ @index += char.length
331
+ if char == "\n"
332
+ @line += 1
333
+ @column = 1
334
+ else
335
+ @column += 1
336
+ end
337
+ char
338
+ end
339
+
340
+ def parse_error(message)
341
+ ParseError.new("#{message} at #{@source_name}, line #{@line}, column #{@column}")
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle/import'
4
+
5
+ module Rjq
6
+ module MathFunctions
7
+ CANDIDATE_LIBRARIES = [
8
+ '/usr/lib/libSystem.B.dylib',
9
+ 'libm.so.6',
10
+ 'libm.so',
11
+ 'libm.dylib'
12
+ ].freeze
13
+ NATIVE_SIGNATURES = {
14
+ fma: 'double fma(double, double, double)',
15
+ remainder: 'double remainder(double, double)',
16
+ scalb: 'double scalb(double, double)',
17
+ scalbln: 'double scalbln(double, long)'
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def bessel(name, *values)
23
+ if %w[jn yn].include?(name)
24
+ bessel_library.public_send(name, values.fetch(0).to_i, values.fetch(1).to_f)
25
+ else
26
+ bessel_library.public_send(name, values.fetch(0).to_f)
27
+ end
28
+ end
29
+
30
+ def fma(left, right, addend)
31
+ library = native_library(:fma)
32
+ return library.fma(left.to_f, right.to_f, addend.to_f) if library
33
+
34
+ raise Rjq::RuntimeError, 'native fused multiply-add is not available on this platform'
35
+ end
36
+
37
+ def remainder(left, right)
38
+ library = native_library(:remainder)
39
+ return library.remainder(left.to_f, right.to_f) if library
40
+
41
+ raise Rjq::RuntimeError, 'native IEEE remainder is not available on this platform'
42
+ end
43
+
44
+ def scalb(value, exponent)
45
+ library = native_library(:scalb)
46
+ if library
47
+ result = library.scalb(value.to_f, exponent.to_f)
48
+ return signed_max(value.to_f) if result.infinite?
49
+
50
+ return result
51
+ end
52
+
53
+ exponent = exponent.to_f
54
+ return Float::NAN if exponent.nan?
55
+ return portable_scale(value.to_f, exponent.positive? ? 10_000 : -10_000) if exponent.infinite?
56
+
57
+ portable_scale(value.to_f, exponent.to_i)
58
+ end
59
+
60
+ def scalbln(value, exponent)
61
+ exponent = exponent.to_f
62
+ if RUBY_PLATFORM.include?('linux') && !exponent.finite?
63
+ # jq's Linux build passes non-finite values through a C long
64
+ # conversion, which yields zero on the supported libc targets.
65
+ return 0.0
66
+ end
67
+
68
+ integral_exponent = c_long_exponent(exponent)
69
+ library = native_library(:scalbln)
70
+ if library
71
+ result = library.scalbln(value.to_f, integral_exponent)
72
+ return signed_max(value.to_f) if result.infinite?
73
+
74
+ return result
75
+ end
76
+
77
+ portable_scale(value.to_f, integral_exponent)
78
+ end
79
+
80
+ def native_available?(name)
81
+ !native_library(name).nil?
82
+ end
83
+
84
+ def native_library(name)
85
+ @native_libraries ||= {}
86
+ return @native_libraries[name] if @native_libraries.key?(name)
87
+
88
+ signature = NATIVE_SIGNATURES.fetch(name)
89
+ @native_libraries[name] = CANDIDATE_LIBRARIES.lazy.filter_map do |library|
90
+ build_native_library(library, signature)
91
+ rescue Fiddle::DLError
92
+ nil
93
+ end.first
94
+ end
95
+
96
+ def build_native_library(library, signature)
97
+ Module.new do
98
+ extend Fiddle::Importer
99
+
100
+ dlload library
101
+ extern signature
102
+ end
103
+ end
104
+
105
+ def c_long_exponent(value)
106
+ bits = Fiddle::SIZEOF_LONG * 8
107
+ minimum = -(1 << (bits - 1))
108
+ maximum = (1 << (bits - 1)) - 1
109
+ return 0 if value.nan?
110
+ return value.positive? ? maximum : minimum if value.infinite?
111
+
112
+ [[value.to_i, minimum].max, maximum].min
113
+ end
114
+
115
+ def portable_scale(value, exponent)
116
+ return value if value.zero?
117
+ return signed_max(value) if value.infinite?
118
+
119
+ _, value_exponent = Math.frexp(value.abs)
120
+ target_exponent = value_exponent + exponent
121
+ return signed_max(value) if target_exponent > 1024
122
+ return value.negative? ? -0.0 : 0.0 if target_exponent < -1074
123
+
124
+ result = Math.ldexp(value, exponent)
125
+ result.infinite? ? signed_max(value) : result
126
+ end
127
+
128
+ def signed_max(value)
129
+ value.negative? ? -Float::MAX : Float::MAX
130
+ end
131
+
132
+ def bessel_library
133
+ @bessel_library ||= load_bessel_library
134
+ end
135
+
136
+ def bessel_available?
137
+ bessel_library
138
+ true
139
+ rescue Rjq::RuntimeError
140
+ false
141
+ end
142
+
143
+ def load_bessel_library
144
+ errors = []
145
+ CANDIDATE_LIBRARIES.each do |library|
146
+ return build_bessel_library(library)
147
+ rescue Fiddle::DLError => e
148
+ errors << "#{library}: #{e.message}"
149
+ end
150
+
151
+ raise Rjq::RuntimeError, "C math library with Bessel functions is not available (#{errors.join('; ')})"
152
+ end
153
+
154
+ def build_bessel_library(library)
155
+ Module.new do
156
+ extend Fiddle::Importer
157
+
158
+ dlload library
159
+ extern 'double j0(double)'
160
+ extern 'double j1(double)'
161
+ extern 'double y0(double)'
162
+ extern 'double y1(double)'
163
+ extern 'double jn(int, double)'
164
+ extern 'double yn(int, double)'
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rjq
4
+ class ModuleLoader
5
+ Result = Struct.new(:program, :metadata, :variables, keyword_init: true)
6
+ MAX_DEPTH = 64
7
+
8
+ def initialize(resolver, allow_comments: true, max_filter_depth: Parser::DEFAULT_MAX_FILTER_DEPTH)
9
+ @resolver = resolver
10
+ @cache = {}
11
+ @allow_comments = allow_comments
12
+ @max_filter_depth = max_filter_depth
13
+ end
14
+
15
+ def load(program, source_path: nil, stack: [])
16
+ raise CompileError, "module import depth exceeds #{MAX_DEPTH}" if stack.length >= MAX_DEPTH
17
+ program.directives.select { |directive| directive.type == :module }.each do |directive|
18
+ ConstantEvaluator.evaluate_object(directive.metadata)
19
+ end
20
+
21
+ definitions = []
22
+ variables = {}
23
+ metadata = @resolver.initial_metadata.dup
24
+ program.directives.each do |directive|
25
+ next if directive.type == :module
26
+
27
+ options = directive.metadata ? ConstantEvaluator.evaluate_object(directive.metadata) : {}
28
+ data = directive.alias_name&.start_with?('$') || false
29
+ source = @resolver.resolve(directive.name, from: source_path, metadata: options, data: data)
30
+ raise CompileError, "circular module import: #{directive.name}" if stack.include?(source.path)
31
+
32
+ if data
33
+ value = JSON::Parser.parse_one(source.content)
34
+ name = directive.alias_name.delete_prefix('$')
35
+ variables[name] = value
36
+ definitions << AST::FunctionDefinition.new("#{name}::#{name}", [], AST::Literal.new(value))
37
+ metadata[directive.name] ||= data_metadata
38
+ next
39
+ end
40
+
41
+ loaded, parsed = @cache[source.path]
42
+ unless loaded
43
+ parsed = Parser.new(source.content, source_name: source.path, allow_comments: @allow_comments,
44
+ max_filter_depth: @max_filter_depth).parse
45
+ loaded = load(parsed, source_path: source.path, stack: stack + [source.path])
46
+ @cache[source.path] = [loaded, parsed]
47
+ end
48
+ metadata.merge!(loaded.metadata)
49
+ metadata[directive.name] = metadata_for(parsed)
50
+ variables.merge!(loaded.variables)
51
+ imported = loaded.program.definitions
52
+ imported = namespace_definitions(imported, directive.alias_name) if directive.type == :import
53
+ definitions.concat(imported)
54
+ end
55
+ definitions.concat(program.definitions)
56
+ Result.new(
57
+ program: AST::Program.new(program.body, definitions, []),
58
+ metadata: metadata,
59
+ variables: variables
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def metadata_for(program)
66
+ declaration = program.directives.find { |directive| directive.type == :module }
67
+ object = declaration ? ConstantEvaluator.evaluate_object(declaration.metadata) : { 'whatever' => nil }
68
+ object.merge(
69
+ 'deps' => program.directives.filter_map { |directive| dependency_metadata(directive) },
70
+ 'defs' => program.definitions.map { |definition| "#{definition.name}/#{definition.params.length}" }
71
+ )
72
+ end
73
+
74
+ def dependency_metadata(directive)
75
+ return if directive.type == :module
76
+
77
+ data = directive.alias_name&.start_with?('$') || false
78
+ metadata = { 'is_data' => data, 'relpath' => directive.name }
79
+ metadata.merge!(ConstantEvaluator.evaluate_object(directive.metadata)) if directive.metadata
80
+ metadata['as'] = directive.alias_name.delete_prefix('$') if directive.alias_name
81
+ metadata['as'] ||= File.basename(directive.name) if directive.type == :include
82
+ metadata
83
+ end
84
+
85
+ def data_metadata
86
+ { 'whatever' => nil, 'deps' => [], 'defs' => [] }
87
+ end
88
+
89
+ def namespace_definitions(definitions, namespace)
90
+ cloned = Marshal.load(Marshal.dump(definitions))
91
+ signatures = cloned.to_h { |definition| [[definition.name, definition.params.length], true] }
92
+ cloned.each do |definition|
93
+ namespace_calls(definition.body, namespace, signatures)
94
+ definition.instance_variable_set(:@name, "#{namespace}::#{definition.name}")
95
+ end
96
+ cloned
97
+ end
98
+
99
+ def namespace_calls(value, namespace, signatures)
100
+ if value.is_a?(AST::FunctionCall)
101
+ name = value.name
102
+ value.instance_variable_set(:@name, "#{namespace}::#{name}") if signatures[[name, value.args.length]]
103
+ end
104
+ nested_values(value).each { |child| namespace_calls(child, namespace, signatures) }
105
+ end
106
+
107
+ def nested_values(value)
108
+ case value
109
+ when AST::Node
110
+ value.instance_variables.map { |name| value.instance_variable_get(name) }
111
+ when Array
112
+ value
113
+ when Hash
114
+ value.values
115
+ when Struct
116
+ value.to_a
117
+ else
118
+ []
119
+ end
120
+ end
121
+ end
122
+
123
+ class ConstantEvaluator
124
+ class << self
125
+ def evaluate_object(node)
126
+ value = evaluate(node)
127
+ raise CompileError, 'Module metadata must be an object' unless value.is_a?(Hash)
128
+
129
+ value
130
+ end
131
+
132
+ def evaluate(node)
133
+ case node
134
+ when AST::Literal
135
+ Value.deep_copy(node.value)
136
+ when AST::StringLiteral
137
+ raise CompileError, 'Module metadata must be constant' unless node.value.is_a?(String)
138
+
139
+ node.value
140
+ when AST::ArrayLiteral
141
+ expression = node.instance_variable_get(:@expression)
142
+ expression ? evaluate_sequence(expression) : []
143
+ when AST::ObjectLiteral
144
+ evaluate_object_literal(node)
145
+ when AST::UnaryOp
146
+ evaluate_unary(node)
147
+ else
148
+ raise CompileError, 'Module metadata must be constant'
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def evaluate_sequence(node)
155
+ return evaluate_sequence(node.left) + evaluate_sequence(node.right) if node.is_a?(AST::Comma)
156
+
157
+ [evaluate(node)]
158
+ end
159
+
160
+ def evaluate_object_literal(node)
161
+ node.instance_variable_get(:@pairs).to_h do |pair|
162
+ key = pair.key
163
+ raise CompileError, 'Module metadata must be constant' unless key.is_a?(String)
164
+
165
+ [key, evaluate(pair.value)]
166
+ end
167
+ end
168
+
169
+ def evaluate_unary(node)
170
+ op = node.instance_variable_get(:@op)
171
+ value = evaluate(node.instance_variable_get(:@expression))
172
+ raise CompileError, 'Module metadata must be constant' unless op == '-' && value.is_a?(Numeric)
173
+
174
+ -value
175
+ end
176
+ end
177
+ end
178
+ end