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}