1use crate::model::buffer::Buffer;
20use crate::primitives::grammar::GrammarRegistry;
21use crate::primitives::highlighter::{
22 highlight_color, HighlightCategory, HighlightSpan, Highlighter, Language,
23};
24use crate::view::theme::Theme;
25use std::ops::Range;
26use std::path::Path;
27use std::sync::Arc;
28use syntect::parsing::SyntaxSet;
29
30fn scope_to_category(scope: &str) -> Option<HighlightCategory> {
32 let scope_lower = scope.to_lowercase();
33
34 if scope_lower.starts_with("comment") {
36 return Some(HighlightCategory::Comment);
37 }
38
39 if scope_lower.starts_with("string") {
41 return Some(HighlightCategory::String);
42 }
43
44 if scope_lower.starts_with("markup.heading") || scope_lower.starts_with("entity.name.section") {
48 return Some(HighlightCategory::Keyword); }
50 if scope_lower.starts_with("markup.bold") {
52 return Some(HighlightCategory::Constant); }
54 if scope_lower.starts_with("markup.italic") {
56 return Some(HighlightCategory::Variable); }
58 if scope_lower.starts_with("markup.raw") || scope_lower.starts_with("markup.inline.raw") {
60 return Some(HighlightCategory::String); }
62 if scope_lower.starts_with("markup.underline.link") {
64 return Some(HighlightCategory::Function); }
66 if scope_lower.starts_with("markup.underline") {
68 return Some(HighlightCategory::Function);
69 }
70 if scope_lower.starts_with("markup.quote") {
72 return Some(HighlightCategory::Comment); }
74 if scope_lower.starts_with("markup.list") {
76 return Some(HighlightCategory::Operator); }
78 if scope_lower.starts_with("markup.strikethrough") {
80 return Some(HighlightCategory::Comment); }
82
83 if scope_lower.starts_with("keyword.control")
85 || scope_lower.starts_with("keyword.other")
86 || scope_lower.starts_with("keyword.declaration")
87 || scope_lower.starts_with("keyword")
88 {
89 if !scope_lower.starts_with("keyword.operator") {
91 return Some(HighlightCategory::Keyword);
92 }
93 }
94
95 if scope_lower.starts_with("keyword.operator") || scope_lower.starts_with("punctuation") {
97 return Some(HighlightCategory::Operator);
98 }
99
100 if scope_lower.starts_with("entity.name.function")
102 || scope_lower.starts_with("support.function")
103 || scope_lower.starts_with("meta.function-call")
104 || scope_lower.starts_with("variable.function")
105 {
106 return Some(HighlightCategory::Function);
107 }
108
109 if scope_lower.starts_with("entity.name.type")
111 || scope_lower.starts_with("entity.name.class")
112 || scope_lower.starts_with("entity.name.struct")
113 || scope_lower.starts_with("entity.name.enum")
114 || scope_lower.starts_with("entity.name.interface")
115 || scope_lower.starts_with("entity.name.trait")
116 || scope_lower.starts_with("support.type")
117 || scope_lower.starts_with("support.class")
118 || scope_lower.starts_with("storage.type")
119 {
120 return Some(HighlightCategory::Type);
121 }
122
123 if scope_lower.starts_with("storage.modifier") {
125 return Some(HighlightCategory::Keyword);
126 }
127
128 if scope_lower.starts_with("constant.numeric")
130 || scope_lower.starts_with("constant.language.boolean")
131 {
132 return Some(HighlightCategory::Number);
133 }
134 if scope_lower.starts_with("constant") {
135 return Some(HighlightCategory::Constant);
136 }
137
138 if scope_lower.starts_with("variable.parameter")
140 || scope_lower.starts_with("variable.other")
141 || scope_lower.starts_with("variable.language")
142 {
143 return Some(HighlightCategory::Variable);
144 }
145
146 if scope_lower.starts_with("entity.name.tag")
148 || scope_lower.starts_with("support.other.property")
149 || scope_lower.starts_with("meta.object-literal.key")
150 || scope_lower.starts_with("variable.other.property")
151 || scope_lower.starts_with("variable.other.object.property")
152 {
153 return Some(HighlightCategory::Property);
154 }
155
156 if scope_lower.starts_with("entity.other.attribute")
158 || scope_lower.starts_with("meta.attribute")
159 || scope_lower.starts_with("entity.name.decorator")
160 {
161 return Some(HighlightCategory::Attribute);
162 }
163
164 if scope_lower.starts_with("variable") {
166 return Some(HighlightCategory::Variable);
167 }
168
169 None
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum HighlighterPreference {
175 #[default]
178 Auto,
179 TreeSitter,
181 TextMate,
183}
184
185#[derive(Default)]
187pub enum HighlightEngine {
188 TreeSitter(Box<Highlighter>),
190 TextMate(Box<TextMateEngine>),
192 #[default]
194 None,
195}
196
197pub struct TextMateEngine {
202 syntax_set: Arc<SyntaxSet>,
203 syntax_index: usize,
204 cache: Option<TextMateCache>,
205 last_buffer_len: usize,
206 ts_language: Option<Language>,
209}
210
211#[derive(Debug, Clone)]
212struct TextMateCache {
213 range: Range<usize>,
214 spans: Vec<CachedSpan>,
215}
216
217#[derive(Debug, Clone)]
218struct CachedSpan {
219 range: Range<usize>,
220 category: crate::primitives::highlighter::HighlightCategory,
221}
222
223const MAX_PARSE_BYTES: usize = 1024 * 1024;
225
226impl TextMateEngine {
227 pub fn new(syntax_set: Arc<SyntaxSet>, syntax_index: usize) -> Self {
229 Self {
230 syntax_set,
231 syntax_index,
232 cache: None,
233 last_buffer_len: 0,
234 ts_language: None,
235 }
236 }
237
238 pub fn with_language(
240 syntax_set: Arc<SyntaxSet>,
241 syntax_index: usize,
242 ts_language: Option<Language>,
243 ) -> Self {
244 Self {
245 syntax_set,
246 syntax_index,
247 cache: None,
248 last_buffer_len: 0,
249 ts_language,
250 }
251 }
252
253 pub fn language(&self) -> Option<&Language> {
255 self.ts_language.as_ref()
256 }
257
258 pub fn highlight_viewport(
263 &mut self,
264 buffer: &Buffer,
265 viewport_start: usize,
266 viewport_end: usize,
267 theme: &Theme,
268 context_bytes: usize,
269 ) -> Vec<HighlightSpan> {
270 use syntect::parsing::{ParseState, ScopeStack};
271
272 if let Some(cache) = &self.cache {
274 if cache.range.start <= viewport_start
275 && cache.range.end >= viewport_end
276 && self.last_buffer_len == buffer.len()
277 {
278 return cache
279 .spans
280 .iter()
281 .filter(|span| {
282 span.range.start < viewport_end && span.range.end > viewport_start
283 })
284 .map(|span| HighlightSpan {
285 range: span.range.clone(),
286 color: highlight_color(span.category, theme),
287 })
288 .collect();
289 }
290 }
291
292 let parse_start = viewport_start.saturating_sub(context_bytes);
294 let parse_end = (viewport_end + context_bytes).min(buffer.len());
295
296 if parse_end <= parse_start || parse_end - parse_start > MAX_PARSE_BYTES {
297 return Vec::new();
298 }
299
300 let syntax = &self.syntax_set.syntaxes()[self.syntax_index];
301 let mut state = ParseState::new(syntax);
302 let mut spans = Vec::new();
303
304 let content = buffer.slice_bytes(parse_start..parse_end);
306 let content_str = match std::str::from_utf8(&content) {
307 Ok(s) => s,
308 Err(_) => return Vec::new(),
309 };
310
311 let content_bytes = content_str.as_bytes();
314 let mut pos = 0;
315 let mut current_offset = parse_start;
316 let mut current_scopes = ScopeStack::new();
317
318 while pos < content_bytes.len() {
319 let line_start = pos;
320 let mut line_end = pos;
321
322 while line_end < content_bytes.len() {
324 if content_bytes[line_end] == b'\n' {
325 line_end += 1;
326 break;
327 } else if content_bytes[line_end] == b'\r' {
328 if line_end + 1 < content_bytes.len() && content_bytes[line_end + 1] == b'\n' {
329 line_end += 2; } else {
331 line_end += 1; }
333 break;
334 }
335 line_end += 1;
336 }
337
338 let line_bytes = &content_bytes[line_start..line_end];
340 let actual_line_byte_len = line_bytes.len();
341
342 let line_str = match std::str::from_utf8(line_bytes) {
344 Ok(s) => s,
345 Err(_) => {
346 pos = line_end;
347 current_offset += actual_line_byte_len;
348 continue;
349 }
350 };
351
352 let line_content = line_str.trim_end_matches(&['\r', '\n'][..]);
354 let line_for_syntect = if line_end < content_bytes.len() || line_str.ends_with('\n') {
355 format!("{}\n", line_content)
356 } else {
357 line_content.to_string()
358 };
359
360 let ops = match state.parse_line(&line_for_syntect, &self.syntax_set) {
361 Ok(ops) => ops,
362 Err(_) => {
363 pos = line_end;
364 current_offset += actual_line_byte_len;
365 continue;
366 }
367 };
368
369 let mut syntect_offset = 0;
373 let line_content_len = line_content.len();
374
375 for (op_offset, op) in ops {
376 let clamped_op_offset = op_offset.min(line_content_len);
378 if clamped_op_offset > syntect_offset {
379 if let Some(category) = Self::scope_stack_to_category(¤t_scopes) {
380 let byte_start = current_offset + syntect_offset;
381 let byte_end = current_offset + clamped_op_offset;
382 if byte_start < byte_end {
383 spans.push(CachedSpan {
384 range: byte_start..byte_end,
385 category,
386 });
387 }
388 }
389 }
390 syntect_offset = clamped_op_offset;
391
392 let _ = current_scopes.apply(&op);
393 }
394
395 if syntect_offset < line_content_len {
397 if let Some(category) = Self::scope_stack_to_category(¤t_scopes) {
398 let byte_start = current_offset + syntect_offset;
399 let byte_end = current_offset + line_content_len;
400 if byte_start < byte_end {
401 spans.push(CachedSpan {
402 range: byte_start..byte_end,
403 category,
404 });
405 }
406 }
407 }
408
409 pos = line_end;
411 current_offset += actual_line_byte_len;
412 }
413
414 Self::merge_adjacent_spans(&mut spans);
416
417 self.cache = Some(TextMateCache {
419 range: parse_start..parse_end,
420 spans: spans.clone(),
421 });
422 self.last_buffer_len = buffer.len();
423
424 spans
426 .into_iter()
427 .filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
428 .map(|span| HighlightSpan {
429 range: span.range,
430 color: highlight_color(span.category, theme),
431 })
432 .collect()
433 }
434
435 fn scope_stack_to_category(scopes: &syntect::parsing::ScopeStack) -> Option<HighlightCategory> {
437 for scope in scopes.as_slice().iter().rev() {
438 let scope_str = scope.build_string();
439 if let Some(cat) = scope_to_category(&scope_str) {
440 return Some(cat);
441 }
442 }
443 None
444 }
445
446 fn merge_adjacent_spans(spans: &mut Vec<CachedSpan>) {
448 if spans.len() < 2 {
449 return;
450 }
451
452 let mut write_idx = 0;
453 for read_idx in 1..spans.len() {
454 if spans[write_idx].category == spans[read_idx].category
455 && spans[write_idx].range.end == spans[read_idx].range.start
456 {
457 spans[write_idx].range.end = spans[read_idx].range.end;
458 } else {
459 write_idx += 1;
460 if write_idx != read_idx {
461 spans[write_idx] = spans[read_idx].clone();
462 }
463 }
464 }
465 spans.truncate(write_idx + 1);
466 }
467
468 pub fn invalidate_range(&mut self, edit_range: Range<usize>) {
470 if let Some(cache) = &self.cache {
471 if edit_range.start < cache.range.end && edit_range.end > cache.range.start {
472 self.cache = None;
473 }
474 }
475 }
476
477 pub fn invalidate_all(&mut self) {
479 self.cache = None;
480 }
481
482 pub fn syntax_name(&self) -> &str {
484 &self.syntax_set.syntaxes()[self.syntax_index].name
485 }
486}
487
488impl HighlightEngine {
489 pub fn for_file(path: &Path, registry: &GrammarRegistry) -> Self {
494 Self::for_file_with_preference(path, registry, HighlighterPreference::Auto)
495 }
496
497 pub fn for_file_with_languages(
504 path: &Path,
505 registry: &GrammarRegistry,
506 languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
507 ) -> Self {
508 Self::for_file_with_languages_and_preference(
509 path,
510 registry,
511 languages,
512 HighlighterPreference::Auto,
513 )
514 }
515
516 pub fn for_file_with_languages_and_preference(
518 path: &Path,
519 registry: &GrammarRegistry,
520 languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
521 preference: HighlighterPreference,
522 ) -> Self {
523 match preference {
524 HighlighterPreference::Auto | HighlighterPreference::TextMate => {
527 Self::textmate_for_file_with_languages(path, registry, languages)
528 }
529 HighlighterPreference::TreeSitter => {
530 if let Some(lang) = Language::from_path(path) {
531 if let Ok(highlighter) = Highlighter::new(lang) {
532 return Self::TreeSitter(Box::new(highlighter));
533 }
534 }
535 Self::None
536 }
537 }
538 }
539
540 pub fn for_file_with_preference(
542 path: &Path,
543 registry: &GrammarRegistry,
544 preference: HighlighterPreference,
545 ) -> Self {
546 match preference {
547 HighlighterPreference::Auto | HighlighterPreference::TextMate => {
550 Self::textmate_for_file(path, registry)
551 }
552 HighlighterPreference::TreeSitter => {
553 if let Some(lang) = Language::from_path(path) {
554 if let Ok(highlighter) = Highlighter::new(lang) {
555 return Self::TreeSitter(Box::new(highlighter));
556 }
557 }
558 Self::None
559 }
560 }
561 }
562
563 fn textmate_for_file(path: &Path, registry: &GrammarRegistry) -> Self {
565 let syntax_set = registry.syntax_set_arc();
566
567 let ts_language = Language::from_path(path);
569
570 if let Some(syntax) = registry.find_syntax_for_file(path) {
572 if let Some(index) = syntax_set
574 .syntaxes()
575 .iter()
576 .position(|s| s.name == syntax.name)
577 {
578 return Self::TextMate(Box::new(TextMateEngine::with_language(
579 syntax_set,
580 index,
581 ts_language,
582 )));
583 }
584 }
585
586 if let Some(lang) = ts_language {
589 if let Ok(highlighter) = Highlighter::new(lang) {
590 tracing::debug!(
591 "No TextMate grammar for {:?}, falling back to tree-sitter",
592 path.extension()
593 );
594 return Self::TreeSitter(Box::new(highlighter));
595 }
596 }
597
598 Self::None
599 }
600
601 fn textmate_for_file_with_languages(
603 path: &Path,
604 registry: &GrammarRegistry,
605 languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
606 ) -> Self {
607 let syntax_set = registry.syntax_set_arc();
608
609 let ts_language = Language::from_path(path);
611
612 if let Some(syntax) = registry.find_syntax_for_file_with_languages(path, languages) {
614 if let Some(index) = syntax_set
616 .syntaxes()
617 .iter()
618 .position(|s| s.name == syntax.name)
619 {
620 return Self::TextMate(Box::new(TextMateEngine::with_language(
621 syntax_set,
622 index,
623 ts_language,
624 )));
625 }
626 }
627
628 if let Some(lang) = ts_language {
631 if let Ok(highlighter) = Highlighter::new(lang) {
632 tracing::debug!(
633 "No TextMate grammar for {:?}, falling back to tree-sitter",
634 path.extension()
635 );
636 return Self::TreeSitter(Box::new(highlighter));
637 }
638 }
639
640 Self::None
641 }
642
643 pub fn for_language(language: Language) -> Self {
648 if let Ok(highlighter) = Highlighter::new(language) {
649 Self::TreeSitter(Box::new(highlighter))
650 } else {
651 Self::None
652 }
653 }
654
655 pub fn for_syntax_name(
664 name: &str,
665 registry: &GrammarRegistry,
666 ts_language: Option<Language>,
667 ) -> Self {
668 let syntax_set = registry.syntax_set_arc();
669
670 if let Some(syntax) = registry.find_syntax_by_name(name) {
671 if let Some(index) = syntax_set
673 .syntaxes()
674 .iter()
675 .position(|s| s.name == syntax.name)
676 {
677 return Self::TextMate(Box::new(TextMateEngine::with_language(
678 syntax_set,
679 index,
680 ts_language,
681 )));
682 }
683 }
684
685 Self::None
686 }
687
688 pub fn highlight_viewport(
693 &mut self,
694 buffer: &Buffer,
695 viewport_start: usize,
696 viewport_end: usize,
697 theme: &Theme,
698 context_bytes: usize,
699 ) -> Vec<HighlightSpan> {
700 match self {
701 Self::TreeSitter(h) => {
702 h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
703 }
704 Self::TextMate(h) => {
705 h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
706 }
707 Self::None => Vec::new(),
708 }
709 }
710
711 pub fn invalidate_range(&mut self, edit_range: Range<usize>) {
713 match self {
714 Self::TreeSitter(h) => h.invalidate_range(edit_range),
715 Self::TextMate(h) => h.invalidate_range(edit_range),
716 Self::None => {}
717 }
718 }
719
720 pub fn invalidate_all(&mut self) {
722 match self {
723 Self::TreeSitter(h) => h.invalidate_all(),
724 Self::TextMate(h) => h.invalidate_all(),
725 Self::None => {}
726 }
727 }
728
729 pub fn has_highlighting(&self) -> bool {
731 !matches!(self, Self::None)
732 }
733
734 pub fn backend_name(&self) -> &str {
736 match self {
737 Self::TreeSitter(_) => "tree-sitter",
738 Self::TextMate(_) => "textmate",
739 Self::None => "none",
740 }
741 }
742
743 pub fn syntax_name(&self) -> Option<&str> {
745 match self {
746 Self::TreeSitter(_) => None, Self::TextMate(h) => Some(h.syntax_name()),
748 Self::None => None,
749 }
750 }
751
752 pub fn language(&self) -> Option<&Language> {
755 match self {
756 Self::TreeSitter(h) => Some(h.language()),
757 Self::TextMate(h) => h.language(),
758 Self::None => None,
759 }
760 }
761}
762
763pub fn highlight_string(
769 code: &str,
770 lang_hint: &str,
771 registry: &GrammarRegistry,
772 theme: &Theme,
773) -> Vec<HighlightSpan> {
774 use syntect::parsing::{ParseState, ScopeStack};
775
776 let syntax = match registry.syntax_set().find_syntax_by_token(lang_hint) {
778 Some(s) => s,
779 None => return Vec::new(),
780 };
781
782 let syntax_set = registry.syntax_set();
783 let mut state = ParseState::new(syntax);
784 let mut spans = Vec::new();
785 let mut current_scopes = ScopeStack::new();
786 let mut current_offset = 0;
787
788 for line in code.split_inclusive('\n') {
790 let line_start = current_offset;
791 let line_len = line.len();
792
793 let line_content = line.trim_end_matches(&['\r', '\n'][..]);
795 let line_for_syntect = if line.ends_with('\n') {
796 format!("{}\n", line_content)
797 } else {
798 line_content.to_string()
799 };
800
801 let ops = match state.parse_line(&line_for_syntect, syntax_set) {
802 Ok(ops) => ops,
803 Err(_) => {
804 current_offset += line_len;
805 continue;
806 }
807 };
808
809 let mut syntect_offset = 0;
810 let line_content_len = line_content.len();
811
812 for (op_offset, op) in ops {
813 let clamped_op_offset = op_offset.min(line_content_len);
814 if clamped_op_offset > syntect_offset {
815 if let Some(category) = scope_stack_to_category(¤t_scopes) {
816 let byte_start = line_start + syntect_offset;
817 let byte_end = line_start + clamped_op_offset;
818 if byte_start < byte_end {
819 spans.push(HighlightSpan {
820 range: byte_start..byte_end,
821 color: highlight_color(category, theme),
822 });
823 }
824 }
825 }
826 syntect_offset = clamped_op_offset;
827 let _ = current_scopes.apply(&op);
828 }
829
830 if syntect_offset < line_content_len {
832 if let Some(category) = scope_stack_to_category(¤t_scopes) {
833 let byte_start = line_start + syntect_offset;
834 let byte_end = line_start + line_content_len;
835 if byte_start < byte_end {
836 spans.push(HighlightSpan {
837 range: byte_start..byte_end,
838 color: highlight_color(category, theme),
839 });
840 }
841 }
842 }
843
844 current_offset += line_len;
845 }
846
847 merge_adjacent_highlight_spans(&mut spans);
849
850 spans
851}
852
853fn scope_stack_to_category(scopes: &syntect::parsing::ScopeStack) -> Option<HighlightCategory> {
855 for scope in scopes.as_slice().iter().rev() {
856 let scope_str = scope.build_string();
857 if let Some(cat) = scope_to_category(&scope_str) {
858 return Some(cat);
859 }
860 }
861 None
862}
863
864fn merge_adjacent_highlight_spans(spans: &mut Vec<HighlightSpan>) {
866 if spans.len() < 2 {
867 return;
868 }
869
870 let mut write_idx = 0;
871 for read_idx in 1..spans.len() {
872 if spans[write_idx].color == spans[read_idx].color
873 && spans[write_idx].range.end == spans[read_idx].range.start
874 {
875 spans[write_idx].range.end = spans[read_idx].range.end;
876 } else {
877 write_idx += 1;
878 if write_idx != read_idx {
879 spans[write_idx] = spans[read_idx].clone();
880 }
881 }
882 }
883 spans.truncate(write_idx + 1);
884}
885
886#[cfg(test)]
887mod tests {
888 use crate::model::filesystem::StdFileSystem;
889 use std::sync::Arc;
890
891 fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
892 Arc::new(StdFileSystem)
893 }
894 use super::*;
895 use crate::view::theme;
896
897 #[test]
898 fn test_highlighter_preference_default() {
899 let pref = HighlighterPreference::default();
900 assert_eq!(pref, HighlighterPreference::Auto);
901 }
902
903 #[test]
904 fn test_highlight_engine_default() {
905 let engine = HighlightEngine::default();
906 assert!(!engine.has_highlighting());
907 assert_eq!(engine.backend_name(), "none");
908 }
909
910 #[test]
911 fn test_textmate_backend_selection() {
912 let registry =
913 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
914
915 let engine = HighlightEngine::for_file(Path::new("test.rs"), ®istry);
917 assert_eq!(engine.backend_name(), "textmate");
918 assert!(engine.language().is_some());
920
921 let engine = HighlightEngine::for_file(Path::new("test.py"), ®istry);
922 assert_eq!(engine.backend_name(), "textmate");
923 assert!(engine.language().is_some());
924
925 let engine = HighlightEngine::for_file(Path::new("test.js"), ®istry);
926 assert_eq!(engine.backend_name(), "textmate");
927 assert!(engine.language().is_some());
928
929 let engine = HighlightEngine::for_file(Path::new("test.ts"), ®istry);
931 assert_eq!(engine.backend_name(), "tree-sitter");
932 assert!(engine.language().is_some());
933
934 let engine = HighlightEngine::for_file(Path::new("test.tsx"), ®istry);
935 assert_eq!(engine.backend_name(), "tree-sitter");
936 assert!(engine.language().is_some());
937 }
938
939 #[test]
940 fn test_tree_sitter_explicit_preference() {
941 let registry =
942 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
943
944 let engine = HighlightEngine::for_file_with_preference(
946 Path::new("test.rs"),
947 ®istry,
948 HighlighterPreference::TreeSitter,
949 );
950 assert_eq!(engine.backend_name(), "tree-sitter");
951 }
952
953 #[test]
954 fn test_unknown_extension() {
955 let registry =
956 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
957
958 let engine = HighlightEngine::for_file(Path::new("test.unknown_xyz_123"), ®istry);
960 let _ = engine.backend_name();
963 }
964
965 #[test]
966 fn test_highlight_viewport_empty_buffer_no_panic() {
967 let registry =
976 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
977
978 let mut engine = HighlightEngine::for_file(Path::new("test.rs"), ®istry);
979
980 let buffer = Buffer::from_str("", 0, test_fs());
982 let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
983
984 if let HighlightEngine::TextMate(ref mut tm) = engine {
988 let spans = tm.highlight_viewport(&buffer, 100, 200, &theme, 10);
990 assert!(spans.is_empty());
991 }
992 }
993
994 #[test]
998 fn test_textmate_engine_crlf_byte_offsets() {
999 let registry =
1000 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
1001
1002 let mut engine = HighlightEngine::for_file(Path::new("test.java"), ®istry);
1003
1004 let content = b"public\r\npublic\r\npublic\r\n";
1010 let buffer = Buffer::from_bytes(content.to_vec(), test_fs());
1011 let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
1012
1013 if let HighlightEngine::TextMate(ref mut tm) = engine {
1014 let spans = tm.highlight_viewport(&buffer, 0, content.len(), &theme, 0);
1016
1017 eprintln!(
1024 "Spans: {:?}",
1025 spans.iter().map(|s| &s.range).collect::<Vec<_>>()
1026 );
1027
1028 let has_span_at = |start: usize, end: usize| -> bool {
1030 spans
1031 .iter()
1032 .any(|s| s.range.start <= start && s.range.end >= end)
1033 };
1034
1035 assert!(
1037 has_span_at(0, 6),
1038 "Should have span covering bytes 0-6 (line 1 'public'). Spans: {:?}",
1039 spans.iter().map(|s| &s.range).collect::<Vec<_>>()
1040 );
1041
1042 assert!(
1045 has_span_at(8, 14),
1046 "Should have span covering bytes 8-14 (line 2 'public'). \
1047 If this fails, CRLF offset drift is occurring. Spans: {:?}",
1048 spans.iter().map(|s| &s.range).collect::<Vec<_>>()
1049 );
1050
1051 assert!(
1054 has_span_at(16, 22),
1055 "Should have span covering bytes 16-22 (line 3 'public'). \
1056 If this fails, CRLF offset drift is occurring. Spans: {:?}",
1057 spans.iter().map(|s| &s.range).collect::<Vec<_>>()
1058 );
1059 } else {
1060 panic!("Expected TextMate engine for .java file");
1061 }
1062 }
1063
1064 #[test]
1065 fn test_git_rebase_todo_highlighting() {
1066 let registry =
1067 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
1068
1069 let engine = HighlightEngine::for_file(Path::new("git-rebase-todo"), ®istry);
1071 assert_eq!(engine.backend_name(), "textmate");
1072 assert!(engine.has_highlighting());
1073 }
1074
1075 #[test]
1076 fn test_git_commit_message_highlighting() {
1077 let registry =
1078 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
1079
1080 let engine = HighlightEngine::for_file(Path::new("COMMIT_EDITMSG"), ®istry);
1082 assert_eq!(engine.backend_name(), "textmate");
1083 assert!(engine.has_highlighting());
1084
1085 let engine = HighlightEngine::for_file(Path::new("MERGE_MSG"), ®istry);
1087 assert_eq!(engine.backend_name(), "textmate");
1088 assert!(engine.has_highlighting());
1089 }
1090
1091 #[test]
1092 fn test_gitignore_highlighting() {
1093 let registry =
1094 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
1095
1096 let engine = HighlightEngine::for_file(Path::new(".gitignore"), ®istry);
1098 assert_eq!(engine.backend_name(), "textmate");
1099 assert!(engine.has_highlighting());
1100
1101 let engine = HighlightEngine::for_file(Path::new(".dockerignore"), ®istry);
1103 assert_eq!(engine.backend_name(), "textmate");
1104 assert!(engine.has_highlighting());
1105 }
1106
1107 #[test]
1108 fn test_gitconfig_highlighting() {
1109 let registry =
1110 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
1111
1112 let engine = HighlightEngine::for_file(Path::new(".gitconfig"), ®istry);
1114 assert_eq!(engine.backend_name(), "textmate");
1115 assert!(engine.has_highlighting());
1116
1117 let engine = HighlightEngine::for_file(Path::new(".gitmodules"), ®istry);
1119 assert_eq!(engine.backend_name(), "textmate");
1120 assert!(engine.has_highlighting());
1121 }
1122
1123 #[test]
1124 fn test_gitattributes_highlighting() {
1125 let registry =
1126 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
1127
1128 let engine = HighlightEngine::for_file(Path::new(".gitattributes"), ®istry);
1130 assert_eq!(engine.backend_name(), "textmate");
1131 assert!(engine.has_highlighting());
1132 }
1133}