gmail_search_syntax 0.1.4 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47b56ee5467d6808c4ceae00289bf291551374a3e5854ed5223a5b2f5ca2f9ac
4
- data.tar.gz: 27aa483d8296eb2a3e775aecfaeffc0813f60936cd9091e976eb5159559cafc9
3
+ metadata.gz: b9a9c5131ce88b747e30a23baa0c32943ccc820097b288c9cec7b8275ec5bdd8
4
+ data.tar.gz: 4f52b8ad66ca0b8894ee3105ca0f9d9cb527dcf4910284ff9f53811f53ebdb81
5
5
  SHA512:
6
- metadata.gz: 0170d5419e8ab3335a3bd4494f09c3b6e6f5a143cf26791a383f2189a9e12fcd49a596e56555670d018a6ffdffa13b13eed27e554cbe5ff82a221dcd12ed4bd7
7
- data.tar.gz: 95d48f2e6aedb4160634db949e32be25823f8b808e71dc113f3ce9e8490b507e136a96372c329f8ff03f2476bc52f4dd31fc89f67a9ba2b7e11e5e0bcebf241f
6
+ metadata.gz: 9ad29d2d10821dc8a1bc1da6cb778f59979aebeaa92daf298939a67ec0b55dd22b7d29911dd64bf43c4564a193120d8bef3efae29c57cc822b35c709cf5b51bf
7
+ data.tar.gz: 1c932b207aa8bceafb7edcf439cae91e144975e3af62a6ccc78eca2f946f0b101f090af7ec58f9ca687426eeaab4e0476183cf1fe5f3a332abfff0dbb6931e29
@@ -8,7 +8,7 @@ puts "=" * 80
8
8
  puts
9
9
 
10
10
  # Example 1: Escaped quotes in a substring search
11
- puts "1. Substring with escaped quotes"
11
+ puts "1. ExactWord with escaped quotes"
12
12
  puts "-" * 40
13
13
  query1 = '"She said \\"hello\\" to me"'
14
14
  puts "Input: #{query1}"
@@ -136,11 +136,11 @@ puts "Summary"
136
136
  puts "=" * 80
137
137
  puts "Escape sequences work in BOTH quoted and unquoted tokens:"
138
138
  puts
139
- puts "Quoted strings (Substring nodes):"
139
+ puts "Quoted strings (ExactWord nodes):"
140
140
  puts " - Use substring matching (LIKE %value%)"
141
141
  puts " - \"She said \\\"hello\\\"\" → 'She said \"hello\"'"
142
142
  puts
143
- puts "Unquoted tokens (StringToken nodes):"
143
+ puts "Unquoted tokens (LooseWord nodes):"
144
144
  puts " - Use word boundary matching (= or LIKE with boundaries)"
145
145
  puts " - meeting\\\"room → 'meeting\"room'"
146
146
  puts " - path\\\\to\\\\file → 'path\\to\\file'"
@@ -3,11 +3,11 @@
3
3
  require_relative "../lib/gmail_search_syntax"
4
4
 
5
5
  puts "=" * 80
6
- puts "StringToken vs Substring Demo"
6
+ puts "LooseWord vs ExactWord Demo"
7
7
  puts "=" * 80
8
8
  puts
9
9
 
10
- # Example 1: Unquoted text (StringToken node) - word boundary matching
10
+ # Example 1: Unquoted text (LooseWord node) - word boundary matching
11
11
  puts "1. Unquoted text: meeting"
12
12
  puts "-" * 40
13
13
  query1 = "meeting"
@@ -27,7 +27,7 @@ puts " - Will match: 'meeting tomorrow', 'the meeting', 'just a meeting here'"
27
27
  puts " - Will NOT match: 'meetings', 'premeeting', 'meetingroom'"
28
28
  puts
29
29
 
30
- # Example 2: Quoted text (Substring node) - substring matching
30
+ # Example 2: Quoted text (ExactWord node) - substring matching
31
31
  puts "2. Quoted text: \"meeting\""
