css_mod 0.1.5

CSS Modules implementation for Rust web applications
Documentation
use super::parser::{self, Error, Rule};
use anyhow::{anyhow, Context, Result};
use pest::iterators::{Pair, Pairs};
use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::fs::File;
use std::io::prelude::*;
use std::path::{Path, PathBuf};

pub type Names = HashMap<String, String>;
pub type Children = Vec<Child>;

#[derive(Debug, PartialEq)]
pub enum Child {
    AtRule {
        name: Option<String>,
        rule: Option<String>,
        children: Children,
    },
    Comment {
        value: Option<String>,
    },
    Property {
        name: Option<String>,
        value: Option<String>,
    },
    SelectRule {
        rule: Option<String>,
        children: Children,
    },
}

impl fmt::Display for Child {
    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Child::AtRule {
                name,
                rule,
                children,
            } => {
                if let (Some(name), Some(rule)) = (name, rule) {
                    if children.is_empty() {
                        write!(formatter, "@{} {}; ", name, rule.trim())?;
                    } else {
                        write!(formatter, "@{} {} {{ ", name, rule.trim())?;

                        for child in children {
                            write!(formatter, "{}", child)?;
                        }

                        writeln!(formatter, "}}")?;
                    }
                } else if let Some(name) = name {
                    if children.is_empty() {
                        write!(formatter, "@{};", name)?;
                    } else {
                        write!(formatter, "@{} {{ ", name)?;

                        for child in children {
                            write!(formatter, "{}", child)?;
                        }

                        writeln!(formatter, "}}")?;
                    }
                }
            }
            Child::SelectRule { rule, children } => {
                if children.is_empty() {
                    write!(formatter, "")?;
                } else if let Some(rule) = rule {
                    write!(formatter, "{} {{ ", rule)?;

                    for child in children {
                        write!(formatter, "{}", child)?;
                    }

                    writeln!(formatter, "}}")?;
                }
            }
            Child::Property { name, value } => {
                if let (Some(name), Some(value)) = (name, value) {
                    write!(formatter, "{}: {}; ", name, value)?;
                } else if let Some(name) = name {
                    write!(formatter, "{}:; ", name)?;
                }
            }
            Child::Comment { value } => {
                if let Some(value) = value {
                    write!(formatter, "{}", value)?;
                }
            }
        };

        Ok(())
    }
}

#[derive(Debug, PartialEq)]
pub struct ParserContext<'c> {
    pub module: &'c mut Module,
    pub name: &'c str,
    pub absolute_path: &'c PathBuf,
    pub stylesheet: &'c mut Stylesheet,
}

impl<'c> ParserContext<'c> {
    fn add_name(&mut self, name: String) -> String {
        let res = self.module.names.entry(name.clone()).or_insert(format!(
            "{}__{}__{}",
            &self.name, &name, &self.stylesheet.names_count
        ));

        self.stylesheet.names_count += 1;

        res.to_owned()
    }
}

#[derive(Debug, PartialEq)]
pub struct Module {
    pub children: Children,
    pub names: Names,
    pub file_path: PathBuf,
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
impl Default for Module {
    fn default() -> Self {
        use std::str::FromStr;

        let path = PathBuf::from_str(file!()).unwrap();

        Self {
            children: Vec::new(),
            names: HashMap::new(),
            file_path: path,
        }
    }
}

impl fmt::Display for Module {
    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        // write!(formatter, "{}", value)
        for child in &self.children {
            write!(formatter, "{}", child)?;
        }

        Ok(())
    }
}

impl<'m> Module {
    pub fn new(stylesheet: &mut Stylesheet, file_path: PathBuf, input: &'m str) -> Result<Self> {
        let pairs = parser::stylesheet(input)?;
        let mut module = Module {
            children: Children::new(),
            names: Names::new(),
            file_path: file_path.clone(),
        };
        let mut context = ParserContext {
            module: &mut module,
            name: file_path
                .file_stem()
                .context("No file stem")?
                .to_str()
                .context("Invalid file path")?,
            absolute_path: &file_path
                .parent()
                .context("No parent directory")?
                .to_path_buf(),
            stylesheet,
        };

        for pair in pairs {
            let child = match pair.as_rule() {
                Rule::comment => comment(pair),
                Rule::atrule => atrule(&mut context, pair)?,
                Rule::selectrule => selectrule(&mut context, pair)?,
                Rule::EOI => None,
                _ => return Err(anyhow!(Error::from(pair))),
            };

            if let Some(child) = child {
                context.module.children.push(child);
            }
        }

        Ok(module)
    }
}

