Skip to main content

strop_engine/editor/
matching.rs

1//! Passive delimiter highlighting through the same pure matcher as `%`.
2//! Jobs are cancellable and revision/caret-owned. Collection probes and
3//! endpoints use their real source; no renderer-specific lexical semantics.
4
5use super::analysis::{worker, AnalysisTarget};
6use super::Editor;
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use strop_core::id::{BufferRevision, DocumentId};
11use strop_core::worker::{Completion, FailureKind, Outcome, Ticket};
12use strop_core::Buffer;
13use strop_grammar::{delimiter_pair, matching_delimiter_at, MatchCancelled};
14
15/// The cheap UI-side gate: the caret (or, in Insert, the just-typed
16/// byte) must sit on a delimiter byte before any job is worth owning.
17/// The lexical check is the worker's; this only keeps non-delimiter
18/// carets from allocating work — it never scans.
19fn near_delimiter(buf: &Buffer, caret: usize, insert: bool) -> bool {
20    let on = buf
21        .byte_at(caret)
22        .is_some_and(|byte| delimiter_pair(byte).is_some());
23    let typed = insert
24        && caret
25            .checked_sub(1)
26            .and_then(|before| buf.byte_at(before))
27            .is_some_and(|byte| delimiter_pair(byte).is_some());
28    on || typed
29}
30
31/// A match job's identity: the source document at one revision, the
32/// probe caret in SOURCE bytes and the Insert-mode just-typed probe.
33/// The overlay paints exact-key cache hits only.
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub struct MatchKey {
36    pub target: AnalysisTarget,
37    pub revision: BufferRevision,
38    pub caret: usize,
39    pub insert: bool,
40}
41
42/// Both delimiter byte offsets of a pair, in the target source document.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct PairMatch {
45    pub first: usize,
46    pub second: usize,
47}
48
49struct PendingMatch {
50    ticket: Ticket<MatchKey>,
51    cancel: Arc<AtomicBool>,
52}
53
54/// Per-source match jobs and their exact-key results. Owned by
55/// `AnalysisState` so the edit/forget/stop lifecycle covers it.
56#[derive(Default)]
57pub(crate) struct PairState {
58    pending: HashMap<AnalysisTarget, PendingMatch>,
59    cache: HashMap<AnalysisTarget, Vec<(MatchKey, Option<PairMatch>)>>,
60}
61
62impl PairState {
63    pub(crate) fn pending_empty(&self) -> bool {
64        self.pending.is_empty()
65    }
66
67    /// The owned job already computes this frame's key: a cache miss
68    /// while the worker is busy must not resubmit per frame.
69    fn pending_covers(&self, key: &MatchKey) -> bool {
70        self.pending
71            .get(&key.target)
72            .is_some_and(|pending| pending.ticket.key == *key)
73    }
74
75    /// A queued scan for a buffer that just changed is unwanted work;
76    /// its delivery would fail the revision recheck anyway.
77    pub(crate) fn cancel_target(&mut self, target: &AnalysisTarget) {
78        if let Some(pending) = self.pending.remove(target) {
79            pending.cancel.store(true, Ordering::Release);
80        }
81    }
82
83    pub(crate) fn cancel_all(&mut self) {
84        for pending in self.pending.drain().map(|(_, pending)| pending) {
85            pending.cancel.store(true, Ordering::Release);
86        }
87    }
88
89    pub(crate) fn forget(&mut self, target: &AnalysisTarget) {
90        self.cancel_target(target);
91        self.cache.remove(target);
92    }
93
94    fn lookup(&self, key: &MatchKey) -> Option<Option<PairMatch>> {
95        self.cache
96            .get(&key.target)?
97            .iter()
98            .find(|(k, _)| k == key)
99            .map(|(_, value)| *value)
100    }
101
102    fn store(&mut self, key: MatchKey, value: Option<PairMatch>) {
103        let entries = self.cache.entry(key.target.clone()).or_default();
104        entries.retain(|(k, _)| *k != key);
105        // a small ring: walking the caret across a line's delimiters
106        // revisits recent keys; old revisions age out unused
107        const CACHED_MATCHES: usize = 8;
108        while entries.len() >= CACHED_MATCHES {
109            entries.remove(0);
110        }
111        entries.push((key, value));
112    }
113}
114
115/// Unlike `%`, the passive overlay never searches ahead from a plain byte.
116pub(crate) fn match_delimiters(
117    buf: &Buffer,
118    caret: usize,
119    insert: bool,
120    cancelled: impl Fn() -> bool,
121) -> Result<Option<(usize, usize)>, MatchCancelled> {
122    if cancelled() {
123        return Err(MatchCancelled);
124    }
125    let probe = if buf.byte_at(caret).and_then(delimiter_pair).is_some() {
126        Some(caret)
127    } else {
128        caret
129            .checked_sub(1)
130            .filter(|_| insert)
131            .filter(|&at| buf.byte_at(at).and_then(delimiter_pair).is_some())
132    };
133    let Some(probe) = probe else { return Ok(None) };
134    matching_delimiter_at(buf, probe, cancelled)
135        .map(|mate| mate.map(|mate| (probe.min(mate), probe.max(mate))))
136}
137
138impl Editor {
139    /// The matching-delimiter overlay for the active pane (0051 §7
140    /// R09): both pair endpoints in VIEW bytes of `doc`, each only when
141    /// it maps into the viewed document — for a collection, through an
142    /// excerpt of the SAME source; never across files, gaps or chrome.
143    /// Passive: a miss owns a cancellable worker job and this frame
144    /// paints nothing; nothing here scrolls, blocks or interprets text.
145    pub fn pair_highlight(
146        &mut self,
147        doc: DocumentId,
148        caret: usize,
149        insert: bool,
150    ) -> [Option<usize>; 2] {
151        const NONE: [Option<usize>; 2] = [None, None];
152        if self.finishing {
153            return NONE;
154        }
155        // Pairing happens in the underlying source (0051 §7): a
156        // collection body row projects the caret into its source.
157        let Some((source, source_caret)) = self.source_position(doc, caret) else {
158            return NONE;
159        };
160        let near = match self.docs.get(source) {
161            Some(document) => near_delimiter(&document.buf, source_caret, insert),
162            None => return NONE,
163        };
164        if !near {
165            // the caret moved off delimiters: a queued scan is unwanted
166            self.analysis
167                .pair
168                .cancel_target(&AnalysisTarget::Document(source));
169            return NONE;
170        }
171        let Some(document) = self.docs.get(source) else {
172            return NONE;
173        };
174        let key = MatchKey {
175            target: AnalysisTarget::Document(source),
176            revision: document.buf.revision(),
177            caret: source_caret,
178            insert,
179        };
180        let rope = document.buf.snapshot();
181        match self.analysis.pair.lookup(&key) {
182            Some(Some(pair)) => [
183                self.view_byte_for_source(doc, source, pair.first),
184                self.view_byte_for_source(doc, source, pair.second),
185            ],
186            Some(None) => NONE,
187            None => {
188                self.request_match(key, rope);
189                NONE
190            }
191        }
192    }
193
194    /// A source byte's view byte in `doc`: identity for ordinary
195    /// buffers; for a collection, the excerpt of THAT SAME source
196    /// covering the byte's line (0051 §7 — never another file's card,
197    /// never a gap, never a generated header). An unexcerpted partner
198    /// maps nowhere and simply paints nothing.
199    fn view_byte_for_source(
200        &self,
201        doc: DocumentId,
202        source: DocumentId,
203        byte: usize,
204    ) -> Option<usize> {
205        if !self.collections.contains_key(&doc) {
206            return (source == doc).then_some(byte);
207        }
208        let collection = self.collections.get(&doc)?;
209        let source_buf = &self.docs.get(source)?.buf;
210        let view_buf = &self.docs.get(doc)?.buf;
211        let target_line = source_buf.line_of(byte.min(source_buf.len_bytes()));
212        for excerpt in &collection.excerpts {
213            if excerpt.source != source {
214                continue;
215            }
216            let first_line = source_buf.line_of(excerpt.start);
217            if target_line < first_line || target_line >= first_line + excerpt.view_lines {
218                continue;
219            }
220            let view_line = excerpt.view_line + 1 + (target_line - first_line);
221            if view_line > view_buf.last_content_line() {
222                return None;
223            }
224            let col = byte.saturating_sub(source_buf.line_start(target_line));
225            let start = view_buf.line_start(view_line);
226            return Some((start + col).min(view_buf.line_end(view_line)));
227        }
228        None
229    }
230
231    /// Own one cancellable match job (0051 §7): a superseded scan is
232    /// cancelled in place — never joined, never awaited on input.
233    fn request_match(&mut self, key: MatchKey, rope: ropey::Rope) {
234        if self.analysis.pair.pending_covers(&key) {
235            return; // the owned job already computes this key
236        }
237        if let Err(error) = self.analysis.start(&self.tape) {
238            self.message = format!("analysis: {error}");
239            return;
240        }
241        self.analysis.pair.cancel_target(&key.target);
242        let request = match self.worker_ids.allocate() {
243            Ok(id) => id,
244            Err(error) => {
245                self.message = error.message;
246                return;
247            }
248        };
249        let ticket = Ticket {
250            request,
251            key: key.clone(),
252        };
253        let cancel = Arc::new(AtomicBool::new(false));
254        self.analysis.register(key.target.clone());
255        self.analysis.pair.pending.insert(
256            key.target.clone(),
257            PendingMatch {
258                ticket: ticket.clone(),
259                cancel: cancel.clone(),
260            },
261        );
262        match self.tape.request("analysis.match", &ticket) {
263            Ok(false) => return,
264            Ok(true) => {}
265            Err(error) => {
266                self.handle_match(Completion {
267                    ticket,
268                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
269                });
270                return;
271            }
272        }
273        let work = worker::MatchWork {
274            ticket,
275            rope,
276            cancel,
277        };
278        let failed = match self.analysis.worker() {
279            Some(worker) => worker.match_work(work).err(),
280            None => Some(Box::new(work)),
281        };
282        if let Some(work) = failed {
283            self.handle_match(Completion {
284                ticket: work.ticket,
285                outcome: Outcome::failed(
286                    FailureKind::Disconnected,
287                    "display analysis worker stopped",
288                ),
289            });
290        }
291    }
292
293    /// Land a match completion (0051 §7): only the live request counts;
294    /// the source revision is rechecked before caching, so a result
295    /// computed against edited text is dropped, and the exact-key
296    /// lookup rechecks caret/mode/view on every frame. Unknown and
297    /// unmatched states cache None — they must not resubmit per frame
298    /// and must never paint a stale previous pair.
299    pub(crate) fn handle_match(&mut self, completion: Completion<MatchKey, Option<PairMatch>>) {
300        let key = completion.ticket.key;
301        if !self
302            .analysis
303            .pair
304            .pending
305            .get(&key.target)
306            .is_some_and(|pending| pending.ticket.request == completion.ticket.request)
307        {
308            return;
309        }
310        self.analysis.pair.pending.remove(&key.target);
311        let current = match &key.target {
312            AnalysisTarget::Document(document) => self
313                .docs
314                .get(*document)
315                .is_some_and(|document| document.buf.revision() == key.revision),
316            AnalysisTarget::Preview(path) => self.previews.contains_key(path),
317        };
318        if !current {
319            return;
320        }
321        match completion.outcome {
322            Outcome::Success(value) => self.analysis.pair.store(key, value),
323            Outcome::Failed { .. } => self.analysis.pair.store(key, None),
324            Outcome::Cancelled(_) => {}
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests;