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 +4 -4
- data/examples/escaped_quotes_demo.rb +3 -3
- data/examples/text_vs_substring_demo.rb +5 -5
- data/lib/gmail_search_syntax/ast.rb +14 -10
- data/lib/gmail_search_syntax/parser.rb +31 -5
- data/lib/gmail_search_syntax/sql_visitor.rb +7 -7
- data/lib/gmail_search_syntax/tokenizer.rb +83 -2
- data/lib/gmail_search_syntax/version.rb +1 -1
- data/slop/EMBEDDED_HYPHENS.md +102 -0
- data/test/gmail_search_syntax_test.rb +166 -22
- data/test/sql_visitor_test.rb +1 -1
- data/test/tokenizer_test.rb +58 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9a9c5131ce88b747e30a23baa0c32943ccc820097b288c9cec7b8275ec5bdd8
|
|
4
|
+
data.tar.gz: 4f52b8ad66ca0b8894ee3105ca0f9d9cb527dcf4910284ff9f53811f53ebdb81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 (
|
|
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 (
|
|
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 "
|
|
6
|
+
puts "LooseWord vs ExactWord Demo"
|
|
7
7
|
puts "=" * 80
|
|
8
8
|
puts
|
|
9
9
|
|
|
10
|
-
# Example 1: Unquoted text (
|
|
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 (
|
|
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 "
|
|
92
|
-
puts "
|
|
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
|
-
"
|
|
26
|
+
"#<#{short_class_name} #{@name}: #{@value.inspect}>"
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
|
|
26
|
-
class
|
|
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
|
-
"
|
|
38
|
+
"#<#{short_class_name} #{@value.inspect}>"
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
class
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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::
|
|
136
|
+
AST::ExactWord.new(value)
|
|
133
137
|
when :email, :number, :date, :relative_time
|
|
134
138
|
value = current_token.value
|
|
135
139
|
advance
|
|
136
|
-
AST::
|
|
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
|
-
|
|
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::
|
|
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::
|
|
50
|
-
|
|
51
|
-
when AST::
|
|
52
|
-
|
|
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
|
|
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
|
|
346
|
-
#
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
401
|
+
assert_instance_of LooseWord, ast.operands[0]
|
|
309
402
|
assert_equal "project", ast.operands[0].value
|
|
310
403
|
|
|
311
|
-
assert_instance_of
|
|
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
|
|
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
|
|
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
|
|
537
|
+
assert_instance_of LooseWord, ast.operands[0]
|
|
445
538
|
assert_equal "meeting", ast.operands[0].value
|
|
446
539
|
|
|
447
|
-
assert_instance_of
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/test/sql_visitor_test.rb
CHANGED
|
@@ -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
|
|
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
|
data/test/tokenizer_test.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
124
|
+
rubygems_version: 3.6.9
|
|
124
125
|
specification_version: 4
|
|
125
126
|
summary: Gmail search syntax parser
|
|
126
127
|
test_files: []
|