32
32
  puts "-" * 40
33
33
  query2 = '"meeting"'
@@ -88,6 +88,6 @@ puts
88
88
  puts "=" * 80
89
89
  puts "Summary"
90
90
  puts "=" * 80
91
- puts "StringToken node (unquoted): Word boundary matching - finds complete words"
92
- puts "Substring node (quoted): Substring matching - finds partial matches"
91
+ puts "LooseWord node (unquoted): Word boundary matching - finds complete words"
92
+ puts "ExactWord node (quoted): ExactWord matching - finds partial matches"
93
93
  puts "=" * 80
@@ -8,6 +8,10 @@ module GmailSearchSyntax
8
8
  def attributes
9
9
  instance_variables.map { |var| instance_variable_get(var) }
10
10
  end
11
+
12
+ def short_class_name
13
+ self.class.name.split("::").last
14
+ end
11
15
  end
12
16
 
13
17
  class Operator < Node
@@ -19,11 +23,11 @@ module GmailSearchSyntax
19
23
  end
20
24
 
21
25
  def inspect
22
- "#<Operator #{@name}: #{@value.inspect}>"
26
+ "#<#{short_class_name} #{@name}: #{@value.inspect}>"
23
27
  end
24
28
  end
25
29
 
26
- class StringToken < Node
30
+ class LooseWord < Node
27
31
  attr_reader :value
28
32
 
29
33
  def initialize(value)
@@ -31,11 +35,11 @@ module GmailSearchSyntax
31
35
  end
32
36
 
33
37
  def inspect
34
- "#<StringToken #{@value.inspect}>"
38
+ "#<#{short_class_name} #{@value.inspect}>"
35
39
  end
36
40
  end
37
41
 
38
- class Substring < Node
42
+ class ExactWord < Node
39
43
  attr_reader :value
40
44
 
41
45
  def initialize(value)
@@ -43,7 +47,7 @@ module GmailSearchSyntax
43
47
  end
44
48
 
45
49
  def inspect
46
- "#<Substring #{@value.inspect}>"
50
+ "#<#{short_class_name} #{@value.inspect}>"
47
51
  end
48
52
  end
49
53
 
@@ -55,7 +59,7 @@ module GmailSearchSyntax
55
59
  end
56
60
 
57
61
  def inspect
58
- "#<And #{@operands.map(&:inspect).join(" AND ")}>"
62
+ "#<#{short_class_name} #{@operands.map(&:inspect).join(" AND ")}>"
59
63
  end
60
64
  end
61
65
 
@@ -67,7 +71,7 @@ module GmailSearchSyntax
67
71
  end
68
72
 
69
73
  def inspect
70
- "#<Or #{@operands.map(&:inspect).join(" OR ")}>"
74
+ "#<#{short_class_name} #{@operands.map(&:inspect).join(" OR ")}>"
71
75
  end
72
76
  end
73
77
 
@@ -79,7 +83,7 @@ module GmailSearchSyntax
79
83
  end
80
84
 
81
85
  def inspect
82
- "#<Not #{@child.inspect}>"
86
+ "#<#{short_class_name} #{@child.inspect}>"
83
87
  end
84
88
  end
85
89
 
@@ -91,7 +95,7 @@ module GmailSearchSyntax
91
95
  end
92
96
 
93
97
  def inspect
94
- "#<Group #{@children.inspect}>"
98
+ "#<#{short_class_name} #{@children.inspect}>"
95
99
  end
96
100
  end
97
101
 
@@ -105,7 +109,7 @@ module GmailSearchSyntax
105
109
  end
106
110
 
107
111
  def inspect
108
- "#<Around #{@left.inspect} AROUND #{@distance} #{@right.inspect}>"
112
+ "#<#{short_class_name} #{@left.inspect} AROUND #{@distance} #{@right.inspect}>"
109
113
  end
110
114
  end
111
115
  end
