/**
* Aliki Search Implementation * * Search algorithm with the following priorities: * 1. Exact full_name match always wins (for namespace/method queries) * 2. Exact name match gets high priority * 3. Match types: * - Namespace queries (::) and method queries (# or .) match against full_name * - Regular queries match against unqualified name * - Prefix (10000) > substring (5000) > fuzzy (1000) * 4. First character determines type priority: * - Starts with lowercase: methods first * - Starts with uppercase: classes/modules/constants first * 5. Within same type priority: * - Unqualified match > qualified match * - Shorter name > longer name * 6. Class methods > instance methods * 7. Result limit: 30 * 8. Minimum query length: 1 character */
var MAX_RESULTS = 30; var MIN_QUERY_LENGTH = 1;
/*
* Scoring constants - organized in tiers where each tier dominates lower tiers. * This ensures match type always beats type priority, etc. * * Tier 0: Exact matches (immediate return) * Tier 1: Match type (prefix > substring > fuzzy) * Tier 2: Exact name bonus * Tier 3: Type priority (method vs class based on query case) * Tier 4: Minor bonuses (top-level, class method, name length) */
var SCORE_EXACT_FULL_NAME = 1000000; // Tier 0: Query exactly matches full_name var SCORE_MATCH_PREFIX = 10000; // Tier 1: Query is prefix of name var SCORE_MATCH_SUBSTRING = 5000; // Tier 1: Query is substring of name var SCORE_MATCH_FUZZY = 1000; // Tier 1: Query chars appear in order var SCORE_EXACT_NAME = 500; // Tier 2: Name exactly equals query var SCORE_TYPE_PRIORITY = 100; // Tier 3: Preferred type (method/class) var SCORE_TOP_LEVEL = 50; // Tier 4: Top-level over namespaced var SCORE_CLASS_METHOD = 10; // Tier 4: Class method over instance method
/**
* Check if all characters in query appear in order in target * e.g., "addalias" fuzzy matches "add_foo_alias" */
function fuzzyMatch(target, query) {
var ti = 0;
for (var qi = 0; qi < query.length; qi++) {
ti = target.indexOf(query[qi], ti);
if (ti === -1) return false;
ti++;
}
return true;
}
/**
* Parse and normalize a search query
* @param {string} query - The raw search query
* @returns {Object} Parsed query with normalized form and flags
*/
function parseQuery(query) {
// Lowercase for case-insensitive matching (so "hash" finds both Hash class and #hash methods)
var normalized = query.toLowerCase();
var isNamespaceQuery = query.includes('::');
var isMethodQuery = query.includes('#') || query.includes('.');
// Normalize . to :: (RDoc uses :: for class methods in full_name)
if (query.includes('.')) {
normalized = normalized.replace(/\./g, '::');
}
return {
original: query,
normalized: normalized,
isNamespaceQuery: isNamespaceQuery,
isMethodQuery: isMethodQuery,
// Namespace and method queries match against full_name instead of name
matchesFullName: isNamespaceQuery || isMethodQuery,
// If query starts with lowercase, prioritize methods; otherwise prioritize classes/modules/constants
prioritizeMethod: !/^[A-Z]/.test(query)
};
}
/**
* Main search function
* @param {string} query - The search query
* @param {Array} index - The search index to search in
* @returns {Array} Array of matching entries, sorted by relevance
*/
function search(query, index) {
if (!query || query.length < MIN_QUERY_LENGTH) {
return [];
}
var q = parseQuery(query);
var results = [];
for (var i = 0; i < index.length; i++) {
var entry = index[i];
var score = computeScore(entry, q);
if (score !== null) {
results.push({ entry: entry, score: score });
}
}
results.sort(function(a, b) {
return b.score - a.score;
});
return results.slice(0, MAX_RESULTS).map(function(r) {
return r.entry;
});
}
/**
* Compute the relevance score for an entry
* @param {Object} entry - The search index entry
* @param {Object} q - Parsed query from parseQuery()
* @returns {number|null} Score or null if no match
*/
function computeScore(entry, q) {
var name = entry.name;
var fullName = entry.full_name;
var type = entry.type;
var nameLower = name.toLowerCase();
var fullNameLower = fullName.toLowerCase();
// Exact full_name match (e.g., "Array#filter" matches Array#filter)
if (q.matchesFullName && fullNameLower === q.normalized) {
return SCORE_EXACT_FULL_NAME;
}
var matchScore = 0;
var target = q.matchesFullName ? fullNameLower : nameLower;
if (target.startsWith(q.normalized)) {
matchScore = SCORE_MATCH_PREFIX; // Prefix (e.g., "Arr" matches "Array")
} else if (target.includes(q.normalized)) {
matchScore = SCORE_MATCH_SUBSTRING; // Substring (e.g., "ray" matches "Array")
} else if (fuzzyMatch(target, q.normalized)) {
matchScore = SCORE_MATCH_FUZZY; // Fuzzy (e.g., "addalias" matches "add_foo_alias")
} else {
return null;
}
var score = matchScore;
var isMethod = (type === 'instance_method' || type === 'class_method');
if (q.prioritizeMethod ? isMethod : !isMethod) {
score += SCORE_TYPE_PRIORITY;
}
if (type === 'class_method') score += SCORE_CLASS_METHOD;
if (name === fullName) score += SCORE_TOP_LEVEL; // Top-level (Hash) > namespaced (Foo::Hash)
if (nameLower === q.normalized) score += SCORE_EXACT_NAME; // Exact name match
score -= name.length;
return score;
}
/**
* SearchRanker class for compatibility with the Search UI * Provides ready() and find() interface */
function SearchRanker(index) {
this.index = index; this.handlers = [];
}
SearchRanker.prototype.ready = function(fn) {
this.handlers.push(fn);
};
SearchRanker.prototype.find = function(query) {
var q = parseQuery(query); var rawResults = search(query, this.index); var results = rawResults.map(function(entry) { return formatResult(entry, q); }); var _this = this; this.handlers.forEach(function(fn) { fn.call(_this, results, true); });
};
/**
* Format a search result entry for display */
function formatResult(entry, q) {
var result = {
title: highlightMatch(entry.full_name, q),
path: entry.path,
type: entry.type
};
if (entry.snippet) {
result.snippet = entry.snippet;
}
return result;
}
/**
* Add highlight markers (\u0001 and \u0002) to matching portions of text
* @param {string} text - The text to highlight
* @param {Object} q - Parsed query from parseQuery()
*/
function highlightMatch(text, q) {
if (!text || !q) return text;
var textLower = text.toLowerCase();
var query = q.normalized;
// Try contiguous match first (prefix or substring)
var matchIndex = textLower.indexOf(query);
if (matchIndex !== -1) {
return text.substring(0, matchIndex) +
'\u0001' + text.substring(matchIndex, matchIndex + query.length) + '\u0002' +
text.substring(matchIndex + query.length);
}
// Fall back to fuzzy highlight (highlight each matched character)
var result = '';
var ti = 0;
for (var qi = 0; qi < query.length; qi++) {
var charIndex = textLower.indexOf(query[qi], ti);
if (charIndex === -1) return text;
result += text.substring(ti, charIndex);
result += '\u0001' + text[charIndex] + '\u0002';
ti = charIndex + 1;
}
result += text.substring(ti);
return result;
}