enshwick 1.0.0-alpha.11

The Enshwick Loader's modding API for Enshrouded: the one crate mods are written against
Documentation
use std::collections::BTreeMap;
use std::path::Path;

use crate::Off;

use super::{
    DEFINITIONS_SUFFIX, FILES, MAX_FILE, MAX_ICON_FILE, MAX_ICONS, MAX_SCRIPT_DEPTH, MAX_SCRIPTS,
    SCRIPT_EXTENSION, SCRIPT_MAIN,
};

/// One problem in a pack: where it is and what is wrong.
///
/// # Examples
///
/// ```
/// use enshwick::packs::{Pack, PackFiles};
///
/// let files = PackFiles::new().with("items.toml", "[[item]]\nid = \"lantern\"\n");
/// let errors = Pack::parse("you.lanterns", &files).unwrap_err();
/// assert_eq!(errors.list()[0].line, 1);
/// assert!(errors.to_string().starts_with("items.toml:1:1: "), "{errors}");
/// ```
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{file}:{line}:{column}: {message}")]
pub struct PackError {
    /// The file, as the pack names it, such as `items.toml`.
    pub file: String,
    /// The line, from 1.
    pub line: usize,
    /// The column, from 1.
    pub column: usize,
    /// What is wrong.
    pub message: String,
}

/// Every problem [`Pack::parse`](super::Pack::parse) found, in file order.
///
/// # Examples
///
/// See [`PackError`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{}", self.lines())]
pub struct ParsePackError(pub(super) Vec<PackError>);

impl ParsePackError {
    /// The problems.
    ///
    /// # Examples
    ///
    /// See [`PackError`].
    #[must_use]
    pub fn list(&self) -> &[PackError] {
        &self.0
    }

    fn lines(&self) -> String {
        self.0
            .iter()
            .map(ToString::to_string)
            .collect::<Vec<_>>()
            .join("\n")
    }
}

impl From<ParsePackError> for Off {
    fn from(errors: ParsePackError) -> Self {
        Self::new(format!("data pack: {errors}"))
    }
}

/// Why [`PackFiles::read_dir`] could not read a pack's folder.
///
/// # Examples
///
/// ```
/// use enshwick::packs::ReadPackError;
///
/// let large = ReadPackError::TooLarge { file: "items.toml".into(), limit: 4 << 20 };
/// assert_eq!(large.to_string(), "items.toml: larger than 4194304 bytes");
/// ```
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReadPackError {
    /// A file could not be read.
    #[error("{file}: {source}")]
    Io {
        /// The file, as the pack names it.
        file: String,
        /// Why.
        source: std::io::Error,
    },
    /// A file is larger than [`MAX_FILE`], or an icon larger than
    /// [`MAX_ICON_FILE`].
    #[error("{file}: larger than {limit} bytes")]
    TooLarge {
        /// The file, as the pack names it.
        file: String,
        /// The most bytes it may hold.
        limit: u64,
    },
    /// The pack holds more than [`MAX_ICONS`] icons.
    #[error("more than {MAX_ICONS} icons")]
    TooManyIcons,
    /// The pack holds more than [`MAX_SCRIPTS`] scripts.
    #[error("more than {MAX_SCRIPTS} scripts")]
    TooManyScripts,
}

/// The files of a pack: their names, such as `items.toml` or
/// `icons/lantern.png`, and their bytes.
///
/// # Examples
///
/// ```
/// use enshwick::packs::PackFiles;
///
/// let files = PackFiles::new().with("texts.toml", "[en]\n");
/// assert_eq!(files.get("texts.toml"), Some(&b"[en]\n"[..]));
/// assert_ne!(files.hash(), PackFiles::new().hash());
/// ```
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PackFiles {
    pub(super) files: BTreeMap<String, Vec<u8>>,
}

impl PackFiles {
    /// No files.
    ///
    /// # Examples
    ///
    /// See [`PackFiles`].
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// The same with the file `name` holding `bytes`.
    ///
    /// # Examples
    ///
    /// See [`PackFiles`].
    #[must_use]
    pub fn with(mut self, name: &str, bytes: impl Into<Vec<u8>>) -> Self {
        self.files.insert(name.to_owned(), bytes.into());
        self
    }