@@ -23,7 +23,11 @@ module GmailSearchSyntax
23
23
  raise GmailSearchSyntax::EmptyQueryError, "Query cannot be empty"
24
24
  end
25
25
 
26
- children.first
26
+ # A token the grammar has no use for (a stray parenthesis, a colon
27
+ # after a space) ends an expression; whatever follows is still part of
28
+ # the query, so the pieces are joined the way adjacent terms are.
29
+ return children.first if children.length == 1
30
+ AST::And.new(children.flat_map { |child| child.is_a?(AST::And) ? child.operands : [child] })
27
31
  end
28
32
 
29
33
  private
@@ -129,11 +133,11 @@ module GmailSearchSyntax
129
133
  when :quoted_string
130
134
  value = current_token.value
131
135
  advance
132
- AST::Substring.new(value)
136
+ AST::ExactWord.new(value)
133
137
  when :email, :number, :date, :relative_time
134
138
  value = current_token.value
135
139
  advance
136
- AST::StringToken.new(value)
140
+ AST::LooseWord.new(value)
137
141
  else
138
142
  advance
139
143
  nil
@@ -171,7 +175,8 @@ module GmailSearchSyntax
171
175
  end
172
176
 
173
177
  def parse_operator_or_text
174
- word = current_token.value
178
+ word_token = current_token
179
+ word = word_token.value
175
180
 
176
181
  if OPERATORS.include?(word.downcase) && peek_token&.type == :colon
177
182
  operator_name = word.downcase
@@ -183,7 +188,28 @@ module GmailSearchSyntax
183
188
  end
184
189
 
185
190
  advance
186
- AST::StringToken.new(word)
191
+ AST::LooseWord.new(glued_text(word_token))
192
+ end
193
+
194
+ # A word that is not an operator keeps whatever is glued to it through
195
+ # colons, so "http://example.com", "10:30" and "foo:bar" are searched as
196
+ # typed, the way Gmail does. A colon followed by whitespace is a
197
+ # separator: "foo: bar" is "foo" and "bar".
198
+ def glued_text(word_token)
199
+ text = word_token.value.to_s
200
+ cursor = word_token.position + text.length
201
+ while current_token&.type == :colon && current_token.position == cursor
202
+ advance
203
+ cursor += 1
204
+ text += ":"
205
+ piece_token = current_token
206
+ break unless piece_token && %i[word email number date relative_time].include?(piece_token.type) && piece_token.position == cursor
207
+ piece = piece_token.value.to_s
208
+ text += piece
209
+ cursor += piece.length
210
+ advance
211
+ end
212
+ text.sub(/:+\z/, "")
187
213
  end
188
214
 
189
215
  def parse_operator_value
@@ -46,10 +46,10 @@ module GmailSearchSyntax
46
46
  case node
47
47
  when AST::Operator
48
48
  visit_operator(node)
49
- when AST::StringToken
50
- visit_string_token(node)
51
- when AST::Substring
52
- visit_substring(node)
49
+ when AST::LooseWord
50
+ visit_loose_word(node)
51
+ when AST::ExactWord
52
+ visit_exact_word(node)
53
53
  when AST::And
54
54
  visit_and(node)
55
55
  when AST::Or
@@ -327,7 +327,7 @@ module GmailSearchSyntax
327
327
  @query.add_condition("m0.rfc822_message_id = ?")
328
328
  end
329
329
 
330
- def visit_string_token(node)
330
+ def visit_loose_word(node)
331
331
  # Word boundary matching - the value should appear as a complete word/token
332
332
  # We use LIKE with word boundaries: spaces, start/end of string
333
333
  value = node.value
@@ -342,8 +342,8 @@ module GmailSearchSyntax
342
342
  @query.add_param("% #{value} %")
343
343
  end
344
344
 
345
- def visit_substring(node)
346
- # Substring matching - the value can appear anywhere in the text
345
+ def visit_exact_word(node)
346
+ # ExactWord matching - the value can appear anywhere in the text
347
347
  @query.add_param("%#{node.value}%")
