gmail_search_syntax 0.1.3 → 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: d7c192efdd70d31b0747a356b1adab4c50160afb81e747d1088f2727f97c7262
4
- data.tar.gz: 73f6401e73d2ecae59b6e6741c08786d2da3dffbbdcb6f21095c63bf40761f6d
3
+ metadata.gz: b9a9c5131ce88b747e30a23baa0c32943ccc820097b288c9cec7b8275ec5bdd8
4
+ data.tar.gz: 4f52b8ad66ca0b8894ee3105ca0f9d9cb527dcf4910284ff9f53811f53ebdb81
5
5
  SHA512:
6
- metadata.gz: ce609cacd2a6276d9178276ee84e0ccffc80eb8840bd62e6402e2275817ba2052559f903a55648373e87a79527415d6be4df1d4052429c83e3faa9abc1fe2c1c
7
- data.tar.gz: 7badbc6024a67752bfab201577ebeb900ba106ed9979110c04eed6c76d35e33e26a7b1147927a2fd9d329f6a7bc28b71d2487d0f26e87f23a9dd7f867fac7e09
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
@@ -55,9 +58,16 @@ module GmailSearchSyntax
55
58
  advance
56
59
  when "-"
57
60
  next_char = peek_char
58
- if next_char && next_char !~ /\s/
61
+ prev_char = (@position > 0) ? @input[@position - 1] : nil
62
+ # Negation requires: non-whitespace follows AND (start of input OR whitespace precedes)
63
+ # Gmail behavior: "Coxlee-Gammage" → Coxlee AND Gammage (hyphen is word separator)
64
+ # "Coxlee -Gammage" → Coxlee AND NOT Gammage (space+hyphen = negation)
65
+ if next_char && next_char !~ /\s/ && (prev_char.nil? || prev_char =~ /\s/)
59
66
  add_token(:minus, char)
60
67
  advance
68
+ elsif prev_char && prev_char !~ /\s/
69
+ # Embedded hyphen (preceded by non-whitespace) - skip it as word separator
70
+ advance
61
71
  else
62
72
  read_word
63
73
  end
@@ -69,11 +79,18 @@ module GmailSearchSyntax
69
79
  when ":"
70
80
  add_token(:colon, char)
71
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
72
88
  else
73
89
  read_word
74
90
  end
75
91
  end
76
92
 
93
+ @token_start = @position
77
94
  add_token(:eof, nil)
78
95
  @tokens
79
96
  end
@@ -98,8 +115,72 @@ module GmailSearchSyntax
98
115
  end
99
116
  end
100
117
 
118
+ # Tokens carry the offset they start at, so the parser can tell "foo:bar"
119
+ # from "foo: bar".
101
120
  def add_token(type, value)
102
- @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
103
184
  end
104
185
 
105
186
  def read_quoted_string
@@ -1,3 +1,3 @@
1
1
  module GmailSearchSyntax
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.5"
3
3
  end
