Skip to main content

mcpls_core/bridge/translator/
folding_range.rs

1//! Folding range handler.
2
3use 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
17/// The closed kind of an LSP folding range kind; a missing or custom kind is
18/// [`FoldingKind::Unspecified`].
19const 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
28/// The regions of `ranges` that are well formed and pass `filter`, ordered by
29/// ascending start line then descending end line.
30fn 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    /// Handle a folding range request: the foldable regions of a file.
86    ///
87    /// Regions that end before they start are dropped, the rest are filtered
88    /// by `filter`, ordered by ascending start line then descending end line,
89    /// and capped (`truncated` is set when more remain). A kind the server
90    /// does not name, or names with a custom value, is
91    /// [`FoldingKind::Unspecified`]. Both answers are syntactic, so the
92    /// request does not wait for indexing.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the LSP request fails, the file cannot be opened,
97    /// or the routed server does not advertise `foldingRangeProvider`
98    /// support.
99    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        // "é" is 2 UTF-8 bytes: byte offset 5 is character 4 (1-based 5).
333        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}