Skip to main content

lattice_cells/
context.rs

1//! Structural context scopes and the resolver that turns them into the
2//! header lines a pane pins above its text.
3//!
4//! A [`ContextScope`] is a structural range plus the line span that names
5//! it — `impl Renderer for TuiRenderer {` naming the impl block, `fn
6//! paint(…) {` naming the function. It is a **pure function of the parse
7//! tree**: no viewport, no cursor, no fold state, no user option. That is
8//! what makes it correct to compute once per parse (off-thread, in a
9//! plugin) and resolve against any anchor line afterwards.
10//!
11//! [`resolve_context`] is the resolution half, and it lives here rather
12//! than in either renderer for the same reason `IndentBlock::paints_on`
13//! does: one implementation means a bug is a failing test here instead of
14//! a wrong strip in one peer and not the other. The host calls it when it
15//! publishes pane inputs, so the scroll model reserves exactly the rows
16//! that get painted.
17//!
18//! Design anchor: `docs/dev/architecture/treesitter-context.md`.
19
20/// One structural scope: the range it spans, and the lines that name it.
21///
22/// `header_start ..= header_end` is normally a single line; it spans
23/// several when a signature wraps. Both ends are inclusive, 0-based
24/// source lines.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct ContextScope {
27    pub scope_start: u32,
28    pub scope_end: u32,
29    pub header_start: u32,
30    pub header_end: u32,
31}
32
33/// Which end of the stack to drop when there are more context rows than
34/// the budget allows.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub enum TrimScope {
37    /// Drop the outermost scopes first. The default: the innermost scope
38    /// is the one you are actually in, so it is the last to go.
39    #[default]
40    Outer,
41    /// Drop the innermost scopes first.
42    Inner,
43}
44
45/// The knobs [`resolve_context`] reads. Mirrors the `context.*` options
46/// the plugin registers; the host resolves them per buffer and passes
47/// them in, so this stays a pure function.
48#[derive(Clone, Copy, Debug)]
49pub struct ContextOptions {
50    /// Maximum context **rows** (not scopes — a multi-line header spends
51    /// more than one). **`0` means unlimited**, and is the default.
52    ///
53    /// The principled bound is [`Self::max_viewport_fraction`], not this: a
54    /// fraction scales with the pane, so a tall window shows deep nesting and
55    /// a short split shows little, which is what a reader wants in both. A row
56    /// count cannot do that — tuned for one pane size it is wrong at every
57    /// other.
58    ///
59    /// Truncating also loses the WRONG end first. Depth is not noise: the
60    /// outermost scope (`impl Foo`) is the most stable and often the most
61    /// valuable line in the strip, and a hard cap of 3 discards it exactly
62    /// when nesting is deep enough to need it. So the cap is off by default
63    /// and available as a preference for anyone who wants a fixed ceiling.
64    pub max_lines: u32,
65    /// Which end to drop when over budget.
66    pub trim: TrimScope,
67    /// Maximum rows a single scope's header may contribute.
68    pub multiline_threshold: u32,
69    /// Percent of the pane height the whole sticky strip may occupy.
70    pub max_viewport_fraction: u32,
71    /// The pane's height in rows, for the fraction guard.
72    pub viewport_height: u32,
73    /// First source line the pane is showing. A scope whose header is at
74    /// or below this line is still on screen, so pinning it would spend a
75    /// row duplicating a visible line — the resolver drops it.
76    pub viewport_top: u32,
77    /// Rows the headerline already occupies. Context stacks *under* it
78    /// and never displaces it, so those rows come out of the same
79    /// viewport budget.
80    pub reserved_rows: u32,
81}
82
83impl Default for ContextOptions {
84    fn default() -> Self {
85        Self {
86            max_lines: 0,
87            trim: TrimScope::Outer,
88            multiline_threshold: 1,
89            max_viewport_fraction: 33,
90            viewport_height: 40,
91            viewport_top: 0,
92            reserved_rows: 0,
93        }
94    }
95}
96
97/// Resolve the context header lines for `anchor`.
98///
99/// Returns source line numbers, outermost scope first, ready for the
100/// cells worker to build rows from. Empty when nothing encloses the
101/// anchor or the budget leaves no room.
102pub fn resolve_context(scopes: &[ContextScope], anchor: u32, opts: &ContextOptions) -> Vec<u32> {
103    let mut enclosing: Vec<&ContextScope> = scopes
104        .iter()
105        .filter(|s| s.scope_start <= anchor && anchor <= s.scope_end)
106        // Only what actually scrolled away: a header still on screen
107        // would cost a row to duplicate a line the user can already read.
108        .filter(|s| s.header_end < opts.viewport_top)
109        .collect();
110    enclosing.sort_by_key(|s| s.scope_start);
111
112    // Expand each scope to the header rows it contributes, capped by
113    // `multiline_threshold`. A wrapped signature names its scope across
114    // several lines and spends several rows.
115    let cap = opts.multiline_threshold.max(1);
116    let groups: Vec<Vec<u32>> = enclosing
117        .iter()
118        .map(|s| {
119            let last = s.header_end.min(s.header_start.saturating_add(cap - 1));
120            (s.header_start..=last).collect()
121        })
122        .collect();
123
124    // Trim to the ROW budget from whichever end `trim` names. A group
125    // that does not fit stops the walk rather than being skipped over:
126    // keeping a further-out scope after dropping a nearer one would
127    // render a stack with a hole in it, which reads as simply wrong.
128    // The budget is the tighter of `max_lines` and this pane's share of
129    // the viewport, minus whatever the headerline already holds — context
130    // stacks under the headerline and never displaces it.
131    let share = (opts.viewport_height as u64 * opts.max_viewport_fraction as u64 / 100) as u32;
132    let viewport_budget = share.saturating_sub(opts.reserved_rows);
133    // `max_lines == 0` is unlimited: the viewport fraction alone bounds the
134    // strip, which is the bound that actually scales with the pane. A non-zero
135    // value is an additional user-chosen ceiling.
136    let budget = if opts.max_lines == 0 {
137        viewport_budget
138    } else {
139        opts.max_lines.min(viewport_budget)
140    } as usize;
141    let mut rows: Vec<u32> = Vec::with_capacity(budget);
142    match opts.trim {
143        // Keep the innermost — walk inward-out, building the strip
144        // backwards, so the survivors are the scopes you are actually in.
145        TrimScope::Outer => {
146            for group in groups.iter().rev() {
147                if rows.len() + group.len() > budget {
148                    break;
149                }
150                rows.splice(0..0, group.iter().copied());
151            }
152        }
153        TrimScope::Inner => {
154            for group in &groups {
155                if rows.len() + group.len() > budget {
156                    break;
157                }
158                rows.extend(group.iter().copied());
159            }
160        }
161    }
162    rows
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// A scope at `start..=end` whose header is its first line.
170    fn scope(start: u32, end: u32) -> ContextScope {
171        ContextScope {
172            scope_start: start,
173            scope_end: end,
174            header_start: start,
175            header_end: start,
176        }
177    }
178
179    /// The whole point of the strip is to show what scrolled away. A
180    /// scope whose header is still on screen must NOT be pinned — doing
181    /// so spends a row duplicating a line the user can already read, and
182    /// on a short pane that is a row the innermost scope needed.
183    ///
184    /// This is why the resolver needs `viewport_top` and not just the
185    /// anchor: with the cursor at 30 and the impl header at 10, whether
186    /// line 10 is visible depends entirely on where the view starts.
187    #[test]
188    fn a_scope_whose_header_is_still_visible_is_not_pinned() {
189        let scopes = [scope(10, 99), scope(20, 40)];
190
191        // View starts at 5: the impl header (10) and the fn header (20)
192        // are both on screen, so there is nothing to pin.
193        let opts = ContextOptions {
194            viewport_top: 5,
195            ..ContextOptions::default()
196        };
197        assert_eq!(
198            resolve_context(&scopes, 30, &opts),
199            Vec::<u32>::new(),
200            "both headers are visible — pinning either duplicates a line \
201             already on screen"
202        );
203
204        // View starts at 15 — between the two headers. The impl header
205        // (10) has scrolled off; the fn header (20) is still on screen.
206        let opts = ContextOptions {
207            viewport_top: 15,
208            ..ContextOptions::default()
209        };
210        assert_eq!(
211            resolve_context(&scopes, 30, &opts),
212            vec![10],
213            "only the header that actually scrolled away is pinned"
214        );
215    }
216
217    /// `max_lines` is a budget in ROWS, and `trim_scope` picks which end
218    /// loses. Default `Outer`: the innermost scope is the one you are
219    /// actually in, so it survives longest.
220    /// The default is unlimited depth, bounded only by the viewport share.
221    /// Depth is not noise: the outermost scope is the most stable line in the
222    /// strip, and a hard cap of 3 would discard it exactly when nesting is
223    /// deep enough to need it.
224    /// Regression probe: a realistic full-height pane must still yield rows
225    /// with the new `max_lines = 0` default. If the viewport share ever
226    /// computes to zero for an ordinary pane, the strip silently never shows.
227    #[test]
228    fn a_realistic_pane_still_yields_rows_with_the_unlimited_default() {
229        let scopes = [scope(10, 400), scope(100, 300)];
230        for viewport_height in [10u32, 24, 40, 50, 80] {
231            let opts = ContextOptions {
232                viewport_top: 150,
233                viewport_height,
234                ..ContextOptions::default()
235            };
236            let rows = resolve_context(&scopes, 200, &opts);
237            assert!(
238                !rows.is_empty(),
239                "height {viewport_height}: an ordinary pane must show context; \
240                 got nothing, which is the strip silently vanishing"
241            );
242        }
243    }
244
245    #[test]
246    fn depth_is_unlimited_by_default_and_bounded_by_the_pane() {
247        // Six nested scopes around the cursor.
248        let scopes: Vec<ContextScope> = (0..6).map(|i| scope(i * 2, 100 - i)).collect();
249        let roomy = ContextOptions {
250            viewport_top: 40,
251            viewport_height: 60, // 60 x 33% = 19 rows available
252            ..ContextOptions::default()
253        };
254        assert_eq!(
255            resolve_context(&scopes, 50, &roomy).len(),
256            6,
257            "all six pin — `max_lines` defaults to 0 (unlimited)"
258        );
259
260        // The same file in a short split: the fraction, not a row count, is
261        // what trims — and it trims from the outermost end.
262        let cramped = ContextOptions {
263            viewport_height: 9, // 9 x 33% = 2 rows
264            ..roomy
265        };
266        assert_eq!(
267            resolve_context(&scopes, 50, &cramped).len(),
268            2,
269            "the viewport share scales with the pane where a row count cannot"
270        );
271
272        // An explicit cap still applies when the user wants a fixed ceiling.
273        let capped = ContextOptions {
274            max_lines: 3,
275            ..roomy
276        };
277        assert_eq!(resolve_context(&scopes, 50, &capped).len(), 3);
278    }
279
280    #[test]
281    fn over_budget_drops_the_end_trim_scope_names() {
282        // mod 5.., impl 10.., fn 20.., loop 25.. — four deep, cursor at 30.
283        let scopes = [scope(5, 99), scope(10, 90), scope(20, 40), scope(25, 35)];
284        let base = ContextOptions {
285            viewport_top: 28,
286            max_lines: 2,
287            ..ContextOptions::default()
288        };
289
290        assert_eq!(
291            resolve_context(&scopes, 30, &base),
292            vec![20, 25],
293            "trim outer keeps the innermost scopes — the ones you are in"
294        );
295
296        let inner = ContextOptions {
297            trim: TrimScope::Inner,
298            ..base
299        };
300        assert_eq!(
301            resolve_context(&scopes, 30, &inner),
302            vec![5, 10],
303            "trim inner keeps the outermost, and STILL emits them \
304             outermost-first — trimming picks which scopes survive, never \
305             the order they paint in"
306        );
307    }
308
309    /// A wrapped signature names its scope across several lines.
310    /// `multiline_threshold` caps how many of them a single scope may
311    /// spend, and those rows come out of the SAME `max_lines` budget —
312    /// which is the whole reason the budget counts rows and not scopes.
313    #[test]
314    fn a_multiline_header_spends_rows_from_the_shared_budget() {
315        let outer = scope(10, 99);
316        // `fn long_signature(` at 20, wrapping through 22.
317        let inner = ContextScope {
318            scope_start: 20,
319            scope_end: 40,
320            header_start: 20,
321            header_end: 22,
322        };
323        let scopes = [outer, inner];
324        let base = ContextOptions {
325            viewport_top: 28,
326            max_lines: 3,
327            ..ContextOptions::default()
328        };
329
330        assert_eq!(
331            resolve_context(&scopes, 30, &base),
332            vec![10, 20],
333            "threshold 1 (the default) shows only the signature's first \
334             line, so both scopes fit in the budget"
335        );
336
337        let full = ContextOptions {
338            multiline_threshold: 3,
339            ..base
340        };
341        assert_eq!(
342            resolve_context(&scopes, 30, &full),
343            vec![20, 21, 22],
344            "the full 3-line signature spends the whole budget, so the \
345             outer scope is trimmed — rows, not scopes"
346        );
347    }
348
349    /// A sticky strip that eats the pane is worse than no strip. The
350    /// guard is a FRACTION rather than a row count so it scales with the
351    /// split — a 6-row pane and a 60-row pane want very different limits
352    /// and neither wants to be told a constant.
353    #[test]
354    fn the_strip_never_outgrows_its_share_of_the_pane() {
355        let scopes = [scope(5, 99), scope(10, 90), scope(20, 40)];
356        let at = |viewport_height| ContextOptions {
357            viewport_top: 28,
358            viewport_height,
359            ..ContextOptions::default()
360        };
361
362        // Roomy: `max_lines` (3) is what binds, not the fraction (33).
363        assert_eq!(resolve_context(&scopes, 30, &at(100)), vec![5, 10, 20]);
364
365        // 10 rows × 33% = 3 — the two limits coincide.
366        assert_eq!(resolve_context(&scopes, 30, &at(10)), vec![5, 10, 20]);
367
368        // 3 rows × 33% = 0. A pane this short has no room to spare, and
369        // showing nothing is the honest answer.
370        assert_eq!(
371            resolve_context(&scopes, 30, &at(3)),
372            Vec::<u32>::new(),
373            "a pane too short for even one context row shows none"
374        );
375    }
376
377    /// The headerline is never displaced — context stacks under it — so
378    /// the rows it already occupies come out of the same viewport share.
379    #[test]
380    fn the_headerline_rows_come_out_of_the_same_budget() {
381        let scopes = [scope(5, 99), scope(10, 90), scope(20, 40)];
382        let opts = ContextOptions {
383            viewport_top: 28,
384            viewport_height: 12, // 12 × 33% = 3 rows for the whole strip
385            reserved_rows: 2,    // headerline already took two of them
386            ..ContextOptions::default()
387        };
388
389        assert_eq!(
390            resolve_context(&scopes, 30, &opts),
391            vec![20],
392            "one row left after the headerline, and it goes to the \
393             innermost scope"
394        );
395    }
396
397    /// Standing ON a scope's header line still counts as being inside
398    /// that scope — which is what makes `[u` terminate. The jump lands
399    /// the cursor on the header, and the next `[u` looks for a header
400    /// STRICTLY above, so it finds the parent instead of sticking.
401    #[test]
402    fn a_scope_encloses_its_own_header_line() {
403        let scopes = [scope(10, 99), scope(20, 40)];
404
405        let scrolled_past = ContextOptions {
406            viewport_top: 22,
407            ..ContextOptions::default()
408        };
409        assert_eq!(
410            resolve_context(&scopes, 20, &scrolled_past),
411            vec![10, 20],
412            "the cursor sits on the fn header; both headers are above the \
413             view, so both pin"
414        );
415
416        let still_visible = ContextOptions {
417            viewport_top: 15,
418            ..ContextOptions::default()
419        };
420        assert_eq!(
421            resolve_context(&scopes, 20, &still_visible),
422            vec![10],
423            "the fn header is the cursor's own line and plainly on screen"
424        );
425    }
426
427    // ── Degenerate input. These passed on arrival; they are regression
428    // guards for a resolver that is about to be called at keystroke rate
429    // from the host, where a panic is a crashed editor and a wrong order
430    // is a strip that reads backwards.
431
432    #[test]
433    fn no_scopes_and_no_enclosing_scopes_resolve_to_nothing() {
434        let opts = ContextOptions {
435            viewport_top: 50,
436            ..ContextOptions::default()
437        };
438        assert_eq!(resolve_context(&[], 30, &opts), Vec::<u32>::new());
439        // Scopes exist but none contain the anchor.
440        let elsewhere = [scope(60, 80)];
441        assert_eq!(
442            resolve_context(&elsewhere, 30, &opts),
443            Vec::<u32>::new(),
444            "a scope the cursor is not inside contributes nothing"
445        );
446    }
447
448    #[test]
449    fn scope_order_comes_from_the_data_not_the_input_ordering() {
450        // A query returns captures in tree-walk order, which is not
451        // guaranteed to be outermost-first.
452        let jumbled = [scope(20, 40), scope(5, 99), scope(10, 90)];
453        let opts = ContextOptions {
454            viewport_top: 28,
455            ..ContextOptions::default()
456        };
457        assert_eq!(resolve_context(&jumbled, 30, &opts), vec![5, 10, 20]);
458    }
459
460    #[test]
461    fn overlapping_and_malformed_scopes_do_not_panic() {
462        let opts = ContextOptions {
463            viewport_top: 50,
464            ..ContextOptions::default()
465        };
466        // Partially overlapping without nesting — impossible from a real
467        // tree, reachable from a hand-written query.
468        let overlapping = [scope(10, 50), scope(30, 70)];
469        assert_eq!(resolve_context(&overlapping, 40, &opts), vec![10, 30]);
470
471        // A header span that ends before it starts.
472        let inverted = [ContextScope {
473            scope_start: 10,
474            scope_end: 60,
475            header_start: 20,
476            header_end: 15,
477        }];
478        assert_eq!(
479            resolve_context(&inverted, 40, &opts),
480            Vec::<u32>::new(),
481            "an inverted header span contributes no rows rather than \
482             panicking on the range"
483        );
484    }
485
486    #[test]
487    fn nested_scopes_resolve_outermost_first() {
488        // impl at 10..=99, fn at 20..=40, cursor at 30.
489        let scopes = [scope(10, 99), scope(20, 40)];
490        // View starts below both headers, so both are pinnable and the
491        // test is about ORDER and nothing else.
492        let opts = ContextOptions {
493            viewport_top: 25,
494            ..ContextOptions::default()
495        };
496
497        let rows = resolve_context(&scopes, 30, &opts);
498
499        assert_eq!(
500            rows,
501            vec![10, 20],
502            "outermost first — the row nearest the text must be the \
503             nearest enclosing scope, so the strip reads as a continuation \
504             of the code"
505        );
506    }
507}