fhirpath 0.2.0.pre1

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,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module FHIRPath
6
+ class Token
7
+ attr_reader :type, :value, :span
8
+
9
+ def initialize(type:, value:, span:)
10
+ @type = type
11
+ @value = value
12
+ @span = span
13
+ freeze
14
+ end
15
+ end
16
+
17
+ class Lexer
18
+ SINGLE = {
19
+ '.' => :dot, '[' => :left_bracket, ']' => :right_bracket,
20
+ '(' => :left_paren, ')' => :right_paren, ',' => :comma,
21
+ '{' => :left_brace, '}' => :right_brace,
22
+ '=' => :equals, '+' => :plus, '-' => :minus,
23
+ '*' => :multiply, '/' => :divide,
24
+ '<' => :less_than, '>' => :greater_than,
25
+ '|' => :union, '&' => :concatenate, '~' => :equivalent
26
+ }.freeze
27
+
28
+ TWO_CHARACTER = {
29
+ '!=' => :not_equals, '<=' => :less_or_equal, '>=' => :greater_or_equal,
30
+ '!~' => :not_equivalent
31
+ }.freeze
32
+
33
+ KEYWORD_OPERATORS = %w[and or xor implies div mod in contains is as].freeze
34
+
35
+ def initialize(source)
36
+ @source = source
37
+ @index = 0
38
+ end
39
+
40
+ def tokens
41
+ result = []
42
+ until eof?
43
+ skip_ignored
44
+ break if eof?
45
+
46
+ result << next_token
47
+ end
48
+ result << Token.new(type: :eof, value: nil,
49
+ span: SourceSpan.new(offset: @index, length: 0))
50
+ end
51
+
52
+ private
53
+
54
+ def eof?
55
+ @index >= @source.length
56
+ end
57
+
58
+ def skip_ignored
59
+ loop do
60
+ @index += 1 while !eof? && @source[@index] =~ /\s/
61
+ return if eof?
62
+
63
+ if @source[@index, 2] == '//'
64
+ @index += 2
65
+ @index += 1 while !eof? && @source[@index] != "\n"
66
+ elsif @source[@index, 2] == '/*'
67
+ start = @index
68
+ closing = @source.index('*/', @index + 2)
69
+ raise_error('unterminated block comment', start) unless closing
70
+
71
+ @index = closing + 2
72
+ else
73
+ return
74
+ end
75
+ end
76
+ end
77
+
78
+ def next_token
79
+ start = @index
80
+ two_character = @source[start, 2]
81
+ if TWO_CHARACTER.key?(two_character)
82
+ @index += 2
83
+ return token(TWO_CHARACTER.fetch(two_character), two_character, start)
84
+ end
85
+
86
+ char = @source[@index]
87
+ if SINGLE.key?(char)
88
+ @index += 1
89
+ return token(SINGLE.fetch(char), char, start)
90
+ end
91
+ return string_token(start) if char == "'"
92
+ return external_token(start) if char == '%'
93
+ return variable_token(start) if char == '$'
94
+ return number_token(start) if char =~ /[0-9]/
95
+ return identifier_token(start) if char =~ /[A-Za-z_]/
96
+
97
+ raise_error("unexpected character #{char.inspect}", start)
98
+ end
99
+
100
+ def string_token(start)
101
+ @index += 1
102
+ value = +''
103
+ until eof?
104
+ char = @source[@index]
105
+ @index += 1
106
+ return token(:string, value, start) if char == "'"
107
+
108
+ if char == '\\'
109
+ raise_error('unterminated string escape', @index - 1) if eof?
110
+
111
+ value << escaped_character
112
+ else
113
+ value << char
114
+ end
115
+ end
116
+ raise_error('unterminated string literal', start)
117
+ end
118
+
119
+ def escaped_character
120
+ escaped = @source[@index]
121
+ @index += 1
122
+ simple = { 'b' => "\b", 'f' => "\f", 'n' => "\n", 'r' => "\r",
123
+ 't' => "\t", '\\' => '\\', "'" => "'", '/' => '/' }
124
+ return simple.fetch(escaped) if simple.key?(escaped)
125
+ return unicode_escape if escaped == 'u'
126
+
127
+ raise_error("unsupported string escape \\#{escaped}", @index - 1, code: :invalid_escape)
128
+ end
129
+
130
+ def unicode_escape
131
+ length = 4
132
+ digits = @source[@index, length]
133
+ unless digits&.match?(/\A[0-9A-Fa-f]{#{length}}\z/)
134
+ raise_error('invalid unicode escape', @index - 1, code: :invalid_escape)
135
+ end
136
+
137
+ @index += length
138
+ codepoint = digits.to_i(16)
139
+ if codepoint.between?(0xD800, 0xDBFF)
140
+ unless @source[@index, 2] == '\\u'
141
+ raise_error('high surrogate must be followed by a low surrogate', @index, code: :invalid_escape)
142
+ end
143
+
144
+ @index += 2
145
+ low_digits = @source[@index, length]
146
+ unless low_digits&.match?(/\A[0-9A-Fa-f]{#{length}}\z/)
147
+ raise_error('invalid unicode surrogate pair', @index - 2, code: :invalid_escape)
148
+ end
149
+
150
+ low = low_digits.to_i(16)
151
+ unless low.between?(0xDC00, 0xDFFF)
152
+ raise_error('high surrogate must be followed by a low surrogate', @index, code: :invalid_escape)
153
+ end
154
+
155
+ @index += length
156
+ codepoint = 0x10000 + ((codepoint - 0xD800) * 0x400) + (low - 0xDC00)
157
+ elsif codepoint.between?(0xDC00, 0xDFFF)
158
+ raise_error('low surrogate must follow a high surrogate', @index - length, code: :invalid_escape)
159
+ end
160
+
161
+ [codepoint].pack('U')
162
+ rescue RangeError
163
+ raise_error('invalid unicode code point', @index - length, code: :invalid_escape)
164
+ end
165
+
166
+ def external_token(start)
167
+ @index += 1
168
+ name_start = @index
169
+ @index += 1 while !eof? && @source[@index] =~ /[A-Za-z0-9_]/
170
+ raise_error('external constant requires a name', start) if name_start == @index
171
+
172
+ token(:external, @source[name_start...@index], start)
173
+ end
174
+
175
+ def variable_token(start)
176
+ @index += 1
177
+ name_start = @index
178
+ @index += 1 while !eof? && @source[@index] =~ /[A-Za-z0-9_]/
179
+ raise_error('variable requires a name', start) if name_start == @index
180
+
181
+ token(:variable, @source[name_start...@index], start)
182
+ end
183
+
184
+ def number_token(start)
185
+ @index += 1 while !eof? && @source[@index] =~ /[0-9]/
186
+ type = :integer
187
+ if !eof? && @source[@index] == '.' && @source[@index + 1] =~ /[0-9]/
188
+ type = :decimal
189
+ @index += 1
190
+ @index += 1 while !eof? && @source[@index] =~ /[0-9]/
191
+ end
192
+ if !eof? && @source[@index] =~ /[eE]/
193
+ type = :decimal
194
+ @index += 1
195
+ @index += 1 if !eof? && @source[@index] =~ /[+-]/
196
+ exponent_start = @index
197
+ @index += 1 while !eof? && @source[@index] =~ /[0-9]/
198
+ raise_error('decimal exponent requires digits', exponent_start) if exponent_start == @index
199
+ end
200
+ text = @source[start...@index]
201
+ value = type == :integer ? text.to_i : BigDecimal(text)
202
+ token(type, value, start)
203
+ end
204
+
205
+ def identifier_token(start)
206
+ @index += 1 while !eof? && @source[@index] =~ /[A-Za-z0-9_]/
207
+ text = @source[start...@index]
208
+ return token(:operator, text.to_sym, start) if KEYWORD_OPERATORS.include?(text)
209
+
210
+ token(:identifier, text, start)
211
+ end
212
+
213
+ def token(type, value, start)
214
+ Token.new(type: type, value: value,
215
+ span: SourceSpan.new(offset: start, length: @index - start))
216
+ end
217
+
218
+ def raise_error(message, start, code: :invalid_token)
219
+ raise ParseError.new(message, code: code,
220
+ span: SourceSpan.new(offset: start, length: [@index - start, 1].max),
221
+ expression: @source)
222
+ end
223
+ end
224
+
225
+ class ParsedExpression
226
+ # Maximum AST depth allowed when building the source map. This is the same
227
+ # budget used by the Parser for recursion; keeping both in sync ensures a
228
+ # parse that succeeds will not then overflow during source-map traversal.
229
+ MAX_NESTING_DEPTH = 256
230
+
231
+ attr_reader :source, :ast, :source_map
232
+
233
+ def initialize(source:, ast:)
234
+ @source = source.freeze
235
+ @ast = ast
236
+ @source_map = build_source_map(ast).freeze
237
+ freeze
238
+ end
239
+
240
+ private
241
+
242
+ def build_source_map(node, result = {}, depth = 0)
243
+ return result unless node.is_a?(AST::Node)
244
+
245
+ if depth > MAX_NESTING_DEPTH
246
+ raise ParseError.new(
247
+ "expression nesting exceeds the maximum depth of #{MAX_NESTING_DEPTH}",
248
+ code: :nesting_depth_exceeded,
249
+ span: node.span,
250
+ expression: @source
251
+ )
252
+ end
253
+
254
+ result[node] = node.span
255
+ node.instance_variables.each do |name|
256
+ value = node.instance_variable_get(name)
257
+ if value.is_a?(Array)
258
+ value.each { |child| build_source_map(child, result, depth + 1) }
259
+ else
260
+ build_source_map(value, result, depth + 1)
261
+ end
262
+ end
263
+ result
264
+ end
265
+ end
266
+
267
+ class Parser
268
+ PRECEDENCE = {
269
+ implies: 1,
270
+ or: 2, xor: 2,
271
+ and: 3,
272
+ in: 4, contains: 4,
273
+ equals: 5, not_equals: 5, equivalent: 5, not_equivalent: 5,
274
+ less_than: 6, less_or_equal: 6, greater_than: 6, greater_or_equal: 6,
275
+ union: 7,
276
+ is: 8, as: 8,
277
+ plus: 9, minus: 9, concatenate: 9,
278
+ multiply: 10, divide: 10, div: 10, mod: 10
279
+ }.freeze
280
+
281
+ BINARY_TOKENS = PRECEDENCE.keys.freeze
282
+
283
+ # Maximum recursion depth for the recursive-descent parser. Deeply nested
284
+ # expressions (parentheses, collections, function calls, indexers, unary
285
+ # chains, and deep precedence trees) recurse on the Ruby call stack; without
286
+ # a budget the process can exhaust the stack and raise SystemStackError,
287
+ # escaping the FHIRPath::Error boundary. Beyond this depth parsing fails
288
+ # with a structured ParseError (code :nesting_depth_exceeded) instead.
289
+ MAX_NESTING_DEPTH = 256
290
+
291
+ def self.parse(source, capability: Capability.current)
292
+ new(source, capability: capability).parse
293
+ end
294
+
295
+ def initialize(source, capability:)
296
+ # Duplicate so we never retain the caller's own String identity: the
297
+ # compiled program is then immutable via a frozen internal snapshot
298
+ # without freezing the caller-owned source string.
299
+ @source = source.to_s.dup
300
+ @capability = capability
301
+ @tokens = Lexer.new(@source).tokens
302
+ @position = 0
303
+ @nesting_depth = 0
304
+ end
305
+
306
+ def parse
307
+ fail_parse('expression cannot be empty', :empty_expression) if current.type == :eof
308
+
309
+ ast = parse_expression(0)
310
+ fail_parse('trailing input after expression', :trailing_input) unless current.type == :eof
311
+
312
+ ParsedExpression.new(source: @source, ast: ast)
313
+ end
314
+
315
+ private
316
+
317
+ def parse_expression(min_precedence)
318
+ with_nesting do
319
+ left = parse_unary
320
+ while (precedence = binary_precedence(current)) && precedence >= min_precedence
321
+ operator_token = advance
322
+ right = parse_expression(precedence + 1)
323
+ left = AST::BinaryExpression.new(left: left, operator: operator_for(operator_token),
324
+ right: right,
325
+ span: span_between(left.span, right.span))
326
+ end
327
+ left
328
+ end
329
+ end
330
+
331
+ def parse_unary
332
+ return parse_postfix(parse_primary) unless %i[plus minus].include?(current.type)
333
+
334
+ operator = advance
335
+ operand = with_nesting { parse_unary }
336
+ AST::UnaryExpression.new(operator: operator.type,
337
+ operand: operand,
338
+ span: span_between(operator.span, operand.span))
339
+ end
340
+
341
+ def parse_primary
342
+ token = advance
343
+ node = case token.type
344
+ when :string, :integer, :decimal
345
+ AST::Literal.new(value: token.value, span: token.span)
346
+ when :identifier
347
+ if %w[true false].include?(token.value)
348
+ AST::Literal.new(value: token.value == 'true', span: token.span)
349
+ else
350
+ AST::Identifier.new(name: token.value, span: token.span)
351
+ end
352
+ when :variable
353
+ AST::Variable.new(name: token.value, span: token.span)
354
+ when :external
355
+ AST::ExternalConstant.new(name: token.value, span: token.span)
356
+ when :left_brace
357
+ parse_collection(token)
358
+ when :left_paren
359
+ inner = parse_expression(0)
360
+ expect(:right_paren)
361
+ inner
362
+ else
363
+ fail_parse("unexpected token #{token.type}", :unexpected_token, token.span)
364
+ end
365
+
366
+ parse_postfix(node)
367
+ end
368
+
369
+ def parse_collection(opening)
370
+ elements = []
371
+ unless current.type == :right_brace
372
+ loop do
373
+ elements << parse_expression(0)
374
+ break unless current.type == :comma
375
+
376
+ advance
377
+ fail_parse('collection item expected after comma', :unexpected_token) if current.type == :right_brace
378
+ end
379
+ end
380
+ closing = expect(:right_brace)
381
+ AST::CollectionLiteral.new(elements: elements,
382
+ span: span_between(opening.span, closing.span))
383
+ end
384
+
385
+ def parse_postfix(node)
386
+ loop do
387
+ case current.type
388
+ when :dot
389
+ advance
390
+ name = expect(:identifier)
391
+ node = if current.type == :left_paren
392
+ parse_function(node, name)
393
+ else
394
+ AST::MemberInvocation.new(receiver: node, name: name.value,
395
+ span: span_between(node.span, name.span))
396
+ end
397
+ when :left_bracket
398
+ advance
399
+ index = parse_expression(0)
400
+ closing = expect(:right_bracket)
401
+ node = AST::Indexer.new(receiver: node, index: index,
402
+ span: span_between(node.span, closing.span))
403
+ when :left_paren
404
+ unless node.is_a?(AST::Identifier)
405
+ fail_parse('only a function name may be invoked', :unexpected_token,
406
+ current.span)
407
+ end
408
+
409
+ node = parse_function(nil, node)
410
+ else
411
+ break
412
+ end
413
+ end
414
+ node
415
+ end
416
+
417
+ def parse_function(receiver, name_token)
418
+ name = name_token.is_a?(AST::Identifier) ? name_token.name : name_token.value
419
+ start = receiver ? receiver.span : name_token.span
420
+ expect(:left_paren)
421
+ arguments = []
422
+ unless current.type == :right_paren
423
+ loop do
424
+ arguments << parse_expression(0)
425
+ break unless current.type == :comma
426
+
427
+ advance
428
+ end
429
+ end
430
+ closing = expect(:right_paren)
431
+ AST::FunctionInvocation.new(receiver: receiver, name: name,
432
+ arguments: arguments,
433
+ span: span_between(start, closing.span))
434
+ end
435
+
436
+ def binary_precedence(token)
437
+ return PRECEDENCE[token.type] if BINARY_TOKENS.include?(token.type)
438
+ return PRECEDENCE[token.value] if token.type == :operator && PRECEDENCE.key?(token.value)
439
+
440
+ nil
441
+ end
442
+
443
+ def operator_for(token)
444
+ token.type == :operator ? token.value : token.type
445
+ end
446
+
447
+ def expect(type)
448
+ return advance if current.type == type
449
+
450
+ fail_parse("expected #{type}, got #{current.type}", :unexpected_token, current.span)
451
+ end
452
+
453
+ def current
454
+ @tokens[@position]
455
+ end
456
+
457
+ def advance
458
+ token = current
459
+ @position += 1 unless token.type == :eof
460
+ token
461
+ end
462
+
463
+ def span_between(first, last)
464
+ SourceSpan.new(offset: first.offset, length: last.end_offset - first.offset)
465
+ end
466
+
467
+ def fail_parse(message, code, span = current.span)
468
+ raise ParseError.new(message, code: code, span: span, expression: @source)
469
+ end
470
+
471
+ def with_nesting
472
+ @nesting_depth += 1
473
+ nesting_exceeded if @nesting_depth > MAX_NESTING_DEPTH
474
+ yield
475
+ ensure
476
+ @nesting_depth -= 1
477
+ end
478
+
479
+ def nesting_exceeded
480
+ fail_parse(
481
+ "expression nesting exceeds the maximum depth of #{MAX_NESTING_DEPTH}",
482
+ :nesting_depth_exceeded,
483
+ current.span
484
+ )
485
+ end
486
+ end
487
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FHIRPath
4
+ # A half-open character range in an expression source string.
5
+ class SourceSpan
6
+ attr_reader :offset, :length
7
+
8
+ def initialize(offset:, length:)
9
+ raise ArgumentError, 'offset must be non-negative' unless offset.is_a?(Integer) && offset >= 0
10
+ raise ArgumentError, 'length must be non-negative' unless length.is_a?(Integer) && length >= 0
11
+
12
+ @offset = offset
13
+ @length = length
14
+ freeze
15
+ end
16
+
17
+ def end_offset
18
+ offset + length
19
+ end
20
+
21
+ def to_h
22
+ { offset: offset, length: length, end_offset: end_offset }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module FHIRPath
6
+ # Logical type metadata retained independently of the Ruby runtime class.
7
+ class TypeInfo
8
+ attr_reader :logical_type, :namespace, :model_path, :runtime_class
9
+
10
+ def initialize(logical_type:, namespace: nil, model_path: nil, runtime_class: nil)
11
+ @logical_type = logical_type.to_s.freeze
12
+ @namespace = namespace&.to_s&.freeze
13
+ @model_path = model_path&.to_s&.freeze
14
+ @runtime_class = runtime_class
15
+ freeze
16
+ end
17
+
18
+ def to_h
19
+ { logical_type: logical_type, namespace: namespace,
20
+ model_path: model_path, runtime_class: runtime_class }
21
+ end
22
+ end
23
+
24
+ module Value
25
+ class Base
26
+ attr_reader :value, :type_info
27
+
28
+ def initialize(value, type_info: nil)
29
+ @value = value
30
+ @type_info = type_info || self.class.default_type_info
31
+ freeze
32
+ end
33
+
34
+ def self.default_type_info
35
+ TypeInfo.new(logical_type: name.split('::').last.downcase)
36
+ end
37
+
38
+ def to_ruby
39
+ value
40
+ end
41
+
42
+ def ==(other)
43
+ other.is_a?(self.class) && other.value == value
44
+ end
45
+ alias eql? ==
46
+
47
+ def hash
48
+ [self.class, value].hash
49
+ end
50
+ end
51
+
52
+ class String < Base
53
+ def initialize(value, **kwargs)
54
+ super(value.to_s, **kwargs)
55
+ end
56
+ end
57
+
58
+ class Integer < Base
59
+ def initialize(value, **kwargs)
60
+ raise ArgumentError, 'FHIRPath integer must be an Integer' unless value.is_a?(::Integer)
61
+
62
+ super(value, **kwargs)
63
+ end
64
+ end
65
+
66
+ class Decimal < Base
67
+ def initialize(value, **kwargs)
68
+ decimal = value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
69
+ super(decimal, **kwargs)
70
+ end
71
+ end
72
+ end
73
+ end