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    frontend::Loader,
11    generator::{DenseLuaGenerator, LuaGenerator, ReadableLuaGenerator, TokenBasedLuaGenerator},
12    nodes::Block,
13    rules::{
14        bundle::{BundleRequireMode, Bundler},
15        get_default_rules, Rule,
16    },
17    utils::{deserialize_one_or_many, deserialize_vec_of_pairs, FilterPattern},
18    DarkluaError, Parser,
19};
20
21const DEFAULT_COLUMN_SPAN: usize = 80;
22
23fn get_default_column_span() -> usize {
24    DEFAULT_COLUMN_SPAN
25}
26
27/// Configuration for processing files (rules, generator, bundling).
28#[derive(Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Configuration {
31    #[serde(alias = "process", default = "get_default_rules")]
32    rules: Vec<Box<dyn Rule>>,
33    #[serde(default, deserialize_with = "crate::utils::string_or_struct")]
34    generator: GeneratorParameters,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    bundle: Option<BundleConfiguration>,
37    #[serde(default, skip)]
38    location: Option<PathBuf>,
39    #[serde(
40        default,
41        skip_serializing_if = "Vec::is_empty",
42        deserialize_with = "deserialize_one_or_many"
43    )]
44    apply_to_files: Vec<FilterPattern>,
45    #[serde(
46        default,
47        skip_serializing_if = "Vec::is_empty",
48        deserialize_with = "deserialize_one_or_many"
49    )]
50    skip_files: Vec<FilterPattern>,
51    #[serde(flatten)]
52    loaders: LoaderConfiguration,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    lua_extension: Option<LuaExtension>,
55}
56
57impl Configuration {
58    /// Creates a configuration object without any rules and with the default generator.
59    pub fn empty() -> Self {
60        Self {
61            rules: Vec::new(),
62            generator: GeneratorParameters::default(),
63            bundle: None,
64            location: None,
65            apply_to_files: Vec::new(),
66            skip_files: Vec::new(),
67            loaders: Default::default(),
68            lua_extension: None,
69        }
70    }
71
72    /// Sets the generator parameters for this configuration.
73    #[inline]
74    pub fn with_generator(mut self, generator: GeneratorParameters) -> Self {
75        self.generator = generator;
76        self
77    }
78
79    /// Sets the generator parameters for this configuration.
80    #[inline]
81    pub fn set_generator(&mut self, generator: GeneratorParameters) {
82        self.generator = generator;
83    }
84
85    /// Adds a rule to this configuration.
86    #[inline]
87    pub fn with_rule(mut self, rule: impl Into<Box<dyn Rule>>) -> Self {
88        self.push_rule(rule);
89        self
90    }
91
92    /// Sets the bundle configuration for this configuration.
93    #[inline]
94    pub fn with_bundle_configuration(mut self, configuration: BundleConfiguration) -> Self {
95        self.bundle = Some(configuration);
96        self
97    }
98
99    /// Sets the location of this configuration.
100    #[inline]
101    pub fn with_location(mut self, location: impl Into<PathBuf>) -> Self {
102        self.location = Some(location.into());
103        self
104    }
105
106    /// Adds a rule to this configuration.
107    #[inline]
108    pub fn push_rule(&mut self, rule: impl Into<Box<dyn Rule>>) {
109        self.rules.push(rule.into());
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 with_apply_to_filter(mut self, apply_to_files: &str) -> Result<Self, DarkluaError> {
115        self.push_apply_to_filter(apply_to_files)?;
116        Ok(self)
117    }
118
119    /// Adds a glob pattern so that rules will be only applied to files matching it.
120    /// Returns an error if the pattern is invalid.
121    pub fn push_apply_to_filter(&mut self, apply_to_files: &str) -> Result<(), DarkluaError> {
122        let pattern = FilterPattern::new(apply_to_files.to_owned())?;
123        self.apply_to_files.push(pattern);
124        Ok(())
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 with_skip_filter(mut self, skip_files: &str) -> Result<Self, DarkluaError> {
130        self.push_skip_filter(skip_files)?;
131        Ok(self)
132    }
133
134    /// Adds a glob pattern so that rules will be skipped for files matching it.
135    /// Returns an error if the pattern is invalid.
136    pub fn push_skip_filter(&mut self, skip_files: &str) -> Result<(), DarkluaError> {
137        let pattern = FilterPattern::new(skip_files.to_owned())?;
138        self.skip_files.push(pattern);
139        Ok(())
140    }
141
142    /// Attaches a loader to a glob pattern and returns the configuration.
143    pub fn with_loader(mut self, loader: Loader, pattern: &str) -> Result<Self, DarkluaError> {
144        self.add_loader(loader, pattern)?;
145        Ok(self)
146    }
147
148    /// Attaches a loader to a glob pattern.
149    pub fn add_loader(&mut self, loader: Loader, pattern: &str) -> Result<(), DarkluaError> {
150        self.loaders
151            .loaders
152            .push((FilterPattern::new(pattern.to_owned())?, loader));
153        Ok(())
154    }
155
156    /// Clears all registered loaders.
157    pub fn clear_loaders(&mut self) {
158        self.loaders.loaders.clear();
159    }
160
161    #[inline]
162    pub(crate) fn rules<'a, 'b: 'a>(&'b self) -> impl Iterator<Item = &'a dyn Rule> {
163        self.rules.iter().map(AsRef::as_ref)
164    }
165
166    #[inline]
167    pub(crate) fn build_parser(&self) -> Parser {
168        self.generator.build_parser()
169    }
170
171    #[inline]
172    pub(crate) fn generate_lua(&self, block: &Block, code: &str) -> String {
173        self.generator.generate_lua(block, code)
174    }
175
176    pub(crate) fn bundle(&self) -> Option<Bundler> {
177        if let Some(bundle_config) = self.bundle.as_ref() {
178            let bundler = Bundler::new(
179                self.build_parser(),
180                bundle_config.require_mode().clone(),
181                bundle_config.excludes(),
182                self.loaders.clone(),
183            )
184            .with_modules_identifier(bundle_config.modules_identifier());
185            Some(bundler)
186        } else {
187            None
188        }
189    }
190
191    #[inline]
192    pub(crate) fn rules_len(&self) -> usize {
193        self.rules.len()
194    }
195
196    #[inline]
197    pub(crate) fn location(&self) -> Option<&Path> {
198        self.location.as_deref()
199    }
200
201    pub(crate) fn should_apply_rule(&self, path: &Path) -> bool {
202        if !self.apply_to_files.is_empty() && self.apply_to_files.iter().all(|f| !f.matches(path)) {
203            return false;
204        }
205
206        if !self.skip_files.is_empty() && self.skip_files.iter().any(|f| f.matches(path)) {
207            return false;
208        }
209
210        true
211    }
212
213    pub(crate) fn loaders(&self) -> &LoaderConfiguration {
214        &self.loaders
215    }
216
217    pub(crate) fn preferred_lua_extension(&self) -> &'static str {
218        self.lua_extension
219            .as_ref()
220            .unwrap_or(&Default::default())
221            .as_str()
222    }
223}
224
225impl Default for Configuration {
226    fn default() -> Self {
227        Self {
228            rules: get_default_rules(),
229            generator: Default::default(),
230            bundle: None,
231            location: None,
232            apply_to_files: Vec::new(),
233            skip_files: Vec::new(),
234            loaders: Default::default(),
235            lua_extension: None,
236        }
237    }
238}
239
240impl std::fmt::Debug for Configuration {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.debug_struct("Config")
243            .field("location", &self.location)
244            .field("generator", &self.generator)
245            .field("apply_to_files", &self.apply_to_files)
246            .field("skip_files", &self.skip_files)
247            .field("loaders", &self.loaders)
248            .field("bundle", &self.bundle)
249            .field(
250                "rules",
251                &self
252                    .rules
253                    .iter()
254                    .map(|rule| {
255                        json5::to_string(rule)
256                            .ok()
257                            .unwrap_or_else(|| rule.get_name().to_owned())
258                    })
259                    .collect::<Vec<_>>()
260                    .join(", "),
261            )
262            .finish()
263    }
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub(crate) struct LoaderConfiguration {
269    #[serde(
270        default,
271        skip_serializing_if = "Vec::is_empty",
272        deserialize_with = "deserialize_vec_of_pairs"
273    )]
274    loaders: Vec<(FilterPattern, Loader)>,
275}
276
277impl LoaderConfiguration {
278    pub(crate) fn get_loader(&self, path: &Path) -> Loader {
279        self.loaders
280            .iter()
281            .find(|(pattern, _)| pattern.matches(path))
282            .map(|(_, loader)| *loader)
283            .or_else(|| Loader::from_path(path))
284            .unwrap_or(Loader::Skip)
285    }
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub(crate) enum LuaExtension {
291    #[default]
292    Lua,
293    Luau,
294}
295
296impl LuaExtension {
297    const fn as_str(&self) -> &'static str {
298        match self {
299            Self::Lua => "lua",
300            Self::Luau => "luau",
301        }
302    }
303}
304
305/// Parameters for configuring the Lua code generator.
306///
307/// This enum defines different modes for generating Lua code, each with its own
308/// formatting characteristics.
309#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
310#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "name")]
311pub enum GeneratorParameters {
312    /// Retains the original line structure of the input code.
313    #[serde(alias = "retain-lines")]
314    #[default]
315    RetainLines,
316    /// Generates dense, compact code with a specified column span.
317    Dense {
318        /// The maximum number of characters per line.
319        #[serde(
320            default = "get_default_column_span",
321            deserialize_with = "crate::utils::deserialize_usize_from_float"
322        )]
323        column_span: usize,
324    },
325    /// Attempts to generate readable code, with a specified column span.
326    Readable {
327        /// The maximum number of characters per line.
328        #[serde(
329            default = "get_default_column_span",
330            deserialize_with = "crate::utils::deserialize_usize_from_float"
331        )]
332        column_span: usize,
333    },
334}
335
336impl GeneratorParameters {
337    /// Creates a new dense generator with default column span.
338    pub fn default_dense() -> Self {
339        Self::Dense {
340            column_span: DEFAULT_COLUMN_SPAN,
341        }
342    }
343
344    /// Creates a new readable generator with default column span.
345    pub fn default_readable() -> Self {
346        Self::Readable {
347            column_span: DEFAULT_COLUMN_SPAN,
348        }
349    }
350
351    fn generate_lua(&self, block: &Block, code: &str) -> String {
352        match self {
353            Self::RetainLines => {
354                let mut generator = TokenBasedLuaGenerator::new(code);
355                generator.write_block(block);
356                generator.into_string()
357            }
358            Self::Dense { column_span } => {
359                let mut generator = DenseLuaGenerator::new(*column_span);
360                generator.write_block(block);
361                generator.into_string()
362            }
363            Self::Readable { column_span } => {
364                let mut generator = ReadableLuaGenerator::new(*column_span);
365                generator.write_block(block);
366                generator.into_string()
367            }
368        }
369    }
370
371    fn build_parser(&self) -> Parser {
372        match self {
373            Self::RetainLines => Parser::default().preserve_tokens(),
374            Self::Dense { .. } | Self::Readable { .. } => Parser::default(),
375        }
376    }
377}
378
379impl FromStr for GeneratorParameters {
380    type Err = String;
381
382    fn from_str(s: &str) -> Result<Self, Self::Err> {
383        Ok(match s {
384            // keep "retain-lines" for back-compatibility
385            "retain_lines" | "retain-lines" => Self::RetainLines,
386            "dense" => Self::Dense {
387                column_span: DEFAULT_COLUMN_SPAN,
388            },
389            "readable" => Self::Readable {
390                column_span: DEFAULT_COLUMN_SPAN,
391            },
392            _ => return Err(format!("invalid generator name `{}`", s)),
393        })
394    }
395}
396
397/// Configuration for bundling modules.
398///
399/// This struct defines how modules should be bundled together, including
400/// how requires are handled.
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
402#[serde(deny_unknown_fields, rename_all = "snake_case")]
403pub struct BundleConfiguration {
404    #[serde(deserialize_with = "crate::utils::string_or_struct")]
405    require_mode: BundleRequireMode,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    modules_identifier: Option<String>,
408    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
409    excludes: HashSet<String>,
410}
411
412impl BundleConfiguration {
413    /// Creates a new bundle configuration with the specified require mode.
414    pub fn new(require_mode: impl Into<BundleRequireMode>) -> Self {
415        Self {
416            require_mode: require_mode.into(),
417            modules_identifier: None,
418            excludes: Default::default(),
419        }
420    }
421
422    /// Sets the modules identifier for this bundle configuration.
423    pub fn with_modules_identifier(mut self, modules_identifier: impl Into<String>) -> Self {
424        self.modules_identifier = Some(modules_identifier.into());
425        self
426    }
427
428    /// Adds a module to exclude from bundling.
429    pub fn with_exclude(mut self, exclude: impl Into<String>) -> Self {
430        self.excludes.insert(exclude.into());
431        self
432    }
433
434    pub(crate) fn require_mode(&self) -> &BundleRequireMode {
435        &self.require_mode
436    }
437
438    pub(crate) fn modules_identifier(&self) -> &str {
439        self.modules_identifier
440            .as_ref()
441            .map(AsRef::as_ref)
442            .unwrap_or("__DARKLUA_BUNDLE_MODULES")
443    }
444
445    pub(crate) fn excludes(&self) -> impl Iterator<Item = &str> {
446        self.excludes.iter().map(AsRef::as_ref)
447    }
448}
449
450#[cfg(test)]
451mod test {
452    use super::*;
453
454    mod generator_parameters {
455        use super::*;
456
457        #[test]
458        fn deserialize_retain_lines_params() {
459            let config: Configuration =
460                json5::from_str("{ generator: { name: 'retain_lines' } }").unwrap();
461
462            pretty_assertions::assert_eq!(config.generator, GeneratorParameters::RetainLines);
463        }
464
465        #[test]
466        fn deserialize_retain_lines_params_deprecated() {
467            let config: Configuration =
468                json5::from_str("{ generator: { name: 'retain-lines' } }").unwrap();
469
470            pretty_assertions::assert_eq!(config.generator, GeneratorParameters::RetainLines);
471        }
472
473        #[test]
474        fn deserialize_dense_params() {
475            let config: Configuration = json5::from_str("{ generator: { name: 'dense' }}").unwrap();
476
477            pretty_assertions::assert_eq!(
478                config.generator,
479                GeneratorParameters::Dense {
480                    column_span: DEFAULT_COLUMN_SPAN
481                }
482            );
483        }
484
485        #[test]
486        fn deserialize_dense_params_with_column_span() {
487            let config: Configuration =
488                json5::from_str("{ generator: { name: 'dense', column_span: 110 } }").unwrap();
489
490            pretty_assertions::assert_eq!(
491                config.generator,
492                GeneratorParameters::Dense { column_span: 110 }
493            );
494        }
495
496        #[test]
497        fn deserialize_dense_params_with_float_column_span() {
498            let config: Configuration =
499                json5::from_str("{ generator: { name: 'dense', column_span: 120.0 } }").unwrap();
500
501            pretty_assertions::assert_eq!(
502                config.generator,
503                GeneratorParameters::Dense { column_span: 120 }
504            );
505        }
506
507        #[test]
508        fn deserialize_dense_params_with_infinite_column_span() {
509            let config: Configuration =
510                json5::from_str("{ generator: { name: 'dense', column_span: Infinity } }").unwrap();
511
512            pretty_assertions::assert_eq!(
513                config.generator,
514                GeneratorParameters::Dense {
515                    column_span: usize::MAX
516                }
517            );
518        }
519
520        #[test]
521        fn deserialize_readable_params() {
522            let config: Configuration =
523                json5::from_str("{ generator: { name: 'readable' } }").unwrap();
524
525            pretty_assertions::assert_eq!(
526                config.generator,
527                GeneratorParameters::Readable {
528                    column_span: DEFAULT_COLUMN_SPAN
529                }
530            );
531        }
532
533        #[test]
534        fn deserialize_readable_params_with_column_span() {
535            let config: Configuration =
536                json5::from_str("{ generator: { name: 'readable', column_span: 110 }}").unwrap();
537
538            pretty_assertions::assert_eq!(
539                config.generator,
540                GeneratorParameters::Readable { column_span: 110 }
541            );
542        }
543
544        #[test]
545        fn deserialize_readable_params_with_float_column_span() {
546            let config: Configuration =
547                json5::from_str("{ generator: { name: 'readable', column_span: 120.6 } }").unwrap();
548
549            pretty_assertions::assert_eq!(
550                config.generator,
551                GeneratorParameters::Readable { column_span: 120 }
552            );
553        }
554
555        #[test]
556        fn deserialize_readable_params_with_infinite_column_span() {
557            let config: Configuration =
558                json5::from_str("{ generator: { name: 'readable', column_span: Infinity } }")
559                    .unwrap();
560
561            pretty_assertions::assert_eq!(
562                config.generator,
563                GeneratorParameters::Readable {
564                    column_span: usize::MAX
565                }
566            );
567        }
568        #[test]
569        fn deserialize_retain_lines_params_as_string() {
570            let config: Configuration = json5::from_str("{generator: 'retain_lines'}").unwrap();
571
572            pretty_assertions::assert_eq!(config.generator, GeneratorParameters::RetainLines);
573        }
574
575        #[test]
576        fn deserialize_dense_params_as_string() {
577            let config: Configuration = json5::from_str("{generator: 'dense'}").unwrap();
578
579            pretty_assertions::assert_eq!(
580                config.generator,
581                GeneratorParameters::Dense {
582                    column_span: DEFAULT_COLUMN_SPAN
583                }
584            );
585        }
586
587        #[test]
588        fn deserialize_readable_params_as_string() {
589            let config: Configuration = json5::from_str("{generator: 'readable'}").unwrap();
590
591            pretty_assertions::assert_eq!(
592                config.generator,
593                GeneratorParameters::Readable {
594                    column_span: DEFAULT_COLUMN_SPAN
595                }
596            );
597        }
598
599        #[test]
600        fn deserialize_unknown_generator_name() {
601            let result: Result<Configuration, _> = json5::from_str("{generator: 'oops'}");
602
603            insta::assert_snapshot!(
604                result.expect_err("deserialization should fail").to_string(),
605                @"invalid generator name `oops` at line 1 column 13"
606            );
607        }
608    }
609
610    mod bundle_configuration {
611        use crate::rules::require::PathRequireMode;
612
613        use super::*;
614
615        #[test]
616        fn deserialize_path_require_mode_as_string() {
617            let config: Configuration =
618                json5::from_str("{ bundle: { require_mode: 'path' } }").unwrap();
619
620            pretty_assertions::assert_eq!(
621                config.bundle.unwrap(),
622                BundleConfiguration::new(PathRequireMode::default())
623            );
624        }
625
626        #[test]
627        fn deserialize_path_require_mode_as_object() {
628            let config: Configuration =
629                json5::from_str("{bundle: { require_mode: { name: 'path' } } }").unwrap();
630
631            pretty_assertions::assert_eq!(
632                config.bundle.unwrap(),
633                BundleConfiguration::new(PathRequireMode::default())
634            );
635        }
636
637        #[test]
638        fn deserialize_path_require_mode_with_custom_module_folder_name() {
639            let config: Configuration = json5::from_str(
640                "{bundle: { require_mode: { name: 'path', module_folder_name: '__INIT__' } } }",
641            )
642            .unwrap();
643
644            pretty_assertions::assert_eq!(
645                config.bundle.unwrap(),
646                BundleConfiguration::new(PathRequireMode::new("__INIT__"))
647            );
648        }
649
650        #[test]
651        fn deserialize_path_require_mode_with_custom_module_identifier() {
652            let config: Configuration =
653                json5::from_str("{bundle: { require_mode: 'path', modules_identifier: '__M' } }")
654                    .unwrap();
655
656            pretty_assertions::assert_eq!(
657                config.bundle.unwrap(),
658                BundleConfiguration::new(PathRequireMode::default()).with_modules_identifier("__M")
659            );
660        }
661
662        #[test]
663        fn deserialize_path_require_mode_with_custom_module_identifier_and_module_folder_name() {
664            let config: Configuration = json5::from_str(
665                "{bundle: { require_mode: { name: 'path', module_folder_name: '__INIT__' }, modules_identifier: '__M' } }",
666            )
667            .unwrap();
668
669            pretty_assertions::assert_eq!(
670                config.bundle.unwrap(),
671                BundleConfiguration::new(PathRequireMode::new("__INIT__"))
672                    .with_modules_identifier("__M")
673            );
674        }
675
676        #[test]
677        fn deserialize_path_require_mode_with_excludes() {
678            let config: Configuration = json5::from_str(
679                "{bundle: { require_mode: { name: 'path' }, excludes: ['@lune', 'secrets'] } }",
680            )
681            .unwrap();
682
683            pretty_assertions::assert_eq!(
684                config.bundle.unwrap(),
685                BundleConfiguration::new(PathRequireMode::default())
686                    .with_exclude("@lune")
687                    .with_exclude("secrets")
688            );
689        }
690
691        #[test]
692        fn deserialize_unknown_require_mode_name() {
693            let result: Result<Configuration, _> =
694                json5::from_str("{bundle: { require_mode: 'oops' } }");
695
696            insta::assert_snapshot!(
697                result.expect_err("deserialization should fail").to_string(),
698                @"invalid require mode `oops` at line 1 column 26"
699            );
700        }
701    }
702
703    mod loaders {
704        use super::*;
705
706        #[test]
707        fn deserialize_custom_loaders() {
708            let config: Configuration =
709                json5::from_str("{ loaders: { '**/*.luau': 'luau', '**/*.json': 'json', '**/*.jsonl': 'json_lines' } }").unwrap();
710
711            insta::assert_debug_snapshot!(config.loaders, @r###"
712            LoaderConfiguration {
713                loaders: [
714                    (
715                        FilterPattern {
716                            pattern: "**/*.luau",
717                        },
718                        Luau,
719                    ),
720                    (
721                        FilterPattern {
722                            pattern: "**/*.json",
723                        },
724                        Json,
725                    ),
726                    (
727                        FilterPattern {
728                            pattern: "**/*.jsonl",
729                        },
730                        JsonLines,
731                    ),
732                ],
733            }
734            "###);
735        }
736    }
737}