Skip to main content

mcpls_core/bridge/translator/
folding_range.rs

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