348
348
  @query.add_param("%#{node.value}%")
349
349
  @query.add_condition("(m0.subject LIKE ? OR m0.body LIKE ?)")
@@ -38,6 +38,9 @@ module GmailSearchSyntax
38
38
 
39
39
  break if @position >= @input.length
40
40
 
41
+ # Every token made in this pass starts here; a value read right
42
+ # after an operator's colon records its own start below.
43
+ @token_start = @position
41
44
  char = current_char
42
45
 
43
46
  case char
@@ -76,11 +79,18 @@ module GmailSearchSyntax
76
79
  when ":"
77
80
  add_token(:colon, char)
78
81
  advance
82
+ # After a colon following an operator word, read the value including hyphens
83
+ # Gmail: label:foo-bar → operator "label" with value "foo-bar"
84
+ if in_operator_context? && @position < @input.length && current_char !~ /[\s(){}]/
85
+ @token_start = @position
86
+ read_operator_value
87
+ end
79
88
  else
80
89
  read_word
81
90
  end
82
91
  end
83
92
 
93
+ @token_start = @position
84
94
  add_token(:eof, nil)
85
95
  @tokens
86
96
  end
@@ -105,8 +115,72 @@ module GmailSearchSyntax
105
115
  end
106
116
  end
107
117
 
118
+ # Tokens carry the offset they start at, so the parser can tell "foo:bar"
119
+ # from "foo: bar".
108
120
  def add_token(type, value)
109
- @tokens << Token.new(type, value, @position)
121
+ @tokens << Token.new(type, value, @token_start || @position)
122
+ end
123
+
124
+ # Check if we're in operator context (last non-colon token is an operator word)
125
+ def in_operator_context?
126
+ # Find the last token before the colon we just added
127
+ return false if @tokens.size < 2
128
+ prev_token = @tokens[-2]
129
+ prev_token.type == :word && OPERATORS.include?(prev_token.value.downcase)
130
+ end
131
+
132
+ # Read an operator value, which can include hyphens
133
+ # Gmail treats label:foo-bar as operator "label" with value "foo-bar"
134
+ def read_operator_value
135
+ # Handle quoted strings normally
136
+ if current_char == '"'
137
+ read_quoted_string
138
+ return
139
+ end
140
+
141
+ # Handle parentheses/braces - these start grouped values
142
+ if current_char == "(" || current_char == "{"
143
+ return # Let the main loop handle these
144
+ end
145
+
146
+ value = ""
147
+ while @position < @input.length
148
+ char = current_char
149
+ break if /[\s():{}]/.match?(char)
150
+ # Include hyphens in operator values (unlike regular words)
151
+
152
+ if char == "\\"
153
+ advance
154
+ if @position < @input.length
155
+ next_char = current_char
156
+ value += case next_char
157
+ when '"', "\\"
158
+ next_char
159
+ else
160
+ "\\" + next_char
161
+ end
162
+ advance
163
+ end
164
+ else
165
+ value += char
166
+ advance
167
+ end
168
+ end
169
+
170
+ return if value.empty?
171
+
172
+ # Classify the value
173
+ if /@/.match?(value)
174
+ add_token(:email, value)
175
+ elsif /^\d+$/.match?(value)
176
+ add_token(:number, value.to_i)
177
+ elsif value =~ /^\d{4}\/\d{2}\/\d{2}$/ || value =~ /^\d{2}\/\d{2}\/\d{4}$/
178
+ add_token(:date, value)
179
+ elsif /^(\d+)([dmy])$/.match?(value)
180
+ add_token(:relative_time, value)
181
+ else
182
+ add_token(:word, value)
183
+ end
110
184
  end
111
185
 
112
186
  def read_quoted_string
@@ -1,3 +1,3 @@
1
1
  module GmailSearchSyntax
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.5"
3
3
  end
@@ -15,7 +15,7 @@ class GmailSearchSyntaxTest < Minitest::Test
15
15
  # This is an Operator specification
