asset.rs raw

//! Asset-reference literals: `(asset "icons/logo.png")`, naming a file to be
//! resolved against the asset search path.
//!
//! The form is read from s-expressions, which both surface languages have in
//! hand at the point they meet a value — so this is the only parser, and the
//! spelling cannot drift between `.gdc` and theme files.
//!
//! Nothing here knows what *kind* of asset a path names. The form carries a
//! path and nothing else; the type of the slot it lands in decides what the
//! bytes mean, and the consumer decides how to get them — the typed build
//! embeds them with `include_bytes!`, the dev runtime reads them from disk.
//! That is what keeps this crate filesystem-free, and what keeps the form
//! open to assets that are not images.

use crate::diagnostics::Diagnostic;
use crate::sexpr::{Sexpr, Span};

/// The head symbol of an asset form. It never collides with the `image`
/// widget that draws one: this form is only ever a *value*, and a widget is
/// only ever in widget position.
pub const ASSET_FORM: &str = "asset";

/// Parse an `(asset "path")` list form. `items` is the whole list including
/// the head symbol; returns `None` (not an error) when the head is something
/// else, so callers can fall through to other value kinds.
pub fn parse_asset_form(items: &[Sexpr], span: Span) -> Option<Result<String, Diagnostic>> {
    if items.first().and_then(Sexpr::as_symbol) != Some(ASSET_FORM) {
        return None;
    }
    let result = match &items[1..] {
        [path] => match path {
            Sexpr::Str(text, span) => asset_path(text, *span),
            other => Err(Diagnostic::new(
                "an asset path is a string, like `(asset \"icons/logo.png\")`",
                other.span(),
            )),
        },
        _ => Err(Diagnostic::new(
            "`asset` takes exactly one path: `(asset \"icons/logo.png\")`",
            span,
        )),
    };
    Some(result)
}

/// Every rule about what an asset path may say.
fn asset_path(text: &str, span: Span) -> Result<String, Diagnostic> {
    if text.is_empty() {
        return Err(Diagnostic::new("an asset path cannot be empty", span));
    }
    // The form is read straight from the s-expression, so a `.gdc` author's
    // `{name}` would arrive here as literal characters rather than as the
    // interpolation they meant. Say so: the bytes are found at build time,
    // and there is nothing for a runtime value to name.
    if text.contains('{') || text.contains('}') {
        return Err(Diagnostic::new(
            "an asset path is fixed at build time, so it cannot use interpolation",
            span,
        ));
    }
    // Absolute paths and parent traversal reach outside the search path,
    // which would make a build depend on the machine it runs on.
    if text.starts_with('/') {
        return Err(Diagnostic::new(
            "an asset path is relative to the asset search path, not absolute",
            span,
        ));
    }
    if text.split('/').any(|part| part == "..") {
        return Err(Diagnostic::new(
            "an asset path cannot climb out of the asset search path with `..`",
            span,
        ));
    }
    Ok(text.to_owned())
}

#[cfg(test)]
mod tests;