sabry_build/
buildmagic.rs

1use std::{
2    collections::HashSet,
3    convert::Infallible,
4    fs::{self, OpenOptions},
5    io::{self, Write},
6    path::PathBuf,
7    str::FromStr,
8    vec,
9};
10
11use sabry_intrnl::{
12    compiler::{CompilerAdapter, SabryCompilerError},
13    config::{manifest::ManifestError, BehavHashCollision, BehavSassModCollision, SabryConfig},
14    scoper::{hash::ScopeHash, ArbitraryScope, ScopeError},
15};
16use sabry_procmacro_impl::impls::{styly, ArbitraryStyleSyntax};
17use walkdir::WalkDir;
18
19use crate::filevisit::{self, FileVisitError};
20
21type ModuleName = String;
22type ModuleCode = String;
23type StyleModule = (ModuleName, ModuleCode);
24type BuilderResult = Result<(), SabryBuildError>;
25
26/// Entry point of sabrys build-magic
27///
28/// Example:
29/// ```
30/// # use sabry_build::buildmagic::buildy;
31/// /* buildy call */
32/// buildy(
33///     vec![("mixins.scss".to_string(), "@mixin abc(){}".to_string())]
34/// ).expect("Sabry failed to build CSS");
35/// ```
36///
37/// Also you could use style-macros defined anywhere with `sabry::scssy!` procmacro:
38/// ```ignore
39/// buildy(
40///     sabry::usey!(
41///         crate1::mixins!(),
42///         crate2::tokens!(),
43///         crate3::mixins!()
44///     )
45/// ).expect("Sabry failed to build CSS");
46/// ```
47///
48/// This function is intended to run at build time, however, you're free to use it however you please
49pub fn buildy(inline_side_modules: impl IntoIterator<Item = StyleModule>) -> BuilderResult {
50    let config = SabryConfig::require()?;
51    let mut builder = SabryBuilder::new(config);
52    builder.build(inline_side_modules)?;
53
54    Ok(())
55}
56
57/// Entrypoint structure of sabrys build-magic.
58///
59/// You could construct CSS compilation process by yourself instead of using `buildy` function:
60/// ```rust
61/// # use sabry_intrnl::config::SabryConfig;
62/// # use sabry_build::buildmagic::SabryBuilder;
63/// let config = SabryConfig::require().expect("Config didnt load");
64/// let builder = SabryBuilder::new(config);
65/// ```
66///
67/// the [buildy] function will just do baseline usage of this structure for you
68pub struct SabryBuilder {
69    config: SabryConfig,
70    css_compiler: CompilerAdapter,
71    state: SabryBuildState,
72}
73
74//🧙
75impl SabryBuilder {
76    /// Construct new [SabryBuilder] from the config
77    ///
78    /// You can require [SabryConfig] automatically, with the [Result], by:
79    ///
80    /// ```
81    /// # use sabry_intrnl::config::SabryConfig;
82    /// SabryConfig::require().expect("Config didnt load");
83    /// ```
84    pub fn new(config: SabryConfig) -> Self {
85        let css_compiler = CompilerAdapter::new(config.clone());
86        Self {
87            config,
88            css_compiler,
89            state: SabryBuildState::default(),
90        }
91    }
92
93    /// Default building workflow implementation
94    pub fn build(
95        &mut self,
96        inline_side_modules: impl IntoIterator<Item = StyleModule>,
97    ) -> BuilderResult {
98        println!("🧙: This is probably the stderr. Something went wrong:");
99
100        println!("🧙 loading preludes");
101        self.load_preludes()?;
102
103        println!("🧙 loading `buildy` modules");
104        for (name, code) in inline_side_modules {
105            self.load_side_module(name, code)?;
106        }
107
108        println!("🧙 loading this crate");
109        self.load_styles_from_this_crate()?;
110
111        println!("🧙 compiling CSS");
112        self.compile_everything()?;
113
114        println!("🧙 writing an output");
115        self.generate_output()?;
116
117        Ok(())
118    }
119
120    /// Write all the loaded CSS
121    ///
122    /// - Bundle file (if configured)
123    /// - Scope chunks (if out-dir is configured)
124    pub fn generate_output(&mut self) -> BuilderResult {
125        // warn on empty loaded_css_modules
126        if self.state.loaded_css_modules.is_empty() {
127            println!("🧙 sabry didn't compile any CSS. Perhaps the crate has no styles? Lets write some!\nAlso you should check that you do use `styly!` macro properly - at the top level as an item.");
128        }
129
130        if let Some(scope_dir) = &self.config.css.scopes {
131            println!("🧙 writing CSS files for each of loaded scopes into {scope_dir}");
132
133            fs::create_dir_all(scope_dir)?;
134            for (scope, code) in &self.state.loaded_css_modules {
135                let scope_path = format!("{}/{}.css", scope_dir, scope);
136                fs::write(scope_path, code)?;
137            }
138        }
139
140        if let Some(bundle_file) = &self.config.css.bundle {
141            println!("🧙 writing merged CSS for the entire crate into {bundle_file}");
142
143            let path = PathBuf::from_str(bundle_file)?;
144
145            if let Some(dir) = path.parent() {
146                fs::create_dir_all(dir)?;
147            }
148
149            // remove the file to write new fresh tasty CSS
150            let _ = fs::remove_file(&path);
151            // and then reopen it
152            let mut file = OpenOptions::new()
153                .create_new(true)
154                .append(true)
155                .open(path)?;
156
157            // merged bundle CSS does require another lightningcss pass
158            let mut buffer = String::new();
159            for (_, code) in &self.state.loaded_css_modules {
160                buffer.push_str(code);
161            }
162            buffer = self.css_compiler.lightningcss(&buffer)?;
163
164            write!(file, "{buffer}")?;
165        }
166
167        Ok(())
168    }
169
170    /// Compile all the loaded styles, SASS/SCSS/CSS, without actually writing them.
171    pub fn compile_everything(&mut self) -> BuilderResult {
172        // warn on empty known_side_modules and loaded_stylyses
173        if self.state.known_side_modules.is_empty() {
174            println!("🧙 sabry didn't load any side modules");
175        }
176        if self.state.loaded_stylyses.is_empty() {
177            println!("🧙 sabry didn't load any usable styles");
178        }
179
180        // compile styly! macro parsed styles
181        for styly in &self.state.loaded_stylyses {
182            let scope = ArbitraryScope::from_source(
183                styly.syntax.into(),
184                styly.scope.clone(),
185                styly.code.code(),
186            )?
187            .hashed(&self.config.hash)?;
188
189            match self.config.hash.collision {
190                BehavHashCollision::Ignore => {}
191                BehavHashCollision::Error => {
192                    if self.state.known_scope_hashes.contains(&scope.hash) {
193                        return Err(SabryBuildError::HashCollision {
194                            scope: scope.original_scope.name.to_string(),
195                        });
196                    }
197                    self.state.known_scope_hashes.insert(scope.hash.clone());
198                }
199            }
200
201            let css = self
202                .css_compiler
203                .compile_module(scope.original_scope.adapter().syntax, &scope.hashed_code)?;
204            self.state
205                .loaded_css_modules
206                .push((scope.original_scope.name.to_string(), css));
207        }
208
209        // compile sass preludes into the CSS prelude
210        for pre in &self.state.sass_prelude {
211            let css = self
212                .css_compiler
213                .compile_module(pre.syntax.into(), &pre.code)?;
214            self.state.css_prelude.push_str(&css);
215        }
216
217        Ok(())
218    }
219
220    /// Visit all the source files in the current crate and look for code that may affect building process:
221    ///
222    /// - `styly!` macro calls
223    ///
224    /// Only files with an **.rs** extension are visited.
225    ///
226    /// All the gathered info is loaded into state
227    pub fn load_styles_from_this_crate(&mut self) -> BuilderResult {
228        println!("🧙 scanning the crate");
229
230        let root = WalkDir::new(&self.config.sass.scanroot);
231
232        for entry in root {
233            let entry = entry?;
234            let metadata = entry.metadata()?;
235
236            if metadata.is_file() {
237                let entry_path = entry.path();
238                let ext = entry_path.extension().unwrap_or_default();
239                if ext == "rs" {
240                    println!(".. reading {entry_path:?}");
241                    let visitor = filevisit::visit_file(entry_path)?;
242                    self.state.loaded_stylyses.extend(visitor.found_stylys);
243                }
244            } else if metadata.is_symlink() {
245                println!(
246                    "🧙 sabry won't go through symlinks currently ('{}' is a symlink)",
247                    entry.path().to_string_lossy()
248                )
249            }
250        }
251
252        Ok(())
253    }
254
255    /// Load up configured preludes and side modules
256    pub fn load_preludes(&mut self) -> BuilderResult {
257        // load sass modules from config
258        let mut modules: Vec<StyleModule> = vec![];
259        if let Some(sass_mods) = &self.config.sass.modules {
260            for pre in sass_mods {
261                let pre_path = PathBuf::from_str(pre)?;
262                let pre_name = pre_path
263                    .file_name()
264                    .ok_or(SabryBuildError::FileName())?
265                    .to_string_lossy()
266                    .to_string();
267                let code = fs::read_to_string(pre_path)?;
268                modules.push((pre_name, code));
269            }
270        }
271        for (name, code) in modules {
272            self.load_side_module(name, code)?;
273        }
274
275        // load SASS preludes
276        if let Some(sass_pres) = &self.config.sass.prelude {
277            let mut sass_preludes: Vec<SassPreludeModule> = vec![];
278            for pre in sass_pres {
279                let pre_path = PathBuf::from_str(pre)?;
280
281                let syntax = pre_path
282                    .extension()
283                    .unwrap_or_default()
284                    .to_str()
285                    .unwrap_or_default();
286                let syntax = match ArbitraryStyleSyntax::try_from(syntax) {
287                    Ok(s) => s,
288                    Err(_) => {
289                        return Err(SabryBuildError::Another(format!(
290                            "Unknown syntax for sass prelude {pre}"
291                        )))
292                    }
293                };
294
295                let code = fs::read_to_string(pre_path)?;
296                sass_preludes.push(SassPreludeModule { syntax, code });
297            }
298            self.state.sass_prelude.extend(sass_preludes);
299        }
300
301        // load css preludes
302        if let Some(css_pre) = &self.config.css.prelude {
303            for pre in css_pre {
304                let code = fs::read_to_string(pre)?;
305                self.state.css_prelude.push_str(&code);
306            }
307        }
308
309        Ok(())
310    }
311
312    /// Load up the SASS module by its `name` and fill the according file by its `code` as-is
313    ///
314    /// Module "loading" is simply a file creation. File is created in the configured `intermediate_dir`
315    /// directory.
316    ///
317    /// Module `name` is not suffixed with syntax/extensions/whatever so it already should have necessary file extensions.
318    ///
319    /// This function will not overwrite the module file. Never.
320    ///
321    pub fn load_side_module(&mut self, name: ModuleName, code: ModuleCode) -> BuilderResult {
322        println!("🧙 loading side module '{}'", &name);
323
324        let module_file_path = format!("{}/{}", &self.config.sass.intermediate_dir, &name);
325        let mut open_options = OpenOptions::new();
326        let mut open_options = open_options.write(true);
327
328        // prematurelly remove module file if the module is not yet known to be loaded
329        if !self.state.known_side_modules.contains(&name) {
330            let _ = fs::remove_file(&module_file_path);
331        }
332
333        open_options = if self.state.known_side_modules.contains(&name) {
334            match &self.config.sass.module_name_collision {
335                BehavSassModCollision::Error => {
336                    return Err(SabryBuildError::ModuleCollision { module: name })
337                }
338                BehavSassModCollision::Merge => {
339                    println!("🧙 sabry found duplicate module name '{name}': merging code, as configured");
340                    open_options.append(true)
341                }
342            }
343        } else {
344            open_options.create(true)
345        };
346
347        fs::create_dir_all(&self.config.sass.intermediate_dir)?;
348        let mut module_file = open_options.open(module_file_path)?;
349
350        write!(module_file, "\n{code}\n")?;
351
352        self.state.known_side_modules.insert(name);
353
354        Ok(())
355    }
356}
357
358#[derive(Default)]
359pub struct SabryBuildState {
360    /// HashSet of scope hashes known by builder
361    /// Used to determine hash collision
362    known_scope_hashes: HashSet<ScopeHash>,
363    /// HashSet of module names known by builder
364    /// Used to determine module name collision
365    known_side_modules: HashSet<ModuleName>,
366    /// styly! macro uses, parsed
367    loaded_stylyses: Vec<styly::MacroSyntax>,
368    /// CSS modules to form bundle/write separately
369    loaded_css_modules: Vec<StyleModule>,
370    /// CSS prelude to write into bundle
371    /// Lives separately from [loaded_css_modules] to avoid name collision
372    css_prelude: String,
373    /// SASS preludes loaded from config
374    /// Should be compiled into loaded_css_modules as well
375    sass_prelude: Vec<SassPreludeModule>,
376}
377
378/// Convenience struct for [SabryBuildState::sass_prelude]
379pub struct SassPreludeModule {
380    /// syntax for the prelude
381    syntax: ArbitraryStyleSyntax,
382    /// code of the prelude
383    code: String,
384}
385
386#[derive(Debug, thiserror::Error)]
387pub enum SabryBuildError {
388    #[error("Filesystem error")]
389    Fs(#[from] io::Error),
390    #[error("Failed to walk through the crate")]
391    CrateWalk(#[from] walkdir::Error),
392    #[error("Failed to load side SASS module")]
393    LoadSass(),
394    #[error(
395        "Module with the similar name was already loaded, and sabry is configured to raise an error"
396    )]
397    ModuleCollision { module: ModuleName },
398    #[error("Sabry cant understand the file")]
399    FileVisit(#[from] FileVisitError),
400    #[error("Syntax of style can not be parsed")]
401    SyntaxError(#[from] ScopeError),
402    #[error("Another scope has the same hash, and sabry is configured to raise an error. Try to adjust config, increase hash size, or change the style code")]
403    HashCollision { scope: ModuleName },
404    #[error("Something's wrong with file path")]
405    Path(#[from] Infallible),
406    #[error("File name wasnt determined properly")]
407    FileName(),
408    #[error("Failed to compile CSS")]
409    CssCompile(#[from] SabryCompilerError),
410    #[error("Failed to load config/manifest")]
411    Manifest(#[from] ManifestError),
412    #[error("Another error")]
413    Another(String),
414}