16
16
  assert_operator(expected_operand, actual_value[index])
17
17
  elsif expected_operand.key?(:value)
18
- # This is a StringToken specification
18
+ # This is a LooseWord specification
19
19
  assert_string_token(expected_operand, actual_value[index])
20
20
  else
21
21
  # Generic node assertion
@@ -34,10 +34,10 @@ class GmailSearchSyntaxTest < Minitest::Test
34
34
  end
35
35
 
36
36
  def assert_string_token(expected_properties, actual_string_token)
37
- assert_instance_of StringToken, actual_string_token, "Expected StringToken, got #{actual_string_token.class}"
37
+ assert_instance_of LooseWord, actual_string_token, "Expected LooseWord, got #{actual_string_token.class}"
38
38
  expected_properties.each do |property, expected_value|
39
39
  actual_value = actual_string_token.public_send(property)
40
- assert_equal expected_value, actual_value, "StringToken: expected #{property} to be #{expected_value.inspect}, got #{actual_value.inspect}"
40
+ assert_equal expected_value, actual_value, "LooseWord: expected #{property} to be #{expected_value.inspect}, got #{actual_value.inspect}"
41
41
  end
42
42
  end
43
43
 
@@ -134,11 +134,11 @@ class GmailSearchSyntaxTest < Minitest::Test
134
134
  assert_instance_of And, ast
135
135
 
136
136
  assert_equal 2, ast.operands.length
137
- assert_instance_of StringToken, ast.operands[0]
137
+ assert_instance_of LooseWord, ast.operands[0]
138
138
  assert_equal "dinner", ast.operands[0].value
139
139
 
140
140
  assert_instance_of Not, ast.operands[1]
141
- assert_instance_of StringToken, ast.operands[1].child
141
+ assert_instance_of LooseWord, ast.operands[1].child
142
142
  assert_equal "movie", ast.operands[1].child.value
143
143
  end
144
144
 
@@ -152,10 +152,10 @@ class GmailSearchSyntaxTest < Minitest::Test
152
152
  assert_instance_of And, ast
153
153
 
154
154
  assert_equal 2, ast.operands.length
155
- assert_instance_of StringToken, ast.operands[0]
155
+ assert_instance_of LooseWord, ast.operands[0]
156
156
  assert_equal "some", ast.operands[0].value
157
157
 
158
- assert_instance_of StringToken, ast.operands[1]
158
+ assert_instance_of LooseWord, ast.operands[1]
159
159
  assert_equal "outfit", ast.operands[1].value
160
160
  end
161
161
 
@@ -186,7 +186,7 @@ class GmailSearchSyntaxTest < Minitest::Test
186
186
  assert_instance_of And, ast
187
187
 
188
188
  assert_equal 2, ast.operands.length
189
- assert_instance_of StringToken, ast.operands[0]
189
+ assert_instance_of LooseWord, ast.operands[0]
190
190
  assert_equal "cats", ast.operands[0].value
191
191
 
192
192
  assert_instance_of Not, ast.operands[1]
@@ -213,35 +213,43 @@ class GmailSearchSyntaxTest < Minitest::Test
213
213
  end
214
214
 
215
215
  def test_embedded_hyphen_with_operator
216
- # Embedded hyphen in operator context
216
+ # Gmail keeps hyphens in operator values: from:mary-jane → operator with value "mary-jane"
217
217
  ast = GmailSearchSyntax.parse!("from:mary-jane")
218
- assert_instance_of And, ast
218
+ assert_instance_of Operator, ast
219
+ assert_equal "from", ast.name
220
+ assert_equal "mary-jane", ast.value
221
+ end
219
222
 
