ts_bridge/protocol/
diagnostics.rs

1use lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Uri};
2use serde_json::{Value, json};
3
4use crate::protocol::NotificationSpec;
5use crate::rpc::{Priority, Route};
6use crate::utils::{file_path_to_uri, tsserver_range_from_value_lsp};
7
8const REQUEST_COMPLETED: &str = "requestCompleted";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DiagnosticsKind {
12    Syntax,
13    Semantic,
14    Suggestion,
15}
16
17impl DiagnosticsKind {
18    fn from_event_name(name: &str) -> Option<Self> {
19        match name {
20            "syntaxDiag" => Some(DiagnosticsKind::Syntax),
21            "semanticDiag" => Some(DiagnosticsKind::Semantic),
22            "suggestionDiag" => Some(DiagnosticsKind::Suggestion),
23            _ => None,
24        }
25    }
26}
27
28#[derive(Debug)]
29pub enum DiagnosticsEvent {
30    Report {
31        kind: DiagnosticsKind,
32        request_seq: Option<u64>,
33        uri: Uri,
34        diagnostics: Vec<Diagnostic>,
35    },
36    Completed {
37        request_seq: u64,
38    },
39}
40
41pub fn request_for_file(file: &str) -> NotificationSpec {
42    let payload = json!({
43        "command": "geterr",
44        "arguments": {
45            "files": [file],
46            "delay": 0,
47        }
48    });
49
50    NotificationSpec {
51        route: Route::Both,
52        payload,
53        priority: Priority::Low,
54    }
55}
56
57pub fn parse_tsserver_event(payload: &Value) -> Option<DiagnosticsEvent> {
58    if payload.get("type")?.as_str()? != "event" {
59        return None;
60    }
61    let event_name = payload.get("event")?.as_str()?;
62    if event_name == REQUEST_COMPLETED {
63        let seq = payload
64            .get("body")
65            .and_then(|body| body.get("request_seq"))
66            .and_then(|value| value.as_u64())?;
67        return Some(DiagnosticsEvent::Completed { request_seq: seq });
68    }
69    let kind = DiagnosticsKind::from_event_name(event_name)?;
70
71    let body = payload.get("body")?;
72    let file = body.get("file")?.as_str()?;
73    let uri = file_path_to_uri(file)?;
74    let request_seq = body.get("request_seq").and_then(|value| value.as_u64());
75    let diagnostics = body
76        .get("diagnostics")
77        .and_then(|value| value.as_array())
78        .cloned()
79        .unwrap_or_default();
80
81    let lsp_diagnostics = diagnostics
82        .into_iter()
83        .filter_map(convert_diagnostic)
84        .collect::<Vec<_>>();
85
86    Some(DiagnosticsEvent::Report {
87        request_seq,
88        kind,
89        uri,
90        diagnostics: lsp_diagnostics,
91    })
92}
93
94fn convert_diagnostic(value: Value) -> Option<Diagnostic> {
95    let range = tsserver_range_from_value_lsp(&value)?;
96    let message = value.get("text")?.as_str()?.to_string();
97    let severity = map_severity(value.get("category").and_then(|v| v.as_str()));
98    let code = value
99        .get("code")
100        .and_then(|c| c.as_i64())
101        .map(|code| NumberOrString::Number(code as i32));
102
103    Some(Diagnostic {
104        range,
105        severity,
106        code,
107        source: Some("tsserver".to_string()),
108        message,
109        ..Diagnostic::default()
110    })
111}
112
113fn map_severity(category: Option<&str>) -> Option<DiagnosticSeverity> {
114    match category {
115        Some("error") => Some(DiagnosticSeverity::ERROR),
116        Some("warning") => Some(DiagnosticSeverity::WARNING),
117        Some("suggestion") => Some(DiagnosticSeverity::HINT),
118        Some("message") => Some(DiagnosticSeverity::INFORMATION),
119        _ => Some(DiagnosticSeverity::WARNING),
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn request_for_file_targets_both_servers() {
129        let spec = request_for_file("/workspace/foo.ts");
130        assert_eq!(spec.route, Route::Both);
131        let files = spec
132            .payload
133            .get("arguments")
134            .and_then(|args| args.get("files"))
135            .and_then(|entry| entry.as_array())
136            .expect("files not present");
137        assert_eq!(files, &[json!("/workspace/foo.ts")]);
138    }
139
140    #[test]
141    fn parse_tsserver_event_converts_diagnostics() {
142        let payload = json!({
143            "type": "event",
144            "event": "semanticDiag",
145            "body": {
146                "file": "/workspace/foo.ts",
147                "request_seq": 7,
148                "diagnostics": [{
149                    "start": { "line": 1, "offset": 1 },
150                    "end": { "line": 1, "offset": 4 },
151                    "text": "oops",
152                    "category": "error",
153                    "code": 123,
154                }]
155            }
156        });
157
158        match parse_tsserver_event(&payload) {
159            Some(DiagnosticsEvent::Report {
160                uri,
161                diagnostics,
162                request_seq,
163                kind,
164            }) => {
165                assert_eq!(kind, DiagnosticsKind::Semantic);
166                assert_eq!(request_seq, Some(7));
167                assert_eq!(uri.to_string(), "file:///workspace/foo.ts");
168                assert_eq!(diagnostics.len(), 1);
169                assert_eq!(diagnostics[0].message, "oops");
170                assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
171            }
172            other => panic!("unexpected diagnostics event: {other:?}"),
173        }
174    }
175
176    #[test]
177    fn parse_tsserver_event_detects_completion_events() {
178        let payload = json!({
179            "type": "event",
180            "event": "requestCompleted",
181            "body": { "request_seq": 99 }
182        });
183
184        match parse_tsserver_event(&payload) {
185            Some(DiagnosticsEvent::Completed { request_seq }) => assert_eq!(request_seq, 99),
186            other => panic!("expected completion event, got {other:?}"),
187        }
188    }
189}