1pub mod file;
13#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
14mod legacy;
15pub use file::default_config_template;
17#[cfg(feature = "cli")]
18pub use file::{
19 default_config_template_for, generate_json_schema, render_effective_config, DumpConfigFormat,
20};
21#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
22pub use legacy::convert_legacy_config_files;
23
24use std::collections::HashMap;
25
26use regex::Regex;
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
32#[serde(rename_all = "lowercase")]
33pub enum CaseStyle {
34 Lower,
36 #[default]
38 Upper,
39 Unchanged,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
46#[serde(rename_all = "lowercase")]
47pub enum LineEnding {
48 #[default]
50 Unix,
51 Windows,
53 Auto,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
60#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
61#[serde(rename_all = "kebab-case")]
62pub enum FractionalTabPolicy {
63 #[default]
65 UseSpace,
66 RoundUp,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
87#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
88#[serde(rename_all = "lowercase")]
89pub enum DangleAlign {
90 #[default]
92 Prefix,
93 Open,
95 Close,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(default)]
133pub struct Config {
134 pub disable: bool,
137
138 pub line_ending: LineEnding,
141
142 pub line_width: usize,
145 pub tab_size: usize,
148 pub use_tabchars: bool,
150 pub fractional_tab_policy: FractionalTabPolicy,
153 pub max_empty_lines: usize,
155 pub max_lines_hwrap: usize,
158 pub max_pargs_hwrap: usize,
161 pub max_subgroups_hwrap: usize,
163 pub max_rows_cmdline: usize,
166 pub always_wrap: Vec<String>,
169 pub require_valid_layout: bool,
172
173 pub dangle_parens: bool,
176 pub dangle_align: DangleAlign,
178 pub min_prefix_chars: usize,
181 pub max_prefix_chars: usize,
184 pub separate_ctrl_name_with_space: bool,
186 pub separate_fn_name_with_space: bool,
188
189 pub command_case: CaseStyle,
192 pub keyword_case: CaseStyle,
194
195 pub enable_markup: bool,
198 pub reflow_comments: bool,
200 pub first_comment_is_literal: bool,
202 pub literal_comment_pattern: String,
204 pub bullet_char: String,
206 pub enum_char: String,
208 pub fence_pattern: String,
210 pub ruler_pattern: String,
212 pub hashruler_min_length: usize,
214 pub canonicalize_hashrulers: bool,
216 pub explicit_trailing_pattern: String,
220
221 pub per_command_overrides: HashMap<String, PerCommandConfig>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
229#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
230#[serde(deny_unknown_fields)]
231pub struct PerCommandConfig {
232 pub command_case: Option<CaseStyle>,
234 pub keyword_case: Option<CaseStyle>,
236 pub line_width: Option<usize>,
238 pub tab_size: Option<usize>,
240 pub dangle_parens: Option<bool>,
242 pub dangle_align: Option<DangleAlign>,
244 #[serde(rename = "max_hanging_wrap_positional_args")]
247 pub max_pargs_hwrap: Option<usize>,
248 #[serde(rename = "max_hanging_wrap_groups")]
250 pub max_subgroups_hwrap: Option<usize>,
251}
252
253impl Default for Config {
254 fn default() -> Self {
255 Self {
256 disable: false,
257 line_ending: LineEnding::Unix,
258 line_width: 80,
259 tab_size: 2,
260 use_tabchars: false,
261 fractional_tab_policy: FractionalTabPolicy::UseSpace,
262 max_empty_lines: 1,
263 max_lines_hwrap: 2,
264 max_pargs_hwrap: 6,
265 max_subgroups_hwrap: 2,
266 max_rows_cmdline: 2,
267 always_wrap: Vec::new(),
268 require_valid_layout: false,
269 dangle_parens: false,
270 dangle_align: DangleAlign::Prefix,
271 min_prefix_chars: 4,
272 max_prefix_chars: 10,
273 separate_ctrl_name_with_space: false,
274 separate_fn_name_with_space: false,
275 command_case: CaseStyle::Lower,
276 keyword_case: CaseStyle::Upper,
277 enable_markup: true,
278 reflow_comments: false,
279 first_comment_is_literal: true,
280 literal_comment_pattern: String::new(),
281 bullet_char: "*".to_string(),
282 enum_char: ".".to_string(),
283 fence_pattern: r"^\s*[`~]{3}[^`\n]*$".to_string(),
284 ruler_pattern: r"^[^\w\s]{3}.*[^\w\s]{3}$".to_string(),
285 hashruler_min_length: 10,
286 canonicalize_hashrulers: true,
287 explicit_trailing_pattern: "#<".to_string(),
288 per_command_overrides: HashMap::new(),
289 }
290 }
291}
292
293const CONTROL_FLOW_COMMANDS: &[&str] = &[
295 "if",
296 "elseif",
297 "else",
298 "endif",
299 "foreach",
300 "endforeach",
301 "while",
302 "endwhile",
303 "break",
304 "continue",
305 "return",
306 "block",
307 "endblock",
308];
309
310const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
313
314impl Config {
315 pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
318 let lower = command_name.to_ascii_lowercase();
319 let per_cmd = self.per_command_overrides.get(&lower);
320
321 let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
322 self.separate_ctrl_name_with_space
323 } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
324 self.separate_fn_name_with_space
325 } else {
326 false
327 };
328
329 CommandConfig {
330 global: self,
331 per_cmd,
332 space_before_paren,
333 }
334 }
335
336 pub fn apply_command_case(&self, name: &str) -> String {
338 apply_case(self.command_case, name)
339 }
340
341 pub fn apply_keyword_case(&self, keyword: &str) -> String {
343 apply_case(self.keyword_case, keyword)
344 }
345
346 pub fn indent_str(&self) -> String {
348 if self.use_tabchars {
349 "\t".to_string()
350 } else {
351 " ".repeat(self.tab_size)
352 }
353 }
354
355 pub fn validate_patterns(&self) -> Result<(), String> {
360 let patterns = [
361 ("literal_comment_pattern", &self.literal_comment_pattern),
362 ("explicit_trailing_pattern", &self.explicit_trailing_pattern),
363 ("fence_pattern", &self.fence_pattern),
364 ("ruler_pattern", &self.ruler_pattern),
365 ];
366 for (name, pattern) in &patterns {
367 if !pattern.is_empty() {
368 if let Err(err) = Regex::new(pattern) {
369 return Err(format!("invalid regex in {name}: {err}"));
370 }
371 }
372 }
373 Ok(())
374 }
375
376 pub(crate) fn compiled_patterns(&self) -> Result<CompiledPatterns, String> {
381 Ok(CompiledPatterns {
382 literal_comment: compile_optional(
383 "literal_comment_pattern",
384 &self.literal_comment_pattern,
385 )?,
386 explicit_trailing: compile_optional(
387 "explicit_trailing_pattern",
388 &self.explicit_trailing_pattern,
389 )?,
390 })
391 }
392}
393
394fn compile_optional(name: &str, pattern: &str) -> Result<Option<Regex>, String> {
395 if pattern.is_empty() {
396 Ok(None)
397 } else {
398 Regex::new(pattern)
399 .map(Some)
400 .map_err(|err| format!("invalid regex in {name}: {err}"))
401 }
402}
403
404pub(crate) struct CompiledPatterns {
406 pub(crate) literal_comment: Option<Regex>,
408 pub(crate) explicit_trailing: Option<Regex>,
410}
411
412#[derive(Debug)]
415pub struct CommandConfig<'a> {
416 pub global: &'a Config,
418 per_cmd: Option<&'a PerCommandConfig>,
419 pub space_before_paren: bool,
421}
422
423impl CommandConfig<'_> {
424 pub fn line_width(&self) -> usize {
426 self.per_cmd
427 .and_then(|p| p.line_width)
428 .unwrap_or(self.global.line_width)
429 }
430
431 pub fn tab_size(&self) -> usize {
433 self.per_cmd
434 .and_then(|p| p.tab_size)
435 .unwrap_or(self.global.tab_size)
436 }
437
438 pub fn dangle_parens(&self) -> bool {
440 self.per_cmd
441 .and_then(|p| p.dangle_parens)
442 .unwrap_or(self.global.dangle_parens)
443 }
444
445 pub fn dangle_align(&self) -> DangleAlign {
447 self.per_cmd
448 .and_then(|p| p.dangle_align)
449 .unwrap_or(self.global.dangle_align)
450 }
451
452 pub fn command_case(&self) -> CaseStyle {
454 self.per_cmd
455 .and_then(|p| p.command_case)
456 .unwrap_or(self.global.command_case)
457 }
458
459 pub fn keyword_case(&self) -> CaseStyle {
461 self.per_cmd
462 .and_then(|p| p.keyword_case)
463 .unwrap_or(self.global.keyword_case)
464 }
465
466 pub fn max_pargs_hwrap(&self) -> usize {
469 self.per_cmd
470 .and_then(|p| p.max_pargs_hwrap)
471 .unwrap_or(self.global.max_pargs_hwrap)
472 }
473
474 pub fn max_subgroups_hwrap(&self) -> usize {
476 self.per_cmd
477 .and_then(|p| p.max_subgroups_hwrap)
478 .unwrap_or(self.global.max_subgroups_hwrap)
479 }
480
481 pub fn indent_str(&self) -> String {
483 if self.global.use_tabchars {
484 "\t".to_string()
485 } else {
486 " ".repeat(self.tab_size())
487 }
488 }
489}
490
491fn apply_case(style: CaseStyle, s: &str) -> String {
492 match style {
493 CaseStyle::Lower => s.to_ascii_lowercase(),
494 CaseStyle::Upper => s.to_ascii_uppercase(),
495 CaseStyle::Unchanged => s.to_string(),
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
506 fn for_command_control_flow_sets_space_before_paren() {
507 let config = Config {
508 separate_ctrl_name_with_space: true,
509 ..Config::default()
510 };
511 for cmd in ["if", "elseif", "foreach", "while", "return"] {
512 let cc = config.for_command(cmd);
513 assert!(
514 cc.space_before_paren,
515 "{cmd} should have space_before_paren=true"
516 );
517 }
518 }
519
520 #[test]
521 fn for_command_fn_definition_sets_space_before_paren() {
522 let config = Config {
523 separate_fn_name_with_space: true,
524 ..Config::default()
525 };
526 for cmd in ["function", "endfunction", "macro", "endmacro"] {
527 let cc = config.for_command(cmd);
528 assert!(
529 cc.space_before_paren,
530 "{cmd} should have space_before_paren=true"
531 );
532 }
533 }
534
535 #[test]
536 fn for_command_regular_command_no_space_before_paren() {
537 let config = Config {
538 separate_ctrl_name_with_space: true,
539 separate_fn_name_with_space: true,
540 ..Config::default()
541 };
542 let cc = config.for_command("message");
543 assert!(
544 !cc.space_before_paren,
545 "message should not have space_before_paren"
546 );
547 }
548
549 #[test]
550 fn for_command_lookup_is_case_insensitive() {
551 let mut overrides = HashMap::new();
552 overrides.insert(
553 "message".to_string(),
554 PerCommandConfig {
555 line_width: Some(120),
556 ..Default::default()
557 },
558 );
559 let config = Config {
560 per_command_overrides: overrides,
561 ..Config::default()
562 };
563 assert_eq!(config.for_command("MESSAGE").line_width(), 120);
565 }
566
567 #[test]
570 fn command_config_returns_global_defaults_when_no_override() {
571 let config = Config::default();
572 let cc = config.for_command("set");
573 assert_eq!(cc.line_width(), config.line_width);
574 assert_eq!(cc.tab_size(), config.tab_size);
575 assert_eq!(cc.dangle_parens(), config.dangle_parens);
576 assert_eq!(cc.command_case(), config.command_case);
577 assert_eq!(cc.keyword_case(), config.keyword_case);
578 assert_eq!(cc.max_pargs_hwrap(), config.max_pargs_hwrap);
579 assert_eq!(cc.max_subgroups_hwrap(), config.max_subgroups_hwrap);
580 }
581
582 #[test]
583 fn command_config_per_command_overrides_take_effect() {
584 let mut overrides = HashMap::new();
585 overrides.insert(
586 "set".to_string(),
587 PerCommandConfig {
588 line_width: Some(120),
589 tab_size: Some(4),
590 dangle_parens: Some(true),
591 dangle_align: Some(DangleAlign::Open),
592 command_case: Some(CaseStyle::Upper),
593 keyword_case: Some(CaseStyle::Lower),
594 max_pargs_hwrap: Some(10),
595 max_subgroups_hwrap: Some(5),
596 },
597 );
598 let config = Config {
599 per_command_overrides: overrides,
600 ..Config::default()
601 };
602 let cc = config.for_command("set");
603 assert_eq!(cc.line_width(), 120);
604 assert_eq!(cc.tab_size(), 4);
605 assert!(cc.dangle_parens());
606 assert_eq!(cc.dangle_align(), DangleAlign::Open);
607 assert_eq!(cc.command_case(), CaseStyle::Upper);
608 assert_eq!(cc.keyword_case(), CaseStyle::Lower);
609 assert_eq!(cc.max_pargs_hwrap(), 10);
610 assert_eq!(cc.max_subgroups_hwrap(), 5);
611 }
612
613 #[test]
614 fn indent_str_spaces() {
615 let config = Config {
616 tab_size: 4,
617 use_tabchars: false,
618 ..Config::default()
619 };
620 assert_eq!(config.indent_str(), " ");
621 assert_eq!(config.for_command("set").indent_str(), " ");
622 }
623
624 #[test]
625 fn indent_str_tab() {
626 let config = Config {
627 use_tabchars: true,
628 ..Config::default()
629 };
630 assert_eq!(config.indent_str(), "\t");
631 assert_eq!(config.for_command("set").indent_str(), "\t");
632 }
633
634 #[test]
637 fn apply_command_case_lower() {
638 let config = Config {
639 command_case: CaseStyle::Lower,
640 ..Config::default()
641 };
642 assert_eq!(
643 config.apply_command_case("TARGET_LINK_LIBRARIES"),
644 "target_link_libraries"
645 );
646 }
647
648 #[test]
649 fn apply_command_case_upper() {
650 let config = Config {
651 command_case: CaseStyle::Upper,
652 ..Config::default()
653 };
654 assert_eq!(
655 config.apply_command_case("target_link_libraries"),
656 "TARGET_LINK_LIBRARIES"
657 );
658 }
659
660 #[test]
661 fn apply_command_case_unchanged() {
662 let config = Config {
663 command_case: CaseStyle::Unchanged,
664 ..Config::default()
665 };
666 assert_eq!(
667 config.apply_command_case("Target_Link_Libraries"),
668 "Target_Link_Libraries"
669 );
670 }
671
672 #[test]
673 fn apply_keyword_case_variants() {
674 let config_upper = Config {
675 keyword_case: CaseStyle::Upper,
676 ..Config::default()
677 };
678 assert_eq!(config_upper.apply_keyword_case("public"), "PUBLIC");
679
680 let config_lower = Config {
681 keyword_case: CaseStyle::Lower,
682 ..Config::default()
683 };
684 assert_eq!(config_lower.apply_keyword_case("PUBLIC"), "public");
685 }
686
687 #[test]
690 fn error_layout_too_wide_display() {
691 use crate::error::Error;
692 let err = Error::LayoutTooWide {
693 line_no: 5,
694 width: 95,
695 limit: 80,
696 };
697 let msg = err.to_string();
698 assert!(msg.contains("5"), "should mention line number");
699 assert!(msg.contains("95"), "should mention actual width");
700 assert!(msg.contains("80"), "should mention limit");
701 }
702
703 #[test]
704 fn error_formatter_display() {
705 use crate::error::Error;
706 let err = Error::Formatter("something went wrong".to_string());
707 assert!(err.to_string().contains("something went wrong"));
708 }
709}