fix(injections): PR #122 round 2 — sibling precedence, observable cap, docs

Two follow-ups + doc cleanup.

[P2] Same-depth sibling precedence was reversed. The wire priority was
(depth, capture_order), omitting the layer ordinal, so two overlapping
spans from different sibling layers tied — and the active-set insert
then applied the later one first, making the earlier sibling win, the
opposite of the grid's layer-by-layer paint. Priority is now
(layer_index, capture_order): layer_index is the depth-ascending
position in bundle.layers, so a deeper layer AND a later same-depth
sibling both override, matching the grid exactly. New
flatten_same_depth_sibling_later_layer_wins pins it.

[P2] Cap surfacing had no end-to-end test. Added
injection_cap_surfaced_once_and_rearms_via_lua, which drives the real
Lua settle path (syntax.lua tick -> _injection_capped -> pmacs.error)
and asserts surfaced-once, suppressed-on-unchanged-reparse, and
re-armed-after-dropping-below-then-exceeding-the-cap.

Docs:
- Q#IJ6 now states the accurate bound O(n log n + Sum active) for the
  event sweep, not O(boundaries).
- The full-buffer perf test is renamed/narrowed to guard the FLATTENER
  regression; the summary's per-line dominant-style tally is a separate
  pre-existing O(lines x spans) loop, not claimed linear.
- Framing #9 now matches the test: it drives spans_from_segments (the
  extracted replace_style_spans transform) + source_color_at, not a live
  State render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
This commit is contained in:
Levi Neuwirth 2026-07-15 12:00:33 +01:00
parent 79d75a29e0
commit 325dcd553f
3 changed files with 212 additions and 33 deletions

View File

