240 lines
7.2 KiB
JavaScript
240 lines
7.2 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|