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