Skip to main content

lean_ctx/core/
text_decode.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Source-text decoding shared by every reader that feeds the indexes.
3//!
4//! `std::fs::read_to_string` rejects anything that is not strict UTF-8, and the
5//! index builders used to `continue` past that error without a trace. On
6//! Windows that silently dropped whole projects: editors there still save
7//! "ANSI" (Windows-1252) files, and Visual Studio / PowerShell write UTF-16
8//! with a byte-order mark. Such files were missing from BM25, the dense index,
9//! the graph and `ctx_search`, so `ctx_compose` answered "no match" for code
10//! that was plainly on disk.
11//!
12//! `decode` never fails:
13//! - a UTF-8 BOM is stripped (it is an encoding artifact, not content);
14//! - a UTF-16 LE/BE BOM selects UTF-16;
15//! - valid UTF-8 passes through without copying;
16//! - otherwise the bytes are mostly UTF-8 with a few corrupt sequences (decoded
17//!   lossily) or a legacy single-byte file (decoded as Windows-1252, a superset
18//!   of Latin-1 that maps every byte, so line numbers stay exact).
19
20use std::path::Path;
21
22/// Windows-1252 code points for bytes 0x80..=0x9F. The five bytes the code
23/// page leaves undefined map to the matching C1 control, as WHATWG does.
24const CP1252_HIGH: [char; 32] = [
25    '\u{20AC}', '\u{0081}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
26    '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{008D}', '\u{017D}', '\u{008F}',
27    '\u{0090}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
28    '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{009D}', '\u{017E}', '\u{0178}',
29];
30
31const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
32const UTF16_LE_BOM: &[u8] = &[0xFF, 0xFE];
33const UTF16_BE_BOM: &[u8] = &[0xFE, 0xFF];
34/// Prefix inspected for NUL bytes when deciding "binary".
35const SNIFF_BYTES: usize = 8192;
36
37/// Whether `bytes` start with a UTF-16 byte-order mark. UTF-16 text is full of
38/// NUL bytes, so binary sniffers must ask this before rejecting a file.
39#[must_use]
40pub(crate) fn has_utf16_bom(bytes: &[u8]) -> bool {
41    bytes.starts_with(UTF16_LE_BOM) || bytes.starts_with(UTF16_BE_BOM)
42}
43
44/// Read a source file as text, whatever its encoding (see module docs).
45/// Replaces `std::fs::read_to_string` in index readers: an encoding is never an
46/// error any more, but binary content (a NUL byte in the first 8 KiB of a file
47/// without a UTF-16 BOM, the same test `ctx_read` applies) still is, so a
48/// binary with an unknown extension is not searched as mojibake.
49pub(crate) fn read_text(path: impl AsRef<Path>) -> std::io::Result<String> {
50    let bytes = std::fs::read(path.as_ref())?;
51    if looks_binary(&bytes) {
52        return Err(std::io::Error::new(
53            std::io::ErrorKind::InvalidData,
54            "binary content",
55        ));
56    }
57    Ok(decode(bytes))
58}
59
60/// `read_text` with the decoder's outcome: the text, the encoding it was
61/// decoded as, and whether any byte could not be represented. Readers that
62/// feed admission, caches or indexes use this form — a `String` alone cannot
63/// say that the text is not the whole source.
64pub fn read_text_checked(path: impl AsRef<Path>) -> std::io::Result<Decoded> {
65    let bytes = std::fs::read(path.as_ref())?;
66    if looks_binary(&bytes) {
67        return Err(std::io::Error::new(
68            std::io::ErrorKind::InvalidData,
69            "binary content",
70        ));
71    }
72    Ok(decode_checked(bytes))
73}
74
75/// What a decode could not represent faithfully.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub(crate) enum DecodeLoss {
78    /// Invalid UTF-8 sequences were replaced by U+FFFD.
79    InvalidUtf8,
80    /// A dangling odd byte at the end of UTF-16 text was dropped.
81    Utf16OddTail,
82    /// An unpaired UTF-16 surrogate was replaced by U+FFFD.
83    Utf16UnpairedSurrogate,
84}
85
86/// The one decoder outcome every decoding path produces.
87///
88/// `loss == None` means every input byte is represented in `text`: valid
89/// UTF-8 (with or without BOM), well-formed UTF-16, and Windows-1252 — a
90/// total mapping of all 256 byte values, so nothing is dropped or replaced
91/// (the encoding is a guess, the decoding is lossless). Anything else is not
92/// a complete source: it may be delivered as the mode defines, but it is
93/// never complete for admission and never storable.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Decoded {
96    pub text: String,
97    pub(crate) encoding: Encoding,
98    pub(crate) loss: Option<DecodeLoss>,
99}
100
101impl Decoded {
102    #[must_use]
103    pub fn complete(&self) -> bool {
104        self.loss.is_none()
105    }
106}
107
108/// Decode file bytes to text and report what was lost; never fails.
109#[must_use]
110pub(crate) fn decode_checked(mut bytes: Vec<u8>) -> Decoded {
111    if bytes.starts_with(UTF16_LE_BOM) {
112        return decode_utf16(&bytes[2..], u16::from_le_bytes, Encoding::Utf16Le);
113    }
114    if bytes.starts_with(UTF16_BE_BOM) {
115        return decode_utf16(&bytes[2..], u16::from_be_bytes, Encoding::Utf16Be);
116    }
117    let bom = bytes.starts_with(UTF8_BOM);
118    if bom {
119        bytes.drain(..UTF8_BOM.len());
120    }
121    match String::from_utf8(bytes) {
122        Ok(text) => Decoded {
123            text,
124            encoding: if bom {
125                Encoding::Utf8Bom
126            } else {
127                Encoding::Utf8
128            },
129            loss: None,
130        },
131        Err(err) => {
132            let bytes = err.into_bytes();
133            if is_mostly_utf8(&bytes) {
134                Decoded {
135                    text: String::from_utf8_lossy(&bytes).into_owned(),
136                    encoding: Encoding::Utf8Lossy,
137                    loss: Some(DecodeLoss::InvalidUtf8),
138                }
139            } else {
140                Decoded {
141                    text: decode_cp1252(&bytes),
142                    encoding: Encoding::Windows1252,
143                    loss: None,
144                }
145            }
146        }
147    }
148}
149
150/// NUL bytes mark binary data — except in UTF-16, where they are half the text.
151#[must_use]
152pub fn looks_binary(bytes: &[u8]) -> bool {
153    !has_utf16_bom(bytes) && bytes.iter().take(SNIFF_BYTES).any(|&b| b == 0)
154}
155
156/// Decode file bytes to text; never fails (see module docs).
157#[must_use]
158pub(crate) fn decode(bytes: Vec<u8>) -> String {
159    decode_checked(bytes).text
160}
161
162/// The encoding [`decode`] picks for a byte sequence, for diagnostics.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub(crate) enum Encoding {
165    Utf8,
166    Utf8Bom,
167    Utf16Le,
168    Utf16Be,
169    /// Mostly UTF-8 with corrupt sequences, which decode to U+FFFD.
170    Utf8Lossy,
171    Windows1252,
172}
173
174impl Encoding {
175    #[must_use]
176    pub(crate) fn label(self) -> &'static str {
177        match self {
178            Encoding::Utf8 => "utf-8",
179            Encoding::Utf8Bom => "utf-8 with BOM",
180            Encoding::Utf16Le => "utf-16le (BOM)",
181            Encoding::Utf16Be => "utf-16be (BOM)",
182            Encoding::Utf8Lossy => "utf-8 with invalid bytes (replaced by U+FFFD)",
183            Encoding::Windows1252 => "windows-1252 (legacy ANSI)",
184        }
185    }
186}
187
188/// Which encoding [`decode`] uses for `bytes`. Kept out of `decode` itself so
189/// the hot path validates UTF-8 only once.
190#[must_use]
191pub(crate) fn detect_encoding(bytes: &[u8]) -> Encoding {
192    if bytes.starts_with(UTF16_LE_BOM) {
193        Encoding::Utf16Le
194    } else if bytes.starts_with(UTF16_BE_BOM) {
195        Encoding::Utf16Be
196    } else if let Some(body) = bytes.strip_prefix(UTF8_BOM) {
197        match detect_single_byte_or_utf8(body) {
198            Encoding::Utf8 => Encoding::Utf8Bom,
199            other => other,
200        }
201    } else {
202        detect_single_byte_or_utf8(bytes)
203    }
204}
205
206/// The BOM-less tail of [`decode`]: UTF-8, lossy UTF-8 or Windows-1252.
207fn detect_single_byte_or_utf8(bytes: &[u8]) -> Encoding {
208    if std::str::from_utf8(bytes).is_ok() {
209        Encoding::Utf8
210    } else if is_mostly_utf8(bytes) {
211        Encoding::Utf8Lossy
212    } else {
213        Encoding::Windows1252
214    }
215}
216
217fn decode_utf16(body: &[u8], unit: fn([u8; 2]) -> u16, encoding: Encoding) -> Decoded {
218    // A dangling odd byte cannot form a code unit; drop it rather than fail —
219    // and say so: the text is then not the whole source.
220    let (pairs, tail) = body.as_chunks::<2>();
221    let mut loss = (!tail.is_empty()).then_some(DecodeLoss::Utf16OddTail);
222    let units = pairs.iter().map(|&pair| unit(pair));
223    let text = char::decode_utf16(units)
224        .map(|unit| {
225            unit.unwrap_or_else(|_| {
226                loss = Some(DecodeLoss::Utf16UnpairedSurrogate);
227                char::REPLACEMENT_CHARACTER
228            })
229        })
230        .collect();
231    Decoded {
232        text,
233        encoding,
234        loss,
235    }
236}
237
238/// A UTF-8 file with a stray corrupt byte still has far more valid multi-byte
239/// sequences than invalid ones; a Windows-1252 file with umlauts has none (its
240/// high bytes almost never form valid UTF-8 sequences).
241fn is_mostly_utf8(bytes: &[u8]) -> bool {
242    let mut multibyte = 0usize;
243    let mut invalid = 0usize;
244    for chunk in bytes.utf8_chunks() {
245        multibyte += chunk.valid().chars().filter(|c| c.len_utf8() > 1).count();
246        if !chunk.invalid().is_empty() {
247            invalid += 1;
248        }
249    }
250    multibyte > invalid
251}
252
253fn decode_cp1252(bytes: &[u8]) -> String {
254    bytes
255        .iter()
256        .map(|&b| match b {
257            0x80..=0x9F => CP1252_HIGH[usize::from(b - 0x80)],
258            _ => char::from(b),
259        })
260        .collect()
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    /// Before, the decoder returned only a `String`: a dropped UTF-16 byte, a
268    /// replaced surrogate and replaced UTF-8 sequences all looked complete.
269    #[test]
270    fn decoder_reports_every_loss_and_keeps_faithful_encodings_complete() {
271        let utf16 = |bom: &[u8], units: &[u16], be: bool, tail: &[u8]| {
272            let mut bytes = bom.to_vec();
273            for unit in units {
274                bytes.extend_from_slice(&if be {
275                    unit.to_be_bytes()
276                } else {
277                    unit.to_le_bytes()
278                });
279            }
280            bytes.extend_from_slice(tail);
281            decode_checked(bytes)
282        };
283        let hi: Vec<u16> = "hi\n".encode_utf16().collect();
284        for (bom, be) in [(UTF16_LE_BOM, false), (UTF16_BE_BOM, true)] {
285            let clean = utf16(bom, &hi, be, &[]);
286            assert_eq!((clean.text.as_str(), clean.loss), ("hi\n", None));
287            assert_eq!(
288                utf16(bom, &hi, be, &[0x41]).loss,
289                Some(DecodeLoss::Utf16OddTail)
290            );
291            let unpaired = utf16(bom, &[0x0068, 0xD800, 0x0069], be, &[]);
292            assert_eq!(unpaired.loss, Some(DecodeLoss::Utf16UnpairedSurrogate));
293            assert!(!unpaired.complete());
294        }
295
296        let mut stray = "fn grüezi() { let ä = \"ö\"; }\n".as_bytes().to_vec();
297        stray.push(0xFF);
298        let lossy = decode_checked(stray);
299        assert_eq!(lossy.encoding, Encoding::Utf8Lossy);
300        assert_eq!(lossy.loss, Some(DecodeLoss::InvalidUtf8));
301
302        // Controls: nothing is lost, text and encoding are as before.
303        let ansi = decode_checked(b"// Gr\xF6\xDFe\r\n".to_vec());
304        assert_eq!((ansi.encoding, ansi.loss), (Encoding::Windows1252, None));
305        assert_eq!(ansi.text, "// Größe\r\n");
306        let mut bom = UTF8_BOM.to_vec();
307        bom.extend_from_slice(b"class A {}\n");
308        let with_bom = decode_checked(bom);
309        assert_eq!(
310            (with_bom.encoding, with_bom.loss),
311            (Encoding::Utf8Bom, None)
312        );
313        assert!(decode_checked(b"plain\n".to_vec()).complete());
314    }
315
316    #[test]
317    fn utf8_passes_through_and_loses_its_bom() {
318        assert_eq!(decode("fn grüezi() {}\n".into()), "fn grüezi() {}\n");
319        let mut bom = UTF8_BOM.to_vec();
320        bom.extend_from_slice(b"class A {}\n");
321        assert_eq!(decode(bom), "class A {}\n");
322    }
323
324    #[test]
325    fn windows_1252_umlauts_and_punctuation_decode() {
326        // "// Größe – “Wert” €" as saved by a Windows ANSI editor.
327        let ansi = b"// Gr\xF6\xDFe \x96 \x93Wert\x94 \x80\r\nint x;\r\n".to_vec();
328        let text = decode(ansi);
329        assert_eq!(text, "// Größe – “Wert” €\r\nint x;\r\n");
330        assert_eq!(text.lines().count(), 2, "line structure must be preserved");
331    }
332
333    #[test]
334    fn every_cp1252_byte_maps_to_one_char() {
335        let all: Vec<u8> = (0x80..=0xFF).collect();
336        assert_eq!(decode_cp1252(&all).chars().count(), all.len());
337    }
338
339    #[test]
340    fn utf16_with_bom_decodes_in_both_byte_orders() {
341        let text = "public class Größe {}\r\n";
342        let mut le = UTF16_LE_BOM.to_vec();
343        let mut be = UTF16_BE_BOM.to_vec();
344        for unit in text.encode_utf16() {
345            le.extend_from_slice(&unit.to_le_bytes());
346            be.extend_from_slice(&unit.to_be_bytes());
347        }
348        assert!(has_utf16_bom(&le) && has_utf16_bom(&be));
349        assert_eq!(decode(le.clone()), text);
350        assert_eq!(decode(be), text);
351        le.push(b'x'); // odd trailing byte
352        assert_eq!(decode(le), text);
353    }
354
355    #[test]
356    fn mostly_utf8_with_a_corrupt_byte_stays_utf8() {
357        let mut bytes = "// äöü éè — fine\n".as_bytes().to_vec();
358        bytes.push(0xFF);
359        bytes.extend_from_slice(b"\nint y;\n");
360        let text = decode(bytes);
361        assert!(text.starts_with("// äöü éè — fine\n"), "{text}");
362        assert!(text.contains('\u{FFFD}'));
363    }
364
365    #[test]
366    fn detect_encoding_names_the_branch_decode_takes() {
367        assert_eq!(detect_encoding(b"plain"), Encoding::Utf8);
368        assert_eq!(detect_encoding(b"\xEF\xBB\xBFx"), Encoding::Utf8Bom);
369        assert_eq!(detect_encoding(b"\xFF\xFEx\x00"), Encoding::Utf16Le);
370        assert_eq!(detect_encoding(b"\xFE\xFF\x00x"), Encoding::Utf16Be);
371        assert_eq!(detect_encoding(b"Gr\xF6\xDFe"), Encoding::Windows1252);
372        let mut lossy = "äöü".as_bytes().to_vec();
373        lossy.push(0xFF);
374        assert_eq!(detect_encoding(&lossy), Encoding::Utf8Lossy);
375        // A UTF-8 BOM followed by UTF-16 BOM bytes is not UTF-16: `decode`
376        // checks the UTF-16 BOM on the raw bytes only.
377        assert_eq!(
378            detect_encoding(b"\xEF\xBB\xBF\xFF\xFE"),
379            Encoding::Windows1252
380        );
381        assert_eq!(decode(b"\xEF\xBB\xBF\xFF\xFE".to_vec()), "\u{FF}\u{FE}");
382    }
383
384    #[test]
385    fn read_text_reads_files_read_to_string_rejects() {
386        let dir = tempfile::tempdir().unwrap();
387        let path = dir.path().join("legacy.cs");
388        std::fs::write(&path, b"// K\xFCndigung\nclass K {}\n").unwrap();
389        assert!(std::fs::read_to_string(&path).is_err());
390        assert_eq!(read_text(&path).unwrap(), "// Kündigung\nclass K {}\n");
391    }
392}