1#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
13#[doc(hidden)]
14pub mod editorconfig;
15pub mod file;
16#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
17mod legacy;
18pub use file::default_config_template;
20#[cfg(feature = "cli")]
21pub use file::{
22 default_config_template_for, generate_json_schema, render_effective_config, DumpConfigFormat,
23};
24#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
25pub use legacy::convert_legacy_config_files;
26
27use std::collections::HashMap;
28
29use regex::Regex;
30use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
35#[serde(rename_all = "lowercase")]
36pub enum CaseStyle {
37 Lower,
39 #[default]
41 Upper,
42 Unchanged,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
49#[serde(rename_all = "lowercase")]
50pub enum LineEnding {
51 #[default]
53 Unix,
54 Windows,
56 Auto,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
63#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
64#[serde(rename_all = "kebab-case")]
65pub enum FractionalTabPolicy {
66 #[default]
68 UseSpace,
69 RoundUp,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
90#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
91#[serde(rename_all = "lowercase")]
92pub enum DangleAlign {
93 #[default]
95 Prefix,
96 Open,
98 Close,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct Config {
136 pub disable: bool,
139
140 pub line_ending: LineEnding,
143
144 pub line_width: usize,
147 pub tab_size: usize,
150 pub use_tabchars: bool,
152 pub fractional_tab_policy: FractionalTabPolicy,
155 pub max_empty_lines: usize,
157 pub max_lines_hwrap: usize,
160 pub max_pargs_hwrap: usize,
163 pub max_subgroups_hwrap: usize,
165 pub max_rows_cmdline: usize,
168 pub always_wrap: Vec<String>,
171 pub require_valid_layout: bool,
174 pub wrap_after_first_arg: bool,
179 pub enable_sort: bool,
182 pub autosort: bool,
187
188 pub dangle_parens: bool,
191 pub dangle_align: DangleAlign,
193 pub min_prefix_chars: usize,
196 pub max_prefix_chars: usize,
199 pub separate_ctrl_name_with_space: bool,
201 pub separate_fn_name_with_space: bool,
203
204 pub command_case: CaseStyle,
207 pub keyword_case: CaseStyle,
209
210 pub enable_markup: bool,
214 pub first_comment_is_literal: bool,
216 pub literal_comment_pattern: String,
218 pub bullet_char: String,
220 pub enum_char: String,
222 pub fence_pattern: String,
224 pub ruler_pattern: String,
226 pub hashruler_min_length: usize,
228 pub canonicalize_hashrulers: bool,
230 pub explicit_trailing_pattern: String,
234
235 pub per_command_overrides: HashMap<String, PerCommandConfig>,
238
239 #[serde(default)]
244 pub experimental: Experimental,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
253#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
254#[serde(default)]
255#[non_exhaustive]
256pub struct Experimental {
257 }
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
264#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
265#[serde(deny_unknown_fields)]
266pub struct PerCommandConfig {
267 pub command_case: Option<CaseStyle>,
269 pub keyword_case: Option<CaseStyle>,
271 pub line_width: Option<usize>,
273 pub tab_size: Option<usize>,
275 pub dangle_parens: Option<bool>,
277 pub dangle_align: Option<DangleAlign>,
279 #[serde(rename = "max_hanging_wrap_positional_args")]
282 pub max_pargs_hwrap: Option<usize>,
283 #[serde(rename = "max_hanging_wrap_groups")]
285 pub max_subgroups_hwrap: Option<usize>,
286 pub wrap_after_first_arg: Option<bool>,
288}
289
290impl Default for Config {
291 fn default() -> Self {
292 Self {
293 disable: false,
294 line_ending: LineEnding::Unix,
295 line_width: 80,
296 tab_size: 2,
297 use_tabchars: false,
298 fractional_tab_policy: FractionalTabPolicy::UseSpace,
299 max_empty_lines: 1,
300 max_lines_hwrap: 2,
301 max_pargs_hwrap: 6,
302 max_subgroups_hwrap: 2,
303 max_rows_cmdline: 2,
304 always_wrap: Vec::new(),
305 require_valid_layout: false,
306 wrap_after_first_arg: false,
307 enable_sort: false,
308 autosort: false,
309 dangle_parens: false,
310 dangle_align: DangleAlign::Prefix,
311 min_prefix_chars: 4,
312 max_prefix_chars: 10,
313 separate_ctrl_name_with_space: false,
314 separate_fn_name_with_space: false,
315 command_case: CaseStyle::Lower,
316 keyword_case: CaseStyle::Upper,
317 enable_markup: true,
318 first_comment_is_literal: true,
319 literal_comment_pattern: String::new(),
320 bullet_char: "*".to_string(),
321 enum_char: ".".to_string(),
322 fence_pattern: DEFAULT_FENCE_PATTERN.to_string(),
323 ruler_pattern: DEFAULT_RULER_PATTERN.to_string(),
324 hashruler_min_length: 10,
325 canonicalize_hashrulers: true,
326 explicit_trailing_pattern: DEFAULT_EXPLICIT_TRAILING_PATTERN.to_string(),
327 per_command_overrides: HashMap::new(),
328 experimental: Experimental::default(),
329 }
330 }
331}
332
333const CONTROL_FLOW_COMMANDS: &[&str] = &[
335 "if",
336 "elseif",
337 "else",
338 "endif",
339 "foreach",
340 "endforeach",
341 "while",
342 "endwhile",
343 "break",
344 "continue",
345 "return",
346 "block",
347 "endblock",
348];
349
350const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
353
354impl Config {
355 pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
358 let lower = command_name.to_ascii_lowercase();
359 let per_cmd = self.per_command_overrides.get(&lower);
360
361 let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
362 self.separate_ctrl_name_with_space
363 } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
364 self.separate_fn_name_with_space
365 } else {
366 false
367 };
368
369 CommandConfig {
370 global: self,
371 per_cmd,
372 space_before_paren,
373 }
374 }
375
376 pub fn apply_command_case(&self, name: &str) -> String {
378 apply_case(self.command_case, name)
379 }
380
381 pub fn apply_keyword_case(&self, keyword: &str) -> String {
383 apply_case(self.keyword_case, keyword)
384 }
385
386 pub fn indent_str(&self) -> String {
388 if self.use_tabchars {
389 "\t".to_string()
390 } else {
391 " ".repeat(self.tab_size)
392 }
393 }
394
395 pub fn validate_patterns(&self) -> Result<(), String> {
400 if self.has_default_regex_patterns() {
405 return Ok(());
406 }
407 let patterns = [
408 ("literal_comment_pattern", &self.literal_comment_pattern),
409 ("explicit_trailing_pattern", &self.explicit_trailing_pattern),
410 ("fence_pattern", &self.fence_pattern),
411 ("ruler_pattern", &self.ruler_pattern),
412 ];
413 for (name, pattern) in &patterns {
414 if !pattern.is_empty() {
415 if let Err(err) = Regex::new(pattern) {
416 return Err(format!("invalid regex in {name}: {err}"));
417 }
418 }
419 }
420 Ok(())
421 }
422
423 fn has_default_regex_patterns(&self) -> bool {
424 self.literal_comment_pattern.is_empty()
425 && self.explicit_trailing_pattern == DEFAULT_EXPLICIT_TRAILING_PATTERN
426 && self.fence_pattern == DEFAULT_FENCE_PATTERN
427 && self.ruler_pattern == DEFAULT_RULER_PATTERN
428 }
429
430 pub(crate) fn compiled_patterns(&self) -> Result<CompiledPatterns, String> {
435 if self.literal_comment_pattern.is_empty()
439 && self.explicit_trailing_pattern == DEFAULT_EXPLICIT_TRAILING_PATTERN
440 {
441 return Ok(CompiledPatterns {
442 literal_comment: None,
443 explicit_trailing: Some(default_explicit_trailing_regex().clone()),
444 });
445 }
446 Ok(CompiledPatterns {
447 literal_comment: compile_optional(
448 "literal_comment_pattern",
449 &self.literal_comment_pattern,
450 )?,
451 explicit_trailing: compile_optional(
452 "explicit_trailing_pattern",
453 &self.explicit_trailing_pattern,
454 )?,
455 })
456 }
457}
458
459const DEFAULT_EXPLICIT_TRAILING_PATTERN: &str = "#<";
460const DEFAULT_FENCE_PATTERN: &str = r"^\s*[`~]{3}[^`\n]*$";
461const DEFAULT_RULER_PATTERN: &str = r"^[^\w\s]{3}.*[^\w\s]{3}$";
462
463fn default_explicit_trailing_regex() -> &'static Regex {
464 static CACHE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
465 CACHE.get_or_init(|| Regex::new(DEFAULT_EXPLICIT_TRAILING_PATTERN).expect("default regex"))
466}
467
468fn compile_optional(name: &str, pattern: &str) -> Result<Option<Regex>, String> {
469 if pattern.is_empty() {
470 Ok(None)
471 } else {
472 Regex::new(pattern)
473 .map(Some)
474 .map_err(|err| format!("invalid regex in {name}: {err}"))
475 }
476}
477
478pub(crate) struct CompiledPatterns {
480 pub(crate) literal_comment: Option<Regex>,
482 #[allow(dead_code)]
486 pub(crate) explicit_trailing: Option<Regex>,
487}
488
489#[derive(Debug)]
492pub struct CommandConfig<'a> {
493 global: &'a Config,
495 per_cmd: Option<&'a PerCommandConfig>,
496 space_before_paren: bool,
498}
499
500impl CommandConfig<'_> {
501 pub fn space_before_paren(&self) -> bool {
503 self.space_before_paren
504 }
505
506 pub(crate) fn global(&self) -> &Config {
507 self.global
508 }
509
510 pub fn line_width(&self) -> usize {
512 self.per_cmd
513 .and_then(|p| p.line_width)
514 .unwrap_or(self.global.line_width)
515 }
516
517 pub fn tab_size(&self) -> usize {
519 self.per_cmd
520 .and_then(|p| p.tab_size)
521 .unwrap_or(self.global.tab_size)
522 }
523
524 pub fn dangle_parens(&self) -> bool {
526 self.per_cmd
527 .and_then(|p| p.dangle_parens)
528 .unwrap_or(self.global.dangle_parens)
529 }
530
531 pub fn dangle_align(&self) -> DangleAlign {
533 self.per_cmd
534 .and_then(|p| p.dangle_align)
535 .unwrap_or(self.global.dangle_align)
536 }
537
538 pub fn command_case(&self) -> CaseStyle {
540 self.per_cmd
541 .and_then(|p| p.command_case)
542 .unwrap_or(self.global.command_case)
543 }
544
545 pub fn keyword_case(&self) -> CaseStyle {
547 self.per_cmd
548 .and_then(|p| p.keyword_case)
549 .unwrap_or(self.global.keyword_case)
550 }
551
552 pub fn max_pargs_hwrap(&self) -> usize {
555 self.per_cmd
556 .and_then(|p| p.max_pargs_hwrap)
557 .unwrap_or(self.global.max_pargs_hwrap)
558 }
559
560 pub fn max_subgroups_hwrap(&self) -> usize {
562 self.per_cmd
563 .and_then(|p| p.max_subgroups_hwrap)
564 .unwrap_or(self.global.max_subgroups_hwrap)
565 }
566
567 pub fn wrap_after_first_arg(&self, spec_value: Option<bool>) -> bool {
572 self.per_cmd
573 .and_then(|p| p.wrap_after_first_arg)
574 .or(spec_value)
575 .unwrap_or(self.global.wrap_after_first_arg)
576 }
577
578 pub fn indent_str(&self) -> String {
580 if self.global.use_tabchars {
581 "\t".to_string()
582 } else {
583 " ".repeat(self.tab_size())
584 }
585 }
586}
587
588fn apply_case(style: CaseStyle, s: &str) -> String {
589 match style {
590 CaseStyle::Lower => s.to_ascii_lowercase(),
591 CaseStyle::Upper => s.to_ascii_uppercase(),
592 CaseStyle::Unchanged => s.to_string(),
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
603 fn for_command_control_flow_sets_space_before_paren() {
604 let config = Config {
605 separate_ctrl_name_with_space: true,
606 ..Config::default()
607 };
608 for cmd in ["if", "elseif", "foreach", "while", "return"] {
609 let cc = config.for_command(cmd);
610 assert!(
611 cc.space_before_paren(),
612 "{cmd} should have space_before_paren=true"
613 );
614 }
615 }
616
617 #[test]
618 fn for_command_fn_definition_sets_space_before_paren() {
619 let config = Config {
620 separate_fn_name_with_space: true,
621 ..Config::default()
622 };
623 for cmd in ["function", "endfunction", "macro", "endmacro"] {
624 let cc = config.for_command(cmd);
625 assert!(
626 cc.space_before_paren(),
627 "{cmd} should have space_before_paren=true"
628 );
629 }
630 }
631
632 #[test]
633 fn for_command_regular_command_no_space_before_paren() {
634 let config = Config {
635 separate_ctrl_name_with_space: true,
636 separate_fn_name_with_space: true,
637 ..Config::default()
638 };
639 let cc = config.for_command("message");
640 assert!(
641 !cc.space_before_paren(),
642 "message should not have space_before_paren"
643 );
644 }
645
646 #[test]
647 fn for_command_lookup_is_case_insensitive() {
648 let mut overrides = HashMap::new();
649 overrides.insert(
650 "message".to_string(),
651 PerCommandConfig {
652 line_width: Some(120),
653 ..Default::default()
654 },
655 );
656 let config = Config {
657 per_command_overrides: overrides,
658 ..Config::default()
659 };
660 assert_eq!(config.for_command("MESSAGE").line_width(), 120);
662 }
663
664 #[test]
667 fn command_config_returns_global_defaults_when_no_override() {
668 let config = Config::default();
669 let cc = config.for_command("set");
670 assert_eq!(cc.line_width(), config.line_width);
671 assert_eq!(cc.tab_size(), config.tab_size);
672 assert_eq!(cc.dangle_parens(), config.dangle_parens);
673 assert_eq!(cc.command_case(), config.command_case);
674 assert_eq!(cc.keyword_case(), config.keyword_case);
675 assert_eq!(cc.max_pargs_hwrap(), config.max_pargs_hwrap);
676 assert_eq!(cc.max_subgroups_hwrap(), config.max_subgroups_hwrap);
677 }
678
679 #[test]
680 fn command_config_per_command_overrides_take_effect() {
681 let mut overrides = HashMap::new();
682 overrides.insert(
683 "set".to_string(),
684 PerCommandConfig {
685 line_width: Some(120),
686 tab_size: Some(4),
687 dangle_parens: Some(true),
688 dangle_align: Some(DangleAlign::Open),
689 command_case: Some(CaseStyle::Upper),
690 keyword_case: Some(CaseStyle::Lower),
691 max_pargs_hwrap: Some(10),
692 max_subgroups_hwrap: Some(5),
693 wrap_after_first_arg: None,
694 },
695 );
696 let config = Config {
697 per_command_overrides: overrides,
698 ..Config::default()
699 };
700 let cc = config.for_command("set");
701 assert_eq!(cc.line_width(), 120);
702 assert_eq!(cc.tab_size(), 4);
703 assert!(cc.dangle_parens());
704 assert_eq!(cc.dangle_align(), DangleAlign::Open);
705 assert_eq!(cc.command_case(), CaseStyle::Upper);
706 assert_eq!(cc.keyword_case(), CaseStyle::Lower);
707 assert_eq!(cc.max_pargs_hwrap(), 10);
708 assert_eq!(cc.max_subgroups_hwrap(), 5);
709 }
710
711 #[test]
712 fn indent_str_spaces() {
713 let config = Config {
714 tab_size: 4,
715 use_tabchars: false,
716 ..Config::default()
717 };
718 assert_eq!(config.indent_str(), " ");
719 assert_eq!(config.for_command("set").indent_str(), " ");
720 }
721
722 #[test]
723 fn indent_str_tab() {
724 let config = Config {
725 use_tabchars: true,
726 ..Config::default()
727 };
728 assert_eq!(config.indent_str(), "\t");
729 assert_eq!(config.for_command("set").indent_str(), "\t");
730 }
731
732 #[test]
735 fn apply_command_case_lower() {
736 let config = Config {
737 command_case: CaseStyle::Lower,
738 ..Config::default()
739 };
740 assert_eq!(
741 config.apply_command_case("TARGET_LINK_LIBRARIES"),
742 "target_link_libraries"
743 );
744 }
745
746 #[test]
747 fn apply_command_case_upper() {
748 let config = Config {
749 command_case: CaseStyle::Upper,
750 ..Config::default()
751 };
752 assert_eq!(
753 config.apply_command_case("target_link_libraries"),
754 "TARGET_LINK_LIBRARIES"
755 );
756 }
757
758 #[test]
759 fn apply_command_case_unchanged() {
760 let config = Config {
761 command_case: CaseStyle::Unchanged,
762 ..Config::default()
763 };
764 assert_eq!(
765 config.apply_command_case("Target_Link_Libraries"),
766 "Target_Link_Libraries"
767 );
768 }
769
770 #[test]
771 fn apply_keyword_case_variants() {
772 let config_upper = Config {
773 keyword_case: CaseStyle::Upper,
774 ..Config::default()
775 };
776 assert_eq!(config_upper.apply_keyword_case("public"), "PUBLIC");
777
778 let config_lower = Config {
779 keyword_case: CaseStyle::Lower,
780 ..Config::default()
781 };
782 assert_eq!(config_lower.apply_keyword_case("PUBLIC"), "public");
783 }
784
785 #[test]
788 fn error_layout_too_wide_display() {
789 use crate::error::Error;
790 let err = Error::LayoutTooWide {
791 line_no: 5,
792 width: 95,
793 limit: 80,
794 };
795 let msg = err.to_string();
796 assert!(msg.contains("5"), "should mention line number");
797 assert!(msg.contains("95"), "should mention actual width");
798 assert!(msg.contains("80"), "should mention limit");
799 }
800
801 #[test]
802 fn error_formatter_display() {
803 use crate::error::Error;
804 let err = Error::Formatter("something went wrong".to_string());
805 assert!(err.to_string().contains("something went wrong"));
806 }
807
808 #[test]
811 fn from_files_empty_path_returns_defaults() {
812 let config = Config::from_files(&[]).expect("default config should load");
813 let defaults = Config::default();
814 assert_eq!(
815 config.literal_comment_pattern,
816 defaults.literal_comment_pattern
817 );
818 assert_eq!(
819 config.explicit_trailing_pattern,
820 defaults.explicit_trailing_pattern
821 );
822 assert_eq!(config.fence_pattern, defaults.fence_pattern);
823 assert_eq!(config.ruler_pattern, defaults.ruler_pattern);
824 assert_eq!(config.line_width, defaults.line_width);
825 }
826
827 #[test]
828 fn validate_patterns_accepts_defaults() {
829 let config = Config::default();
830 assert!(
831 config.validate_patterns().is_ok(),
832 "default patterns must pass validation"
833 );
834 }
835
836 #[test]
837 fn validate_patterns_rejects_invalid_custom_pattern() {
838 let config = Config {
839 fence_pattern: "(".to_string(),
840 ..Config::default()
841 };
842 let err = config
843 .validate_patterns()
844 .expect_err("invalid fence_pattern must be rejected");
845 assert!(
846 err.contains("fence_pattern"),
847 "error should identify fence_pattern, got: {err}"
848 );
849 }
850
851 #[test]
852 fn validate_patterns_accepts_valid_custom_pattern() {
853 let config = Config {
854 fence_pattern: r"^\s*[#]{3,}$".to_string(),
855 ..Config::default()
856 };
857 assert!(config.validate_patterns().is_ok());
858 }
859
860 #[test]
861 fn compiled_patterns_uses_cached_default_regex() {
862 let config = Config::default();
863 let compiled = config.compiled_patterns().expect("defaults must compile");
864 assert!(
865 compiled.literal_comment.is_none(),
866 "empty literal_comment_pattern should produce None"
867 );
868 let explicit = compiled
869 .explicit_trailing
870 .expect("default explicit_trailing_pattern should compile to Some");
871 assert!(
872 explicit.is_match("#<"),
873 "default explicit_trailing regex should match the default marker"
874 );
875 }
876
877 #[test]
878 fn compiled_patterns_compiles_custom_literal_comment() {
879 let config = Config {
880 literal_comment_pattern: r"^\s*TODO:".to_string(),
881 ..Config::default()
882 };
883 let compiled = config
884 .compiled_patterns()
885 .expect("custom literal_comment_pattern must compile");
886 let literal = compiled
887 .literal_comment
888 .expect("custom literal_comment_pattern should compile to Some");
889 assert!(literal.is_match(" TODO: fix me"));
890 assert!(!literal.is_match("# regular comment"));
891 }
892
893 #[test]
894 fn compiled_patterns_compiles_custom_explicit_trailing() {
895 let config = Config {
896 explicit_trailing_pattern: r"^#>".to_string(),
897 ..Config::default()
898 };
899 let compiled = config
900 .compiled_patterns()
901 .expect("custom explicit_trailing_pattern must compile");
902 let explicit = compiled
903 .explicit_trailing
904 .expect("custom explicit_trailing_pattern should compile to Some");
905 assert!(explicit.is_match("#>"));
906 assert!(
907 !explicit.is_match("x#<"),
908 "custom pattern must not fall back to the cached default"
909 );
910 }
911
912 #[test]
913 fn compiled_patterns_errors_on_invalid_custom() {
914 let config = Config {
915 literal_comment_pattern: "(".to_string(),
916 ..Config::default()
917 };
918 match config.compiled_patterns() {
919 Ok(_) => panic!("invalid custom pattern must error"),
920 Err(err) => assert!(
921 err.contains("literal_comment_pattern"),
922 "error should identify literal_comment_pattern, got: {err}"
923 ),
924 }
925 }
926}