diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index 1c4ec4670..873cadf43 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -394,12 +394,15 @@ const getCurrentWordWithContext = (cm) => { const extractNextSegmentSuggestions = (filteredHints, currentInput) => { const prefixMatches = new Set(); const substringMatches = new Set(); + const lowerInput = currentInput.toLowerCase(); filteredHints.forEach((hint) => { + const lowerHint = hint.toLowerCase(); + // For prefix matches, use the original progressive logic - if (hint.toLowerCase().startsWith(currentInput.toLowerCase())) { + if (lowerHint.startsWith(lowerInput)) { // Handle exact match case - if (hint.toLowerCase() === currentInput.toLowerCase()) { + if (lowerHint === lowerInput) { prefixMatches.add(hint.substring(hint.lastIndexOf('.') + 1)); return; } @@ -417,13 +420,13 @@ const extractNextSegmentSuggestions = (filteredHints, currentInput) => { const lastDotInInput = currentInput.lastIndexOf('.'); const currentSegmentStart = lastDotInInput + 1; const nextDotAfterInput = hint.indexOf('.', currentSegmentStart); - const segment = - nextDotAfterInput === -1 + const segment + = nextDotAfterInput === -1 ? hint.substring(currentSegmentStart) : hint.substring(currentSegmentStart, nextDotAfterInput); prefixMatches.add(segment); } - } else if (hint.toLowerCase().includes(currentInput.toLowerCase())) { + } else if (lowerHint.includes(lowerInput)) { // For substring matches (search within words), suggest the complete hint substringMatches.add(hint); } @@ -486,8 +489,9 @@ const filterHintsByContext = (categorizedHints, currentWord, context, showHintsF const allowedHints = getAllowedHintsByContext(categorizedHints, context, showHintsFor); + const lowerWord = currentWord.toLowerCase(); const filtered = allowedHints.filter((hint) => { - return hint.toLowerCase().includes(currentWord.toLowerCase()); + return hint.toLowerCase().includes(lowerWord); }); const hintParts = getHintParts(filtered, currentWord); @@ -719,6 +723,9 @@ export const setupAutoComplete = (editor, options = {}) => { }; }; +// Exported for testing +export { extractNextSegmentSuggestions }; + // Initialize autocomplete command if not already present if (!CodeMirror.commands.autocomplete) { CodeMirror.commands.autocomplete = (cm, hint, options) => { diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js index 16e5a2882..526e5c10f 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js @@ -18,7 +18,8 @@ jest.mock('codemirror', () => { // Import the functions to test import { getAutoCompleteHints, - setupAutoComplete + setupAutoComplete, + extractNextSegmentSuggestions } from './autocomplete'; describe('Bruno Autocomplete', () => { @@ -403,6 +404,133 @@ describe('Bruno Autocomplete', () => { }); }); + describe('extractNextSegmentSuggestions', () => { + describe('prefix matching', () => { + it('should extract the current segment for a partial prefix match', () => { + const hints = ['req.getUrl()', 'req.getMethod()', 'req.setUrl(url)']; + const result = extractNextSegmentSuggestions(hints, 'req.get'); + + expect(result).toEqual(['getMethod()', 'getUrl()']); + }); + + it('should return the next segment after a trailing dot', () => { + const hints = ['bru.cookies.jar()', 'bru.runner.skipRequest()']; + const result = extractNextSegmentSuggestions(hints, 'bru.'); + + expect(result).toEqual(['cookies', 'runner']); + }); + + it('should return the last segment on exact match', () => { + const hints = ['req.url']; + const result = extractNextSegmentSuggestions(hints, 'req.url'); + + expect(result).toEqual(['url']); + }); + + it('should deduplicate segments from multiple hints', () => { + const hints = ['bru.cookies.jar().getCookie(url, name, callback)', 'bru.cookies.jar().getCookies(url, callback)']; + const result = extractNextSegmentSuggestions(hints, 'bru.'); + + expect(result).toEqual(['cookies']); + }); + + it('should extract top-level segment when input has no dots', () => { + const hints = ['req.url', 'req.getUrl()', 'res.url']; + const result = extractNextSegmentSuggestions(hints, 'r'); + + expect(result).toEqual(['req', 'res']); + }); + }); + + describe('substring matching', () => { + it('should return full hints for substring-only matches', () => { + const hints = ['base_url', 'api_url', 'url_prefix']; + const result = extractNextSegmentSuggestions(hints, 'url'); + + // url_prefix is a prefix match (segment), base_url and api_url are substring matches (full hints) + expect(result).toEqual(['url_prefix', 'api_url', 'base_url']); + }); + + it('should return full hints for dotted substring matches', () => { + const hints = ['req.getUrl()', 'req.setUrl(url)', 'req.url']; + const result = extractNextSegmentSuggestions(hints, 'Url'); + + expect(result).toEqual(['req.getUrl()', 'req.setUrl(url)', 'req.url']); + }); + + it('should not include hints that do not contain the input', () => { + const hints = ['base_url', 'api_key', 'url_prefix']; + const result = extractNextSegmentSuggestions(hints, 'url'); + + expect(result).not.toContain('api_key'); + }); + }); + + describe('ordering', () => { + it('should return prefix matches before substring matches', () => { + const hints = ['base_url', 'url_prefix']; + const result = extractNextSegmentSuggestions(hints, 'url'); + + // url_prefix is prefix → segment "url_prefix"; base_url is substring → full hint + expect(result).toEqual(['url_prefix', 'base_url']); + }); + + it('should sort prefix matches alphabetically among themselves', () => { + const hints = ['req.setUrl(url)', 'req.getUrl()', 'req.getMethod()']; + const result = extractNextSegmentSuggestions(hints, 'req.'); + + expect(result).toEqual(['getMethod()', 'getUrl()', 'setUrl(url)']); + }); + + it('should sort substring matches alphabetically among themselves', () => { + const hints = ['z_url', 'a_url']; + const result = extractNextSegmentSuggestions(hints, 'url'); + + // Both are substring-only matches + expect(result).toEqual(['a_url', 'z_url']); + }); + }); + + describe('case insensitivity', () => { + it('should match prefix regardless of case', () => { + const hints = ['Content-Type', 'Content-Length']; + const result = extractNextSegmentSuggestions(hints, 'content'); + + expect(result).toEqual(['Content-Length', 'Content-Type']); + }); + + it('should match substring regardless of case', () => { + const hints = ['X-Custom-Type', 'Accept']; + const result = extractNextSegmentSuggestions(hints, 'type'); + + expect(result).toEqual(['X-Custom-Type']); + }); + }); + + describe('edge cases', () => { + it('should return an empty array when no hints match', () => { + const hints = ['foo', 'bar', 'baz']; + const result = extractNextSegmentSuggestions(hints, 'xyz'); + + expect(result).toEqual([]); + }); + + it('should return an empty array for empty hints list', () => { + const result = extractNextSegmentSuggestions([], 'url'); + + expect(result).toEqual([]); + }); + + it('should handle single-character input', () => { + const hints = ['apple', 'banana', 'avocado']; + const result = extractNextSegmentSuggestions(hints, 'a'); + + // apple and avocado are prefix matches, banana contains 'a' as substring + expect(result).toEqual(['apple', 'avocado', 'banana']); + }); + }); + }); + describe('setupAutoComplete', () => { let mockGetAllVariables; let cleanupFn;