swt 5.0.0-rc.8

🍬 Sweet: A blazing-fast code health and architecture analyzer.
Documentation
//! Backend implementation for the Sweet Language Server.

pub mod diag;

use dashmap::DashMap;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use swt::Config;
use swt::analyzer::ignore::get_disabled_rules;
use swt::analyzer::{AnalysisEngine, analyze_content};
use swt::languages::{Language, LanguageRegistry};
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::{
    CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams,
    CodeActionProviderCapability, CodeActionResponse, DidChangeTextDocumentParams,
    DidCloseTextDocumentParams, DidOpenTextDocumentParams, InitializeParams, InitializeResult,
    InitializedParams, MessageType, Position, Range, ServerCapabilities,
    TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, WorkspaceEdit,
};
use tower_lsp::{Client, LanguageServer};

#[derive(Debug)]
pub struct Backend {
    pub client: Client,
    pub workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
    pub pending_validations: Arc<DashMap<Url, JoinHandle<()>>>,
}

impl Backend {
    pub fn new(client: Client) -> Self {
        Self {
            client,
            workspace_roots: Arc::new(RwLock::new(Vec::new())),
            pending_validations: Arc::new(DashMap::new()),
        }
    }

    pub async fn validate_document(&self, uri: Url, content: String) {
        if let Some(report) = self.perform_analysis(uri.clone(), content).await {
            let config = Config::load(&uri.to_file_path().unwrap_or_default()).unwrap_or_default();
            let diagnostics = diag::generate(&report, &config);
            self.client
                .publish_diagnostics(uri, diagnostics, None)
                .await;
        } else {
            self.client.publish_diagnostics(uri, Vec::new(), None).await;
        }
    }

    async fn perform_analysis(&self, uri: Url, content: String) -> Option<swt::FileReport> {
        let path = uri.to_file_path().ok()?;

        if !Config::is_supported_file(&path) {
            return None;
        }

        let canonical_path = fs::canonicalize(&path).unwrap_or(path);

        {
            let roots_lock = self.workspace_roots.read().await;
            if !roots_lock.is_empty()
                && !roots_lock
                    .iter()
                    .any(|root| canonical_path.starts_with(root))
            {
                return None;
            }
        }

        let config = Config::load(&canonical_path).unwrap_or_default();
        if config.is_excluded(&canonical_path) {
            return None;
        }

        let extension = canonical_path.extension()?.to_str()?;
        let thresholds = config.get_thresholds(extension);
        let disabled_rules = get_disabled_rules(content.as_bytes());

        Some(analyze_content(
            content.as_bytes(),
            extension,
            &thresholds,
            &canonical_path,
            &config,
            &disabled_rules,
            true,
        ))
    }
}

#[tower_lsp::async_trait]
impl LanguageServer for Backend {
    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
        let mut roots = Vec::new();

        if let Some(folders) = params.workspace_folders {
            for folder in folders {
                if let Ok(path) = folder.uri.to_file_path() {
                    let canonical = fs::canonicalize(&path).unwrap_or(path);
                    roots.push(canonical);
                }
            }
        }

        if roots.is_empty()
            && let Some(root_uri) = params.root_uri
            && let Ok(path) = root_uri.to_file_path()
        {
            let canonical = fs::canonicalize(&path).unwrap_or(path);
            roots.push(canonical);
        }

        *self.workspace_roots.write().await = roots;

