recursive-file-loader 1.0.4

Library to recursively load files via references in the files
Documentation
use crate::{canonical_path::CanonicalPath, includes::Include, Error, dependency_path::DependencyPath};
use std::{cell::RefCell, fs, path::Path};

#[derive(Default)]
pub struct Loader {
    resolution_stack: RefCell<Vec<CanonicalPath>>,
}

impl Loader {
    pub(crate) fn new() -> Self {
        Self::default()
    }

    pub(crate) fn load_file_recursively<P: AsRef<Path>>(&self, path: P) -> Result<String, Error> {
        self.get_text_for_path(path)
    }

    fn get_text_for_path<P: AsRef<Path>>(&self, path: P) -> Result<String, Error> {
        let path = CanonicalPath::new(path)?;
        if self.resolution_stack.borrow().contains(&path) {
            let stack = self.resolution_stack.borrow();
            let last = stack.last().unwrap();
            return Err(Error::CyclicDependency(last.source().to_owned(), path.source().to_owned()));
        } else {
            self.resolution_stack.borrow_mut().push(path.clone());
        }

        let mut content = fs::read_to_string(&path)?;
        let includes = self.find_includes(&path, &content)?;
        for include in includes {
            include.replace(&mut content, || self.get_text_for_path(include.path()))?;
        }

        self.resolution_stack.borrow_mut().pop();

        Ok(content)
    }

    fn find_includes<P: AsRef<Path>>(
        &self,
        source_path: P,
        text: &str,
    ) -> Result<Vec<Include>, Error> {
        use lazy_regex::{regex::Match, Captures};

        let env_regex = lazy_regex::regex!(
            r##"(?m)(?P<indentation>^\s*)?(?P<backslashes>\\*)(?P<expr>\$\{include(?P<indent>_indent)?\("(?P<path>[^"]*)"\)})"##
        );

        let reversed_captures: Result<Vec<Include>, Error> = env_regex
            .captures_iter(text)
            .collect::<Vec<Captures>>()
            .into_iter()
            .rev()
            .map(|capture| {
                let backslashes = capture.name("backslashes").unwrap().range();
                let expression: Match = capture.name("expr").unwrap();
                let preserve_indentation: Option<Match> = capture.name("indent");
                let indentation = capture
                    .get(1)
                    .map(|it| String::from(it.as_str()))
                    .unwrap_or_default();
                let path = capture.name("path").unwrap().as_str();
                let path = source_path.get_dependency_path(path);

                let indentation = preserve_indentation.map(|_| indentation);

                Ok(Include::new(
                    expression.range(),
                    path,
                    backslashes,
                    indentation,
                ))
            })
            .collect();

        reversed_captures
    }
}

#[cfg(test)]
mod test_loader {
    use crate::{Error, loader::Loader};
    use rstest::rstest;
    use temp_dir::TempDir;

    #[rstest]
    fn should_load_single_files() -> Result<(), Error> {
        let dir = TempDir::new()?;

        std::fs::write(
            dir.child("start.txt"),
            "hello, world!".as_bytes(),
        )?;

        let result = Loader::new().load_file_recursively(dir.child("start.txt"))?;
        assert_eq!(result, "hello, world!");

        Ok(())
    }

    #[rstest]
    fn should_load_recursively() -> Result<(), Error> {
        let dir = TempDir::new()?;

        std::fs::write(
            dir.child("start.txt"),
            r#"hello, ${include("world.txt")}!"#.as_bytes(),
        )?;
        std::fs::write(
            dir.child("world.txt"),
            "world".as_bytes(),
        )?;

        let result = Loader::new().load_file_recursively(dir.child("start.txt"))?;
        assert_eq!(result, "hello, world!");

        Ok(())
    }

    #[rstest]
    fn should_preserve_indentation() -> Result<(), Error> {
        let dir = TempDir::new()?;

        std::fs::write(
            dir.child("start.txt"),
            "start\n  ${include_indent(\"1.txt\")}".as_bytes(),
        )?;
        std::fs::write(
            dir.child("1.txt"),
            "1\n\t${include(\"2.txt\")}".as_bytes(),
        )?;
        std::fs::write(
            dir.child("2.txt"),
            "2\n2".as_bytes(),
        )?;

        let result = Loader::new().load_file_recursively(dir.child("start.txt"))?;
        assert_eq!(result, "start\n  1\n  \t2\n  2");

        Ok(())
    }

    #[rstest]
    fn should_respect_escapes() -> Result<(), Error> {
        let dir = TempDir::new()?;

        std::fs::write(
            dir.child("start.txt"),
            "hello, \\${include(\"world.txt\")}!".as_bytes(),
        )?;

        let result = Loader::new().load_file_recursively(dir.child("start.txt"))?;
        assert_eq!(result, "hello, ${include(\"world.txt\")}!");

        Ok(())
    }

    #[rstest]
    fn should_report_file_not_found() -> Result<(), Error> {
        let dir = TempDir::new()?;
        let file = dir.child("non-existent.txt");

        let result = Loader::new().load_file_recursively(&file);
        if let Err(e) = result {
            let msg = e.to_string();
            assert!(msg.contains(&format!("file not found: '{}'", file.to_string_lossy())));
        } else {
            panic!("expected an err");
        }

        Ok(())
    }

    #[rstest]
    fn should_report_cyclic_dependencies() -> Result<(), Error> {
        let dir = TempDir::new()?;
        let mid = dir.child("mid");
        let end = dir.child("end");
        std::fs::create_dir(&mid)?;
        std::fs::create_dir(&end)?;
        let start = dir.child("start.txt");
        let mid = mid.join("mid.txt");
        let end = end.join("end.txt");

        std::fs::write(
            &start,
            "start\n${include(\"mid/mid.txt\")}".as_bytes(),
        )?;

        std::fs::write(
            mid,
            "${include(\"../end/end.txt\")}".as_bytes(),
        )?;

        std::fs::write(
            end,
            "${include(\"../start.txt\")}".as_bytes(),
        )?;

        let result = Loader::new().load_file_recursively(&start);
        if let Err(e) = result {
            let msg = e.to_string();
            assert!(msg.contains("cyclic dependency detected between"));
            assert!(msg.contains("/end/end.txt' and '"));
            assert!(msg.contains("../start.txt'"));
        } else {
            panic!("expected an err");
        }

        Ok(())
    }
}