import { mockDataFunctions } from '@usebruno/common'; const CodeMirror = require('codemirror'); // Static API hints - Bruno JavaScript API (subgrouped by category) // TODO: Restore the commented-out APIs once the UI update fixes are live. // Currently these APIs only work within the request lifecycle but fail to update the UI tables. // e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI. const STATIC_API_HINTS = { req: [ 'req', 'req.url', 'req.method', 'req.headers', 'req.body', 'req.timeout', 'req.getUrl()', 'req.setUrl(url)', 'req.getHost()', 'req.getPath()', 'req.getQueryString()', 'req.getMethod()', 'req.getAuthMode()', 'req.setMethod(method)', 'req.getHeader(name)', 'req.getHeaders()', 'req.setHeader(name, value)', 'req.setHeaders(data)', 'req.deleteHeader(name)', 'req.deleteHeaders(data)', 'req.getBody()', 'req.setBody(data)', 'req.setMaxRedirects(maxRedirects)', 'req.getTimeout()', 'req.setTimeout(timeout)', 'req.getExecutionMode()', 'req.getName()', 'req.getPathParams()', 'req.getTags()', 'req.disableParsingResponseJson()', 'req.onFail(function(err) {})' ], res: [ 'res', 'res.status', 'res.statusText', 'res.headers', 'res.body', 'res.responseTime', 'res.url', 'res.getStatus()', 'res.getStatusText()', 'res.getHeader(name)', 'res.getHeaders()', 'res.getBody()', 'res.setBody(data)', 'res.getResponseTime()', 'res.getSize()', 'res.getSize().header', 'res.getSize().body', 'res.getSize().total', 'res.getUrl()' ], bru: [ 'bru', 'bru.cwd()', 'bru.getEnvName()', 'bru.getProcessEnv(key)', 'bru.hasEnvVar(key)', 'bru.getEnvVar(key)', 'bru.getFolderVar(key)', 'bru.getCollectionVar(key)', // 'bru.setCollectionVar(key, value)', 'bru.hasCollectionVar(key)', // 'bru.deleteCollectionVar(key)', // 'bru.deleteAllCollectionVars()', // 'bru.getAllCollectionVars()', 'bru.setEnvVar(key, value)', 'bru.setEnvVar(key, value, options)', 'bru.deleteEnvVar(key)', 'bru.getAllEnvVars()', 'bru.deleteAllEnvVars()', 'bru.hasVar(key)', 'bru.getVar(key)', 'bru.setVar(key,value)', 'bru.deleteVar(key)', 'bru.deleteAllVars()', 'bru.getAllVars()', 'bru.setNextRequest(requestName)', 'bru.getRequestVar(key)', 'bru.runRequest(requestPathName)', 'bru.sendRequest(requestConfig)', 'bru.sendRequest(requestConfig, callback)', 'bru.getAssertionResults()', 'bru.getTestResults()', 'bru.sleep(ms)', 'bru.getCollectionName()', 'bru.isSafeMode()', 'bru.getOauth2CredentialVar(key)', 'bru.getGlobalEnvVar(key)', 'bru.setGlobalEnvVar(key, value)', // 'bru.deleteGlobalEnvVar(key)', 'bru.getAllGlobalEnvVars()', // 'bru.deleteAllGlobalEnvVars()', 'bru.runner', 'bru.runner.setNextRequest(requestName)', 'bru.runner.skipRequest()', 'bru.runner.stopExecution()', 'bru.interpolate(str)', 'bru.cookies', 'bru.cookies.jar()', 'bru.cookies.jar().getCookie(url, name, callback)', 'bru.cookies.jar().getCookies(url, callback)', 'bru.cookies.jar().setCookie(url, name, value, callback)', 'bru.cookies.jar().setCookie(url, cookieObject, callback)', 'bru.cookies.jar().setCookies(url, cookiesArray, callback)', 'bru.cookies.jar().clear(callback)', 'bru.cookies.jar().deleteCookies(url, callback)', 'bru.cookies.jar().deleteCookie(url, name, callback)', 'bru.cookies.jar().hasCookie(url, name, callback)', 'bru.utils', 'bru.utils.minifyJson(json)', 'bru.utils.minifyXml(xml)', 'bru.resetOauth2Credential(credentialId)' ] }; // Mock data functions - prefixed with $ const MOCK_DATA_HINTS = Object.keys(mockDataFunctions).map((key) => `$${key}`); // Constants for word pattern matching const WORD_PATTERN = /[\w.$-/]/; const VARIABLE_PATTERN = /\{\{([\w$.-]*)$/; const NON_CHARACTER_KEYS = /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/; /** * Generate progressive hints for a given full hint * @param {string} fullHint - The complete hint string * @returns {string[]} Array of progressive hints */ const generateProgressiveHints = (fullHint) => { const parts = fullHint.split('.'); const progressiveHints = []; for (let i = 1; i <= parts.length; i++) { progressiveHints.push(parts.slice(0, i).join('.')); } return progressiveHints; }; /** * Check if a variable key should be skipped * @param {string} key - The variable key to check * @returns {boolean} True if the key should be skipped */ const shouldSkipVariableKey = (key) => { return key === 'pathParams' || key === 'maskedEnvVariables' || key === 'process'; }; /** * Transform variables object into flat hint list * @param {Object} allVariables - All available variables * @returns {string[]} Array of variable hints */ const transformVariablesToHints = (allVariables = {}) => { const hints = []; // Process all variables without type-specific handling Object.keys(allVariables).forEach((key) => { if (!shouldSkipVariableKey(key)) { hints.push(key); } }); // Handle process environment variables if (allVariables.process && allVariables.process.env) { Object.keys(allVariables.process.env).forEach((key) => { hints.push(`process.env.${key}`); }); } return hints; }; /** * Add API hints to categorized hints based on showHintsFor configuration * @param {Set} apiHints - Set to add API hints to * @param {string[]} showHintsFor - Array of hint types to show */ const addApiHintsToSet = (apiHints, showHintsFor) => { const apiTypes = ['req', 'res', 'bru']; apiTypes.forEach((apiType) => { if (showHintsFor.includes(apiType)) { STATIC_API_HINTS[apiType].forEach((hint) => { generateProgressiveHints(hint).forEach((h) => apiHints.add(h)); }); } }); }; /** * Add variable hints to categorized hints * @param {Set} variableHints - Set to add variable hints to * @param {Object} allVariables - All available variables */ const addVariableHintsToSet = (variableHints, allVariables) => { // Add mock data hints MOCK_DATA_HINTS.forEach((hint) => { generateProgressiveHints(hint).forEach((h) => variableHints.add(h)); }); // Add variable hints with progressive hints const variableHintsList = transformVariablesToHints(allVariables); variableHintsList.forEach((hint) => { generateProgressiveHints(hint).forEach((h) => variableHints.add(h)); }); }; /** * Add custom hints to categorized hints * @param {Set} anywordHints - Set to add custom hints to * @param {string[]} customHints - Array of custom hints */ const addCustomHintsToSet = (anywordHints, customHints) => { if (customHints && Array.isArray(customHints)) { customHints.forEach((hint) => { generateProgressiveHints(hint).forEach((h) => anywordHints.add(h)); }); } }; /** * Build categorized hints list from all sources * @param {Object} allVariables - All available variables * @param {string[]} anywordAutocompleteHints - Custom autocomplete hints * @param {Object} options - Configuration options * @returns {Object} Categorized hints object */ const buildCategorizedHintsList = (allVariables = {}, anywordAutocompleteHints = [], options = {}) => { const categorizedHints = { api: new Set(), variables: new Set(), anyword: new Set() }; const showHintsFor = options.showHintsFor || []; // Add different types of hints addApiHintsToSet(categorizedHints.api, showHintsFor); addVariableHintsToSet(categorizedHints.variables, allVariables); addCustomHintsToSet(categorizedHints.anyword, anywordAutocompleteHints); return { api: Array.from(categorizedHints.api).sort(), variables: Array.from(categorizedHints.variables).sort(), anyword: Array.from(categorizedHints.anyword).sort() }; }; /** * Calculate replacement positions for variable context * @param {Object} cursor - Current cursor position * @param {Object} startPos - Start position of variable * @param {string} wordMatch - The matched word * @returns {Object} From and to positions for replacement */ const calculateVariableReplacementPositions = (cursor, startPos, wordMatch) => { let replaceFrom, replaceTo; if (wordMatch.endsWith('.')) { replaceFrom = cursor; replaceTo = cursor; } else { const lastDotIndex = wordMatch.lastIndexOf('.'); if (lastDotIndex !== -1) { replaceFrom = { line: cursor.line, ch: startPos.ch + lastDotIndex + 1 }; replaceTo = cursor; } else { replaceFrom = startPos; replaceTo = cursor; } } return { replaceFrom, replaceTo }; }; /** * Calculate replacement positions for regular word context * @param {Object} cursor - Current cursor position * @param {number} start - Start position of word * @param {number} end - End position of word * @param {string} word - The matched word * @returns {Object} From and to positions for replacement */ const calculateWordReplacementPositions = (cursor, start, end, word) => { let replaceFrom, replaceTo; if (word.endsWith('.')) { replaceFrom = { line: cursor.line, ch: end }; replaceTo = cursor; } else { const lastDotIndex = word.lastIndexOf('.'); if (lastDotIndex !== -1) { replaceFrom = { line: cursor.line, ch: start + lastDotIndex + 1 }; replaceTo = { line: cursor.line, ch: end }; } else { replaceFrom = { line: cursor.line, ch: start }; replaceTo = { line: cursor.line, ch: end }; } } return { replaceFrom, replaceTo }; }; /** * Determine context based on word prefix * @param {string} word - The word to analyze * @returns {string} The determined context */ const determineWordContext = (word) => { const isApiHint = Object.keys(STATIC_API_HINTS).some( (apiRoot) => apiRoot.toLowerCase().startsWith(word.toLowerCase()) || word.toLowerCase().startsWith(apiRoot.toLowerCase()) ); if (isApiHint) { return 'api'; } return 'anyword'; }; /** * Extract word from current line with boundaries * @param {string} currentLine - The current line content * @param {number} cursorPosition - Current cursor position * @returns {Object|null} Word information or null if no word found */ const extractWordFromLine = (currentLine, cursorPosition) => { let start = cursorPosition; let end = start; while (end < currentLine.length && WORD_PATTERN.test(currentLine.charAt(end))) { ++end; } while (start && WORD_PATTERN.test(currentLine.charAt(start - 1))) { --start; } if (start === end) { return null; } return { word: currentLine.slice(start, end), start, end }; }; /** * Get current word being typed at cursor position with context information * @param {Object} cm - CodeMirror instance * @returns {Object|null} Word information with context or null */ const getCurrentWordWithContext = (cm) => { const cursor = cm.getCursor(); const currentLine = cm.getLine(cursor.line); const currentString = cm.getRange({ line: cursor.line, ch: 0 }, cursor); // Check for variable pattern {{word const variableMatch = currentString.match(VARIABLE_PATTERN); if (variableMatch) { const wordMatch = variableMatch[1]; const startPos = { line: cursor.line, ch: currentString.lastIndexOf('{{') + 2 }; const { replaceFrom, replaceTo } = calculateVariableReplacementPositions(cursor, startPos, wordMatch); return { word: wordMatch, from: replaceFrom, to: replaceTo, context: 'variables', requiresBraces: true }; } // Check for regular word const wordInfo = extractWordFromLine(currentLine, cursor.ch); if (!wordInfo) { return null; } const { word, start, end } = wordInfo; const { replaceFrom, replaceTo } = calculateWordReplacementPositions(cursor, start, end, word); const context = determineWordContext(word); return { word, from: replaceFrom, to: replaceTo, context, requiresBraces: false }; }; /** * Extract next segment suggestions from filtered hints * @param {string[]} filteredHints - Pre-filtered hints * @param {string} currentInput - Current user input * @returns {string[]} Array of suggestion segments */ 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 (lowerHint.startsWith(lowerInput)) { // Handle exact match case if (lowerHint === lowerInput) { prefixMatches.add(hint.substring(hint.lastIndexOf('.') + 1)); return; } const inputLength = currentInput.length; if (currentInput.endsWith('.')) { // Show next segment after the dot const afterDot = hint.substring(inputLength); const nextDot = afterDot.indexOf('.'); const segment = nextDot === -1 ? afterDot : afterDot.substring(0, nextDot); prefixMatches.add(segment); } else { // Show complete current segment const lastDotInInput = currentInput.lastIndexOf('.'); const currentSegmentStart = lastDotInInput + 1; const nextDotAfterInput = hint.indexOf('.', currentSegmentStart); const segment = nextDotAfterInput === -1 ? hint.substring(currentSegmentStart) : hint.substring(currentSegmentStart, nextDotAfterInput); prefixMatches.add(segment); } } else if (lowerHint.includes(lowerInput)) { // For substring matches (search within words), suggest the complete hint substringMatches.add(hint); } }); // Return prefix matches first, then substring matches return [...Array.from(prefixMatches).sort(), ...Array.from(substringMatches).sort()]; }; /** * Extract the relevant part of hints based on user input * @param {string[]} filteredHints - Pre-filtered hints * @param {string} currentInput - Current user input * @returns {string[]} Array of hint parts */ const getHintParts = (filteredHints, currentInput) => { if (!filteredHints || filteredHints.length === 0) { return []; } return extractNextSegmentSuggestions(filteredHints, currentInput); }; /** * Get allowed hints based on context and configuration * @param {Object} categorizedHints - All categorized hints * @param {string} context - Current context * @param {string[]} showHintsFor - Allowed hint types * @returns {string[]} Array of allowed hints */ const getAllowedHintsByContext = (categorizedHints, context, showHintsFor) => { let allowedHints = []; if (context === 'variables' && showHintsFor.includes('variables')) { allowedHints = [...categorizedHints.variables]; } else if (context === 'api') { const hasApiHints = showHintsFor.some((hint) => ['req', 'res', 'bru'].includes(hint)); if (hasApiHints) { allowedHints = [...categorizedHints.api]; } } else if (context === 'anyword') { allowedHints = [...categorizedHints.anyword]; } return allowedHints; }; /** * Filter hints based on current word and allowed hint types * @param {Object} categorizedHints - All categorized hints * @param {string} currentWord - Current word being typed * @param {string} context - Current context * @param {string[]} showHintsFor - Allowed hint types * @returns {string[]} Filtered hints */ const filterHintsByContext = (categorizedHints, currentWord, context, showHintsFor = []) => { if (!currentWord) { return []; } const allowedHints = getAllowedHintsByContext(categorizedHints, context, showHintsFor); const lowerWord = currentWord.toLowerCase(); const filtered = allowedHints.filter((hint) => { return hint.toLowerCase().includes(lowerWord); }); const hintParts = getHintParts(filtered, currentWord); return hintParts.slice(0, 50); }; /** * Create hint list for variables context * @param {string[]} filteredHints - Filtered hints * @param {Object} from - Start position * @param {Object} to - End position * @returns {Object} Hint object with list and positions */ const createVariableHintList = (filteredHints, from, to) => { const hintList = filteredHints.map((hint) => ({ text: hint, displayText: hint })); return { list: hintList, from, to }; }; /** * Create hint list for non-variable contexts * @param {string[]} filteredHints - Filtered hints * @param {Object} from - Start position * @param {Object} to - End position * @returns {Object} Hint object with list and positions */ const createStandardHintList = (filteredHints, from, to) => { return { list: filteredHints, from, to }; }; /** * Show root-level API hints when the editor is empty * @param {Object} cm - CodeMirror instance * @param {string[]} showHintsFor - Array of hint types to show (e.g., ['req', 'res', 'bru']) * @returns {boolean} True if hints were shown, false otherwise */ export const showRootHints = (cm, showHintsFor = []) => { const wordInfo = getCurrentWordWithContext(cm); // If user is currently typing a word, let handleKeyupForAutocomplete // handle it instead of showing root hints. if (wordInfo) { return false; } const hints = Object.keys(STATIC_API_HINTS).filter((rootHint) => showHintsFor.includes(rootHint)); if (hints.length === 0) return false; const cursor = cm.getCursor(); const hintList = createStandardHintList(hints, cursor, cursor); cm.showHint({ hint: () => hintList, completeSingle: false }); return true; }; /** * Bruno AutoComplete Helper - Main function with context awareness * @param {Object} cm - CodeMirror instance * @param {Object} allVariables - All available variables * @param {string[]} anywordAutocompleteHints - Custom autocomplete hints * @param {Object} options - Configuration options * @returns {Object|null} Hint object or null */ export const getAutoCompleteHints = (cm, allVariables = {}, anywordAutocompleteHints = [], options = {}) => { if (!allVariables) { return null; } const wordInfo = getCurrentWordWithContext(cm); if (!wordInfo) { return null; } const { word, from, to, context, requiresBraces } = wordInfo; const showHintsFor = options.showHintsFor || []; // Check if this context requires braces but we're not in a brace context if (context === 'variables' && !requiresBraces) { return null; } const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options); const filteredHints = filterHintsByContext(categorizedHints, word, context, showHintsFor); if (filteredHints.length === 0) { return null; } if (context === 'variables') { return createVariableHintList(filteredHints, from, to); } return createStandardHintList(filteredHints, from, to); }; /** * Handle click events for autocomplete * @param {Object} cm - CodeMirror instance * @param {Object} options - Configuration options */ const handleClickForAutocomplete = (cm, options) => { const allVariables = options.getAllVariables?.() || {}; const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || []; const showHintsFor = options.showHintsFor || []; // Build all available hints const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options); // Combine all hints based on showHintsFor configuration let allHints = []; // Add API hints if enabled const hasApiHints = showHintsFor.some((hint) => ['req', 'res', 'bru'].includes(hint)); if (hasApiHints) { allHints = [...allHints, ...categorizedHints.api]; } // Add variable hints if enabled if (showHintsFor.includes('variables')) { allHints = [...allHints, ...categorizedHints.variables]; } // Add anyword hints (always included) allHints = [...allHints, ...categorizedHints.anyword]; // Remove duplicates and sort allHints = [...new Set(allHints)].sort(); if (allHints.length === 0) { return; } const cursor = cm.getCursor(); if (cursor.ch > 0) return; // Defer showHint to ensure editor is focused setTimeout(() => { cm.showHint({ hint: () => ({ list: allHints, from: cursor, to: cursor }), completeSingle: false }); }, 0); }; /** * Handle keyup events for autocomplete * @param {Object} cm - CodeMirror instance * @param {Event} event - The keyup event * @param {Object} options - Configuration options */ const handleKeyupForAutocomplete = (cm, event, options) => { // Skip non-character keys if (!NON_CHARACTER_KEYS.test(event?.key)) { return; } const allVariables = options.getAllVariables?.() || {}; const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || []; const hints = getAutoCompleteHints(cm, allVariables, anywordAutocompleteHints, options); if (!hints) { const wordInfo = getCurrentWordWithContext(cm); if (cm.state.completionActive && wordInfo) { cm.state.completionActive.close(); } return; } cm.showHint({ hint: () => hints, completeSingle: false }); }; /** * Setup Bruno AutoComplete Helper on a CodeMirror editor * @param {Object} editor - CodeMirror editor instance * @param {Object} options - Configuration options * @returns {Function} Cleanup function */ export const setupAutoComplete = (editor, options = {}) => { if (!editor) { return; } const keyupHandler = (cm, event) => { handleKeyupForAutocomplete(cm, event, options); }; editor.on('keyup', keyupHandler); const clickHandler = (cm) => { // Only show hints on click if the option is enabled and there's no active completion if (options.showHintsOnClick) { handleClickForAutocomplete(cm, options); } }; // Add click handler if showHintsOnClick is enabled if (options.showHintsOnClick) { editor.on('mousedown', clickHandler); } return () => { editor.off('keyup', keyupHandler); if (options.showHintsOnClick) { editor.off('mousedown', clickHandler); } }; }; // Exported for testing export { extractNextSegmentSuggestions }; // Initialize autocomplete command if not already present if (!CodeMirror.commands.autocomplete) { CodeMirror.commands.autocomplete = (cm, hint, options) => { cm.showHint({ hint, ...options }); }; }