texlang_common/
lib.rs

1//! Common abstractions used in Texlang
2
3use std::collections::HashMap;
4use std::{cell::RefCell, rc::Rc};
5use texlang::vm::TexlangState;
6
7/// Implementations of this trait can provide access to the file system.
8///
9/// This trait is intended to be implemented by the state and used as a trait
10/// bound in Texlang primitives like `\input` that require a file system.
11///
12/// The filesystem is returned in a dynamic pointer to avoid complicating
13/// the trait with a generic parameter.
14/// File system operations are rate in TeX documents so the overhead
15/// of a vtable lookup is negligible.
16pub trait HasFileSystem {
17    fn file_system(&self) -> Rc<RefCell<dyn FileSystem>> {
18        Rc::new(RefCell::new(RealFileSystem {}))
19    }
20}
21
22/// File system operations that TeX may need to perform.
23///
24/// These operations are extracted to a trait so that they be mocked out in unit testing
25///     and in execution contexts like WASM.
26pub trait FileSystem {
27    /// Read the entire contents of a file into a string.
28    ///
29    /// This is implemented by [std::fs::read_to_string].
30    fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String>;
31
32    /// Read the entire contents of a file into a bytes buffer.
33    ///
34    /// This is implemented by [std::fs::read].
35    fn read_to_bytes(&self, path: &std::path::Path) -> std::io::Result<Vec<u8>>;
36
37    /// Write a slice of bytes to a file.
38    ///
39    /// This is implemented by [std::fs::write].
40    fn write_bytes(&self, path: &std::path::Path, contents: &[u8]) -> std::io::Result<()>;
41}
42
43pub fn read_file_to_string<S: HasFileSystem + TexlangState>(
44    vm: &texlang::vm::VM<S>,
45    file_location: texlang::parse::FileLocation,
46    default_extension: &str,
47) -> Result<(std::path::PathBuf, String), FileReadError> {
48    let file_path = file_location.determine_full_path(
49        vm.working_directory
50            .as_ref()
51            .map(std::path::PathBuf::as_ref),
52        default_extension,
53    );
54    match vm
55        .state
56        .file_system()
57        .borrow_mut()
58        .read_to_string(&file_path)
59    {
60        Ok(source_code) => Ok((file_path, source_code)),
61        Err(err) => Err(FileReadError {
62            title: format!("could not read from `{}`", file_path.display()),
63            underlying_error: err,
64        }),
65    }
66}
67
68pub fn read_file_to_bytes<S: HasFileSystem + TexlangState>(
69    vm: &texlang::vm::VM<S>,
70    file_location: texlang::parse::FileLocation,
71    default_extension: &str,
72) -> Result<(std::path::PathBuf, Vec<u8>), FileReadError> {
73    let file_path = file_location.determine_full_path(
74        vm.working_directory
75            .as_ref()
76            .map(std::path::PathBuf::as_ref),
77        default_extension,
78    );
79    match vm
80        .state
81        .file_system()
82        .borrow_mut()
83        .read_to_bytes(&file_path)
84    {
85        Ok(source_code) => Ok((file_path, source_code)),
86        Err(err) => Err(FileReadError {
87            title: format!("could not read from `{}`", file_path.display()),
88            underlying_error: err,
89        }),
90    }
91}
92
93/// Error when reading a file.
94#[derive(Debug)]
95pub struct FileReadError {
96    pub title: String,
97    pub underlying_error: std::io::Error,
98}
99
100impl texlang::error::TexError for FileReadError {
101    fn kind(&self) -> texlang::error::Kind {
102        texlang::error::Kind::FailedPrecondition
103    }
104
105    fn title(&self) -> String {
106        self.title.clone()
107    }
108
109    fn notes(&self) -> Vec<texlang::error::display::Note> {
110        vec![format!("underlying filesystem error: {}", self.underlying_error).into()]
111    }
112}
113
114/// Implementation of the file system trait the uses the real file system.
115pub struct RealFileSystem;
116
117impl FileSystem for RealFileSystem {
118    fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String> {
119        std::fs::read_to_string(path)
120    }
121    fn read_to_bytes(&self, path: &std::path::Path) -> std::io::Result<Vec<u8>> {
122        std::fs::read(path)
123    }
124    fn write_bytes(&self, path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
125        std::fs::write(path, contents)
126    }
127}
128
129/// In-memory filesystem for use in unit tests.
130///
131/// This type mocks out the file system operations in the VM.
132/// It provides an in-memory system to which "files" can be added before the test runs.
133/// It is designed to help test primitives that interact with the filesystem.
134///
135/// Given a VM, the file system can be set as follows:
136/// ```
137/// # use texlang::vm;
138/// # use std::rc::Rc;
139/// # use std::cell::RefCell;
140/// # use texlang_common::*;
141/// # use std::collections::HashMap;
142/// #[derive(Default)]
143/// struct State {
144///     file_system: Rc<RefCell<InMemoryFileSystem>>,
145/// }
146/// let mut vm = vm::VM::<State>::new_with_built_in_commands(
147///     HashMap::new(),  // empty set of built-in commands
148/// );
149/// let mut mock_file_system = InMemoryFileSystem::new(&vm.working_directory.as_ref().unwrap());
150/// mock_file_system.add_string_file("file/path.tex", "file content");
151/// vm.state.file_system = Rc::new(RefCell::new(mock_file_system));
152/// ```
153#[derive(Default)]
154pub struct InMemoryFileSystem {
155    working_directory: std::path::PathBuf,
156    string_files: HashMap<std::path::PathBuf, String>,
157    bytes_files: HashMap<std::path::PathBuf, Vec<u8>>,
158}
159
160impl InMemoryFileSystem {
161    /// Create a new in-memory file system.
162    ///
163    /// Typically the working directory is taken from the VM.
164    pub fn new(working_directory: &std::path::Path) -> Self {
165        Self {
166            working_directory: working_directory.into(),
167            string_files: Default::default(),
168            bytes_files: Default::default(),
169        }
170    }
171    /// Add a string file to the in-memory file system.
172    ///
173    /// The provided path is relative to the working directory
174    pub fn add_string_file(&mut self, relative_path: &str, content: &str) {
175        let mut path = self.working_directory.clone();
176        path.push(relative_path);
177        self.string_files.insert(path, content.to_string());
178    }
179    /// Add a bytes file to the in-memory file system.
180    ///
181    /// The provided path is relative to the working directory
182    pub fn add_bytes_file(&mut self, relative_path: &str, content: &[u8]) {
183        let mut path = self.working_directory.clone();
184        path.push(relative_path);
185        self.bytes_files.insert(path, content.into());
186    }
187}
188
189impl FileSystem for InMemoryFileSystem {
190    fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String> {
191        match self.string_files.get(path) {
192            None => Err(std::io::Error::new(
193                std::io::ErrorKind::NotFound,
194                "not found",
195            )),
196            Some(content) => Ok(content.clone()),
197        }
198    }
199    fn read_to_bytes(&self, path: &std::path::Path) -> std::io::Result<Vec<u8>> {
200        match self.bytes_files.get(path) {
201            None => Err(std::io::Error::new(
202                std::io::ErrorKind::NotFound,
203                "not found",
204            )),
205            Some(content) => Ok(content.clone()),
206        }
207    }
208    fn write_bytes(&self, _: &std::path::Path, _: &[u8]) -> std::io::Result<()> {
209        unimplemented!()
210    }
211}
212
213/// Implementations of this trait can provide access to an output terminal and a log file.
214pub trait HasLogging {
215    /// Return the output terminal.
216    ///
217    /// The default implementation returns standard out.
218    fn terminal_out(&self) -> Rc<RefCell<dyn std::io::Write>> {
219        Rc::new(RefCell::new(std::io::stdout()))
220    }
221
222    /// Return the log file.
223    ///
224    /// The default implementation returns a sink that writes nothing.
225    fn log_file(&self) -> Rc<RefCell<dyn std::io::Write>> {
226        Rc::new(RefCell::new(std::io::sink()))
227    }
228}
229
230/// Implementations of this trait can provide access to an input terminal.
231///
232/// This trait is intended to be implemented by the state and used as a trait
233/// bound in Texlang primitives like `\read` that require an input terminal.
234pub trait HasTerminalIn {
235    fn terminal_in(&self) -> Rc<RefCell<dyn TerminalIn>> {
236        Rc::new(RefCell::new(std::io::stdin()))
237    }
238}
239
240/// Input operations from the terminal.
241pub trait TerminalIn {
242    /// Read a line from the terminal and append it to the provided buffer.
243    fn read_line(&mut self, prompt: Option<&str>, buffer: &mut String) -> std::io::Result<()>;
244}
245
246impl TerminalIn for std::io::Stdin {
247    fn read_line(&mut self, prompt: Option<&str>, buffer: &mut String) -> std::io::Result<()> {
248        if let Some(prompt) = prompt {
249            eprint!("\n{prompt}")
250        }
251        std::io::Stdin::read_line(self, buffer)?;
252        Ok(())
253    }
254}
255
256/// A mock version of [`TerminalIn`].
257///
258/// This type wraps a vector of strings.
259/// The first call to [`TerminalIn::read_line`] returns the first string;
260///   the second call returns the second string;
261///   and so on.
262/// When the vector is exhausted, `read_line` returns an IO error of
263///   kind [std::io::ErrorKind::UnexpectedEof].
264#[derive(Default)]
265pub struct MockTerminalIn(usize, Vec<String>);
266
267impl MockTerminalIn {
268    /// Add a new line to be returned from the mock terminal.
269    pub fn add_line<S: Into<String>>(&mut self, line: S) {
270        self.1.push(line.into());
271    }
272}
273
274impl TerminalIn for MockTerminalIn {
275    fn read_line(&mut self, _: Option<&str>, buffer: &mut String) -> std::io::Result<()> {
276        match self.1.get(self.0) {
277            None => Err(std::io::Error::new(
278                std::io::ErrorKind::UnexpectedEof,
279                "mock terminal input exhausted",
280            )),
281            Some(line) => {
282                buffer.push_str(line);
283                self.0 += 1;
284                Ok(())
285            }
286        }
287    }
288}