Skip to content

Commit b30ebfe

Browse files
committed
feat(cli): add edge case handling for @ mention triggers
- Prevent @ menu from triggering inside quotes (single, double, backticks) - Prevent @ menu for email addresses (alphanumeric before @) - Prevent @ menu for escaped @ symbols (\@) - Prevent @ menu in URLs (colon before @) - Fix quote escape handling to properly count backslashes - Add comprehensive test suite with 53 edge case tests - All 343 CLI tests passing
1 parent ca0b0f8 commit b30ebfe

File tree

3 files changed

+462
-0
lines changed

3 files changed

+462
-0
lines changed
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
import { describe, test, expect } from 'bun:test'
2+
3+
// Helper function extracted from use-suggestion-engine.ts for testing
4+
const isInsideQuotes = (text: string, position: number): boolean => {
5+
let inSingleQuote = false
6+
let inDoubleQuote = false
7+
let inBacktick = false
8+
let escaped = false
9+
10+
for (let i = 0; i < position; i++) {
11+
const char = text[i]
12+
13+
if (escaped) {
14+
escaped = false
15+
continue
16+
}
17+
18+
if (char === '\\') {
19+
escaped = true
20+
continue
21+
}
22+
23+
if (char === "'" && !inDoubleQuote && !inBacktick) {
24+
inSingleQuote = !inSingleQuote
25+
} else if (char === '"' && !inSingleQuote && !inBacktick) {
26+
inDoubleQuote = !inDoubleQuote
27+
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
28+
inBacktick = !inBacktick
29+
}
30+
}
31+
32+
return inSingleQuote || inDoubleQuote || inBacktick
33+
}
34+
35+
const parseAtInLine = (line: string): { active: boolean; query: string; atIndex: number } => {
36+
const atIndex = line.lastIndexOf('@')
37+
if (atIndex === -1) {
38+
return { active: false, query: '', atIndex: -1 }
39+
}
40+
41+
// Check if @ is inside quotes
42+
if (isInsideQuotes(line, atIndex)) {
43+
return { active: false, query: '', atIndex: -1 }
44+
}
45+
46+
const beforeChar = atIndex > 0 ? line[atIndex - 1] : ''
47+
48+
// Don't trigger on escaped @: \@
49+
if (beforeChar === '\\') {
50+
return { active: false, query: '', atIndex: -1 }
51+
}
52+
53+
// Don't trigger on email-like patterns or URLs
54+
if (beforeChar && /[a-zA-Z0-9.:]/.test(beforeChar)) {
55+
return { active: false, query: '', atIndex: -1 }
56+
}
57+
58+
// Require whitespace or start of line before @
59+
if (beforeChar && !/\s/.test(beforeChar)) {
60+
return { active: false, query: '', atIndex: -1 }
61+
}
62+
63+
const afterAt = line.slice(atIndex + 1)
64+
const firstSpaceIndex = afterAt.search(/\s/)
65+
const query = firstSpaceIndex === -1 ? afterAt : afterAt.slice(0, firstSpaceIndex)
66+
67+
if (firstSpaceIndex !== -1) {
68+
return { active: false, query: '', atIndex: -1 }
69+
}
70+
71+
return { active: true, query, atIndex }
72+
}
73+
74+
describe('@ mention edge cases - quote detection', () => {
75+
test('isInsideQuotes detects position inside double quotes', () => {
76+
expect(isInsideQuotes('"hello @world"', 7)).toBe(true)
77+
})
78+
79+
test('isInsideQuotes detects position inside single quotes', () => {
80+
expect(isInsideQuotes("'hello @world'", 7)).toBe(true)
81+
})
82+
83+
test('isInsideQuotes detects position inside backticks', () => {
84+
expect(isInsideQuotes('`hello @world`', 7)).toBe(true)
85+
})
86+
87+
test('isInsideQuotes returns false for position outside quotes', () => {
88+
expect(isInsideQuotes('"hello" @world', 8)).toBe(false)
89+
})
90+
91+
test('isInsideQuotes handles escaped quotes', () => {
92+
expect(isInsideQuotes('"hello \\" @world"', 11)).toBe(true)
93+
})
94+
})
95+
96+
describe('parseAtInLine - @ mention trigger logic', () => {
97+
test('triggers for @ at start of line', () => {
98+
const result = parseAtInLine('@agent')
99+
expect(result.active).toBe(true)
100+
expect(result.query).toBe('agent')
101+
})
102+
103+
test('triggers for @ after whitespace', () => {
104+
const result = parseAtInLine('hello @agent')
105+
expect(result.active).toBe(true)
106+
expect(result.query).toBe('agent')
107+
})
108+
109+
test('does NOT trigger for @ inside double quotes', () => {
110+
const result = parseAtInLine('"@agent"')
111+
expect(result.active).toBe(false)
112+
})
113+
114+
test('does NOT trigger for @ inside single quotes', () => {
115+
const result = parseAtInLine("'@agent'")
116+
expect(result.active).toBe(false)
117+
})
118+
119+
test('does NOT trigger for @ inside backticks', () => {
120+
const result = parseAtInLine('`@agent`')
121+
expect(result.active).toBe(false)
122+
})
123+
124+
test('does NOT trigger for email addresses', () => {
125+
const result = parseAtInLine('user@example.com')
126+
expect(result.active).toBe(false)
127+
})
128+
129+
test('does NOT trigger for escaped @ symbol', () => {
130+
const result = parseAtInLine('\\@agent')
131+
expect(result.active).toBe(false)
132+
})
133+
134+
test('does NOT trigger for @ in URLs with colon', () => {
135+
const result = parseAtInLine('https://example.com/@user')
136+
expect(result.active).toBe(false)
137+
})
138+
139+
test('does NOT trigger for @ after dot', () => {
140+
const result = parseAtInLine('file.@property')
141+
expect(result.active).toBe(false)
142+
})
143+
144+
test('triggers after closing quote', () => {
145+
const result = parseAtInLine('"test" @agent')
146+
expect(result.active).toBe(true)
147+
expect(result.query).toBe('agent')
148+
})
149+
150+
test('handles nested quotes correctly - @ inside outer quotes', () => {
151+
const result = parseAtInLine('"test \'nested\' @here"')
152+
expect(result.active).toBe(false)
153+
})
154+
155+
test('extracts query correctly', () => {
156+
const result = parseAtInLine('@myagent')
157+
expect(result.active).toBe(true)
158+
expect(result.query).toBe('myagent')
159+
})
160+
161+
test('does NOT trigger if @ followed by space', () => {
162+
const result = parseAtInLine('@ agent')
163+
expect(result.active).toBe(false)
164+
})
165+
166+
test('uses lastIndexOf to find the rightmost @', () => {
167+
const result = parseAtInLine('@first @second')
168+
expect(result.active).toBe(true)
169+
expect(result.query).toBe('second')
170+
})
171+
})
172+
173+
describe('parseAtInLine - comprehensive edge cases', () => {
174+
// Email variations
175+
test('does NOT trigger for email with subdomain', () => {
176+
const result = parseAtInLine('user@mail.example.com')
177+
expect(result.active).toBe(false)
178+
})
179+
180+
test('does NOT trigger for email with numbers', () => {
181+
const result = parseAtInLine('user123@example.com')
182+
expect(result.active).toBe(false)
183+
})
184+
185+
test('does NOT trigger for email with underscores', () => {
186+
const result = parseAtInLine('user_name@example.com')
187+
expect(result.active).toBe(false)
188+
})
189+
190+
test('does NOT trigger for email with hyphens', () => {
191+
const result = parseAtInLine('user-name@example.com')
192+
expect(result.active).toBe(false)
193+
})
194+
195+
test('does NOT trigger for email with dots in username', () => {
196+
const result = parseAtInLine('first.last@example.com')
197+
expect(result.active).toBe(false)
198+
})
199+
200+
// URL variations
201+
test('does NOT trigger for http URL', () => {
202+
const result = parseAtInLine('http://example.com/@user')
203+
expect(result.active).toBe(false)
204+
})
205+
206+
test('does NOT trigger for https URL', () => {
207+
const result = parseAtInLine('https://example.com/@user')
208+
expect(result.active).toBe(false)
209+
})
210+
211+
test('does NOT trigger for URL with port', () => {
212+
const result = parseAtInLine('http://localhost:3000/@user')
213+
expect(result.active).toBe(false)
214+
})
215+
216+
// Quote escape variations
217+
test('does NOT trigger for @ after escaped backslash in quotes', () => {
218+
const result = parseAtInLine('"\\\\@test"')
219+
expect(result.active).toBe(false)
220+
})
221+
222+
test('does NOT trigger for @ when quote is escaped (string still open)', () => {
223+
// In "test\" @agent, the \" is an escaped quote, so the string is still open
224+
const result = parseAtInLine('"test\\" @agent')
225+
expect(result.active).toBe(false)
226+
})
227+
228+
test('triggers for @ after quote with escaped backslash before it', () => {
229+
// In "test\\" @agent, the \\ is an escaped backslash, so the " closes the string
230+
const result = parseAtInLine('"test\\\\" @agent')
231+
expect(result.active).toBe(true)
232+
expect(result.query).toBe('agent')
233+
})
234+
235+
test('handles multiple escaped quotes correctly', () => {
236+
const result = parseAtInLine('"test\\"more\\" @here"')
237+
expect(result.active).toBe(false)
238+
})
239+
240+
// Mixed quote types
241+
test('handles single quote inside double quotes', () => {
242+
const result = parseAtInLine('"it\'s @here"')
243+
expect(result.active).toBe(false)
244+
})
245+
246+
test('handles double quote inside single quotes', () => {
247+
const result = parseAtInLine("'say \"@hello\"'")
248+
expect(result.active).toBe(false)
249+
})
250+
251+
test('handles backticks with quotes inside', () => {
252+
const result = parseAtInLine('`"@test"`')
253+
expect(result.active).toBe(false)
254+
})
255+
256+
// Multiple @ symbols
257+
test('finds last @ when multiple exist outside quotes', () => {
258+
const result = parseAtInLine('@first "@quoted" @last')
259+
expect(result.active).toBe(true)
260+
expect(result.query).toBe('last')
261+
})
262+
263+
test('finds last @ even if previous ones are in quotes', () => {
264+
const result = parseAtInLine('"@in_quotes" @real_one')
265+
expect(result.active).toBe(true)
266+
expect(result.query).toBe('real_one')
267+
})
268+
269+
// Special characters after @
270+
test('does NOT trigger for @ followed by special characters', () => {
271+
const result = parseAtInLine('@!')
272+
expect(result.active).toBe(true)
273+
expect(result.query).toBe('!')
274+
})
275+
276+
test('extracts alphanumeric query with underscores and hyphens', () => {
277+
const result = parseAtInLine('@my-agent_v2')
278+
expect(result.active).toBe(true)
279+
expect(result.query).toBe('my-agent_v2')
280+
})
281+
282+
// Whitespace variations
283+
test('triggers with tab before @', () => {
284+
const result = parseAtInLine('\t@agent')
285+
expect(result.active).toBe(true)
286+
expect(result.query).toBe('agent')
287+
})
288+
289+
test('triggers with newline before @ (in same line context)', () => {
290+
const result = parseAtInLine(' @agent')
291+
expect(result.active).toBe(true)
292+
expect(result.query).toBe('agent')
293+
})
294+
295+
test('triggers with multiple spaces before @', () => {
296+
const result = parseAtInLine('text @agent')
297+
expect(result.active).toBe(true)
298+
expect(result.query).toBe('agent')
299+
})
300+
301+
// Empty and edge cases
302+
test('handles empty string', () => {
303+
const result = parseAtInLine('')
304+
expect(result.active).toBe(false)
305+
})
306+
307+
test('handles just @', () => {
308+
const result = parseAtInLine('@')
309+
expect(result.active).toBe(true)
310+
expect(result.query).toBe('')
311+
})
312+
313+
test('handles @ at end of string with query', () => {
314+
const result = parseAtInLine('text @query')
315+
expect(result.active).toBe(true)
316+
expect(result.query).toBe('query')
317+
})
318+
319+
// Code-like contexts (where @ might appear)
320+
test('does NOT trigger for decorator-like syntax', () => {
321+
const result = parseAtInLine('something.@decorator')
322+
expect(result.active).toBe(false)
323+
})
324+
325+
test('does NOT trigger for array access', () => {
326+
const result = parseAtInLine('array.@index')
327+
expect(result.active).toBe(false)
328+
})
329+
330+
// Social media handles (ambiguous - should these trigger?)
331+
test('triggers for Twitter-like handles after space', () => {
332+
const result = parseAtInLine('follow @username')
333+
expect(result.active).toBe(true)
334+
expect(result.query).toBe('username')
335+
})
336+
337+
test('does NOT trigger when @ is part of word', () => {
338+
const result = parseAtInLine('user@mention')
339+
expect(result.active).toBe(false)
340+
})
341+
342+
// Multiple quotes on same line
343+
test('handles alternating quotes correctly', () => {
344+
const result = parseAtInLine('"first" \'second\' "@third"')
345+
expect(result.active).toBe(false)
346+
})
347+
348+
test('triggers after all quotes are closed', () => {
349+
const result = parseAtInLine('"first" \'second\' @third')
350+
expect(result.active).toBe(true)
351+
expect(result.query).toBe('third')
352+
})
353+
354+
// Unclosed quotes
355+
test('does NOT trigger when inside unclosed double quote', () => {
356+
const result = parseAtInLine('"unclosed @mention')
357+
expect(result.active).toBe(false)
358+
})
359+
360+
test('does NOT trigger when inside unclosed single quote', () => {
361+
const result = parseAtInLine("'unclosed @mention")
362+
expect(result.active).toBe(false)
363+
})
364+
365+
test('does NOT trigger when inside unclosed backtick', () => {
366+
const result = parseAtInLine('`unclosed @mention')
367+
expect(result.active).toBe(false)
368+
})
369+
})

0 commit comments

Comments
 (0)