fresh-editor 0.1.43

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
//! Unified highlighting engine
//!
//! This module provides a unified abstraction over different highlighting backends:
//! - TextMate grammars via syntect (default for highlighting)
//! - Tree-sitter (available via explicit preference, also used for non-highlighting features)
//!
//! # Backend Selection
//! By default, syntect/TextMate is used for syntax highlighting because it provides
//! broader language coverage. Tree-sitter language detection is still performed
//! to support non-highlighting features like auto-indentation and semantic highlighting.
//!
//! # Non-Highlighting Features
//! Even when using TextMate for highlighting, tree-sitter `Language` is detected
//! and available via `.language()` for:
//! - Auto-indentation (via IndentCalculator)
//! - Semantic highlighting (variable scope tracking)
//! - Other syntax-aware features

use crate::model::buffer::Buffer;
use crate::primitives::grammar_registry::GrammarRegistry;
use crate::primitives::highlighter::{HighlightSpan, Highlighter, Language};
use crate::view::theme::Theme;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use syntect::parsing::SyntaxSet;

/// Preference for which highlighting backend to use
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HighlighterPreference {
    /// Use TextMate/syntect for highlighting (default)
    /// Tree-sitter language is still detected for other features (indentation, semantic highlighting)
    #[default]
    Auto,
    /// Force tree-sitter for highlighting (useful for testing/comparison)
    TreeSitter,
    /// Explicitly use TextMate grammar (same as Auto)
    TextMate,
}

/// Unified highlighting engine supporting multiple backends
pub enum HighlightEngine {
    /// Tree-sitter based highlighting (built-in languages)
    TreeSitter(Highlighter),
    /// TextMate grammar based highlighting
    TextMate(TextMateEngine),
    /// No highlighting available
    None,
}

/// TextMate highlighting engine wrapper
///
/// This struct handles the lifetime complexities of syntect by storing
/// the syntax set and using indices rather than references.
pub struct TextMateEngine {
    syntax_set: Arc<SyntaxSet>,
    syntax_index: usize,
    cache: Option<TextMateCache>,
    last_buffer_len: usize,
    /// Tree-sitter language for non-highlighting features (indentation, semantic highlighting)
    /// Even when using syntect for highlighting, we track the language for other features
    ts_language: Option<Language>,
}

#[derive(Debug, Clone)]
struct TextMateCache {
    range: Range<usize>,
    spans: Vec<CachedSpan>,
}

#[derive(Debug, Clone)]
struct CachedSpan {
    range: Range<usize>,
    category: crate::primitives::highlighter::HighlightCategory,
}

/// Maximum bytes to parse in a single operation
const MAX_PARSE_BYTES: usize = 1024 * 1024;

impl TextMateEngine {
    /// Create a new TextMate engine for the given syntax
    pub fn new(syntax_set: Arc<SyntaxSet>, syntax_index: usize) -> Self {
        Self {
            syntax_set,
            syntax_index,
            cache: None,
            last_buffer_len: 0,
            ts_language: None,
        }
    }

    /// Create a new TextMate engine with a tree-sitter language for non-highlighting features
    pub fn with_language(
        syntax_set: Arc<SyntaxSet>,
        syntax_index: usize,
        ts_language: Option<Language>,
    ) -> Self {
        Self {
            syntax_set,
            syntax_index,
            cache: None,
            last_buffer_len: 0,
            ts_language,
        }
    }

    /// Get the tree-sitter language (for indentation, semantic highlighting, etc.)
    pub fn language(&self) -> Option<&Language> {
        self.ts_language.as_ref()
    }