220
- # "from:mary" becomes operator, "-jane" is embedded hyphen → "jane" is separate word
221
- assert_equal 2, ast.operands.length
222
- assert_instance_of Operator, ast.operands[0]
223
- assert_equal "from", ast.operands[0].name
224
- assert_equal "mary", ast.operands[0].value
223
+ def test_hyphenated_label_name
224
+ # Common case: label names with hyphens
225
+ ast = GmailSearchSyntax.parse!("label:work-projects")
226
+ assert_instance_of Operator, ast
227
+ assert_equal "label", ast.name
228
+ assert_equal "work-projects", ast.value
229
+ end
225
230
 
226
- assert_instance_of StringToken, ast.operands[1]
227
- assert_equal "jane", ast.operands[1].value
231
+ def test_multiple_hyphens_in_operator_value
232
+ ast = GmailSearchSyntax.parse!("label:starter-league-thisforthat")
233
+ assert_instance_of Operator, ast
234
+ assert_equal "label", ast.name
235
+ assert_equal "starter-league-thisforthat", ast.value
228
236
  end
229
237
 
230
238
  def test_around_operator
231
239
  ast = GmailSearchSyntax.parse!("holiday AROUND 10 vacation")
232
240
  assert_instance_of Around, ast
233
241
 
234
- assert_instance_of StringToken, ast.left
242
+ assert_instance_of LooseWord, ast.left
235
243
  assert_equal "holiday", ast.left.value
236
244
  assert_equal 10, ast.distance
237
245
 
238
- assert_instance_of StringToken, ast.right
246
+ assert_instance_of LooseWord, ast.right
239
247
  assert_equal "vacation", ast.right.value
240
248
  end
241
249
 
242
250
  def test_around_with_quoted_string
243
251
  ast = GmailSearchSyntax.parse!('"secret AROUND 25 birthday"')
244
- assert_instance_of Substring, ast
252
+ assert_instance_of ExactWord, ast
245
253
  assert_equal "secret AROUND 25 birthday", ast.value
246
254
  end
247
255
 
@@ -272,7 +280,7 @@ class GmailSearchSyntaxTest < Minitest::Test
272
280
 
273
281
  def test_quoted_exact_phrase
274
282
  ast = GmailSearchSyntax.parse!('"dinner and movie tonight"')
275
- assert_instance_of Substring, ast
283
+ assert_instance_of ExactWord, ast
276
284
  assert_equal "dinner and movie tonight", ast.value
277
285
  end
278
286
 
@@ -381,7 +389,7 @@ class GmailSearchSyntaxTest < Minitest::Test
381
389
 
382
390
  def test_plain_text_search
383
391
  ast = GmailSearchSyntax.parse!("meeting")
384
- assert_instance_of StringToken, ast
392
+ assert_instance_of LooseWord, ast
385
393
  assert_equal "meeting", ast.value
386
394
  end
387
395
 
@@ -390,10 +398,10 @@ class GmailSearchSyntaxTest < Minitest::Test
390
398
  assert_instance_of And, ast
391
399
 
392
400
  assert_equal 2, ast.operands.length
393
- assert_instance_of StringToken, ast.operands[0]
401
+ assert_instance_of LooseWord, ast.operands[0]
394
402
  assert_equal "project", ast.operands[0].value
395
403
 
396
- assert_instance_of StringToken, ast.operands[1]
404
+ assert_instance_of LooseWord, ast.operands[1]
397
405
  assert_equal "report", ast.operands[1].value
398
406
  end
399
407
 
@@ -471,7 +479,7 @@ class GmailSearchSyntaxTest < Minitest::Test
471
479
 
472
480
  def test_quoted_string_with_operators_inside
473
481
  ast = GmailSearchSyntax.parse!('"from:amy to:bob"')
474
- assert_instance_of Substring, ast
482
+ assert_instance_of ExactWord, ast
475
483
  assert_equal "from:amy to:bob", ast.value
476
484
  end
477
485
 
@@ -508,7 +516,7 @@ class GmailSearchSyntaxTest < Minitest::Test
508
516
 
509
517
  def test_parentheses_with_single_term
510
518
  ast = GmailSearchSyntax.parse!("(meeting)")
