jql_ruby 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.
@@ -0,0 +1,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JqlRuby
4
+ class Parser
5
+ attr_reader :errors
6
+
7
+ def initialize(tokens)
8
+ @tokens = tokens
9
+ @pos = 0
10
+ @errors = []
11
+ end
12
+
13
+ def parse
14
+ query = parse_query
15
+ expect(TokenType::EOF)
16
+ query
17
+ end
18
+
19
+ private
20
+
21
+ # query = where? orderBy? EOF
22
+ def parse_query
23
+ where_clause = nil
24
+ order_by = nil
25
+
26
+ unless current_type == TokenType::EOF
27
+ if current_type == TokenType::ORDER
28
+ order_by = parse_order_by
29
+ else
30
+ where_clause = parse_where
31
+ order_by = parse_order_by if current_type == TokenType::ORDER
32
+ end
33
+ end
34
+
35
+ Ast::Query.new(where_clause: where_clause, order_by: order_by, position: 0)
36
+ end
37
+
38
+ # where = orClause
39
+ def parse_where
40
+ parse_or_clause
41
+ end
42
+
43
+ # orClause = andClause (OR andClause)*
44
+ def parse_or_clause
45
+ pos = current_position
46
+ left = parse_and_clause
47
+ clauses = [left]
48
+
49
+ while current_type == TokenType::OR
50
+ advance
51
+ clauses << parse_and_clause
52
+ end
53
+
54
+ return clauses.first if clauses.size == 1
55
+
56
+ Ast::OrClause.new(clauses: clauses, position: pos)
57
+ end
58
+
59
+ # andClause = notClause (AND notClause)*
60
+ def parse_and_clause
61
+ pos = current_position
62
+ left = parse_not_clause
63
+ clauses = [left]
64
+
65
+ while current_type == TokenType::AND
66
+ advance
67
+ clauses << parse_not_clause
68
+ end
69
+
70
+ return clauses.first if clauses.size == 1
71
+
72
+ Ast::AndClause.new(clauses: clauses, position: pos)
73
+ end
74
+
75
+ # notClause = NOT notClause | '!' notClause | subClause | terminalClause
76
+ def parse_not_clause
77
+ pos = current_position
78
+
79
+ if current_type == TokenType::NOT
80
+ advance
81
+ clause = parse_not_clause
82
+ return Ast::NotClause.new(clause: clause, operator: :not, position: pos)
83
+ end
84
+
85
+ if current_type == TokenType::BANG
86
+ advance
87
+ clause = parse_not_clause
88
+ return Ast::NotClause.new(clause: clause, operator: :bang, position: pos)
89
+ end
90
+
91
+ if current_type == TokenType::LPAREN
92
+ return parse_sub_clause
93
+ end
94
+
95
+ parse_terminal_clause
96
+ end
97
+
98
+ # subClause = '(' orClause ')'
99
+ def parse_sub_clause
100
+ expect(TokenType::LPAREN)
101
+ clause = parse_or_clause
102
+ expect(TokenType::RPAREN)
103
+ clause
104
+ end
105
+
106
+ # terminalClause = field terminalClauseRhs
107
+ def parse_terminal_clause
108
+ pos = current_position
109
+ field = parse_field
110
+
111
+ parse_terminal_clause_rhs(field, pos)
112
+ end
113
+
114
+ # terminalClauseRhs dispatches based on operator lookahead
115
+ def parse_terminal_clause_rhs(field, pos)
116
+ case current_type
117
+ when TokenType::EQUALS, TokenType::NOT_EQUALS
118
+ parse_equals_clause(field, pos)
119
+ when TokenType::LT, TokenType::GT, TokenType::LTEQ, TokenType::GTEQ
120
+ parse_comparison_clause(field, pos)
121
+ when TokenType::LIKE, TokenType::NOT_LIKE
122
+ parse_like_clause(field, pos)
123
+ when TokenType::NOT
124
+ # NOT IN
125
+ if peek_type == TokenType::IN
126
+ parse_in_clause(field, pos)
127
+ else
128
+ error("unexpected NOT after field")
129
+ end
130
+ when TokenType::IN
131
+ parse_in_clause(field, pos)
132
+ when TokenType::IS
133
+ parse_is_clause(field, pos)
134
+ when TokenType::WAS
135
+ parse_was_clause(field, pos)
136
+ when TokenType::CHANGED
137
+ parse_changed_clause(field, pos)
138
+ else
139
+ error("expected operator after field '#{field_name(field)}'")
140
+ end
141
+ end
142
+
143
+ # equalsClause = ('=' | '!=') (value | function | empty)
144
+ def parse_equals_clause(field, pos)
145
+ op_value = current_type == TokenType::EQUALS ? :eq : :not_eq
146
+ advance
147
+ operator = Ast::Operator.new(value: op_value, position: pos)
148
+
149
+ operand = if current_type == TokenType::EMPTY || current_type == TokenType::NULL
150
+ parse_keyword_operand
151
+ else
152
+ parse_value_or_function
153
+ end
154
+
155
+ Ast::TerminalClause.new(field: field, operator: operator, operand: operand, position: pos)
156
+ end
157
+
158
+ # comparisonClause = ('<' | '>' | '<=' | '>=') (value | function)
159
+ def parse_comparison_clause(field, pos)
160
+ op_map = {
161
+ TokenType::LT => :lt,
162
+ TokenType::GT => :gt,
163
+ TokenType::LTEQ => :lteq,
164
+ TokenType::GTEQ => :gteq,
165
+ }
166
+ op_value = op_map[current_type]
167
+ advance
168
+ operator = Ast::Operator.new(value: op_value, position: pos)
169
+ operand = parse_value_or_function
170
+
171
+ Ast::TerminalClause.new(field: field, operator: operator, operand: operand, position: pos)
172
+ end
173
+
174
+ # likeClause = ('~' | '!~') (value | function | empty)
175
+ def parse_like_clause(field, pos)
176
+ op_value = current_type == TokenType::LIKE ? :like : :not_like
177
+ advance
178
+ operator = Ast::Operator.new(value: op_value, position: pos)
179
+
180
+ operand = if current_type == TokenType::EMPTY || current_type == TokenType::NULL
181
+ parse_keyword_operand
182
+ else
183
+ parse_value_or_function
184
+ end
185
+
186
+ Ast::TerminalClause.new(field: field, operator: operator, operand: operand, position: pos)
187
+ end
188
+
189
+ # inClause = [NOT] IN (list | function)
190
+ def parse_in_clause(field, pos)
191
+ negated = false
192
+ if current_type == TokenType::NOT
193
+ negated = true
194
+ advance
195
+ end
196
+
197
+ expect(TokenType::IN)
198
+ op_value = negated ? :not_in : :in
199
+ operator = Ast::Operator.new(value: op_value, position: pos)
200
+
201
+ operand = if current_type == TokenType::LPAREN
202
+ parse_list_operand
203
+ else
204
+ parse_function
205
+ end
206
+
207
+ Ast::TerminalClause.new(field: field, operator: operator, operand: operand, position: pos)
208
+ end
209
+
210
+ # isClause = IS [NOT] empty
211
+ def parse_is_clause(field, pos)
212
+ advance
213
+ negated = false
214
+ if current_type == TokenType::NOT
215
+ negated = true
216
+ advance
217
+ end
218
+
219
+ op_value = negated ? :is_not : :is
220
+ operator = Ast::Operator.new(value: op_value, position: pos)
221
+ operand = parse_keyword_operand
222
+
223
+ Ast::TerminalClause.new(field: field, operator: operator, operand: operand, position: pos)
224
+ end
225
+
226
+ # wasClause / wasInClause
227
+ def parse_was_clause(field, pos)
228
+ advance
229
+
230
+ negated = false
231
+ if current_type == TokenType::NOT
232
+ negated = true
233
+ advance
234
+ end
235
+
236
+ # WAS [NOT] IN => wasInClause
237
+ if current_type == TokenType::IN
238
+ return parse_was_in_clause(field, pos, negated)
239
+ end
240
+
241
+ op_value = negated ? :was_not : :was
242
+ operator = Ast::Operator.new(value: op_value, position: pos)
243
+ operand = parse_value_or_function
244
+ predicates = parse_predicates
245
+
246
+ Ast::TerminalClause.new(
247
+ field: field, operator: operator, operand: operand,
248
+ predicates: predicates, position: pos
249
+ )
250
+ end
251
+
252
+ def parse_was_in_clause(field, pos, negated)
253
+ advance
254
+ op_value = negated ? :was_not_in : :was_in
255
+ operator = Ast::Operator.new(value: op_value, position: pos)
256
+
257
+ operand = if current_type == TokenType::LPAREN
258
+ parse_list_operand
259
+ else
260
+ parse_function
261
+ end
262
+
263
+ predicates = parse_predicates
264
+
265
+ Ast::TerminalClause.new(
266
+ field: field, operator: operator, operand: operand,
267
+ predicates: predicates, position: pos
268
+ )
269
+ end
270
+
271
+ # changedClause = CHANGED changedPredicate*
272
+ def parse_changed_clause(field, pos)
273
+ advance
274
+ operator = Ast::Operator.new(value: :changed, position: pos)
275
+ predicates = parse_predicates
276
+
277
+ Ast::TerminalClause.new(
278
+ field: field, operator: operator,
279
+ predicates: predicates, position: pos
280
+ )
281
+ end
282
+
283
+ PREDICATE_KEYWORDS = Set.new([
284
+ TokenType::AFTER, TokenType::BEFORE, TokenType::ON,
285
+ TokenType::DURING, TokenType::BY,
286
+ TokenType::FROM, TokenType::TO,
287
+ ]).freeze
288
+
289
+ def parse_predicates
290
+ predicates = []
291
+ while PREDICATE_KEYWORDS.include?(current_type)
292
+ predicates << parse_predicate
293
+ end
294
+ predicates
295
+ end
296
+
297
+ def parse_predicate
298
+ pos = current_position
299
+ op_map = {
300
+ TokenType::AFTER => :after,
301
+ TokenType::BEFORE => :before,
302
+ TokenType::ON => :on,
303
+ TokenType::DURING => :during,
304
+ TokenType::BY => :by,
305
+ TokenType::FROM => :from,
306
+ TokenType::TO => :to,
307
+ }
308
+ op = op_map[current_type]
309
+ advance
310
+
311
+ operand = if current_type == TokenType::LPAREN
312
+ parse_list_operand
313
+ else
314
+ parse_value_or_function
315
+ end
316
+
317
+ Ast::Predicate.new(operator: op, operand: operand, position: pos)
318
+ end
319
+
320
+ # field = string fieldProperty* | number | customField fieldProperty*
321
+ def parse_field
322
+ pos = current_position
323
+
324
+ case current_type
325
+ when TokenType::NUMBER
326
+ name = current_value
327
+ advance
328
+ Ast::Field.new(name: name, position: pos)
329
+ when TokenType::STRING, TokenType::QUOTED_STRING
330
+ value = current_value
331
+ advance
332
+
333
+ # check for custom field: cf[12345]
334
+ if value.downcase == "cf" && current_type == TokenType::LBRACKET
335
+ return parse_custom_field(pos)
336
+ end
337
+
338
+ properties = parse_field_properties
339
+ Ast::Field.new(name: value, properties: properties, position: pos)
340
+ else
341
+ error("expected field name")
342
+ # return a placeholder to allow parser to continue
343
+ advance
344
+ Ast::Field.new(name: "", position: pos)
345
+ end
346
+ end
347
+
348
+ def parse_custom_field(pos)
349
+ expect(TokenType::LBRACKET)
350
+ id_str = current_value
351
+ expect(TokenType::NUMBER) || expect(TokenType::STRING)
352
+ expect(TokenType::RBRACKET)
353
+ properties = parse_field_properties
354
+ Ast::CustomField.new(id: id_str.to_i, properties: properties, position: pos)
355
+ end
356
+
357
+ def parse_field_properties
358
+ properties = []
359
+ while current_type == TokenType::DOT
360
+ properties << parse_field_property
361
+ end
362
+ properties
363
+ end
364
+
365
+ # fieldProperty = '.' propertyArgument+ ('[' argument ']')?
366
+ def parse_field_property
367
+ pos = current_position
368
+ advance
369
+ keys = []
370
+
371
+ while current_type == TokenType::STRING || current_type == TokenType::QUOTED_STRING || current_type == TokenType::NUMBER
372
+ keys << current_value
373
+ advance
374
+ break unless current_type == TokenType::DOT && peek_type != TokenType::LPAREN
375
+ advance if current_type == TokenType::DOT
376
+ end
377
+
378
+ argument = nil
379
+ if current_type == TokenType::LBRACKET
380
+ advance
381
+ argument = current_value if current_type == TokenType::STRING || current_type == TokenType::QUOTED_STRING || current_type == TokenType::NUMBER
382
+ advance
383
+ expect(TokenType::RBRACKET)
384
+ end
385
+
386
+ Ast::FieldProperty.new(keys: keys, argument: argument, position: pos)
387
+ end
388
+
389
+ # value = string | number
390
+ def parse_value
391
+ pos = current_position
392
+ case current_type
393
+ when TokenType::STRING, TokenType::QUOTED_STRING
394
+ val = current_value
395
+ advance
396
+ Ast::ValueOperand.new(value: val, position: pos)
397
+ when TokenType::NUMBER
398
+ val = current_value
399
+ advance
400
+ Ast::ValueOperand.new(value: val, position: pos)
401
+ else
402
+ error("expected value")
403
+ advance
404
+ Ast::ValueOperand.new(value: "", position: pos)
405
+ end
406
+ end
407
+
408
+ # parse value or function, checking for '(' after a name to distinguish
409
+ def parse_value_or_function
410
+ pos = current_position
411
+ if (current_type == TokenType::STRING || current_type == TokenType::QUOTED_STRING || current_type == TokenType::NUMBER) && peek_type == TokenType::LPAREN
412
+ return parse_function
413
+ end
414
+
415
+ parse_value
416
+ end
417
+
418
+ # function = functionName '(' argumentList? ')'
419
+ def parse_function
420
+ pos = current_position
421
+ name = current_value
422
+ advance
423
+ expect(TokenType::LPAREN)
424
+
425
+ arguments = []
426
+ unless current_type == TokenType::RPAREN
427
+ arguments << parse_argument
428
+ while current_type == TokenType::COMMA
429
+ advance
430
+ arguments << parse_argument
431
+ end
432
+ end
433
+
434
+ expect(TokenType::RPAREN)
435
+ Ast::FunctionOperand.new(name: name, arguments: arguments, position: pos)
436
+ end
437
+
438
+ def parse_argument
439
+ pos = current_position
440
+ case current_type
441
+ when TokenType::STRING, TokenType::QUOTED_STRING, TokenType::NUMBER
442
+ val = current_value
443
+ advance
444
+ Ast::ValueOperand.new(value: val, position: pos)
445
+ else
446
+ error("expected function argument")
447
+ advance
448
+ Ast::ValueOperand.new(value: "", position: pos)
449
+ end
450
+ end
451
+
452
+ # list = '(' operand (',' operand)* ')'
453
+ def parse_list_operand
454
+ pos = current_position
455
+ expect(TokenType::LPAREN)
456
+
457
+ values = []
458
+ values << parse_operand
459
+ while current_type == TokenType::COMMA
460
+ advance
461
+ values << parse_operand
462
+ end
463
+
464
+ expect(TokenType::RPAREN)
465
+ Ast::ListOperand.new(values: values, position: pos)
466
+ end
467
+
468
+ # operand = value | function | list | empty
469
+ def parse_operand
470
+ if current_type == TokenType::EMPTY || current_type == TokenType::NULL
471
+ return parse_keyword_operand
472
+ end
473
+
474
+ if current_type == TokenType::LPAREN
475
+ return parse_list_operand
476
+ end
477
+
478
+ parse_value_or_function
479
+ end
480
+
481
+ def parse_keyword_operand
482
+ pos = current_position
483
+ val = current_type == TokenType::EMPTY ? :empty : :null
484
+ advance
485
+ Ast::KeywordOperand.new(value: val, position: pos)
486
+ end
487
+
488
+ # orderBy = ORDER BY searchSort (',' searchSort)*
489
+ def parse_order_by
490
+ pos = current_position
491
+ expect(TokenType::ORDER)
492
+ expect(TokenType::BY)
493
+
494
+ sorts = []
495
+ sorts << parse_search_sort
496
+ while current_type == TokenType::COMMA
497
+ advance
498
+ sorts << parse_search_sort
499
+ end
500
+
501
+ Ast::OrderBy.new(fields: sorts, position: pos)
502
+ end
503
+
504
+ # searchSort = field (ASC | DESC)?
505
+ def parse_search_sort
506
+ pos = current_position
507
+ field = parse_field
508
+
509
+ direction = nil
510
+ if current_type == TokenType::ASC
511
+ direction = :asc
512
+ advance
513
+ elsif current_type == TokenType::DESC
514
+ direction = :desc
515
+ advance
516
+ end
517
+
518
+ Ast::SearchSort.new(field: field, direction: direction, position: pos)
519
+ end
520
+
521
+ # token helpers
522
+
523
+ def current_token
524
+ @tokens[@pos] || Token.new(type: TokenType::EOF, value: nil, position: @pos)
525
+ end
526
+
527
+ def current_type
528
+ current_token.type
529
+ end
530
+
531
+ def current_value
532
+ current_token.value
533
+ end
534
+
535
+ def current_position
536
+ current_token.position || @pos
537
+ end
538
+
539
+ def peek_token
540
+ @tokens[@pos + 1] || Token.new(type: TokenType::EOF, value: nil, position: @pos)
541
+ end
542
+
543
+ def peek_type
544
+ peek_token.type
545
+ end
546
+
547
+ def advance
548
+ @pos += 1
549
+ end
550
+
551
+ def expect(type)
552
+ if current_type == type
553
+ token = current_token
554
+ advance
555
+ token
556
+ else
557
+ error("expected #{type}, got #{current_type} (#{current_value.inspect})")
558
+ nil
559
+ end
560
+ end
561
+
562
+ def error(message)
563
+ err = ParseError.new(message, position: current_position)
564
+ @errors << err
565
+ err
566
+ end
567
+
568
+ def field_name(field)
569
+ case field
570
+ when Ast::Field then field.name
571
+ when Ast::CustomField then "cf[#{field.id}]"
572
+ else ""
573
+ end
574
+ end
575
+ end
576
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JqlRuby
4
+ class Result
5
+ attr_reader :query, :errors
6
+
7
+ def initialize(query:, errors: [])
8
+ @query = query
9
+ @errors = errors
10
+ end
11
+
12
+ def success?
13
+ @errors.empty?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JqlRuby
4
+ Token = Struct.new(:type, :value, :position, keyword_init: true) do
5
+ def keyword?
6
+ KEYWORD_TYPES.include?(type)
7
+ end
8
+
9
+ KEYWORD_TYPES = %i[
10
+ and or not in is was changed
11
+ order by asc desc
12
+ empty null
13
+ after before during on from to
14
+ ].freeze
15
+ end
16
+
17
+ module TokenType
18
+ # delimiters
19
+ LPAREN = :lparen
20
+ RPAREN = :rparen
21
+ COMMA = :comma
22
+ LBRACKET = :lbracket
23
+ RBRACKET = :rbracket
24
+ DOT = :dot
25
+
26
+ # operators
27
+ EQUALS = :equals
28
+ NOT_EQUALS = :not_equals
29
+ LT = :lt
30
+ GT = :gt
31
+ LTEQ = :lteq
32
+ GTEQ = :gteq
33
+ LIKE = :like
34
+ NOT_LIKE = :not_like
35
+ BANG = :bang
36
+
37
+ # keywords
38
+ AND = :and
39
+ OR = :or
40
+ NOT = :not
41
+ IN = :in
42
+ IS = :is
43
+ WAS = :was
44
+ CHANGED = :changed
45
+ ORDER = :order
46
+ BY = :by
47
+ ASC = :asc
48
+ DESC = :desc
49
+ EMPTY = :empty
50
+ NULL = :null
51
+ AFTER = :after
52
+ BEFORE = :before
53
+ DURING = :during
54
+ ON = :on
55
+ FROM = :from
56
+ TO = :to
57
+
58
+ # literals
59
+ STRING = :string
60
+ QUOTED_STRING = :quoted_string
61
+ NUMBER = :number
62
+
63
+ # special
64
+ EOF = :eof
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JqlRuby
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jql_ruby.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jql_ruby/version"
4
+ require_relative "jql_ruby/errors"
5
+ require_relative "jql_ruby/token"
6
+ require_relative "jql_ruby/lexer"
7
+ require_relative "jql_ruby/ast"
8
+ require_relative "jql_ruby/parser"
9
+ require_relative "jql_ruby/result"
10
+ require_relative "jql_ruby/adapters"
11
+
12
+ module JqlRuby
13
+ def self.parse(input)
14
+ tokens = Lexer.new(input).tokenize
15
+ parser = Parser.new(tokens)
16
+ query = parser.parse
17
+ Result.new(query: query, errors: parser.errors)
18
+ end
19
+ end