use std::collections::{HashMap, HashSet};
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
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_with_diagnostic;
use crate::completion_provider::completion_items_for_document;
use crate::diagnostic_mapper::{deserialize_fixes, to_lsp_diagnostic, to_lsp_diagnostics};
use crate::hover_provider::hover_at_position;
mod events;
mod helpers;
mod revalidation;
use helpers::{create_error_diagnostic, normalize_path};
#[cfg(test)]
use revalidation::{
MAX_CONFIG_REVALIDATION_CONCURRENCY, config_revalidation_concurrency, for_each_bounded,
};
#[derive(Clone)]
pub struct Backend {
client: Client,
config: Arc<RwLock<Arc<agnix_core::LintConfig>>>,
workspace_root: Arc<RwLock<Option<PathBuf>>>,
workspace_root_canonical: Arc<RwLock<Option<PathBuf>>>,
documents: Arc<RwLock<HashMap<Url, Arc<String>>>>,
config_generation: Arc<AtomicU64>,
project_validation_generation: Arc<AtomicU64>,
registry: Arc<agnix_core::ValidatorRegistry>,
project_level_diagnostics: Arc<RwLock<HashMap<Url, Vec<Diagnostic>>>>,
project_diagnostics_uris: Arc<RwLock<HashSet<Url>>>,
}
impl Backend {
pub fn new(client: Client) -> Self {
Self {
client,
config: Arc::new(RwLock::new(Arc::new(agnix_core::LintConfig::default()))),
workspace_root: Arc::new(RwLock::new(None)),
workspace_root_canonical: Arc::new(RwLock::new(None)),
documents: Arc::new(RwLock::new(HashMap::new())),
config_generation: Arc::new(AtomicU64::new(0)),
project_validation_generation: Arc::new(AtomicU64::new(0)),
registry: Arc::new(agnix_core::ValidatorRegistry::with_defaults()),
project_level_diagnostics: Arc::new(RwLock::new(HashMap::new())),
project_diagnostics_uris: Arc::new(RwLock::new(HashSet::new())),
}
}
fn spawn_project_validation(&self) {
let backend = self.clone();
let client = self.client.clone();
tokio::spawn(async move {
let result = tokio::spawn(async move {
backend.validate_project_rules_and_publish().await;
})
.await;
if let Err(e) = result {
client
.log_message(
MessageType::ERROR,
format!("Project-level validation task panicked: {}", e),
)
.await;
}
});
}
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,
expected_config_generation: Option<u64>,
) {
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, canonical_root) = match file_path.canonicalize() {
Ok(path) => {
let root = self
.workspace_root_canonical
.read()
.await
.clone()
.unwrap_or_else(|| normalize_path(workspace_root));
(path, root)
}
Err(_) => (normalize_path(&file_path), normalize_path(workspace_root)),
};
if !canonical_path.starts_with(&canonical_root) {
self.client
.log_message(
MessageType::WARNING,
format!("File outside workspace boundary: {}", uri),
)
.await;
return;
}
}
{
let config = self.config.read().await;
let file_type = agnix_core::resolve_file_type(&file_path, &config);
if file_type.is_generic() {
self.client
.publish_diagnostics(uri, vec![], None)
.await;
return;
}
}
let (content, expected_content) = {
let docs = self.documents.read().await;
match docs.get(&uri) {
Some(cached) => {
let snapshot = Arc::clone(cached);
(Arc::clone(&snapshot), Some(snapshot))
}
None => {
drop(docs);
let diagnostics = self.validate_file(file_path).await;
if !self
.should_publish_diagnostics(&uri, expected_config_generation, None)
.await
{
return;
}
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
return;
}
}
};
let config = Arc::clone(&*self.config.read().await);
let registry = Arc::clone(&self.registry);
let result = tokio::task::spawn_blocking(move || {
Ok::<_, agnix_core::LintError>(agnix_core::validate_content(
&file_path,
content.as_str(),
&config,
®istry,
))
})
.await;
let mut 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),
)],
};
{
let proj_diags = self.project_level_diagnostics.read().await;
if let Some(project_diags) = proj_diags.get(&uri) {
diagnostics.extend(project_diags.iter().cloned());
}
}
if !self
.should_publish_diagnostics(&uri, expected_config_generation, expected_content.as_ref())
.await
{
return;
}
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
}
#[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());
*self.workspace_root_canonical.write().await = Some(
root_path
.canonicalize()
.unwrap_or_else(|_| normalize_path(&root_path)),
);
let config_path = root_path.join(".agnix.toml");
if config_path.exists() {
match agnix_core::LintConfig::load(&config_path) {
Ok(loaded_config) => {
if let Some(config_locale) = loaded_config.locale() {
crate::locale::init_from_config(config_locale);
}
let mut config_with_root = loaded_config;
config_with_root.set_root_dir(root_path.clone());
*self.config.write().await = Arc::new(config_with_root);
}
Err(e) => {
self.client
.log_message(
MessageType::WARNING,
format!("Failed to load .agnix.toml: {}", e),
)
.await;
}
}
}
}
}
self.spawn_project_validation();
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
code_action_provider: Some(CodeActionProviderCapability::Options(
CodeActionOptions {
code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
..Default::default()
},
)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
completion_provider: Some(CompletionOptions {
resolve_provider: Some(false),
trigger_characters: Some(vec![":".to_string(), "\"".to_string()]),
..Default::default()
}),
execute_command_provider: Some(ExecuteCommandOptions {
commands: vec!["agnix.validateProjectRules".to_string()],
..Default::default()
}),
..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) {
self.handle_did_open(params).await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
self.handle_did_change(params).await;
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.handle_did_save(params).await;
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
self.handle_did_close(params).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_with_diagnostic(
uri,
&fixes,
content.as_str(),
diag,
));
}
}
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),
};
let config = self.config.read().await;
let file_type = uri
.to_file_path()
.ok()
.map(|path| agnix_core::resolve_file_type(&path, &config))
.unwrap_or(agnix_core::FileType::Unknown);
if matches!(file_type, agnix_core::FileType::Unknown) || file_type.is_generic() {
return Ok(None);
}
Ok(hover_at_position(file_type, content.as_str(), position))
}
async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
let uri = ¶ms.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let path = match uri.to_file_path() {
Ok(path) => path,
Err(_) => return Ok(None),
};
let content = match self.get_document_content(uri).await {
Some(c) => c,
None => return Ok(None),
};
let config = self.config.read().await;
let items = completion_items_for_document(&path, content.as_str(), position, &config);
if items.is_empty() {
Ok(None)
} else {
Ok(Some(CompletionResponse::Array(items)))
}
}
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
self.handle_did_change_configuration(params).await;
}
async fn execute_command(
&self,
params: ExecuteCommandParams,
) -> Result<Option<serde_json::Value>> {
match params.command.as_str() {
"agnix.validateProjectRules" => {
self.client
.log_message(
MessageType::INFO,
"Running project-level validation (via executeCommand)",
)
.await;
self.validate_project_rules_and_publish().await;
Ok(None)
}
_ => {
self.client
.log_message(
MessageType::WARNING,
format!("Unknown command: {}", params.command),
)
.await;
Ok(None)
}
}
}
}
#[cfg(test)]
mod tests;