@ -215,17 +215,20 @@ Because the wire re-sorts by start (`main.rs:4117`/`4130`), producer
order cannot carry overlap precedence. So overlaps are resolved **in the
producer, before the wire**:
- **`scoped_style_spans` flattens** all layers over the viewport into
**disjoint effective spans** — a sweep-line that, at each byte, takes
the deepest layer covering it, and within a layer the existing
wider-first "narrower overrides" rule. Output is disjoint runs of a
single folded style. The viewport already bounds the sweep, so this is
O(boundaries) in the visible range. Positional re-sorting downstream is
then a no-op on precedence, and the disjoint output aligns with the
model's existing style-tile disjointness invariant. (Per-byte effective
style is unchanged from today for single-layer buffers; only the *shape*
goes overlapping→disjoint, so existing `StyleSpans` shape assertions are
updated to match.)
- **`scoped_style_spans` flattens** all layers into **disjoint effective
spans** — an ordered active-set **event sweep**: activate a span at its
start boundary, expire it at its end, and at each interval fold the
active set (deeper layer / later same-depth sibling / narrower capture
wins, keyed by `(layer_index, capture_order)`). Cost is
**O(n·log n + Σ active)** in the span count — linear in practice, since
the active set is bounded by overlap depth, not the total span count.
This bound matters because the file-style summary runs the flattener
over the **whole buffer**, not just the viewport. Positional re-sorting
downstream is then a no-op on precedence, and the disjoint output aligns
with the model's existing style-tile disjointness invariant. (Per-byte
effective style is unchanged from today for single-layer buffers; only
the *shape* goes overlapping→disjoint, so existing `StyleSpans` shape
assertions are updated to match.)
- **The GPU `source_color_at` fold fix is kept** (fold all covering spans
in order, matching `effective_style_at`) — defense-in-depth and a
contract alignment, correct even for any residual same-start overlap.
@ -233,9 +236,12 @@ producer, before the wire**:
it paints layer-by-layer in depth order (later overrides via the cell
merge), which is already correct.
Acceptance test #9 drives the **full message-application path**
(`replace_style_spans` → render → `source_color_at`) with an overlapping
parent-red / child-green case, not a direct `source_color_at` call.
Acceptance test #9 drives the real full-frame message transform
(`spans_from_segments`, extracted from `replace_style_spans`) then
`source_color_at`, with an overlapping parent-red / child-green case —
exercising the start-sort + fold, not a hand-rolled sort. (A live-`State`
render pass needs a GPU device and is out of unit-test scope; the extracted
transform is the code that matters here.)
### Q#IJ7 — Both producers walk layers; Policy A unchanged at buffer scope
@ -329,15 +335,20 @@ half-styled frame. `grammar_style_parse_not_ready` unchanged.
falsify multi-range.)
7. `recursion_bounds_terminate` — rust macro self-injection terminates
within the depth bound; `injection_layer_cap_surfaces_and_preserves_root`
drives >4096 fences and asserts the surfaced `injection_capped` flag,
the bounded count, and an intact root; a failing child drops only itself
(Q#IJ3).
drives >4096 fences (bundle-flag level), and
`injection_cap_surfaced_once_and_rearms_via_lua` (round 2) drives the
**observable** Lua settle path: `pmacs.error` once, suppressed on an
unchanged re-parse, and re-armed after dropping below the cap and
exceeding it again. A failing child drops only itself (Q#IJ3).
8. `wire_producer_emits_disjoint_child_spans_in_fence``scoped_style_spans`
over a ` ```rust ` fence emits disjoint `StyleSpan`s covering a rust
keyword **inside** the fence. **Bite-verified** vs the single-layer
producer. Plus `full_buffer_summary_scales_on_large_grammar_file`
(round 1): the whole-buffer summary path stays ~linear under the event
sweep (a quadratic flatten regresses it).
producer. `flatten_same_depth_sibling_later_layer_wins` (round 2): a
later same-depth sibling layer wins the fold, matching grid paint order.
`full_buffer_summary_flatten_scales_on_large_grammar_file` (round 1):
the whole-buffer **flatten** stays ~linear under the event sweep (a
quadratic flatten regresses it; the summary's per-line tally is a
separate pre-existing loop).
9. `source_color_folds_overlapping_child_over_parent` — parent-red /
child-green overlap driven through the real `spans_from_segments`
(`replace_style_spans` body): the child (green) wins the fold.

View File

@ -1766,7 +1766,7 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<StyleSp
// (framing Q#IJ6). Fully-default styles are dropped (they fold as
// identity anyway).
let mut styled: Vec<StyledLayerSpan> = Vec::new();
for layer in &bundle.layers {
for (layer_idx, layer) in bundle.layers.iter().enumerate() {
let Some(query) = layer.highlight_query.as_ref() else {
continue;
};
@ -1794,7 +1794,7 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<StyleSp
start: s,
end: e,
style,
priority: (layer.depth, order as u32),
priority: (layer_idx as u32, order as u32),
});
}
}
@ -1802,14 +1802,18 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<StyleSp
}
/// One styled span from a single injection layer, tagged with a priority
/// used to resolve overlaps: `(depth, order)`, higher wins. `order` is the
/// index in the layer's wider-first list, so a narrower capture (later)
/// overrides a wider one within the same layer.
/// used to resolve overlaps: `(layer_index, capture_order)`, higher wins.
/// `layer_index` is the position in `bundle.layers` — depth-ascending, so a
/// deeper layer AND a later same-depth sibling both sort after (and thus
/// override) an earlier one, exactly matching the grid's shallow-to-deep,
/// layer-by-layer paint order. `capture_order` is the index in the layer's
/// wider-first list, so within a layer a narrower capture overrides a wider
/// one. The pair is unique per span, so the fold order is total (no ties).
struct StyledLayerSpan {
start: u64,
end: u64,
style: Style,
priority: (u16, u32),
priority: (u32, u32),
}
/// Flatten possibly-overlapping per-layer styled spans into **disjoint**
@ -3489,11 +3493,14 @@ mod tests {
}
#[test]
fn full_buffer_summary_scales_on_large_grammar_file() {
// Perf gate (round-2 finding 1): the file-style summary runs the
// flattener over the WHOLE buffer, not the viewport. The ordered
// active-set sweep must keep that roughly linear — the pre-sweep
// O(spans^2) flatten stalled a large grammar-backed file here.
fn full_buffer_summary_flatten_scales_on_large_grammar_file() {
// Perf gate (round-1 finding 1): the file-style summary runs the
// FLATTENER over the WHOLE buffer, not the viewport. The ordered
// active-set sweep keeps the flatten O(n·log n + Σ active); the
// pre-sweep O(spans^2) flatten stalled a large grammar-backed file
// here. (This guards the *flattener* regression specifically — the
// summary's separate per-line dominant-style tally is a pre-existing
// O(lines × spans) loop, not addressed or claimed linear here.)
use std::fmt::Write as _;
let state = empty_state();
let bid = active_buffer(&state);
@ -3513,8 +3520,64 @@ mod tests {
assert!(!summary.is_empty(), "summary produced for a styled buffer");
assert!(
elapsed < std::time::Duration::from_secs(1),
"full-buffer summary took {elapsed:?}; the sweep must stay ~linear \
(a quadratic flatten regresses here)"
"full-buffer flatten took {elapsed:?}; the event sweep must stay \
~linear (a quadratic flatten regresses here)"
);
}
#[test]
fn flatten_same_depth_sibling_later_layer_wins() {
// Round-2 finding: two overlapping spans from different sibling
// layers must resolve to the LATER layer (higher layer_index),
// matching the grid's layer-by-layer paint order. The old
// `(depth, order)` priority tied same-depth siblings, and the
// active-set insert then reversed them — making the earlier sibling
// win, the opposite of the grid. Layer index in the priority fixes
// it.
use crate::cell::Color;
let red = Style {
fg: Color::Indexed(1),
..Style::default()
};
let green = Style {
fg: Color::Indexed(2),
..Style::default()
};
// Layer 0 span [0,10) red; layer 1 span [3,6) green (overlapping).
let styled = vec![
StyledLayerSpan {
start: 0,
end: 10,
style: red,
priority: (0, 0),
},
StyledLayerSpan {
start: 3,
end: 6,
style: green,
priority: (1, 0),
},
];
let out = flatten_layer_spans(&styled);
// Byte 4 (covered by both) folds to the later layer (green).
let covering = out
.iter()
.find(|s| s.range.start <= 4 && 4 < s.range.end)
.expect("byte 4 covered");
assert_eq!(
covering.style.fg,
Color::Indexed(2),
"the later sibling layer wins at the overlap"
);
// Byte 1 (layer 0 only) keeps the base layer's color.
let c1 = out
.iter()
.find(|s| s.range.start <= 1 && 1 < s.range.end)
.expect("byte 1 covered");
assert_eq!(
c1.style.fg,
Color::Indexed(1),
"a non-overlapping byte keeps the base layer color"
);
}

View File

@ -9,11 +9,13 @@
//! `docs/multi-language-injections-framing.md`.
use std::fmt::Write as _;
use std::sync::Arc;
use std::time::{Duration, Instant};
use pmacs::buffer::{Buffer, BufferId, EditOp};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::BufferIdLua;
use pmacs::rope::Range;
use pmacs::syntax::{self, ParseView, SyntaxRegistry};
/// Drive `tick_async` until `predicate` holds or a deadline passes.
@ -127,6 +129,109 @@ fn sync_parse_now_resolves_alias() {
);
}
/// Round-2 finding: the injection layer cap must be *observably* surfaced,
/// not merely flagged. Drives the real Lua settle path (`syntax.lua`'s tick
/// → `_injection_capped` → `pmacs.error`) and asserts the three behaviors:
/// surfaced once, suppressed on an unchanged re-parse, and re-armed after
/// the file drops below the cap and exceeds it again.
#[test]
fn injection_cap_surfaced_once_and_rearms_via_lua() {
let mut state = EditorState::new();
// Capture pmacs.error messages into a Lua global.
state
.lua_host
.lua()
.load("_CAP = {}\npmacs.error = function(msg) _CAP[#_CAP + 1] = tostring(msg) end")
.exec()
.expect("install error capture");
let capping: String = "```rust\nx\n```\n\n".repeat(4096 + 8);
let buf_id = state
.lua_host
.registry()
.borrow_mut()
.create_from_bytes("big.md".to_owned(), capping.as_bytes());
state
.lua_host
.lua()
.globals()
.set("BUF", BufferIdLua(buf_id))
.expect("bind BUF");
let dispatch = |state: &EditorState| {
state
.lua_host
.lua()
.load("pmacs.parse._dispatch(BUF, 'markdown')")
.exec()
.expect("dispatch");
};
let cap_count = |state: &EditorState| -> usize {
state.lua_host.lua().load("return #_CAP").eval().unwrap()
};
let current =
|state: &EditorState| state.syntax_registry.view(buf_id).and_then(|h| h.current());
let replace_all = |state: &EditorState, bytes: &[u8]| {
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
let buf = reg.get_mut(buf_id).expect("buffer");
let len = buf.len();
buf.apply_edit(EditOp::Replace {
range: Range::new(0, len),
bytes,
})
.expect("replace");
};
// 1) First settle → surfaced exactly once, message names the cap.
dispatch(&state);
pump_async(&mut state, |s| current(s).is_some());
assert_eq!(cap_count(&state), 1, "cap surfaced once on first settle");
let msg: String = state.lua_host.lua().load("return _CAP[1]").eval().unwrap();
assert!(
msg.contains("injection layer cap"),
"message names the cap: {msg}"
);
// 2) Re-dispatch with no change → suppressed (still once).
let b1 = current(&state).unwrap();
dispatch(&state);
pump_async(&mut state, |s| {
current(s).is_some_and(|b| !Arc::ptr_eq(&b1, &b))
});
assert_eq!(
cap_count(&state),
1,
"once-per-buffer: no re-warn without change"
);
// 3) Shrink below the cap → warned flag clears, no new error.
replace_all(&state, b"# small\n\n```rust\nx\n```\n");
let b2 = current(&state).unwrap();
dispatch(&state);
pump_async(&mut state, |s| {
current(s).is_some_and(|b| !Arc::ptr_eq(&b2, &b))
});
assert_eq!(
cap_count(&state),
1,
"dropping below the cap surfaces no new error"
);
// 4) Grow back above the cap → re-armed, warns once more.
replace_all(&state, capping.as_bytes());
let b3 = current(&state).unwrap();
dispatch(&state);
pump_async(&mut state, |s| {
current(s).is_some_and(|b| !Arc::ptr_eq(&b3, &b))
});
assert_eq!(
cap_count(&state),
2,
"re-armed: exceeding the cap again warns once more"
);
}
/// Framing acceptance #12: a large all-inline markdown buffer settles
/// (root + one cold inline layer per paragraph) within a comfortable
/// budget, and the FINAL paragraph still receives an inline layer — the