nautilus-orm-lsp 0.1.4

LSP server for .nautilus schema files
//! LSP [`LanguageServer`] implementation for nautilus schemas.
//!
//! All schema intelligence (parse, validate, complete, hover, goto-definition)
//! lives in `nautilus-schema`; this module is pure glue.

use dashmap::DashMap;
use tower_lsp::jsonrpc::Result as LspResult;
use tower_lsp::lsp_types::{
    CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic,
    DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
    DidSaveTextDocumentParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams,
    HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location,
    MessageType, OneOf, SaveOptions, SemanticTokenType, SemanticTokens, SemanticTokensFullOptions,
    SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult,
    SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, TextDocumentSyncCapability,
    TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Url,
};
use tower_lsp::{Client, LanguageServer};

use nautilus_schema::analysis::{completion, goto_definition, hover, semantic_tokens};

use crate::convert::{
    hover_info_to_lsp, nautilus_completion_to_lsp, nautilus_diagnostic_to_lsp, position_to_offset,
    semantic_tokens_to_lsp, span_to_range,
};
use crate::document::DocumentState;

/// The LSP backend.  Holds the client handle and the per-document cache.
pub struct Backend {
    pub client: Client,
    pub docs: DashMap<Url, DocumentState>,
}

impl Backend {
    /// Returns `true` when `uri` points to a `.nautilus` file.
    fn is_nautilus(uri: &Url) -> bool {
        uri.path().ends_with(".nautilus")
    }

    /// Re-run analysis on `source`, store the result, and publish diagnostics.
    async fn reanalyze(&self, uri: Url, source: String) {
        let state = DocumentState::new(source.clone());
        let lsp_diags: Vec<Diagnostic> = state
            .analysis
            .diagnostics
            .iter()
            .map(|d| nautilus_diagnostic_to_lsp(&source, d))
            .collect();
        self.docs.insert(uri.clone(), state);
        self.client.publish_diagnostics(uri, lsp_diags, None).await;
    }
}

#[tower_lsp::async_trait]
impl LanguageServer for Backend {
    async fn initialize(&self, _params: InitializeParams) -> LspResult<InitializeResult> {
        Ok(InitializeResult {
            capabilities: ServerCapabilities {
                text_document_sync: Some(TextDocumentSyncCapability::Options(
                    TextDocumentSyncOptions {
                        open_close: Some(true),
                        change: Some(TextDocumentSyncKind::FULL),
                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
                            include_text: Some(true),
                        })),
                        ..Default::default()
                    },
                )),
                completion_provider: Some(CompletionOptions {
                    trigger_characters: Some(vec!["@".to_string(), "=".to_string()]),
                    ..Default::default()
                }),
                hover_provider: Some(HoverProviderCapability::Simple(true)),
                definition_provider: Some(OneOf::Left(true)),
                semantic_tokens_provider: Some(
                    SemanticTokensServerCapabilities::SemanticTokensOptions(
                        SemanticTokensOptions {
                            legend: SemanticTokensLegend {
                                token_types: vec![
                                    SemanticTokenType::from("nautilusModel"),         // 0
                                    SemanticTokenType::from("nautilusEnum"),          // 1
                                    SemanticTokenType::from("nautilusCompositeType"), // 2
                                ],
                                token_modifiers: vec![],
                            },
                            full: Some(SemanticTokensFullOptions::Bool(true)),
                            ..Default::default()
                        },
                    ),
                ),
                ..Default::default()
            },
            server_info: Some(ServerInfo {
                name: "nautilus-lsp".to_string(),
                version: Some(env!("CARGO_PKG_VERSION").to_string()),
            }),
        })
    }

    async fn initialized(&self, _params: InitializedParams) {
        self.client
            .log_message(MessageType::INFO, "nautilus-lsp initialized")
            .await;
    }

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

    async fn did_open(&self, params: DidOpenTextDocumentParams) {
        let uri = params.text_document.uri;
        if !Self::is_nautilus(&uri) {
            return;
        }
        self.reanalyze(uri, params.text_document.text).await;
    }

    async fn did_change(&self, params: DidChangeTextDocumentParams) {
        let uri = params.text_document.uri;
        if !Self::is_nautilus(&uri) {
            return;
        }
        // FULL sync → always exactly one content change with the full text.
        if let Some(change) = params.content_changes.into_iter().next() {
            self.reanalyze(uri, change.text).await;
        }
    }

    async fn did_close(&self, params: DidCloseTextDocumentParams) {
        let uri = params.text_document.uri;
        self.docs.remove(&uri);
        self.client.publish_diagnostics(uri, Vec::new(), None).await;
    }

    async fn did_save(&self, params: DidSaveTextDocumentParams) {
        let uri = params.text_document.uri;
        if !Self::is_nautilus(&uri) {
            return;
        }
        // `include_text` is set to true in ServerCapabilities, so `text` is
        // always present.  Fall back to the cache only as a safety net.
        if let Some(text) = params.text {
            self.reanalyze(uri, text).await;
        } else if let Some(state) = self.docs.get(&uri) {
            let source = state.source.clone();
            drop(state);
            self.reanalyze(uri, source).await;
        }
    }

    async fn completion(&self, params: CompletionParams) -> LspResult<Option<CompletionResponse>> {
        let uri = &params.text_document_position.text_document.uri;
        let pos = params.text_document_position.position;

        let Some(state) = self.docs.get(uri) else {
            return Ok(None);
        };
        let offset = position_to_offset(&state.source, pos);
        let items = completion(&state.source, offset);
        let lsp_items: Vec<CompletionItem> = items.iter().map(nautilus_completion_to_lsp).collect();

        Ok(Some(CompletionResponse::Array(lsp_items)))
    }

    async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
        let uri = &params.text_document_position_params.text_document.uri;
        let pos = params.text_document_position_params.position;

        let Some(state) = self.docs.get(uri) else {
            return Ok(None);
        };
        let offset = position_to_offset(&state.source, pos);

        Ok(hover(&state.source, offset)
            .as_ref()
            .map(|h| hover_info_to_lsp(&state.source, h)))
    }

    async fn goto_definition(
        &self,
        params: GotoDefinitionParams,
    ) -> LspResult<Option<GotoDefinitionResponse>> {
        let uri = &params.text_document_position_params.text_document.uri;
        let pos = params.text_document_position_params.position;

        let Some(state) = self.docs.get(uri) else {
            return Ok(None);
        };
        let offset = position_to_offset(&state.source, pos);

        let Some(span) = goto_definition(&state.source, offset) else {
            return Ok(None);
        };

        let range = span_to_range(&state.source, &span);
        let location = Location {
            uri: uri.clone(),
            range,
        };

        Ok(Some(GotoDefinitionResponse::Scalar(location)))
    }

    async fn semantic_tokens_full(
        &self,
        params: SemanticTokensParams,
    ) -> LspResult<Option<SemanticTokensResult>> {
        let uri = &params.text_document.uri;
        let Some(state) = self.docs.get(uri) else {
            return Ok(None);
        };
        let Some(ast) = &state.analysis.ast else {
            return Ok(None);
        };

        let tokens = semantic_tokens(ast, &state.analysis.tokens);
        let data = semantic_tokens_to_lsp(&state.source, &tokens);

        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
            result_id: None,
            data,
        })))
    }
}