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:
parent
79d75a29e0
commit
325dcd553f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue