tracekit 0.2.2 → 0.2.4

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,604 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracekit
4
+ # Expression evaluator for the TraceKit portable expression subset.
5
+ # Evaluates breakpoint conditions locally to avoid server round-trips.
6
+ #
7
+ # Uses a custom recursive-descent parser -- never eval()/instance_eval.
8
+ # Supports: comparison, logical, arithmetic, string concat, property access,
9
+ # bracket notation, membership (in), null safety, and grouping.
10
+ module Evaluator
11
+ class UnsupportedExpressionError < StandardError; end
12
+
13
+ # Returns true if the expression can be evaluated locally by the SDK.
14
+ # Returns false for expressions containing function calls, regex operators,
15
+ # assignment, array indexing, ternary, range, template literals, or bitwise operators.
16
+ def self.sdk_evaluable?(expression)
17
+ return true if expression.nil? || expression.strip.empty?
18
+
19
+ # Function calls: word followed by opening paren
20
+ return false if expression.match?(/\b[a-zA-Z_]\w*\s*\(/)
21
+
22
+ # Regex match keyword
23
+ return false if expression.match?(/\bmatches\b/)
24
+
25
+ # Regex operator =~
26
+ return false if expression.include?("=~")
27
+
28
+ # Bitwise NOT ~ (but not inside =~, already handled above)
29
+ expression.each_char.with_index do |ch, i|
30
+ if ch == "~" && (i == 0 || expression[i - 1] != "=")
31
+ return false
32
+ end
33
+ end
34
+
35
+ # Bitwise AND: single & not part of &&
36
+ i = 0
37
+ while i < expression.length
38
+ if expression[i] == "&"
39
+ if i + 1 < expression.length && expression[i + 1] == "&"
40
+ i += 2
41
+ next
42
+ end
43
+ return false
44
+ end
45
+ i += 1
46
+ end
47
+
48
+ # Bitwise OR: single | not part of ||
49
+ i = 0
50
+ while i < expression.length
51
+ if expression[i] == "|"
52
+ if i + 1 < expression.length && expression[i + 1] == "|"
53
+ i += 2
54
+ next
55
+ end
56
+ return false
57
+ end
58
+ i += 1
59
+ end
60
+
61
+ # Bit shift
62
+ return false if expression.include?("<<") || expression.include?(">>")
63
+
64
+ # Template literals
65
+ return false if expression.include?("${")
66
+
67
+ # Range operator
68
+ return false if expression.include?("..")
69
+
70
+ # Ternary
71
+ return false if expression.include?("?")
72
+
73
+ # Array indexing [N]
74
+ return false if expression.match?(/\[\d/)
75
+
76
+ # Compound assignment
77
+ return false if expression.match?(/[+\-*\/]=/)
78
+
79
+ true
80
+ end
81
+
82
+ # Evaluates an expression string against the given environment
83
+ # and returns a boolean result. Empty expressions return true.
84
+ # Raises UnsupportedExpressionError for server-only expressions.
85
+ def self.evaluate_condition(expression, env)
86
+ return true if expression.nil? || expression.strip.empty?
87
+
88
+ unless sdk_evaluable?(expression)
89
+ raise UnsupportedExpressionError, "unsupported expression: requires server-side evaluation"
90
+ end
91
+
92
+ result = evaluate_expression(expression, env)
93
+
94
+ case result
95
+ when true, false
96
+ result
97
+ when nil
98
+ false
99
+ else
100
+ # Non-boolean result from a condition -- treat as truthy
101
+ true
102
+ end
103
+ end
104
+
105
+ # Evaluates an expression and returns the raw result value.
106
+ # Raises UnsupportedExpressionError for server-only expressions.
107
+ def self.evaluate_expression(expression, env)
108
+ return nil if expression.nil? || expression.strip.empty?
109
+
110
+ unless sdk_evaluable?(expression)
111
+ raise UnsupportedExpressionError, "unsupported expression: requires server-side evaluation"
112
+ end
113
+
114
+ tokens = Lexer.tokenize(expression)
115
+ parser = Parser.new(tokens, env)
116
+ parser.parse_expression
117
+ end
118
+
119
+ # Evaluates multiple expressions against the given environment.
120
+ # Results are keyed by expression string. On error, nil is stored.
121
+ def self.evaluate_expressions(expressions, env)
122
+ results = {}
123
+ expressions.each do |expr|
124
+ results[expr] = evaluate_expression(expr, env)
125
+ rescue StandardError
126
+ results[expr] = nil
127
+ end
128
+ results
129
+ end
130
+
131
+ # Token types for the lexer
132
+ module TokenType
133
+ NUMBER = :number
134
+ STRING = :string
135
+ BOOL = :bool
136
+ NIL = :nil
137
+ IDENT = :ident
138
+ DOT = :dot
139
+ LBRACKET = :lbracket
140
+ RBRACKET = :rbracket
141
+ LPAREN = :lparen
142
+ RPAREN = :rparen
143
+ PLUS = :plus
144
+ MINUS = :minus
145
+ STAR = :star
146
+ SLASH = :slash
147
+ EQ = :eq
148
+ NEQ = :neq
149
+ LT = :lt
150
+ GT = :gt
151
+ LTE = :lte
152
+ GTE = :gte
153
+ AND = :and
154
+ OR = :or
155
+ NOT = :not
156
+ IN = :in
157
+ EOF = :eof
158
+ end
159
+
160
+ Token = Struct.new(:type, :value, keyword_init: true)
161
+
162
+ # Lexer: converts expression string into tokens.
163
+ module Lexer
164
+ def self.tokenize(input)
165
+ tokens = []
166
+ i = 0
167
+ while i < input.length
168
+ ch = input[i]
169
+
170
+ # Skip whitespace
171
+ if ch =~ /\s/
172
+ i += 1
173
+ next
174
+ end
175
+
176
+ # Two-character operators
177
+ if i + 1 < input.length
178
+ two = input[i, 2]
179
+ case two
180
+ when "=="
181
+ tokens << Token.new(type: TokenType::EQ, value: "==")
182
+ i += 2
183
+ next
184
+ when "!="
185
+ tokens << Token.new(type: TokenType::NEQ, value: "!=")
186
+ i += 2
187
+ next
188
+ when "<="
189
+ tokens << Token.new(type: TokenType::LTE, value: "<=")
190
+ i += 2
191
+ next
192
+ when ">="
193
+ tokens << Token.new(type: TokenType::GTE, value: ">=")
194
+ i += 2
195
+ next
196
+ when "&&"
197
+ tokens << Token.new(type: TokenType::AND, value: "&&")
198
+ i += 2
199
+ next
200
+ when "||"
201
+ tokens << Token.new(type: TokenType::OR, value: "||")
202
+ i += 2
203
+ next
204
+ end
205
+ end
206
+
207
+ # Single-character tokens
208
+ case ch
209
+ when "."
210
+ tokens << Token.new(type: TokenType::DOT, value: ".")
211
+ i += 1
212
+ next
213
+ when "["
214
+ tokens << Token.new(type: TokenType::LBRACKET, value: "[")
215
+ i += 1
216
+ next
217
+ when "]"
218
+ tokens << Token.new(type: TokenType::RBRACKET, value: "]")
219
+ i += 1
220
+ next
221
+ when "("
222
+ tokens << Token.new(type: TokenType::LPAREN, value: "(")
223
+ i += 1
224
+ next
225
+ when ")"
226
+ tokens << Token.new(type: TokenType::RPAREN, value: ")")
227
+ i += 1
228
+ next
229
+ when "+"
230
+ tokens << Token.new(type: TokenType::PLUS, value: "+")
231
+ i += 1
232
+ next
233
+ when "-"
234
+ # Check if this is a negative number (unary minus before digits)
235
+ if (tokens.empty? || [:plus, :minus, :star, :slash, :eq, :neq, :lt, :gt, :lte, :gte, :and, :or, :not, :lparen].include?(tokens.last&.type)) &&
236
+ i + 1 < input.length && input[i + 1] =~ /\d/
237
+ # Parse as negative number
238
+ start = i
239
+ i += 1
240
+ i += 1 while i < input.length && (input[i] =~ /\d/ || input[i] == ".")
241
+ num_str = input[start...i]
242
+ value = num_str.include?(".") ? num_str.to_f : num_str.to_i
243
+ tokens << Token.new(type: TokenType::NUMBER, value: value)
244
+ next
245
+ end
246
+ tokens << Token.new(type: TokenType::MINUS, value: "-")
247
+ i += 1
248
+ next
249
+ when "*"
250
+ tokens << Token.new(type: TokenType::STAR, value: "*")
251
+ i += 1
252
+ next
253
+ when "/"
254
+ tokens << Token.new(type: TokenType::SLASH, value: "/")
255
+ i += 1
256
+ next
257
+ when "<"
258
+ tokens << Token.new(type: TokenType::LT, value: "<")
259
+ i += 1
260
+ next
261
+ when ">"
262
+ tokens << Token.new(type: TokenType::GT, value: ">")
263
+ i += 1
264
+ next
265
+ when "!"
266
+ tokens << Token.new(type: TokenType::NOT, value: "!")
267
+ i += 1
268
+ next
269
+ end
270
+
271
+ # String literals (double or single quoted)
272
+ if ch == '"' || ch == "'"
273
+ quote = ch
274
+ i += 1
275
+ start = i
276
+ while i < input.length && input[i] != quote
277
+ i += 1 if input[i] == "\\" # skip escaped char
278
+ i += 1
279
+ end
280
+ tokens << Token.new(type: TokenType::STRING, value: input[start...i])
281
+ i += 1 # skip closing quote
282
+ next
283
+ end
284
+
285
+ # Numbers
286
+ if ch =~ /\d/
287
+ start = i
288
+ i += 1 while i < input.length && (input[i] =~ /\d/ || input[i] == ".")
289
+ num_str = input[start...i]
290
+ value = num_str.include?(".") ? num_str.to_f : num_str.to_i
291
+ tokens << Token.new(type: TokenType::NUMBER, value: value)
292
+ next
293
+ end
294
+
295
+ # Identifiers and keywords
296
+ if ch =~ /[a-zA-Z_]/
297
+ start = i
298
+ i += 1 while i < input.length && input[i] =~ /[a-zA-Z0-9_]/
299
+ word = input[start...i]
300
+ case word
301
+ when "true"
302
+ tokens << Token.new(type: TokenType::BOOL, value: true)
303
+ when "false"
304
+ tokens << Token.new(type: TokenType::BOOL, value: false)
305
+ when "nil", "null", "None"
306
+ tokens << Token.new(type: TokenType::NIL, value: nil)
307
+ when "in"
308
+ tokens << Token.new(type: TokenType::IN, value: "in")
309
+ else
310
+ tokens << Token.new(type: TokenType::IDENT, value: word)
311
+ end
312
+ next
313
+ end
314
+
315
+ raise "unexpected character: #{ch}"
316
+ end
317
+
318
+ tokens << Token.new(type: TokenType::EOF, value: nil)
319
+ tokens
320
+ end
321
+ end
322
+
323
+ # Recursive-descent parser and evaluator.
324
+ # Operator precedence (lowest to highest):
325
+ # || -> && -> == != -> < > <= >= -> + - -> * / -> ! (unary) -> primary
326
+ class Parser
327
+ def initialize(tokens, env)
328
+ @tokens = tokens
329
+ @env = env
330
+ @pos = 0
331
+ end
332
+
333
+ def parse_expression
334
+ parse_or
335
+ end
336
+
337
+ private
338
+
339
+ def current
340
+ @tokens[@pos]
341
+ end
342
+
343
+ def advance
344
+ t = @tokens[@pos]
345
+ @pos += 1
346
+ t
347
+ end
348
+
349
+ def match(*types)
350
+ if types.include?(current.type)
351
+ advance
352
+ end
353
+ end
354
+
355
+ def expect(type)
356
+ if current.type == type
357
+ advance
358
+ else
359
+ raise "expected #{type}, got #{current.type} (#{current.value})"
360
+ end
361
+ end
362
+
363
+ # OR: expr || expr
364
+ def parse_or
365
+ left = parse_and
366
+ while current.type == TokenType::OR
367
+ advance
368
+ right = parse_and
369
+ left = left || right
370
+ end
371
+ left
372
+ end
373
+
374
+ # AND: expr && expr
375
+ def parse_and
376
+ left = parse_equality
377
+ while current.type == TokenType::AND
378
+ advance
379
+ right = parse_equality
380
+ left = left && right
381
+ end
382
+ left
383
+ end
384
+
385
+ # Equality: == !=
386
+ def parse_equality
387
+ left = parse_comparison
388
+ while (op = match(TokenType::EQ, TokenType::NEQ))
389
+ right = parse_comparison
390
+ left = case op.type
391
+ when TokenType::EQ then safe_equal?(left, right)
392
+ when TokenType::NEQ then !safe_equal?(left, right)
393
+ end
394
+ end
395
+ left
396
+ end
397
+
398
+ # Comparison: < > <= >=
399
+ def parse_comparison
400
+ left = parse_membership
401
+ while (op = match(TokenType::LT, TokenType::GT, TokenType::LTE, TokenType::GTE))
402
+ right = parse_membership
403
+ left = safe_compare(op.type, left, right)
404
+ end
405
+ left
406
+ end
407
+
408
+ # Membership: "key" in map
409
+ def parse_membership
410
+ left = parse_addition
411
+ if current.type == TokenType::IN
412
+ advance
413
+ right = parse_addition
414
+ return membership_check(left, right)
415
+ end
416
+ left
417
+ end
418
+
419
+ # Addition/subtraction/string concatenation
420
+ def parse_addition
421
+ left = parse_multiplication
422
+ while (op = match(TokenType::PLUS, TokenType::MINUS))
423
+ right = parse_multiplication
424
+ if op.type == TokenType::PLUS
425
+ if left.is_a?(String) || right.is_a?(String)
426
+ left = "#{left}#{right}"
427
+ else
428
+ left = numeric_val(left) + numeric_val(right)
429
+ end
430
+ else
431
+ left = numeric_val(left) - numeric_val(right)
432
+ end
433
+ end
434
+ left
435
+ end
436
+
437
+ # Multiplication/division
438
+ def parse_multiplication
439
+ left = parse_unary
440
+ while (op = match(TokenType::STAR, TokenType::SLASH))
441
+ right = parse_unary
442
+ if op.type == TokenType::STAR
443
+ left = numeric_val(left) * numeric_val(right)
444
+ else
445
+ divisor = numeric_val(right)
446
+ return nil if divisor == 0
447
+ left = numeric_val(left) / divisor
448
+ end
449
+ end
450
+ left
451
+ end
452
+
453
+ # Unary: ! (NOT)
454
+ def parse_unary
455
+ if current.type == TokenType::NOT
456
+ advance
457
+ val = parse_unary
458
+ return !val
459
+ end
460
+ parse_primary
461
+ end
462
+
463
+ # Primary: literals, identifiers (with property access), grouping
464
+ def parse_primary
465
+ tok = current
466
+
467
+ case tok.type
468
+ when TokenType::NUMBER
469
+ advance
470
+ resolve_postfix(tok.value)
471
+ when TokenType::STRING
472
+ advance
473
+ resolve_postfix(tok.value)
474
+ when TokenType::BOOL
475
+ advance
476
+ resolve_postfix(tok.value)
477
+ when TokenType::NIL
478
+ advance
479
+ resolve_postfix(nil)
480
+ when TokenType::IDENT
481
+ advance
482
+ value = resolve_identifier(tok.value)
483
+ resolve_postfix(value)
484
+ when TokenType::LPAREN
485
+ advance
486
+ result = parse_expression
487
+ expect(TokenType::RPAREN)
488
+ resolve_postfix(result)
489
+ else
490
+ raise "unexpected token: #{tok.type} (#{tok.value})"
491
+ end
492
+ end
493
+
494
+ # Handle dot notation and bracket notation after a value
495
+ def resolve_postfix(value)
496
+ loop do
497
+ if current.type == TokenType::DOT
498
+ advance
499
+ if current.type == TokenType::IDENT
500
+ key = advance.value
501
+ value = safe_access(value, key)
502
+ else
503
+ raise "expected identifier after dot"
504
+ end
505
+ elsif current.type == TokenType::LBRACKET
506
+ advance
507
+ key = parse_expression
508
+ expect(TokenType::RBRACKET)
509
+ value = safe_access(value, key.to_s)
510
+ else
511
+ break
512
+ end
513
+ end
514
+ value
515
+ end
516
+
517
+ # Resolve a top-level identifier from the environment
518
+ def resolve_identifier(name)
519
+ if @env.key?(name)
520
+ @env[name]
521
+ elsif @env.key?(name.to_sym)
522
+ @env[name.to_sym]
523
+ else
524
+ nil
525
+ end
526
+ end
527
+
528
+ # Null-safe property access: accessing a key on nil returns nil
529
+ def safe_access(obj, key)
530
+ return nil if obj.nil?
531
+
532
+ if obj.is_a?(Hash)
533
+ if obj.key?(key)
534
+ obj[key]
535
+ elsif obj.key?(key.to_sym)
536
+ obj[key.to_sym]
537
+ else
538
+ nil
539
+ end
540
+ else
541
+ nil
542
+ end
543
+ end
544
+
545
+ # Safe equality with type coercion rules:
546
+ # - Integer/Float promotion allowed
547
+ # - No other implicit coercion
548
+ def safe_equal?(a, b)
549
+ # nil handling
550
+ return true if a.nil? && b.nil?
551
+ return false if a.nil? || b.nil?
552
+
553
+ # Integer/Float promotion
554
+ if numeric?(a) && numeric?(b)
555
+ return a.to_f == b.to_f
556
+ end
557
+
558
+ a == b
559
+ end
560
+
561
+ # Safe comparison for < > <= >=
562
+ # Returns false for incompatible types
563
+ def safe_compare(op, a, b)
564
+ return false if a.nil? || b.nil?
565
+
566
+ # Both numeric
567
+ if numeric?(a) && numeric?(b)
568
+ a_val = a.to_f
569
+ b_val = b.to_f
570
+ case op
571
+ when TokenType::LT then a_val < b_val
572
+ when TokenType::GT then a_val > b_val
573
+ when TokenType::LTE then a_val <= b_val
574
+ when TokenType::GTE then a_val >= b_val
575
+ end
576
+ elsif a.is_a?(String) && b.is_a?(String)
577
+ case op
578
+ when TokenType::LT then a < b
579
+ when TokenType::GT then a > b
580
+ when TokenType::LTE then a <= b
581
+ when TokenType::GTE then a >= b
582
+ end
583
+ else
584
+ false
585
+ end
586
+ end
587
+
588
+ # Check if "key" in map
589
+ def membership_check(key, map)
590
+ return false unless map.is_a?(Hash)
591
+ map.key?(key) || map.key?(key.to_s) || map.key?(key.to_sym)
592
+ end
593
+
594
+ def numeric?(val)
595
+ val.is_a?(Integer) || val.is_a?(Float)
596
+ end
597
+
598
+ def numeric_val(val)
599
+ return 0 if val.nil?
600
+ val
601
+ end
602
+ end
603
+ end
604
+ end