    /// The pack files in the folder `dir`: the files of [`FILES`] it holds,
    /// every PNG under `dir/icons`, and when it has a [`SCRIPT_MAIN`] every
    /// script up to [`MAX_SCRIPT_DEPTH`] folders deep but definitions files;
    /// linked folders are not followed. Each file is at most [`MAX_FILE`]
    /// bytes, and each of at most [`MAX_ICONS`] icons at most
    /// [`MAX_ICON_FILE`].
    ///
    /// # Errors
    ///
    /// [`ReadPackError`] naming the file or folder that could not be read,
    /// a file larger than it may be, or for too many scripts or icons.
    ///
    /// # Examples
    ///
    /// ```
    /// use enshwick::packs::PackFiles;
    ///
    /// let files = PackFiles::read_dir(std::path::Path::new("no such folder"))?;
    /// assert!(files.names().next().is_none());
    /// # Ok::<(), enshwick::packs::ReadPackError>(())
    /// ```
    pub fn read_dir(dir: &Path) -> Result<Self, ReadPackError> {
        let mut files = Self::new();
        let read =
            |path: &Path, name: &str, limit: u64, files: &mut Self| -> Result<(), ReadPackError> {
                let io = |source| ReadPackError::Io {
                    file: name.to_owned(),
                    source,
                };
                let meta = std::fs::metadata(path).map_err(io)?;
                if meta.len() > limit {
                    return Err(ReadPackError::TooLarge {
                        file: name.to_owned(),
                        limit,
                    });
                }
                let bytes = std::fs::read(path).map_err(io)?;
                files.files.insert(name.to_owned(), bytes);
                Ok(())
            };
        for name in FILES {
            let path = dir.join(name);
            if path.is_file() {
                read(&path, name, MAX_FILE, &mut files)?;
            }
        }
        if let Ok(entries) = std::fs::read_dir(dir.join("icons")) {
            let mut icons = 0usize;
            for entry in entries.filter_map(Result::ok) {
                let path = entry.path();
                let png = path
                    .extension()
                    .is_some_and(|e| e.eq_ignore_ascii_case("png"));
                if let (true, Some(file)) = (png, path.file_name().and_then(|f| f.to_str())) {
                    icons += 1;
                    if icons > MAX_ICONS {
                        return Err(ReadPackError::TooManyIcons);
                    }
                    read(&path, &format!("icons/{file}"), MAX_ICON_FILE, &mut files)?;
                }
            }
        }
        for (path, name) in scripts_in(dir)? {
            read(&path, &name, MAX_FILE, &mut files)?;
        }
        Ok(files)
    }

    /// Every script, by its name relative to the pack's folder, with its
    /// bytes, sorted by name.
    ///
    /// # Examples
    ///
    /// ```
    /// use enshwick::packs::PackFiles;
    ///
    /// let files = PackFiles::new().with("main.luau", "print('hi')").with("items.toml", "");
    /// assert_eq!(files.scripts().map(|(name, _)| name).collect::<Vec<_>>(), ["main.luau"]);
    /// ```
    pub fn scripts(&self) -> impl Iterator<Item = (&str, &[u8])> {
        self.files
            .iter()
            .filter(|(name, _)| is_script(name))
            .map(|(name, bytes)| (name.as_str(), bytes.as_slice()))
    }

    /// The bytes of the file `name`.
    ///
    /// # Examples
    ///
    /// See [`PackFiles`].
    #[must_use]
    pub fn get(&self, name: &str) -> Option<&[u8]> {
        self.files.get(name).map(Vec::as_slice)
    }

    /// The names of the files, sorted.
    ///
    /// # Examples
    ///
    /// See [`PackFiles::read_dir`].
    pub fn names(&self) -> impl Iterator<Item = &str> {
        self.files.keys().map(String::as_str)
    }

