Skip to main content

bynkc_lsp/
structure.rs

1//! v0.37 (ADR 0070): structural ranges — `textDocument/foldingRange` and
2//! `textDocument/selectionRange`.
3//!
4//! Both read the per-file **recovered AST** (the document-symbols parse path)
5//! and share one span visitor ([`collect`]): every node contributes its
6//! `(span, foldable)` pair. **Folding** keeps the multi-line block-like nodes
7//! (`foldable`); **selection** keeps every span containing the cursor and
8//! nests them. Neither touches the binding index or the analysis round — they
9//! parse the live document, so they answer even when the project doesn't check.
10
11use std::collections::HashSet;
12
13use bynk_syntax::ast::*;
14use bynk_syntax::lexer::{TokenKind, tokenize};
15use bynk_syntax::parser::parse_unit_with_recovery;
16use bynk_syntax::span::Span;
17use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind, Position, Range, SelectionRange};
18
19use crate::position::{offset_to_position, position_to_offset, span_to_range};
20
21/// Every AST node's span paired with whether it is a folding candidate (a
22/// multi-line block-like construct). Non-candidate spans are still collected —
23/// selection chains need the fine-grained leaves. Empty when the file has no
24/// recognisable header (recovery returned nothing).
25fn collect(source: &str) -> Vec<(Span, bool)> {
26    let Ok(tokens) = tokenize(source) else {
27        return Vec::new();
28    };
29    let (Some(unit), _errs) = parse_unit_with_recovery(&tokens, source) else {
30        return Vec::new();
31    };
32    let mut out = Vec::new();
33    walk_unit(&unit, &mut out);
34    out
35}
36
37fn walk_unit(unit: &SourceUnit, out: &mut Vec<(Span, bool)>) {
38    match unit {
39        SourceUnit::Commons(c) => {
40            out.push((c.span, true));
41            c.items.iter().for_each(|i| walk_item(i, out));
42        }
43        SourceUnit::Context(c) => {
44            out.push((c.span, true));
45            c.items.iter().for_each(|i| walk_item(i, out));
46        }
47        SourceUnit::Adapter(a) => {
48            out.push((a.span, true));
49            a.items.iter().for_each(|i| walk_item(i, out));
50        }
51        SourceUnit::Suite(t) => {
52            out.push((t.span, true));
53            for case in &t.cases {
54                out.push((case.span, true));
55                walk_block(&case.body, out);
56            }
57        }
58        SourceUnit::Integration(i) => {
59            out.push((i.span, true));
60            for case in &i.cases {
61                out.push((case.span, true));
62            }
63        }
64    }
65}
66
67fn walk_item(item: &CommonsItem, out: &mut Vec<(Span, bool)>) {
68    match item {
69        CommonsItem::Type(t) => {
70            out.push((t.span, true));
71            match &t.body {
72                TypeBody::Record(r) => out.push((r.span, true)),
73                TypeBody::Sum(s) => out.push((s.span, true)),
74                TypeBody::Opaque { .. } | TypeBody::Refined { .. } => {}
75            }
76        }
77        CommonsItem::Fn(f) => {
78            out.push((f.span, true));
79            walk_block(&f.body, out);
80        }
81        CommonsItem::Capability(c) => out.push((c.span, true)),
82        CommonsItem::Provider(p) => {
83            out.push((p.span, true));
84            for op in &p.ops {
85                out.push((op.span, true));
86                walk_block(&op.body, out);
87            }
88        }
89        CommonsItem::Service(s) => {
90            out.push((s.span, true));
91            for h in &s.handlers {
92                out.push((h.span, true));
93                walk_block(&h.body, out);
94            }
95        }
96        CommonsItem::Agent(a) => {
97            out.push((a.span, true));
98            for h in &a.handlers {
99                out.push((h.span, true));
100                walk_block(&h.body, out);
101            }
102        }
103        CommonsItem::Actor(a) => {
104            out.push((a.span, true));
105        }
106    }
107}
108
109fn walk_block(b: &Block, out: &mut Vec<(Span, bool)>) {
110    out.push((b.span, true));
111    for s in &b.statements {
112        out.push((s.span(), false));
113        match s {
114            Statement::Let(l) | Statement::EffectLet(l) => walk_expr(&l.value, out),
115            Statement::Expect(a) => walk_expr(&a.value, out),
116            Statement::Send(s) => walk_expr(&s.value, out),
117            Statement::Assign(a) => walk_expr(&a.value, out),
118        }
119    }
120    walk_expr(&b.tail, out);
121}
122
123fn walk_expr(e: &Expr, out: &mut Vec<(Span, bool)>) {
124    let foldable = matches!(
125        e.kind,
126        ExprKind::Block(_)
127            | ExprKind::If { .. }
128            | ExprKind::Match { .. }
129            | ExprKind::RecordConstruction { .. }
130            | ExprKind::RecordSpread { .. }
131            | ExprKind::ListLit(_)
132            | ExprKind::Lambda(_)
133    );
134    out.push((e.span, foldable));
135    match &e.kind {
136        ExprKind::Block(b) => {
137            for s in &b.statements {
138                out.push((s.span(), false));
139                match s {
140                    Statement::Let(l) | Statement::EffectLet(l) => walk_expr(&l.value, out),
141                    Statement::Expect(a) => walk_expr(&a.value, out),
142                    Statement::Send(s) => walk_expr(&s.value, out),
143                    Statement::Assign(a) => walk_expr(&a.value, out),
144                }
145            }
146            walk_expr(&b.tail, out);
147        }
148        ExprKind::If {
149            cond,
150            then_block,
151            else_block,
152        } => {
153            walk_expr(cond, out);
154            walk_block(then_block, out);
155            walk_block(else_block, out);
156        }
157        ExprKind::Match { discriminant, arms } => {
158            walk_expr(discriminant, out);
159            for arm in arms {
160                out.push((arm.span, true));
161                match &arm.body {
162                    MatchBody::Expr(ex) => walk_expr(ex, out),
163                    MatchBody::Block(bl) => walk_block(bl, out),
164                }
165            }
166        }
167        ExprKind::RecordConstruction { fields, .. } => {
168            for f in fields {
169                if let Some(v) = &f.value {
170                    walk_expr(v, out);
171                }
172            }
173        }
174        ExprKind::RecordSpread {
175            base, overrides, ..
176        } => {
177            walk_expr(base, out);
178            for f in overrides {
179                if let Some(v) = &f.value {
180                    walk_expr(v, out);
181                }
182            }
183        }
184        ExprKind::ListLit(elems) => elems.iter().for_each(|el| walk_expr(el, out)),
185        ExprKind::Lambda(l) => walk_expr(&l.body, out),
186        ExprKind::BinOp(_, a, b) => {
187            walk_expr(a, out);
188            walk_expr(b, out);
189        }
190        ExprKind::UnaryOp(_, x)
191        | ExprKind::Paren(x)
192        | ExprKind::Ok(x)
193        | ExprKind::Err(x)
194        | ExprKind::Question(x)
195        | ExprKind::Some(x)
196        | ExprKind::EffectPure(x)
197        | ExprKind::Expect(x) => walk_expr(x, out),
198        ExprKind::Call { args, .. } | ExprKind::ConstructorCall { args, .. } => {
199            args.iter().for_each(|a| walk_expr(a, out))
200        }
201        ExprKind::MethodCall { receiver, args, .. } => {
202            walk_expr(receiver, out);
203            args.iter().for_each(|a| walk_expr(a, out));
204        }
205        ExprKind::FieldAccess { receiver, .. } => walk_expr(receiver, out),
206        ExprKind::Is { value, .. } => walk_expr(value, out),
207        ExprKind::Val { args, .. } => args.iter().for_each(|a| walk_expr(a, out)),
208        // v0.43: walk each interpolation hole's expression.
209        ExprKind::InterpStr(parts) => parts.iter().for_each(|part| {
210            if let InterpPart::Hole(hole) = part {
211                walk_expr(hole, out);
212            }
213        }),
214        // Leaves carry no foldable children.
215        ExprKind::IntLit(_)
216        | ExprKind::FloatLit { .. }
217        | ExprKind::DurationLit { .. }
218        | ExprKind::StrLit(_)
219        | ExprKind::BoolLit(_)
220        | ExprKind::Ident(_)
221        | ExprKind::None
222        | ExprKind::UnitLit => {}
223    }
224}
225
226/// `textDocument/foldingRange` — the structural multi-line constructs plus
227/// multi-line comment runs. A range is emitted only when it spans more than
228/// one line (LSP folds ≥2 lines); duplicate `(start, end)` line pairs (a decl
229/// and its body sharing both lines) collapse to one.
230pub fn folding_ranges(source: &str) -> Vec<FoldingRange> {
231    let mut out = Vec::new();
232    let mut seen: HashSet<(u32, u32)> = HashSet::new();
233    for (span, foldable) in collect(source) {
234        if !foldable {
235            continue;
236        }
237        let start = offset_to_position(source, span.start).line;
238        let end = offset_to_position(source, span.end).line;
239        if end > start && seen.insert((start, end)) {
240            out.push(fold(start, end, None));
241        }
242    }
243    out.extend(comment_folds(source));
244    out
245}
246
247/// Multi-line runs of consecutive `--` line comments → `Comment` folds. Spans
248/// come from the lexer's `Comment` tokens (the trivia table keeps only bodies),
249/// grouped while each comment sits on the line immediately after the previous.
250fn comment_folds(source: &str) -> Vec<FoldingRange> {
251    let Ok(tokens) = tokenize(source) else {
252        return Vec::new();
253    };
254    let comments: Vec<Span> = tokens
255        .iter()
256        .filter(|t| t.kind == TokenKind::Comment)
257        .map(|t| t.span)
258        .collect();
259    let mut out = Vec::new();
260    let mut i = 0;
261    while i < comments.len() {
262        let start = offset_to_position(source, comments[i].start).line;
263        let mut end = offset_to_position(source, comments[i].end).line;
264        let mut j = i;
265        while j + 1 < comments.len() {
266            let next = offset_to_position(source, comments[j + 1].start).line;
267            if next == end + 1 {
268                j += 1;
269                end = offset_to_position(source, comments[j].end).line;
270            } else {
271                break;
272            }
273        }
274        if end > start {
275            out.push(fold(start, end, Some(FoldingRangeKind::Comment)));
276        }
277        i = j + 1;
278    }
279    out
280}
281
282fn fold(start_line: u32, end_line: u32, kind: Option<FoldingRangeKind>) -> FoldingRange {
283    FoldingRange {
284        start_line,
285        start_character: None,
286        end_line,
287        end_character: None,
288        kind,
289        collapsed_text: None,
290    }
291}
292
293/// `textDocument/selectionRange` — for each position, the chain of enclosing
294/// AST node ranges, innermost first (each `.parent` widens outward to the
295/// file). Falls back to an empty range at the cursor when no node contains it
296/// (e.g. trailing whitespace) or the file doesn't parse.
297pub fn selection_ranges(source: &str, positions: &[Position]) -> Vec<SelectionRange> {
298    let nodes = collect(source);
299    positions
300        .iter()
301        .map(|pos| selection_at(source, &nodes, *pos))
302        .collect()
303}
304
305fn selection_at(source: &str, nodes: &[(Span, bool)], pos: Position) -> SelectionRange {
306    let empty = SelectionRange {
307        range: Range::new(pos, pos),
308        parent: None,
309    };
310    let Some(offset) = position_to_offset(source, pos) else {
311        return empty;
312    };
313    // Spans containing the offset, de-duplicated, smallest first.
314    let mut spans: Vec<Span> = nodes
315        .iter()
316        .map(|(s, _)| *s)
317        .filter(|s| s.start <= offset && offset <= s.end)
318        .collect();
319    spans.sort_by_key(|s| (s.start, s.end));
320    spans.dedup();
321    spans.sort_by_key(|s| s.end - s.start);
322    // Build outermost → innermost so each node's `parent` is the next-larger.
323    let mut chain: Option<Box<SelectionRange>> = None;
324    for span in spans.into_iter().rev() {
325        chain = Some(Box::new(SelectionRange {
326            range: span_to_range(source, span),
327            parent: chain,
328        }));
329    }
330    chain.map(|b| *b).unwrap_or(empty)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    const SRC: &str = concat!(
338        "context shop\n",
339        "\n",
340        "-- a comment\n",
341        "-- second line\n",
342        "\n",
343        "type Money = {\n",
344        "  cents: Int,\n",
345        "  currency: String,\n",
346        "}\n",
347        "\n",
348        "fn total(m: Money) -> Int {\n",
349        "  if m.cents > 0 {\n",
350        "    m.cents\n",
351        "  } else {\n",
352        "    0\n",
353        "  }\n",
354        "}\n",
355    );
356
357    /// The 0-based line a substring first appears on.
358    fn line_of(needle: &str) -> u32 {
359        let off = SRC.find(needle).expect("substring present");
360        offset_to_position(SRC, off).line
361    }
362
363    #[test]
364    fn folds_structural_constructs_and_comment_runs_omitting_single_lines() {
365        let folds = folding_ranges(SRC);
366
367        // Every fold spans more than one line.
368        assert!(folds.iter().all(|f| f.end_line > f.start_line));
369
370        // The two-line comment run folds as a Comment.
371        let comment = folds
372            .iter()
373            .find(|f| f.kind == Some(FoldingRangeKind::Comment))
374            .expect("comment run folds");
375        assert_eq!((comment.start_line, comment.end_line), (2, 3));
376
377        // The record body folds (start at the `type` line, end at its `}`).
378        assert!(
379            folds
380                .iter()
381                .any(|f| f.start_line == line_of("type Money") && f.end_line == line_of("}\n\nfn")),
382            "record body folds"
383        );
384
385        // The `if` folds (a structural Region); the single-line then/else tail
386        // expression (`m.cents` alone, line 12) does not start a fold.
387        assert!(
388            folds
389                .iter()
390                .any(|f| f.start_line == line_of("if m.cents") && f.kind.is_none()),
391            "if folds"
392        );
393        let tail_line = line_of("    m.cents"); // the indented tail, line 12
394        assert!(
395            !folds.iter().any(|f| f.start_line == tail_line),
396            "single-line tail expression is not folded"
397        );
398    }
399
400    #[test]
401    fn selection_chain_widens_from_the_cursor_to_the_file() {
402        // Cursor on `cents` of the then-block tail `m.cents` (line 12).
403        let off = SRC.find("    m.cents").unwrap() + 6; // onto `cents`
404        let pos = offset_to_position(SRC, off);
405        let ranges = selection_ranges(SRC, &[pos]);
406        assert_eq!(ranges.len(), 1);
407
408        // Walk the parent chain; ranges must strictly widen and stay nested.
409        let mut levels = 0;
410        let mut cur = Some(&ranges[0]);
411        let mut prev: Option<&Range> = None;
412        let mut outermost = ranges[0].range;
413        while let Some(node) = cur {
414            if let Some(p) = prev {
415                // Each parent contains the previous (child) range.
416                assert!(node.range.start <= p.start && node.range.end >= p.end);
417                assert!(node.range != *p, "ranges strictly widen");
418            }
419            outermost = node.range;
420            prev = Some(&node.range);
421            levels += 1;
422            cur = node.parent.as_deref();
423        }
424        assert!(levels >= 4, "cursor → … → context is several levels");
425        // Outermost is the whole context (starts on line 0).
426        assert_eq!(outermost.start.line, 0);
427    }
428
429    #[test]
430    fn partial_parse_still_folds_what_parsed() {
431        // A malformed trailing item must not panic and must still fold the
432        // valid context + type above it.
433        let src = "context shop\n\ntype Money = {\n  cents: Int,\n}\n\nfn broken(";
434        let folds = folding_ranges(src);
435        assert!(
436            folds.iter().any(|f| f.start_line == 2),
437            "the type still folds"
438        );
439        // Selection at the top of the file is well-formed too.
440        let sel = selection_ranges(src, &[Position::new(3, 4)]);
441        assert_eq!(sel.len(), 1);
442    }
443}