ts_bridge/protocol/text_document/
definition.rs

1//! =============================================================================
2//! textDocument/definition
3//! =============================================================================
4//!
5//! Tsserver powers definition requests through the `definitionAndBoundSpan`
6//! command (plus `findSourceDefinition` for source preference).
7//! Converting each returned `FileSpanWithContext`
8//! into an LSP `LocationLink` so the client can show peek-definition previews
9//! with context.
10
11use anyhow::{Context, Result};
12use lsp_types::{GotoDefinitionParams, GotoDefinitionResponse};
13use serde::Deserialize;
14use serde_json::{Value, json};
15
16use crate::protocol::{AdapterResult, RequestSpec};
17use crate::rpc::{Priority, Route};
18use crate::utils::{
19    tsserver_range_from_value_lsp, tsserver_span_to_location_link, uri_to_file_path,
20};
21
22const CMD_DEFINITION: &str = "definitionAndBoundSpan";
23const CMD_SOURCE_DEFINITION: &str = "findSourceDefinition";
24
25#[derive(Deserialize)]
26pub struct DefinitionParams {
27    #[serde(flatten)]
28    pub base: GotoDefinitionParams,
29    #[serde(default)]
30    pub context: Option<DefinitionContext>,
31}
32
33#[derive(Debug, Deserialize, Default)]
34#[serde(rename_all = "camelCase")]
35pub struct DefinitionContext {
36    pub source_definition: Option<bool>,
37}
38
39pub fn handle(params: DefinitionParams) -> RequestSpec {
40    let text_document = params.base.text_document_position_params.text_document;
41    let uri_string = text_document.uri.to_string();
42    let file_name = uri_to_file_path(text_document.uri.as_str()).unwrap_or(uri_string);
43
44    let position = params.base.text_document_position_params.position;
45    let use_source_definition = params
46        .context
47        .and_then(|ctx| ctx.source_definition)
48        .unwrap_or(false);
49    let command = if use_source_definition {
50        CMD_SOURCE_DEFINITION
51    } else {
52        CMD_DEFINITION
53    };
54    let request = json!({
55        "command": command,
56        "arguments": {
57            "file": file_name,
58            "line": position.line + 1,
59            "offset": position.character + 1,
60        }
61    });
62
63    RequestSpec {
64        route: Route::Syntax,
65        payload: request,
66        priority: Priority::Normal,
67        on_response: Some(adapt_definition),
68        response_context: None,
69    }
70}
71
72fn adapt_definition(payload: &Value, _context: Option<&Value>) -> Result<AdapterResult> {
73    let command = payload
74        .get("command")
75        .and_then(|cmd| cmd.as_str())
76        .unwrap_or(CMD_DEFINITION);
77    let body = payload
78        .get("body")
79        .context("tsserver definition missing body")?;
80
81    let origin_selection = body.get("textSpan").and_then(tsserver_range_from_value_lsp);
82
83    let defs: Box<dyn Iterator<Item = &Value> + '_> = if command == CMD_SOURCE_DEFINITION {
84        let array = body
85            .as_array()
86            .context("source definition body must be array")?;
87        Box::new(array.iter())
88    } else {
89        let array = body
90            .get("definitions")
91            .and_then(|value| value.as_array())
92            .context("definition body missing definitions array")?;
93        Box::new(array.iter())
94    };
95
96    let mut links = Vec::new();
97    for def in defs {
98        if let Some(link) = tsserver_span_to_location_link(def, origin_selection.clone()) {
99            links.push(link);
100        }
101    }
102
103    let response = GotoDefinitionResponse::Link(links);
104    Ok(AdapterResult::ready(serde_json::to_value(response)?))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use lsp_types::{
111        GotoDefinitionParams, GotoDefinitionResponse, LocationLink, Position,
112        TextDocumentIdentifier, TextDocumentPositionParams, Uri,
113    };
114    use std::str::FromStr;
115
116    fn params_with_context(source_definition: bool) -> DefinitionParams {
117        let base = GotoDefinitionParams {
118            text_document_position_params: TextDocumentPositionParams {
119                text_document: TextDocumentIdentifier {
120                    uri: Uri::from_str("file:///workspace/app.ts").unwrap(),
121                },
122                position: Position {
123                    line: 2,
124                    character: 10,
125                },
126            },
127            work_done_progress_params: Default::default(),
128            partial_result_params: Default::default(),
129        };
130        DefinitionParams {
131            base,
132            context: Some(DefinitionContext {
133                source_definition: Some(source_definition),
134            }),
135        }
136    }
137
138    #[test]
139    fn handle_builds_definition_request() {
140        let params = DefinitionParams {
141            base: GotoDefinitionParams {
142                text_document_position_params: TextDocumentPositionParams {
143                    text_document: TextDocumentIdentifier {
144                        uri: Uri::from_str("file:///workspace/app.ts").unwrap(),
145                    },
146                    position: Position {
147                        line: 1,
148                        character: 4,
149                    },
150                },
151                work_done_progress_params: Default::default(),
152                partial_result_params: Default::default(),
153            },
154            context: None,
155        };
156        let spec = handle(params);
157        assert_eq!(spec.route, Route::Syntax);
158        assert_eq!(spec.priority, Priority::Normal);
159        assert_eq!(spec.payload.get("command"), Some(&json!(CMD_DEFINITION)));
160        let args = spec.payload.get("arguments").expect("missing args");
161        assert_eq!(
162            args.get("file").and_then(|v| v.as_str()),
163            Some("/workspace/app.ts")
164        );
165        assert_eq!(args.get("line").and_then(|v| v.as_u64()), Some(2));
166        assert_eq!(args.get("offset").and_then(|v| v.as_u64()), Some(5));
167    }
168
169    #[test]
170    fn handle_uses_source_definition_command_when_context_requests_it() {
171        let spec = handle(params_with_context(true));
172        assert_eq!(
173            spec.payload.get("command"),
174            Some(&json!(CMD_SOURCE_DEFINITION))
175        );
176    }
177
178    #[test]
179    fn source_definition_flag_deserializes_from_camel_case_context() {
180        let raw = json!({
181            "textDocument": { "uri": "file:///workspace/app.ts" },
182            "position": { "line": 4, "character": 2 },
183            "context": { "sourceDefinition": true }
184        });
185        let params: DefinitionParams =
186            serde_json::from_value(raw).expect("definition params should deserialize");
187        let spec = handle(params);
188        assert_eq!(
189            spec.payload.get("command"),
190            Some(&json!(CMD_SOURCE_DEFINITION))
191        );
192    }
193
194    #[test]
195    fn adapt_definition_converts_standard_payload() {
196        let payload = json!({
197            "command": CMD_DEFINITION,
198            "body": {
199                "textSpan": {
200                    "start": { "line": 3, "offset": 1 },
201                    "end": { "line": 3, "offset": 6 }
202                },
203                "definitions": [{
204                    "file": "/workspace/foo.ts",
205                    "start": { "line": 10, "offset": 2 },
206                    "end": { "line": 10, "offset": 12 },
207                    "contextStart": { "line": 9, "offset": 1 },
208                    "contextEnd": { "line": 11, "offset": 1 }
209                }]
210            }
211        });
212
213        let adapted = adapt_definition(&payload, None).expect("definition should adapt");
214        let value = match adapted {
215            AdapterResult::Ready(value) => value,
216            AdapterResult::Continue(_) => panic!("expected ready definition response"),
217        };
218        match serde_json::from_value::<GotoDefinitionResponse>(value)
219            .expect("response should deserialize")
220        {
221            GotoDefinitionResponse::Link(links) => {
222                assert_eq!(links.len(), 1);
223                let LocationLink {
224                    target_uri,
225                    target_selection_range,
226                    target_range,
227                    origin_selection_range,
228                } = &links[0];
229                assert_eq!(target_uri.to_string(), "file:///workspace/foo.ts");
230                assert_eq!(target_selection_range.start.line, 9);
231                assert_eq!(target_selection_range.start.character, 1);
232                assert_eq!(target_range.start.line, 8);
233                assert_eq!(
234                    origin_selection_range.as_ref().map(|r| r.start.line),
235                    Some(2)
236                );
237            }
238            _ => panic!("expected link response"),
239        }
240    }
241
242    #[test]
243    fn adapt_definition_handles_source_definition_shape() {
244        let payload = json!({
245            "command": CMD_SOURCE_DEFINITION,
246            "body": [{
247                "file": "/workspace/src.ts",
248                "start": { "line": 5, "offset": 3 },
249                "end": { "line": 5, "offset": 7 }
250            }]
251        });
252        let adapted = adapt_definition(&payload, None).expect("source definition adapts");
253        let value = match adapted {
254            AdapterResult::Ready(value) => value,
255            AdapterResult::Continue(_) => panic!("expected ready source definition response"),
256        };
257        match serde_json::from_value::<GotoDefinitionResponse>(value)
258            .expect("response should deserialize")
259        {
260            GotoDefinitionResponse::Link(links) => {
261                assert_eq!(links.len(), 1);
262                let LocationLink {
263                    target_uri,
264                    origin_selection_range,
265                    ..
266                } = &links[0];
267                assert_eq!(target_uri.to_string(), "file:///workspace/src.ts");
268                assert!(origin_selection_range.is_none());
269            }
270            _ => panic!("expected link response"),
271        }
272    }
273}