ailloli_ui_editor/code/
session.rs

1//! Document-aware editor session orchestration.
2
3use ailloli_ui_text::TextBuffer;
4
5#[cfg(feature = "tree_sitter")]
6use crate::code::highlight_rust_tree_sitter_hybrid;
7use crate::code::{
8    collapsed_region_hiding_line, diagnostic_at_byte, fold_regions_for_document,
9    highlight_rust_lexical, line_for_byte, line_start_byte, lsp_diagnostics_to_diagnostics,
10    merge_fold_regions_with_previous, CodeEditorConfig, Diagnostic, DiagnosticHit,
11    DiagnosticSource, Document, DocumentId, DocumentVersion, EditorLanguage, FoldRegion,
12    LspDiagnostic, LspEnrichment, SearchMatch, SearchQuery, SearchState, SyntaxToken,
13};
14use crate::EditorSession;
15
16/// Code-editor session: document metadata plus generic editor state and caches.
17///
18/// The public fields support adapter composition, but direct mutation can break
19/// the version stamps that guard syntax, fold, and search caches. Prefer the
20/// provided synchronization and setter methods when cache coherence matters.
21///
22/// # Examples
23///
24/// ```
25/// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
26/// use ailloli_ui_text::TextBuffer;
27/// let session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("fn main() {}")), CodeEditorConfig::default());
28/// assert_eq!(session.editor.buffer.as_str(), session.document.buffer.as_str());
29/// assert!(session.diagnostics.is_empty() && session.fold_regions.is_empty());
30/// ```
31#[derive(Debug, Clone)]
32pub struct CodeEditorSession {
33    /// Canonical document metadata and content.
34    pub document: Document,
35    /// Generic editable buffer, caret, selection, scroll, and input state.
36    pub editor: EditorSession,
37    /// Generic and code-specific behavior/style configuration.
38    pub config: CodeEditorConfig,
39    /// Local and current-version LSP diagnostics in display order.
40    pub diagnostics: Vec<Diagnostic>,
41    /// Selected diagnostic index, or `None`.
42    pub active_diagnostic_index: Option<usize>,
43    /// Query, cached matches, and active search result.
44    pub search: SearchState,
45    /// Current syntax-token cache.
46    pub syntax_tokens: Vec<SyntaxToken>,
47    /// Document identity from which syntax tokens were derived.
48    pub syntax_tokens_document_id: Option<DocumentId>,
49    /// Document version from which syntax tokens were derived.
50    pub syntax_tokens_version: Option<DocumentVersion>,
51    /// Language used to derive syntax tokens.
52    pub syntax_tokens_language: Option<EditorLanguage>,
53    /// Current logical fold regions.
54    pub fold_regions: Vec<FoldRegion>,
55    /// Document identity from which folds were derived.
56    pub fold_regions_document_id: Option<DocumentId>,
57    /// Document version from which folds were derived.
58    pub fold_regions_version: Option<DocumentVersion>,
59    /// Language used to derive folds.
60    pub fold_regions_language: Option<EditorLanguage>,
61}
62
63/// Synchronizes document/editor state and derived code caches.
64impl CodeEditorSession {
65    /// Creates a session with synchronized buffers and empty derived state.
66    ///
67    /// The document buffer is cheaply cloned into a generic [`EditorSession`]
68    /// using `config.editor`. Document version, dirty state, and metadata are
69    /// preserved; every cache provenance stamp starts as `None`.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
75    /// use ailloli_ui_text::TextBuffer;
76    /// let session = CodeEditorSession::new(Document::new(DocumentId(7), TextBuffer::from_string("text")), CodeEditorConfig::default());
77    /// assert_eq!(session.document.id, DocumentId(7));
78    /// assert!(session.syntax_tokens_document_id.is_none());
79    /// ```
80    pub fn new(document: Document, config: CodeEditorConfig) -> Self {
81        let editor = EditorSession::with_config(document.buffer.clone(), config.editor);
82        Self {
83            document,
84            editor,
85            config,
86            diagnostics: Vec::new(),
87            active_diagnostic_index: None,
88            search: SearchState::default(),
89            syntax_tokens: Vec::new(),
90            syntax_tokens_document_id: None,
91            syntax_tokens_version: None,
92            syntax_tokens_language: None,
93            fold_regions: Vec::new(),
94            fold_regions_document_id: None,
95            fold_regions_version: None,
96            fold_regions_language: None,
97        }
98    }
99
100    /// Borrows the generic editor session.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
106    /// use ailloli_ui_text::TextBuffer;
107    /// let session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("abc")), CodeEditorConfig::default());
108    /// assert_eq!(session.editor_session().buffer.as_str(), "abc");
109    /// ```
110    pub fn editor_session(&self) -> &EditorSession {
111        &self.editor
112    }
113
114    /// Mutably borrows the generic editor session.
115    ///
116    /// Buffer mutations made through this reference do not update the document
117    /// until [`Self::sync_document_from_editor`] is called.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
123    /// use ailloli_ui_text::TextBuffer;
124    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("abc")), CodeEditorConfig::default());
125    /// session.editor_session_mut().edit.caret_byte = 2;
126    /// assert_eq!(session.editor.edit.caret_byte, 2);
127    /// ```
128    pub fn editor_session_mut(&mut self) -> &mut EditorSession {
129        &mut self.editor
130    }
131
132    /// Copies changed editor text into the document.
133    ///
134    /// Returns `false` when strings are identical. A change rebuilds the
135    /// document buffer, sets `dirty = true`, and increments its `i32` version
136    /// with saturation at [`i32::MAX`]. Other document metadata is unchanged.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, DocumentVersion};
142    /// use ailloli_ui_text::TextBuffer;
143    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("old")), CodeEditorConfig::default());
144    /// session.editor.buffer = TextBuffer::from_string("new");
145    /// assert!(session.sync_document_from_editor());
146    /// assert_eq!(session.document.version, DocumentVersion(1));
147    /// assert!(session.document.dirty);
148    /// ```
149    pub fn sync_document_from_editor(&mut self) -> bool {
150        if self.document.buffer.as_str() == self.editor.buffer.as_str() {
151            return false;
152        }
153        self.document.buffer = TextBuffer::from_string(self.editor.buffer.as_str());
154        self.document.dirty = true;
155        self.document.version = DocumentVersion(self.document.version.0.saturating_add(1));
156        true
157    }
158
159    /// Replaces generic editor text from the document when its string differs.
160    ///
161    /// The generic session clamps its edit state and clears incompatible input
162    /// state according to [`EditorSession::replace_buffer_if_changed`]. Document
163    /// metadata and derived code caches are not changed here.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
169    /// use ailloli_ui_text::TextBuffer;
170    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("old")), CodeEditorConfig::default());
171    /// session.document.buffer = TextBuffer::from_string("new");
172    /// assert!(session.sync_editor_from_document());
173    /// assert_eq!(session.editor.buffer.as_str(), "new");
174    /// ```
175    pub fn sync_editor_from_document(&mut self) -> bool {
176        self.editor
177            .replace_buffer_if_changed(self.document.buffer.clone())
178    }
179
180    /// Replaces the complete document when any compared field differs.
181    ///
182    /// Text, ID, path, source, language, version, and dirty state participate;
183    /// internal buffer revision metadata does not when text is equal. A new ID
184    /// invalidates syntax/fold provenance and the search key. Text changes with
185    /// the same ID invalidate syntax/fold versions and search. Cached values are
186    /// retained until their next refresh.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
192    /// use ailloli_ui_text::TextBuffer;
193    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("one")), CodeEditorConfig::default());
194    /// let replacement = Document::new(DocumentId(2), TextBuffer::from_string("two"));
195    /// assert!(session.replace_document_if_changed(replacement));
196    /// assert_eq!(session.editor.buffer.as_str(), "two");
197    /// ```
198    pub fn replace_document_if_changed(&mut self, document: Document) -> bool {
199        let document_changed = self.document.buffer.as_str() != document.buffer.as_str()
200            || self.document.id != document.id
201            || self.document.path != document.path
202            || self.document.source != document.source
203            || self.document.language != document.language
204            || self.document.version != document.version
205            || self.document.dirty != document.dirty;
206        if !document_changed {
207            return false;
208        }
209
210        let previous_id = self.document.id;
211        let buffer_changed = self.document.buffer.as_str() != document.buffer.as_str();
212        self.document = document;
213        self.sync_editor_from_document();
214
215        if previous_id != self.document.id {
216            self.syntax_tokens_document_id = None;
217            self.syntax_tokens_version = None;
218            self.syntax_tokens_language = None;
219            self.fold_regions_document_id = None;
220            self.fold_regions_version = None;
221            self.fold_regions_language = None;
222            self.search.invalidate_cache();
223        } else if buffer_changed {
224            self.syntax_tokens_version = None;
225            self.fold_regions_version = None;
226            self.search.invalidate_cache();
227        }
228
229        true
230    }
231
232    /// Replaces diagnostics and retains only an in-range active index.
233    ///
234    /// Input order, ranges, duplicates, and sources are preserved verbatim.
235    ///
236    /// # Examples
237    ///
238    /// ```
239    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Diagnostic, DiagnosticSeverity, Document, DocumentId};
240    /// use ailloli_ui_text::TextBuffer;
241    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
242    /// session.set_diagnostics(vec![Diagnostic::new(0..0, DiagnosticSeverity::Hint, "hint")]);
243    /// assert_eq!(session.diagnostics.len(), 1);
244    /// ```
245    pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>) {
246        self.diagnostics = diagnostics;
247        self.active_diagnostic_index = self
248            .active_diagnostic_index
249            .filter(|idx| *idx < self.diagnostics.len());
250    }
251
252    /// Replaces all LSP diagnostics with current-version mapped inputs.
253    ///
254    /// Local diagnostics remain in order. Stale LSP inputs are discarded,
255    /// ranges clamp to document length, and empty ranges are removed. Returns
256    /// whether the resulting complete diagnostic vector differs.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, DiagnosticSeverity, Document, DocumentId, DocumentVersion, LspDiagnostic};
262    /// use ailloli_ui_text::TextBuffer;
263    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("abc")), CodeEditorConfig::default());
264    /// let input = [LspDiagnostic { range: 1..9, severity: DiagnosticSeverity::Error, message: "bad".into(), document_version: DocumentVersion(0) }];
265    /// assert!(session.apply_lsp_diagnostics(&input));
266    /// assert_eq!(session.diagnostics[0].range, 1..3);
267    /// ```
268    pub fn apply_lsp_diagnostics(&mut self, diagnostics: &[LspDiagnostic]) -> bool {
269        let mut next = self.diagnostics.clone();
270        next.retain(|diagnostic| diagnostic.source != DiagnosticSource::Lsp);
271        next.extend(lsp_diagnostics_to_diagnostics(&self.document, diagnostics));
272        if next == self.diagnostics {
273            return false;
274        }
275        self.set_diagnostics(next);
276        true
277    }
278
279    /// Applies only the diagnostic portion of current-version LSP enrichment.
280    ///
281    /// A stale enrichment returns `false` without changes. Current enrichment
282    /// removes prior LSP diagnostics, retains local diagnostics, and accepts only
283    /// entries explicitly marked LSP with the current document version. Symbols,
284    /// references, and capabilities are intentionally ignored by this session.
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, DocumentVersion, LspCapabilities, LspEnrichment};
290    /// use ailloli_ui_text::TextBuffer;
291    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
292    /// let stale = LspEnrichment { document_version: DocumentVersion(9), capabilities: LspCapabilities::default(), symbols: vec![], references: vec![], diagnostics: vec![] };
293    /// assert!(!session.apply_lsp_enrichment(&stale));
294    /// ```
295    pub fn apply_lsp_enrichment(&mut self, enrichment: &LspEnrichment) -> bool {
296        if enrichment.document_version != self.document.version {
297            return false;
298        }
299        let mut next = self.diagnostics.clone();
300        next.retain(|diagnostic| diagnostic.source != DiagnosticSource::Lsp);
301        next.extend(
302            enrichment
303                .diagnostics
304                .iter()
305                .filter(|diagnostic| {
306                    diagnostic.source == DiagnosticSource::Lsp
307                        && diagnostic.document_version == Some(self.document.version)
308                })
309                .cloned(),
310        );
311        if next == self.diagnostics {
312            return false;
313        }
314        self.set_diagnostics(next);
315        true
316    }
317
318    /// Selects an in-range diagnostic index, otherwise stores `None`.
319    ///
320    /// # Examples
321    ///
322    /// ```
323    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Diagnostic, DiagnosticSeverity, Document, DocumentId};
324    /// use ailloli_ui_text::TextBuffer;
325    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
326    /// session.set_diagnostics(vec![Diagnostic::new(0..0, DiagnosticSeverity::Info, "note")]);
327    /// session.set_active_diagnostic_index(Some(1));
328    /// assert_eq!(session.active_diagnostic_index, None);
329    /// ```
330    pub fn set_active_diagnostic_index(&mut self, active_diagnostic_index: Option<usize>) {
331        self.active_diagnostic_index =
332            active_diagnostic_index.filter(|idx| *idx < self.diagnostics.len());
333    }
334
335    /// Returns the first diagnostic whose inclusive range contains `byte`.
336    ///
337    /// The returned diagnostic is cloned and includes its slice index.
338    ///
339    /// # Examples
340    ///
341    /// ```
342    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Diagnostic, DiagnosticSeverity, Document, DocumentId};
343    /// use ailloli_ui_text::TextBuffer;
344    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
345    /// session.set_diagnostics(vec![Diagnostic::new(2..4, DiagnosticSeverity::Error, "bad")]);
346    /// assert_eq!(session.diagnostic_at_byte(4).unwrap().index, 0);
347    /// ```
348    pub fn diagnostic_at_byte(&self, byte: usize) -> Option<DiagnosticHit> {
349        diagnostic_at_byte(&self.diagnostics, byte)
350    }
351
352    /// Installs externally computed matches and clears active selection.
353    ///
354    /// The query and existing cache key are left unchanged. A later refresh may
355    /// preserve these matches on a cache hit or replace them after invalidation.
356    /// Ranges are not validated against the document.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchMatch};
362    /// use ailloli_ui_text::TextBuffer;
363    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("abc")), CodeEditorConfig::default());
364    /// session.set_search_matches(vec![SearchMatch { range: 1..2 }]);
365    /// assert_eq!(session.search.matches[0].range, 1..2);
366    /// assert_eq!(session.search.active_index, None);
367    /// ```
368    pub fn set_search_matches(&mut self, search_matches: Vec<SearchMatch>) {
369        self.search.matches = search_matches;
370        self.search.set_active_index(None);
371    }
372
373    /// Sets a query and immediately refreshes it against the current document.
374    ///
375    /// An equal query can reuse the current version/query cache; a changed query
376    /// clears old results before recomputation.
377    ///
378    /// # Examples
379    ///
380    /// ```
381    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchQuery};
382    /// use ailloli_ui_text::TextBuffer;
383    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("go go")), CodeEditorConfig::default());
384    /// session.set_search_query(SearchQuery::new("go"));
385    /// assert_eq!(session.search.matches.len(), 2);
386    /// ```
387    pub fn set_search_query(&mut self, query: SearchQuery) {
388        self.search.set_query(query);
389        self.refresh_search();
390    }
391
392    /// Selects an in-range search match, otherwise clears the selection.
393    ///
394    /// # Examples
395    ///
396    /// ```
397    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchQuery};
398    /// use ailloli_ui_text::TextBuffer;
399    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("x x")), CodeEditorConfig::default());
400    /// session.set_search_query(SearchQuery::new("x"));
401    /// session.set_search_active_index(Some(1));
402    /// assert_eq!(session.search.active_index, Some(1));
403    /// ```
404    pub fn set_search_active_index(&mut self, active_index: Option<usize>) {
405        self.search.set_active_index(active_index);
406    }
407
408    /// Refreshes search results for the current text, version, and query.
409    ///
410    /// Returns `true` when recomputed and `false` on a cache hit. Direct text
411    /// mutation without a corresponding document-version change can therefore
412    /// produce a stale cache hit.
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchQuery};
418    /// use ailloli_ui_text::TextBuffer;
419    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("abc")), CodeEditorConfig::default());
420    /// session.search.set_query(SearchQuery::new("b"));
421    /// assert!(session.refresh_search());
422    /// assert!(!session.refresh_search());
423    /// ```
424    pub fn refresh_search(&mut self) -> bool {
425        self.search
426            .refresh(&self.document.buffer.as_str(), self.document.version)
427    }
428
429    /// Refreshes, advances to, and returns the next match with wraparound.
430    ///
431    /// A refresh selects index zero, so for multiple fresh matches this call
432    /// returns index one. With no matches it returns `None`.
433    ///
434    /// # Examples
435    ///
436    /// ```
437    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchQuery};
438    /// use ailloli_ui_text::TextBuffer;
439    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("x x")), CodeEditorConfig::default());
440    /// session.search.set_query(SearchQuery::new("x"));
441    /// assert_eq!(session.search_next().map(|m| m.range.clone()), Some(2..3));
442    /// ```
443    pub fn search_next(&mut self) -> Option<&SearchMatch> {
444        self.refresh_search();
445        self.search.next_match()
446    }
447
448    /// Refreshes, moves to, and returns the previous match with wraparound.
449    ///
450    /// # Examples
451    ///
452    /// ```
453    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchQuery};
454    /// use ailloli_ui_text::TextBuffer;
455    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("x x")), CodeEditorConfig::default());
456    /// session.search.set_query(SearchQuery::new("x"));
457    /// assert_eq!(session.search_previous().map(|m| m.range.clone()), Some(2..3));
458    /// ```
459    pub fn search_previous(&mut self) -> Option<&SearchMatch> {
460        self.refresh_search();
461        self.search.previous_match()
462    }
463
464    /// Restores empty query, results, active index, and search cache.
465    ///
466    /// # Examples
467    ///
468    /// ```
469    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, SearchQuery};
470    /// use ailloli_ui_text::TextBuffer;
471    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("x")), CodeEditorConfig::default());
472    /// session.set_search_query(SearchQuery::new("x"));
473    /// session.clear_search();
474    /// assert!(session.search.query.text.is_empty());
475    /// ```
476    pub fn clear_search(&mut self) {
477        self.search.clear();
478    }
479
480    /// Refreshes syntax and selects a syntax token or lexical word at `byte`.
481    ///
482    /// Returns whether generic editor caret/selection state changed. Byte input
483    /// is clamped by selection helpers and the editor buffer remains unchanged.
484    ///
485    /// # Examples
486    ///
487    /// ```
488    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, EditorLanguage};
489    /// use ailloli_ui_text::TextBuffer;
490    /// let document = Document::new(DocumentId(1), TextBuffer::from_string("hello world")).with_language(EditorLanguage::PlainText);
491    /// let mut session = CodeEditorSession::new(document, CodeEditorConfig::default());
492    /// assert!(session.select_word_at_byte(1));
493    /// assert_eq!(session.editor.edit.selection.unwrap().normalized(), (0, 5));
494    /// ```
495    pub fn select_word_at_byte(&mut self, byte: usize) -> bool {
496        self.refresh_syntax_tokens();
497        self.editor
498            .select_word_at_byte(byte, Some(&self.syntax_tokens), self.document.language)
499    }
500
501    /// Selects the logical line containing `byte`, excluding its newline.
502    ///
503    /// Returns whether generic editor state changed.
504    ///
505    /// # Examples
506    ///
507    /// ```
508    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
509    /// use ailloli_ui_text::TextBuffer;
510    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("one\ntwo")), CodeEditorConfig::default());
511    /// assert!(session.select_line_at_byte(5));
512    /// assert_eq!(session.editor.edit.selection.unwrap().normalized(), (4, 7));
513    /// ```
514    pub fn select_line_at_byte(&mut self, byte: usize) -> bool {
515        self.editor.select_line_at_byte(byte)
516    }
517
518    /// Installs manual syntax tokens and clears all provenance stamps.
519    ///
520    /// Tokens are stored without normalization. Because stamps become `None`,
521    /// the next syntax refresh (including word selection) replaces this manual
522    /// vector with language-derived tokens.
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// use ailloli_ui_editor::{code::{SyntaxKind, SyntaxToken}, CodeEditorConfig, CodeEditorSession, Document, DocumentId};
528    /// use ailloli_ui_text::TextBuffer;
529    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("x")), CodeEditorConfig::default());
530    /// session.set_syntax_tokens(vec![SyntaxToken { range: 0..1, kind: SyntaxKind::Identifier }]);
531    /// assert_eq!(session.syntax_tokens.len(), 1);
532    /// assert!(session.syntax_tokens_version.is_none());
533    /// ```
534    pub fn set_syntax_tokens(&mut self, syntax_tokens: Vec<SyntaxToken>) {
535        self.syntax_tokens = syntax_tokens;
536        self.syntax_tokens_document_id = None;
537        self.syntax_tokens_version = None;
538        self.syntax_tokens_language = None;
539    }
540
541    /// Installs manual fold regions and clears all provenance stamps.
542    ///
543    /// Regions and collapsed states are stored without validation. The next fold
544    /// refresh treats them as belonging to no document and replaces them rather
545    /// than preserving their collapsed state.
546    ///
547    /// # Examples
548    ///
549    /// ```
550    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, FoldRegion};
551    /// use ailloli_ui_text::TextBuffer;
552    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::new()), CodeEditorConfig::default());
553    /// session.set_fold_regions(vec![FoldRegion::new(0, 2)]);
554    /// assert_eq!(session.fold_regions.len(), 1);
555    /// assert!(session.fold_regions_document_id.is_none());
556    /// ```
557    pub fn set_fold_regions(&mut self, fold_regions: Vec<FoldRegion>) {
558        self.fold_regions = fold_regions;
559        self.fold_regions_document_id = None;
560        self.fold_regions_version = None;
561        self.fold_regions_language = None;
562    }
563
564    /// Refreshes language-derived fold regions when identity/version/language changes.
565    ///
566    /// On the same document ID, matching region IDs inherit prior collapsed
567    /// state. A different ID discards it. Without `tree_sitter`, or for an
568    /// unsupported language, the refreshed vector is empty. Provenance stamps
569    /// are updated even when discovery returns no regions.
570    ///
571    /// # Examples
572    ///
573    /// ```
574    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId};
575    /// use ailloli_ui_text::TextBuffer;
576    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("plain")), CodeEditorConfig::default());
577    /// session.refresh_fold_regions();
578    /// assert_eq!(session.fold_regions_document_id, Some(DocumentId(1)));
579    /// assert!(session.fold_regions.is_empty());
580    /// ```
581    pub fn refresh_fold_regions(&mut self) {
582        let same_document = self.fold_regions_document_id == Some(self.document.id);
583        if same_document
584            && self.fold_regions_version == Some(self.document.version)
585            && self.fold_regions_language == Some(self.document.language)
586        {
587            return;
588        }
589        let next =
590            fold_regions_for_document(self.document.language, &self.document.buffer.as_str());
591        self.fold_regions = if same_document {
592            merge_fold_regions_with_previous(next, &self.fold_regions)
593        } else {
594            next
595        };
596        self.fold_regions_document_id = Some(self.document.id);
597        self.fold_regions_version = Some(self.document.version);
598        self.fold_regions_language = Some(self.document.language);
599    }
600
601    /// Toggles one fold region by slice index.
602    ///
603    /// Returns `false` for an out-of-range index. Collapsing also moves a caret
604    /// hidden by any collapsed region to that region's header; expanding does
605    /// not move it. A valid index returns `true` even if caret state is unchanged.
606    ///
607    /// # Examples
608    ///
609    /// ```
610    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, FoldRegion};
611    /// use ailloli_ui_text::TextBuffer;
612    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("a\nb")), CodeEditorConfig::default());
613    /// session.set_fold_regions(vec![FoldRegion::new(0, 1)]);
614    /// assert!(session.toggle_fold_region(0));
615    /// assert!(session.fold_regions[0].collapsed);
616    /// assert!(!session.toggle_fold_region(9));
617    /// ```
618    pub fn toggle_fold_region(&mut self, index: usize) -> bool {
619        let Some(region) = self.fold_regions.get_mut(index) else {
620            return false;
621        };
622        region.collapsed = !region.collapsed;
623        if region.collapsed {
624            self.move_caret_out_of_folded_regions();
625        }
626        true
627    }
628
629    /// Moves a caret hidden by the first collapsed region to its header line.
630    ///
631    /// Returns `false` when the caret is visible or setting it causes no editor
632    /// state change. Selection is cleared and desired horizontal position reset
633    /// by the generic caret setter. The document and editor buffers are expected
634    /// to be synchronized and the caret to be a valid UTF-8 boundary.
635    ///
636    /// # Examples
637    ///
638    /// ```
639    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, FoldRegion};
640    /// use ailloli_ui_text::TextBuffer;
641    /// let mut session = CodeEditorSession::new(Document::new(DocumentId(1), TextBuffer::from_string("a\nb\nc")), CodeEditorConfig::default());
642    /// session.editor.edit.caret_byte = 4;
643    /// session.fold_regions = vec![FoldRegion::new(0, 2).collapsed(true)];
644    /// assert!(session.move_caret_out_of_folded_regions());
645    /// assert_eq!(session.editor.edit.caret_byte, 0);
646    /// ```
647    pub fn move_caret_out_of_folded_regions(&mut self) -> bool {
648        let line = line_for_byte(&self.document.buffer.as_str(), self.editor.edit.caret_byte);
649        let Some(region) = collapsed_region_hiding_line(&self.fold_regions, line) else {
650            return false;
651        };
652        let byte = line_start_byte(&self.document.buffer.as_str(), region.start_line);
653        self.editor.edit.set_caret(&self.editor.buffer, byte, false)
654    }
655
656    /// Refreshes syntax tokens when document ID, version, or language changes.
657    ///
658    /// Rust uses hybrid Tree-sitter tokens when enabled and successful, then
659    /// falls back to the lexical highlighter. Every other language yields an
660    /// empty vector. Provenance stamps are updated even for empty results, so an
661    /// unchanged subsequent call is a no-op.
662    ///
663    /// # Examples
664    ///
665    /// ```
666    /// use ailloli_ui_editor::{CodeEditorConfig, CodeEditorSession, Document, DocumentId, EditorLanguage};
667    /// use ailloli_ui_text::TextBuffer;
668    /// let document = Document::new(DocumentId(1), TextBuffer::from_string("fn main() {}"))
669    ///     .with_language(EditorLanguage::Rust);
670    /// let mut session = CodeEditorSession::new(document, CodeEditorConfig::default());
671    /// session.refresh_syntax_tokens();
672    /// assert!(!session.syntax_tokens.is_empty());
673    /// assert_eq!(session.syntax_tokens_language, Some(EditorLanguage::Rust));
674    /// ```
675    pub fn refresh_syntax_tokens(&mut self) {
676        if self.syntax_tokens_document_id == Some(self.document.id)
677            && self.syntax_tokens_version == Some(self.document.version)
678            && self.syntax_tokens_language == Some(self.document.language)
679        {
680            return;
681        }
682        self.syntax_tokens = match self.document.language {
683            EditorLanguage::Rust => {
684                #[cfg(feature = "tree_sitter")]
685                {
686                    highlight_rust_tree_sitter_hybrid(&self.document.buffer.as_str())
687                        .unwrap_or_else(|| highlight_rust_lexical(&self.document.buffer.as_str()))
688                }
689                #[cfg(not(feature = "tree_sitter"))]
690                {
691                    highlight_rust_lexical(&self.document.buffer.as_str())
692                }
693            }
694            _ => Vec::new(),
695        };
696        self.syntax_tokens_document_id = Some(self.document.id);
697        self.syntax_tokens_version = Some(self.document.version);
698        self.syntax_tokens_language = Some(self.document.language);
699    }
700}