        Ok(InitializeResult {
            capabilities: ServerCapabilities {
                text_document_sync: Some(TextDocumentSyncCapability::Kind(
                    TextDocumentSyncKind::FULL,
                )),
                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
                ..Default::default()
            },
            ..Default::default()
        })
    }

    async fn initialized(&self, _: InitializedParams) {
        self.client
            .log_message(MessageType::INFO, "Sweet LSP server initialized!")
            .await;

        // Initial full workspace scan
        let roots = self.workspace_roots.read().await.clone();
        let client = self.client.clone();

        tokio::spawn(async move {
            for root in roots {
                let engine = AnalysisEngine::new(root, Config::default());
                let results = engine.run(true, false, true, true);

                for report in results {
                    if let Ok(uri) = Url::from_file_path(&report.path) {
                        let config = report.config.clone().unwrap_or_default();
                        let diagnostics = diag::generate(&report, &config);
                        let () = client.publish_diagnostics(uri, diagnostics, None).await;
                    }
                }
            }
        });
    }

    async fn did_open(&self, params: DidOpenTextDocumentParams) {
        self.validate_document(params.text_document.uri, params.text_document.text)
            .await;
    }

    async fn did_close(&self, params: DidCloseTextDocumentParams) {
        let uri = params.text_document.uri;
        if let Some((_, handle)) = self.pending_validations.remove(&uri) {
            handle.abort();
        }

        // Only clear diagnostics on close if the file is OUTSIDE the workspace
        // or excluded. Workspace files should keep their diagnostics visible.
        if let Ok(path) = uri.to_file_path() {
            let canonical_path = fs::canonicalize(&path).unwrap_or(path);
            let is_external = {
                let roots_lock = self.workspace_roots.read().await;
                !roots_lock.is_empty()
                    && !roots_lock
                        .iter()
                        .any(|root| canonical_path.starts_with(root))
            };

            let config = Config::load(&canonical_path).unwrap_or_default();
            if is_external || config.is_excluded(&canonical_path) {
                self.client.publish_diagnostics(uri, Vec::new(), None).await;
            }
        }
    }

    async fn did_change(&self, params: DidChangeTextDocumentParams) {
        let uri = params.text_document.uri;
        let Some(change) = params.content_changes.into_iter().next() else {
            return;
        };

        if let Some((_, handle)) = self.pending_validations.remove(&uri) {
            handle.abort();
        }

        let client = self.client.clone();
        let workspace_roots = self.workspace_roots.clone();
        let pending_validations = self.pending_validations.clone();
        let uri_clone = uri.clone();
        let content = change.text;

        let handle = tokio::spawn(async move {
            sleep(Duration::from_millis(300)).await;

            let path = uri_clone.to_file_path().unwrap_or_default();
            if !Config::is_supported_file(&path) {
                let () = client
                    .publish_diagnostics(uri_clone.clone(), Vec::new(), None)
                    .await;
                return;
            }

            let canonical_path = fs::canonicalize(&path).unwrap_or(path);

            {
                let roots_lock = workspace_roots.read().await;
                if !roots_lock.is_empty()
                    && !roots_lock
                        .iter()
                        .any(|root| canonical_path.starts_with(root))
                {
                    let () = client
                        .publish_diagnostics(uri_clone.clone(), Vec::new(), None)
                        .await;
                    return;
                }
            }

            let config = Config::load(&canonical_path).unwrap_or_default();
            if config.is_excluded(&canonical_path) {
                let () = client
                    .publish_diagnostics(uri_clone.clone(), Vec::new(), None)
                    .await;
                return;
            }

            let extension = canonical_path
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or_default();
            let thresholds = config.get_thresholds(extension);
            let disabled_rules = get_disabled_rules(content.as_bytes());
            let report = analyze_content(
                content.as_bytes(),
                extension,
                &thresholds,
                &canonical_path,
                &config,
                &disabled_rules,
                true,
            );

            let diagnostics = diag::generate(&report, &config);
            let () = client
                .publish_diagnostics(uri_clone.clone(), diagnostics, None)
                .await;

            pending_validations.remove(&uri_clone);
        });

        self.pending_validations.insert(uri, handle);
    }

    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
        let mut actions = Vec::new();
        let Ok(path) = params.text_document.uri.to_file_path() else {
            return Ok(None);
        };
        let extension = path
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or_default();
        let registry = LanguageRegistry::get();
        let comment = registry
            .get_by_extension(extension)
            .and_then(Language::line_comment)
            .unwrap_or("//");

        for diagnostic in params.context.diagnostics {
            if let Some(rule) = diagnostic.data.as_ref().and_then(|v| v.as_str()) {
                if rule == "unknown" {
                    continue;
                }
                let title = format!("🍬 Disable rule '{rule}' for this file");
                let edit = TextEdit::new(
                    Range::new(Position::new(0, 0), Position::new(0, 0)),
                    format!("{comment} @swt-disable {rule}\n"),
                );
                let mut changes = HashMap::new();
                changes.insert(params.text_document.uri.clone(), vec![edit]);
                actions.push(CodeActionOrCommand::CodeAction(CodeAction {
                    title,
                    kind: Some(CodeActionKind::QUICKFIX),
                    edit: Some(WorkspaceEdit {
                        changes: Some(changes),
                        ..Default::default()
                    }),
                    diagnostics: Some(vec![diagnostic]),
                    ..Default::default()
                }));
            }
        }
        Ok(Some(actions))
    }

    async fn shutdown(&self) -> Result<()> {
        Ok(())
    }
}