S1 follow-up — scope the per-edit highlight queries (typing perf, Q#S6)

Scrolling became fast after S1 but typing stayed slow: scrolling
doesn't bump the CRDT generation, so the daemon's StyleGate caches and
no query runs — but every keystroke bumps the generation and forced
TWO whole-file tree-sitter passes on the daemon, which S1 deferred as
Q#S6. With the GPU now O(visible), this was the remaining O(file)
per-keystroke cost.

1. StyleSpans query scoped to the viewport. New
   `compute_highlight_spans_in_range` sets `QueryCursor::set_byte_range`
   so the capture walk is proportional to the visible range, not the
   whole tree; `scoped_style_spans` passes the declared viewport. The
   StyleGate still recomputes on the edit's generation bump (M11.7
   resync), but that recompute is now O(visible).

2. FileStyleSummary (the minimap — inherently a whole-file pass)
   debounced to reparse-completion: skip the recompute while a reparse
   is in flight (`pending_edit_count() > 0`). During continuous typing
   the whole-file pass runs at reparse rate, not keystroke rate;
   when typing settles and the parse lands, it recomputes once.

Together these drop the daemon's per-keystroke cost from two whole-file
tree-sitter passes to one viewport-scoped pass (+ an amortized
whole-file summary). Only the semantic (pmacs-gpu) path is affected;
the grid/TUI path doesn't use this producer.

Gates green: fmt; clippy --all-targets --workspace -D warnings (default
+ crdt); pmacs lib 1334; syntax 6; semantic_render 28;
m11_5_semantic_acceptance 2; m4_acceptance 88.

Awaiting visual confirmation: typing in a large file is now responsive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-30 10:47:43 -04:00
parent ecb0a862fc
commit 6bebd770e1
2 changed files with 39 additions and 1 deletions

View File

@ -356,6 +356,18 @@ impl SemanticRenderState {
buffer_id: BufferId,
generation: u64,
) -> Option<InstanceMessage> {
// The summary is a *whole-file* tree-sitter pass (the minimap
// needs every line). Recomputing it on every edit's generation
// bump was a per-keystroke O(file) cost — a major part of the
// typing slowness. For a grammar-backed buffer, debounce it to
// reparse-completion: skip while a reparse is in flight
// (`current_fresh` is `None`), so during continuous typing the
// whole-file pass runs at reparse rate, not keystroke rate.
if let Some(handle) = state.syntax_registry.view(buffer_id)
&& handle.pending_edit_count() > 0
{
return None;
}
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
return None;
}
@ -871,7 +883,15 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<StyleSp
}
let capture_names = query.capture_names();
let highlights = crate::syntax::compute_highlight_spans(&query, &bundle);
// Scope the tree-sitter capture walk to the visible byte range so
// re-styling on each edit is O(visible), not O(file) — the typing
// bottleneck on large files (framing Q#S6). Captures whose nodes
// intersect the range are returned, then clipped exactly below.
let highlights = crate::syntax::compute_highlight_spans_in_range(
&query,
&bundle,
Some(vis_start as usize..vis_end as usize),
);
let mut out = Vec::new();
for hs in highlights {
let s = u64::from(hs.start_byte).max(vis_start);

View File

@ -691,9 +691,27 @@ pub struct HighlightSpan {
pub fn compute_highlight_spans(
query: &tree_sitter::Query,
bundle: &ParseTreeBundle,
) -> Vec<HighlightSpan> {
compute_highlight_spans_in_range(query, bundle, None)
}
/// Like [`compute_highlight_spans`], but restricts the query to nodes
/// intersecting `byte_range` when `Some`. tree-sitter's
/// `QueryCursor::set_byte_range` makes the capture walk proportional to
/// the range, not the whole tree — the semantic producer passes the
/// declared viewport so styling a screenful of a huge file is
/// O(visible), not O(file) (the per-edit typing cost; framing Q#S6).
#[must_use]
pub fn compute_highlight_spans_in_range(
query: &tree_sitter::Query,
bundle: &ParseTreeBundle,
byte_range: Option<std::ops::Range<usize>>,
) -> Vec<HighlightSpan> {
let mut spans = Vec::new();
let mut cursor = tree_sitter::QueryCursor::new();
if let Some(range) = byte_range {
cursor.set_byte_range(range);
}
let source: &[u8] = bundle.source.as_ref();
let root = bundle.tree.root_node();
let mut iter = cursor.captures(query, root, source);