Skip to main content

strop_engine/editor/analysis/
mod.rs

1//! UI-side analysis ownership. Frozen ropes go to one actor; frames consume only
2//! immutable, revision-matched viewport results. Native parser state stays there.
3pub(crate) mod layouts;
4mod search;
5pub(crate) mod worker;
6use super::matching::{MatchKey, PairMatch, PairState};
7use super::{document::DocumentSource, Editor};
8use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{mpsc, Arc};
12use strop_core::id::{BufferRevision, DocumentId};
13use strop_core::worker::{Completion, Outcome, Ticket};
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
16pub enum AnalysisTarget {
17    Document(DocumentId),
18    Preview(#[serde(with = "strop_core::path_serde")] PathBuf),
19}
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21pub struct AnalysisKey {
22    pub target: AnalysisTarget,
23    pub revision: BufferRevision,
24    pub first: usize,
25    pub last: usize,
26    pub tab: usize,
27    pub guides: bool,
28    pub left: usize,
29    pub right: usize,
30    #[serde(with = "strop_core::path_serde::option")]
31    pub syntax_path: Option<PathBuf>,
32    pub search: Option<strop_grammar::CompiledQuery>,
33}
34#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
35pub struct FrameAnalysis {
36    pub spans: Vec<strop_syntax::Span>,
37    pub guides: strop_syntax::GuideFrame,
38    pub search: Option<Result<SearchSummary, String>>,
39    pub layouts: Vec<strop_core::layout::PreparedLineLayout>,
40}
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub struct SearchSummary {
43    pub count: usize,
44    pub hits: Vec<strop_grammar::SearchMatch>,
45}
46#[derive(serde::Serialize, serde::Deserialize)]
47pub enum AnalysisEvent {
48    Completed(Box<Completion<AnalysisKey, FrameAnalysis>>),
49    /// A matching-delimiter job landed (0051 §7 R09).
50    Matched(Box<Completion<MatchKey, Option<PairMatch>>>),
51    Stopped,
52}
53struct Pending {
54    ticket: Ticket<AnalysisKey>,
55    cancel: Arc<AtomicBool>,
56}
57struct Cached {
58    key: AnalysisKey,
59    value: Option<Arc<FrameAnalysis>>,
60}
61pub(crate) struct AnalysisState {
62    pub tx: mpsc::Sender<AnalysisEvent>,
63    pub rx: Option<mpsc::Receiver<AnalysisEvent>>,
64    worker: Option<worker::Worker>,
65    registered: HashSet<AnalysisTarget>,
66    pending: HashMap<AnalysisTarget, Pending>,
67    cache: HashMap<AnalysisTarget, Vec<Cached>>,
68    /// Matching-delimiter jobs/results (0051 §7 R09) — same worker,
69    /// same lifecycle.
70    pub(crate) pair: PairState,
71    started: bool,
72    stopping: bool,
73}
74impl Default for AnalysisState {
75    fn default() -> Self {
76        let (tx, rx) = mpsc::channel();
77        Self {
78            tx,
79            rx: Some(rx),
80            worker: None,
81            registered: HashSet::new(),
82            pending: HashMap::new(),
83            cache: HashMap::new(),
84            pair: PairState::default(),
85            started: false,
86            stopping: false,
87        }
88    }
89}
90impl AnalysisState {
91    pub fn pending(&self) -> bool {
92        !self.pending.is_empty() || !self.pair.pending_empty() || self.stopping
93    }
94    pub(crate) fn start(&mut self, tape: &strop_trace::replay::Tape) -> Result<(), String> {
95        if self.started {
96            return Ok(());
97        }
98        let result: Result<(), String> = tape
99            .call("analysis.start", &(), || {
100                worker::Worker::start(self.tx.clone())
101                    .map(|worker| self.worker = Some(worker))
102                    .map_err(|error| error.to_string())
103            })
104            .map_err(|error| error.to_string())?;
105        result?;
106        self.started = true;
107        Ok(())
108    }
109
110    /// Match jobs share the worker and its edit journal (0051 §7 R09).
111    pub(crate) fn register(&mut self, target: AnalysisTarget) {
112        self.registered.insert(target);
113    }
114
115    pub(crate) fn worker(&self) -> Option<&worker::Worker> {
116        self.worker.as_ref()
117    }
118    pub fn edits(&mut self, document: DocumentId, changes: &[strop_core::Change]) {
119        let target = AnalysisTarget::Document(document);
120        // a queued match scan for text that just changed is unwanted
121        // work (0051 §7 R09): its delivery would fail the revision
122        // recheck anyway — cancel the scan itself, not only the guard
123        self.pair.cancel_target(&target);
124        if !self.registered.contains(&target) {
125            return;
126        }
127        if let Some(pending) = self.pending.get(&target) {
128            pending.cancel.store(true, Ordering::Release);
129        }
130        if let Some(worker) = &self.worker {
131            if !worker.edits(target, changes.to_vec()) {
132                self.started = false;
133            }
134        }
135    }
136
137    pub fn forget(&mut self, target: AnalysisTarget) {
138        if let Some(pending) = self.pending.remove(&target) {
139            pending.cancel.store(true, Ordering::Release);
140        }
141        self.pair.forget(&target);
142        self.cache.remove(&target);
143        self.registered.remove(&target);
144        if let Some(worker) = &self.worker {
145            if !worker.forget(target) {
146                self.started = false;
147            }
148        }
149    }
150
151    pub fn stop(&mut self) {
152        for pending in self.pending.values() {
153            pending.cancel.store(true, Ordering::Release);
154        }
155        self.pair.cancel_all();
156        self.stopping = self.started;
157        self.worker = None;
158    }
159}
160
161/// A stale frame served for an interim frame: spans clipped to the live
162/// text length so a shrink can never hand an out-of-range range to the
163/// renderer. Guides/search ride along unclipped — they are approximate
164/// for one frame by design.
165pub fn clip_stale_frame(frame: Arc<FrameAnalysis>, len: usize) -> Arc<FrameAnalysis> {
166    let mut clipped = (*frame).clone();
167    clipped.spans.retain(|span| span.start < len);
168    for span in &mut clipped.spans {
169        span.end = span.end.min(len);
170    }
171    Arc::new(clipped)
172}
173impl Editor {
174    pub fn document_analysis(
175        &mut self,
176        document: DocumentId,
177        first: usize,
178        last: usize,
179        left: usize,
180        width: usize,
181    ) -> Option<Arc<FrameAnalysis>> {
182        if self.finishing {
183            return None;
184        }
185        let doc = self.docs.get(document)?;
186        let guides = self.config.indent_guides
187            && matches!(
188                doc.source,
189                DocumentSource::File | DocumentSource::Scratch | DocumentSource::Remote(_)
190            );
191        let target = AnalysisTarget::Document(document);
192        let search = if document == self.current() {
193            self.current_search_query().ok().flatten()
194        } else {
195            None
196        };
197        if !guides && doc.syntax_path().is_none() && search.is_none() {
198            return None;
199        }
200        let key = AnalysisKey {
201            target,
202            revision: doc.buf.revision(),
203            first,
204            last,
205            tab: doc.indent.width.max(1),
206            guides,
207            left,
208            right: left.saturating_add(width),
209            syntax_path: doc.syntax_path().map(std::path::Path::to_path_buf),
210            search,
211        };
212        if let Some(cached) = self
213            .analysis
214            .cache
215            .get(&key.target)
216            .and_then(|entries| entries.iter().find(|entry| entry.key == key))
217        {
218            return cached.value.clone();
219        }
220        // An edit changes the revision before the worker's fresh frame
221        // lands. Serve the newest older frame for the same window and
222        // signature — highlights track one frame behind (spans clipped
223        // to the live length) instead of blanking for a frame.
224        let stale = self
225            .analysis
226            .cache
227            .get(&key.target)
228            .and_then(|entries| {
229                entries
230                    .iter()
231                    .rev()
232                    .find(|entry| {
233                        entry.key.first == key.first
234                            && entry.key.last == key.last
235                            && entry.key.tab == key.tab
236                            && entry.key.search == key.search
237                            && entry.key.revision.get() < key.revision.get()
238                            && entry.value.is_some()
239                    })
240                    .and_then(|entry| entry.value.clone())
241            })
242            .map(|frame| clip_stale_frame(frame, doc.buf.len_bytes()));
243        if let Some(pending) = self.analysis.pending.get(&key.target) {
244            if pending.ticket.key.revision != key.revision
245                || pending.ticket.key.search != key.search
246            {
247                pending.cancel.store(true, Ordering::Release);
248            }
249            return stale;
250        }
251        let rope = doc.buf.snapshot();
252        self.request_analysis(key, rope);
253        stale
254    }
255
256    pub fn preview_analysis(
257        &mut self,
258        path: &std::path::Path,
259        first: usize,
260        last: usize,
261        width: usize,
262    ) -> Option<Arc<FrameAnalysis>> {
263        if self.finishing {
264            return None;
265        }
266        let entry = self.previews.get(path)?;
267        let key = AnalysisKey {
268            target: AnalysisTarget::Preview(path.to_path_buf()),
269            revision: BufferRevision::new(0),
270            first,
271            last,
272            tab: self.cur_indent().width.max(1),
273            guides: false,
274            left: 0,
275            right: width,
276            syntax_path: Some(path.to_path_buf()),
277            search: None,
278        };
279        if let Some(cached) = self
280            .analysis
281            .cache
282            .get(&key.target)
283            .and_then(|entries| entries.iter().find(|entry| entry.key == key))
284        {
285            return cached.value.clone();
286        }
287        if self.analysis.pending.contains_key(&key.target) {
288            return None;
289        }
290        let rope = entry.rope.clone();
291        self.request_analysis(key, rope);
292        None
293    }
294
295    pub fn search_summary(
296        &self,
297        query: &strop_grammar::CompiledQuery,
298    ) -> Option<&Result<SearchSummary, String>> {
299        self.analysis
300            .cache
301            .get(&AnalysisTarget::Document(self.current()))?
302            .iter()
303            .rev()
304            .find(|entry| {
305                entry.key.revision == self.buf().revision()
306                    && entry.key.search.as_ref() == Some(query)
307            })?
308            .value
309            .as_ref()?
310            .search
311            .as_ref()
312    }
313
314    fn request_analysis(&mut self, key: AnalysisKey, rope: ropey::Rope) {
315        if let Err(error) = self.analysis.start(&self.tape) {
316            self.message = format!("analysis: {error}");
317            self.analysis
318                .cache
319                .entry(key.target.clone())
320                .or_default()
321                .push(Cached { key, value: None });
322            return;
323        }
324        let request = match self.worker_ids.allocate() {
325            Ok(id) => id,
326            Err(error) => {
327                self.message = error.message;
328                return;
329            }
330        };
331        let ticket = Ticket {
332            request,
333            key: key.clone(),
334        };
335        let cancel = Arc::new(AtomicBool::new(false));
336        self.analysis.registered.insert(key.target.clone());
337        self.analysis.pending.insert(
338            key.target.clone(),
339            Pending {
340                ticket: ticket.clone(),
341                cancel: cancel.clone(),
342            },
343        );
344        match self.tape.request("analysis.viewport", &ticket) {
345            Ok(false) => return,
346            Ok(true) => {}
347            Err(error) => {
348                self.handle_analysis(AnalysisEvent::Completed(Box::new(Completion {
349                    ticket,
350                    outcome: Outcome::failed(
351                        strop_core::worker::FailureKind::Protocol,
352                        error.to_string(),
353                    ),
354                })));
355                return;
356            }
357        }
358        let work = worker::Work {
359            ticket,
360            rope,
361            cancel,
362        };
363        let failed = match &self.analysis.worker {
364            Some(worker) => worker.analyze(work).err(),
365            None => Some(Box::new(work)),
366        };
367        if let Some(work) = failed {
368            self.handle_analysis(AnalysisEvent::Completed(Box::new(Completion {
369                ticket: work.ticket,
370                outcome: Outcome::failed(
371                    strop_core::worker::FailureKind::Disconnected,
372                    "display analysis worker stopped",
373                ),
374            })));
375        }
376    }
377
378    pub(crate) fn handle_analysis(&mut self, event: AnalysisEvent) {
379        let completion = match event {
380            AnalysisEvent::Stopped => {
381                self.analysis.stopping = false;
382                self.analysis.started = false;
383                return;
384            }
385            AnalysisEvent::Matched(completion) => {
386                self.handle_match(*completion);
387                return;
388            }
389            AnalysisEvent::Completed(completion) => completion,
390        };
391        let key = completion.ticket.key;
392        if !self
393            .analysis
394            .pending
395            .get(&key.target)
396            .is_some_and(|pending| pending.ticket.request == completion.ticket.request)
397        {
398            return;
399        }
400        self.analysis.pending.remove(&key.target);
401        let current = match &key.target {
402            AnalysisTarget::Document(document) => self
403                .docs
404                .get(*document)
405                .is_some_and(|document| document.buf.revision() == key.revision),
406            AnalysisTarget::Preview(path) => self.previews.contains_key(path),
407        };
408        if !current {
409            return;
410        }
411        let value = match completion.outcome {
412            Outcome::Success(frame) => {
413                if let AnalysisTarget::Document(document) = key.target {
414                    if let Some(document) = self.docs.get_mut(document) {
415                        if !document
416                            .buf
417                            .install_line_layouts(key.revision, &frame.layouts)
418                        {
419                            self.message = "invalid layout publication".into();
420                            return;
421                        }
422                    }
423                }
424                Some(Arc::new(frame))
425            }
426            Outcome::Failed { failure, .. } => {
427                self.message = format!("analysis: {}", failure.message);
428                None
429            }
430            Outcome::Cancelled(_) => return,
431        };
432        let entries = self.analysis.cache.entry(key.target.clone()).or_default();
433        // Keep the previous revision's frames too: a miss on the current
434        // revision serves the newest older frame for the interim
435        // (document_analysis' stale serve — the no-flicker path).
436        let previous = key.revision.get().saturating_sub(1);
437        entries.retain(|entry| entry.key.revision.get() >= previous && entry.key.tab == key.tab);
438        const CACHED_WINDOWS: usize = 8;
439        if entries.len() == CACHED_WINDOWS {
440            entries.remove(0);
441        }
442        entries.push(Cached { key, value });
443    }
444}
445
446#[cfg(any(test, feature = "test-support"))]
447impl Editor {
448    pub fn analysis_fixture(&mut self) -> Arc<FrameAnalysis> {
449        loop {
450            if let Some(frame) =
451                self.document_analysis(self.current(), 0, self.buf().len_bytes(), 0, 80)
452            {
453                return frame;
454            }
455            let event = self
456                .analysis
457                .rx
458                .as_ref()
459                .expect("unforwarded analysis")
460                .recv_timeout(std::time::Duration::from_secs(5))
461                .expect("analysis completion");
462            self.handle_analysis(event);
463        }
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use strop_core::worker::WorkerId;
471    use strop_core::Buffer;
472    use strop_syntax::{Class, Emphasis, Span};
473
474    /// The interim frame (field report: highlights blank for a frame
475    /// after each edit): with a cached frame at revision 0, an edit to
476    /// revision 1 must still serve spans — clipped to the live text.
477    #[test]
478    fn an_edit_serves_the_previous_frame_instead_of_blanking() {
479        let mut e = Editor::new(Buffer::from_text("fn main() {}\n"));
480        e.buf_mut().path = Some(PathBuf::from("/workspace/a.rs"));
481        let doc = e.current();
482        let target = AnalysisTarget::Document(doc);
483        let revision = e.buf().revision();
484        let key = AnalysisKey {
485            target: target.clone(),
486            revision,
487            first: 0,
488            last: 0,
489            tab: 4,
490            guides: true,
491            left: 0,
492            right: 100,
493            syntax_path: Some(PathBuf::from("/workspace/a.rs")),
494            search: None,
495        };
496        let frame = FrameAnalysis {
497            spans: vec![Span {
498                start: 0,
499                end: 2,
500                class: Class::Keyword,
501                emphasis: Emphasis::default(),
502            }],
503            ..Default::default()
504        };
505        e.analysis.pending.insert(
506            target.clone(),
507            Pending {
508                ticket: Ticket {
509                    request: WorkerId::new(1),
510                    key: key.clone(),
511                },
512                cancel: Arc::new(AtomicBool::new(false)),
513            },
514        );
515        e.handle_analysis(AnalysisEvent::Completed(Box::new(Completion {
516            ticket: Ticket {
517                request: WorkerId::new(1),
518                key,
519            },
520            outcome: Outcome::Success(frame),
521        })));
522        assert!(e.document_analysis(doc, 0, 0, 0, 100).is_some());
523        e.feed_text("x"); // revision moves; no fresh frame exists yet
524        let served = e.document_analysis(doc, 0, 0, 0, 100);
525        assert!(
526            served.is_some(),
527            "an interim frame serves the previous analysis"
528        );
529        // shrink the text past the span: clipping keeps it in range
530        e.feed_text("0wD");
531        let _ = e.document_analysis(doc, 0, 0, 0, 100);
532    }
533}