stylance_core/
lib.rs

1mod class_name_pattern;
2mod parse;
3
4use std::{
5    borrow::Cow,
6    fs,
7    hash::{Hash as _, Hasher as _},
8    path::{Path, PathBuf},
9    str::FromStr,
10};
11
12use anyhow::{anyhow, bail, Context};
13use class_name_pattern::ClassNamePattern;
14use parse::{CssFragment, Global};
15use serde::Deserialize;
16use siphasher::sip::SipHasher13;
17
18fn default_extensions() -> Vec<String> {
19    vec![".module.css".to_owned(), ".module.scss".to_owned()]
20}
21
22fn default_folders() -> Vec<PathBuf> {
23    vec![PathBuf::from_str("./src/").expect("path is valid")]
24}
25
26fn default_hash_len() -> usize {
27    7
28}
29
30#[derive(Deserialize, Debug)]
31#[serde(deny_unknown_fields)]
32pub struct Config {
33    pub output_file: Option<PathBuf>,
34    pub output_dir: Option<PathBuf>,
35    #[serde(default = "default_extensions")]
36    pub extensions: Vec<String>,
37    #[serde(default = "default_folders")]
38    pub folders: Vec<PathBuf>,
39    pub scss_prelude: Option<String>,
40    #[serde(default = "default_hash_len")]
41    pub hash_len: usize,
42    #[serde(default)]
43    pub class_name_pattern: ClassNamePattern,
44}
45
46impl Default for Config {
47    fn default() -> Self {
48        Self {
49            output_file: None,
50            output_dir: None,
51            extensions: default_extensions(),
52            folders: default_folders(),
53            scss_prelude: None,
54            hash_len: default_hash_len(),
55            class_name_pattern: Default::default(),
56        }
57    }
58}
59
60#[derive(Deserialize)]
61pub struct CargoToml {
62    package: Option<CargoTomlPackage>,
63}
64
65#[derive(Deserialize)]
66pub struct CargoTomlPackage {
67    metadata: Option<CargoTomlPackageMetadata>,
68}
69
70#[derive(Deserialize)]
71pub struct CargoTomlPackageMetadata {
72    stylance: Option<Config>,
73}
74
75pub fn hash_string(input: &str) -> u64 {
76    let mut hasher = SipHasher13::new();
77    input.hash(&mut hasher);
78    hasher.finish()
79}
80
81pub struct Class {
82    pub original_name: String,
83    pub hashed_name: String,
84}
85
86pub fn load_config(manifest_dir: &Path) -> anyhow::Result<Config> {
87    let cargo_toml_contents =
88        fs::read_to_string(manifest_dir.join("Cargo.toml")).context("Failed to read Cargo.toml")?;
89    let cargo_toml: CargoToml = toml::from_str(&cargo_toml_contents)?;
90
91    let config = match cargo_toml.package {
92        Some(CargoTomlPackage {
93            metadata:
94                Some(CargoTomlPackageMetadata {
95                    stylance: Some(config),
96                }),
97        }) => config,
98        _ => Config::default(),
99    };
100
101    if config.extensions.iter().any(|e| e.is_empty()) {
102        bail!("Stylance config extensions can't be empty strings");
103    }
104
105    Ok(config)
106}
107
108fn normalized_relative_path(base: &Path, subpath: &Path) -> anyhow::Result<String> {
109    let base = base.canonicalize()?;
110    let subpath = subpath.canonicalize()?;
111
112    let relative_path_str: String = subpath
113        .strip_prefix(base)
114        .context("css file should be inside manifest_dir")?
115        .to_string_lossy()
116        .into();
117
118    #[cfg(target_os = "windows")]
119    let relative_path_str = relative_path_str.replace('\\', "/");
120
121    Ok(relative_path_str)
122}
123
124fn make_hash(manifest_dir: &Path, css_file: &Path, hash_len: usize) -> anyhow::Result<String> {
125    let relative_path_str = normalized_relative_path(manifest_dir, css_file)?;
126
127    let hash = hash_string(&relative_path_str);
128    let mut hash_str = format!("{hash:x}");
129    hash_str.truncate(hash_len);
130    Ok(hash_str)
131}
132
133pub struct ModifyCssResult {
134    pub path: PathBuf,
135    pub normalized_path_str: String,
136    pub hash: String,
137    pub contents: String,
138}
139
140pub fn load_and_modify_css(
141    manifest_dir: &Path,
142    css_file: &Path,
143    config: &Config,
144) -> anyhow::Result<ModifyCssResult> {
145    let hash_str = make_hash(manifest_dir, css_file, config.hash_len)?;
146    let css_file_contents = fs::read_to_string(css_file)?;
147
148    let fragments = parse::parse_css(&css_file_contents).map_err(|e| anyhow!("{e}"))?;
149
150    let mut new_file = String::with_capacity(css_file_contents.len() * 2);
151    let mut cursor = css_file_contents.as_str();
152
153    for fragment in fragments {
154        let (span, replace) = match fragment {
155            CssFragment::Class(class) => (
156                class,
157                Cow::Owned(config.class_name_pattern.apply(class, &hash_str)),
158            ),
159            CssFragment::Global(Global { inner, outer }) => (outer, Cow::Borrowed(inner)),
160        };
161
162        let (before, after) = cursor.split_at(span.as_ptr() as usize - cursor.as_ptr() as usize);
163        cursor = &after[span.len()..];
164        new_file.push_str(before);
165        new_file.push_str(&replace);
166    }
167
168    new_file.push_str(cursor);
169
170    Ok(ModifyCssResult {
171        path: css_file.to_owned(),
172        normalized_path_str: normalized_relative_path(manifest_dir, css_file)?,
173        hash: hash_str,
174        contents: new_file,
175    })
176}
177
178pub fn get_classes(
179    manifest_dir: &Path,
180    css_file: &Path,
181    config: &Config,
182) -> anyhow::Result<(String, Vec<Class>)> {
183    let hash_str = make_hash(manifest_dir, css_file, config.hash_len)?;
184
185    let css_file_contents = fs::read_to_string(css_file)?;
186
187    let mut classes = parse::parse_css(&css_file_contents)
188        .map_err(|e| anyhow!("{e}"))?
189        .into_iter()
190        .filter_map(|c| {
191            if let CssFragment::Class(c) = c {
192                Some(c)
193            } else {
194                None
195            }
196        })
197        .collect::<Vec<_>>();
198
199    classes.sort();
200    classes.dedup();
201
202    Ok((
203        hash_str.clone(),
204        classes
205            .into_iter()
206            .map(|class| Class {
207                original_name: class.to_owned(),
208                hashed_name: config.class_name_pattern.apply(class, &hash_str),
209            })
210            .collect(),
211    ))
212}