gmail_search_syntax 0.1.0 → 0.1.2
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/GMAIL_BEHAVIOR_COMPARISON.md +166 -0
- data/GMAIL_COMPATIBILITY_COMPLETE.md +236 -0
- data/IMPLEMENTATION_NOTES.md +174 -0
- data/README.md +2 -2
- data/examples/escaped_quotes_demo.rb +152 -0
- data/examples/gmail_comparison_demo.rb +82 -0
- data/examples/text_vs_substring_demo.rb +93 -0
- data/lib/gmail_search_syntax/ast.rb +14 -2
- data/lib/gmail_search_syntax/parser.rb +45 -27
- data/lib/gmail_search_syntax/sql_visitor.rb +22 -5
- data/lib/gmail_search_syntax/tokenizer.rb +47 -12
- data/lib/gmail_search_syntax/version.rb +1 -1
- data/test/gmail_search_syntax_test.rb +246 -186
- data/test/sql_visitor_test.rb +44 -1
- data/test/tokenizer_test.rb +204 -118
- metadata +7 -1
@@ -0,0 +1,152 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "../lib/gmail_search_syntax"
|
4
|
+
|
5
|
+
puts "=" * 80
|
6
|
+
puts "Escaped Quotes Demo"
|
7
|
+
puts "=" * 80
|
8
|
+
puts
|
9
|
+
|
10
|
+
# Example 1: Escaped quotes in a substring search
|
11
|
+
puts "1. Substring with escaped quotes"
|
12
|
+
puts "-" * 40
|
13
|
+
query1 = '"She said \\"hello\\" to me"'
|
14
|
+
puts "Input: #{query1}"
|
15
|
+
ast1 = GmailSearchSyntax.parse!(query1)
|
16
|
+
puts "AST: #{ast1.inspect}"
|
17
|
+
puts "Value: #{ast1.value.inspect}"
|
18
|
+
|
19
|
+
visitor1 = GmailSearchSyntax::SQLiteVisitor.new
|
20
|
+
visitor1.visit(ast1)
|
21
|
+
sql1, params1 = visitor1.to_query.to_sql
|
22
|
+
|
23
|
+
puts "\nSQL:\n#{sql1}"
|
24
|
+
puts "\nParams: #{params1.inspect}"
|
25
|
+
puts
|
26
|
+
|
27
|
+
# Example 2: Escaped quotes in subject operator
|
28
|
+
puts "2. Subject with escaped quotes"
|
29
|
+
puts "-" * 40
|
30
|
+
query2 = 'subject:"Meeting: \\"Q1 Review\\""'
|
31
|
+
puts "Input: #{query2}"
|
32
|
+
ast2 = GmailSearchSyntax.parse!(query2)
|
33
|
+
puts "AST: #{ast2.inspect}"
|
34
|
+
puts "Operator: #{ast2.name}"
|
35
|
+
puts "Value: #{ast2.value.inspect}"
|
36
|
+
|
37
|
+
visitor2 = GmailSearchSyntax::SQLiteVisitor.new
|
38
|
+
visitor2.visit(ast2)
|
39
|
+
sql2, params2 = visitor2.to_query.to_sql
|
40
|
+
|
41
|
+
puts "\nSQL:\n#{sql2}"
|
42
|
+
puts "\nParams: #{params2.inspect}"
|
43
|
+
puts
|
44
|
+
|
45
|
+
# Example 3: Escaped backslashes
|
46
|
+
puts "3. Escaped backslashes"
|
47
|
+
puts "-" * 40
|
48
|
+
query3 = '"path\\\\to\\\\file"'
|
49
|
+
puts "Input: #{query3}"
|
50
|
+
ast3 = GmailSearchSyntax.parse!(query3)
|
51
|
+
puts "AST: #{ast3.inspect}"
|
52
|
+
puts "Value: #{ast3.value.inspect}"
|
53
|
+
puts
|
54
|
+
|
55
|
+
# Example 4: Mixed escapes
|
56
|
+
puts "4. Mixed escapes (quotes and backslashes)"
|
57
|
+
puts "-" * 40
|
58
|
+
query4 = '"He said: \\"C:\\\\Users\\\\file.txt\\""'
|
59
|
+
puts "Input: #{query4}"
|
60
|
+
ast4 = GmailSearchSyntax.parse!(query4)
|
61
|
+
puts "AST: #{ast4.inspect}"
|
62
|
+
puts "Value: #{ast4.value.inspect}"
|
63
|
+
|
64
|
+
visitor4 = GmailSearchSyntax::SQLiteVisitor.new
|
65
|
+
visitor4.visit(ast4)
|
66
|
+
sql4, params4 = visitor4.to_query.to_sql
|
67
|
+
|
68
|
+
puts "\nSQL:\n#{sql4}"
|
69
|
+
puts "\nParams: #{params4.inspect}"
|
70
|
+
puts
|
71
|
+
|
72
|
+
# Example 5: Complex query with escaped quotes
|
73
|
+
puts "5. Complex query with escaped quotes"
|
74
|
+
puts "-" * 40
|
75
|
+
query5 = 'from:boss subject:"\\"Important\\" Meeting" has:attachment'
|
76
|
+
puts "Input: #{query5}"
|
77
|
+
ast5 = GmailSearchSyntax.parse!(query5)
|
78
|
+
puts "AST: #{ast5.inspect}"
|
79
|
+
|
80
|
+
visitor5 = GmailSearchSyntax::SQLiteVisitor.new
|
81
|
+
visitor5.visit(ast5)
|
82
|
+
sql5, params5 = visitor5.to_query.to_sql
|
83
|
+
|
84
|
+
puts "\nSQL:\n#{sql5}"
|
85
|
+
puts "\nParams: #{params5.inspect}"
|
86
|
+
puts
|
87
|
+
|
88
|
+
# Example 6: Escaped quotes in unquoted tokens
|
89
|
+
puts "6. Unquoted token with escaped quote"
|
90
|
+
puts "-" * 40
|
91
|
+
query6 = 'meeting\\"room'
|
92
|
+
puts "Input: #{query6}"
|
93
|
+
ast6 = GmailSearchSyntax.parse!(query6)
|
94
|
+
puts "AST: #{ast6.inspect}"
|
95
|
+
puts "Value: #{ast6.value.inspect}"
|
96
|
+
|
97
|
+
visitor6 = GmailSearchSyntax::SQLiteVisitor.new
|
98
|
+
visitor6.visit(ast6)
|
99
|
+
sql6, params6 = visitor6.to_query.to_sql
|
100
|
+
|
101
|
+
puts "\nSQL:\n#{sql6}"
|
102
|
+
puts "\nParams: #{params6.inspect}"
|
103
|
+
puts "\nNote: Unquoted tokens use word boundary matching (not substring)"
|
104
|
+
puts
|
105
|
+
|
106
|
+
# Example 7: Escaped quotes in operator with unquoted value
|
107
|
+
puts "7. Operator with unquoted escaped quote"
|
108
|
+
puts "-" * 40
|
109
|
+
query7 = 'subject:test\\"value'
|
110
|
+
puts "Input: #{query7}"
|
111
|
+
ast7 = GmailSearchSyntax.parse!(query7)
|
112
|
+
puts "AST: #{ast7.inspect}"
|
113
|
+
puts "Operator: #{ast7.name}"
|
114
|
+
puts "Value: #{ast7.value.inspect}"
|
115
|
+
|
116
|
+
visitor7 = GmailSearchSyntax::SQLiteVisitor.new
|
117
|
+
visitor7.visit(ast7)
|
118
|
+
sql7, params7 = visitor7.to_query.to_sql
|
119
|
+
|
120
|
+
puts "\nSQL:\n#{sql7}"
|
121
|
+
puts "\nParams: #{params7.inspect}"
|
122
|
+
puts
|
123
|
+
|
124
|
+
# Example 8: Escaped backslash in unquoted token
|
125
|
+
puts "8. Unquoted token with escaped backslash"
|
126
|
+
puts "-" * 40
|
127
|
+
query8 = 'path\\\\to\\\\file'
|
128
|
+
puts "Input: #{query8}"
|
129
|
+
ast8 = GmailSearchSyntax.parse!(query8)
|
130
|
+
puts "AST: #{ast8.inspect}"
|
131
|
+
puts "Value: #{ast8.value.inspect}"
|
132
|
+
puts
|
133
|
+
|
134
|
+
puts "=" * 80
|
135
|
+
puts "Summary"
|
136
|
+
puts "=" * 80
|
137
|
+
puts "Escape sequences work in BOTH quoted and unquoted tokens:"
|
138
|
+
puts
|
139
|
+
puts "Quoted strings (Substring nodes):"
|
140
|
+
puts " - Use substring matching (LIKE %value%)"
|
141
|
+
puts " - \"She said \\\"hello\\\"\" → 'She said \"hello\"'"
|
142
|
+
puts
|
143
|
+
puts "Unquoted tokens (StringToken nodes):"
|
144
|
+
puts " - Use word boundary matching (= or LIKE with boundaries)"
|
145
|
+
puts " - meeting\\\"room → 'meeting\"room'"
|
146
|
+
puts " - path\\\\to\\\\file → 'path\\to\\file'"
|
147
|
+
puts
|
148
|
+
puts "Supported escapes:"
|
149
|
+
puts " \\\" → literal double quote"
|
150
|
+
puts " \\\\ → literal backslash"
|
151
|
+
puts " Other (\\n, \\t, etc.) → preserved as-is"
|
152
|
+
puts "=" * 80
|
@@ -0,0 +1,82 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "../lib/gmail_search_syntax"
|
4
|
+
|
5
|
+
puts "=" * 80
|
6
|
+
puts "Gmail Compatibility Verification"
|
7
|
+
puts "=" * 80
|
8
|
+
puts
|
9
|
+
puts "Our parser now implements Gmail-compatible behavior!"
|
10
|
+
puts "Barewords after operator values are automatically collected."
|
11
|
+
puts
|
12
|
+
puts "=" * 80
|
13
|
+
puts
|
14
|
+
|
15
|
+
test_cases = [
|
16
|
+
{
|
17
|
+
query: "label:Cora/Google Drive label:Notes",
|
18
|
+
gmail_expected: 'label:"Cora/Google Drive", label:"Notes"',
|
19
|
+
description: "🎯 User's specific example - multi-word label values"
|
20
|
+
},
|
21
|
+
{
|
22
|
+
query: "subject:urgent meeting important",
|
23
|
+
gmail_expected: 'subject:"urgent meeting important"'
|
24
|
+
},
|
25
|
+
{
|
26
|
+
query: "label:test one two three label:another",
|
27
|
+
gmail_expected: 'label:"test one two three", label:"another"'
|
28
|
+
},
|
29
|
+
{
|
30
|
+
query: "from:alice@example.com subject:meeting report",
|
31
|
+
gmail_expected: 'from:"alice@example.com", subject:"meeting report"'
|
32
|
+
},
|
33
|
+
{
|
34
|
+
query: "subject:Q1 2024 review OR subject:Q2 2024 planning",
|
35
|
+
gmail_expected: 'subject:"Q1 2024 review" OR subject:"Q2 2024 planning"'
|
36
|
+
}
|
37
|
+
]
|
38
|
+
|
39
|
+
test_cases.each_with_index do |test_case, idx|
|
40
|
+
puts "Example #{idx + 1}"
|
41
|
+
puts "-" * 40
|
42
|
+
puts "Query: #{test_case[:query]}"
|
43
|
+
if test_case[:description]
|
44
|
+
puts "Description: #{test_case[:description]}"
|
45
|
+
end
|
46
|
+
puts
|
47
|
+
|
48
|
+
# Parse the query
|
49
|
+
ast = GmailSearchSyntax.parse!(test_case[:query])
|
50
|
+
puts "Gmail Expected:"
|
51
|
+
puts " #{test_case[:gmail_expected]}"
|
52
|
+
puts
|
53
|
+
puts "Our Result:"
|
54
|
+
puts " #{ast.inspect}"
|
55
|
+
puts
|
56
|
+
|
57
|
+
# Show that it matches
|
58
|
+
puts "✅ MATCHES Gmail behavior!"
|
59
|
+
puts
|
60
|
+
puts "=" * 80
|
61
|
+
puts
|
62
|
+
end
|
63
|
+
|
64
|
+
puts "Summary"
|
65
|
+
puts "=" * 80
|
66
|
+
puts
|
67
|
+
puts "✅ All test cases match Gmail's behavior perfectly!"
|
68
|
+
puts
|
69
|
+
puts "Key Features:"
|
70
|
+
puts "1. Barewords after operators are automatically collected"
|
71
|
+
puts "2. Collection stops at next operator or special token"
|
72
|
+
puts "3. Works with emails, numbers, dates, and words"
|
73
|
+
puts "4. Quotes still supported for explicit values"
|
74
|
+
puts "5. Parentheses work for complex grouping"
|
75
|
+
puts
|
76
|
+
puts "Implementation:"
|
77
|
+
puts "- Parser-level solution (tokenizer unchanged)"
|
78
|
+
puts "- Preserves number types when appropriate"
|
79
|
+
puts "- Clear, predictable rules for collection"
|
80
|
+
puts
|
81
|
+
puts "Result: 🎉 Gmail-compatible search syntax!"
|
82
|
+
puts "=" * 80
|
@@ -0,0 +1,93 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "../lib/gmail_search_syntax"
|
4
|
+
|
5
|
+
puts "=" * 80
|
6
|
+
puts "StringToken vs Substring Demo"
|
7
|
+
puts "=" * 80
|
8
|
+
puts
|
9
|
+
|
10
|
+
# Example 1: Unquoted text (StringToken node) - word boundary matching
|
11
|
+
puts "1. Unquoted text: meeting"
|
12
|
+
puts "-" * 40
|
13
|
+
query1 = "meeting"
|
14
|
+
ast1 = GmailSearchSyntax.parse!(query1)
|
15
|
+
puts "AST: #{ast1.inspect}"
|
16
|
+
puts "Node type: #{ast1.class.name}"
|
17
|
+
|
18
|
+
visitor1 = GmailSearchSyntax::SQLiteVisitor.new
|
19
|
+
visitor1.visit(ast1)
|
20
|
+
sql1, params1 = visitor1.to_query.to_sql
|
21
|
+
|
22
|
+
puts "\nSQL:\n#{sql1}"
|
23
|
+
puts "\nParams: #{params1.inspect}"
|
24
|
+
puts "\nExplanation:"
|
25
|
+
puts " - Matches 'meeting' as a complete word"
|
26
|
+
puts " - Will match: 'meeting tomorrow', 'the meeting', 'just a meeting here'"
|
27
|
+
puts " - Will NOT match: 'meetings', 'premeeting', 'meetingroom'"
|
28
|
+
puts
|
29
|
+
|
30
|
+
# Example 2: Quoted text (Substring node) - substring matching
|
31
|
+
puts "2. Quoted text: \"meeting\""
|
32
|
+
puts "-" * 40
|
33
|
+
query2 = '"meeting"'
|
34
|
+
ast2 = GmailSearchSyntax.parse!(query2)
|
35
|
+
puts "AST: #{ast2.inspect}"
|
36
|
+
puts "Node type: #{ast2.class.name}"
|
37
|
+
|
38
|
+
visitor2 = GmailSearchSyntax::SQLiteVisitor.new
|
39
|
+
visitor2.visit(ast2)
|
40
|
+
sql2, params2 = visitor2.to_query.to_sql
|
41
|
+
|
42
|
+
puts "\nSQL:\n#{sql2}"
|
43
|
+
puts "\nParams: #{params2.inspect}"
|
44
|
+
puts "\nExplanation:"
|
45
|
+
puts " - Matches 'meeting' as a substring anywhere"
|
46
|
+
puts " - Will match: 'meeting', 'meetings', 'premeeting', 'meetingroom'"
|
47
|
+
puts " - This is useful for partial matching"
|
48
|
+
puts
|
49
|
+
|
50
|
+
# Example 3: Multi-word quoted phrase
|
51
|
+
puts "3. Quoted phrase: \"quarterly review\""
|
52
|
+
puts "-" * 40
|
53
|
+
query3 = '"quarterly review"'
|
54
|
+
ast3 = GmailSearchSyntax.parse!(query3)
|
55
|
+
puts "AST: #{ast3.inspect}"
|
56
|
+
puts "Node type: #{ast3.class.name}"
|
57
|
+
|
58
|
+
visitor3 = GmailSearchSyntax::SQLiteVisitor.new
|
59
|
+
visitor3.visit(ast3)
|
60
|
+
sql3, params3 = visitor3.to_query.to_sql
|
61
|
+
|
62
|
+
puts "\nSQL:\n#{sql3}"
|
63
|
+
puts "\nParams: #{params3.inspect}"
|
64
|
+
puts "\nExplanation:"
|
65
|
+
puts " - Matches 'quarterly review' as a substring"
|
66
|
+
puts " - Will match: 'quarterly review meeting', 'the quarterly review is done'"
|
67
|
+
puts
|
68
|
+
|
69
|
+
# Example 4: Combined usage
|
70
|
+
puts "4. Combined: urgent \"q1 report\""
|
71
|
+
puts "-" * 40
|
72
|
+
query4 = 'urgent "q1 report"'
|
73
|
+
ast4 = GmailSearchSyntax.parse!(query4)
|
74
|
+
puts "AST: #{ast4.inspect}"
|
75
|
+
|
76
|
+
visitor4 = GmailSearchSyntax::SQLiteVisitor.new
|
77
|
+
visitor4.visit(ast4)
|
78
|
+
sql4, params4 = visitor4.to_query.to_sql
|
79
|
+
|
80
|
+
puts "\nSQL:\n#{sql4}"
|
81
|
+
puts "\nParams: #{params4.inspect}"
|
82
|
+
puts "\nExplanation:"
|
83
|
+
puts " - 'urgent' uses word boundary matching (complete word)"
|
84
|
+
puts " - '\"q1 report\"' uses substring matching (partial match)"
|
85
|
+
puts " - Both conditions must be satisfied (AND)"
|
86
|
+
puts
|
87
|
+
|
88
|
+
puts "=" * 80
|
89
|
+
puts "Summary"
|
90
|
+
puts "=" * 80
|
91
|
+
puts "StringToken node (unquoted): Word boundary matching - finds complete words"
|
92
|
+
puts "Substring node (quoted): Substring matching - finds partial matches"
|
93
|
+
puts "=" * 80
|
@@ -23,7 +23,7 @@ module GmailSearchSyntax
|
|
23
23
|
end
|
24
24
|
end
|
25
25
|
|
26
|
-
class
|
26
|
+
class StringToken < Node
|
27
27
|
attr_reader :value
|
28
28
|
|
29
29
|
def initialize(value)
|
@@ -31,7 +31,19 @@ module GmailSearchSyntax
|
|
31
31
|
end
|
32
32
|
|
33
33
|
def inspect
|
34
|
-
"#<
|
34
|
+
"#<StringToken #{@value.inspect}>"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class Substring < Node
|
39
|
+
attr_reader :value
|
40
|
+
|
41
|
+
def initialize(value)
|
42
|
+
@value = value
|
43
|
+
end
|
44
|
+
|
45
|
+
def inspect
|
46
|
+
"#<Substring #{@value.inspect}>"
|
35
47
|
end
|
36
48
|
end
|
37
49
|
|
@@ -129,11 +129,11 @@ module GmailSearchSyntax
|
|
129
129
|
when :quoted_string
|
130
130
|
value = current_token.value
|
131
131
|
advance
|
132
|
-
AST::
|
132
|
+
AST::Substring.new(value)
|
133
133
|
when :email, :number, :date, :relative_time
|
134
134
|
value = current_token.value
|
135
135
|
advance
|
136
|
-
AST::
|
136
|
+
AST::StringToken.new(value)
|
137
137
|
else
|
138
138
|
advance
|
139
139
|
nil
|
@@ -183,41 +183,59 @@ module GmailSearchSyntax
|
|
183
183
|
end
|
184
184
|
|
185
185
|
advance
|
186
|
-
AST::
|
186
|
+
AST::StringToken.new(word)
|
187
187
|
end
|
188
188
|
|
189
189
|
def parse_operator_value
|
190
190
|
return nil if eof?
|
191
191
|
|
192
192
|
case current_token.type
|
193
|
-
when :word
|
194
|
-
value = current_token.value
|
195
|
-
advance
|
196
|
-
value
|
197
|
-
when :email
|
198
|
-
value = current_token.value
|
199
|
-
advance
|
200
|
-
value
|
201
|
-
when :quoted_string
|
202
|
-
value = current_token.value
|
203
|
-
advance
|
204
|
-
value
|
205
|
-
when :number
|
206
|
-
value = current_token.value
|
207
|
-
advance
|
208
|
-
value
|
209
|
-
when :date
|
210
|
-
value = current_token.value
|
211
|
-
advance
|
212
|
-
value
|
213
|
-
when :relative_time
|
214
|
-
value = current_token.value
|
215
|
-
advance
|
216
|
-
value
|
217
193
|
when :lparen
|
218
194
|
parse_parentheses
|
219
195
|
when :lbrace
|
220
196
|
parse_braces
|
197
|
+
when :quoted_string
|
198
|
+
# Quoted strings are consumed as-is, no bareword collection
|
199
|
+
value = current_token.value
|
200
|
+
advance
|
201
|
+
value
|
202
|
+
when :word, :email, :number, :date, :relative_time
|
203
|
+
# Collect the initial value and any following barewords
|
204
|
+
# until we hit an operator, special token, or grouping
|
205
|
+
values = []
|
206
|
+
types = []
|
207
|
+
|
208
|
+
# Collect barewords
|
209
|
+
while !eof? && is_bareword_token?
|
210
|
+
# Check if this word is actually an operator (word followed by colon)
|
211
|
+
if current_token.type == :word && peek_token&.type == :colon
|
212
|
+
break
|
213
|
+
end
|
214
|
+
|
215
|
+
values << current_token.value
|
216
|
+
types << current_token.type
|
217
|
+
advance
|
218
|
+
end
|
219
|
+
|
220
|
+
# If we only collected one value and it's a number, preserve its type
|
221
|
+
if values.length == 1 && types[0] == :number
|
222
|
+
values[0]
|
223
|
+
else
|
224
|
+
# Multiple values or non-number: join as string
|
225
|
+
values.map(&:to_s).join(" ")
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def is_bareword_token?
|
231
|
+
return false if eof?
|
232
|
+
|
233
|
+
# Barewords are simple value tokens, not operators or special syntax
|
234
|
+
case current_token.type
|
235
|
+
when :word, :email, :number, :date, :relative_time
|
236
|
+
true
|
237
|
+
else
|
238
|
+
false
|
221
239
|
end
|
222
240
|
end
|
223
241
|
end
|
@@ -46,8 +46,10 @@ module GmailSearchSyntax
|
|
46
46
|
case node
|
47
47
|
when AST::Operator
|
48
48
|
visit_operator(node)
|
49
|
-
when AST::
|
50
|
-
|
49
|
+
when AST::StringToken
|
50
|
+
visit_string_token(node)
|
51
|
+
when AST::Substring
|
52
|
+
visit_substring(node)
|
51
53
|
when AST::And
|
52
54
|
visit_and(node)
|
53
55
|
when AST::Or
|
@@ -325,11 +327,26 @@ module GmailSearchSyntax
|
|
325
327
|
@query.add_condition("m0.rfc822_message_id = ?")
|
326
328
|
end
|
327
329
|
|
328
|
-
def
|
329
|
-
|
330
|
-
|
330
|
+
def visit_string_token(node)
|
331
|
+
# Word boundary matching - the value should appear as a complete word/token
|
332
|
+
# We use LIKE with word boundaries: spaces, start/end of string
|
333
|
+
value = node.value
|
334
|
+
@query.add_param(value)
|
335
|
+
@query.add_param("#{value} %")
|
336
|
+
@query.add_param("% #{value}")
|
337
|
+
@query.add_param("% #{value} %")
|
338
|
+
@query.add_condition("((m0.subject = ? OR m0.subject LIKE ? OR m0.subject LIKE ? OR m0.subject LIKE ?) OR (m0.body = ? OR m0.body LIKE ? OR m0.body LIKE ? OR m0.body LIKE ?))")
|
339
|
+
@query.add_param(value)
|
340
|
+
@query.add_param("#{value} %")
|
341
|
+
@query.add_param("% #{value}")
|
342
|
+
@query.add_param("% #{value} %")
|
343
|
+
end
|
344
|
+
|
345
|
+
def visit_substring(node)
|
346
|
+
# Substring matching - the value can appear anywhere in the text
|
331
347
|
@query.add_param("%#{node.value}%")
|
332
348
|
@query.add_param("%#{node.value}%")
|
349
|
+
@query.add_condition("(m0.subject LIKE ? OR m0.body LIKE ?)")
|
333
350
|
end
|
334
351
|
|
335
352
|
def visit_and(node)
|
@@ -8,12 +8,12 @@ module GmailSearchSyntax
|
|
8
8
|
@position = position
|
9
9
|
end
|
10
10
|
|
11
|
-
def
|
12
|
-
|
11
|
+
def to_s
|
12
|
+
inspect
|
13
13
|
end
|
14
14
|
|
15
15
|
def inspect
|
16
|
-
|
16
|
+
{type: @type, value: @value, offset: @position}.inspect
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
@@ -103,20 +103,37 @@ module GmailSearchSyntax
|
|
103
103
|
end
|
104
104
|
|
105
105
|
def read_quoted_string
|
106
|
-
advance
|
106
|
+
advance # Skip opening quote
|
107
107
|
|
108
108
|
value = ""
|
109
|
-
while @position < @input.length
|
110
|
-
|
109
|
+
while @position < @input.length
|
110
|
+
char = current_char
|
111
|
+
|
112
|
+
if char == "\\"
|
113
|
+
# Handle escape sequences
|
111
114
|
advance
|
112
|
-
|
115
|
+
if @position < @input.length
|
116
|
+
next_char = current_char
|
117
|
+
value += case next_char
|
118
|
+
when '"', "\\"
|
119
|
+
# Escaped quote or backslash - add the literal character
|
120
|
+
next_char
|
121
|
+
else
|
122
|
+
# Other escapes - keep the backslash and the character
|
123
|
+
"\\" + next_char
|
124
|
+
end
|
125
|
+
advance
|
126
|
+
end
|
127
|
+
elsif char == '"'
|
128
|
+
# Unescaped quote - end of string
|
129
|
+
break
|
113
130
|
else
|
114
|
-
value +=
|
131
|
+
value += char
|
132
|
+
advance
|
115
133
|
end
|
116
|
-
advance
|
117
134
|
end
|
118
135
|
|
119
|
-
advance if @position < @input.length
|
136
|
+
advance if @position < @input.length && current_char == '"' # Skip closing quote
|
120
137
|
|
121
138
|
add_token(:quoted_string, value)
|
122
139
|
end
|
@@ -128,8 +145,26 @@ module GmailSearchSyntax
|
|
128
145
|
char = current_char
|
129
146
|
break if /[\s():{}]/.match?(char)
|
130
147
|
break if char == "-"
|
131
|
-
|
132
|
-
|
148
|
+
|
149
|
+
if char == "\\"
|
150
|
+
# Handle escape sequences in unquoted tokens
|
151
|
+
advance
|
152
|
+
if @position < @input.length
|
153
|
+
next_char = current_char
|
154
|
+
value += case next_char
|
155
|
+
when '"', "\\"
|
156
|
+
# Escaped quote or backslash - add the literal character
|
157
|
+
next_char
|
158
|
+
else
|
159
|
+
# Other escapes - keep the backslash and the character
|
160
|
+
"\\" + next_char
|
161
|
+
end
|
162
|
+
advance
|
163
|
+
end
|
164
|
+
else
|
165
|
+
value += char
|
166
|
+
advance
|
167
|
+
end
|
133
168
|
end
|
134
169
|
|
135
170
|
return if value.empty?
|