Skip to main content

trail/preview/
text.rs

1//! Text file preview provider.
2//!
3//! Provides preview content for text files, and for files not yet known to be
4//! anything else. Every preview is deferred to `workers/highlight.rs` via the
5//! async worker pool — no blocking I/O is performed on the UI thread — and the
6//! `Loading` placeholder is shown until the worker delivers its result.
7//!
8//! `can_handle` therefore takes any regular file except one already classified
9//! as binary: the worker has to read the file anyway, so it is the cheapest
10//! place to find out which it is. [`is_text_file`] is the probe it uses.
11
12use std::fs;
13use std::path::Path;
14
15use content_inspector::inspect;
16
17use crate::app::state::{Entry, EntryKind};
18use crate::preview::provider::{
19    sanitize, PreviewContent, PreviewCtx, PreviewOutcome, PreviewProvider,
20};
21
22/// Most bytes of a file the preview will read.
23///
24/// Every text preview is off-thread now, so this is no longer a
25/// synchronous/asynchronous boundary — it is the ceiling that keeps one read
26/// bounded, and a preview that hits it is reported as truncated.
27///
28/// Named constant per coding-standard §10: no magic numbers.
29pub const TEXT_SYNC_THRESHOLD: usize = 256 * 1024; // 256 KB
30
31/// Alias kept for backwards compatibility with code that referenced the old name.
32pub const TEXT_PREVIEW_MAX_BYTES: usize = TEXT_SYNC_THRESHOLD;
33
34/// Maximum number of lines shown in the plain-text (non-highlighted) fallback.
35#[allow(dead_code)]
36const TEXT_PREVIEW_MAX_LINES: usize = 500;
37
38/// Preview provider for text files and for files not yet classified.
39///
40/// Always returns `PreviewOutcome::Deferred`: `workers::highlight` reads the
41/// file, decides whether it is text, and highlights it if so. A file already
42/// known to be binary is left to `BinaryProvider`, which needs no worker.
43pub struct TextProvider;
44
45impl PreviewProvider for TextProvider {
46    fn can_handle(&self, entry: &Entry) -> bool {
47        // Only handle regular files; directories go to DirectoryProvider.
48        if entry.kind != EntryKind::File {
49            return false;
50        }
51        // Take anything not already known to be binary. An unclassified file
52        // lands here rather than on the binary provider because the worker this
53        // spawns has to read the file to preview it anyway, and can answer the
54        // question on the way past — whereas answering it here would mean
55        // reading from disk on the UI thread.
56        entry.is_text != Some(false)
57    }
58
59    fn preview(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome {
60        // Always spawn the worker, regardless of file size.
61        //
62        // Files under a size threshold used to be highlighted synchronously on
63        // the UI thread, which stalled the event loop for any file big enough to
64        // take perceptible time. Routing every file through the worker keeps the
65        // thread free; the `Loading` placeholder covers the async window, and the
66        // generation-guard in `workers::merge` discards results for a selection
67        // the user has already left.
68        crate::workers::highlight::spawn_highlight(
69            entry.path.clone(),
70            ctx.generation,
71            ctx.worker_tx.clone(),
72            ctx.max_preview_lines,
73            ctx.text_sync_threshold_bytes,
74        );
75        PreviewOutcome::Deferred
76    }
77}
78
79/// Returns `true` if `path` is a readable text file (not binary).
80///
81/// Reads the first 8 KB and passes it to `content_inspector` to detect
82/// binary content. Returns `false` on I/O errors.
83pub fn is_text_file(path: &Path) -> bool {
84    use std::io::Read;
85    let mut buf = [0u8; 8192];
86    let Ok(mut f) = fs::File::open(path) else {
87        return false;
88    };
89    let n = f.read(&mut buf).unwrap_or(0);
90    inspect(&buf[..n]).is_text()
91}
92
93/// Decodes the bytes of a text file for display, whatever its encoding.
94///
95/// A byte-order mark selects UTF-8 or UTF-16 (either endianness). Without one,
96/// valid UTF-8 is taken as it is, and anything else is read as Windows-1252 —
97/// the "ANSI" code page Windows editors save Western European text in, and a
98/// superset of Latin-1 — so a `café` saved by Notepad still reads `café`. A
99/// file that is mostly UTF-8 with a stray bad byte keeps its UTF-8 and shows
100/// the bad byte as `�`.
101///
102/// Never fails: the preview's job is to show the file, and refusing to decode
103/// it would leave a pane indistinguishable from an empty file. `bytes` may have
104/// been cut mid-character by the preview's byte ceiling; the partial character
105/// is dropped rather than shown as garbage.
106pub fn decode_text(bytes: &[u8]) -> String {
107    if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
108        return decode_utf8_or_ansi(rest);
109    }
110    if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
111        return decode_utf16(rest, u16::from_le_bytes);
112    }
113    if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
114        return decode_utf16(rest, u16::from_be_bytes);
115    }
116    decode_utf8_or_ansi(bytes)
117}
118
119/// UTF-8 when the bytes are (or are mostly) UTF-8, Windows-1252 otherwise.
120fn decode_utf8_or_ansi(bytes: &[u8]) -> String {
121    match std::str::from_utf8(bytes) {
122        Ok(text) => text.to_owned(),
123        Err(err) => {
124            let valid = &bytes[..err.valid_up_to()];
125            // Cut mid-character at the very end: everything before it is good.
126            if err.error_len().is_none() {
127                return String::from_utf8_lossy(valid).into_owned();
128            }
129            // Multi-byte UTF-8 already seen before the bad byte: a UTF-8 file
130            // with damage, not an ANSI one.
131            if valid.iter().any(|b| !b.is_ascii()) {
132                return String::from_utf8_lossy(bytes).into_owned();
133            }
134            bytes.iter().map(|&b| windows_1252(b)).collect()
135        }
136    }
137}
138
139/// Decodes UTF-16 code units, replacing unpaired surrogates with `�`. A
140/// trailing odd byte — the byte ceiling cutting a unit in half — is dropped.
141fn decode_utf16(bytes: &[u8], unit: fn([u8; 2]) -> u16) -> String {
142    let units = bytes.chunks_exact(2).map(|pair| unit([pair[0], pair[1]]));
143    char::decode_utf16(units)
144        .map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER))
145        .collect()
146}
147
148/// The Windows-1252 character for `byte`.
149///
150/// Identical to Latin-1 except for 0x80–0x9F, where Windows put the euro sign,
151/// curly quotes and dashes instead of C1 control codes. The five bytes it left
152/// unassigned stay control codes, which [`sanitize`] draws as a placeholder.
153fn windows_1252(byte: u8) -> char {
154    const HIGH: [char; 32] = [
155        '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž',
156        '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}',
157        'ž', 'Ÿ',
158    ];
159    match byte {
160        0x80..=0x9F => HIGH[usize::from(byte - 0x80)],
161        _ => char::from(byte),
162    }
163}
164
165/// Builds a `PreviewContent::Text` for `path`.
166///
167/// Reads up to `TEXT_PREVIEW_MAX_BYTES` and formats each line as
168/// `\" {n:>4}  {line}\"`. Returns `PreviewContent::Empty` on I/O error.
169///
170/// Used as a fallback when the syntect path returns `Empty`, and by the async
171/// highlight worker's plain-text fallback path.
172// Integration tests call this function via `trail::preview::text::build_text_preview`.
173// The binary itself no longer needs it (all text previews are async), so the
174// compiler warns about dead code; suppress that warning rather than removing
175// the function from the public API.
176#[allow(dead_code)]
177pub fn build_text_preview(path: &Path) -> PreviewContent {
178    use std::io::Read;
179    let Ok(f) = fs::File::open(path) else {
180        return PreviewContent::Empty;
181    };
182
183    let mut buf = Vec::with_capacity(TEXT_PREVIEW_MAX_BYTES.min(65536));
184    // Read at most TEXT_PREVIEW_MAX_BYTES to keep the UI thread responsive.
185    let mut limited = f.take(TEXT_PREVIEW_MAX_BYTES as u64);
186    if limited.read_to_end(&mut buf).is_err() {
187        return PreviewContent::Empty;
188    }
189
190    // Attempt lossy UTF-8 conversion.
191    let text = String::from_utf8_lossy(&buf);
192
193    let lines: Vec<String> = text
194        .lines()
195        .take(TEXT_PREVIEW_MAX_LINES)
196        .enumerate()
197        .map(|(i, line)| format!("{:>4}  {}", i + 1, sanitize(line)))
198        .collect();
199
200    PreviewContent::Text(lines)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use tempfile::NamedTempFile;
207
208    #[test]
209    fn text_preview_numbers_lines() {
210        let mut f = NamedTempFile::new().unwrap();
211        use std::io::Write;
212        writeln!(f, "hello").unwrap();
213        writeln!(f, "world").unwrap();
214
215        let content = build_text_preview(f.path());
216        if let PreviewContent::Text(lines) = content {
217            assert_eq!(lines.len(), 2);
218            assert!(lines[0].contains("hello"));
219            assert!(lines[0].contains("   1"));
220            assert!(lines[1].contains("world"));
221        } else {
222            panic!("expected Text variant");
223        }
224    }
225
226    #[test]
227    fn is_text_file_detects_text() {
228        let mut f = NamedTempFile::new().unwrap();
229        use std::io::Write;
230        write!(f, "plain text content").unwrap();
231        assert!(is_text_file(f.path()));
232    }
233
234    #[test]
235    fn is_text_file_rejects_binary() {
236        let mut f = NamedTempFile::new().unwrap();
237        use std::io::Write;
238        // Write a sequence of null bytes — content_inspector will flag these as binary.
239        f.write_all(&[0u8; 64]).unwrap();
240        assert!(!is_text_file(f.path()));
241    }
242
243    #[test]
244    fn decode_keeps_utf8_as_it_is() {
245        assert_eq!(decode_text("café ✓".as_bytes()), "café ✓");
246        assert_eq!(decode_text(b"\xEF\xBB\xBFbom"), "bom");
247    }
248
249    /// Notepad's "ANSI" is Windows-1252: a Spanish file saved that way must
250    /// read as Spanish, not come back blank as it did when UTF-8 was required.
251    #[test]
252    fn decode_reads_non_utf8_as_windows_1252() {
253        assert_eq!(decode_text(b"caf\xe9 \x80 \x93ol\xe9\x94"), "café € “olé”");
254    }
255
256    #[test]
257    fn decode_reads_utf16_with_a_byte_order_mark() {
258        let mut le = vec![0xFF, 0xFE];
259        let mut be = vec![0xFE, 0xFF];
260        for unit in "año\r\n".encode_utf16() {
261            le.extend_from_slice(&unit.to_le_bytes());
262            be.extend_from_slice(&unit.to_be_bytes());
263        }
264        assert_eq!(decode_text(&le), "año\r\n");
265        assert_eq!(decode_text(&be), "año\r\n");
266        // The byte ceiling can split a code unit; the half is dropped.
267        le.push(b'x');
268        assert_eq!(decode_text(&le), "año\r\n");
269    }
270
271    #[test]
272    fn decode_keeps_damaged_utf8_as_utf8() {
273        assert_eq!(decode_text(b"\xC3\xA9 \xFF ok"), "é \u{FFFD} ok");
274    }
275
276    #[test]
277    fn decode_drops_a_character_cut_by_the_ceiling() {
278        // "é" is two bytes; the ceiling kept only the first.
279        assert_eq!(decode_text(b"ab\xC3"), "ab");
280    }
281}