pinkie 0.2.0

(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 = find_invocation(&source[line_pos..])
            .and_then(find_block)
            .ok_or("couldn't find css! macro call")?;

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

    /// Find the text right after the opening delimiter of a `css!` call,
    /// making sure `css` is at an identifier boundary (so that e.g. an
    /// identifier like `my_css` followed by `!` doesn't match).
    fn find_invocation(mut source: &str) -> Option<&str> {
        loop {
            let idx = source.find("css!")?;
            let boundary = source[..idx]
                .chars()
                .next_back()
                .is_none_or(|ch| !ch.is_alphanumeric() && ch != '_');
            source = &source[idx + 4..];
            if boundary {
                let rest = source.trim_start();
                if rest.starts_with(['{', '(', '[']) {
                    return Some(&rest[1..]);
                }
            }
        }
    }

    /// Find the extent of the macro block by counting delimiters, skipping
    /// over string/char literals and comments so that braces inside them
    /// don't confuse the count.
    fn find_block(input: &str) -> Option<&str> {
        let bytes = input.as_bytes();
        let mut depth = 1;
        let mut i = 0;
        while i < bytes.len() {
            match bytes[i] {
                b'{' | b'[' | b'(' => depth += 1,
                b'}' | b']' | b')' => {
                    depth -= 1;
                    if depth == 0 {
                        return Some(&input[..i]);
                    }
                }
                // line comment
                b'/' if bytes.get(i + 1) == Some(&b'/') => {
                    i += input[i..].find('\n').unwrap_or(input.len() - i);
                    continue;
                }
                // block comment (they nest in Rust)
                b'/' if bytes.get(i + 1) == Some(&b'*') => {
                    let mut comments = 1;
                    i += 2;
                    while i < bytes.len() && comments > 0 {
                        match (bytes[i], bytes.get(i + 1)) {
                            (b'/', Some(b'*')) => {
                                comments += 1;
                                i += 2;
                            }
                            (b'*', Some(b'/')) => {
                                comments -= 1;
                                i += 2;
                            }
                            _ => i += 1,
                        }
                    }
                    continue;
                }
                // string literal
                b'"' => {
                    i += 1;
                    while i < bytes.len() && bytes[i] != b'"' {
                        i += if bytes[i] == b'\\' { 2 } else { 1 };
                    }
                }
                // raw string literal
                b'r' if matches!(bytes.get(i + 1), Some(b'"' | b'#')) => {
                    let hashes = bytes[i + 1..].iter().take_while(|&&b| b == b'#').count();
                    if bytes.get(i + 1 + hashes) == Some(&b'"') {
                        let close = format!("\"{}", "#".repeat(hashes));
                        i += 2 + hashes;
                        i += input[i..].find(&close)? + close.len();
                        continue;
                    }
                }
                // char literal (but not a lifetime)
                b'\'' => {
                    let rest = &input[i + 1..];
                    let mut chars = rest.chars();
                    match chars.next() {
                        Some('\\') => {
                            i += 3; // skip the opening quote, backslash and one escape char
                            while i < bytes.len() && bytes[i] != b'\'' {
                                i += 1;
                            }
                        }
                        Some(ch) if chars.next() == Some('\'') => {
                            i += 1 + ch.len_utf8(); // ends on the closing quote
                        }
                        _ => {} // a lifetime or a lone quote, ignore
                    }
                }
                _ => {}
            }
            i += 1;
        }
        None
    }

    /// 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.
    ///
    /// Note that `file!()` paths are relative to the workspace root, so this
    /// only works when the process working directory is the workspace root
    /// (which is the common `cargo run` situation). When a source file cannot
    /// be read or parsed (e.g. `css!` calls from dependencies), a warning is
    /// logged and the static CSS embedded by the macro is used instead.
    ///
    /// 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 {:?}), falling back to static: {e}",
                    style.location
                );
                out.push_str(style.css);
            }
        })
    }

    #[cfg(test)]
    mod tests {
        use super::*;

        #[test]
        fn invocation_boundary() {
            assert_eq!(find_invocation("css! { a }"), Some(" a }"));
            assert_eq!(find_invocation("css!{a}"), Some("a}"));
            assert_eq!(find_invocation("css!(a)"), Some("a)"));
            assert_eq!(find_invocation("pinkie::css! { a }"), Some(" a }"));
            // not at an identifier boundary - skipped over
            assert_eq!(find_invocation("my_css! { a } css! { b }"), Some(" b }"));
            assert_eq!(find_invocation("scss! { a }"), None);
            assert_eq!(find_invocation("no invocation"), None);
            // `css!` not followed by an open delimiter is not a call
            assert_eq!(find_invocation("\"css!\" css! { a }"), Some(" a }"));
        }

        #[test]
        fn block_delimiters() {
            assert_eq!(find_block("color: red; } after"), Some("color: red; "));
            assert_eq!(find_block("a { b { } } }"), Some("a { b { } } "));
            assert_eq!(find_block("mixed ([{}]) }"), Some("mixed ([{}]) "));
            assert_eq!(find_block("unterminated {"), None);
        }

        #[test]
        fn block_skips_comments() {
            assert_eq!(find_block("// }\n }"), Some("// }\n "));
            assert_eq!(find_block("// } no newline"), None);
            assert_eq!(find_block("/* } */ }"), Some("/* } */ "));
            // rust block comments nest
            assert_eq!(find_block("/* /* } */ } */ }"), Some("/* /* } */ } */ "));
            // a lone slash is not a comment
            assert_eq!(find_block("a / b }"), Some("a / b "));
        }

        #[test]
        fn block_skips_strings() {
            assert_eq!(find_block(r#" "}" }"#), Some(r#" "}" "#));
            assert_eq!(find_block(r#" "\"}" }"#), Some(r#" "\"}" "#));
            assert_eq!(find_block(r###" r"}" }"###), Some(r###" r"}" "###));
            assert_eq!(find_block(r###" r#"}"# }"###), Some(r###" r#"}"# "###));
            // raw string closing must have matching hashes
            assert_eq!(find_block(r###" r##"}"# "## }"###), Some(r###" r##"}"# "## "###));
            // `r` that is not a raw string prefix
            assert_eq!(find_block("var(--r) }"), Some("var(--r) "));
        }

        #[test]
        fn block_skips_char_literals() {
            assert_eq!(find_block("'}' }"), Some("'}' "));
            assert_eq!(find_block(r"'\'' }"), Some(r"'\'' "));
            assert_eq!(find_block(r"'\u{7d}' }"), Some(r"'\u{7d}' "));
            // lifetimes are not char literals
            assert_eq!(find_block("&'a () }"), Some("&'a () "));
        }
    }
}