    /// Highlight the visible viewport range
    ///
    /// `context_bytes` controls how far before/after the viewport to parse for accurate
    /// highlighting of multi-line constructs (strings, comments, nested blocks).
    pub fn highlight_viewport(
        &mut self,
        buffer: &Buffer,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
        context_bytes: usize,
    ) -> Vec<HighlightSpan> {
        use syntect::parsing::{ParseState, ScopeStack};

        // Check cache validity
        if let Some(cache) = &self.cache {
            if cache.range.start <= viewport_start
                && cache.range.end >= viewport_end
                && self.last_buffer_len == buffer.len()
            {
                return cache
                    .spans
                    .iter()
                    .filter(|span| {
                        span.range.start < viewport_end && span.range.end > viewport_start
                    })
                    .map(|span| HighlightSpan {
                        range: span.range.clone(),
                        color: span.category.color(theme),
                    })
                    .collect();
            }
        }

        // Cache miss - parse viewport region
        let parse_start = viewport_start.saturating_sub(context_bytes);
        let parse_end = (viewport_end + context_bytes).min(buffer.len());

        if parse_end <= parse_start || parse_end - parse_start > MAX_PARSE_BYTES {
            return Vec::new();
        }

        let syntax = &self.syntax_set.syntaxes()[self.syntax_index];
        let mut state = ParseState::new(syntax);
        let mut spans = Vec::new();

        // Get content
        let content = buffer.slice_bytes(parse_start..parse_end);
        let content_str = match std::str::from_utf8(&content) {
            Ok(s) => s,
            Err(_) => return Vec::new(),
        };

        // Parse line by line
        let mut current_offset = parse_start;
        let mut current_scopes = ScopeStack::new();

        for line in content_str.lines() {
            let line_with_newline = if current_offset + line.len() < parse_end {
                format!("{}\n", line)
            } else {
                line.to_string()
            };

            let ops = match state.parse_line(&line_with_newline, &self.syntax_set) {
                Ok(ops) => ops,
                Err(_) => continue, // Skip lines that fail to parse
            };

            // Convert operations to spans
            let mut char_offset = 0;

            // ops is Vec<(usize, ScopeStackOp)>
            for (op_offset, op) in ops {
                if op_offset > char_offset {
                    if let Some(category) = Self::scope_stack_to_category(&current_scopes) {
                        let byte_start = current_offset + char_offset;
                        let byte_end = current_offset + op_offset;
                        if byte_start < byte_end {
                            spans.push(CachedSpan {
                                range: byte_start..byte_end,
                                category,
                            });
                        }
                    }
                }
                char_offset = op_offset;

                let _ = current_scopes.apply(&op);
            }

            // Handle remaining text
            let line_len = line_with_newline.len();
            if char_offset < line_len {
                if let Some(category) = Self::scope_stack_to_category(&current_scopes) {
                    spans.push(CachedSpan {
                        range: (current_offset + char_offset)..(current_offset + line_len),
                        category,
                    });
                }
            }

            current_offset += line_len;
        }

        // Merge adjacent spans
        Self::merge_adjacent_spans(&mut spans);

        // Update cache
        self.cache = Some(TextMateCache {
            range: parse_start..parse_end,
            spans: spans.clone(),
        });
        self.last_buffer_len = buffer.len();

        // Filter and resolve colors
        spans
            .into_iter()
            .filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
            .map(|span| HighlightSpan {
                range: span.range,
                color: span.category.color(theme),
            })
            .collect()
    }

    /// Map scope stack to highlight category
    fn scope_stack_to_category(
        scopes: &syntect::parsing::ScopeStack,
    ) -> Option<crate::primitives::highlighter::HighlightCategory> {
        use crate::primitives::textmate_highlighter::scope_to_category;

        for scope in scopes.as_slice().iter().rev() {
            let scope_str = scope.build_string();
            if let Some(cat) = scope_to_category(&scope_str) {
                return Some(cat);
            }
        }
        None
    }

    /// Merge adjacent spans with same category
    fn merge_adjacent_spans(spans: &mut Vec<CachedSpan>) {
        if spans.len() < 2 {
            return;
        }

        let mut write_idx = 0;
        for read_idx in 1..spans.len() {
            if spans[write_idx].category == spans[read_idx].category
                && spans[write_idx].range.end == spans[read_idx].range.start
            {
                spans[write_idx].range.end = spans[read_idx].range.end;
            } else {
                write_idx += 1;
                if write_idx != read_idx {
                    spans[write_idx] = spans[read_idx].clone();
                }
            }
        }
        spans.truncate(write_idx + 1);
    }

