use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer};
use crate::code_actions::fixes_to_code_actions;
use crate::diagnostic_mapper::{deserialize_fixes, to_lsp_diagnostics};
use crate::hover_provider::hover_at_position;
fn create_error_diagnostic(code: &str, message: String) -> Diagnostic {
Diagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(code.to_string())),
code_description: None,
source: Some("agnix".to_string()),
message,
related_information: None,
tags: None,
data: None,
}
}
pub struct Backend {
client: Client,
config: RwLock<Arc<agnix_core::LintConfig>>,
workspace_root: RwLock<Option<PathBuf>>,
documents: RwLock<HashMap<Url, String>>,
registry: Arc<agnix_core::ValidatorRegistry>,
}
impl Backend {
pub fn new(client: Client) -> Self {
Self {
client,
config: RwLock::new(Arc::new(agnix_core::LintConfig::default())),
workspace_root: RwLock::new(None),
documents: RwLock::new(HashMap::new()),
registry: Arc::new(agnix_core::ValidatorRegistry::with_defaults()),
}
}
async fn validate_file(&self, path: PathBuf) -> Vec<Diagnostic> {
let config = Arc::clone(&*self.config.read().await);
let registry = Arc::clone(&self.registry);
let result = tokio::task::spawn_blocking(move || {
agnix_core::validate_file_with_registry(&path, &config, ®istry)
})
.await;
match result {
Ok(Ok(diagnostics)) => to_lsp_diagnostics(diagnostics),
Ok(Err(e)) => vec![create_error_diagnostic(
"agnix::validation-error",
format!("Validation error: {}", e),
)],
Err(e) => vec![create_error_diagnostic(
"agnix::internal-error",
format!("Internal error: {}", e),
)],
}
}
async fn validate_from_content_and_publish(&self, uri: Url) {
let file_path = match uri.to_file_path() {
Ok(p) => p,
Err(()) => {
self.client
.log_message(MessageType::WARNING, format!("Invalid file URI: {}", uri))
.await;
return;
}
};
if let Some(ref workspace_root) = *self.workspace_root.read().await {
let canonical_path = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => file_path.clone(),
};
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.clone());
if !canonical_path.starts_with(&canonical_root) {
self.client
.log_message(
MessageType::WARNING,
format!("File outside workspace boundary: {}", uri),
)
.await;
return;
}
}
let content = {
let docs = self.documents.read().await;
match docs.get(&uri) {
Some(c) => c.clone(),
None => {
drop(docs);
let diagnostics = self.validate_file(file_path).await;
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
return;
}
}
};
let config = Arc::clone(&*self.config.read().await);
let result = tokio::task::spawn_blocking(move || {
let file_type = agnix_core::detect_file_type(&file_path);
if file_type == agnix_core::FileType::Unknown {
return Ok(vec![]);
}
let registry = agnix_core::ValidatorRegistry::with_defaults();
let validators = registry.validators_for(file_type);
let mut diagnostics = Vec::new();
for validator in validators {
diagnostics.extend(validator.validate(&file_path, &content, &config));
}
Ok::<_, agnix_core::LintError>(diagnostics)
})
.await;
let diagnostics = match result {
Ok(Ok(diagnostics)) => to_lsp_diagnostics(diagnostics),
Ok(Err(e)) => vec![create_error_diagnostic(
"agnix::validation-error",
format!("Validation error: {}", e),
)],
Err(e) => vec![create_error_diagnostic(
"agnix::internal-error",
format!("Internal error: {}", e),
)],
};
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
async fn get_document_content(&self, uri: &Url) -> Option<String> {
self.documents.read().await.get(uri).cloned()
}
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
if let Some(root_uri) = params.root_uri {
if let Ok(root_path) = root_uri.to_file_path() {
*self.workspace_root.write().await = Some(root_path.clone());
let config_path = root_path.join(".agnix.toml");
if config_path.exists() {
match agnix_core::LintConfig::load(&config_path) {
Ok(loaded_config) => {
*self.config.write().await = Arc::new(loaded_config);
}
Err(e) => {
self.client
.log_message(
MessageType::WARNING,
format!("Failed to load .agnix.toml: {}", e),
)
.await;
}
}
}
}
}
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
..Default::default()
},
server_info: Some(ServerInfo {
name: "agnix-lsp".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
self.client
.log_message(MessageType::INFO, "agnix-lsp initialized")
.await;
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
{
let mut docs = self.documents.write().await;
docs.insert(
params.text_document.uri.clone(),
params.text_document.text.clone(),
);
}
self.validate_from_content_and_publish(params.text_document.uri)
.await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
if let Some(change) = params.content_changes.into_iter().next() {
{
let mut docs = self.documents.write().await;
docs.insert(params.text_document.uri.clone(), change.text);
}
self.validate_from_content_and_publish(params.text_document.uri)
.await;
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.validate_from_content_and_publish(params.text_document.uri)
.await;
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
{
let mut docs = self.documents.write().await;
docs.remove(¶ms.text_document.uri);
}
self.client
.publish_diagnostics(params.text_document.uri, vec![], None)
.await;
}
async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
let uri = ¶ms.text_document.uri;
let content = match self.get_document_content(uri).await {
Some(c) => c,
None => return Ok(None),
};
let mut actions = Vec::new();
for diag in ¶ms.context.diagnostics {
let diag_range = &diag.range;
let req_range = ¶ms.range;
let overlaps = diag_range.start.line <= req_range.end.line
&& diag_range.end.line >= req_range.start.line;
if !overlaps {
continue;
}
let fixes = deserialize_fixes(diag.data.as_ref());
if !fixes.is_empty() {
actions.extend(fixes_to_code_actions(uri, &fixes, &content));
}
}
if actions.is_empty() {
Ok(None)
} else {
Ok(Some(
actions
.into_iter()
.map(CodeActionOrCommand::CodeAction)
.collect(),
))
}
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let uri = ¶ms.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let content = match self.get_document_content(uri).await {
Some(c) => c,
None => return Ok(None),
};
Ok(hover_at_position(&content, position))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tower_lsp::LspService;
#[tokio::test]
async fn test_backend_new_creates_valid_instance() {
let (service, _socket) = LspService::new(Backend::new);
let init_params = InitializeParams::default();
let result = service.inner().initialize(init_params).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_initialize_returns_correct_capabilities() {
let (service, _socket) = LspService::new(Backend::new);
let init_params = InitializeParams::default();
let result = service.inner().initialize(init_params).await;
let init_result = result.expect("initialize should succeed");
match init_result.capabilities.text_document_sync {
Some(TextDocumentSyncCapability::Kind(kind)) => {
assert_eq!(kind, TextDocumentSyncKind::FULL);
}
_ => panic!("Expected FULL text document sync capability"),
}
let server_info = init_result
.server_info
.expect("server_info should be present");
assert_eq!(server_info.name, "agnix-lsp");
assert!(server_info.version.is_some());
}
#[tokio::test]
async fn test_shutdown_returns_ok() {
let (service, _socket) = LspService::new(Backend::new);
let result = service.inner().shutdown().await;
assert!(result.is_ok());
}
#[test]
fn test_validation_error_diagnostic_structure() {
let error_message = "Failed to parse file";
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(
"agnix::validation-error".to_string(),
)),
code_description: None,
source: Some("agnix".to_string()),
message: format!("Validation error: {}", error_message),
related_information: None,
tags: None,
data: None,
};
assert_eq!(
diagnostic.code,
Some(NumberOrString::String(
"agnix::validation-error".to_string()
))
);
assert_eq!(diagnostic.source, Some("agnix".to_string()));
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
assert!(diagnostic.message.contains("Validation error:"));
}
#[test]
fn test_internal_error_diagnostic_structure() {
let error_message = "task panicked";
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("agnix::internal-error".to_string())),
code_description: None,
source: Some("agnix".to_string()),
message: format!("Internal error: {}", error_message),
related_information: None,
tags: None,
data: None,
};
assert_eq!(
diagnostic.code,
Some(NumberOrString::String("agnix::internal-error".to_string()))
);
assert_eq!(diagnostic.source, Some("agnix".to_string()));
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
assert!(diagnostic.message.contains("Internal error:"));
}
#[test]
fn test_invalid_uri_detection() {
let http_uri = Url::parse("http://example.com/file.md").unwrap();
assert!(http_uri.to_file_path().is_err());
let data_uri = Url::parse("data:text/plain;base64,SGVsbG8=").unwrap();
assert!(data_uri.to_file_path().is_err());
#[cfg(windows)]
let file_uri = Url::parse("file:///C:/tmp/test.md").unwrap();
#[cfg(not(windows))]
let file_uri = Url::parse("file:///tmp/test.md").unwrap();
assert!(file_uri.to_file_path().is_ok());
}
#[tokio::test]
async fn test_validate_file_valid_skill() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
r#"---
name: test-skill
version: 1.0.0
model: sonnet
---
# Test Skill
This is a valid skill.
"#,
)
.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(), },
})
.await;
}
#[tokio::test]
async fn test_validate_file_invalid_skill() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
r#"---
name: Invalid Name With Spaces
version: 1.0.0
model: sonnet
---
# Invalid Skill
This skill has an invalid name.
"#,
)
.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
#[tokio::test]
async fn test_did_save_triggers_validation() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
r#"---
name: test-skill
version: 1.0.0
model: sonnet
---
# Test Skill
"#,
)
.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_save(DidSaveTextDocumentParams {
text_document: TextDocumentIdentifier { uri },
text: None,
})
.await;
}
#[tokio::test]
async fn test_did_close_clears_diagnostics() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(&skill_path, "# Test").unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_close(DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri },
})
.await;
}
#[tokio::test]
async fn test_initialized_completes() {
let (service, _socket) = LspService::new(Backend::new);
service.inner().initialized(InitializedParams {}).await;
}
#[tokio::test]
async fn test_non_file_uri_handled_gracefully() {
let (service, _socket) = LspService::new(Backend::new);
let http_uri = Url::parse("http://example.com/test.md").unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: http_uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
#[tokio::test]
async fn test_validate_nonexistent_file() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let nonexistent_path = temp_dir.path().join("nonexistent.md");
let uri = Url::from_file_path(&nonexistent_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
#[tokio::test]
async fn test_server_info_version() {
let (service, _socket) = LspService::new(Backend::new);
let init_params = InitializeParams::default();
let result = service.inner().initialize(init_params).await.unwrap();
let server_info = result.server_info.unwrap();
let version = server_info.version.unwrap();
assert!(!version.is_empty());
assert!(version.contains('.'));
}
#[tokio::test]
async fn test_initialize_captures_workspace_root() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
let init_params = InitializeParams {
root_uri: Some(root_uri),
..Default::default()
};
let result = service.inner().initialize(init_params).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_initialize_loads_config_from_file() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join(".agnix.toml");
std::fs::write(
&config_path,
r#"
severity = "Warning"
target = "ClaudeCode"
exclude = []
[rules]
skills = false
"#,
)
.unwrap();
let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
let init_params = InitializeParams {
root_uri: Some(root_uri),
..Default::default()
};
let result = service.inner().initialize(init_params).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_initialize_handles_invalid_config() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join(".agnix.toml");
std::fs::write(&config_path, "this is not valid toml [[[").unwrap();
let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
let init_params = InitializeParams {
root_uri: Some(root_uri),
..Default::default()
};
let result = service.inner().initialize(init_params).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_file_within_workspace_validated() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
r#"---
name: test-skill
version: 1.0.0
model: sonnet
---
# Test Skill
"#,
)
.unwrap();
let root_uri = Url::from_file_path(temp_dir.path()).unwrap();
let init_params = InitializeParams {
root_uri: Some(root_uri),
..Default::default()
};
service.inner().initialize(init_params).await.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
#[tokio::test]
async fn test_file_outside_workspace_rejected() {
let (service, _socket) = LspService::new(Backend::new);
let workspace_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let outside_file = outside_dir.path().join("SKILL.md");
std::fs::write(
&outside_file,
r#"---
name: outside-skill
version: 1.0.0
model: sonnet
---
# Outside Skill
"#,
)
.unwrap();
let root_uri = Url::from_file_path(workspace_dir.path()).unwrap();
let init_params = InitializeParams {
root_uri: Some(root_uri),
..Default::default()
};
service.inner().initialize(init_params).await.unwrap();
let uri = Url::from_file_path(&outside_file).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
#[tokio::test]
async fn test_validation_without_workspace_root() {
let (service, _socket) = LspService::new(Backend::new);
let init_params = InitializeParams::default();
service.inner().initialize(init_params).await.unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
r#"---
name: test-skill
version: 1.0.0
model: sonnet
---
# Test Skill
"#,
)
.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
#[tokio::test]
async fn test_cached_config_used_for_multiple_validations() {
let (service, _socket) = LspService::new(Backend::new);
service
.inner()
.initialize(InitializeParams::default())
.await
.unwrap();
let temp_dir = tempfile::tempdir().unwrap();
for i in 0..3 {
let skill_path = temp_dir.path().join(format!("skill{}/SKILL.md", i));
std::fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
std::fs::write(
&skill_path,
format!(
r#"---
name: test-skill-{}
version: 1.0.0
model: sonnet
---
# Test Skill {}
"#,
i, i
),
)
.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
}
#[tokio::test]
async fn test_cached_registry_used_for_multiple_validations() {
let (service, _socket) = LspService::new(Backend::new);
service
.inner()
.initialize(InitializeParams::default())
.await
.unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
r#"---
name: test-skill
version: 1.0.0
model: sonnet
---
# Test Skill
"#,
)
.unwrap();
let claude_path = temp_dir.path().join("CLAUDE.md");
std::fs::write(
&claude_path,
r#"# Project Memory
This is a test project.
"#,
)
.unwrap();
for path in [&skill_path, &claude_path] {
let uri = Url::from_file_path(path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "markdown".to_string(),
version: 1,
text: String::new(),
},
})
.await;
}
}
#[tokio::test]
async fn test_document_cache_cleared_on_close() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(
&skill_path,
"---\nname: test\ndescription: Test\n---\n# Test",
)
.unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "---\nname: test\ndescription: Test\n---\n# Test".to_string(),
},
})
.await;
let hover_before = service
.inner()
.hover(HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 1,
character: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
})
.await;
assert!(hover_before.is_ok());
assert!(hover_before.unwrap().is_some());
service
.inner()
.did_close(DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
})
.await;
let hover_after = service
.inner()
.hover(HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
})
.await;
assert!(hover_after.is_ok());
assert!(hover_after.unwrap().is_none());
}
#[tokio::test]
async fn test_document_cache_updated_on_change() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("SKILL.md");
std::fs::write(&skill_path, "# Initial").unwrap();
let uri = Url::from_file_path(&skill_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "# Initial".to_string(),
},
})
.await;
service
.inner()
.did_change(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "---\nname: updated\ndescription: Updated\n---\n# Updated".to_string(),
}],
})
.await;
let hover = service
.inner()
.hover(HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 1,
character: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
})
.await;
assert!(hover.is_ok());
assert!(hover.unwrap().is_some());
}
#[tokio::test]
async fn test_multiple_documents_independent_caches() {
let (service, _socket) = LspService::new(Backend::new);
let temp_dir = tempfile::tempdir().unwrap();
let skill1_path = temp_dir.path().join("skill1").join("SKILL.md");
let skill2_path = temp_dir.path().join("skill2").join("SKILL.md");
std::fs::create_dir_all(skill1_path.parent().unwrap()).unwrap();
std::fs::create_dir_all(skill2_path.parent().unwrap()).unwrap();
std::fs::write(
&skill1_path,
"---\nname: skill-one\ndescription: First\n---\n# One",
)
.unwrap();
std::fs::write(
&skill2_path,
"---\nname: skill-two\ndescription: Second\n---\n# Two",
)
.unwrap();
let uri1 = Url::from_file_path(&skill1_path).unwrap();
let uri2 = Url::from_file_path(&skill2_path).unwrap();
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri1.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "---\nname: skill-one\ndescription: First\n---\n# One".to_string(),
},
})
.await;
service
.inner()
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri2.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "---\nname: skill-two\ndescription: Second\n---\n# Two".to_string(),
},
})
.await;
service
.inner()
.did_close(DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri: uri1.clone() },
})
.await;
let hover1 = service
.inner()
.hover(HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri1 },
position: Position {
line: 1,
character: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
})
.await;
assert!(hover1.is_ok());
assert!(hover1.unwrap().is_none());
let hover2 = service
.inner()
.hover(HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri2 },
position: Position {
line: 1,
character: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
})
.await;
assert!(hover2.is_ok());
assert!(hover2.unwrap().is_some());
}
}