@@ -0,0 +1,102 @@
1
+ # Embedded Hyphens in Gmail Search
2
+
3
+ ## Gmail's Actual Behavior
4
+
5
+ Gmail treats hyphens differently depending on whether they are preceded by whitespace:
6
+
7
+ ### Embedded Hyphen (No Preceding Whitespace)
8
+
9
+ When a hyphen appears immediately after a word character (no space before it), Gmail treats it as a **word separator**, not a negation operator. Both parts become separate search tokens that are implicitly ANDed together.
10
+
11
+ ```
12
+ Coxlee-Gammage
13
+ ```
14
+
15
+ Gmail behavior: Search for messages containing both "Coxlee" AND "Gammage". Both tokens get highlighted in search results.
16
+
17
+ Parsed as:
18
+ ```ruby
19
+ GmailSearchSyntax.parse!("Coxlee-Gammage")
20
+ # => #<And [#<StringToken "Coxlee">, #<StringToken "Gammage">]>
21
+ ```
22
+
23
+ ### Space + Hyphen (Negation)
24
+
25
+ When a hyphen is preceded by whitespace (or at the start of input), it functions as the **negation operator**.
26
+
27
+ ```
28
+ Coxlee -Gammage
29
+ ```
30
+
31
+ Gmail behavior: Search for messages containing "Coxlee" but NOT "Gammage".
32
+
33
+ Parsed as:
34
+ ```ruby
35
+ GmailSearchSyntax.parse!("Coxlee -Gammage")
36
+ # => #<And [#<StringToken "Coxlee">, #<Not #<StringToken "Gammage">>]>
37
+ ```
38
+
39
+ ## Examples
40
+
41
+ | Query | Parsed As | Meaning |
42
+ |-------|-----------|---------|
43
+ | `some-outfit` | `some AND outfit` | Contains both "some" and "outfit" |
44
+ | `some -outfit` | `some AND NOT outfit` | Contains "some" but not "outfit" |
45
+ | `a-b-c` | `a AND b AND c` | Contains all three tokens |
46
+ | `-spam` | `NOT spam` | Does not contain "spam" |
47
+ | `cats-dogs -birds` | `cats AND dogs AND NOT birds` | Contains "cats" and "dogs", not "birds" |
48
+
49
+ ## Real-World Use Cases
50
+
51
+ ### Hyphenated Names
52
+ ```
53
+ from:Mary-Jane
54
+ ```
55
+ Searches for emails where "from" contains "Mary" AND message contains "Jane".
56
+
57
+ ### Hyphenated Terms
58
+ ```
59
+ self-service
60
+ ```
61
+ Finds messages containing both "self" and "service".
62
+
63
+ ### Compound Words
64
+ ```
65
+ e-commerce
66
+ ```
67
+ Finds messages containing both "e" and "commerce".
68
+
69
+ ## Implementation Details
70
+
71
+ The fix is in the tokenizer (`lib/gmail_search_syntax/tokenizer.rb`). When encountering a `-` character:
72
+
73
+ 1. Check if there's a non-whitespace character following (potential negation or word separator)
74
+ 2. Check if there's whitespace (or nothing) preceding the hyphen
75
+ 3. If preceded by whitespace or at start of input: treat as negation operator (`:minus` token)
76
+ 4. If preceded by non-whitespace: skip the hyphen (acts as word separator, no token emitted)
77
+
78
+ ```ruby
79
+ when "-"
80
+ next_char = peek_char
81
+ prev_char = @position > 0 ? @input[@position - 1] : nil
82
+
83
+ if next_char && next_char !~ /\s/ && (prev_char.nil? || prev_char =~ /\s/)
84
+ # Negation: preceded by whitespace or at start, followed by non-whitespace
85
+ add_token(:minus, char)
86
+ advance
87
+ elsif prev_char && prev_char !~ /\s/
88
+ # Embedded hyphen: preceded by non-whitespace - skip as word separator
89
+ advance
90
+ else
91
+ read_word
92
+ end
93
+ ```
94
+
95
+ ## Bug That Was Fixed
96
+
97
+ Previously, the gem incorrectly treated all hyphens followed by non-whitespace as negation:
98
+
99
+ - **Old (incorrect):** `some-outfit` was parsed as `some AND NOT outfit`
100
+ - **New (correct):** `some-outfit` is parsed as `some AND outfit`
101
+
102
+ This matches Gmail's actual search behavior where hyphenated terms find messages containing both parts of the hyphenated word.
@@ -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,29 +134,122 @@ 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
 
