use std::sync::LazyLock;
use futures::StreamExt as _;
use regex::Regex;
use zeph_memory::TokenCounter;
use crate::sanitizer::{ContentSanitizer, ContentSource, ContentSourceKind};
use super::{LspHookRunner, LspNote};
const MAX_CONCURRENT_HOVER_CALLS: usize = 3;
static SYMBOL_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?m)^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:fn|struct|enum|trait|impl|type)\s+\w",
)
.expect("valid regex")
});
fn extract_symbol_positions(content: &str, max_symbols: usize) -> Vec<(u64, u64)> {
let mut positions = Vec::new();
for m in SYMBOL_LINE_RE.find_iter(content) {
if positions.len() >= max_symbols {
break;
}
let line = content[..m.start()].chars().filter(|c| *c == '\n').count() as u64;
let line_start = content[..m.start()].rfind('\n').map_or(0, |p| p + 1);
let character = (m.start() - line_start) as u64;
positions.push((line, character));
}
positions
}
pub(super) async fn fetch_hover(
runner: &LspHookRunner,
tool_params: &serde_json::Value,
tool_output: &str,
token_counter: &std::sync::Arc<TokenCounter>,
sanitizer: &ContentSanitizer,
) -> Option<LspNote> {
let file_path = tool_params.get("path").and_then(|v| v.as_str())?.to_owned();
if !std::path::Path::new(&file_path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
{
return None;
}
let positions = extract_symbol_positions(tool_output, runner.config.hover.max_symbols);
if positions.is_empty() {
return None;
}
let timeout = std::time::Duration::from_secs(runner.config.call_timeout_secs);
let manager = &runner.manager;
let server_id = &runner.config.mcp_server_id;
let mut entries: Vec<String> =
futures::stream::iter(positions.iter().map(|(line, character)| {
let args = serde_json::json!({
"path": file_path,
"line": line,
"character": character,
});
tokio::time::timeout(timeout, manager.call_tool(server_id, "get_hover", args))
}))
.buffer_unordered(MAX_CONCURRENT_HOVER_CALLS)
.filter_map(|r| async move {
match r {
Ok(Ok(result)) => {
let text = result
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.trim().to_owned()))?;
if text.is_empty() { None } else { Some(text) }
}
_ => None,
}
})
.collect()
.await;
if entries.is_empty() {
return None;
}
entries.sort_unstable();
entries.dedup();
let raw_content = entries.join("\n---\n");
let clean = sanitizer.sanitize(
&raw_content,
ContentSource::new(ContentSourceKind::McpResponse).with_identifier("mcpls/hover"),
);
if !clean.injection_flags.is_empty() {
tracing::warn!(
path = file_path,
flags = ?clean.injection_flags.iter().map(|f| f.pattern_name).collect::<Vec<_>>(),
"LSP hover content contains injection patterns"
);
}
let estimated_tokens = token_counter.count_tokens(&clean.body);
Some(LspNote {
kind: "hover",
content: clean.body,
estimated_tokens,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_rust_symbols() {
let src = "pub fn foo() {}\npub struct Bar;\npub enum Baz {}";
let positions = extract_symbol_positions(src, 10);
assert_eq!(positions.len(), 3);
assert_eq!(positions[0].0, 0);
assert_eq!(positions[1].0, 1);
assert_eq!(positions[2].0, 2);
}
#[test]
fn respects_max_symbols() {
let src = "pub fn a() {}\npub fn b() {}\npub fn c() {}";
let positions = extract_symbol_positions(src, 2);
assert_eq!(positions.len(), 2);
}
#[test]
fn no_symbols_empty_file() {
let positions = extract_symbol_positions("", 10);
assert!(positions.is_empty());
}
}