#[derive(Debug, Default, PartialEq)]
pub struct Stylesheet {
    // use sorted map instead of hash map so output bundle is rendered deterministically with the
    // same content across recompilations
    pub modules: BTreeMap<PathBuf, Module>,
    pub names_count: u64,
}

impl Stylesheet {
    pub fn add_module(&mut self, module_path: &Path) -> Result<&Module> {
        let mut file = File::open(module_path)?;
        let mut input = String::new();

        file.read_to_string(&mut input)?;

        let module = Module::new(self, module_path.to_path_buf(), &input)?;

        Ok(self
            .modules
            .entry(module_path.to_path_buf())
            .or_insert(module))
    }

    #[cfg(test)]
    #[allow(clippy::unwrap_used)]
    fn add_test_module(&mut self, input: &str) -> Result<&Module> {
        use std::str::FromStr;

        let path = PathBuf::from_str(file!()).unwrap();
        let module = Module::new(self, path.clone(), input)?;

        Ok(self.modules.entry(path).or_insert(module))
    }
}

pub fn atrule<'t>(context: &mut ParserContext, pair: Pair<'t, Rule>) -> Result<Option<Child>> {
    let mut name: Option<String> = None;
    let mut rule: Option<String> = None;
    let mut children = Vec::new();

    for pair in pair.into_inner() {
        let child = match pair.as_rule() {
            Rule::identifier => {
                name = Some(pair.as_str().into());
                None
            }
            Rule::atrule_rule => {
                if Some("keyframes".into()) == name {
                    rule = Some((&context.add_name(pair.as_str().trim().into())).to_string());
                } else if Some("import".into()) == name {
                    let quotes: &[_] = &['"', '\''];
                    let path = context
                        .absolute_path
                        .clone()
                        .join(pair.as_str().trim_matches(quotes));
                    let import = context.stylesheet.add_module(&path)?;

                    for (old, new) in import.names.iter() {
                        context
                            .module
                            .names
                            .entry(old.clone())
                            .or_insert_with(|| new.clone());
                    }

                    return Ok(None);
                } else {
                    rule = Some(pair.as_str().into());
                }

                None
            }
            Rule::comment | Rule::line_comment => comment(pair),
            Rule::property => property(context, pair)?,
            Rule::atrule => atrule(context, pair)?,
            Rule::selectrule => selectrule(context, pair)?,
            _ => return Err(anyhow!(Error::from(pair))),
        };

        if let Some(child) = child {
            children.push(child);
        }
    }

    Ok(Some(Child::AtRule {
        name,
        rule,
        children,
    }))
}

pub fn comment(pair: Pair<Rule>) -> Option<Child> {
    Some(Child::Comment {
        value: Some(pair.as_str().into()),
    })
}

pub fn property<'t>(context: &mut ParserContext, pair: Pair<'t, Rule>) -> Result<Option<Child>> {
    let mut name: Option<String> = None;
    let mut value: Option<String> = None;

    for pair in pair.into_inner() {
        match pair.as_rule() {
            Rule::identifier => {
                name = Some(pair.as_str().into());
            }
            Rule::property_value => {
                if Some("animation".into()) == name || Some("animation-name".into()) == name {
                    value = replace_names(context, parser::animation(pair.as_str())?)?;
                } else {
                    value = Some(pair.as_str().trim().into());
                }
            }
            _ => return Err(anyhow!(Error::from(pair))),
        }
    }

    Ok(Some(Child::Property { name, value }))
}