145
+ # Gmail behavior: embedded hyphens (no preceding whitespace) are word separators, not negation
146
+ # "Coxlee-Gammage" → Coxlee AND Gammage (both tokens highlighted)
147
+ # "Coxlee -Gammage" → Coxlee AND NOT Gammage (space+hyphen = negation)
148
+
149
+ def test_embedded_hyphen_is_word_separator
150
+ # Gmail behavior: hyphen without preceding whitespace separates words, not negation
151
+ ast = GmailSearchSyntax.parse!("some-outfit")
152
+ assert_instance_of And, ast
153
+
154
+ assert_equal 2, ast.operands.length
155
+ assert_instance_of LooseWord, ast.operands[0]
156
+ assert_equal "some", ast.operands[0].value
157
+
158
+ assert_instance_of LooseWord, ast.operands[1]
159
+ assert_equal "outfit", ast.operands[1].value
160
+ end
161
+
162
+ def test_embedded_hyphen_multiple
163
+ # Multiple hyphens: a-b-c → a AND b AND c
164
+ ast = GmailSearchSyntax.parse!("a-b-c")
165
+ assert_instance_of And, ast
166
+
167
+ assert_equal 3, ast.operands.length
168
+ assert_equal "a", ast.operands[0].value
169
+ assert_equal "b", ast.operands[1].value
170
+ assert_equal "c", ast.operands[2].value
171
+ end
172
+
173
+ def test_embedded_hyphen_real_name
174
+ # Real-world case: hyphenated names
175
+ ast = GmailSearchSyntax.parse!("Coxlee-Gammage")
176
+ assert_instance_of And, ast
177
+
178
+ assert_equal 2, ast.operands.length
179
+ assert_equal "Coxlee", ast.operands[0].value
180
+ assert_equal "Gammage", ast.operands[1].value
181
+ end
182
+
183
+ def test_space_hyphen_is_negation
184
+ # Space + hyphen = negation (unchanged behavior)
185
+ ast = GmailSearchSyntax.parse!("cats -dogs")
186
+ assert_instance_of And, ast
187
+
188
+ assert_equal 2, ast.operands.length
189
+ assert_instance_of LooseWord, ast.operands[0]
190
+ assert_equal "cats", ast.operands[0].value
191
+
192
+ assert_instance_of Not, ast.operands[1]
193
+ assert_equal "dogs", ast.operands[1].child.value
194
+ end
195
+
196
+ def test_embedded_hyphen_combined_with_negation
197
+ # Mixed: embedded hyphen + space-preceded negation
198
+ ast = GmailSearchSyntax.parse!("some-outfit -dogs")
199
+ assert_instance_of And, ast
200
+
201
+ assert_equal 3, ast.operands.length
202
+ assert_equal "some", ast.operands[0].value
203
+ assert_equal "outfit", ast.operands[1].value
204
+ assert_instance_of Not, ast.operands[2]
205
+ assert_equal "dogs", ast.operands[2].child.value
206
+ end
207
+
208
+ def test_negation_at_start_of_input
209
+ # Negation at start of input still works
210
+ ast = GmailSearchSyntax.parse!("-spam")
211
+ assert_instance_of Not, ast
212
+ assert_equal "spam", ast.child.value
213
+ end
214
+
215
+ def test_embedded_hyphen_with_operator
216
+ # Gmail keeps hyphens in operator values: from:mary-jane → operator with value "mary-jane"
217
+ ast = GmailSearchSyntax.parse!("from:mary-jane")
218
+ assert_instance_of Operator, ast
219
+ assert_equal "from", ast.name
220
+ assert_equal "mary-jane", ast.value
221
+ end
222
+
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
230
+
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
236
+ end
237
+
145
238
  def test_around_operator
146
239
  ast = GmailSearchSyntax.parse!("holiday AROUND 10 vacation")
147
240
  assert_instance_of Around, ast
148
241
 
149
- assert_instance_of StringToken, ast.left
242
+ assert_instance_of LooseWord, ast.left
150
243
  assert_equal "holiday", ast.left.value
151
244
  assert_equal 10, ast.distance
152
245
 
153
- assert_instance_of StringToken, ast.right
246
+ assert_instance_of LooseWord, ast.right
154
247
  assert_equal "vacation", ast.right.value
155
248
  end
156
249
 
157
250
  def test_around_with_quoted_string
158
251
  ast = GmailSearchSyntax.parse!('"secret AROUND 25 birthday"')
159
- assert_instance_of Substring, ast
252
+ assert_instance_of ExactWord, ast
160
253
  assert_equal "secret AROUND 25 birthday", ast.value
161
254
  end
162
255
 
@@ -187,7 +280,7 @@ class GmailSearchSyntaxTest < Minitest::Test
187
280
 
188
281
  def test_quoted_exact_phrase
189
282
  ast = GmailSearchSyntax.parse!('"dinner and movie tonight"')
190
- assert_instance_of Substring, ast
283
+ assert_instance_of ExactWord, ast
191
284
  assert_equal "dinner and movie tonight", ast.value
192
285
  end
193
286
 
@@ -296,7 +389,7 @@ class GmailSearchSyntaxTest < Minitest::Test
296
389
 
297
390
  def test_plain_text_search
298
391
  ast = GmailSearchSyntax.parse!("meeting")