    /// Invalidate cache for edited range
    pub fn invalidate_range(&mut self, edit_range: Range<usize>) {
        if let Some(cache) = &self.cache {
            if edit_range.start < cache.range.end && edit_range.end > cache.range.start {
                self.cache = None;
            }
        }
    }

    /// Invalidate all cache
    pub fn invalidate_all(&mut self) {
        self.cache = None;
    }

    /// Get syntax name
    pub fn syntax_name(&self) -> &str {
        &self.syntax_set.syntaxes()[self.syntax_index].name
    }
}

impl HighlightEngine {
    /// Create a highlighting engine for a file
    ///
    /// Always uses syntect/TextMate for highlighting, but detects tree-sitter
    /// language for other features (indentation, semantic highlighting).
    pub fn for_file(path: &Path, registry: &GrammarRegistry) -> Self {
        Self::for_file_with_preference(path, registry, HighlighterPreference::Auto)
    }

    /// Create a highlighting engine with explicit preference
    pub fn for_file_with_preference(
        path: &Path,
        registry: &GrammarRegistry,
        preference: HighlighterPreference,
    ) -> Self {
        match preference {
            // Auto now defaults to TextMate for highlighting (syntect has broader coverage)
            // but still detects tree-sitter language for indentation/semantic features
            HighlighterPreference::Auto | HighlighterPreference::TextMate => {
                Self::textmate_for_file(path, registry)
            }
            HighlighterPreference::TreeSitter => {
                if let Some(lang) = Language::from_path(path) {
                    if let Ok(highlighter) = Highlighter::new(lang) {
                        return Self::TreeSitter(highlighter);
                    }
                }
                Self::None
            }
        }
    }

    /// Create a TextMate engine for a file, falling back to tree-sitter if no TextMate grammar
    fn textmate_for_file(path: &Path, registry: &GrammarRegistry) -> Self {
        let syntax_set = registry.syntax_set_arc();

        // Detect tree-sitter language for non-highlighting features
        let ts_language = Language::from_path(path);

        // Find syntax by file extension
        if let Some(syntax) = registry.find_syntax_for_file(path) {
            // Find the index of this syntax in the set
            if let Some(index) = syntax_set
                .syntaxes()
                .iter()
                .position(|s| s.name == syntax.name)
            {
                return Self::TextMate(TextMateEngine::with_language(
                    syntax_set,
                    index,
                    ts_language,
                ));
            }
        }

        // No TextMate grammar found - fall back to tree-sitter if available
        // This handles languages like TypeScript that syntect doesn't include by default
        if let Some(lang) = ts_language {
            if let Ok(highlighter) = Highlighter::new(lang) {
                tracing::debug!(
                    "No TextMate grammar for {:?}, falling back to tree-sitter",
                    path.extension()
                );
                return Self::TreeSitter(highlighter);
            }
        }

        Self::None
    }

    /// Highlight the visible viewport
    ///
    /// `context_bytes` controls how far before/after the viewport to parse for accurate
    /// highlighting of multi-line constructs (strings, comments, nested blocks).
    pub fn highlight_viewport(
        &mut self,
        buffer: &Buffer,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
        context_bytes: usize,
    ) -> Vec<HighlightSpan> {
        match self {
            Self::TreeSitter(h) => {
                h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
            }
            Self::TextMate(h) => {
                h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
            }
            Self::None => Vec::new(),
        }
    }

    /// Invalidate cache for an edited range
    pub fn invalidate_range(&mut self, edit_range: Range<usize>) {
        match self {
            Self::TreeSitter(h) => h.invalidate_range(edit_range),
            Self::TextMate(h) => h.invalidate_range(edit_range),
            Self::None => {}
        }
    }

    /// Invalidate entire cache
    pub fn invalidate_all(&mut self) {
        match self {
            Self::TreeSitter(h) => h.invalidate_all(),
            Self::TextMate(h) => h.invalidate_all(),
            Self::None => {}
        }
    }

    /// Check if this engine has highlighting available
    pub fn has_highlighting(&self) -> bool {
        !matches!(self, Self::None)
    }

