diff --git a/Cargo.lock b/Cargo.lock index a4929a0..bb711d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -32,6 +38,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.2" @@ -92,6 +122,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -237,6 +276,34 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -417,6 +484,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -453,6 +530,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "palette" version = "0.7.7" @@ -760,6 +843,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "smallvec" version = "1.16.2" @@ -821,6 +910,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror", + "walkdir", +] + [[package]] name = "thiserror" version = "2.0.21" @@ -900,6 +1007,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "syntect", "unicode-segmentation", "unicode-width", ] @@ -966,6 +1074,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index b879b95..7d6c4bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,5 +9,6 @@ ignore = "0.4" ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "regex-fancy"] } unicode-segmentation = "1.12" unicode-width = "0.2" diff --git a/README.md b/README.md index 0cdbeeb..f86e6e8 100644 --- a/README.md +++ b/README.md @@ -200,13 +200,14 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility ## Module seams -One crate, six modules, no workspace or terminal-coordinate types in the editing backend: +One crate, seven modules, no workspace or terminal-coordinate types in the editing backend: - `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. +- `syntax`: owns bundled grammar detection, semantic byte spans, multiline parser checkpoints, and changed-suffix highlighting; it has no terminal color/geometry or editing dependency. - `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure. -- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy. +- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text` and `styled_text` capabilities. It maps semantic tokens to terminal colors and has no plugin lifecycle callback, host storage, or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. @@ -225,6 +226,16 @@ The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a o The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column. +### Syntax highlighting + +Buffers highlight automatically using [Syntect's bundled Sublime grammars](https://docs.rs/syntect/5.3.0/syntect/). Detection uses the supplied filename/extension and the buffer's own first line/shebang, without rereading the file from disk. Tested languages include Rust, Ruby, Python, JSON, shell, Makefile, and Markdown; other bundled grammars are available. Unknown and Plain Text buffers keep the terminal's normal foreground. The detected language appears in status, and saving under a different extension redetects it. + +Colors use the terminal palette rather than a fixed background/theme: comments are dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Multiline/nested comments and strings retain parser scope across lines, including when their opening is above the viewport. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide characters keep the existing blank-placeholder behavior. Search, Visual selections, and secondary carets paint afterward and retain their foreground/background precedence. + +Each buffer has a frontend-owned cache. Text revisions distinguish edits/plugin effects/undo/redo from cursor and explorer changes; unchanged revision/path skips comparison and parsing. Changed text resumes at the first changed line's cached parser/scope state and reparses the suffix, preserving prefix work. Buffer switching restores its cache, and closing retires it. Real parser/scope errors clear syntax spans, retain readable/editable plain text, and report a status error. No custom lexer, runtime grammar downloads, or editor-offset changes are involved. + +### Core feature registry + Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, or disable core. User configuration can override any keybinding without replacing the core implementation. `:plugins` lists active features and frontend capabilities. A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions. diff --git a/src/editor.rs b/src/editor.rs index d46abe6..a0106c1 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -693,6 +693,7 @@ struct Buffer { id: usize, lines: Vec, saved_lines: Vec, + text_revision: u64, targets: Vec, primary: usize, mode: Mode, @@ -731,6 +732,7 @@ impl Buffer { id, saved_lines: lines.clone(), lines, + text_revision: 0, targets: vec![Target::caret(Cursor::default())], primary: 0, mode: Mode::Normal, @@ -938,6 +940,10 @@ impl Editor { pub fn lines(&self) -> &[String] { &self.active.lines } + /// Changes only when this buffer's text changes, independent of cursor/view state. + pub fn text_revision(&self) -> u64 { + self.active.text_revision + } pub fn cursor(&self) -> Cursor { self.active.targets[self.active.primary].active } @@ -2267,7 +2273,11 @@ impl Editor { match effect { Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), Effect::Replace(text) => { - self.active.lines = split_lines(&text); + let lines = split_lines(&text); + if lines != self.active.lines { + self.active.text_revision = self.active.text_revision.wrapping_add(1); + } + self.active.lines = lines; self.active.search_match = None; self.normalize_targets(); } @@ -3165,6 +3175,9 @@ impl Editor { } fn restore(&mut self, snapshot: Snapshot) { + if self.active.lines != snapshot.lines { + self.active.text_revision = self.active.text_revision.wrapping_add(1); + } self.active.lines = snapshot.lines; self.active.targets = snapshot.targets; self.active.primary = snapshot.primary; @@ -3377,6 +3390,7 @@ impl Editor { destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up); } if text != old { + self.active.text_revision = self.active.text_revision.wrapping_add(1); self.active.search_match = None; } self.active.lines = text.split('\n').map(str::to_owned).collect(); diff --git a/src/lib.rs b/src/lib.rs index f9bcafb..fa61f4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ pub mod editor; pub mod files; pub mod ruby; pub mod session; +pub mod syntax; pub mod tui; diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 0000000..8e8741e --- /dev/null +++ b/src/syntax.rs @@ -0,0 +1,703 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; +use syntect::highlighting::ScopeSelectors; +use syntect::parsing::{ + ParseState, ScopeError, ScopeStack, ScopeStackOp, SyntaxReference, SyntaxSet, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SyntaxKind { + Comment, + String, + Keyword, + Number, + Type, + Function, + Markup, +} + +/// A semantic foreground range in the original line's bytes, excluding its newline. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SyntaxSpan { + pub start_byte: usize, + pub end_byte: usize, + pub kind: SyntaxKind, +} + +#[derive(Default)] +struct CachedLine { + source: String, + spans: Vec, + after: Option<(ParseState, ScopeStack)>, +} + +/// Frontend-owned, per-buffer parser state. Revision and path identify unchanged input. +#[derive(Default)] +pub struct SyntaxCache { + revision: Option, + path: Option, + syntax: Option<&'static SyntaxReference>, + lines: Vec, + parse_line: String, + error: Option, + #[cfg(test)] + parsed_lines: usize, +} + +impl SyntaxCache { + pub fn new() -> Self { + Self::default() + } + + /// `revision` must change whenever line content changes; path changes are detected separately. + pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) { + let path_changed = self.path.as_deref() != path; + if self.revision == Some(revision) && !path_changed { + return; + } + + let prefix = self + .lines + .iter() + .zip(lines) + .take_while(|(cached, source)| cached.source == **source) + .count(); + let source_changed = prefix != self.lines.len() || prefix != lines.len(); + let syntaxes = syntax_set(); + let syntax = if self.revision.is_none() || path_changed || (source_changed && prefix == 0) { + detect_syntax(syntaxes, path, lines.first().map(String::as_str)) + } else { + self.syntax + }; + let syntax_changed = self.language() != syntax.map(|syntax| syntax.name.as_str()); + + self.revision = Some(revision); + if path_changed { + self.path = path.map(Path::to_path_buf); + } + self.syntax = syntax; + if !source_changed && !syntax_changed { + return; + } + + self.lines.truncate(lines.len()); + for (row, source) in lines.iter().enumerate().skip(prefix) { + if let Some(cached) = self.lines.get_mut(row) { + if cached.source != *source { + cached.source.clone_from(source); + } + } else { + self.lines.push(CachedLine { + source: source.clone(), + ..CachedLine::default() + }); + } + } + + let Some(syntax) = syntax else { + self.clear_highlighting(); + self.error = None; + return; + }; + let start = if syntax_changed || self.error.is_some() { + 0 + } else { + prefix + }; + self.error = None; + if start < self.lines.len() { + self.parse_from(start, syntax, syntaxes); + } + } + + /// Sorted, non-overlapping, end-exclusive ranges. Unclassified text uses the normal foreground. + pub fn line(&self, row: usize) -> &[SyntaxSpan] { + self.lines.get(row).map_or(&[], |line| &line.spans) + } + + /// Bundled grammar name, or `None` for unknown languages and Plain Text. + pub fn language(&self) -> Option<&str> { + self.syntax.map(|syntax| syntax.name.as_str()) + } + + /// A genuine parser/scope error disables highlighting for the whole buffer until input changes. + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + fn clear_highlighting(&mut self) { + for line in &mut self.lines { + line.spans.clear(); + line.after = None; + } + } + + fn parse_from(&mut self, start: usize, syntax: &SyntaxReference, syntaxes: &SyntaxSet) { + let (mut parser, mut scopes) = if start == 0 { + (ParseState::new(syntax), ScopeStack::new()) + } else { + self.lines[start - 1] + .after + .as_ref() + .expect("unchanged prefix has a parser checkpoint") + .clone() + }; + + // ponytail: parse the dirty suffix to EOF; stop at converged checkpoints if this becomes costly. + for row in start..self.lines.len() { + let cached = &mut self.lines[row]; + self.parse_line.clear(); + self.parse_line.push_str(&cached.source); + self.parse_line.push('\n'); + #[cfg(test)] + { + self.parsed_lines += 1; + } + let result = parser + .parse_line(&self.parse_line, syntaxes) + .map_err(|error| error.to_string()) + .and_then(|changes| { + collect_spans( + cached.source.len(), + &changes, + &mut scopes, + &mut cached.spans, + ) + .map_err(|error| error.to_string()) + }); + if let Err(error) = result { + self.error = Some(format!("{} syntax, line {}: {error}", syntax.name, row + 1)); + self.clear_highlighting(); + return; + } + cached.after = Some((parser.clone(), scopes.clone())); + } + } +} + +fn syntax_set() -> &'static SyntaxSet { + static SYNTAXES: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); + &SYNTAXES +} + +fn detect_syntax<'a>( + syntaxes: &'a SyntaxSet, + path: Option<&Path>, + first_line: Option<&str>, +) -> Option<&'a SyntaxReference> { + let by_path = path.and_then(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| syntaxes.find_syntax_by_extension(name)) + .or_else(|| { + path.extension() + .and_then(|extension| extension.to_str()) + .and_then(|extension| syntaxes.find_syntax_by_extension(extension)) + }) + }); + by_path + .or_else(|| first_line.and_then(|line| syntaxes.find_syntax_by_first_line(line))) + .filter(|syntax| syntax.name != "Plain Text") +} + +fn classify(scopes: &ScopeStack) -> Option { + static SELECTORS: LazyLock<[(ScopeSelectors, SyntaxKind); 7]> = LazyLock::new(|| { + [ + ("comment", SyntaxKind::Comment), + ("string", SyntaxKind::String), + ("keyword, storage, constant.language", SyntaxKind::Keyword), + ("constant.numeric", SyntaxKind::Number), + ( + "entity.name.type, entity.name.class, entity.name.struct, entity.name.enum, \ + entity.name.trait, support.type, support.class", + SyntaxKind::Type, + ), + ( + "entity.name.function, support.function", + SyntaxKind::Function, + ), + ("markup, entity.name.tag", SyntaxKind::Markup), + ] + .map(|(selector, kind)| { + ( + selector.parse().expect("fixed semantic scope selector"), + kind, + ) + }) + }); + SELECTORS + .iter() + .filter_map(|(selector, kind)| { + selector + .does_match(scopes.as_slice()) + .map(|power| (power, *kind)) + }) + .max_by_key(|(power, _)| *power) + .map(|(_, kind)| kind) +} + +fn collect_spans( + line_len: usize, + changes: &[(usize, ScopeStackOp)], + scopes: &mut ScopeStack, + spans: &mut Vec, +) -> Result<(), ScopeError> { + spans.clear(); + let mut start = 0; + let mut changes = changes.iter().peekable(); + while let Some((offset, operation)) = changes.next() { + let end = (*offset).min(line_len); + if start < end { + append_span(spans, start, end, classify(scopes)); + } + scopes.apply(operation)?; + while changes.peek().is_some_and(|(next, _)| next == offset) { + scopes.apply(&changes.next().unwrap().1)?; + } + start = end; + } + if start < line_len { + append_span(spans, start, line_len, classify(scopes)); + } + Ok(()) +} + +fn append_span(spans: &mut Vec, start: usize, end: usize, kind: Option) { + if start >= end { + return; + } + let Some(kind) = kind else { + return; + }; + if let Some(previous) = spans.last_mut() + && previous.end_byte == start + && previous.kind == kind + { + previous.end_byte = end; + } else { + spans.push(SyntaxSpan { + start_byte: start, + end_byte: end, + kind, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use syntect::parsing::{ClearAmount, Scope}; + + fn lines(source: &str) -> Vec { + source.split('\n').map(str::to_owned).collect() + } + + fn kind_at(cache: &SyntaxCache, row: usize, byte: usize) -> Option { + cache + .line(row) + .iter() + .find_map(|span| (span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)) + } + + fn assert_token( + cache: &SyntaxCache, + source: &[String], + row: usize, + token: &str, + kind: SyntaxKind, + ) { + let start = source[row].find(token).unwrap(); + for (byte, _) in source[row][start..start + token.len()].char_indices() { + assert_eq!( + kind_at(cache, row, start + byte), + Some(kind), + "{token:?} on row {row}" + ); + } + } + + fn assert_fresh(cache: &SyntaxCache, source: &[String], path: Option<&Path>) { + let mut fresh = SyntaxCache::new(); + fresh.update(source, path, 0); + assert_eq!(cache.language(), fresh.language()); + assert_eq!(cache.error(), fresh.error()); + for row in 0..source.len() { + assert_eq!(cache.line(row), fresh.line(row), "row {row}"); + } + } + + #[test] + fn bundled_languages_supply_semantic_scopes() { + let cases = [ + ( + "sample.rs", + "Rust", + "fn main() { let value = 42; let text = \"hello\"; /* note */ }", + "fn", + "main", + ), + ( + "sample.rb", + "Ruby", + "def hello\n value = 42\n text = \"hello\" # note\nend", + "def", + "hello", + ), + ( + "sample.py", + "Python", + "def hello():\n value = 42\n text = \"hello\" # note", + "def", + "hello", + ), + ]; + for (path, language, text, keyword, function) in cases { + let source = lines(text); + let mut cache = SyntaxCache::new(); + cache.update(&source, Some(Path::new(path)), 0); + assert_eq!(cache.language(), Some(language)); + assert_eq!(cache.error(), None); + assert_token(&cache, &source, 0, keyword, SyntaxKind::Keyword); + assert_token(&cache, &source, 0, function, SyntaxKind::Function); + let number_row = if language == "Rust" { 0 } else { 1 }; + let string_row = if language == "Rust" { 0 } else { 2 }; + assert_token(&cache, &source, number_row, "42", SyntaxKind::Number); + // Select the quoted occurrence, not a function with the same name. + let quoted = source[string_row].find("\"hello\"").unwrap(); + assert_eq!( + kind_at(&cache, string_row, quoted + 1), + Some(SyntaxKind::String) + ); + assert_token(&cache, &source, string_row, "note", SyntaxKind::Comment); + } + + let source = lines("{\"message\":\"hello\",\"n\":42,\"enabled\":true}"); + let mut cache = SyntaxCache::new(); + cache.update(&source, Some(Path::new("sample.json")), 0); + assert_eq!(cache.language(), Some("JSON")); + assert_token(&cache, &source, 0, "hello", SyntaxKind::String); + assert_token(&cache, &source, 0, "42", SyntaxKind::Number); + assert_token(&cache, &source, 0, "true", SyntaxKind::Keyword); + assert_eq!(cache.error(), None); + } + + #[test] + fn detects_bundled_special_filenames_extensions_and_actual_shebangs() { + let source = lines("all:\n\techo hello"); + let mut cache = SyntaxCache::new(); + cache.update(&source, Some(Path::new("/not/read/from/disk/Makefile")), 0); + assert_eq!(cache.language(), Some("Makefile")); + + let source = lines("#!/usr/bin/env bash\necho \"hello\" # note"); + cache.update(&source, Some(Path::new("/not/read/from/disk/script")), 1); + assert_eq!(cache.language(), Some("Bourne Again Shell (bash)")); + assert_token(&cache, &source, 1, "hello", SyntaxKind::String); + assert_token(&cache, &source, 1, "note", SyntaxKind::Comment); + assert_eq!(cache.error(), None); + + let source = lines("echo \"hello\" # note"); + cache.update(&source, Some(Path::new(".bashrc")), 1); + assert_eq!(cache.language(), Some("Bourne Again Shell (bash)")); + assert_token(&cache, &source, 0, "hello", SyntaxKind::String); + + let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42"); + cache.update(&source, None, 2); + assert_eq!(cache.language(), Some("Python")); + assert_token(&cache, &source, 1, "return", SyntaxKind::Keyword); + cache.update(&source, Some(Path::new("sample.RS")), 2); + assert_eq!(cache.language(), Some("Rust")); + } + + #[test] + fn unknown_and_plain_text_have_no_spans_or_errors() { + let source = lines("fn main() { \"hello\"; 42; }\n/* note"); + let mut cache = SyntaxCache::new(); + for path in [ + None, + Some(Path::new("sample.unknown-extension")), + Some(Path::new("sample.txt")), + ] { + cache.update(&source, path, 0); + assert_eq!(cache.language(), None); + assert_eq!(cache.error(), None); + assert!(cache.line(0).is_empty()); + assert!(cache.line(1).is_empty()); + assert!(cache.line(500).is_empty()); + } + cache.update(&[], Some(Path::new("empty.rs")), 1); + assert_eq!(cache.language(), Some("Rust")); + assert!(cache.line(0).is_empty()); + } + + #[test] + fn nested_multiline_comments_and_edits_before_visible_rows() { + let mut source = lines("/* outer\n/* inner */ still outer\nlast */ fn main() {}\n// tail"); + let path = Some(Path::new("sample.rs")); + let mut cache = SyntaxCache::new(); + cache.update(&source, path, 0); + assert_token(&cache, &source, 1, "inner", SyntaxKind::Comment); + assert_token(&cache, &source, 1, "still outer", SyntaxKind::Comment); + assert_token(&cache, &source, 2, "last", SyntaxKind::Comment); + assert_token(&cache, &source, 2, "fn", SyntaxKind::Keyword); + + source[0] = "fn earlier() {}".into(); + cache.update(&source, path, 1); + assert_eq!( + kind_at(&cache, 1, source[1].find("still outer").unwrap()), + None + ); + assert_eq!(kind_at(&cache, 2, 0), None); + assert_fresh(&cache, &source, path); + + source[0] = "/* outer".into(); + cache.update(&source, path, 2); + assert_token(&cache, &source, 2, "last", SyntaxKind::Comment); + assert_fresh(&cache, &source, path); + } + + #[test] + fn multiline_strings_preserve_unicode_byte_ranges_and_tolerate_unfinished_source() { + let mut source = lines( + "text = \"\"\"start\n\t界e\u{301}👩‍💻 middle\nfinish\"\"\"\nreturn 42 # 界e\u{301}👩‍💻", + ); + let path = Some(Path::new("sample.py")); + let mut cache = SyntaxCache::new(); + cache.update(&source, path, 0); + assert_token( + &cache, + &source, + 1, + "\t界e\u{301}👩‍💻 middle", + SyntaxKind::String, + ); + assert_token(&cache, &source, 2, "finish", SyntaxKind::String); + assert_token(&cache, &source, 3, "return", SyntaxKind::Keyword); + assert_token(&cache, &source, 3, "界e\u{301}👩‍💻", SyntaxKind::Comment); + for (row, line) in source.iter().enumerate() { + let mut end = 0; + for span in cache.line(row) { + assert!(end <= span.start_byte && span.start_byte < span.end_byte); + assert!(span.end_byte <= line.len()); + assert!(line.is_char_boundary(span.start_byte)); + assert!(line.is_char_boundary(span.end_byte)); + end = span.end_byte; + } + } + source[2] = "unfinished".into(); + cache.update(&source, path, 1); + assert_token(&cache, &source, 3, "return", SyntaxKind::String); + assert_eq!(cache.error(), None); + assert_fresh(&cache, &source, path); + } + + #[test] + fn blank_lines_keep_scopes_and_unicode_before_tokens_keeps_byte_offsets() { + let source = lines("/* first\n\n\t界e\u{301}👩‍💻 last */ fn main() { let value = 42; }"); + let mut cache = SyntaxCache::new(); + cache.update(&source, Some(Path::new("sample.rs")), 0); + assert!(cache.line(1).is_empty()); + assert_token(&cache, &source, 2, "界e\u{301}👩‍💻 last", SyntaxKind::Comment); + assert_token(&cache, &source, 2, "fn", SyntaxKind::Keyword); + assert_token(&cache, &source, 2, "main", SyntaxKind::Function); + assert_token(&cache, &source, 2, "42", SyntaxKind::Number); + for span in cache.line(2) { + assert!(source[2].is_char_boundary(span.start_byte)); + assert!(source[2].is_char_boundary(span.end_byte)); + assert!(span.end_byte <= source[2].len()); + } + assert_eq!(cache.error(), None); + } + + #[test] + fn revisions_reuse_prefix_checkpoints_and_unchanged_source_snapshots() { + let mut source = lines("fn main() {\n /* note\n continued */\n let value = 42;\n}"); + let path = Some(Path::new("sample.rs")); + let mut cache = SyntaxCache::new(); + cache.update(&source, path, 10); + assert_eq!(cache.parsed_lines, 5); + let prefix_source = cache.lines[0].source.as_ptr(); + let prefix_spans = cache.lines[0].spans.as_ptr(); + cache.update(&source, path, 10); + cache.update(&source, path, 11); + assert_eq!(cache.parsed_lines, 5); + + source[2] = " changed */".into(); + cache.update(&source, path, 12); + assert_eq!(cache.parsed_lines, 8); + assert_eq!(cache.lines[0].source.as_ptr(), prefix_source); + assert_eq!(cache.lines[0].spans.as_ptr(), prefix_spans); + assert_token(&cache, &source, 2, "changed", SyntaxKind::Comment); + assert_fresh(&cache, &source, path); + + source[2] = " continued */".into(); + cache.update(&source, path, 13); + assert_eq!(cache.parsed_lines, 11); + assert_fresh(&cache, &source, path); + cache.update(&source, path, 14); + assert_eq!(cache.parsed_lines, 11); + } + + #[test] + fn insertion_removal_and_appending_preserve_multiline_state() { + let path = Some(Path::new("sample.py")); + let mut source = lines("text = \"\"\"start\nmiddle\nend\"\"\"\nreturn 42"); + let mut cache = SyntaxCache::new(); + cache.update(&source, path, 0); + assert_eq!(cache.parsed_lines, 4); + + source.insert(1, "inserted 界".into()); + cache.update(&source, path, 1); + assert_eq!(cache.parsed_lines, 8); + assert_token(&cache, &source, 2, "middle", SyntaxKind::String); + assert_fresh(&cache, &source, path); + + source.remove(1); + cache.update(&source, path, 2); + assert_eq!(cache.parsed_lines, 11); + assert_fresh(&cache, &source, path); + + source.push("# appended".into()); + cache.update(&source, path, 3); + assert_eq!(cache.parsed_lines, 12); + assert_token(&cache, &source, 4, "appended", SyntaxKind::Comment); + source.pop(); + cache.update(&source, path, 4); + assert_eq!(cache.parsed_lines, 12); + assert!(cache.line(4).is_empty()); + assert_fresh(&cache, &source, path); + } + + #[test] + fn rename_at_the_same_revision_and_shebang_edits_redetect_language() { + let mut source = lines("fn main() { let value = 42; }"); + let mut cache = SyntaxCache::new(); + cache.update(&source, Some(Path::new("old.rs")), 7); + assert_eq!(cache.parsed_lines, 1); + cache.update(&source, Some(Path::new("new.RS")), 7); + assert_eq!(cache.parsed_lines, 1); + cache.update(&source, Some(Path::new("new.txt")), 7); + assert_eq!(cache.language(), None); + assert!(cache.line(0).is_empty()); + cache.update(&source, Some(Path::new("new.rs")), 7); + assert_eq!(cache.parsed_lines, 2); + assert_token(&cache, &source, 0, "fn", SyntaxKind::Keyword); + + source = lines("#!/usr/bin/env bash\necho \"hello\""); + cache.update(&source, None, 8); + assert_eq!(cache.language(), Some("Bourne Again Shell (bash)")); + source[0] = "#!/usr/bin/env python3".into(); + source[1] = "return \"hello\"".into(); + cache.update(&source, None, 9); + assert_eq!(cache.language(), Some("Python")); + assert_token(&cache, &source, 1, "return", SyntaxKind::Keyword); + assert_fresh(&cache, &source, None); + } + + #[test] + fn type_function_and_markup_categories_come_from_scopes() { + let mut cache = SyntaxCache::new(); + let source = lines("struct Widget;\nfn build() {}"); + cache.update(&source, Some(Path::new("sample.rs")), 0); + assert_token(&cache, &source, 0, "Widget", SyntaxKind::Type); + assert_token(&cache, &source, 1, "build", SyntaxKind::Function); + let source = lines("# Heading"); + cache.update(&source, Some(Path::new("sample.md")), 1); + assert_token(&cache, &source, 0, "Heading", SyntaxKind::Markup); + } + + #[test] + fn deepest_scope_match_wins_and_clear_restore_survives_checkpoints() { + let mut scopes: ScopeStack = "source.python string.quoted.double.python".parse().unwrap(); + let mut spans = Vec::new(); + let changes = [ + (2, ScopeStackOp::Clear(ClearAmount::All)), + (2, ScopeStackOp::Push(Scope::new("source.rust").unwrap())), + ( + 2, + ScopeStackOp::Push(Scope::new("comment.block.rust").unwrap()), + ), + ]; + collect_spans(4, &changes, &mut scopes, &mut spans).unwrap(); + assert_eq!( + spans, + [ + SyntaxSpan { + start_byte: 0, + end_byte: 2, + kind: SyntaxKind::String + }, + SyntaxSpan { + start_byte: 2, + end_byte: 4, + kind: SyntaxKind::Comment + }, + ] + ); + let mut restored = scopes.clone(); + collect_spans( + 4, + &[(1, ScopeStackOp::Pop(2)), (1, ScopeStackOp::Restore)], + &mut restored, + &mut spans, + ) + .unwrap(); + assert_eq!( + spans, + [ + SyntaxSpan { + start_byte: 0, + end_byte: 1, + kind: SyntaxKind::Comment + }, + SyntaxSpan { + start_byte: 1, + end_byte: 4, + kind: SyntaxKind::String + }, + ] + ); + let before = restored.clone(); + collect_spans( + 3, + &[ + (1, ScopeStackOp::Clear(ClearAmount::TopN(1))), + ( + 1, + ScopeStackOp::Push(Scope::new("constant.numeric.python").unwrap()), + ), + (2, ScopeStackOp::Pop(1)), + (2, ScopeStackOp::Restore), + ], + &mut restored, + &mut spans, + ) + .unwrap(); + assert_eq!(restored, before); + assert_eq!(spans[1].kind, SyntaxKind::Number); + restored.push(Scope::new("keyword.operator.python").unwrap()); + assert_eq!(classify(&restored), Some(SyntaxKind::Keyword)); + } + + #[test] + fn real_parser_errors_clear_the_entire_buffer_and_recover_on_an_edit() { + let path = Some(Path::new("sample.rs")); + let mut source = lines("fn main() {}\nlet value = 42;"); + let mut cache = SyntaxCache::new(); + cache.update(&source, path, 0); + assert!(!cache.line(0).is_empty()); + // Missing grammar contexts make Syntect return a genuine ParsingError, not a mock failure. + cache.parse_from(0, cache.syntax.unwrap(), &SyntaxSet::new()); + assert!(cache.error().unwrap().contains("Missing context")); + assert!(cache.error().unwrap().contains("line 1")); + assert!(cache.line(0).is_empty()); + assert!(cache.line(1).is_empty()); + cache.update(&source, path, 1); + assert!(cache.error().is_some()); + source[1] = "let value = 43;".into(); + cache.update(&source, path, 2); + assert_eq!(cache.error(), None); + assert_token(&cache, &source, 0, "fn", SyntaxKind::Keyword); + assert_token(&cache, &source, 1, "43", SyntaxKind::Number); + assert_fresh(&cache, &source, path); + } +} diff --git a/src/tui.rs b/src/tui.rs index 5606970..006ac26 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -30,6 +30,7 @@ use crate::{ editor::{Editor, Input, Mode, Palette}, files::{DockSide, FilePicker, FilePresentation, FileView}, session::EditorSession, + syntax::{SyntaxCache, SyntaxKind}, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text"]; @@ -115,13 +116,16 @@ impl Drop for TerminalSession { #[derive(Default)] struct BufferViewports { offsets: BTreeMap, + syntax: BTreeMap, } impl BufferViewports { fn draw(&mut self, frame: &mut Frame, editor: &Editor) { self.offsets.retain(|id, _| editor.has_buffer(*id)); + self.syntax.retain(|id, _| editor.has_buffer(*id)); let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); - draw(frame, editor, top, left); + let syntax = self.syntax.entry(editor.buffer_id()).or_default(); + draw(frame, editor, top, left, syntax); } } @@ -216,7 +220,14 @@ fn visible_line(line: &str, left: usize, width: usize) -> String { visible } -fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { +fn draw( + frame: &mut Frame, + editor: &Editor, + top: &mut usize, + left: &mut usize, + syntax: &mut SyntaxCache, +) { + syntax.update(editor.lines(), editor.path(), editor.text_revision()); let area = frame.area(); if area.width == 0 || area.height == 0 { return; @@ -291,6 +302,49 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { .map(|line| Line::from(visible_line(line, *left, width))) .collect(); frame.render_widget(Paragraph::new(lines), viewport); + let buffer = frame.buffer_mut(); + for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) { + let spans = syntax.line(row); + if spans.is_empty() { + continue; + } + let mut span_index = 0; + let mut column = 0; + let right = left.saturating_add(width); + for (byte, grapheme) in line.grapheme_indices(true) { + if column >= right { + break; + } + let cells = if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + }; + let end = column + cells; + while span_index < spans.len() && spans[span_index].end_byte <= byte { + span_index += 1; + } + if let Some(span) = spans.get(span_index) + && span.start_byte <= byte + && end > *left + && (grapheme == "\t" || (column >= *left && end <= right)) + { + let color = match span.kind { + SyntaxKind::Comment => Color::DarkGray, + SyntaxKind::String => Color::Green, + SyntaxKind::Keyword => Color::Magenta, + SyntaxKind::Number => Color::Yellow, + SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan, + SyntaxKind::Function => Color::LightBlue, + }; + let y = viewport.y + (row - *top) as u16; + for cell in column.max(*left)..end.min(right) { + buffer[(viewport.x + (cell - *left) as u16, y)].set_fg(color); + } + } + column = end; + } + } if let Some(found) = editor.search_match() && found.start.row >= *top && found.start.row < top.saturating_add(height) @@ -380,6 +434,11 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { if let Some(count) = editor.count() { write!(status, " count: {count}").unwrap(); } + if let Some(error) = syntax.error() { + write!(status, " syntax: {error}").unwrap(); + } else if let Some(language) = syntax.language() { + write!(status, " {language}").unwrap(); + } frame.render_widget( Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), status_area, @@ -786,6 +845,387 @@ mod tests { .join("\n") } + #[test] + fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() { + let directory = Directory::new(); + let path = directory.file("number.rs", "42"); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + let revision = editor.text_revision(); + editor.handle(Input::Right); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.lines(), &["42"]); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + editor.handle(Input::Char('c')); + for ch in "\"ok\"".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Escape); + assert_eq!(editor.lines(), &["\"ok\""]); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); + assert!(editor.text_revision() > revision); + editor.handle(Input::Char('u')); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(editor.lines(), &["42"]); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + + editor + .apply(vec![crate::editor::Effect::Replace( + "fn main() { let n = 42; let s = \"ok\"; } // note".into(), + )]) + .unwrap(); + editor.handle(Input::Home); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let source = &editor.lines()[0]; + for (token, color) in [ + ("fn", Color::Magenta), + ("main", Color::LightBlue), + ("let", Color::Magenta), + ("42", Color::Yellow), + ("\"ok\"", Color::Green), + ] { + let x = 2 + source.find(token).unwrap() as u16; + assert_eq!(buffer[(x, 0)].fg, color, "{token}"); + assert_eq!(buffer[(x, 0)].bg, Color::Reset); + } + } + + #[test] + fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() { + let directory = Directory::new(); + for (name, before, after, color) in [ + ( + "comment.rs", + "/*\nhidden\nvisible\n*/", + "//\nhidden\nvisible\n//", + Color::DarkGray, + ), + ( + "string.py", + "text = \"\"\"\nhidden\nvisible\n\"\"\"", + "text = \"\"\nhidden\nvisible\n\"\"", + Color::Green, + ), + ] { + let path = directory.file(name, before); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Down); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&editor.buffer_id()].0, 2); + assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); + editor + .apply(vec![crate::editor::Effect::Replace(after.into())]) + .unwrap(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); + assert_eq!( + terminal.backend().buffer()[(2, 0)].fg, + Color::Reset, + "{name}" + ); + editor.handle(Input::Char('u')); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); + } + } + + #[test] + fn syntax_colors_whole_unicode_graphemes_and_clipped_tabs_without_painting_wide_blanks() { + let directory = Directory::new(); + let source = "#a\t界e\u{301}👩‍🚀z"; + let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); + let mut syntax = SyntaxCache::new(); + for (offset, width, caret, colors) in [ + (2, 5, 4, vec![Color::DarkGray; 5]), + (5, 2, 4, vec![Color::Reset, Color::DarkGray]), + (7, 2, 5, vec![Color::DarkGray; 2]), + (8, 2, 6, vec![Color::Reset, Color::DarkGray]), + ( + 0, + 5, + 2, + vec![ + Color::DarkGray, + Color::DarkGray, + Color::DarkGray, + Color::DarkGray, + Color::Reset, + ], + ), + ] { + editor.handle(Input::Home); + for _ in 0..caret { + editor.handle(Input::Right); + } + let area = Rect::new(3, 2, width + 2, 4); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(20, 8), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let (mut top, mut left) = (0, offset); + let rendered = terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) + .unwrap() + .buffer + .clone(); + assert_eq!(left, offset); + let text_area = Rect::new(area.x + 2, area.y, width, 1); + let mut expected = ratatui::buffer::Buffer::empty(text_area); + use ratatui::widgets::Widget; + Paragraph::new(visible_line(source, offset, usize::from(width))) + .render(text_area, &mut expected); + let buffer = terminal.backend().buffer(); + for (cell, color) in colors.into_iter().enumerate() { + let position = (text_area.x + cell as u16, text_area.y); + assert_eq!(buffer[position].symbol(), expected[position].symbol()); + assert_eq!(rendered[position].fg, color, "left={offset}, cell={cell}"); + assert_eq!(rendered[position].bg, Color::Reset); + } + assert_eq!(buffer[(area.x, area.y)].fg, Color::Cyan); + assert_eq!(buffer[(0, 0)].fg, Color::Reset); + assert_eq!(editor.lines(), &[source]); + } + } + + #[test] + fn search_visual_selection_and_secondary_carets_override_syntax_foreground() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("overlays.py", "# word\n# word\n# word")), + vec![], + ) + .unwrap(); + editor.handle(Input::Char('/')); + for ch in "word".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let found = editor.search_match().unwrap(); + let y = found.start.row as u16; + for x in 4..8 { + assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black); + assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow); + } + editor.handle(Input::Char('v')); + editor.handle(Input::Char('e')); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 3); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + for selection in editor.selections() { + let y = selection.start.row as u16; + let start = + display_column(&editor.lines()[selection.start.row], selection.start.column); + let end = display_column(&editor.lines()[selection.end.row], selection.end.column); + for column in start..end { + let x = 2 + column as u16; + if buffer[(x, y)].bg == Color::Magenta { + assert_eq!(buffer[(x, y)].fg, Color::Black); + } else { + assert_eq!(buffer[(x, y)].fg, Color::White); + assert_eq!(buffer[(x, y)].bg, Color::Blue); + } + } + assert_eq!(buffer[(2, y)].fg, Color::DarkGray); + } + for caret in editor + .carets() + .into_iter() + .filter(|caret| *caret != editor.cursor()) + { + let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; + assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black); + assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); + } + } + + #[test] + fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() { + let directory = Directory::new(); + let mut editor = + Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); + let revision = editor.text_revision(); + for name in ["source.vampunknown", "source.txt", "again.rs"] { + editor.execute(&format!("w {}", directory.0.join(name).display())); + assert_eq!(editor.text_revision(), revision); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + if name.ends_with(".rs") { + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); + assert_eq!( + viewports.syntax[&editor.buffer_id()].language(), + Some("Rust") + ); + } else { + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset); + assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty()); + assert!(viewports.syntax[&editor.buffer_id()].language().is_none()); + } + assert_eq!(editor.lines(), &["let value = 42;"]); + } + } + + #[test] + fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() { + let directory = Directory::new(); + let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + editor.execute(&format!( + "edit {}", + directory.file("b.py", "# note").display() + )); + let b_id = editor.buffer_id(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); + editor + .apply(vec![crate::editor::Effect::Replace("\"ok\"".into())]) + .unwrap(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + assert_eq!(viewports.syntax[&a_id].language(), Some("Rust")); + assert_eq!(viewports.syntax[&b_id].language(), Some("Python")); + editor.execute(&format!("buffer {b_id}")); + editor.execute(&format!("bdelete {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); + assert!(!viewports.syntax.contains_key(&a_id)); + assert!(!viewports.offsets.contains_key(&a_id)); + assert_eq!(viewports.syntax.len(), 1); + } + + #[test] + fn highlighted_editor_text_stays_out_of_docked_and_floating_picker_surfaces() { + let directory = Directory::new(); + let path = directory.file("panels.rs", &vec!["// note"; 30].join("\n")); + let area = Rect::new(3, 2, 100, 24); + for (name, side) in [ + ("left", Some(DockSide::Left)), + ("right", Some(DockSide::Right)), + ("top", Some(DockSide::Top)), + ("bottom", Some(DockSide::Bottom)), + ("floating", None), + ] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + if side.is_some() { + editor.set_file_explorer_side(name).unwrap(); + } + editor.handle(Input::Char(' ')); + editor.handle(Input::Char(if side.is_some() { 'e' } else { 'o' })); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 30), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let workspace = Rect::new(area.x, area.y, area.width, area.height - 2); + let (text, panel) = if let Some(side) = side { + let (text, panel) = dock_layout(workspace, side, true); + (text, panel.unwrap()) + } else { + (workspace, Rect::new(area.x + 8, area.y + 3, 84, 18)) + }; + let mut expected = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 30), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(panel), + }, + ) + .unwrap(); + expected + .draw(|frame| { + draw_file_list( + frame, + editor.file_picker().unwrap(), + panel, + editor.file_navigation_hint(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let expected = expected.backend().buffer(); + for y in panel.y..panel.bottom() { + for x in panel.x..panel.right() { + assert_eq!(buffer[(x, y)], expected[(x, y)], "{name}: {x},{y}"); + } + } + assert_eq!(buffer[(text.x + 3, text.y)].symbol(), "/"); + assert_eq!(buffer[(text.x + 3, text.y)].fg, Color::DarkGray); + assert_eq!(buffer[(text.x, text.y)].fg, Color::Cyan); + assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan); + assert_eq!(buffer[(0, 0)].fg, Color::Reset); + } + } + #[test] fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() { for (code, modifiers, expected) in [ @@ -879,11 +1319,12 @@ mod tests { editor.set_file_explorer_side("bottom").unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); for key in ['o', 'e'] { editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&hint), "{text}"); @@ -892,7 +1333,7 @@ mod tests { } editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&hint), "{text}"); @@ -1362,8 +1803,9 @@ mod tests { .collect(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let query_cursor = terminal.get_cursor_position().unwrap(); assert!(screen_text(&terminal).contains(&format!("> {query}"))); @@ -1384,7 +1826,7 @@ mod tests { filtered ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&format!("filter: {query}")), "{text}"); @@ -1405,7 +1847,7 @@ mod tests { editor.handle(Input::Char('i')); assert_eq!(editor.file_picker().unwrap().query, query); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor); editor.handle(Input::Escape); @@ -1417,7 +1859,7 @@ mod tests { Path::new(query) ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( @@ -1437,7 +1879,7 @@ mod tests { Path::new(query) ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!(cursor.y, query_cursor.y); @@ -1455,7 +1897,7 @@ mod tests { Path::new(query) ); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), cursor); editor.handle(Input::Ctrl('t')); @@ -1481,6 +1923,7 @@ mod tests { editor.set_project_root(directory.0.clone()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); editor.handle(Input::Char('i')); @@ -1495,7 +1938,7 @@ mod tests { assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains("List")); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); @@ -1504,7 +1947,7 @@ mod tests { assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); editor.handle(Input::Escape); @@ -1516,7 +1959,7 @@ mod tests { assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains("filter: n")); let cursor = terminal.get_cursor_position().unwrap(); @@ -1549,6 +1992,7 @@ mod tests { .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] { editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); @@ -1567,7 +2011,7 @@ mod tests { ] { assert_eq!(editor.file_picker().unwrap().view, view); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(editor.file_navigation_hint()), "{text}"); @@ -1623,7 +2067,7 @@ mod tests { } editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); } @@ -1648,6 +2092,7 @@ mod tests { ) .unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); assert!(editor.file_picker().unwrap().focused); @@ -1659,7 +2104,7 @@ mod tests { ] { editor.set_file_explorer_side(name).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let workspace = Rect::new(area.x, area.y, area.width, area.height - 2); let (editor_area, panel) = dock_layout(workspace, side, true); @@ -1675,7 +2120,7 @@ mod tests { assert!(panel.contains(terminal.get_cursor_position().unwrap())); editor.handle(Input::Escape); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(editor_area.contains(terminal.get_cursor_position().unwrap())); let buffer = terminal.backend().buffer(); @@ -1727,14 +2172,15 @@ mod tests { ) .unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let before = (top, left); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let panel = Rect::new(area.x + 13, area.y + 4, 84, 18); assert_eq!((top, left), before); @@ -1855,6 +2301,7 @@ mod tests { ) .unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); for (name, side) in [ ("left", DockSide::Left), ("right", DockSide::Right), @@ -1876,7 +2323,7 @@ mod tests { terminal.resize(area).unwrap(); let area = terminal.get_frame().area(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); if width > 0 && height > 0 { let workspace = Rect::new( @@ -1894,7 +2341,7 @@ mod tests { } editor.handle(Input::Escape); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); if width > 0 && height > 0 { let cursor = terminal.get_cursor_position().unwrap(); @@ -1940,15 +2387,16 @@ mod tests { let mut editor = Editor::open(Some(path), vec![]).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('b')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains("leader: Space b")); editor.handle(Input::Char('x')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(!screen_text(&terminal).contains("leader:")); assert_eq!(editor.lines(), &["keep"]); @@ -1966,8 +2414,9 @@ mod tests { } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains(", b n")); assert!(!screen_text(&terminal).contains("Space b n")); @@ -1975,7 +2424,7 @@ mod tests { .bind("normal", ",", Some("core.command".into())) .unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains("unbound")); assert!(!screen_text(&terminal).contains(", b n")); @@ -2214,16 +2663,17 @@ mod tests { .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); editor.handle(Input::Char('1')); editor.handle(Input::Char('0')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains("count: 10")); editor.handle(Input::Escape); editor.handle(Input::Down); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(!screen_text(&terminal).contains("count:")); assert_eq!( @@ -2253,8 +2703,9 @@ mod tests { ) .unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!((top, left), (8, 8)); let buffer = terminal.backend().buffer(); @@ -2272,7 +2723,7 @@ mod tests { ); editor.handle(Input::Down); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); let last_number: String = (3..6).map(|x| buffer[(x, 4)].symbol()).collect(); @@ -2285,7 +2736,7 @@ mod tests { editor.handle(Input::Up); editor.handle(Input::Up); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); for (y, expected) in [(2, "10 "), (3, " 1 "), (4, " 2 ")] { @@ -2295,7 +2746,7 @@ mod tests { editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); for (y, expected) in [(2, " 1 "), (3, "11 "), (4, " 1 ")] { @@ -2308,6 +2759,7 @@ mod tests { fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() { let mut editor = Editor::open(None, vec![]).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); for count in [9usize, 10, 100] { editor .apply(vec![crate::editor::Effect::Replace( @@ -2320,7 +2772,7 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); let buffer = terminal.backend().buffer(); @@ -2345,7 +2797,7 @@ mod tests { .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].symbol(), "1"); @@ -2370,8 +2822,9 @@ mod tests { } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("> no-such-command"), "{text}"); @@ -2384,7 +2837,7 @@ mod tests { assert_eq!(editor.mode(), Mode::Search); assert_eq!(editor.command_line(), "saved search"); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert!(screen_text(&terminal).contains("/saved search")); } @@ -2404,8 +2857,9 @@ mod tests { let first_name = palette.entries[0].name.clone(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&selected.name), "{text}"); @@ -2439,8 +2893,9 @@ mod tests { } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}"); @@ -2452,7 +2907,7 @@ mod tests { editor.handle(Input::Char(ch)); } terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("sample | unbound | NORMAL"), "{text}"); @@ -2549,18 +3004,19 @@ mod tests { editor.handle(Input::End); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let viewport = (top, left); editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!((top, left), viewport); editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!((top, left), viewport); let cursor = terminal.get_cursor_position().unwrap(); @@ -2582,6 +3038,7 @@ mod tests { editor.handle(Input::Down); editor.handle(Input::End); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); for prompting in [false, true] { if prompting { editor.handle(Input::Char('/')); @@ -2599,7 +3056,7 @@ mod tests { ) .unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); @@ -2645,8 +3102,9 @@ mod tests { assert_eq!(editor.selections().len(), 5); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); for selection in editor.selections() { @@ -2676,8 +3134,9 @@ mod tests { editor.handle(Input::Char('A')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); for caret in editor.carets() { @@ -2714,8 +3173,9 @@ mod tests { editor.handle(Input::Left); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 6)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); for x in [1, 2, 3, 4] { @@ -2726,7 +3186,7 @@ mod tests { editor.handle(Input::Down); editor.handle(Input::Down); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(10, 0)].symbol(), " "); @@ -2740,7 +3200,7 @@ mod tests { editor.handle(Input::End); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(left, 4); @@ -2753,7 +3213,7 @@ mod tests { assert_ne!(buffer[(2, 1)].bg, Color::Blue); editor.handle(Input::Escape); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(2, 0)].bg, Color::Yellow); @@ -2778,8 +3238,9 @@ mod tests { editor.handle(Input::End); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 5)).unwrap(); let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(left, 4); @@ -2796,7 +3257,7 @@ mod tests { editor.handle(Input::Char(ch)); } terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(1, 4)].symbol(), "界"); @@ -2805,7 +3266,7 @@ mod tests { editor.handle(Input::Backspace); editor.handle(Input::Char('\t')); terminal - .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax)) .unwrap(); assert_eq!( terminal.get_cursor_position().unwrap(),