299
- assert_instance_of StringToken, ast
392
+ assert_instance_of LooseWord, ast
300
393
  assert_equal "meeting", ast.value
301
394
  end
302
395
 
@@ -305,10 +398,10 @@ class GmailSearchSyntaxTest < Minitest::Test
305
398
  assert_instance_of And, ast
306
399
 
307
400
  assert_equal 2, ast.operands.length
308
- assert_instance_of StringToken, ast.operands[0]
401
+ assert_instance_of LooseWord, ast.operands[0]
309
402
  assert_equal "project", ast.operands[0].value
310
403
 
311
- assert_instance_of StringToken, ast.operands[1]
404
+ assert_instance_of LooseWord, ast.operands[1]
312
405
  assert_equal "report", ast.operands[1].value
313
406
  end
314
407
 
@@ -386,7 +479,7 @@ class GmailSearchSyntaxTest < Minitest::Test
386
479
 
387
480
  def test_quoted_string_with_operators_inside
388
481
  ast = GmailSearchSyntax.parse!('"from:amy to:bob"')
389
- assert_instance_of Substring, ast
482
+ assert_instance_of ExactWord, ast
390
483
  assert_equal "from:amy to:bob", ast.value
391
484
  end
392
485
 
@@ -423,7 +516,7 @@ class GmailSearchSyntaxTest < Minitest::Test
423
516
 
424
517
  def test_parentheses_with_single_term
425
518
  ast = GmailSearchSyntax.parse!("(meeting)")
426
- assert_instance_of StringToken, ast
519
+ assert_instance_of LooseWord, ast
427
520
  assert_equal "meeting", ast.value
428
521
  end
429
522
 
@@ -441,10 +534,10 @@ class GmailSearchSyntaxTest < Minitest::Test
441
534
  assert_instance_of And, ast
442
535
 
443
536
  assert_equal 2, ast.operands.length
444
- assert_instance_of StringToken, ast.operands[0]
537
+ assert_instance_of LooseWord, ast.operands[0]
445
538
  assert_equal "meeting", ast.operands[0].value
446
539
 
447
- assert_instance_of StringToken, ast.operands[1]
540
+ assert_instance_of LooseWord, ast.operands[1]
448
541
  assert_equal "project", ast.operands[1].value
449
542
  end
450
543
 
@@ -537,7 +630,7 @@ class GmailSearchSyntaxTest < Minitest::Test
537
630
  assert_equal 2, ast.value.operands[0].operands.length
538
631
  assert_equal "urgent", ast.value.operands[0].operands[0].value
539
632
  assert_equal "important", ast.value.operands[0].operands[1].value
540
- assert_instance_of StringToken, ast.value.operands[1]
633
+ assert_instance_of LooseWord, ast.value.operands[1]
541
634
  assert_equal "meeting", ast.value.operands[1].value
542
635
  end
543
636
 
@@ -619,13 +712,13 @@ class GmailSearchSyntaxTest < Minitest::Test
619
712
 
620
713
  def test_quoted_string_with_escaped_quotes
621
714
  ast = GmailSearchSyntax.parse!('"She said \\"hello\\" to me"')
622
- assert_instance_of Substring, ast
715
+ assert_instance_of ExactWord, ast
623
716
  assert_equal 'She said "hello" to me', ast.value
624
717
  end
625
718
 
626
719
  def test_quoted_string_with_escaped_backslash
627
720
  ast = GmailSearchSyntax.parse!('"path\\\\to\\\\file"')
628
- assert_instance_of Substring, ast
721
+ assert_instance_of ExactWord, ast
629
722
  assert_equal 'path\\to\\file', ast.value
630
723
  end
631
724
 
@@ -638,13 +731,13 @@ class GmailSearchSyntaxTest < Minitest::Test
638
731
 
639
732
  def test_unquoted_text_with_escaped_quote
640
733
  ast = GmailSearchSyntax.parse!('meeting\\"room')
641
- assert_instance_of StringToken, ast
734
+ assert_instance_of LooseWord, ast
642
735
  assert_equal 'meeting"room', ast.value
643
736
  end
644
737
 
645
738
  def test_unquoted_text_with_escaped_backslash
646
739
  ast = GmailSearchSyntax.parse!('path\\\\to\\\\file')
