Skip to main content

darklua_core/frontend/
configuration.rs

1use std::{
2    collections::HashSet,
3    path::{Path, PathBuf},
4    str::FromStr,
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    generator::{DenseLuaGenerator, LuaGenerator, ReadableLuaGenerator, TokenBasedLuaGenerator},
11    nodes::Block,
12    rules::{
13        bundle::{BundleRequireMode, Bundler},
14        get_default_rules, Rule,
15    },
16    utils::{deserialize_one_or_many, FilterPattern},
17    DarkluaError, Parser,
18};
19
20const DEFAULT_COLUMN_SPAN: usize = 80;
21
22fn get_default_column_span() -> usize {
23    DEFAULT_COLUMN_SPAN
24}
25
26/// Configuration for processing files (rules, generator, bundling).
27#[derive(Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct Configuration {
30    #[serde(alias = "process", default = "get_default_rules")]
31    rules: Vec<Box<dyn Rule>>,
32    #[serde(default, deserialize_with = "crate::utils::string_or_struct")]
33    generator: GeneratorParameters,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    bundle: Option<BundleConfiguration>,
36    #[serde(default, skip)]
37    location: Option<PathBuf>,
38    #[serde(
39        default,
40        skip_serializing_if = "Vec::is_empty",
41        deserialize_with = "deserialize_one_or_many"
42    )]
43    apply_to_files: Vec<FilterPattern>,
44    #[serde(
45        default,
46        skip_serializing_if = "Vec::is_empty",
47        deserialize_with = "deserialize_one_or_many"
48    )]
49    skip_files: Vec<FilterPattern>,
50}
51
52impl Configuration {
53    /// Creates a configuration object without any rules and with the default generator.
54    pub fn empty() -> Self {
55        Self {
56            rules: Vec::new(),
57            generator: GeneratorParameters::default(),
58            bundle: None,
59            location: None,
60            apply_to_files: Vec::new(),
61            skip_files: Vec::new(),
62        }
63    }
64
65    /// Sets the generator parameters for this configuration.
66    #[inline]
67    pub fn with_generator(mut self, generator: GeneratorParameters) -> Self {
68        self.generator = generator;
69        self
70    }
71
72    /// Sets the generator parameters for this configuration.
73    #[inline]
74    pub fn set_generator(&mut self, generator: GeneratorParameters) {
75        self.generator = generator;
76    }
77
78    /// Adds a rule to this configuration.
79    #[inline]
80    pub fn with_rule(mut self, rule: impl Into<Box<dyn Rule>>) -> Self {
81        self.push_rule(rule);
82        self
83    }
84
85    /// Sets the bundle configuration for this configuration.
86    #[inline]
87    pub fn with_bundle_configuration(mut self, configuration: BundleConfiguration) -> Self {
88        self.bundle = Some(configuration);
89        self
90    }
91
92    /// Sets the location of this configuration.
93    #[inline]
94    pub fn with_location(mut self, location: impl Into<PathBuf>) -> Self {
95        self.location = Some(location.into());
96        self
97    }
98
99    /// Adds a rule to this configuration.
100    #[inline]
101    pub fn push_rule(&mut self, rule: impl Into<Box<dyn Rule>>) {
102        self.rules.push(rule.into());
103    }
104
105    /// Adds a glob pattern so that rules will be only applied to files matching it.
106    /// Returns an error if the pattern is invalid.
107    pub fn with_apply_to_filter(mut self, apply_to_files: &str) -> Result<Self, DarkluaError> {
108        self.push_apply_to_filter(apply_to_files)?;
109        Ok(self)
110    }
111
112    /// Adds a glob pattern so that rules will be only applied to files matching it.
113    /// Returns an error if the pattern is invalid.
114    pub fn push_apply_to_filter(&mut self, apply_to_files: &str) -> Result<(), DarkluaError> {
115        let pattern = FilterPattern::new(apply_to_files.to_owned())?;
116        self.apply_to_files.push(pattern);
117        Ok(())
118    }
119
120    /// Adds a glob pattern so that rules will be skipped for files matching it.
121    /// Returns an error if the pattern is invalid.
122    pub fn with_skip_filter(mut self, skip_files: &str) -> Result<Self, DarkluaError> {
123        self.push_skip_filter(skip_files)?;
124        Ok(self)
125    }
126
127    /// Adds a glob pattern so that rules will be skipped for files matching it.
128    /// Returns an error if the pattern is invalid.
129    pub fn push_skip_filter(&mut self, skip_files: &str) -> Result<(), DarkluaError> {
130        let pattern = FilterPattern::new(skip_files.to_owned())?;
131        self.skip_files.push(pattern);
132        Ok(())
133    }
134
135    #[inline]
136    pub(crate) fn rules<'a, 'b: 'a>(&'b self) -> impl Iterator<Item = &'a dyn Rule> {
137        self.rules.iter().map(AsRef::as_ref)
138    }
139
140    #[inline]
141    pub(crate) fn build_parser(&self) -> Parser {
142        self.generator.build_parser()
143    }
144
145    #[inline]
146    pub(crate) fn generate_lua(&self, block: &Block, code: &str) -> String {
147        self.generator.generate_lua(block, code)
148    }
149
150    pub(crate) fn bundle(&self) -> Option<Bundler> {
151        if let Some(bundle_config) = self.bundle.as_ref() {
152            let bundler = Bundler::new(
153                self.build_parser(),
154                bundle_config.require_mode().clone(),
155                bundle_config.excludes(),
156            )
157            .with_modules_identifier(bundle_config.modules_identifier());
158            Some(bundler)
159        } else {
160            None
161        }
162    }
163
164    #[inline]
165    pub(crate) fn rules_len(&self) -> usize {
166        self.rules.len()
167    }
168
169    #[inline]
170    pub(crate) fn location(&self) -> Option<&Path> {
171        self.location.as_deref()
172    }
173
174    pub(crate) fn should_apply_rule(&self, path: &Path) -> bool {
175        if !self.apply_to_files.is_empty() && self.apply_to_files.iter().all(|f| !f.matches(path)) {
176            return false;
177        }
178
179        if !self.skip_files.is_empty() && self.skip_files.iter().any(|f| f.matches(path)) {
180            return false;
181        }
182
183        true
184    }
185}
186
187impl Default for Configuration {
188    fn default() -> Self {
189        Self {
190            rules: get_default_rules(),
191            generator: Default::default(),
192            bundle: None,
193            location: None,
194            apply_to_files: Vec::new(),
195            skip_files: Vec::new(),
196        }
197    }
198}
199
200impl std::fmt::Debug for Configuration {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.debug_struct("Config")
203            .field("generator", &self.generator)
204            .field(
205                "rules",
206                &self
207                    .rules
208                    .iter()
209                    .map(|rule| {
210                        json5::to_string(rule)
211                            .ok()
212                            .unwrap_or_else(|| rule.get_name().to_owned())
213                    })
214                    .collect::<Vec<_>>()
215                    .join(", "),
216            )
217            .finish()
218    }
219}
220
221/// Parameters for configuring the Lua code generator.
222///
223/// This enum defines different modes for generating Lua code, each with its own
224/// formatting characteristics.
225#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "name")]
227pub enum GeneratorParameters {
228    /// Retains the original line structure of the input code.
229    #[serde(alias = "retain-lines")]
230    #[default]
231    RetainLines,
232    /// Generates dense, compact code with a specified column span.
233    Dense {
234        /// The maximum number of characters per line.
235        #[serde(default = "get_default_column_span")]
236        column_span: usize,
237    },
238    /// Attempts to generate readable code, with a specified column span.
239    Readable {
240        /// The maximum number of characters per line.
241        #[serde(default = "get_default_column_span")]
242        column_span: usize,
243    },
244}
245
246impl GeneratorParameters {
247    /// Creates a new dense generator with default column span.
248    pub fn default_dense() -> Self {
249        Self::Dense {
250            column_span: DEFAULT_COLUMN_SPAN,
251        }
252    }
253
254    /// Creates a new readable generator with default column span.
255    pub fn default_readable() -> Self {
256        Self::Readable {
257            column_span: DEFAULT_COLUMN_SPAN,
258        }
259    }
260
261    fn generate_lua(&self, block: &Block, code: &str) -> String {
262        match self {
263            Self::RetainLines => {
264                let mut generator = TokenBasedLuaGenerator::new(code);
265                generator.write_block(block);
266                generator.into_string()
267            }
268            Self::Dense { column_span } => {
269                let mut generator = DenseLuaGenerator::new(*column_span);
270                generator.write_block(block);
271                generator.into_string()
272            }
273            Self::Readable { column_span } => {
274                let mut generator = ReadableLuaGenerator::new(*column_span);
275                generator.write_block(block);
276                generator.into_string()
277            }
278        }
279    }
280
281    fn build_parser(&self) -> Parser {
282        match self {
283            Self::RetainLines => Parser::default().preserve_tokens(),
284            Self::Dense { .. } | Self::Readable { .. } => Parser::default(),
285        }
286    }
287}
288
289impl FromStr for GeneratorParameters {
290    type Err = String;
291
292    fn from_str(s: &str) -> Result<Self, Self::Err> {
293        Ok(match s {
294            // keep "retain-lines" for back-compatibility
295            "retain_lines" | "retain-lines" => Self::RetainLines,
296            "dense" => Self::Dense {
297                column_span: DEFAULT_COLUMN_SPAN,
298            },
299            "readable" => Self::Readable {
300                column_span: DEFAULT_COLUMN_SPAN,
301            },
302            _ => return Err(format!("invalid generator name `{}`", s)),
303        })
304    }
305}
306
307/// Configuration for bundling modules.
308///
309/// This struct defines how modules should be bundled together, including
310/// how requires are handled.
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
312#[serde(deny_unknown_fields, rename_all = "snake_case")]
313pub struct BundleConfiguration {
314    #[serde(deserialize_with = "crate::utils::string_or_struct")]
315    require_mode: BundleRequireMode,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    modules_identifier: Option<String>,
318    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
319    excludes: HashSet<String>,
320}
321
322impl BundleConfiguration {
323    /// Creates a new bundle configuration with the specified require mode.
324    pub fn new(require_mode: impl Into<BundleRequireMode>) -> Self {
325        Self {
326            require_mode: require_mode.into(),
327            modules_identifier: None,
328            excludes: Default::default(),
329        }
330    }
331
332    /// Sets the modules identifier for this bundle configuration.
333    pub fn with_modules_identifier(mut self, modules_identifier: impl Into<String>) -> Self {
334        self.modules_identifier = Some(modules_identifier.into());
335        self
336    }
337
338    /// Adds a module to exclude from bundling.
339    pub fn with_exclude(mut self, exclude: impl Into<String>) -> Self {
340        self.excludes.insert(exclude.into());
341        self
342    }
343
344    pub(crate) fn require_mode(&self) -> &BundleRequireMode {
345        &self.require_mode
346    }
347
348    pub(crate) fn modules_identifier(&self) -> &str {
349        self.modules_identifier
350            .as_ref()
351            .map(AsRef::as_ref)
352            .unwrap_or("__DARKLUA_BUNDLE_MODULES")
353    }
354
355    pub(crate) fn excludes(&self) -> impl Iterator<Item = &str> {
356        self.excludes.iter().map(AsRef::as_ref)
357    }
358}
359
360#[cfg(test)]
361mod test {
362    use super::*;
363
364    mod generator_parameters {
365        use super::*;
366
367        #[test]
368        fn deserialize_retain_lines_params() {
369            let config: Configuration =
370                json5::from_str("{ generator: { name: 'retain_lines' } }").unwrap();
371
372            pretty_assertions::assert_eq!(config.generator, GeneratorParameters::RetainLines);
373        }
374
375        #[test]
376        fn deserialize_retain_lines_params_deprecated() {
377            let config: Configuration =
378                json5::from_str("{ generator: { name: 'retain-lines' } }").unwrap();
379
380            pretty_assertions::assert_eq!(config.generator, GeneratorParameters::RetainLines);
381        }
382
383        #[test]
384        fn deserialize_dense_params() {
385            let config: Configuration = json5::from_str("{ generator: { name: 'dense' }}").unwrap();
386
387            pretty_assertions::assert_eq!(
388                config.generator,
389                GeneratorParameters::Dense {
390                    column_span: DEFAULT_COLUMN_SPAN
391                }
392            );
393        }
394
395        #[test]
396        fn deserialize_dense_params_with_column_span() {
397            let config: Configuration =
398                json5::from_str("{ generator: { name: 'dense', column_span: 110 } }").unwrap();
399
400            pretty_assertions::assert_eq!(
401                config.generator,
402                GeneratorParameters::Dense { column_span: 110 }
403            );
404        }
405
406        #[test]
407        fn deserialize_readable_params() {
408            let config: Configuration =
409                json5::from_str("{ generator: { name: 'readable' } }").unwrap();
410
411            pretty_assertions::assert_eq!(
412                config.generator,
413                GeneratorParameters::Readable {
414                    column_span: DEFAULT_COLUMN_SPAN
415                }
416            );
417        }
418
419        #[test]
420        fn deserialize_readable_params_with_column_span() {
421            let config: Configuration =
422                json5::from_str("{ generator: { name: 'readable', column_span: 110 }}").unwrap();
423
424            pretty_assertions::assert_eq!(
425                config.generator,
426                GeneratorParameters::Readable { column_span: 110 }
427            );
428        }
429
430        #[test]
431        fn deserialize_retain_lines_params_as_string() {
432            let config: Configuration = json5::from_str("{generator: 'retain_lines'}").unwrap();
433
434            pretty_assertions::assert_eq!(config.generator, GeneratorParameters::RetainLines);
435        }
436
437        #[test]
438        fn deserialize_dense_params_as_string() {
439            let config: Configuration = json5::from_str("{generator: 'dense'}").unwrap();
440
441            pretty_assertions::assert_eq!(
442                config.generator,
443                GeneratorParameters::Dense {
444                    column_span: DEFAULT_COLUMN_SPAN
445                }
446            );
447        }
448
449        #[test]
450        fn deserialize_readable_params_as_string() {
451            let config: Configuration = json5::from_str("{generator: 'readable'}").unwrap();
452
453            pretty_assertions::assert_eq!(
454                config.generator,
455                GeneratorParameters::Readable {
456                    column_span: DEFAULT_COLUMN_SPAN
457                }
458            );
459        }
460
461        #[test]
462        fn deserialize_unknown_generator_name() {
463            let result: Result<Configuration, _> = json5::from_str("{generator: 'oops'}");
464
465            insta::assert_snapshot!(
466                result.expect_err("deserialization should fail").to_string(),
467                @"invalid generator name `oops` at line 1 column 13"
468            );
469        }
470    }
471
472    mod bundle_configuration {
473        use crate::rules::require::PathRequireMode;
474
475        use super::*;
476
477        #[test]
478        fn deserialize_path_require_mode_as_string() {
479            let config: Configuration =
480                json5::from_str("{ bundle: { require_mode: 'path' } }").unwrap();
481
482            pretty_assertions::assert_eq!(
483                config.bundle.unwrap(),
484                BundleConfiguration::new(PathRequireMode::default())
485            );
486        }
487
488        #[test]
489        fn deserialize_path_require_mode_as_object() {
490            let config: Configuration =
491                json5::from_str("{bundle: { require_mode: { name: 'path' } } }").unwrap();
492
493            pretty_assertions::assert_eq!(
494                config.bundle.unwrap(),
495                BundleConfiguration::new(PathRequireMode::default())
496            );
497        }
498
499        #[test]
500        fn deserialize_path_require_mode_with_custom_module_folder_name() {
501            let config: Configuration = json5::from_str(
502                "{bundle: { require_mode: { name: 'path', module_folder_name: '__INIT__' } } }",
503            )
504            .unwrap();
505
506            pretty_assertions::assert_eq!(
507                config.bundle.unwrap(),
508                BundleConfiguration::new(PathRequireMode::new("__INIT__"))
509            );
510        }
511
512        #[test]
513        fn deserialize_path_require_mode_with_custom_module_identifier() {
514            let config: Configuration =
515                json5::from_str("{bundle: { require_mode: 'path', modules_identifier: '__M' } }")
516                    .unwrap();
517
518            pretty_assertions::assert_eq!(
519                config.bundle.unwrap(),
520                BundleConfiguration::new(PathRequireMode::default()).with_modules_identifier("__M")
521            );
522        }
523
524        #[test]
525        fn deserialize_path_require_mode_with_custom_module_identifier_and_module_folder_name() {
526            let config: Configuration = json5::from_str(
527                "{bundle: { require_mode: { name: 'path', module_folder_name: '__INIT__' }, modules_identifier: '__M' } }",
528            )
529            .unwrap();
530
531            pretty_assertions::assert_eq!(
532                config.bundle.unwrap(),
533                BundleConfiguration::new(PathRequireMode::new("__INIT__"))
534                    .with_modules_identifier("__M")
535            );
536        }
537
538        #[test]
539        fn deserialize_path_require_mode_with_excludes() {
540            let config: Configuration = json5::from_str(
541                "{bundle: { require_mode: { name: 'path' }, excludes: ['@lune', 'secrets'] } }",
542            )
543            .unwrap();
544
545            pretty_assertions::assert_eq!(
546                config.bundle.unwrap(),
547                BundleConfiguration::new(PathRequireMode::default())
548                    .with_exclude("@lune")
549                    .with_exclude("secrets")
550            );
551        }
552
553        #[test]
554        fn deserialize_unknown_require_mode_name() {
555            let result: Result<Configuration, _> =
556                json5::from_str("{bundle: { require_mode: 'oops' } }");
557
558            insta::assert_snapshot!(
559                result.expect_err("deserialization should fail").to_string(),
560                @"invalid require mode `oops` at line 1 column 26"
561            );
562        }
563    }
564}