Skip to main content

termdoc_core/
source.rs

1//! Input sources: memory-mapped files and stdin.
2
3use std::io::Read;
4use std::path::{Path, PathBuf};
5
6use memmap2::Mmap;
7
8use crate::{Error, Result};
9
10/// How many prefix bytes are available for detection without consuming the input.
11/// 8 KiB comfortably covers any structural sniff (docs/DESIGN.md §4).
12pub const PROBE_SIZE: usize = 8 * 1024;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum Origin {
16    File(PathBuf),
17    Stdin,
18    /// Synthetic input: tests and embedded uses.
19    Memory(String),
20}
21
22enum Data {
23    /// Mapped: the OS pages in only what gets rendered, and `Cow::Borrowed` borrows
24    /// straight from here without copying.
25    Mapped(Mmap),
26    Owned(Vec<u8>),
27}
28
29pub struct Source {
30    origin: Origin,
31    data: Data,
32    /// Transcoded text, populated only when the source was not valid UTF-8.
33    ///
34    /// It lives here rather than in the reader so that `as_str` can return a `&str` with
35    /// the source's lifetime. A reader that needs the whole document —Markdown, for
36    /// instance— cannot borrow from a local `String` of its own without becoming
37    /// self-referential; borrowing from the source, which outlives it, works.
38    text_cache: std::sync::OnceLock<String>,
39    /// The encoding to decode with. UTF-8 until `set_encoding` says otherwise.
40    encoding: &'static encoding_rs::Encoding,
41}
42
43impl Source {
44    /// Opens a file by memory-mapping it.
45    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
46        let path = path.as_ref();
47        let file = std::fs::File::open(path).map_err(|source| Error::Io {
48            path: path.to_path_buf(),
49            source,
50        })?;
51        let len = file
52            .metadata()
53            .map_err(|source| Error::Io {
54                path: path.to_path_buf(),
55                source,
56            })?
57            .len();
58
59        // `Mmap::map` fails on zero length, and an empty file is legitimate input.
60        let data = if len == 0 {
61            Data::Owned(Vec::new())
62        } else {
63            // SAFETY: if another process truncates the file while we read it, access can
64            // fail with SIGBUS. This is the same contract `bat`, `rg` and `less` accept,
65            // and the price of not copying gigabytes.
66            let map = unsafe { Mmap::map(&file) }.map_err(|source| Error::Io {
67                path: path.to_path_buf(),
68                source,
69            })?;
70
71            // Tell the kernel the access will be sequential. Almost everything termdoc
72            // does is a front-to-back traversal, and with this hint the kernel reads
73            // ahead and drops behind instead of keeping the whole file resident.
74            //
75            // The hint is an optimization, not a requirement: on systems that ignore it,
76            // nothing changes, which is exactly what should happen.
77            #[cfg(unix)]
78            let _ = map.advise(memmap2::Advice::Sequential);
79
80            Data::Mapped(map)
81        };
82
83        Ok(Source {
84            origin: Origin::File(path.to_path_buf()),
85            data,
86            text_cache: std::sync::OnceLock::new(),
87            encoding: encoding_rs::UTF_8,
88        })
89    }
90
91    /// Reads stdin to completion.
92    ///
93    /// KNOWN M0 LIMITATION: stdin is fully buffered. The M0 formats (plain text and
94    /// Markdown) gain nothing from incremental streaming —Markdown needs the whole input
95    /// anyway— and huge files have the file path, which *is* lazy. The incremental stdin
96    /// reader arrives with the log reader in M1, where `kubectl logs -f | termdoc` makes
97    /// it essential.
98    pub fn from_stdin() -> Result<Self> {
99        let mut buf = Vec::new();
100        std::io::stdin().lock().read_to_end(&mut buf)?;
101        Ok(Source {
102            origin: Origin::Stdin,
103            data: Data::Owned(buf),
104            text_cache: std::sync::OnceLock::new(),
105            encoding: encoding_rs::UTF_8,
106        })
107    }
108
109    pub fn from_bytes(name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
110        Source {
111            origin: Origin::Memory(name.into()),
112            data: Data::Owned(bytes.into()),
113            text_cache: std::sync::OnceLock::new(),
114            encoding: encoding_rs::UTF_8,
115        }
116    }
117
118    pub fn origin(&self) -> &Origin {
119        &self.origin
120    }
121
122    /// The file path, when the source is a file. Extension-based detection needs it;
123    /// stdin has none, which is why content sniffing is the primary path rather than an
124    /// optional extra.
125    pub fn path(&self) -> Option<&Path> {
126        match &self.origin {
127            Origin::File(p) => Some(p.as_path()),
128            _ => None,
129        }
130    }
131
132    /// A human-readable name for headers and error messages.
133    pub fn display_name(&self) -> &str {
134        match &self.origin {
135            Origin::File(p) => p.to_str().unwrap_or("<non-UTF-8 path>"),
136            Origin::Stdin => "<stdin>",
137            Origin::Memory(n) => n.as_str(),
138        }
139    }
140
141    pub fn bytes(&self) -> &[u8] {
142        match &self.data {
143            Data::Mapped(m) => m,
144            Data::Owned(v) => v,
145        }
146    }
147
148    pub fn len(&self) -> usize {
149        self.bytes().len()
150    }
151
152    pub fn is_empty(&self) -> bool {
153        self.bytes().is_empty()
154    }
155
156    /// The detection prefix, without consuming anything.
157    pub fn peek(&self, n: usize) -> &[u8] {
158        let b = self.bytes();
159        &b[..n.min(b.len())]
160    }
161
162    pub fn probe(&self) -> &[u8] {
163        self.peek(PROBE_SIZE)
164    }
165
166    /// Sets the encoding the source will be decoded with.
167    ///
168    /// It has to be called **before** any read, and only once: the decoded text is cached so
169    /// readers can borrow a `&str` with the source's lifetime, and re-decoding would
170    /// invalidate borrows that already exist. Taking `&mut self` is what makes that a
171    /// compile-time guarantee rather than a comment.
172    ///
173    /// The label is anything `encoding_rs` accepts (`latin1`, `windows-1252`, `shift_jis`,
174    /// …). An unknown label is a usage error, not a silent fallback: guessing after the user
175    /// asked for something specific is worse than saying no.
176    pub fn set_encoding(&mut self, label: &str) -> Result<()> {
177        match encoding_rs::Encoding::for_label(label.as_bytes()) {
178            Some(enc) => {
179                self.encoding = enc;
180                Ok(())
181            }
182            None => Err(Error::Encoding(format!(
183                "unknown encoding '{label}'; use a label such as utf-8, latin1, \
184                 windows-1252 or shift_jis"
185            ))),
186        }
187    }
188
189    /// The encoding in force. UTF-8 unless `set_encoding` said otherwise.
190    pub fn encoding_name(&self) -> &'static str {
191        self.encoding.name()
192    }
193
194    /// The source as text, as a `Cow`.
195    pub fn text(&self) -> (std::borrow::Cow<'_, str>, bool) {
196        let (s, lossy) = self.as_str();
197        (std::borrow::Cow::Borrowed(s), lossy)
198    }
199
200    /// The source as a `&str` with the source's own lifetime.
201    ///
202    /// Returns `(text, had_replacements)`. Readers that cannot work line by line need this —
203    /// Markdown needs the complete document — because a `&'a str` borrowed from the source
204    /// can travel inside the events, while a `String` local to the reader cannot.
205    ///
206    /// UTF-8 input copies nothing. Any other encoding is transcoded once and cached here.
207    /// **This walks the entire source**, so a reader that *can* go line by line must use
208    /// `decode_line` instead: that is the difference between `termdoc huge.log | head -5`
209    /// reading a few pages and reading the whole file.
210    pub fn as_str(&self) -> (&str, bool) {
211        // The fast path: UTF-8 that is already valid borrows straight from the mapping.
212        if self.encoding == encoding_rs::UTF_8
213            && let Ok(s) = std::str::from_utf8(self.bytes())
214        {
215            return (s, false);
216        }
217
218        let mut had_errors = false;
219        let cached = self.text_cache.get_or_init(|| {
220            // `decode` (with BOM handling) would *override* the configured encoding when the
221            // input starts with a BOM: the bytes `FF FE` are a UTF-16LE BOM, so a UTF-8
222            // source beginning with them would be reinterpreted entirely. Detecting a BOM is
223            // the detection layer's job (docs/DESIGN.md §4); here the caller's choice is
224            // honored exactly.
225            let (text, errors) = self.encoding.decode_without_bom_handling(self.bytes());
226            had_errors = errors;
227            text.into_owned()
228        });
229        // `get_or_init` only runs the closure the first time, so on later calls the flag has
230        // to be recomputed. Scanning for the replacement character is cheap next to the
231        // decode itself and keeps the answer honest.
232        if !had_errors {
233            had_errors = cached.contains('\u{FFFD}');
234        }
235        (cached.as_str(), had_errors)
236    }
237
238    /// Decodes a single slice of the source, honoring the configured encoding.
239    ///
240    /// This is the streaming readers' path: it keeps the laziness of going line by line —
241    /// nothing forces a walk of the whole file — while still handling non-UTF-8 input
242    /// correctly. Valid UTF-8 is borrowed; anything else costs one allocation for that line
243    /// alone.
244    pub fn decode_line<'a>(&self, bytes: &'a [u8]) -> (std::borrow::Cow<'a, str>, bool) {
245        if self.encoding == encoding_rs::UTF_8
246            && let Ok(s) = std::str::from_utf8(bytes)
247        {
248            return (std::borrow::Cow::Borrowed(s), false);
249        }
250        // Without BOM handling, for the same reason as in `as_str`, and because a per-line
251        // BOM check would be meaningless anyway.
252        let (text, errors) = self.encoding.decode_without_bom_handling(bytes);
253        (std::borrow::Cow::Owned(text.into_owned()), errors)
254    }
255
256    /// Binary heuristic: a NUL byte in the prefix. The same rule `grep` and `git` use,
257    /// and enough to avoid dumping an executable into the terminal.
258    pub fn looks_binary(&self) -> bool {
259        self.probe().contains(&0)
260    }
261}
262
263impl std::fmt::Debug for Source {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct("Source")
266            .field("origin", &self.origin)
267            .field("len", &self.len())
268            .finish()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn from_bytes_borrows_when_the_input_is_utf8() {
278        let s = Source::from_bytes("t", "hello");
279        let (text, lossy) = s.text();
280        assert_eq!(text, "hello");
281        assert!(!lossy);
282        assert!(matches!(text, std::borrow::Cow::Borrowed(_)));
283    }
284
285    #[test]
286    fn invalid_utf8_degrades_instead_of_failing() {
287        let s = Source::from_bytes("t", vec![0xff, 0xfe, b'a']);
288        let (text, lossy) = s.text();
289        assert!(lossy, "the loss must be reported");
290        assert!(text.contains('a'), "and what is readable must still show");
291    }
292
293    #[test]
294    fn peek_does_not_overrun_short_input() {
295        let s = Source::from_bytes("t", "ab");
296        assert_eq!(s.peek(100), b"ab");
297        assert_eq!(s.probe(), b"ab");
298    }
299
300    #[test]
301    fn an_empty_file_is_valid_input() {
302        let dir = std::env::temp_dir().join("termdoc-test-empty");
303        std::fs::create_dir_all(&dir).unwrap();
304        let path = dir.join("empty.txt");
305        std::fs::write(&path, b"").unwrap();
306
307        let s = Source::open(&path).expect("an empty file must not be an error");
308        assert!(s.is_empty());
309        assert_eq!(s.text().0, "");
310
311        std::fs::remove_file(&path).ok();
312    }
313
314    #[test]
315    fn latin1_is_decoded_rather_than_mangled() {
316        // 0xE9 is "é" in latin-1 and invalid UTF-8. Without an encoding it degrades to a
317        // replacement character; with one it comes back correctly.
318        let mut s = Source::from_bytes("t", vec![b'a', 0xE9, b'b']);
319        let (lossy, had_errors) = s.as_str();
320        assert!(had_errors, "as UTF-8 it must report the loss");
321        assert!(lossy.contains('\u{FFFD}'));
322
323        let mut s2 = Source::from_bytes("t", vec![b'a', 0xE9, b'b']);
324        s2.set_encoding("latin1").expect("latin1 is a valid label");
325        let (text, had_errors) = s2.as_str();
326        assert_eq!(text, "aéb");
327        assert!(!had_errors, "latin-1 has no invalid bytes");
328        let _ = &mut s;
329    }
330
331    #[test]
332    fn an_unknown_encoding_is_an_error_not_a_silent_fallback() {
333        let mut s = Source::from_bytes("t", "x");
334        let err = s.set_encoding("not-an-encoding").unwrap_err();
335        assert_eq!(err.exit_code(), crate::exit::UNREADABLE);
336        assert!(err.to_string().contains("latin1"), "{err}");
337    }
338
339    #[test]
340    fn decode_line_stays_borrowed_for_utf8() {
341        // The streaming readers' invariant: going line by line must not allocate on the
342        // common path.
343        let s = Source::from_bytes("t", "hello");
344        let (text, _) = s.decode_line(b"hello");
345        assert!(matches!(text, std::borrow::Cow::Borrowed(_)));
346    }
347
348    #[test]
349    fn decode_line_honors_the_configured_encoding() {
350        let mut s = Source::from_bytes("t", "");
351        s.set_encoding("windows-1252").unwrap();
352        let (text, _) = s.decode_line(&[b'a', 0xE9]);
353        assert_eq!(text, "aé");
354    }
355
356    #[test]
357    fn as_str_reports_replacements_on_repeated_calls() {
358        // Regression: `get_or_init` only runs its closure once, so the flag has to be
359        // recomputed or the second caller would be told the decode was clean.
360        let s = Source::from_bytes("t", vec![0xff, b'a']);
361        assert!(s.as_str().1, "first call");
362        assert!(s.as_str().1, "second call must report it too");
363    }
364
365    #[test]
366    fn detects_binary_by_nul_byte() {
367        assert!(Source::from_bytes("t", vec![b'a', 0, b'b']).looks_binary());
368        assert!(!Source::from_bytes("t", "normal text").looks_binary());
369    }
370}