    /// A hash of every file's name and bytes, the same for the same files:
    /// FNV-1a 32, as eight hex digits go into the join check.
    ///
    /// # Examples
    ///
    /// See [`PackFiles`].
    #[must_use]
    pub fn hash(&self) -> u32 {
        let mut hash: u32 = 0x811c_9dc5;
        let mut eat = |bytes: &[u8]| {
            for &b in bytes {
                hash ^= u32::from(b);
                hash = hash.wrapping_mul(0x0100_0193);
            }
        };
        for (name, bytes) in &self.files {
            eat(name.as_bytes());
            eat(&[0]);
            eat(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
            eat(bytes);
        }
        hash
    }

    pub(super) fn text(&self, name: &str) -> Result<Option<&str>, PackError> {
        self.get(name)
            .map(|bytes| {
                core::str::from_utf8(bytes)
                    .map_err(|e| PackError::at(name, "", 0, &format!("not UTF-8: {e}")))
            })
            .transpose()
    }
}

impl PackError {
    /// The error at byte `offset` of `text`, the contents of `file`.
    pub(super) fn at(file: &str, text: &str, offset: usize, message: &str) -> Self {
        let before = text.get(..offset.min(text.len())).unwrap_or("");
        let line = before.matches('\n').count() + 1;
        let column = before
            .rsplit_once('\n')
            .map_or(before, |(_, last)| last)
            .chars()
            .count()
            + 1;
        Self {
            file: file.to_owned(),
            line,
            column,
            message: message.to_owned(),
        }
    }

    /// A TOML error of `file`.
    pub(super) fn of(file: &str, text: &str, error: &toml::de::Error) -> Self {
        let offset = error.span().map_or(0, |span| span.start);
        Self::at(file, text, offset, error.message().trim_end())
    }
}

/// The scripts [`PackFiles::read_dir`] reads from the folder `dir`, as
/// paths, without reading them: what a watcher looks at to see a script
/// added, removed or renamed.
///
/// # Errors
///
/// [`ReadPackError`] naming the folder that could not be read, or for too
/// many scripts.
///
/// # Examples
///
/// ```
/// let scripts = enshwick::packs::script_files(std::path::Path::new("no such folder"))?;
/// assert!(scripts.is_empty());
/// # Ok::<(), enshwick::packs::ReadPackError>(())
/// ```
pub fn script_files(dir: &Path) -> Result<Vec<std::path::PathBuf>, ReadPackError> {
    Ok(scripts_in(dir)?.into_iter().map(|(path, _)| path).collect())
}

/// Every script of the pack folder `dir` with its name, when it has a
/// [`SCRIPT_MAIN`].
fn scripts_in(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>, ReadPackError> {
    let mut scripts = Vec::new();
    if dir.join(SCRIPT_MAIN).is_file() {
        find_scripts(dir, "", 0, &mut scripts)?;
    }
    Ok(scripts)
}

/// Whether the file `name` is a script.
fn is_script(name: &str) -> bool {
    name.rsplit_once('.')
        .is_some_and(|(_, ext)| ext == SCRIPT_EXTENSION)
        && !name.ends_with(DEFINITIONS_SUFFIX)
}

/// Adds every script under `dir`, named from `prefix`, `depth` folders below
/// the pack's folder, to `found`; hidden entries, linked folders and folders
/// past [`MAX_SCRIPT_DEPTH`] are left out.
fn find_scripts(
    dir: &Path,
    prefix: &str,
    depth: usize,
    found: &mut Vec<(std::path::PathBuf, String)>,
) -> Result<(), ReadPackError> {
    let io = |source| ReadPackError::Io {
        file: if prefix.is_empty() {
            ".".to_owned()
        } else {
            prefix.to_owned()
        },
        source,
    };
    for entry in std::fs::read_dir(dir).map_err(io)? {
        let entry = entry.map_err(io)?;
        let Some(file) = entry.file_name().to_str().map(str::to_owned) else {
            continue;
        };
        if file.starts_with('.') {
            continue;
        }
        let name = format!("{prefix}{file}");
        let path = entry.path();
        let kind = entry.file_type().map_err(io)?;
        if kind.is_dir() {
            if depth < MAX_SCRIPT_DEPTH {
                find_scripts(&path, &format!("{name}/"), depth + 1, found)?;
            }
        } else if is_script(&name) && path.is_file() {
            found.push((path, name));
            if found.len() > MAX_SCRIPTS {
                return Err(ReadPackError::TooManyScripts);
            }
        }
    }
    Ok(())
}