pinkie 0.1.2

(Almost) compile-time scoped CSS-in-Rust
Documentation
#![doc = include_str!("../readme.md")]
#![cfg_attr(not(all(debug_assertions, feature = "dynamic")), no_std)]
#![deny(missing_docs)]

extern crate alloc;

use alloc::{collections::BTreeMap, string::String};
use core::fmt::{self, Debug, Display};

/// The macro used to define scoped CSS styles.
///
/// It returns a [`Style`], the `Display` implementation of which writes
/// out the class name.
pub use pinkie_macros::css;

/// Re-export of `inventory::submit` that's used by the macro.
/// Not intended to be used directly.
///
/// Nothing _stops_ you, of course, but those two underscores sure are ugly,
/// aren't they?
#[doc(hidden)]
pub use inventory::submit as __submit;

/// A simple location in the source code.
#[cfg(feature = "location")]
#[derive(Clone)]
pub struct Location {
    /// Name of the file as returned by the `file!()` macro.
    pub file: &'static str,
    /// Line number as returned by the `line!()` macro.
    pub line: usize,
}

#[cfg(feature = "location")]
impl Debug for Location {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}:{}", self.file, self.line)
    }
}

/// A value returned by the `css!` macro.
///
/// Usually you'd just use the `Display` implementation to write out the class
/// name somewhere.
#[derive(Debug, Clone)]
pub struct Style {
    /// The scoping class name - `env!("PINKIE_CSS_CLASS_PREFIX")` (`pinkie-`
    /// by default) followed by a hash of the generated CSS string.
    pub class: &'static str,
    /// The CSS string generated from Rust tokens passed to the `css!` macro.
    pub css: &'static str,
    /// The location in the source code where the `css!` macro was called, the
    /// line number corresponding to the line containing the "css!" string.
    #[cfg(feature = "location")]
    pub location: Location,
}

impl Display for Style {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.class)
    }
}

inventory::collect!(Style);

/// Iterate over all `css!` macro invocations.
pub fn styles() -> impl Iterator<Item = &'static Style> {
    inventory::iter::<Style>()
}

#[inline]
fn collect_impl(mut write: impl FnMut(&'static Style, &mut String)) -> String {
    let mut joined = String::new();
    let mut visited = BTreeMap::new();
    for style in styles() {
        if let Some(visited) = visited.insert(style.class, style) {
            if visited.css == style.css {
                continue;
            }
            #[cfg(feature = "location")]
            panic!(
                "duplicate class (hash collision): {}, at {:?} and {:?}",
                style.class, style.location, visited.location
            );
            #[cfg(not(feature = "location"))]
            panic!("duplicate class (hash collision): {}", style.class);
        }
        joined.push('.');
        joined.push_str(style.class);
        joined.push('{');
        write(style, &mut joined);
        joined.push_str("}\n");
    }
    joined
}

/// Collect all `css!` styles into a single string under unique classes.
///
/// If the feature `dynamic` and debug assertions are enabled, this will
/// ignore the static css string generated by the macro, and instead try to
/// read the Rust source at the location of the `css!` macro calls.
///
/// This means that every time this function is called, it will return the most
/// up-to-date CSS according to the source code, without requiring a
/// Rust recompilation.
///
/// Upgrading it into a hot-reload system is trivial, and left as an exercise
/// to the reader.
///
/// # Panics
/// Will panic on hash collisions - this can be fixed by slightly adjusting
/// one of the clashing styles.
/// Such collisions should be pretty rare.
#[cfg(not(all(debug_assertions, feature = "dynamic")))]
pub fn collect() -> String {
    collect_impl(|style, res| res.push_str(&style.css))
}

#[cfg(all(debug_assertions, feature = "dynamic"))]
pub use dynamic::collect;

#[cfg(all(debug_assertions, feature = "dynamic"))]
mod dynamic {
    use super::*;
    use std::{
        collections::{hash_map::Entry, HashMap},
        error::Error,
        io::ErrorKind,
    };

    fn collect_dynamic(
        style: &Style,
        files: &mut HashMap<&str, String>,
    ) -> Result<String, Box<dyn Error>> {
        let source = match files.entry(style.location.file) {
            Entry::Occupied(entry) => entry.into_mut(),
            Entry::Vacant(entry) => {
                let source = match std::fs::read_to_string(entry.key()) {
                    Err(e) if e.kind() == ErrorKind::NotFound => {
                        return Err(format!("file {} not found", entry.key()).into())
                    }
                    r => r?,
                };
                entry.insert(source)
            }
        };

        let line_pos: usize = source
            .split_inclusive('\n')
            .take(style.location.line.saturating_sub(1))
            .map(|line| line.len())
            .sum();

        let block = &source[line_pos..]
            .split_once("css!")
            .and_then(|(_, rest)| {
                rest.trim_start()
                    .strip_prefix(|ch| matches!(ch, '{' | '(' | '['))
            })
            .and_then(|input| {
                let mut depth = 1;
                for (i, ch) in input.char_indices() {
                    match ch {
                        '{' | '[' | '(' => depth += 1,
                        '}' | ']' | ')' => depth -= 1,
                        _ => {}
                    }
                    if depth == 0 {
                        return Some(&input[..i]);
                    }
                }
                None
            })
            .ok_or("couldn't find css! macro call")?;

        Ok(pinkie_parser::parse(block.parse()?).css)
    }

    /// Collect all `css!` styles into a single string under unique classes.
    ///
    /// If the feature `dynamic` and debug assertions are enabled, this will
    /// ignore the static css string generated by the macro, and instead try to
    /// read the Rust source at the location of the `css!` macro calls.
    ///
    /// This means that every time this function is called, it will return the most
    /// up-to-date CSS according to the source code, without requiring a
    /// Rust recompilation.
    ///
    /// Upgrading it into a hot-reload system is trivial, and left as an exercise
    /// to the reader.
    ///
    /// # Panics
    /// Will panic on hash collisions - this can be fixed by slightly adjusting
    /// one of the clashing styles.
    ///
    /// Such collisions should be pretty rare.
    pub fn collect() -> String {
        let mut files = Default::default();
        collect_impl(|style, out| match collect_dynamic(style, &mut files) {
            Ok(s) => out.push_str(&s),
            Err(e) => log::warn!(
                "dynamic css error (css! macro at {:?}): {e}",
                style.location
            ),
        })
    }
}