511
- assert_instance_of StringToken, ast
519
+ assert_instance_of LooseWord, ast
512
520
  assert_equal "meeting", ast.value
513
521
  end
514
522
 
@@ -526,10 +534,10 @@ class GmailSearchSyntaxTest < Minitest::Test
526
534
  assert_instance_of And, ast
527
535
 
528
536
  assert_equal 2, ast.operands.length
529
- assert_instance_of StringToken, ast.operands[0]
537
+ assert_instance_of LooseWord, ast.operands[0]
530
538
  assert_equal "meeting", ast.operands[0].value
531
539
 
532
- assert_instance_of StringToken, ast.operands[1]
540
+ assert_instance_of LooseWord, ast.operands[1]
533
541
  assert_equal "project", ast.operands[1].value
534
542
  end
535
543
 
@@ -622,7 +630,7 @@ class GmailSearchSyntaxTest < Minitest::Test
622
630
  assert_equal 2, ast.value.operands[0].operands.length
623
631
  assert_equal "urgent", ast.value.operands[0].operands[0].value
624
632
  assert_equal "important", ast.value.operands[0].operands[1].value
625
- assert_instance_of StringToken, ast.value.operands[1]
633
+ assert_instance_of LooseWord, ast.value.operands[1]
626
634
  assert_equal "meeting", ast.value.operands[1].value
627
635
  end
628
636
 
@@ -704,13 +712,13 @@ class GmailSearchSyntaxTest < Minitest::Test
704
712
 
705
713
  def test_quoted_string_with_escaped_quotes
706
714
  ast = GmailSearchSyntax.parse!('"She said \\"hello\\" to me"')
707
- assert_instance_of Substring, ast
715
+ assert_instance_of ExactWord, ast
708
716
  assert_equal 'She said "hello" to me', ast.value
709
717
  end
710
718
 
711
719
  def test_quoted_string_with_escaped_backslash
712
720
  ast = GmailSearchSyntax.parse!('"path\\\\to\\\\file"')
713
- assert_instance_of Substring, ast
721
+ assert_instance_of ExactWord, ast
714
722
  assert_equal 'path\\to\\file', ast.value
715
723
  end
716
724
 
@@ -723,13 +731,13 @@ class GmailSearchSyntaxTest < Minitest::Test
723
731
 
724
732
  def test_unquoted_text_with_escaped_quote
725
733
  ast = GmailSearchSyntax.parse!('meeting\\"room')
726
- assert_instance_of StringToken, ast
734
+ assert_instance_of LooseWord, ast
727
735
  assert_equal 'meeting"room', ast.value
728
736
  end
729
737
 
730
738
  def test_unquoted_text_with_escaped_backslash
731
739
  ast = GmailSearchSyntax.parse!('path\\\\to\\\\file')
732
- assert_instance_of StringToken, ast
740
+ assert_instance_of LooseWord, ast
733
741
  assert_equal 'path\\to\\file', ast.value
734
742
  end
735
743
 
@@ -764,7 +772,7 @@ class GmailSearchSyntaxTest < Minitest::Test
764
772
  assert_equal "Cora/Google", ast.operands[0].value
765
773
 
766
774
  # "Drive" becomes a separate search term
767
- assert_instance_of StringToken, ast.operands[1]
775
+ assert_instance_of LooseWord, ast.operands[1]
768
776
  assert_equal "Drive", ast.operands[1].value
769
777
 
770
778
  # Second operator parsed correctly
@@ -847,4 +855,55 @@ class GmailSearchSyntaxTest < Minitest::Test
847
855
  # Second operator: label with "Notes"
848
856
  assert_operator({name: "label", value: "Notes"}, ast.operands[2])
849
857
  end