647
- assert_instance_of StringToken, ast
740
+ assert_instance_of LooseWord, ast
648
741
  assert_equal 'path\\to\\file', ast.value
649
742
  end
650
743
 
@@ -679,7 +772,7 @@ class GmailSearchSyntaxTest < Minitest::Test
679
772
  assert_equal "Cora/Google", ast.operands[0].value
680
773
 
681
774
  # "Drive" becomes a separate search term
682
- assert_instance_of StringToken, ast.operands[1]
775
+ assert_instance_of LooseWord, ast.operands[1]
683
776
  assert_equal "Drive", ast.operands[1].value
684
777
 
685
778
  # Second operator parsed correctly
@@ -762,4 +855,55 @@ class GmailSearchSyntaxTest < Minitest::Test
762
855
  # Second operator: label with "Notes"
763
856
  assert_operator({name: "label", value: "Notes"}, ast.operands[2])
764
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
765
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
@@ -94,6 +94,64 @@ class TokenizerTest < Minitest::Test
94
94
  assert_token_stream(expected, tokens)
95
95
  end
96
96
 
97
+ def test_tokenize_embedded_hyphen
98
+ # Gmail behavior: embedded hyphen (no preceding whitespace) is a word separator, not negation
99
+ tokens = tokenize("some-outfit")
100
+ expected = [
101
+ {type: :word, value: "some"},
102
+ {type: :word, value: "outfit"},
103
+ {type: :eof}
104
+ ]
105
+ assert_token_stream(expected, tokens)
106
+ end
107
+
108
+ def test_tokenize_multiple_embedded_hyphens
109
+ # Multiple hyphens: a-b-c → three separate words
110
+ tokens = tokenize("a-b-c")
111
+ expected = [
112
+ {type: :word, value: "a"},
113
+ {type: :word, value: "b"},
114
+ {type: :word, value: "c"},
115
+ {type: :eof}
116
+ ]
117
+ assert_token_stream(expected, tokens)
118
+ end
119
+
120
+ def test_tokenize_hyphenated_name
121
+ # Real-world case: hyphenated names like "Coxlee-Gammage"
122
+ tokens = tokenize("Coxlee-Gammage")
123
+ expected = [
124
+ {type: :word, value: "Coxlee"},
125
+ {type: :word, value: "Gammage"},
126
+ {type: :eof}
127
+ ]
128
+ assert_token_stream(expected, tokens)
129
+ end
130
+
131
+ def test_tokenize_negation_at_start
132
+ # Negation at start of input
133
+ tokens = tokenize("-spam")
134
+ expected = [
135
+ {type: :minus},
136
+ {type: :word, value: "spam"},
137
+ {type: :eof}
138
+ ]
139
+ assert_token_stream(expected, tokens)
140
+ end
141
+
142
+ def test_tokenize_embedded_hyphen_vs_negation
143
+ # Mixed: embedded hyphen + space-preceded negation
144
+ tokens = tokenize("some-outfit -dogs")
145
+ expected = [
146
+ {type: :word, value: "some"},
147
+ {type: :word, value: "outfit"},
148
+ {type: :minus},
149
+ {type: :word, value: "dogs"},
150
+ {type: :eof}
151
+ ]
152
+ assert_token_stream(expected, tokens)
153
+ end
154
+
97
155
  def test_tokenize_around
98
156
  tokens = tokenize("holiday AROUND 10 vacation")
99
157
  expected = [
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.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - me@julik.nl
@@ -92,6 +92,7 @@ files:
92
92
  - lib/gmail_search_syntax/tokenizer.rb
93
93
  - lib/gmail_search_syntax/version.rb
94
94
  - slop/ARCHITECTURE.md
95
+ - slop/EMBEDDED_HYPHENS.md
95
96
  - slop/GMAIL_BEHAVIOR_COMPARISON.md
96
97
  - slop/GMAIL_COMPATIBILITY_COMPLETE.md
97
98
  - slop/GREEDY_VS_NON_GREEDY_TOKENIZATION.md
@@ -120,7 +121,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
121
  - !ruby/object:Gem::Version
121
122
  version: '0'
122
123
  requirements: []
123
- rubygems_version: 3.6.7
124
+ rubygems_version: 3.6.9
124
125
  specification_version: 4
125
126
  summary: Gmail search syntax parser
126
127
  test_files: []