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}