fatou 0.5.0

A language server, formatter, and linter for Julia
//! Server entry points: the initialize handshake, advertised capabilities, and
//! the main event loop that wires the channels, pools, and threads together.

use std::error::Error;

use crossbeam_channel::select;
use lsp_server::{Connection, Message};
use lsp_types::{
    ClientCapabilities, FoldingRangeProviderCapability, InitializeParams, OneOf,
    PositionEncodingKind, SelectionRangeProviderCapability, SemanticTokensFullOptions,
    SemanticTokensOptions, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind,
};

use crate::text::PositionEncoding;

use super::analysis_thread::{AnalysisRequest, spawn_analysis_thread};
use super::read_jobs::ReadJob;
use super::semantic_tokens::legend;
use super::state::{GlobalState, Outbound};
use super::task_pool::{TaskPool, read_pool_size};

pub(crate) type DynError = Box<dyn Error + Sync + Send>;

/// Run the language server on stdio until the client shuts it down.
pub fn run() -> Result<(), DynError> {
    let (connection, io_threads) = Connection::stdio();
    serve(&connection)?;
    io_threads.join()?;
    Ok(())
}

/// Perform the initialize handshake on `connection`, then run the message loop.
/// Split out from [`run`] so tests can drive it over an in-memory connection.
///
/// The handshake is two-step ([`Connection::initialize_start`] /
/// [`Connection::initialize_finish`]) rather than [`Connection::initialize`]
/// because the advertised capabilities depend on the client's: the position
/// encoding is negotiated from `general.positionEncodings`.
pub fn serve(connection: &Connection) -> Result<(), DynError> {
    let (id, params) = connection.initialize_start()?;
    let params: InitializeParams = serde_json::from_value(params)?;
    let encoding = negotiate_position_encoding(&params.capabilities);
    let result = serde_json::json!({ "capabilities": server_capabilities(encoding) });
    connection.initialize_finish(id, result)?;
    main_loop(connection, encoding)
}

/// Pick the position encoding for the session: UTF-8 (plain byte offsets, no
/// re-encoding on our side) when the client offers it, otherwise the mandatory
/// LSP default of UTF-16.
fn negotiate_position_encoding(capabilities: &ClientCapabilities) -> PositionEncoding {
    let offered = capabilities
        .general
        .as_ref()
        .and_then(|general| general.position_encodings.as_deref())
        .unwrap_or_default();
    if offered.contains(&PositionEncodingKind::UTF8) {
        PositionEncoding::Utf8
    } else {
        PositionEncoding::Utf16
    }
}

fn server_capabilities(encoding: PositionEncoding) -> ServerCapabilities {
    ServerCapabilities {
        position_encoding: Some(match encoding {
            PositionEncoding::Utf8 => PositionEncodingKind::UTF8,
            PositionEncoding::Utf16 => PositionEncodingKind::UTF16,
        }),
        text_document_sync: Some(TextDocumentSyncCapability::Kind(
            TextDocumentSyncKind::INCREMENTAL,
        )),
        document_formatting_provider: Some(OneOf::Left(true)),
        document_range_formatting_provider: Some(OneOf::Left(true)),
        document_symbol_provider: Some(OneOf::Left(true)),
        folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
        selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
        semantic_tokens_provider: Some(
            SemanticTokensOptions {
                work_done_progress_options: Default::default(),
                legend: legend(),
                range: None,
                full: Some(SemanticTokensFullOptions::Bool(true)),
            }
            .into(),
        ),
        ..Default::default()
    }
}

/// The main event loop: dispatch incoming JSON-RPC messages and analysis
/// results. Owns no salsa database (see the module docs); joins the analysis
/// thread before returning.
fn main_loop(connection: &Connection, encoding: PositionEncoding) -> Result<(), DynError> {
    let (out_tx, out_rx) = crossbeam_channel::unbounded::<Outbound>();
    let (analysis_tx, analysis_rx) = crossbeam_channel::unbounded::<AnalysisRequest>();
    let (read_tx, read_rx) = crossbeam_channel::unbounded::<ReadJob>();

    // The read pool serves latency-sensitive work (formatting, the analysis
    // read-phase). Its workers must outlive both `state` and the analysis
    // thread; the drop order at the end of this function guarantees that.
    let read_pool = TaskPool::new("fatou-lsp-read", read_pool_size());
    let analysis_handle =
        spawn_analysis_thread(analysis_rx, read_rx, out_tx, read_pool.spawner(), encoding);

    let mut state = GlobalState::new(connection.sender.clone(), analysis_tx, read_tx, encoding);

    loop {
        select! {
            recv(connection.receiver) -> msg => {
                let Ok(msg) = msg else { break };
                match msg {
                    Message::Request(req) => {
                        if connection.handle_shutdown(&req)? {
                            break;
                        }
                        state.on_request(req);
                    }
                    Message::Notification(note) => state.on_notification(note),
                    Message::Response(_) => {}
                }
            }
            recv(out_rx) -> outbound => {
                let Ok(outbound) = outbound else { break };
                state.on_outbound(outbound);
            }
        }
    }

    // Dropping `state` drops `analysis_tx`/`read_tx` → the analysis thread's
    // recv disconnects → it exits and drops the db.
    drop(state);
    let _ = analysis_handle.join();
    Ok(())
}

#[cfg(test)]
mod tests {
    use lsp_types::GeneralClientCapabilities;

    use super::*;

    fn caps_offering(encodings: Option<Vec<PositionEncodingKind>>) -> ClientCapabilities {
        ClientCapabilities {
            general: Some(GeneralClientCapabilities {
                position_encodings: encodings,
                ..Default::default()
            }),
            ..Default::default()
        }
    }

    #[test]
    fn negotiation_defaults_to_utf16() {
        // No `general` capabilities at all, and `general` without an
        // `positionEncodings` offer, both fall back to the mandatory default.
        let none = ClientCapabilities::default();
        assert_eq!(negotiate_position_encoding(&none), PositionEncoding::Utf16);
        assert_eq!(
            negotiate_position_encoding(&caps_offering(None)),
            PositionEncoding::Utf16
        );
        assert_eq!(
            negotiate_position_encoding(&caps_offering(Some(vec![
                PositionEncodingKind::UTF16,
                PositionEncodingKind::UTF32,
            ]))),
            PositionEncoding::Utf16
        );
    }

    #[test]
    fn negotiation_prefers_offered_utf8() {
        assert_eq!(
            negotiate_position_encoding(&caps_offering(Some(vec![
                PositionEncodingKind::UTF16,
                PositionEncodingKind::UTF8,
            ]))),
            PositionEncoding::Utf8
        );
    }
}