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}