858
+
859
+ def test_label_with_consecutive_hyphens
860
+ # Labels like "Every (Every.To)" get sanitized to "every--every.to-"
861
+ # Gmail replaces parens, spaces, slashes with hyphens
862
+ ast = GmailSearchSyntax.parse!("label:every--every.to-")
863
+ assert_instance_of Operator, ast
864
+ assert_equal "label", ast.name
865
+ assert_equal "every--every.to-", ast.value
866
+ end
867
+
868
+ def test_label_with_dots_and_hyphens
869
+ # Complex label names with dots should work
870
+ ast = GmailSearchSyntax.parse!("label:cora-every--every.to-")
871
+ assert_instance_of Operator, ast
872
+ assert_equal "label", ast.name
873
+ assert_equal "cora-every--every.to-", ast.value
874
+ end
875
+
876
+ def test_quoted_word_with_or_group
877
+ # Quoted word followed by OR group in parentheses
878
+ ast = GmailSearchSyntax.parse!('"dropbox" (file OR share OR sync OR storage OR cloud)')
879
+ assert_instance_of And, ast
880
+ assert_equal 2, ast.operands.length
881
+
882
+ # First operand: quoted "dropbox" as ExactWord
883
+ assert_instance_of ExactWord, ast.operands[0]
884
+ assert_equal "dropbox", ast.operands[0].value
885
+
886
+ # Second operand: OR group with 5 terms
887
+ assert_instance_of Or, ast.operands[1]
888
+ assert_equal 5, ast.operands[1].operands.length
889
+ assert_equal "file", ast.operands[1].operands[0].value
890
+ assert_equal "share", ast.operands[1].operands[1].value
891
+ assert_equal "sync", ast.operands[1].operands[2].value
892
+ assert_equal "storage", ast.operands[1].operands[3].value
893
+ assert_equal "cloud", ast.operands[1].operands[4].value
894
+ end
895
+
896
+ def test_unknown_operator_is_searched_as_typed
897
+ assert_equal LooseWord.new("foo:bar"), GmailSearchSyntax.parse!("foo:bar")
898
+ assert_equal And.new([LooseWord.new("foo:bar"), LooseWord.new("baz")]), GmailSearchSyntax.parse!("foo:bar baz")
899
+ assert_equal LooseWord.new("http://x.y/z"), GmailSearchSyntax.parse!("http://x.y/z")
900
+ assert_equal LooseWord.new("a:b:c"), GmailSearchSyntax.parse!("a:b:c")
901
+ assert_equal And.new([LooseWord.new("foo"), LooseWord.new("bar")]), GmailSearchSyntax.parse!("foo: bar")
902
+ assert_equal And.new([Operator.new("from", "bob"), LooseWord.new("foo:bar")]), GmailSearchSyntax.parse!("from:bob foo:bar")
903
+ end
904
+
905
+ def test_a_stray_token_does_not_swallow_the_rest_of_the_query
906
+ assert_equal And.new([LooseWord.new("foo"), Operator.new("from", "bob")]), GmailSearchSyntax.parse!("foo ) from:bob")
907
+ assert_equal And.new([LooseWord.new("foo"), LooseWord.new("bar"), Operator.new("to", "ann")]), GmailSearchSyntax.parse!("foo : bar to:ann")
908
+ end
850
909
  end
@@ -255,7 +255,7 @@ class SqlVisitorTest < Minitest::Test
255
255
  def test_quoted_text_search_uses_substring
256
256
  sql, params = parse_and_visit('"meeting"')
257
257
 
258
- # Quoted strings create Substring nodes which use LIKE %value%
258
+ # Quoted strings create ExactWord nodes which use LIKE %value%
259
259
  assert_includes sql, "m0.subject LIKE ?"
260
260
  assert_includes sql, "m0.body LIKE ?"
261
261
  assert_equal ["%meeting%", "%meeting%"], params
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gmail_search_syntax
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - me@julik.nl
@@ -121,7 +121,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
121
121
  - !ruby/object:Gem::Version
122
122
  version: '0'
123
123
  requirements: []
124
- rubygems_version: 3.6.7
124
+ rubygems_version: 3.6.9
125
125
  specification_version: 4
126
126
  summary: Gmail search syntax parser
127
127
  test_files: []