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 +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 +75 -1
- data/lib/gmail_search_syntax/version.rb +1 -1
- data/test/gmail_search_syntax_test.rb +93 -34
- data/test/sql_visitor_test.rb +1 -1
- metadata +2 -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
|
|
@@ -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
|
|
@@ -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,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
|
|
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
|
|
|
@@ -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
|
|
155
|
+
assert_instance_of LooseWord, ast.operands[0]
|
|
156
156
|
assert_equal "some", ast.operands[0].value
|
|
157
157
|
|
|
158
|
-
assert_instance_of
|
|
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
|
|
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
|
-
#
|
|
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
|
|
218
|
+
assert_instance_of Operator, ast
|
|
219
|
+
assert_equal "from", ast.name
|
|
220
|
+
assert_equal "mary-jane", ast.value
|
|
221
|
+
end
|
|
219
222
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
assert_equal "
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
401
|
+
assert_instance_of LooseWord, ast.operands[0]
|
|
394
402
|
assert_equal "project", ast.operands[0].value
|
|
395
403
|
|
|
396
|
-
assert_instance_of
|
|
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
|
|
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
|
|
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
|
|
537
|
+
assert_instance_of LooseWord, ast.operands[0]
|
|
530
538
|
assert_equal "meeting", ast.operands[0].value
|
|
531
539
|
|
|
532
|
-
assert_instance_of
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
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
|
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
|
|
@@ -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.
|
|
124
|
+
rubygems_version: 3.6.9
|
|
125
125
|
specification_version: 4
|
|
126
126
|
summary: Gmail search syntax parser
|
|
127
127
|
test_files: []
|