    /// Get a description of the active backend
    pub fn backend_name(&self) -> &str {
        match self {
            Self::TreeSitter(_) => "tree-sitter",
            Self::TextMate(_) => "textmate",
            Self::None => "none",
        }
    }

    /// Get the language/syntax name if available
    pub fn syntax_name(&self) -> Option<&str> {
        match self {
            Self::TreeSitter(_) => None, // Tree-sitter doesn't expose name easily
            Self::TextMate(h) => Some(h.syntax_name()),
            Self::None => None,
        }
    }

    /// Get the tree-sitter Language for non-highlighting features
    /// Returns the language even when using TextMate for highlighting
    pub fn language(&self) -> Option<&Language> {
        match self {
            Self::TreeSitter(h) => Some(h.language()),
            Self::TextMate(h) => h.language(),
            Self::None => None,
        }
    }
}

impl Default for HighlightEngine {
    fn default() -> Self {
        Self::None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_highlighter_preference_default() {
        let pref = HighlighterPreference::default();
        assert_eq!(pref, HighlighterPreference::Auto);
    }

    #[test]
    fn test_highlight_engine_default() {
        let engine = HighlightEngine::default();
        assert!(!engine.has_highlighting());
        assert_eq!(engine.backend_name(), "none");
    }

    #[test]
    fn test_textmate_backend_selection() {
        let registry = GrammarRegistry::load();

        // Languages with TextMate grammars use TextMate for highlighting
        let engine = HighlightEngine::for_file(Path::new("test.rs"), &registry);
        assert_eq!(engine.backend_name(), "textmate");
        // Tree-sitter language should still be detected for other features
        assert!(engine.language().is_some());

        let engine = HighlightEngine::for_file(Path::new("test.py"), &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.language().is_some());

        let engine = HighlightEngine::for_file(Path::new("test.js"), &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.language().is_some());

        // TypeScript falls back to tree-sitter (syntect doesn't include TS by default)
        let engine = HighlightEngine::for_file(Path::new("test.ts"), &registry);
        assert_eq!(engine.backend_name(), "tree-sitter");
        assert!(engine.language().is_some());

        let engine = HighlightEngine::for_file(Path::new("test.tsx"), &registry);
        assert_eq!(engine.backend_name(), "tree-sitter");
        assert!(engine.language().is_some());
    }

    #[test]
    fn test_tree_sitter_explicit_preference() {
        let registry = GrammarRegistry::load();

        // Force tree-sitter for highlighting
        let engine = HighlightEngine::for_file_with_preference(
            Path::new("test.rs"),
            &registry,
            HighlighterPreference::TreeSitter,
        );
        assert_eq!(engine.backend_name(), "tree-sitter");
    }

    #[test]
    fn test_unknown_extension() {
        let registry = GrammarRegistry::load();

        // Unknown extension
        let engine = HighlightEngine::for_file(Path::new("test.unknown_xyz_123"), &registry);
        // Might be none or might find something via syntect
        // Just verify it doesn't panic
        let _ = engine.backend_name();
    }

    #[test]
    fn test_highlight_viewport_empty_buffer_no_panic() {
        // Regression test: calling highlight_viewport with an empty buffer
        // and non-zero viewport range previously caused subtraction overflow panic.
        //
        // The bug occurred when:
        // - buffer is empty (len = 0)
        // - viewport_start > context_bytes (so parse_start > 0 after saturating_sub)
        // - parse_end = min(viewport_end + context_bytes, buffer.len()) = 0
        // - parse_end - parse_start would underflow (0 - positive = overflow)
        let registry = GrammarRegistry::load();

        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), &registry);

        // Create empty buffer
        let buffer = Buffer::from_str("", 0);
        let theme = Theme::default();

        // Test the specific case that triggered the overflow:
        // viewport_start=100, context_bytes=10 => parse_start=90, parse_end=0
        // 0 - 90 = overflow!
        if let HighlightEngine::TextMate(ref mut tm) = engine {
            // Small context_bytes so parse_start remains > 0
            let spans = tm.highlight_viewport(&buffer, 100, 200, &theme, 10);
            assert!(spans.is_empty());
        }
    }
}