ts-bridge 0.2.3

A TypeScript language-server shim that bridges Neovim's LSP client with tsserver.
Documentation
use lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Uri};
use serde_json::{Value, json};

use crate::protocol::NotificationSpec;
use crate::rpc::{Priority, Route};
use crate::utils::{file_path_to_uri, tsserver_range_from_value_lsp};

const REQUEST_COMPLETED: &str = "requestCompleted";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticsKind {
    Syntax,
    Semantic,
    Suggestion,
}

impl DiagnosticsKind {
    fn from_event_name(name: &str) -> Option<Self> {
        match name {
            "syntaxDiag" => Some(DiagnosticsKind::Syntax),
            "semanticDiag" => Some(DiagnosticsKind::Semantic),
            "suggestionDiag" => Some(DiagnosticsKind::Suggestion),
            _ => None,
        }
    }
}

#[derive(Debug)]
pub enum DiagnosticsEvent {
    Report {
        kind: DiagnosticsKind,
        request_seq: Option<u64>,
        uri: Uri,
        diagnostics: Vec<Diagnostic>,
    },
    Completed {
        request_seq: u64,
    },
}

pub fn request_for_file(file: &str) -> NotificationSpec {
    let payload = json!({
        "command": "geterr",
        "arguments": {
            "files": [file],
            "delay": 0,
        }
    });

    NotificationSpec {
        route: Route::Both,
        payload,
        priority: Priority::Low,
    }
}

pub fn parse_tsserver_event(payload: &Value) -> Option<DiagnosticsEvent> {
    if payload.get("type")?.as_str()? != "event" {
        return None;
    }
    let event_name = payload.get("event")?.as_str()?;
    if event_name == REQUEST_COMPLETED {
        let seq = payload
            .get("body")
            .and_then(|body| body.get("request_seq"))
            .and_then(|value| value.as_u64())?;
        return Some(DiagnosticsEvent::Completed { request_seq: seq });
    }
    let kind = DiagnosticsKind::from_event_name(event_name)?;

    let body = payload.get("body")?;
    let file = body.get("file")?.as_str()?;
    let uri = file_path_to_uri(file)?;
    let request_seq = body.get("request_seq").and_then(|value| value.as_u64());
    let diagnostics = body
        .get("diagnostics")
        .and_then(|value| value.as_array())
        .cloned()
        .unwrap_or_default();

    let lsp_diagnostics = diagnostics
        .into_iter()
        .filter_map(convert_diagnostic)
        .collect::<Vec<_>>();

    Some(DiagnosticsEvent::Report {
        request_seq,
        kind,
        uri,
        diagnostics: lsp_diagnostics,
    })
}

fn convert_diagnostic(value: Value) -> Option<Diagnostic> {
    let range = tsserver_range_from_value_lsp(&value)?;
    let message = value.get("text")?.as_str()?.to_string();
    let severity = map_severity(value.get("category").and_then(|v| v.as_str()));
    let code = value
        .get("code")
        .and_then(|c| c.as_i64())
        .map(|code| NumberOrString::Number(code as i32));

    Some(Diagnostic {
        range,
        severity,
        code,
        source: Some("tsserver".to_string()),
        message,
        ..Diagnostic::default()
    })
}

fn map_severity(category: Option<&str>) -> Option<DiagnosticSeverity> {
    match category {
        Some("error") => Some(DiagnosticSeverity::ERROR),
        Some("warning") => Some(DiagnosticSeverity::WARNING),
        Some("suggestion") => Some(DiagnosticSeverity::HINT),
        Some("message") => Some(DiagnosticSeverity::INFORMATION),
        _ => Some(DiagnosticSeverity::WARNING),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn request_for_file_targets_both_servers() {
        let spec = request_for_file("/workspace/foo.ts");
        assert_eq!(spec.route, Route::Both);
        let files = spec
            .payload
            .get("arguments")
            .and_then(|args| args.get("files"))
            .and_then(|entry| entry.as_array())
            .expect("files not present");
        assert_eq!(files, &[json!("/workspace/foo.ts")]);
    }

    #[test]
    fn parse_tsserver_event_converts_diagnostics() {
        let payload = json!({
            "type": "event",
            "event": "semanticDiag",
            "body": {
                "file": "/workspace/foo.ts",
                "request_seq": 7,
                "diagnostics": [{
                    "start": { "line": 1, "offset": 1 },
                    "end": { "line": 1, "offset": 4 },
                    "text": "oops",
                    "category": "error",
                    "code": 123,
                }]
            }
        });

        match parse_tsserver_event(&payload) {
            Some(DiagnosticsEvent::Report {
                uri,
                diagnostics,
                request_seq,
                kind,
            }) => {
                assert_eq!(kind, DiagnosticsKind::Semantic);
                assert_eq!(request_seq, Some(7));
                assert_eq!(uri.to_string(), "file:///workspace/foo.ts");
                assert_eq!(diagnostics.len(), 1);
                assert_eq!(diagnostics[0].message, "oops");
                assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
            }
            other => panic!("unexpected diagnostics event: {other:?}"),
        }
    }

    #[test]
    fn parse_tsserver_event_detects_completion_events() {
        let payload = json!({
            "type": "event",
            "event": "requestCompleted",
            "body": { "request_seq": 99 }
        });

        match parse_tsserver_event(&payload) {
            Some(DiagnosticsEvent::Completed { request_seq }) => assert_eq!(request_seq, 99),
            other => panic!("expected completion event, got {other:?}"),
        }
    }
}