use crate::domain::error::{DomainError, DomainResult};
use std::sync::Arc;
use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionTextEdit, Documentation, InsertTextFormat,
TextEdit,
};
use tracing::{debug, instrument};
use crate::lsp::backend::BkmrConfig;
use crate::lsp::domain::{CompletionContext, Snippet, SnippetFilter};
use crate::lsp::services::{AsyncSnippetService, LanguageTranslator, LspSnippetService};
pub struct CompletionService {
snippet_service: Arc<LspSnippetService>,
config: BkmrConfig,
}
impl std::fmt::Debug for CompletionService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompletionService")
.field("snippet_service", &"<LspSnippetService>")
.field("config", &self.config)
.finish()
}
}
impl CompletionService {
pub fn new(snippet_service: Arc<LspSnippetService>) -> Self {
Self::with_config(snippet_service, BkmrConfig::default())
}
pub fn with_config(snippet_service: Arc<LspSnippetService>, config: BkmrConfig) -> Self {
Self {
snippet_service,
config,
}
}
#[instrument(skip(self))]
pub async fn get_completions(
&self,
context: &CompletionContext,
) -> DomainResult<Vec<CompletionItem>> {
let filter = self.build_snippet_filter(context);
let snippets = self.snippet_service.fetch_snippets(&filter).await?;
let completion_items: Result<Vec<CompletionItem>, _> = snippets
.iter()
.map(|snippet| {
self.snippet_to_completion_item(
snippet,
context.get_query_text().unwrap_or(""),
context.get_replacement_range(),
context.language_id.as_deref().unwrap_or("unknown"),
&context.uri,
)
})
.collect();
let completion_items = completion_items.map_err(|e| {
DomainError::Other(format!(
"Failed to convert snippets to completion items: {}",
e
))
})?;
debug!("Generated {} completion items", completion_items.len());
Ok(completion_items)
}
fn build_snippet_filter(&self, context: &CompletionContext) -> SnippetFilter {
let query_prefix = context.get_query_text().map(|s| s.to_string());
SnippetFilter::new(
context.language_id.clone(),
query_prefix,
50, self.config.enable_interpolation,
)
}
pub fn snippet_to_completion_item(
&self,
snippet: &Snippet,
query: &str,
replacement_range: Option<tower_lsp::lsp_types::Range>,
language_id: &str,
uri: &tower_lsp::lsp_types::Url,
) -> DomainResult<CompletionItem> {
let snippet_content = LanguageTranslator::translate_snippet(snippet, language_id, uri)?;
let label = snippet.title.clone();
debug!(
"Creating completion item: query='{}', label='{}', content_preview='{}'",
query,
label,
snippet_content.chars().take(20).collect::<String>()
);
let (item_kind, text_format, detail_text) = if snippet.is_plain() {
(
CompletionItemKind::TEXT,
InsertTextFormat::PLAIN_TEXT,
"bkmr plain text",
)
} else {
(
CompletionItemKind::SNIPPET,
InsertTextFormat::SNIPPET,
"bkmr snippet",
)
};
let mut completion_item = CompletionItem {
label: label.clone(),
kind: Some(item_kind),
detail: Some(detail_text.to_string()),
documentation: Some(Documentation::String(if snippet_content.len() > 500 {
format!("{}...", &snippet_content[..500])
} else {
snippet_content.clone()
})),
insert_text_format: Some(text_format),
filter_text: Some(label.clone()),
sort_text: Some(label.clone()),
..Default::default()
};
if let Some(range) = replacement_range {
completion_item.text_edit = Some(CompletionTextEdit::Edit(TextEdit {
range,
new_text: snippet_content,
}));
debug!("Set text_edit for range replacement: {:?}", range);
} else {
completion_item.insert_text = Some(snippet_content);
debug!("Using fallback insert_text (no range available)");
}
Ok(completion_item)
}
pub async fn health_check(&self) -> DomainResult<()> {
self.snippet_service
.health_check()
.await
.map_err(|e| DomainError::Other(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::testing::{init_test_env, EnvGuard};
use tower_lsp::lsp_types::{Position, Range, Url};
#[tokio::test]
async fn given_context_with_query_when_getting_completions_then_returns_filtered_items() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let ctx = crate::util::test_context::TestContext::new();
let lsp_bundle = ctx.create_lsp_services();
let service = lsp_bundle.completion_service;
let uri = Url::parse("file:///test.rs").expect("parse URI");
let context = CompletionContext::new(
uri,
Position {
line: 0,
character: 5,
},
Some("rust".to_string()),
);
let result = service.get_completions(&context).await;
assert!(result.is_ok());
let items = result.expect("valid completion items");
debug!("Got {} completion items", items.len());
}
#[tokio::test]
async fn given_plain_snippet_when_creating_completion_item_then_uses_plain_text_format() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let plain_snippet = Snippet::new(
1,
"Plain Text".to_string(),
"simple text content with no ${1:placeholders}".to_string(),
"Plain text snippet".to_string(),
vec!["plain".to_string(), "_snip_".to_string()],
);
let ctx = crate::util::test_context::TestContext::new();
let lsp_bundle = ctx.create_lsp_services();
let service = lsp_bundle.completion_service;
let uri = Url::parse("file:///test.rs").expect("parse URI");
let result = service.snippet_to_completion_item(&plain_snippet, "", None, "rust", &uri);
assert!(result.is_ok());
let item = result.expect("valid completion item");
assert_eq!(item.kind, Some(CompletionItemKind::TEXT));
assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT));
assert_eq!(item.detail, Some("bkmr plain text".to_string()));
assert_eq!(item.label, "Plain Text");
}
#[tokio::test]
async fn given_regular_snippet_when_creating_completion_item_then_uses_snippet_format() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let regular_snippet = Snippet::new(
1,
"Regular Snippet".to_string(),
"snippet with ${1:placeholder}".to_string(),
"Regular snippet".to_string(),
vec!["rust".to_string(), "_snip_".to_string()],
);
let ctx = crate::util::test_context::TestContext::new();
let lsp_bundle = ctx.create_lsp_services();
let service = lsp_bundle.completion_service;
let uri = Url::parse("file:///test.rs").expect("parse URI");
let result = service.snippet_to_completion_item(®ular_snippet, "", None, "rust", &uri);
assert!(result.is_ok());
let item = result.expect("valid completion item");
assert_eq!(item.kind, Some(CompletionItemKind::SNIPPET));
assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET));
assert_eq!(item.detail, Some("bkmr snippet".to_string()));
assert_eq!(item.label, "Regular Snippet");
}
#[tokio::test]
async fn given_universal_snippet_when_creating_completion_item_then_translates_content() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let universal_snippet = Snippet::new(
1,
"Universal Comment".to_string(),
"// This is a universal comment".to_string(),
"Universal snippet".to_string(),
vec!["universal".to_string(), "_snip_".to_string()],
);
let ctx = crate::util::test_context::TestContext::new();
let lsp_bundle = ctx.create_lsp_services();
let service = lsp_bundle.completion_service;
let uri = Url::parse("file:///test.py").expect("parse URI");
let result =
service.snippet_to_completion_item(&universal_snippet, "", None, "python", &uri);
assert!(result.is_ok());
let item = result.expect("valid completion item");
let insert_text = item.insert_text.expect("insert text");
assert!(insert_text.contains("# This is a universal comment"));
}
#[tokio::test]
async fn given_completion_item_with_range_when_creating_then_uses_text_edit() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let snippet = Snippet::new(
1,
"Test Snippet".to_string(),
"test content".to_string(),
"Test description".to_string(),
vec!["rust".to_string(), "_snip_".to_string()],
);
let ctx = crate::util::test_context::TestContext::new();
let lsp_bundle = ctx.create_lsp_services();
let service = lsp_bundle.completion_service;
let uri = Url::parse("file:///test.rs").expect("parse URI");
let range = Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 4,
},
};
let result =
service.snippet_to_completion_item(&snippet, "test", Some(range), "rust", &uri);
assert!(result.is_ok());
let item = result.expect("valid completion item");
match item.text_edit {
Some(CompletionTextEdit::Edit(edit)) => {
assert_eq!(edit.range, range);
assert_eq!(edit.new_text, "test content");
}
_ => panic!("Expected text edit"),
}
}
#[tokio::test]
async fn given_healthy_service_when_health_check_then_returns_ok() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let ctx = crate::util::test_context::TestContext::new();
let lsp_bundle = ctx.create_lsp_services();
let service = lsp_bundle.completion_service;
let result = service.health_check().await;
assert!(result.is_ok());
}
}