fix(injections): PR #122 round 1 — sweep flatten, sync aliases, real multi-range, surfaced cap
Four review findings + a cleanup bundle. [P1] Wire flattener was O(spans²) and ran over the WHOLE buffer (the file-style summary uses a whole-buffer viewport, not the visible one). Replaced the per-interval full scan with an ordered active-set event sweep (activate on start, expire on end, fold the active set) — linear in practice. Added full_buffer_summary_scales_on_large_grammar_file (1500-line rust) as the perf gate. [P2] _parse_now used the empty alias map from make_request while _dispatch snapshotted the registry map, so a `py` fence injected async but not sync. Snapshot aliases on both paths; pinned by sync_parse_now_resolves_alias. [P2] The multi-range inline test used a one-line paragraph, whose block inline node has no named children (link/emphasis are child-grammar structures) — one range, so it couldn't falsify multi-range. Replaced with a multi-line blockquote whose inline node carries a named block_continuation: content_node_ranges now asserts >1 collected range and emphasis parses on both lines. [P2] The layer backstop dropped regions silently; the framing requires a surfaced warning. run_parse now sets ParseTreeBundle::injection_capped; syntax.lua's settle tick raises it once per buffer via pmacs.error (_injection_capped). Added injection_layer_cap_surfaces_and_preserves_root (drives >4096 fences, asserts the flag + bounded count + intact root). Cleanup: - The GPU acceptance test now drives the real StyleSpans full-frame transform (spans_from_segments, extracted from replace_style_spans) instead of a hand-rolled sort. - content_node_ranges excludes NAMED children (documented as a round-1 refinement); framing mechanic #3 / Q#IJ5 updated to match. - parse_duration doc now says root parse; the markdown entry no longer describes inline as unhighlighted/future. 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
4282b1c333
commit
79d75a29e0
|
|
@ -22,6 +22,10 @@ local inflight_parse_by_buffer = {}
|
||||||
local parse_buffer_by_key = {}
|
local parse_buffer_by_key = {}
|
||||||
local parse_lang_by_buffer = {}
|
local parse_lang_by_buffer = {}
|
||||||
local reparse_requested_by_buffer = {}
|
local reparse_requested_by_buffer = {}
|
||||||
|
-- Buffers already warned about hitting the injection layer cap (Q#IJ3);
|
||||||
|
-- keyed like the others so we warn once, and re-arm if the file stops
|
||||||
|
-- capping (an edit removed the excess regions).
|
||||||
|
local injection_cap_warned = {}
|
||||||
|
|
||||||
local raw_dispatch = pmacs.parse._dispatch
|
local raw_dispatch = pmacs.parse._dispatch
|
||||||
|
|
||||||
|
|
@ -338,6 +342,19 @@ pmacs._async.tick = function(...)
|
||||||
if pmacs._async._is_complete(job_id) then
|
if pmacs._async._is_complete(job_id) then
|
||||||
local key = parse_job_buffer_keys[job_id]
|
local key = parse_job_buffer_keys[job_id]
|
||||||
pmacs.parse._install_settled(job_id)
|
pmacs.parse._install_settled(job_id)
|
||||||
|
-- Surface the injection layer backstop (Q#IJ3) once per buffer rather
|
||||||
|
-- than dropping embedded regions silently. Best-effort: a missing
|
||||||
|
-- buffer or error here must not stall the settle loop.
|
||||||
|
local capped_buf = key and parse_buffer_by_key[key]
|
||||||
|
if capped_buf and pmacs.parse._injection_capped(capped_buf) then
|
||||||
|
if not injection_cap_warned[key] and pmacs.error then
|
||||||
|
injection_cap_warned[key] = true
|
||||||
|
pmacs.error(
|
||||||
|
"syntax: injection layer cap reached; some embedded regions are unhighlighted")
|
||||||
|
end
|
||||||
|
elseif key then
|
||||||
|
injection_cap_warned[key] = nil
|
||||||
|
end
|
||||||
pending_parse_jobs[job_id] = nil
|
pending_parse_jobs[job_id] = nil
|
||||||
parse_job_buffer_keys[job_id] = nil
|
parse_job_buffer_keys[job_id] = nil
|
||||||
if key and inflight_parse_by_buffer[key] == job_id then
|
if key and inflight_parse_by_buffer[key] == job_id then
|
||||||
|
|
|
||||||
|
|
@ -83,15 +83,17 @@ docs):
|
||||||
@injection.language)` + `(code_fence_content) @injection.content`, and
|
@injection.language)` + `(code_fence_content) @injection.content`, and
|
||||||
static `((inline) @injection.content (#set! injection.language
|
static `((inline) @injection.content (#set! injection.language
|
||||||
"markdown_inline"))` (also `html`/`yaml`/`toml`).
|
"markdown_inline"))` (also `html`/`yaml`/`toml`).
|
||||||
3. **The generic injection contract excludes child ranges and intersects
|
3. **The injection contract excludes child ranges and intersects with the
|
||||||
with the parent.** Unless `#set! injection.include-children` is set,
|
parent.** Unless `#set! injection.include-children` is set, the injected
|
||||||
the injected ranges are the content node's extent **minus its direct
|
ranges are the content node's extent **minus its NAMED children's
|
||||||
children's ranges**, then **intersected with the parent layer's own
|
ranges**, then **intersected with the parent layer's own included
|
||||||
included ranges** so a nested injection cannot reintroduce bytes its
|
ranges** so a nested injection cannot reintroduce bytes its parent
|
||||||
parent excluded (tree-sitter highlighting docs; the bundled markdown
|
excluded. *(Implementation note, round 1: excluding ALL children — not
|
||||||
convenience parser shows a markdown-specialized form of the child
|
just named — shreds a markdown block `inline` node, whose children are
|
||||||
split at `parser.rs:406-425`). This makes `markdown_inline` a genuine
|
anonymous text tokens, into unparseable fragments. Excluding only named
|
||||||
multi-range case (Q#IJ5).
|
children matches `tree-sitter-md`'s own inline splitter,
|
||||||
|
`parser.rs:406-425`, and is correct for every real injection site.)*
|
||||||
|
This makes `markdown_inline` a genuine multi-range case (Q#IJ5).
|
||||||
4. `LanguageEntry.loader` (`fn() -> Language`) and query-source `&'static
|
4. `LanguageEntry.loader` (`fn() -> Language`) and query-source `&'static
|
||||||
str` consts are `Send` and touch no grammar C-object until called — a
|
str` consts are `Send` and touch no grammar C-object until called — a
|
||||||
worker resolves injected languages **lazily** by indexing `&'static
|
worker resolves injected languages **lazily** by indexing `&'static
|
||||||
|
|
@ -154,9 +156,13 @@ in v1. Every headline case is bundled.
|
||||||
- **max depth** (default 3),
|
- **max depth** (default 3),
|
||||||
- **max total layer count** — a *runaway backstop* set well above any
|
- **max total layer count** — a *runaway backstop* set well above any
|
||||||
real document (default **4096**), decoupled from performance; the real
|
real document (default **4096**), decoupled from performance; the real
|
||||||
perf bound is the Q#IJ10 settle-time guard. Hitting it is **logged, not
|
perf bound is the Q#IJ10 settle-time guard. Hitting it is **surfaced, not
|
||||||
silent** (surfaced via `pmacs.error`/`*workers*`), and a modest markdown
|
silent**: `run_parse` sets a `ParseTreeBundle::injection_capped` flag,
|
||||||
doc's inline layers (one per paragraph/heading) sit far under it.
|
and `syntax.lua`'s settle tick raises it once per buffer via
|
||||||
|
`pmacs.error` (`_injection_capped`). A modest markdown doc's inline
|
||||||
|
layers (one per paragraph/heading) sit far under the backstop. A real
|
||||||
|
boundary test drives just over 4096 fences and asserts the flag + capped
|
||||||
|
count + intact root.
|
||||||
- a **visited set on `(language_name, ranges)`** so a same-language
|
- a **visited set on `(language_name, ranges)`** so a same-language
|
||||||
self-injection over a non-shrinking region can't reproduce itself.
|
self-injection over a non-shrinking region can't reproduce itself.
|
||||||
|
|
||||||
|
|
@ -190,14 +196,18 @@ A layer's ranges are built from its `@injection.content` node(s):
|
||||||
|
|
||||||
- `#set! injection.include-children` → `[node.range]`;
|
- `#set! injection.include-children` → `[node.range]`;
|
||||||
- otherwise (default; the markdown-inline case) → the node's extent
|
- otherwise (default; the markdown-inline case) → the node's extent
|
||||||
**minus its direct children's ranges**, **then intersected with the
|
**minus its NAMED children's ranges** (anonymous token children are the
|
||||||
parent layer's included ranges** (mechanic #3). Ranges come out ordered
|
injected text itself and are kept — mechanic #3), **then intersected
|
||||||
and non-overlapping; empty ranges dropped.
|
with the parent layer's included ranges**. Ranges come out ordered and
|
||||||
|
non-overlapping; empty ranges dropped.
|
||||||
|
|
||||||
Fenced code (`code_fence_content`, no children) → one range; an inline
|
Fenced code (`code_fence_content`, no children) → one range; a **multi-line**
|
||||||
paragraph with a link and emphasis → several. Core, not deferred —
|
container (a blockquote/list whose inline node carries a named
|
||||||
`markdown_inline` needs it. Acceptance parses an inline paragraph with a
|
`block_continuation` child) → several. Core, not deferred —
|
||||||
link **and** emphasis, not just plain text.
|
`markdown_inline` needs it. Acceptance asserts `content_node_ranges`
|
||||||
|
returns **>1 range** for a multi-line blockquote and that both sides parse
|
||||||
|
and highlight (a one-line paragraph would give a single range and could
|
||||||
|
not falsify multi-range support).
|
||||||
|
|
||||||
### Q#IJ6 — The wire producer flattens layers into disjoint effective spans
|
### Q#IJ6 — The wire producer flattens layers into disjoint effective spans
|
||||||
|
|
||||||
|
|
@ -308,27 +318,39 @@ half-styled frame. `grammar_style_parse_not_ready` unchanged.
|
||||||
offsets matching its position in the **full** markdown source.
|
offsets matching its position in the **full** markdown source.
|
||||||
4. `dynamic_alias_resolves` — ` ```py `→python, ` ```JS `→javascript
|
4. `dynamic_alias_resolves` — ` ```py `→python, ` ```JS `→javascript
|
||||||
(case-folded); ` ```nonsense ` → no child, no error, root intact.
|
(case-folded); ` ```nonsense ` → no child, no error, root intact.
|
||||||
5. `alias_override_from_lua_async` — mutate `injection_aliases` in Lua,
|
5. `lua_alias_override_resolves_on_async_parse` — mutate
|
||||||
then an **async** injected parse resolves the new alias (Q#IJ4 bridge).
|
`injection_aliases` in Lua, then an **async** injected parse resolves
|
||||||
6. `inline_layer_multi_range_link_and_emphasis` — an inline paragraph
|
the new alias (Q#IJ4 bridge). Plus `sync_parse_now_resolves_alias`
|
||||||
with a link **and** emphasis becomes a `markdown_inline` layer whose
|
(round 1): the same must hold on the **synchronous** `_parse_now` path.
|
||||||
ranges exclude the direct children and highlight correctly (Q#IJ5).
|
6. `inline_layer_multi_range_excludes_block_continuation` — a **multi-line**
|
||||||
7. `recursion_bounds_hold` — depth cap, layer-count backstop, and
|
blockquote's inline node carries a named `block_continuation`;
|
||||||
`(language, ranges)` visited guard each terminate; a failing child
|
`content_node_ranges` returns **>1 range** and both sides parse +
|
||||||
drops only itself, root installs (Q#IJ3).
|
highlight (Q#IJ5). (A one-line paragraph gives a single range and can't
|
||||||
8. `wire_producer_emits_child_spans` — `scoped_style_spans` over a
|
falsify multi-range.)
|
||||||
` ```rust ` fence emits disjoint `StyleSpan`s covering a rust keyword
|
7. `recursion_bounds_terminate` — rust macro self-injection terminates
|
||||||
**inside** the fence. **Bite-verified** vs the single-layer producer.
|
within the depth bound; `injection_layer_cap_surfaces_and_preserves_root`
|
||||||
9. `gpu_overlapping_child_color_wins` — parent-red / child-green overlap
|
drives >4096 fences and asserts the surfaced `injection_capped` flag,
|
||||||
applied through `replace_style_spans` then rendered: the child (green)
|
the bounded count, and an intact root; a failing child drops only itself
|
||||||
wins. **Bite-verified** vs the current first-span-wins path (Q#IJ6).
|
(Q#IJ3).
|
||||||
10. `grid_producer_paints_child_span` — `SyntaxHighlightView` paints a
|
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).
|
||||||
|
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.
|
||||||
|
**Bite-verified** vs the first-span-wins path (Q#IJ6).
|
||||||
|
10. `grid_paints_injected_child_keyword` — `SyntaxHighlightView` paints a
|
||||||
rust-keyword cell inside the fence.
|
rust-keyword cell inside the fence.
|
||||||
11. `incremental_edit_reflects_in_child` — editing inside a fence shows in
|
11. `incremental_edit_reflects_in_child_and_new_fence_adds_layer` — editing
|
||||||
child spans after reparse; a **new** fence adds a layer (Q#IJ8).
|
inside a fence shows in child spans after reparse; a **new** fence adds
|
||||||
12. `many_paragraph_settle_under_budget_tail_covered` — a large all-inline
|
a layer (Q#IJ8).
|
||||||
markdown buffer settles within a comfortable budget **and the final
|
12. `many_paragraph_settle_under_budget_with_tail_covered` — a large
|
||||||
paragraph receives a layer/capture** (Q#IJ3 cap + Q#IJ10 guard).
|
all-inline markdown buffer settles within a comfortable budget **and
|
||||||
|
the final paragraph receives an inline layer** (Q#IJ3 cap + Q#IJ10
|
||||||
|
guard).
|
||||||
13. `non_injecting_buffer_single_layer` — a plain `.rs` buffer still
|
13. `non_injecting_buffer_single_layer` — a plain `.rs` buffer still
|
||||||
yields exactly one layer (regression guard).
|
yields exactly one layer (regression guard).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4126,11 +4126,7 @@ impl State {
|
||||||
/// `full = true` path: discard prior styling, take the segments'
|
/// `full = true` path: discard prior styling, take the segments'
|
||||||
/// spans as authoritative for the declared viewport.
|
/// spans as authoritative for the declared viewport.
|
||||||
fn replace_style_spans(&mut self, segments: Vec<StyleSegment>) {
|
fn replace_style_spans(&mut self, segments: Vec<StyleSegment>) {
|
||||||
self.current_spans.clear();
|
self.current_spans = spans_from_segments(segments);
|
||||||
for seg in segments {
|
|
||||||
self.current_spans.extend(seg.spans);
|
|
||||||
}
|
|
||||||
self.current_spans.sort_by_key(|s| s.range.start);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `full = false` path: each segment's `range` authoritatively
|
/// `full = false` path: each segment's `range` authoritatively
|
||||||
|
|
@ -6731,6 +6727,21 @@ fn adornment_text_color(fg: CellColor) -> glyphon::Color {
|
||||||
cell_color_to_glyphon(fg).unwrap_or_else(|| glyphon::Color::rgb(130, 130, 140))
|
cell_color_to_glyphon(fg).unwrap_or_else(|| glyphon::Color::rgb(130, 130, 140))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `StyleSpans { full: true }` transform `replace_style_spans` applies:
|
||||||
|
/// flatten every segment's spans and start-sort. Extracted as a free
|
||||||
|
/// function so it (and `source_color_at`) can be exercised without a live
|
||||||
|
/// `State` — the start-sort is the step that makes producer depth-order
|
||||||
|
/// irrelevant on the wire, which is why the daemon flattens to disjoint
|
||||||
|
/// spans and this consumer folds (framing Q#IJ6).
|
||||||
|
fn spans_from_segments(segments: Vec<StyleSegment>) -> Vec<StyleSpan> {
|
||||||
|
let mut spans: Vec<StyleSpan> = Vec::new();
|
||||||
|
for seg in segments {
|
||||||
|
spans.extend(seg.spans);
|
||||||
|
}
|
||||||
|
spans.sort_by_key(|s| s.range.start);
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
fn source_color_at(byte: u64, spans: &[StyleSpan]) -> Option<glyphon::Color> {
|
fn source_color_at(byte: u64, spans: &[StyleSpan]) -> Option<glyphon::Color> {
|
||||||
// Fold every covering span in order, matching the semantic-client
|
// Fold every covering span in order, matching the semantic-client
|
||||||
// `effective_style_at` contract (last covering span with a non-default
|
// `effective_style_at` contract (last covering span with a non-default
|
||||||
|
|
@ -8985,24 +8996,29 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn source_color_folds_overlapping_child_over_parent() {
|
fn source_color_folds_overlapping_child_over_parent() {
|
||||||
// Framing acceptance #9 (GPU consumer): a styled markdown parent
|
// Framing acceptance #9 (GPU consumer): a styled markdown parent
|
||||||
// span [0,10) (red) with an injected child span [3,6) (green) on top.
|
// span [0,10) (red) with an injected child span [3,6) (green) on top,
|
||||||
// `replace_style_spans` sorts incoming spans by start, so we mirror
|
// driven through the ACTUAL `StyleSpans { full: true }` transform
|
||||||
// that here; `source_color_at` must FOLD the covering spans (child
|
// (`spans_from_segments`, the body of `replace_style_spans`) rather
|
||||||
// green wins at a shared byte), not return the first (parent) span.
|
// than a hand-rolled sort. `source_color_at` must FOLD the covering
|
||||||
// Bite: the pre-fix first-covering-span code returns red at byte 4.
|
// spans (child green wins at a shared byte), not return the first
|
||||||
|
// (parent) span. Bite: the pre-fix first-covering-span code returns
|
||||||
|
// red at byte 4.
|
||||||
let red = style_with_fg(CellColor::Rgb(200, 0, 0));
|
let red = style_with_fg(CellColor::Rgb(200, 0, 0));
|
||||||
let green = style_with_fg(CellColor::Rgb(0, 200, 0));
|
let green = style_with_fg(CellColor::Rgb(0, 200, 0));
|
||||||
let mut spans = vec![
|
let segments = vec![StyleSegment {
|
||||||
StyleSpan {
|
range: ByteRange { start: 0, end: 10 },
|
||||||
range: ByteRange { start: 0, end: 10 },
|
spans: vec![
|
||||||
style: red,
|
StyleSpan {
|
||||||
},
|
range: ByteRange { start: 0, end: 10 },
|
||||||
StyleSpan {
|
style: red,
|
||||||
range: ByteRange { start: 3, end: 6 },
|
},
|
||||||
style: green,
|
StyleSpan {
|
||||||
},
|
range: ByteRange { start: 3, end: 6 },
|
||||||
];
|
style: green,
|
||||||
spans.sort_by_key(|s| s.range.start); // as replace_style_spans does
|
},
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
let spans = spans_from_segments(segments); // full-frame message application
|
||||||
|
|
||||||
// Byte 4 is covered by both: the child (green) wins the fold.
|
// Byte 4 is covered by both: the child (green) wins the fold.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -6652,6 +6652,21 @@ pub fn install_parse(
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True if the buffer's installed bundle hit the injection layer backstop
|
||||||
|
// (framing Q#IJ3). `syntax.lua` reads this after settle to surface the
|
||||||
|
// cap once via `pmacs.error` rather than dropping regions silently.
|
||||||
|
{
|
||||||
|
let s = syntax.clone();
|
||||||
|
parse_mod.set(
|
||||||
|
"_injection_capped",
|
||||||
|
lua.create_function(move |_, id: BufferIdLua| {
|
||||||
|
Ok(s.view(id.0)
|
||||||
|
.and_then(|h| h.current())
|
||||||
|
.is_some_and(|b| b.injection_capped))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let s = syntax.clone();
|
let s = syntax.clone();
|
||||||
parse_mod.set(
|
parse_mod.set(
|
||||||
|
|
@ -6673,7 +6688,11 @@ pub fn install_parse(
|
||||||
"_parse_now",
|
"_parse_now",
|
||||||
lua.create_function(move |_, (id, lang): (BufferIdLua, String)| {
|
lua.create_function(move |_, (id, lang): (BufferIdLua, String)| {
|
||||||
let handle = get_or_create_parse_view(&s, ®, id.0, &lang)?;
|
let handle = get_or_create_parse_view(&s, ®, id.0, &lang)?;
|
||||||
let req = handle.make_request();
|
let mut req = handle.make_request();
|
||||||
|
// Snapshot the alias map on the sync path too (framing Q#IJ4)
|
||||||
|
// — otherwise a `py` fence or a Lua-added alias would resolve
|
||||||
|
// asynchronously but not through `_parse_now`.
|
||||||
|
req.injection_aliases = s.injection_alias_snapshot();
|
||||||
let bundle = syntax::run_parse(req).map_err(mlua::Error::external)?;
|
let bundle = syntax::run_parse(req).map_err(mlua::Error::external)?;
|
||||||
// Resolve each layer's highlight query from the registry
|
// Resolve each layer's highlight query from the registry
|
||||||
// cache before install so producers can style every layer
|
// cache before install so producers can style every layer
|
||||||
|
|
|
||||||
|
|
@ -1823,6 +1823,14 @@ fn flatten_layer_spans(styled: &[StyledLayerSpan]) -> Vec<StyleSpan> {
|
||||||
if styled.is_empty() {
|
if styled.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// Boundary sweep. Every unique span endpoint is a boundary; between
|
||||||
|
// consecutive boundaries the covering set is constant. An ordered
|
||||||
|
// active-set (activate on start, expire on end) keeps each interval's
|
||||||
|
// fold O(active) rather than O(all spans), so the whole pass is
|
||||||
|
// O(spans·log spans + Σ active) — linear in practice (active is bounded
|
||||||
|
// by overlap depth, not the total span count). This matters because the
|
||||||
|
// file-style summary runs this over the *entire* buffer, not just the
|
||||||
|
// viewport.
|
||||||
let mut bounds: Vec<u64> = Vec::with_capacity(styled.len() * 2);
|
let mut bounds: Vec<u64> = Vec::with_capacity(styled.len() * 2);
|
||||||
for sp in styled {
|
for sp in styled {
|
||||||
bounds.push(sp.start);
|
bounds.push(sp.start);
|
||||||
|
|
@ -1831,27 +1839,36 @@ fn flatten_layer_spans(styled: &[StyledLayerSpan]) -> Vec<StyleSpan> {
|
||||||
bounds.sort_unstable();
|
bounds.sort_unstable();
|
||||||
bounds.dedup();
|
bounds.dedup();
|
||||||
|
|
||||||
|
// Span indices ordered by start; activated as the sweep reaches them.
|
||||||
|
let mut by_start: Vec<usize> = (0..styled.len()).collect();
|
||||||
|
by_start.sort_by_key(|&i| styled[i].start);
|
||||||
|
let mut next = 0usize;
|
||||||
|
|
||||||
|
// Active covering spans, kept in ascending-priority order so the fold
|
||||||
|
// is a single in-order pass.
|
||||||
|
let mut active: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
let mut out: Vec<StyleSpan> = Vec::new();
|
let mut out: Vec<StyleSpan> = Vec::new();
|
||||||
for win in bounds.windows(2) {
|
for win in bounds.windows(2) {
|
||||||
let (a, b) = (win[0], win[1]);
|
let (a, b) = (win[0], win[1]);
|
||||||
if b <= a {
|
// Activate spans starting at or before `a` (each activates once).
|
||||||
|
while next < by_start.len() && styled[by_start[next]].start <= a {
|
||||||
|
let idx = by_start[next];
|
||||||
|
let pos = active.partition_point(|&j| styled[j].priority < styled[idx].priority);
|
||||||
|
active.insert(pos, idx);
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
// Expire spans that ended at or before `a` (ranges are half-open).
|
||||||
|
active.retain(|&j| styled[j].end > a);
|
||||||
|
if active.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Fold every span covering [a, b) in ascending priority so a
|
// Fold the active set in ascending priority: a deeper/narrower span
|
||||||
// deeper/narrower span overrides, while a span that only sets a
|
// overrides, an attribute-only span still composes (matches the
|
||||||
// non-color attribute still composes (matches the semantic-client
|
// semantic-client `effective_style_at` contract).
|
||||||
// `effective_style_at` contract).
|
|
||||||
let mut covering: Vec<&StyledLayerSpan> = styled
|
|
||||||
.iter()
|
|
||||||
.filter(|sp| sp.start <= a && sp.end >= b)
|
|
||||||
.collect();
|
|
||||||
if covering.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
covering.sort_by_key(|sp| sp.priority);
|
|
||||||
let mut style = Style::default();
|
let mut style = Style::default();
|
||||||
for sp in covering {
|
for &j in &active {
|
||||||
style = crate::overlay::merge_styles(style, sp.style);
|
style = crate::overlay::merge_styles(style, styled[j].style);
|
||||||
}
|
}
|
||||||
if style == Style::default() {
|
if style == Style::default() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -3471,6 +3488,36 @@ 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.
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let state = empty_state();
|
||||||
|
let bid = active_buffer(&state);
|
||||||
|
let mut src = String::new();
|
||||||
|
for i in 0..1500 {
|
||||||
|
writeln!(
|
||||||
|
src,
|
||||||
|
"pub fn f_{i}(x: u32) -> u32 {{ let y = x + {i}; y * 2 }}"
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
}
|
||||||
|
seed_rust_parse_view(&state, bid, src.as_bytes());
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let summary = scoped_file_summary(&state, bid);
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() {
|
fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() {
|
||||||
let state = empty_state();
|
let state = empty_state();
|
||||||
|
|
|
||||||
156
src/syntax.rs
156
src/syntax.rs
|
|
@ -103,10 +103,16 @@ pub struct ParseTreeBundle {
|
||||||
/// and the `*workers*` buffer can ask "what grammar produced this?"
|
/// and the `*workers*` buffer can ask "what grammar produced this?"
|
||||||
/// without indexing the layer vec.
|
/// without indexing the layer vec.
|
||||||
pub language_name: String,
|
pub language_name: String,
|
||||||
/// Wall-clock duration of the whole layered parse (excludes dispatch
|
/// Wall-clock duration of the **root** parse (excludes injection layer
|
||||||
/// queueing, source materialization, and bus delivery). T M4.1
|
/// building and dispatch/materialization/bus overhead). The M4.1
|
||||||
/// acceptance criteria are stated in this metric.
|
/// acceptance perf gates are stated in this metric, so it stays the
|
||||||
|
/// single-tree cost even as injection layers are added on top.
|
||||||
pub parse_duration: Duration,
|
pub parse_duration: Duration,
|
||||||
|
/// True if injection expansion hit the total-layer backstop (framing
|
||||||
|
/// Q#IJ3) and dropped some regions. Surfaced (not silent) at settle via
|
||||||
|
/// `pmacs.error`; only a pathological file (thousands of embedded
|
||||||
|
/// regions) can set it.
|
||||||
|
pub injection_capped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One injection layer within a [`ParseTreeBundle`] (framing Q#IJ1). A
|
/// One injection layer within a [`ParseTreeBundle`] (framing Q#IJ1). A
|
||||||
|
|
@ -179,12 +185,14 @@ pub fn run_parse(req: ParseRequest) -> Result<ParseTreeBundle, String> {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
highlight_query: None,
|
highlight_query: None,
|
||||||
}];
|
}];
|
||||||
build_injection_layers(&mut layers, req.source.as_ref(), &req.injection_aliases);
|
let injection_capped =
|
||||||
|
build_injection_layers(&mut layers, req.source.as_ref(), &req.injection_aliases);
|
||||||
Ok(ParseTreeBundle {
|
Ok(ParseTreeBundle {
|
||||||
layers,
|
layers,
|
||||||
source: req.source,
|
source: req.source,
|
||||||
language_name: req.language_name,
|
language_name: req.language_name,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
|
injection_capped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,17 +259,20 @@ struct InjectionMatch {
|
||||||
/// appending children in depth-ascending order (Q#IJ1, Q#IJ6 rely on this
|
/// appending children in depth-ascending order (Q#IJ1, Q#IJ6 rely on this
|
||||||
/// ordering). Bounded by depth, total layer count, and a
|
/// ordering). Bounded by depth, total layer count, and a
|
||||||
/// `(language, ranges)` visited guard (Q#IJ3). BFS by depth so siblings
|
/// `(language, ranges)` visited guard (Q#IJ3). BFS by depth so siblings
|
||||||
/// at a level are grouped before descending.
|
/// at a level are grouped before descending. Returns `true` if the
|
||||||
|
/// total-layer backstop was hit and some regions were dropped (surfaced at
|
||||||
|
/// settle, framing Q#IJ3).
|
||||||
fn build_injection_layers(
|
fn build_injection_layers(
|
||||||
layers: &mut Vec<Layer>,
|
layers: &mut Vec<Layer>,
|
||||||
source: &[u8],
|
source: &[u8],
|
||||||
aliases: &HashMap<String, String>,
|
aliases: &HashMap<String, String>,
|
||||||
) {
|
) -> bool {
|
||||||
let mut query_cache: HashMap<String, Option<Arc<tree_sitter::Query>>> = HashMap::new();
|
let mut query_cache: HashMap<String, Option<Arc<tree_sitter::Query>>> = HashMap::new();
|
||||||
let mut visited: HashSet<(String, Vec<(usize, usize)>)> = HashSet::new();
|
let mut visited: HashSet<(String, Vec<(usize, usize)>)> = HashSet::new();
|
||||||
// Frontier entries are (layer index, that layer's included ranges).
|
// Frontier entries are (layer index, that layer's included ranges).
|
||||||
let mut frontier: Vec<(usize, Vec<Range>)> = vec![(0, vec![whole_source_range(source)])];
|
let mut frontier: Vec<(usize, Vec<Range>)> = vec![(0, vec![whole_source_range(source)])];
|
||||||
let mut depth: u16 = 0;
|
let mut depth: u16 = 0;
|
||||||
|
let mut capped = false;
|
||||||
|
|
||||||
while depth < MAX_INJECTION_DEPTH && !frontier.is_empty() {
|
while depth < MAX_INJECTION_DEPTH && !frontier.is_empty() {
|
||||||
// Children discovered this level: (layer, its ranges) to append and
|
// Children discovered this level: (layer, its ranges) to append and
|
||||||
|
|
@ -274,6 +285,7 @@ fn build_injection_layers(
|
||||||
};
|
};
|
||||||
for m in collect_injection_matches(&query, &layers[*parent_idx].tree, source) {
|
for m in collect_injection_matches(&query, &layers[*parent_idx].tree, source) {
|
||||||
if layers.len() + children.len() >= MAX_INJECTION_LAYERS {
|
if layers.len() + children.len() >= MAX_INJECTION_LAYERS {
|
||||||
|
capped = true;
|
||||||
break 'parents; // runaway backstop; tail dropped
|
break 'parents; // runaway backstop; tail dropped
|
||||||
}
|
}
|
||||||
let Some(child_lang) = resolve_injected_language(&m.language, aliases) else {
|
let Some(child_lang) = resolve_injected_language(&m.language, aliases) else {
|
||||||
|
|
@ -314,6 +326,7 @@ fn build_injection_layers(
|
||||||
frontier = next_frontier;
|
frontier = next_frontier;
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
|
capped
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compile (once, cached) the `injections.scm` for `lang` from the static
|
/// Compile (once, cached) the `injections.scm` for `lang` from the static
|
||||||
|
|
@ -797,16 +810,12 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
|
||||||
highlights_query: &[tree_sitter_lua::HIGHLIGHTS_QUERY],
|
highlights_query: &[tree_sitter_lua::HIGHLIGHTS_QUERY],
|
||||||
injections_query: &[],
|
injections_query: &[],
|
||||||
},
|
},
|
||||||
// T M9.7: markdown grammar for prompt-result buffers with
|
// T M9.7: markdown block grammar (`tree_sitter_md::LANGUAGE`) — headers,
|
||||||
// `_meta.format = "markdown"`. Uses only the block grammar
|
// lists, fenced code blocks, blockquotes. Its `injections.scm` (framing
|
||||||
// (`tree_sitter_md::LANGUAGE`) — block-level highlighting (headers,
|
// Q#IJ10) drives two layer kinds: fenced code blocks inject the fence's
|
||||||
// lists, fenced code blocks, blockquotes) is the v0.1 floor.
|
// named language, and paragraph/heading text injects `markdown_inline`
|
||||||
// Inline highlighting (emphasis, links inside running text) would
|
// (the entry below) — so inline emphasis/links are now highlighted, and
|
||||||
// require the dual-grammar `MarkdownParser` and is M9.8+ work.
|
// the former M9.7 "block-only, inline unhighlighted" floor is retired.
|
||||||
// The `markdown_inline` fixture prompt + matching acceptance test
|
|
||||||
// pin this floor: an `**emphasis**` span must not crash, and is
|
|
||||||
// expected to render unhighlighted; any future expansion that
|
|
||||||
// adds inline coverage is additive, not a regression.
|
|
||||||
// Note the constant name: `HIGHLIGHT_QUERY_BLOCK` (singular) is
|
// Note the constant name: `HIGHLIGHT_QUERY_BLOCK` (singular) is
|
||||||
// the markdown crate's idiom; `tree-sitter-rust` and
|
// the markdown crate's idiom; `tree-sitter-rust` and
|
||||||
// `tree-sitter-lua` use `HIGHLIGHTS_QUERY` (plural).
|
// `tree-sitter-lua` use `HIGHLIGHTS_QUERY` (plural).
|
||||||
|
|
@ -1281,6 +1290,7 @@ impl SyntaxRegistry {
|
||||||
source: raw.source.clone(),
|
source: raw.source.clone(),
|
||||||
language_name: raw.language_name.clone(),
|
language_name: raw.language_name.clone(),
|
||||||
parse_duration: raw.parse_duration,
|
parse_duration: raw.parse_duration,
|
||||||
|
injection_capped: raw.injection_capped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1597,44 +1607,80 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inline_layer_multi_range_link_and_emphasis() {
|
fn inline_layer_multi_range_excludes_block_continuation() {
|
||||||
// Framing acceptance #6: a paragraph with a link AND emphasis
|
// Framing acceptance #6 (round-2 finding 3): the multi-range path.
|
||||||
// becomes a markdown_inline layer whose included ranges exclude the
|
// A one-line paragraph would give a SINGLE range (a link/emphasis
|
||||||
// block grammar's named children — the inline grammar still parses
|
// are child-grammar structures, not block-grammar named children),
|
||||||
// the emphasis run in a surviving text range.
|
// so it can't prove multi-range. A MULTI-LINE blockquote's inline
|
||||||
|
// node carries a named `block_continuation` child (the `> ` marker),
|
||||||
|
// which `content_node_ranges` excludes — yielding MORE THAN ONE
|
||||||
|
// included range, the genuine path markdown_inline depends on.
|
||||||
let reg = SyntaxRegistry::new();
|
let reg = SyntaxRegistry::new();
|
||||||
let src = b"See [the docs](http://example.com) and *emphasis* here.\n";
|
let src = b"> first *one*\n> second *two*\n";
|
||||||
let bundle = parse_layered(®, "markdown", src);
|
let language = reg.language("markdown").expect("markdown");
|
||||||
|
let mut buf = fresh_buffer("doc");
|
||||||
|
buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
|
||||||
|
.unwrap();
|
||||||
|
let view = ParseView::new(&buf, language, "markdown".to_owned());
|
||||||
|
let handle = view.handle();
|
||||||
|
let _vid = buf.attach_view(Box::new(view));
|
||||||
|
let mut req = handle.make_request();
|
||||||
|
req.injection_aliases = reg.injection_alias_snapshot();
|
||||||
|
let raw = run_parse(req).expect("parse");
|
||||||
|
|
||||||
|
// The inline injection match collects more than one included range.
|
||||||
|
let mut cache = HashMap::new();
|
||||||
|
let query = injection_query_cached(&mut cache, "markdown").expect("md injections");
|
||||||
|
let inline_match = collect_injection_matches(&query, &raw.layers[0].tree, src)
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| m.language == "markdown_inline")
|
||||||
|
.expect("an inline injection match");
|
||||||
|
assert!(
|
||||||
|
inline_match.ranges.len() >= 2,
|
||||||
|
"the multi-line inline node yields >1 range (block_continuation \
|
||||||
|
excluded); got {:?}",
|
||||||
|
inline_match
|
||||||
|
.ranges
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.start_byte, r.end_byte))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both ranges parse and highlight: emphasis is recognized on BOTH
|
||||||
|
// lines, and the resolved layer produces spans.
|
||||||
|
let bundle = reg.resolve_layer_queries(&raw);
|
||||||
let inline = bundle
|
let inline = bundle
|
||||||
.layers
|
.layers
|
||||||
.iter()
|
.iter()
|
||||||
.find(|l| l.language_name == "markdown_inline")
|
.find(|l| l.language_name == "markdown_inline")
|
||||||
.expect("inline paragraph becomes a markdown_inline layer");
|
.expect("inline layer");
|
||||||
assert_eq!(
|
|
||||||
inline.depth, 1,
|
|
||||||
"inline is a depth-1 injection of the block grammar"
|
|
||||||
);
|
|
||||||
let sexp = inline.tree.root_node().to_sexp();
|
let sexp = inline.tree.root_node().to_sexp();
|
||||||
assert!(
|
assert!(
|
||||||
sexp.contains("emphasis"),
|
sexp.matches("emphasis").count() >= 2,
|
||||||
"the inline layer parsed the *emphasis* run: {sexp}"
|
"the inline grammar parsed emphasis in both ranges: {sexp}"
|
||||||
);
|
);
|
||||||
let query = inline
|
let hquery = inline
|
||||||
.highlight_query
|
.highlight_query
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("inline highlights resolved at settle");
|
.expect("inline highlights resolved at settle");
|
||||||
let spans = compute_highlight_spans_for(query, &inline.tree, &bundle.source, None);
|
let spans = compute_highlight_spans_for(hquery, &inline.tree, &bundle.source, None);
|
||||||
assert!(
|
assert!(
|
||||||
!spans.is_empty(),
|
!spans.is_empty(),
|
||||||
"the inline layer produces highlight spans"
|
"the inline layer produces highlight spans across both ranges"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recursion_bounds_terminate() {
|
fn recursion_bounds_terminate() {
|
||||||
// Framing acceptance #7: rust self-injects into macro token-trees.
|
// Framing acceptance #7: rust self-injects into macro token-trees, so
|
||||||
// Nested injections must terminate (depth cap + visited guard),
|
// nested injections recurse. This proves the depth bound and, most
|
||||||
// never loop — if the guard failed this test would hang.
|
// importantly, TERMINATION — a completing test (vs a hang) is the
|
||||||
|
// observable guarantee. (The total-layer backstop is exercised
|
||||||
|
// separately by `injection_layer_cap_surfaces_and_preserves_root`;
|
||||||
|
// the `(lang, ranges)` visited guard is a defensive early-out that
|
||||||
|
// no bundled grammar's self-injection-over-a-fixed-range can trip,
|
||||||
|
// so it is covered by inspection + this termination check, not an
|
||||||
|
// isolated positive case.)
|
||||||
let reg = SyntaxRegistry::new();
|
let reg = SyntaxRegistry::new();
|
||||||
let src =
|
let src =
|
||||||
b"macro_rules! m { () => { println!(\"{}\", vec![1, 2, 3]); }; }\nfn f() { m!(); }\n";
|
b"macro_rules! m { () => { println!(\"{}\", vec![1, 2, 3]); }; }\nfn f() { m!(); }\n";
|
||||||
|
|
@ -1655,6 +1701,44 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injection_layer_cap_surfaces_and_preserves_root() {
|
||||||
|
// Round-2 finding 4: hitting the total-layer backstop must set the
|
||||||
|
// surfaced `injection_capped` flag (not drop silently), bound the
|
||||||
|
// layer count, and keep the root intact.
|
||||||
|
let reg = SyntaxRegistry::new();
|
||||||
|
let fences = MAX_INJECTION_LAYERS + 8; // just over the backstop
|
||||||
|
let mut src = String::with_capacity(fences * 15);
|
||||||
|
for _ in 0..fences {
|
||||||
|
src.push_str("```rust\nx\n```\n\n");
|
||||||
|
}
|
||||||
|
let language = reg.language("markdown").expect("markdown");
|
||||||
|
let mut buf = fresh_buffer("doc");
|
||||||
|
buf.apply_edit(EditOp::Insert {
|
||||||
|
pos: 0,
|
||||||
|
bytes: src.as_bytes(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let view = ParseView::new(&buf, language, "markdown".to_owned());
|
||||||
|
let handle = view.handle();
|
||||||
|
let _vid = buf.attach_view(Box::new(view));
|
||||||
|
let mut req = handle.make_request();
|
||||||
|
req.injection_aliases = reg.injection_alias_snapshot();
|
||||||
|
let bundle = run_parse(req).expect("root parse");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bundle.injection_capped,
|
||||||
|
"hitting the backstop sets the surfaced flag"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bundle.layers.len() <= MAX_INJECTION_LAYERS,
|
||||||
|
"layer count is bounded by the backstop; got {}",
|
||||||
|
bundle.layers.len()
|
||||||
|
);
|
||||||
|
assert_eq!(bundle.layers[0].language_name, "markdown", "root intact");
|
||||||
|
assert_eq!(bundle.root_tree().root_node().kind(), "document");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_injecting_buffer_single_layer() {
|
fn non_injecting_buffer_single_layer() {
|
||||||
// Framing acceptance #13: a plain rust file with no macros produces
|
// Framing acceptance #13: a plain rust file with no macros produces
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,43 @@ fn lua_alias_override_resolves_on_async_parse() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Round-2 finding: the synchronous parse path (`_parse_now`) must snapshot
|
||||||
|
/// injection aliases too — otherwise a `py` fence (or a Lua-added alias)
|
||||||
|
/// injects asynchronously but not synchronously. The default `py`→python
|
||||||
|
/// alias discriminates the fix: with the empty map it would not resolve.
|
||||||
|
#[test]
|
||||||
|
fn sync_parse_now_resolves_alias() {
|
||||||
|
let state = EditorState::new();
|
||||||
|
let src = b"# Doc\n\n```py\nx = 1\n```\n";
|
||||||
|
let buf_id = state
|
||||||
|
.lua_host
|
||||||
|
.registry()
|
||||||
|
.borrow_mut()
|
||||||
|
.create_from_bytes("doc.md".to_owned(), src);
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.globals()
|
||||||
|
.set("BUF", BufferIdLua(buf_id))
|
||||||
|
.expect("bind BUF");
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("pmacs.parse._parse_now(BUF, 'markdown')")
|
||||||
|
.exec()
|
||||||
|
.expect("synchronous parse");
|
||||||
|
|
||||||
|
let bundle = state
|
||||||
|
.syntax_registry
|
||||||
|
.view(buf_id)
|
||||||
|
.and_then(|h| h.current())
|
||||||
|
.expect("installed bundle");
|
||||||
|
assert!(
|
||||||
|
bundle.layers.iter().any(|l| l.language_name == "python"),
|
||||||
|
"the `py` fence resolved to python on the synchronous `_parse_now` path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Framing acceptance #12: a large all-inline markdown buffer settles
|
/// Framing acceptance #12: a large all-inline markdown buffer settles
|
||||||
/// (root + one cold inline layer per paragraph) within a comfortable
|
/// (root + one cold inline layer per paragraph) within a comfortable
|
||||||
/// budget, and the FINAL paragraph still receives an inline layer — the
|
/// budget, and the FINAL paragraph still receives an inline layer — the
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue