stuart_core/fs/
mod.rs

1//! Provides a virtual filesystem tree which parses the files as it is constructed, and saves them according to the configuration.
2//!
3//! When building a Stuart project, the files are loaded into memory and parsed at the same time, then processed wholly
4//!   in memory. They are saved back to disk after processing. In this way, you can think of the entire build process
5//!   as simply a function that maps `Node -> Node`. This function is called [`Node::process`].
6
7use crate::error::{FsError, ParseError};
8use crate::parse::{parse_html, parse_markdown};
9use crate::plugins::Manager;
10use crate::{Config, Error, TracebackError};
11
12pub use crate::parse::ParsedContents;
13
14use humphrey_json::prelude::*;
15use humphrey_json::Value;
16
17use std::fmt::Debug;
18use std::fs::{create_dir, metadata, read, read_dir, remove_dir_all, write};
19use std::io::ErrorKind;
20use std::path::{Component, Path, PathBuf};
21use std::rc::Rc;
22
23/// Represents a node in the virtual filesystem tree.
24#[derive(Clone)]
25pub enum Node {
26    /// A file in the virtual filesystem tree.
27    File {
28        /// The name of the file.
29        name: String,
30        /// The contents of the file.
31        contents: Vec<u8>,
32        /// The contents of the file after having been parsed.
33        parsed_contents: ParsedContents,
34        /// The metadata of the file after having been processed.
35        metadata: Option<Value>,
36        /// The filesystem source of the file.
37        source: PathBuf,
38    },
39    /// A directory in the virtual filesystem tree.
40    Directory {
41        /// The name of the directory.
42        name: String,
43        /// The children of the directory.
44        children: Vec<Node>,
45        /// The filesystem source of the directory.
46        source: PathBuf,
47    },
48}
49
50impl Node {
51    /// Constructs a new virtual filesystem tree from the given filesystem path.
52    pub fn new(root: impl AsRef<Path>, parse: bool) -> Result<Self, Error> {
53        let root = root.as_ref().to_path_buf().canonicalize().map_err(|_| {
54            Error::Fs(FsError::NotFound(
55                root.as_ref().to_string_lossy().to_string(),
56            ))
57        })?;
58
59        Self::create_from_dir(root, parse, None)
60    }
61
62    /// Constructs a new virtual filesystem tree from the given filesystem path, with the configured plugins.
63    pub fn new_with_plugins(
64        root: impl AsRef<Path>,
65        parse: bool,
66        plugins: &dyn Manager,
67    ) -> Result<Self, Error> {
68        let root = root.as_ref().to_path_buf().canonicalize().map_err(|_| {
69            Error::Fs(FsError::NotFound(
70                root.as_ref().to_string_lossy().to_string(),
71            ))
72        })?;
73
74        Self::create_from_dir(root, parse, Some(plugins))
75    }
76
77    /// Returns `true` if the node is a directory.
78    pub fn is_dir(&self) -> bool {
79        matches!(self, Node::Directory { .. })
80    }
81
82    /// Returns `true` if the node is a file.
83    pub fn is_file(&self) -> bool {
84        matches!(self, Node::File { .. })
85    }
86
87    /// Returns the name of the node.
88    pub fn name(&self) -> &str {
89        match self {
90            Node::File { name, .. } => name,
91            Node::Directory { name, .. } => name,
92        }
93    }
94
95    /// Returns the node's children.
96    pub fn children(&self) -> Option<&[Node]> {
97        match self {
98            Node::Directory { children, .. } => Some(children),
99            Node::File { .. } => None,
100        }
101    }
102
103    /// Returns the node's contents.
104    pub fn contents(&self) -> Option<&[u8]> {
105        match self {
106            Node::File { contents, .. } => Some(contents),
107            Node::Directory { .. } => None,
108        }
109    }
110
111    /// Returns the node's parsed contents.
112    pub fn parsed_contents(&self) -> &ParsedContents {
113        match self {
114            Node::File {
115                parsed_contents, ..
116            } => parsed_contents,
117            Node::Directory { .. } => &ParsedContents::None,
118        }
119    }
120
121    /// Returns the node's parsed contents mutably.
122    /// (This goes against everything Stuart is supposed to be but don't worry about it, it's for markdown preprocessing)
123    pub fn parsed_contents_mut(&mut self) -> &mut ParsedContents {
124        match self {
125            Node::File {
126                parsed_contents, ..
127            } => parsed_contents,
128            Node::Directory { .. } => {
129                panic!("`Node::parsed_contents_mut` should only be used on files")
130            }
131        }
132    }
133
134    /// Returns the filesystem source of the node.
135    pub fn source(&self) -> &Path {
136        match self {
137            Node::File { source, .. } => source,
138            Node::Directory { source, .. } => source,
139        }
140    }
141
142    /// Attempts to get a node at the given path of the filesystem.
143    pub fn get_at_path(&self, path: &Path) -> Option<&Self> {
144        let mut working_path = vec![self];
145
146        for part in path.components() {
147            match part {
148                Component::Normal(name) => {
149                    working_path.push(
150                        working_path
151                            .last()
152                            .and_then(|n| n.children())
153                            .and_then(|children| children.iter().find(|n| n.name() == name))?,
154                    );
155                }
156                Component::CurDir => (),
157                _ => return None,
158            }
159        }
160
161        working_path.last().copied()
162    }
163
164    /// Creates a new node from a directory of the filesystem.
165    pub(crate) fn create_from_dir(
166        dir: impl AsRef<Path>,
167        parse: bool,
168        plugins: Option<&dyn Manager>,
169    ) -> Result<Self, Error> {
170        let dir = dir.as_ref();
171        let content = read_dir(dir)
172            .map_err(|_| Error::Fs(FsError::NotFound(dir.to_string_lossy().to_string())))?;
173
174        let children = content
175            .flatten()
176            .map(|path| {
177                let path = path.path();
178
179                match metadata(&path).map(|m| m.file_type()) {
180                    Ok(t) if t.is_dir() => Self::create_from_dir(&path, parse, plugins),
181                    Ok(t) if t.is_file() => Self::create_from_file(&path, parse, plugins),
182                    _ => Err(Error::Fs(FsError::Read)),
183                }
184            })
185            .collect::<Result<_, _>>()?;
186
187        Ok(Node::Directory {
188            name: dir.file_name().unwrap().to_string_lossy().to_string(),
189            children,
190            source: dir.to_path_buf(),
191        })
192    }
193
194    /// Creates a new node from a file of the filesystem.
195    pub(crate) fn create_from_file(
196        file: impl AsRef<Path>,
197        parse: bool,
198        plugins: Option<&dyn Manager>,
199    ) -> Result<Self, Error> {
200        let file = file.as_ref();
201        let name = file.file_name().unwrap().to_string_lossy().to_string();
202        let contents = read(file).map_err(|_| Error::Fs(FsError::Read))?;
203
204        let parsed_contents = if parse {
205            let extension = file.extension().map(|e| e.to_string_lossy().to_string());
206            let contents_string =
207                std::str::from_utf8(&contents).map_err(|_| Error::Fs(FsError::Read));
208
209            match extension.as_deref() {
210                Some("html") => ParsedContents::Html(
211                    parse_html(contents_string?, file, plugins).map_err(Error::Parse)?,
212                ),
213                Some("md") => ParsedContents::Markdown(
214                    parse_markdown(contents_string?.to_string(), file, plugins)
215                        .map_err(Error::Parse)?,
216                ),
217                Some("json") => ParsedContents::Json(
218                    humphrey_json::from_str(contents_string?).map_err(|_| {
219                        Error::Parse(TracebackError {
220                            path: file.to_path_buf(),
221                            kind: ParseError::InvalidJson,
222                            column: 0,
223                            line: 0,
224                        })
225                    })?,
226                ),
227                Some(extension) => {
228                    let mut result = ParsedContents::None;
229
230                    if let Some(plugins) = plugins {
231                        'outer: for plugin in plugins.plugins() {
232                            for parser in &plugin.parsers {
233                                if parser.extensions().contains(&extension) {
234                                    result = ParsedContents::Custom(Rc::new(
235                                        parser.parse(&contents, file).map_err(Error::Plugin)?,
236                                    ));
237                                    break 'outer;
238                                }
239                            }
240                        }
241                    }
242
243                    result
244                }
245                None => ParsedContents::None,
246            }
247        } else {
248            ParsedContents::Ignored
249        };
250
251        Ok(Node::File {
252            name,
253            contents,
254            parsed_contents,
255            metadata: None,
256            source: file.to_path_buf(),
257        })
258    }
259
260    /// Save the node to the filesystem with the given configuration.
261    pub fn save(&self, path: impl AsRef<Path>, config: &Config) -> Result<(), Error> {
262        let path = path.as_ref().to_path_buf();
263
264        if path.exists() && path.is_dir() {
265            remove_dir_all(&path).map_err(|_| Error::Fs(FsError::Write))?;
266        }
267
268        match self {
269            Self::Directory { children, .. } => {
270                create_dir(&path).map_err(|_| Error::Fs(FsError::Write))?;
271
272                for child in children {
273                    child.save_recur(&path, config)?;
274                }
275            }
276            _ => panic!("`Node::save` should only be used on the root directory"),
277        }
278
279        Ok(())
280    }
281
282    /// Save the node's metadata to the given path.
283    /// The `base` argument should be a JSON object to which the metadata will be added under the key `data`.
284    pub fn save_metadata(&self, mut base: Value, path: impl AsRef<Path>) -> Result<(), Error> {
285        base["data"] = self.save_metadata_recur(true);
286
287        write(path, base.serialize()).map_err(|_| Error::Fs(FsError::Write))?;
288
289        Ok(())
290    }
291
292    /// Merge two virtual filesystem trees into a single virtual filesystem tree.
293    /// This will return an error if two files share the same path.
294    pub fn merge(&mut self, other: Node) -> Result<(), Error> {
295        match (self, other) {
296            (
297                Self::Directory { children, .. },
298                Self::Directory {
299                    children: other_children,
300                    ..
301                },
302            ) => {
303                for other_child in other_children {
304                    if let Some(child) = children
305                        .iter_mut()
306                        .find(|child| child.name() == other_child.name())
307                    {
308                        // This is definitely not the best way of doing this (it should be done through destructuring in a match statement),
309                        //   but I can't seem to get around lifetime problems with the other way.
310                        if matches!(child, Self::Directory { .. })
311                            && matches!(other_child, Self::Directory { .. })
312                        {
313                            child.merge(other_child)?;
314                        } else {
315                            return Err(Error::Fs(FsError::Conflict(
316                                child.source().to_path_buf(),
317                                other_child.source().to_path_buf(),
318                            )));
319                        }
320                    } else {
321                        children.push(other_child);
322                    }
323                }
324
325                Ok(())
326            }
327            _ => panic!("`Node::merge` should only be used on directories"),
328        }
329    }
330
331    /// Recursively saves this node and its descendants to the filesystem.
332    fn save_recur(&self, path: impl AsRef<Path>, config: &Config) -> Result<(), Error> {
333        let path = path.as_ref().to_path_buf();
334
335        match self {
336            Self::Directory { name, children, .. } => {
337                let dir = path.join(name);
338
339                // It is possible that the directory already exists if strip extensions is enabled.
340                match create_dir(&dir) {
341                    Ok(_) => (),
342                    Err(e) if e.kind() == ErrorKind::AlreadyExists => (),
343                    Err(_) => return Err(Error::Fs(FsError::Write)),
344                };
345
346                for child in children {
347                    child.save_recur(&dir, config)?;
348                }
349            }
350            Self::File {
351                name,
352                contents,
353                parsed_contents,
354                ..
355            } => {
356                if name != "root.html"
357                    && name != "md.html"
358                    && (config.save_data_files || !name.ends_with(".json"))
359                {
360                    if config.strip_extensions
361                        && name.ends_with(".html")
362                        && name != "index.html"
363                        && !parsed_contents.is_ignored()
364                    {
365                        let directory_name = name.strip_suffix(".html").unwrap().to_string();
366                        let dir = path.join(directory_name);
367
368                        match create_dir(&dir) {
369                            Ok(_) => (),
370                            Err(e) if e.kind() == ErrorKind::AlreadyExists => (),
371                            Err(_) => return Err(Error::Fs(FsError::Write)),
372                        };
373
374                        write(dir.join("index.html"), contents)
375                            .map_err(|_| Error::Fs(FsError::Write))?;
376                    } else {
377                        write(path.join(name), contents).map_err(|_| Error::Fs(FsError::Write))?;
378                    }
379                }
380            }
381        }
382
383        Ok(())
384    }
385
386    /// Recursively exports this node's and its descendants' metadata to a JSON object.
387    fn save_metadata_recur(&self, is_first: bool) -> Value {
388        match self {
389            Self::Directory { name, children, .. } => {
390                let children = children
391                    .iter()
392                    .map(|c| c.save_metadata_recur(false))
393                    .collect();
394
395                if is_first {
396                    Value::Array(children)
397                } else {
398                    json!({
399                        "type": "directory",
400                        "name": name,
401                        "children": (Value::Array(children))
402                    })
403                }
404            }
405            Self::File {
406                name,
407                metadata: json,
408                ..
409            } => {
410                let mut metadata = json!({ "name": name });
411
412                if let Some(json) = json {
413                    for (key, value) in json.as_object().unwrap() {
414                        metadata[key.as_str()] = value.clone();
415                    }
416                } else {
417                    metadata["type"] = json!("file");
418                }
419
420                metadata
421            }
422        }
423    }
424}
425
426impl Debug for Node {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        match self {
429            Self::File {
430                name,
431                contents,
432                parsed_contents,
433                metadata,
434                source,
435            } => f
436                .debug_struct("File")
437                .field("name", name)
438                .field("contents", &format!("{} bytes", contents.len()))
439                .field("parsed_contents", parsed_contents)
440                .field("metadata", metadata)
441                .field("source", source)
442                .finish(),
443            Self::Directory {
444                name,
445                children,
446                source,
447            } => f
448                .debug_struct("Directory")
449                .field("name", name)
450                .field("children", children)
451                .field("source", source)
452                .finish(),
453        }
454    }
455}