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