1use std::io::Read;
4use std::path::{Path, PathBuf};
5
6use memmap2::Mmap;
7
8use crate::{Error, Result};
9
10pub const PROBE_SIZE: usize = 8 * 1024;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum Origin {
16 File(PathBuf),
17 Stdin,
18 Memory(String),
20}
21
22enum Data {
23 Mapped(Mmap),
26 Owned(Vec<u8>),
27}
28
29pub struct Source {
30 origin: Origin,
31 data: Data,
32 text_cache: std::sync::OnceLock<String>,
39 encoding: &'static encoding_rs::Encoding,
41}
42
43impl Source {
44 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 let data = if len == 0 {
61 Data::Owned(Vec::new())
62 } else {
63 let map = unsafe { Mmap::map(&file) }.map_err(|source| Error::Io {
67 path: path.to_path_buf(),
68 source,
69 })?;
70
71 #[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 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 pub fn path(&self) -> Option<&Path> {
126 match &self.origin {
127 Origin::File(p) => Some(p.as_path()),
128 _ => None,
129 }
130 }
131
132 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 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 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 pub fn encoding_name(&self) -> &'static str {
191 self.encoding.name()
192 }
193
194 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 pub fn as_str(&self) -> (&str, bool) {
211 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 let (text, errors) = self.encoding.decode_without_bom_handling(self.bytes());
226 had_errors = errors;
227 text.into_owned()
228 });
229 if !had_errors {
233 had_errors = cached.contains('\u{FFFD}');
234 }
235 (cached.as_str(), had_errors)
236 }
237
238 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 let (text, errors) = self.encoding.decode_without_bom_handling(bytes);
253 (std::borrow::Cow::Owned(text.into_owned()), errors)
254 }
255
256 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 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 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 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}