1use lsp_types::{
4 FoldingRangeParams, PartialResultParams, TextDocumentIdentifier, WorkDoneProgressParams,
5};
6use tracing::debug;
7
8use super::Translator;
9use super::dto::{FoldingKind, FoldingKindFilter, FoldingRangesResult, FoldingRegion};
10use super::encoding_ctx::EncodingCtx;
11use super::navigation::ItemBudget;
12use super::routing::{FileLocalCapability, IndexingGate};
13use crate::bridge::ClientPath;
14use crate::error::Result;
15use crate::util::escape_control_owned;
16
17const fn folding_kind(kind: Option<&lsp_types::FoldingRangeKind>) -> FoldingKind {
20 match kind {
21 Some(lsp_types::FoldingRangeKind::Comment) => FoldingKind::Comment,
22 Some(lsp_types::FoldingRangeKind::Imports) => FoldingKind::Imports,
23 Some(lsp_types::FoldingRangeKind::Region) => FoldingKind::Region,
24 Some(lsp_types::FoldingRangeKind::Custom(_)) | None => FoldingKind::Unspecified,
25 }
26}
27
28fn select(
31 ranges: Vec<lsp_types::FoldingRange>,
32 filter: FoldingKindFilter,
33) -> Vec<(FoldingKind, lsp_types::FoldingRange)> {
34 let mut selected: Vec<_> = ranges
35 .into_iter()
36 .filter(|range| {
37 let well_formed = range.end_line >= range.start_line;
38 if !well_formed {
39 debug!(
40 start_line = range.start_line,
41 end_line = range.end_line,
42 "dropping a folding range that ends before it starts"
43 );
44 }
45 well_formed
46 })
47 .map(|range| (folding_kind(range.kind.as_ref()), range))
48 .filter(|(kind, _)| filter.admits(*kind))
49 .collect();
50 selected.sort_by_key(|(_, range)| (range.start_line, std::cmp::Reverse(range.end_line)));
51 selected
52}
53
54async fn character(
55 ctx: &EncodingCtx,
56 uri: &lsp_types::Uri,
57 line: u32,
58 character: Option<u32>,
59) -> Option<u32> {
60 let character = character?;
61 Some(
62 ctx.to_mcp(uri, lsp_types::Position { line, character })
63 .await
64 .character,
65 )
66}
67
68async fn region(
69 ctx: &EncodingCtx,
70 uri: &lsp_types::Uri,
71 kind: FoldingKind,
72 range: lsp_types::FoldingRange,
73) -> FoldingRegion {
74 FoldingRegion {
75 start_line: range.start_line.saturating_add(1),
76 end_line: range.end_line.saturating_add(1),
77 start_character: character(ctx, uri, range.start_line, range.start_character).await,
78 end_character: character(ctx, uri, range.end_line, range.end_character).await,
79 kind,
80 collapsed_text: range.collapsed_text.map(escape_control_owned),
81 }
82}
83
84impl Translator {
85 pub async fn handle_folding_range(
100 &self,
101 file_path: ClientPath,
102 filter: FoldingKindFilter,
103 ) -> Result<FoldingRangesResult> {
104 let doc = self
105 .prepare_gated_document(
106 &file_path,
107 IndexingGate::FileLocal(FileLocalCapability::FoldingRange),
108 )
109 .await?;
110 let (server_id, client, uri) = (doc.server_id(), doc.client(), doc.uri());
111 let ctx = self.encoding_ctx(server_id);
112 let response_uri = uri.clone();
113
114 let params = FoldingRangeParams {
115 text_document: TextDocumentIdentifier { uri: uri.clone() },
116 work_done_progress_params: WorkDoneProgressParams::default(),
117 partial_result_params: PartialResultParams::default(),
118 };
119
120 let response = client
121 .request_typed::<lsp_types::FoldingRangeRequest>(params, client.request_timeout())
122 .await?;
123
124 let mut budget = ItemBudget::new();
125 let admitted = budget.admit(select(response.unwrap_or_default(), filter));
126 let mut regions = Vec::with_capacity(admitted.len());
127 for (kind, range) in admitted {
128 regions.push(region(&ctx, &response_uri, kind, range).await);
129 }
130
131 Ok(FoldingRangesResult {
132 regions,
133 truncated: budget.truncated(),
134 positions_degraded: ctx.positions_degraded(),
135 })
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use std::sync::Arc;
142 use std::time::Duration;
143 use std::{assert_matches, fs};
144
145 use tempfile::TempDir;
146 use tokio::io::BufReader;
147 use tokio::time::timeout;
148
149 use super::*;
150 use crate::bridge::translator::testing::*;
151 use crate::config::ServerId;
152 use crate::error::Error;
153 use crate::test_lsp::client_path;
154
155 fn caps() -> lsp_types::ServerCapabilities {
156 lsp_types::ServerCapabilities {
157 folding_range_provider: Some(lsp_types::FoldingRangeProvider::Bool(true)),
158 ..Default::default()
159 }
160 }
161
162 fn fold(start: u32, end: u32, kind: Option<&str>) -> serde_json::Value {
163 let mut value = serde_json::json!({"startLine": start, "endLine": end});
164 if let Some(kind) = kind {
165 value["kind"] = kind.into();
166 }
167 value
168 }
169
170 async fn folding_with_response(
171 source: &str,
172 filter: FoldingKindFilter,
173 response: serde_json::Value,
174 encoding: Option<lsp_types::PositionEncodingKind>,
175 ) -> FoldingRangesResult {
176 let dir = TempDir::new().unwrap();
177 let server_id = ServerId::from_static("rust");
178 let (translator, mut server) = encoding.map_or_else(
179 || translator_with_capabilities(&dir, &server_id, caps()),
180 |encoding| {
181 translator_with_capabilities_and_encoding(&dir, &server_id, caps(), encoding)
182 },
183 );
184 let path = dir.path().join("a.rs");
185 fs::write(&path, source).unwrap();
186
187 let translator = Arc::new(translator);
188 let handle = {
189 let translator = Arc::clone(&translator);
190 let path = path.to_string_lossy().into_owned();
191 tokio::spawn(async move {
192 translator
193 .handle_folding_range(client_path(path), filter)
194 .await
195 })
196 };
197 let mut wire = BufReader::new(&mut server.write_stdout);
198 assert_eq!(
199 read_framed_message(&mut wire).await["method"],
200 "textDocument/didOpen"
201 );
202 let request = read_framed_message(&mut wire).await;
203 assert_eq!(request["method"], "textDocument/foldingRange");
204 write_response(&mut server.read_half_stdin, &request["id"], response).await;
205
206 timeout(Duration::from_secs(30), handle)
207 .await
208 .expect("handler call should not hang")
209 .unwrap()
210 .unwrap()
211 }
212
213 #[test]
214 fn maps_kinds_to_a_closed_set() {
215 use lsp_types::FoldingRangeKind as Lsp;
216
217 assert_eq!(folding_kind(Some(&Lsp::Comment)), FoldingKind::Comment);
218 assert_eq!(folding_kind(Some(&Lsp::Imports)), FoldingKind::Imports);
219 assert_eq!(folding_kind(Some(&Lsp::Region)), FoldingKind::Region);
220 assert_eq!(
221 folding_kind(Some(&Lsp::Custom("fn".into()))),
222 FoldingKind::Unspecified
223 );
224 assert_eq!(folding_kind(None), FoldingKind::Unspecified);
225 }
226
227 #[tokio::test]
228 async fn orders_regions_by_start_then_longest_and_converts_lines() {
229 let result = folding_with_response(
230 "a\nb\nc\nd\ne\nf\n",
231 FoldingKindFilter::All,
232 serde_json::json!([
233 fold(3, 4, None),
234 fold(0, 2, Some("imports")),
235 fold(0, 5, Some("region")),
236 fold(3, 3, Some("comment")),
237 ]),
238 None,
239 )
240 .await;
241
242 let spans: Vec<_> = result
243 .regions
244 .iter()
245 .map(|r| (r.start_line, r.end_line, r.kind))
246 .collect();
247 assert_eq!(
248 spans,
249 [
250 (1, 6, FoldingKind::Region),
251 (1, 3, FoldingKind::Imports),
252 (4, 5, FoldingKind::Unspecified),
253 (4, 4, FoldingKind::Comment),
254 ]
255 );
256 let wire = serde_json::to_value(&result).unwrap();
257 assert_eq!(wire["regions"][0]["kind"], "region");
258 assert!(wire["regions"][0].get("start_character").is_none());
259 assert!(wire.get("truncated").is_none());
260 }
261
262 #[tokio::test]
263 async fn the_kind_filter_selects_only_that_kind_and_never_unspecified() {
264 let response = serde_json::json!([
265 fold(0, 1, Some("imports")),
266 fold(2, 3, None),
267 fold(4, 5, Some("custom-kind")),
268 fold(6, 7, Some("comment")),
269 ]);
270 for (filter, expected) in [
271 (FoldingKindFilter::Imports, 1),
272 (FoldingKindFilter::Comment, 1),
273 (FoldingKindFilter::Region, 0),
274 (FoldingKindFilter::All, 4),
275 ] {
276 let result =
277 folding_with_response("x\n".repeat(8).as_str(), filter, response.clone(), None)
278 .await;
279 assert_eq!(result.regions.len(), expected, "{filter:?}");
280 }
281 }
282
283 #[tokio::test]
284 async fn drops_regions_that_end_before_they_start() {
285 let result = folding_with_response(
286 "a\nb\nc\n",
287 FoldingKindFilter::All,
288 serde_json::json!([fold(2, 1, None), fold(0, 1, None)]),
289 None,
290 )
291 .await;
292 assert_eq!(result.regions.len(), 1);
293 assert_eq!(result.regions[0].start_line, 1);
294 }
295
296 #[tokio::test]
297 async fn the_filter_applies_before_the_bound() {
298 let mut regions: Vec<_> = (0..10_050).map(|i| fold(i, i + 1, None)).collect();
299 regions.push(fold(20_000, 20_001, Some("imports")));
300 let result = folding_with_response(
301 "x",
302 FoldingKindFilter::Imports,
303 serde_json::Value::Array(regions.clone()),
304 None,
305 )
306 .await;
307 assert_eq!(result.regions.len(), 1);
308 assert!(!result.truncated);
309
310 let all = folding_with_response(
311 "x",
312 FoldingKindFilter::All,
313 serde_json::Value::Array(regions),
314 None,
315 )
316 .await;
317 assert!(all.truncated);
318 assert_eq!(all.regions.len(), 10_000);
319 assert_eq!(all.regions[0].start_line, 1);
320 }
321
322 #[tokio::test]
323 async fn null_and_empty_answers_are_an_empty_result() {
324 for response in [serde_json::Value::Null, serde_json::json!([])] {
325 let result = folding_with_response("x", FoldingKindFilter::All, response, None).await;
326 assert!(result.regions.is_empty() && !result.truncated);
327 }
328 }
329
330 #[tokio::test]
331 async fn characters_are_independent_converted_and_never_invented() {
332 let result = folding_with_response(
334 "é = {\n x\n}\n",
335 FoldingKindFilter::All,
336 serde_json::json!([{
337 "startLine": 0, "startCharacter": 5,
338 "endLine": 2, "kind": "region", "collapsedText": "{...}"
339 }]),
340 Some(lsp_types::PositionEncodingKind::UTF8),
341 )
342 .await;
343
344 let region = &result.regions[0];
345 assert_eq!(region.start_character, Some(5));
346 assert_eq!(region.end_character, None);
347 assert_eq!(region.collapsed_text.as_deref(), Some("{...}"));
348 assert_eq!(result.positions_degraded, None);
349 }
350
351 #[tokio::test]
352 async fn collapsed_text_is_escaped() {
353 let result = folding_with_response(
354 "a\nb\n",
355 FoldingKindFilter::All,
356 serde_json::json!([{
357 "startLine": 0, "endLine": 1, "collapsedText": "x\u{1b}[31my"
358 }]),
359 None,
360 )
361 .await;
362 assert_eq!(
363 result.regions[0].collapsed_text.as_deref(),
364 Some("x\\u{1b}[31my")
365 );
366 assert_ne!(
367 result.regions[0].collapsed_text.as_deref(),
368 Some("x\u{1b}[31my")
369 );
370 }
371
372 #[tokio::test]
373 async fn rejects_missing_capability() {
374 let dir = TempDir::new().unwrap();
375 let (translator, _server) = translator_with_capabilities(
376 &dir,
377 &ServerId::from_static("rust"),
378 lsp_types::ServerCapabilities::default(),
379 );
380 let path = dir.path().join("a.rs");
381 fs::write(&path, "x").unwrap();
382 let result = translator
383 .handle_folding_range(
384 client_path(path.to_string_lossy().into_owned()),
385 FoldingKindFilter::All,
386 )
387 .await;
388 assert_matches!(result, Err(Error::CapabilityNotSupported { .. }));
389 }
390}