use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tower_lsp::{Client, LanguageServer, jsonrpc::Result as LspResult, lsp_types::*};
use tracing::{debug, error, info, instrument, warn};
use crate::domain::CompletionContext;
use crate::repositories::{BkmrRepository, RepositoryConfig};
use crate::services::CompletionService;
#[derive(Debug, Clone)]
pub struct LanguageInfo {
pub line_comment: Option<String>,
pub block_comment: Option<(String, String)>,
pub indent_char: String,
}
#[derive(Debug, Clone)]
pub struct BkmrConfig {
pub bkmr_binary: String,
pub max_completions: usize,
pub enable_interpolation: bool,
}
impl Default for BkmrConfig {
fn default() -> Self {
Self {
bkmr_binary: "bkmr".to_string(),
max_completions: 50,
enable_interpolation: true,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct BkmrSnippet {
pub id: i32,
pub title: String,
pub url: String, pub description: String,
pub tags: Vec<String>,
#[serde(default)]
pub access_count: i32,
}
#[derive(Debug)]
pub struct BkmrLspBackend {
client: Client,
config: BkmrConfig,
completion_service: CompletionService,
document_cache: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, String>>>,
language_cache: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, String>>>,
}
impl BkmrLspBackend {
pub fn new(client: Client) -> Self {
Self::with_config(client, BkmrConfig::default())
}
pub fn with_config(client: Client, config: BkmrConfig) -> Self {
debug!("Creating BkmrLspBackend with config: {:?}", config);
let repo_config = RepositoryConfig {
binary_path: config.bkmr_binary.clone(),
max_results: config.max_completions,
timeout_seconds: 10,
enable_interpolation: config.enable_interpolation,
};
let repository = std::sync::Arc::new(BkmrRepository::new(repo_config));
let completion_service = CompletionService::with_config(repository, config.clone());
Self {
client,
config,
completion_service,
document_cache: std::sync::Arc::new(std::sync::RwLock::new(
std::collections::HashMap::new(),
)),
language_cache: std::sync::Arc::new(std::sync::RwLock::new(
std::collections::HashMap::new(),
)),
}
}
#[instrument(skip(self))]
fn extract_snippet_query(&self, uri: &Url, position: Position) -> Option<(String, Range)> {
let cache = self.document_cache.read().ok()?;
let content = cache.get(&uri.to_string())?;
let lines: Vec<&str> = content.lines().collect();
if position.line as usize >= lines.len() {
return None;
}
let line = lines[position.line as usize];
let char_pos = position.character as usize;
if char_pos > line.len() {
return None;
}
let before_cursor = &line[..char_pos];
debug!(
"Extracting from line: '{}', char_pos: {}, before_cursor: '{}'",
line, char_pos, before_cursor
);
let word_start = before_cursor
.char_indices()
.rev()
.take_while(|(_, c)| c.is_alphanumeric() || *c == '_' || *c == '-')
.last()
.map(|(i, _)| i)
.unwrap_or(char_pos);
debug!("Word boundaries: start={}, end={}", word_start, char_pos);
if word_start < char_pos {
let word = &before_cursor[word_start..];
if !word.is_empty() && word.chars().any(|c| c.is_alphanumeric()) {
debug!("Extracted word: '{}' from position {}", word, char_pos);
let range = Range {
start: Position {
line: position.line,
character: word_start as u32,
},
end: Position {
line: position.line,
character: char_pos as u32,
},
};
return Some((word.to_string(), range));
}
}
debug!("No valid word found at position {}", char_pos);
None
}
fn get_language_id(&self, uri: &Url) -> Option<String> {
let cache = self.language_cache.read().ok()?;
cache.get(&uri.to_string()).cloned()
}
#[instrument(skip(self))]
async fn verify_bkmr_availability(&self) -> Result<()> {
debug!("Verifying bkmr availability");
let command_future = tokio::process::Command::new(&self.config.bkmr_binary)
.args(["--help"])
.output();
let output =
match tokio::time::timeout(std::time::Duration::from_secs(5), command_future).await {
Ok(Ok(output)) => output,
Ok(Err(e)) => {
return Err(anyhow!("bkmr binary not found: {}", e));
}
Err(_) => {
return Err(anyhow!("bkmr --help command timed out"));
}
};
if !output.status.success() {
return Err(anyhow!("bkmr binary is not working properly"));
}
info!("bkmr binary verified successfully");
Ok(())
}
pub fn get_language_info(&self, language_id: &str) -> LanguageInfo {
match language_id.to_lowercase().as_str() {
"rust" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"javascript" | "js" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"typescript" | "ts" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"python" => LanguageInfo {
line_comment: Some("#".to_string()),
block_comment: Some(("\"\"\"".to_string(), "\"\"\"".to_string())),
indent_char: " ".to_string(),
},
"go" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: "\t".to_string(),
},
"java" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"c" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"cpp" | "c++" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"html" => LanguageInfo {
line_comment: None,
block_comment: Some(("<!--".to_string(), "-->".to_string())),
indent_char: " ".to_string(),
},
"css" => LanguageInfo {
line_comment: None,
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"scss" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"ruby" => LanguageInfo {
line_comment: Some("#".to_string()),
block_comment: Some(("=begin".to_string(), "=end".to_string())),
indent_char: " ".to_string(),
},
"php" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"swift" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"kotlin" => LanguageInfo {
line_comment: Some("//".to_string()),
block_comment: Some(("/*".to_string(), "*/".to_string())),
indent_char: " ".to_string(),
},
"shell" | "bash" | "sh" => LanguageInfo {
line_comment: Some("#".to_string()),
block_comment: None,
indent_char: " ".to_string(),
},
"yaml" | "yml" => LanguageInfo {
line_comment: Some("#".to_string()),
block_comment: None,
indent_char: " ".to_string(),
},
"json" => LanguageInfo {
line_comment: None,
block_comment: None,
indent_char: " ".to_string(),
},
"markdown" | "md" => LanguageInfo {
line_comment: None,
block_comment: Some(("<!--".to_string(), "-->".to_string())),
indent_char: " ".to_string(),
},
"xml" => LanguageInfo {
line_comment: None,
block_comment: Some(("<!--".to_string(), "-->".to_string())),
indent_char: " ".to_string(),
},
"vim" | "viml" => LanguageInfo {
line_comment: Some("\"".to_string()),
block_comment: None,
indent_char: " ".to_string(),
},
_ => LanguageInfo {
line_comment: Some("#".to_string()),
block_comment: None,
indent_char: " ".to_string(),
},
}
}
fn get_comment_syntax(&self, file_path: &str) -> &'static str {
let path = Path::new(file_path);
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let language_id = match extension {
"rs" => "rust",
"js" | "mjs" => "javascript",
"ts" | "tsx" => "typescript",
"py" | "pyw" => "python",
"go" => "go",
"java" => "java",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" => "cpp",
"html" | "htm" => "html",
"css" => "css",
"scss" => "scss",
"rb" => "ruby",
"php" => "php",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"sh" | "bash" | "zsh" => "shell",
"yaml" | "yml" => "yaml",
"json" => "json",
"md" | "markdown" => "markdown",
"xml" => "xml",
"vim" => "vim",
_ => "unknown",
};
let lang_info = self.get_language_info(language_id);
if let Some(_line_comment) = &lang_info.line_comment {
match language_id {
"rust" | "javascript" | "typescript" | "go" | "java" | "c" | "cpp" | "swift"
| "kotlin" | "scss" | "php" => "//",
"python" | "shell" | "yaml" => "#",
"html" | "markdown" | "xml" => "<!--",
"css" => "/*",
"vim" => "\"",
_ => "#",
}
} else {
"#"
}
}
fn get_relative_path(&self, file_uri: &str) -> String {
let url = match Url::parse(file_uri) {
Ok(u) => u,
Err(_) => return file_uri.to_string(),
};
let file_path = match url.to_file_path() {
Ok(p) => p,
Err(_) => return file_uri.to_string(),
};
let mut current = file_path.as_path();
while let Some(parent) = current.parent() {
if parent.join("Cargo.toml").exists()
|| parent.join("package.json").exists()
|| parent.join("pom.xml").exists()
|| parent.join("build.gradle").exists()
|| parent.join("build.gradle.kts").exists()
|| parent.join("Makefile").exists()
|| parent.join(".git").exists()
{
if let Ok(rel_path) = file_path.strip_prefix(parent) {
return rel_path.to_string_lossy().to_string();
}
break;
}
current = parent;
}
file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| file_uri.to_string())
}
#[instrument(skip(self))]
async fn insert_filepath_comment(&self, file_uri: &str) -> Result<Vec<TextEdit>> {
let relative_path = self.get_relative_path(file_uri);
let comment_syntax = self.get_comment_syntax(file_uri);
let comment_text = match comment_syntax {
"<!--" => format!("<!-- {} -->\n", relative_path),
"/*" => format!("/* {} */\n", relative_path),
_ => format!("{} {}\n", comment_syntax, relative_path),
};
debug!("Inserting filepath comment: {}", comment_text.trim());
let edit = TextEdit {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
new_text: comment_text,
};
Ok(vec![edit])
}
}
#[tower_lsp::async_trait]
impl LanguageServer for BkmrLspBackend {
#[instrument(skip(self, params))]
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
info!(
"Initialize request received from client: {:?}",
params.client_info
);
if let Err(e) = self.verify_bkmr_availability().await {
error!("bkmr verification failed: {}", e);
self.client
.log_message(
MessageType::ERROR,
&format!("Failed to verify bkmr availability: {}", e),
)
.await;
}
let snippet_support = params
.capabilities
.text_document
.as_ref()
.and_then(|td| td.completion.as_ref())
.and_then(|comp| comp.completion_item.as_ref())
.and_then(|item| item.snippet_support)
.unwrap_or(false);
info!("Client snippet support: {}", snippet_support);
if !snippet_support {
warn!("Client does not support snippets");
self.client
.log_message(
MessageType::WARNING,
"Client does not support snippets, functionality may be limited",
)
.await;
}
let result = InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
completion_provider: Some(CompletionOptions {
resolve_provider: Some(false),
trigger_characters: None, all_commit_characters: None,
work_done_progress_options: WorkDoneProgressOptions::default(),
completion_item: None,
}),
execute_command_provider: Some(ExecuteCommandOptions {
commands: vec!["bkmr.insertFilepathComment".to_string()],
work_done_progress_options: WorkDoneProgressOptions::default(),
}),
..Default::default()
},
..Default::default()
};
info!("Initialize complete - manual completion only (no trigger characters)");
Ok(result)
}
#[instrument(skip(self))]
async fn initialized(&self, _: InitializedParams) {
info!("Server initialized successfully");
self.client
.log_message(MessageType::INFO, "bkmr-lsp server ready")
.await;
}
#[instrument(skip(self))]
async fn shutdown(&self) -> LspResult<()> {
info!("Shutdown request received");
self.client
.log_message(MessageType::INFO, "Shutting down bkmr-lsp server")
.await;
Ok(())
}
#[instrument(skip(self, params))]
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri.to_string();
let content = params.text_document.text;
let language_id = params.text_document.language_id;
debug!("Document opened: {} (language: {})", uri, language_id);
if let Ok(mut cache) = self.document_cache.write() {
cache.insert(uri.clone(), content);
}
if let Ok(mut lang_cache) = self.language_cache.write() {
lang_cache.insert(uri, language_id);
}
}
#[instrument(skip(self, params))]
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri.to_string();
debug!("Document changed: {}", uri);
if let Ok(mut cache) = self.document_cache.write() {
for change in params.content_changes {
if let Some(content) = cache.get_mut(&uri) {
if change.range.is_none() {
*content = change.text;
} else {
*content = change.text;
}
}
}
}
}
#[instrument(skip(self, params))]
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri.to_string();
debug!("Document closed: {}", uri);
if let Ok(mut cache) = self.document_cache.write() {
cache.remove(&uri);
}
if let Ok(mut lang_cache) = self.language_cache.write() {
lang_cache.remove(&uri);
}
}
#[instrument(skip(self, params))]
async fn completion(&self, params: CompletionParams) -> LspResult<Option<CompletionResponse>> {
let uri = ¶ms.text_document_position.text_document.uri;
let position = params.text_document_position.position;
debug!(
"Completion request for {}:{},{}",
uri, position.line, position.character
);
if let Some(context) = ¶ms.context {
match context.trigger_kind {
CompletionTriggerKind::INVOKED => {
debug!("Manual completion request - proceeding with word-based snippet search");
}
CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS => {
debug!("Completion for incomplete results - proceeding");
}
_ => {
debug!("Ignoring automatic trigger - only manual completion supported");
return Ok(Some(CompletionResponse::Array(vec![])));
}
}
} else {
debug!("No completion context - skipping");
return Ok(Some(CompletionResponse::Array(vec![])));
}
let query_info = self.extract_snippet_query(uri, position);
debug!("Extracted snippet query info: {:?}", query_info);
let language_id = self.get_language_id(uri);
debug!("Document language ID: {:?}", language_id);
let mut context = CompletionContext::new(
uri.clone(),
position,
language_id
);
if let Some((query, range)) = query_info {
debug!("Query: '{}', Range: {:?}", query, range);
context = context.with_query(crate::domain::CompletionQuery::new(query, range));
} else {
debug!("No query extracted, using empty query");
}
match self.completion_service.get_completions(&context).await {
Ok(completion_items) => {
info!(
"Returning {} completion items for query: {:?}",
completion_items.len(),
context.get_query_text().unwrap_or("")
);
for (i, item) in completion_items.iter().enumerate().take(3) {
debug!(
"Item {}: label='{}', sort_text={:?}",
i, item.label, item.sort_text
);
}
if completion_items.len() > 3 {
debug!("... and {} more items", completion_items.len() - 3);
}
Ok(Some(CompletionResponse::List(CompletionList {
is_incomplete: true,
items: completion_items,
})))
}
Err(e) => {
error!("Failed to get completions: {}", e);
self.client
.log_message(
MessageType::ERROR,
&format!("Failed to get completions: {}", e),
)
.await;
Ok(Some(CompletionResponse::Array(vec![])))
}
}
}
#[instrument(skip(self, params))]
async fn execute_command(
&self,
params: ExecuteCommandParams,
) -> LspResult<Option<serde_json::Value>> {
debug!("Execute command request: {}", params.command);
match params.command.as_str() {
"bkmr.insertFilepathComment" => {
if !params.arguments.is_empty() {
if let Some(first_arg) = params.arguments.first() {
if let Ok(uri_str) = serde_json::from_value::<String>(first_arg.clone()) {
match self.insert_filepath_comment(&uri_str).await {
Ok(edits) => {
let workspace_edit = WorkspaceEdit {
changes: Some({
let mut changes = std::collections::HashMap::new();
if let Ok(uri) = Url::parse(&uri_str) {
changes.insert(uri, edits);
}
changes
}),
document_changes: None,
change_annotations: None,
};
match self.client.apply_edit(workspace_edit).await {
Ok(response) => {
if response.applied {
info!("Successfully inserted filepath comment");
self.client
.log_message(
MessageType::INFO,
"Filepath comment inserted successfully",
)
.await;
} else {
warn!("Client rejected the edit");
self.client
.log_message(
MessageType::WARNING,
"Failed to apply filepath comment edit",
)
.await;
}
}
Err(e) => {
error!("Failed to apply edit: {}", e);
self.client
.log_message(
MessageType::ERROR,
&format!("Failed to apply edit: {}", e),
)
.await;
}
}
}
Err(e) => {
error!("Failed to create filepath comment: {}", e);
self.client
.log_message(
MessageType::ERROR,
&format!("Failed to create filepath comment: {}", e),
)
.await;
}
}
} else {
error!("Invalid argument format for insertFilepathComment");
}
} else {
error!("No arguments provided for insertFilepathComment command");
}
} else {
error!("No arguments provided for insertFilepathComment command");
}
}
_ => {
error!("Unknown command: {}", params.command);
self.client
.log_message(
MessageType::ERROR,
&format!("Unknown command: {}", params.command),
)
.await;
}
}
Ok(None)
}
}
pub async fn start_server<I, O>(read: I, write: O)
where
I: tokio::io::AsyncRead + Unpin,
O: tokio::io::AsyncWrite,
{
use tower_lsp::{LspService, Server};
let (service, socket) = LspService::new(BkmrLspBackend::new);
Server::new(read, write, socket).serve(service).await;
}