code-moniker-workspace 0.4.0

Workspace model, ports, snapshots, linkage, and change analysis for code-moniker.
Documentation
use super::model::SearchHit;
use crate::snapshot::model::{SymbolRecord, WorkspaceSnapshot};

pub struct SearchView<'a> {
	snapshot: &'a WorkspaceSnapshot,
}

impl<'a> SearchView<'a> {
	pub(super) fn new(snapshot: &'a WorkspaceSnapshot) -> Self {
		Self { snapshot }
	}

	pub fn search_symbols(&self, query: &str, limit: usize) -> Vec<SearchHit> {
		self.search_symbols_matching(query, limit, |_| true)
	}

	pub fn search_symbols_matching(
		&self,
		query: &str,
		limit: usize,
		mut matches: impl FnMut(&SymbolRecord) -> bool,
	) -> Vec<SearchHit> {
		let trimmed = query.trim();
		let raw = trimmed.to_ascii_lowercase();
		let terms = search_terms(trimmed);
		if raw.is_empty() || terms.is_empty() || limit == 0 {
			return Vec::new();
		}
		let mut hits = self
			.snapshot
			.index
			.symbols
			.iter()
			.filter(|symbol| symbol.navigable)
			.filter(|symbol| matches(symbol))
			.filter_map(|symbol| {
				let (score, reason) = score_symbol(symbol, &raw, &terms)?;
				Some((
					SearchHit {
						symbol: symbol.id,
						score,
						reason,
					},
					symbol.identity.as_ref(),
				))
			})
			.collect::<Vec<_>>();
		hits.sort_by(|(left, left_identity), (right, right_identity)| {
			right
				.score
				.cmp(&left.score)
				.then_with(|| left_identity.cmp(right_identity))
		});
		hits.truncate(limit);
		hits.into_iter().map(|(hit, _)| hit).collect()
	}
}

fn search_terms(query: &str) -> Vec<String> {
	query
		.split(|c: char| !c.is_alphanumeric())
		.flat_map(split_camel_case)
		.filter(|term| !term.is_empty())
		.map(|term| term.to_ascii_lowercase())
		.collect()
}

fn split_camel_case(word: &str) -> Vec<&str> {
	let mut parts = Vec::new();
	let mut start = 0;
	let bytes = word.as_bytes();
	for idx in 1..bytes.len() {
		let boundary = bytes[idx].is_ascii_uppercase()
			&& (bytes[idx - 1].is_ascii_lowercase()
				|| bytes
					.get(idx + 1)
					.is_some_and(|next| next.is_ascii_lowercase()));
		if boundary {
			parts.push(&word[start..idx]);
			start = idx;
		}
	}
	parts.push(&word[start..]);
	parts
}

fn score_symbol(symbol: &SymbolRecord, phrase: &str, terms: &[String]) -> Option<(u32, String)> {
	let name = symbol.name.to_ascii_lowercase();
	let kind = symbol.kind.to_ascii_lowercase();
	let identity = symbol.identity.to_ascii_lowercase();
	let fields = [
		("name", name.as_str(), 120, 50),
		("kind", kind.as_str(), 35, 20),
		("identity", identity.as_str(), 20, 10),
	];
	let mut score = 0;
	let mut reason = None;
	for (label, value, exact_score, _) in fields {
		if let Some(field_score) = phrase_match_score(value, phrase, exact_score) {
			score += field_score;
			reason.get_or_insert(label);
		}
	}
	for term in terms {
		let mut matched = false;
		for (label, value, _, term_score) in fields {
			if value.contains(term) {
				score += term_score;
				matched = true;
				reason.get_or_insert(label);
			}
		}
		if !matched {
			return None;
		}
	}
	(score > 0).then(|| (score, reason.unwrap_or("match").to_string()))
}

fn phrase_match_score(value: &str, phrase: &str, exact_score: u32) -> Option<u32> {
	if value == phrase {
		return Some(exact_score * 2);
	}
	let start = value.find(phrase)?;
	let extra_len = value.chars().count().saturating_sub(phrase.chars().count()) as u32;
	let proximity_bonus = 30u32.saturating_sub(extra_len.min(30));
	let prefix_bonus = if start == 0 { 30 } else { 0 };
	Some(exact_score + prefix_bonus + proximity_bonus)
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::snapshot::model::{
		ChangeOverlay, CodeIndex, LinkageSnapshot, ResourceGeneration, SourceCatalog, SourceId,
		SymbolId, WorkspaceTimings,
	};

	fn symbol(id: usize, name: &str, kind: &str, identity: &str) -> SymbolRecord {
		SymbolRecord {
			id: SymbolId::at(0, id),
			source: SourceId::at(0),
			identity: std::sync::Arc::from(identity),
			name: name.to_string(),
			kind: kind.to_string(),
			visibility: String::new(),
			signature: String::new(),
			navigable: true,
			line_range: None,
			parent: None,
		}
	}

	fn score(symbol: &SymbolRecord, query: &str) -> u32 {
		let raw = query.to_ascii_lowercase();
		let terms = search_terms(&raw);
		score_symbol(symbol, &raw, &terms)
			.expect("symbol should match")
			.0
	}

	fn snapshot(symbols: Vec<SymbolRecord>) -> WorkspaceSnapshot {
		WorkspaceSnapshot {
			generation: ResourceGeneration::new(1),
			catalog: SourceCatalog::new(ResourceGeneration::new(1), Vec::new()),
			index: CodeIndex::new(
				ResourceGeneration::new(1),
				ResourceGeneration::new(1),
				symbols,
			),
			linkage: LinkageSnapshot::new(
				ResourceGeneration::new(1),
				ResourceGeneration::new(1),
				0,
				0,
			),
			changes: ChangeOverlay::new(
				ResourceGeneration::new(1),
				ResourceGeneration::new(1),
				ResourceGeneration::new(1),
				Vec::new(),
			),
			timings: WorkspaceTimings::default(),
		}
	}

	#[test]
	fn exact_name_match_still_beats_partial_match() {
		let exact = symbol(0, "parse", "fn", "code+moniker://./fn:parse");
		let partial = symbol(
			1,
			"parse_fragment",
			"fn",
			"code+moniker://./fn:parse_fragment",
		);

		assert!(score(&exact, "parse") > score(&partial, "parse"));
	}

	#[test]
	fn shorter_prefix_match_beats_longer_prefix_match() {
		let short = symbol(0, "parse_ast", "fn", "code+moniker://./fn:parse_ast");
		let long = symbol(
			1,
			"parse_fragment_from_source",
			"fn",
			"code+moniker://./fn:parse_fragment_from_source",
		);

		assert!(score(&short, "parse") > score(&long, "parse"));
	}

	#[test]
	fn filtered_search_ranks_after_filtering() {
		let unfiltered_best = symbol(0, "parse", "fn", "code+moniker://./fn:parse");
		let filtered_hit = symbol(
			1,
			"parse_fragment",
			"fn",
			"code+moniker://./module:target/fn:parse_fragment",
		);
		let snapshot = snapshot(vec![unfiltered_best, filtered_hit]);

		let hits = SearchView::new(&snapshot).search_symbols_matching("parse", 1, |symbol| {
			symbol.identity.contains("module:target")
		});

		assert_eq!(hits.len(), 1);
		assert_eq!(hits[0].symbol, SymbolId::at(0, 1));
	}
}