pub fn selectrule<'t>(context: &mut ParserContext, pair: Pair<'t, Rule>) -> Result<Option<Child>> {
    let mut rule: Option<String> = None;
    let mut children = Vec::new();

    for pair in pair.into_inner() {
        let child = match pair.as_rule() {
            Rule::selectrule_rule => {
                rule = replace_names(context, parser::selector(pair.as_str())?)?;

                None
            }
            Rule::comment | Rule::line_comment => comment(pair),
            Rule::property => property(context, pair)?,
            Rule::atrule => atrule(context, pair)?,
            Rule::selectrule => selectrule(context, pair)?,
            _ => return Err(anyhow!(Error::from(pair))),
        };

        if let Some(child) = child {
            children.push(child);
        }
    }

    Ok(Some(Child::SelectRule { rule, children }))
}

pub fn replace_names(context: &mut ParserContext, pairs: Pairs<Rule>) -> Result<Option<String>> {
    let mut result = String::new();

    for pair in pairs {
        match pair.as_rule() {
            Rule::identifier => {
                result.push_str(&context.add_name(pair.as_str().trim().into()));
            }
            Rule::selector_class => {
                result.push_str(&format!(
                    ".{}",
                    &context.add_name(pair.as_str()[1..].trim().into())
                ));
            }
            _ => {
                result.push_str(pair.as_str());
            }
        }
    }

    result = result.trim().into();

    if result.is_empty() {
        Ok(None)
    } else {
        Ok(Some(result))
    }
}

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

    #[test]
    fn parses_empty_stylesheet() {
        assert_eq!(
            Stylesheet::default(),
            Stylesheet {
                names_count: 0,
                modules: BTreeMap::new(),
            }
        )
    }

    #[test]
    fn parses_empty_select_rule() {
        assert_eq!(
            Stylesheet::default().add_test_module(".foobar {}").unwrap(),
            &Module {
                children: vec![Child::SelectRule {
                    children: Vec::new(),
                    rule: Some(".ast__foobar__0".into()),
                }],
                names: vec![("foobar".into(), "ast__foobar__0".into())]
                    .into_iter()
                    .collect(),
                ..Module::default()
            }
        )
    }

    #[test]
    fn parses_select_rule_with_property() {
        assert_eq!(
            Stylesheet::default()
                .add_test_module(".foobar { color: red; }")
                .unwrap(),
            &Module {
                children: vec![Child::SelectRule {
                    rule: Some(".ast__foobar__0".into()),
                    children: vec![Child::Property {
                        name: Some("color".into()),
                        value: Some("red".into())
                    }],
                }],
                names: vec![("foobar".into(), "ast__foobar__0".into())]
                    .into_iter()
                    .collect(),
                ..Module::default()
            }
        )
    }

    #[test]
    fn parses_empty_at_rule() {
        assert_eq!(
            Stylesheet::default()
                .add_test_module("@keyframes foobar;")
                .unwrap(),
            &Module {
                children: vec![Child::AtRule {
                    name: Some("keyframes".into()),
                    children: Vec::new(),
                    rule: Some("ast__foobar__0".into()),
                }],
                names: vec![("foobar".into(), "ast__foobar__0".into())]
                    .into_iter()
                    .collect(),
                ..Module::default()
            }
        )
    }

    #[test]
    fn format_empty_module() {
        let mut stylesheet = Stylesheet::default();
        let module = stylesheet.add_test_module("").unwrap();

        assert_eq!(format!("{}", module), String::new());
    }

    #[test]
    fn format_select_rule() {
        let mut stylesheet = Stylesheet::default();
        let module = stylesheet
            .add_test_module("p.foobar  {  color :  #fff ;  }")
            .unwrap();

        assert_eq!(
            &format!("{}", module),
            "p.ast__foobar__0 { color: #fff; }\n"
        );
    }

    #[test]
    fn format_at_rule() {
        let mut stylesheet = Stylesheet::default();
        let module = stylesheet
            .add_test_module("@keyframes animation {0% { top: 0;} 100% {top: 100px;}}")
            .unwrap();

        assert_eq!(
            &format!("{}", module),
            "@keyframes ast__animation__0 { 0% { top: 0; }\n100% { top: 100px; }\n}\n"
        );
    }

    #[test]
    fn error_is_unclosed_block() {
        assert!(Stylesheet::default().add_test_module("p {").is_err());
    }

    #[test]
    fn error_is_property_without_value() {
        assert!(Stylesheet::default().add_test_module("p {color;}").is_err());
    }
}