ts_bridge/protocol/text_document/
definition.rs1use 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}