1use std::collections::HashMap;
2use std::path::Path;
3
4use regex::Regex;
5use serde::Deserialize;
6use thiserror::Error;
7
8use code_moniker_core::lang::Lang;
9
10const DEFAULT_PRESET: &str = include_str!("presets/default.toml");
11
12pub(crate) use code_moniker_core::lang::kinds::INTERNAL_KINDS;
13
14const RESERVED_LANG_KEYS: &[&str] = &["refs"];
17
18#[derive(Debug, Default, Deserialize, Clone)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21 #[serde(default)]
22 pub aliases: HashMap<String, String>,
23 #[serde(default)]
24 pub refs: RefsRules,
25 #[serde(default)]
26 pub default: LangRules,
27 #[serde(default)]
28 pub ts: LangRules,
29 #[serde(default)]
30 pub rust: LangRules,
31 #[serde(default)]
32 pub java: LangRules,
33 #[serde(default)]
34 pub python: LangRules,
35 #[serde(default)]
36 pub go: LangRules,
37 #[serde(default)]
38 pub cs: LangRules,
39 #[serde(default)]
40 pub sql: LangRules,
41 #[serde(default)]
42 pub profiles: HashMap<String, Profile>,
43}
44
45#[derive(Debug, Default, Deserialize, Clone)]
46#[serde(deny_unknown_fields)]
47pub struct Profile {
48 #[serde(default)]
49 pub enable: Vec<String>,
50 #[serde(default)]
51 pub disable: Vec<String>,
52}
53
54#[derive(Debug, Default, Deserialize, Clone)]
55#[serde(deny_unknown_fields)]
56pub struct RefsRules {
57 #[serde(default, rename = "where")]
58 pub rules: Vec<RuleEntry>,
59}
60
61#[derive(Debug, Default, Deserialize, Clone)]
62pub struct LangRules {
63 #[serde(flatten)]
64 pub kinds: HashMap<String, KindRules>,
65}
66
67#[derive(Debug, Default, Deserialize, Clone)]
68#[serde(deny_unknown_fields)]
69pub struct KindRules {
70 #[serde(default, rename = "where")]
71 pub rules: Vec<RuleEntry>,
72 pub require_doc_comment: Option<String>,
73}
74
75#[derive(Debug, Deserialize, Clone)]
76#[serde(deny_unknown_fields)]
77pub struct RuleEntry {
78 #[serde(default)]
79 pub id: Option<String>,
80 pub expr: String,
81 #[serde(default)]
82 pub message: Option<String>,
83}
84
85#[derive(Debug, Error)]
86pub enum ConfigError {
87 #[error("default preset embedded in the binary is invalid: {0}")]
88 DefaultPresetInvalid(toml::de::Error),
89 #[error("user config `{path}`: {error}")]
90 UserConfig {
91 path: String,
92 error: toml::de::Error,
93 },
94 #[error("cannot read `{path}`: {error}")]
95 Io { path: String, error: std::io::Error },
96 #[error("invalid expression at `{at}`: {error}")]
97 InvalidExpr {
98 at: String,
99 error: super::expr::ParseError,
100 },
101 #[error("unknown kind `{kind}` under `[{section}.{kind}]` (allowed: {allowed})")]
102 UnknownKind {
103 section: String,
104 kind: String,
105 allowed: String,
106 },
107 #[error(
108 "require_doc_comment = `{value}` under `[{section}.{kind}]` is not a recognised visibility for that language (allowed: {allowed})"
109 )]
110 UnknownDocVisibility {
111 section: String,
112 kind: String,
113 value: String,
114 allowed: String,
115 },
116 #[error("alias cycle through `{chain}`")]
117 AliasCycle { chain: String },
118 #[error("unknown alias `${name}` referenced under `{at}`")]
119 UnknownAlias { name: String, at: String },
120 #[error("unknown profile `{name}` (known: {known})")]
121 UnknownProfile { name: String, known: String },
122 #[error("invalid regex `{pattern}` in profile `{profile}` ({field}): {error}")]
123 BadProfileRegex {
124 profile: String,
125 field: &'static str,
126 pattern: String,
127 error: regex::Error,
128 },
129}
130
131pub fn load_default() -> Result<Config, ConfigError> {
132 let cfg: Config = toml::from_str(DEFAULT_PRESET).map_err(ConfigError::DefaultPresetInvalid)?;
133 validate(&cfg, "<embedded preset>")?;
134 Ok(cfg)
135}
136
137pub fn load_with_overrides(user_path: Option<&Path>) -> Result<Config, ConfigError> {
140 let mut cfg = load_default()?;
141 if let Some(p) = user_path {
142 if !p.exists() {
143 return Ok(cfg);
144 }
145 let raw = std::fs::read_to_string(p).map_err(|error| ConfigError::Io {
146 path: p.display().to_string(),
147 error,
148 })?;
149 let user: Config = toml::from_str(&raw).map_err(|error| ConfigError::UserConfig {
150 path: p.display().to_string(),
151 error,
152 })?;
153 validate(&user, &p.display().to_string())?;
154 merge_into(&mut cfg, user);
155 }
156 Ok(cfg)
157}
158
159fn merge_into(base: &mut Config, ov: Config) {
160 for (k, v) in ov.aliases {
161 base.aliases.insert(k, v);
162 }
163 for (k, v) in ov.profiles {
164 base.profiles.insert(k, v);
165 }
166 merge_refs(&mut base.refs, ov.refs);
167 merge_lang(&mut base.default, ov.default);
168 merge_lang(&mut base.ts, ov.ts);
169 merge_lang(&mut base.rust, ov.rust);
170 merge_lang(&mut base.java, ov.java);
171 merge_lang(&mut base.python, ov.python);
172 merge_lang(&mut base.go, ov.go);
173 merge_lang(&mut base.cs, ov.cs);
174 merge_lang(&mut base.sql, ov.sql);
175}
176
177fn merge_refs(base: &mut RefsRules, ov: RefsRules) {
178 for ov_rule in ov.rules {
179 match ov_rule
180 .id
181 .as_deref()
182 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
183 {
184 Some(idx) => base.rules[idx] = ov_rule,
185 None => base.rules.push(ov_rule),
186 }
187 }
188}
189
190fn merge_lang(base: &mut LangRules, ov: LangRules) {
191 for (kind, ov_rules) in ov.kinds {
192 match base.kinds.get_mut(&kind) {
193 Some(base_rules) => merge_kind(base_rules, ov_rules),
194 None => {
195 base.kinds.insert(kind, ov_rules);
196 }
197 }
198 }
199}
200
201fn merge_kind(base: &mut KindRules, ov: KindRules) {
205 for ov_rule in ov.rules {
206 match ov_rule
207 .id
208 .as_deref()
209 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
210 {
211 Some(idx) => base.rules[idx] = ov_rule,
212 None => base.rules.push(ov_rule),
213 }
214 }
215 if ov.require_doc_comment.is_some() {
216 base.require_doc_comment = ov.require_doc_comment;
217 }
218}
219
220pub(crate) fn resolve_aliases(
225 aliases: &HashMap<String, String>,
226) -> Result<HashMap<String, String>, ConfigError> {
227 let mut resolved: HashMap<String, String> = HashMap::new();
228 for name in aliases.keys() {
229 let mut stack: Vec<String> = Vec::new();
230 resolve_one(name, aliases, &mut resolved, &mut stack)?;
231 }
232 Ok(resolved)
233}
234
235fn resolve_one(
236 name: &str,
237 src: &HashMap<String, String>,
238 resolved: &mut HashMap<String, String>,
239 stack: &mut Vec<String>,
240) -> Result<String, ConfigError> {
241 if let Some(v) = resolved.get(name) {
242 return Ok(v.clone());
243 }
244 if stack.iter().any(|s| s == name) {
245 stack.push(name.to_string());
246 return Err(ConfigError::AliasCycle {
247 chain: stack.join(" → "),
248 });
249 }
250 let Some(body) = src.get(name) else {
251 return Err(ConfigError::UnknownAlias {
252 name: name.to_string(),
253 at: format!("alias `{}`", stack.last().unwrap_or(&"<root>".to_string())),
254 });
255 };
256 stack.push(name.to_string());
257 let expanded = expand_refs(body, src, resolved, stack)?;
258 stack.pop();
259 resolved.insert(name.to_string(), expanded.clone());
260 Ok(expanded)
261}
262
263fn expand_refs(
264 body: &str,
265 src: &HashMap<String, String>,
266 resolved: &mut HashMap<String, String>,
267 stack: &mut Vec<String>,
268) -> Result<String, ConfigError> {
269 let mut out = String::with_capacity(body.len());
270 let bytes = body.as_bytes();
271 let mut i = 0;
272 while i < bytes.len() {
273 if bytes[i] == b'$' {
274 let start = i + 1;
275 let mut j = start;
276 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
277 j += 1;
278 }
279 if j > start {
280 let name = &body[start..j];
281 let expanded = resolve_one(name, src, resolved, stack)?;
282 out.push('(');
283 out.push_str(&expanded);
284 out.push(')');
285 i = j;
286 continue;
287 }
288 }
289 out.push(bytes[i] as char);
290 i += 1;
291 }
292 Ok(out)
293}
294
295pub(crate) fn substitute_aliases(
298 expr: &str,
299 resolved: &HashMap<String, String>,
300 at: &str,
301) -> Result<String, ConfigError> {
302 let mut out = String::with_capacity(expr.len());
303 let bytes = expr.as_bytes();
304 let mut i = 0;
305 while i < bytes.len() {
306 if bytes[i] == b'$' {
307 let start = i + 1;
308 let mut j = start;
309 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
310 j += 1;
311 }
312 if j > start {
313 let name = &expr[start..j];
314 let Some(expanded) = resolved.get(name) else {
315 return Err(ConfigError::UnknownAlias {
316 name: name.to_string(),
317 at: at.to_string(),
318 });
319 };
320 out.push('(');
321 out.push_str(expanded);
322 out.push(')');
323 i = j;
324 continue;
325 }
326 }
327 out.push(bytes[i] as char);
328 i += 1;
329 }
330 Ok(out)
331}
332
333fn validate(cfg: &Config, path: &str) -> Result<(), ConfigError> {
335 resolve_aliases(&cfg.aliases)?;
336 validate_lang_section(
337 &cfg.default,
338 "default",
339 &allowed_kinds_set(None),
340 None,
341 path,
342 )?;
343 for lang in Lang::ALL {
344 let allowed = allowed_kinds_set(Some(*lang));
345 validate_lang_section(
346 cfg.for_lang(*lang),
347 config_section(*lang),
348 &allowed,
349 Some(*lang),
350 path,
351 )?;
352 }
353 Ok(())
354}
355
356fn allowed_kinds_set(lang: Option<Lang>) -> Vec<&'static str> {
357 let mut out: Vec<&'static str> = INTERNAL_KINDS.to_vec();
358 if let Some(l) = lang {
359 out.extend(l.allowed_kinds().iter().copied());
360 } else {
361 for l in Lang::ALL {
362 out.extend(l.allowed_kinds().iter().copied());
363 }
364 }
365 out.sort();
366 out.dedup();
367 out
368}
369
370pub(crate) fn allowed_kinds_for(lang: Lang) -> Vec<&'static str> {
374 allowed_kinds_set(Some(lang))
375}
376
377fn allowed_doc_vis_for(lang: Lang) -> Vec<&'static str> {
380 let mut out: Vec<&'static str> = vec!["any"];
381 out.extend(lang.allowed_visibilities().iter().copied());
382 out
383}
384
385pub(crate) fn config_section(lang: Lang) -> &'static str {
388 match lang {
389 Lang::Rs => "rust",
390 other => other.tag(),
391 }
392}
393
394fn validate_lang_section(
395 lr: &LangRules,
396 section: &str,
397 allowed: &[&str],
398 lang: Option<Lang>,
399 _path: &str,
400) -> Result<(), ConfigError> {
401 for (kind, kr) in lr.kinds.iter() {
402 if RESERVED_LANG_KEYS.contains(&kind.as_str()) {
403 continue;
404 }
405 if !allowed.contains(&kind.as_str()) {
406 return Err(ConfigError::UnknownKind {
407 section: section.to_string(),
408 kind: kind.clone(),
409 allowed: allowed.join(", "),
410 });
411 }
412 if let (Some(value), Some(l)) = (&kr.require_doc_comment, lang) {
413 let allowed_vis = allowed_doc_vis_for(l);
414 if !allowed_vis.contains(&value.as_str()) {
415 return Err(ConfigError::UnknownDocVisibility {
416 section: section.to_string(),
417 kind: kind.clone(),
418 value: value.clone(),
419 allowed: allowed_vis.join(", "),
420 });
421 }
422 }
423 }
424 Ok(())
425}
426
427impl Config {
428 pub fn for_lang(&self, lang: Lang) -> &LangRules {
429 match lang {
430 Lang::Ts => &self.ts,
431 Lang::Rs => &self.rust,
432 Lang::Java => &self.java,
433 Lang::Python => &self.python,
434 Lang::Go => &self.go,
435 Lang::Cs => &self.cs,
436 Lang::Sql => &self.sql,
437 }
438 }
439
440 pub fn for_lang_mut(&mut self, lang: Lang) -> &mut LangRules {
441 match lang {
442 Lang::Ts => &mut self.ts,
443 Lang::Rs => &mut self.rust,
444 Lang::Java => &mut self.java,
445 Lang::Python => &mut self.python,
446 Lang::Go => &mut self.go,
447 Lang::Cs => &mut self.cs,
448 Lang::Sql => &mut self.sql,
449 }
450 }
451
452 pub fn rules_for(&self, lang: Lang, kind: &str) -> Option<&KindRules> {
453 self.for_lang(lang)
454 .kinds
455 .get(kind)
456 .or_else(|| self.default.kinds.get(kind))
457 }
458
459 pub fn apply_profile(&mut self, name: &str) -> Result<(), ConfigError> {
460 let profile = self
461 .profiles
462 .get(name)
463 .ok_or_else(|| ConfigError::UnknownProfile {
464 name: name.to_string(),
465 known: self.known_profiles(),
466 })?
467 .clone();
468 let enable = compile_patterns(&profile.enable, name, "enable")?;
469 let disable = compile_patterns(&profile.disable, name, "disable")?;
470 filter_rules(&mut self.refs.rules, "refs", &enable, &disable);
471 filter_lang(&mut self.default, "default", &enable, &disable);
472 for lang in Lang::ALL {
473 filter_lang(
474 self.for_lang_mut(*lang),
475 config_section(*lang),
476 &enable,
477 &disable,
478 );
479 }
480 Ok(())
481 }
482
483 fn known_profiles(&self) -> String {
484 let mut names: Vec<&str> = self.profiles.keys().map(|s| s.as_str()).collect();
485 names.sort();
486 names.join(", ")
487 }
488}
489
490impl RuleEntry {
491 pub(crate) fn fallback_id(&self, idx: usize) -> String {
492 self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
493 }
494}
495
496fn compile_patterns(
497 patterns: &[String],
498 profile: &str,
499 field: &'static str,
500) -> Result<Vec<Regex>, ConfigError> {
501 patterns
502 .iter()
503 .map(|p| {
504 Regex::new(p).map_err(|error| ConfigError::BadProfileRegex {
505 profile: profile.to_string(),
506 field,
507 pattern: p.clone(),
508 error,
509 })
510 })
511 .collect()
512}
513
514fn filter_lang(lr: &mut LangRules, section: &str, enable: &[Regex], disable: &[Regex]) {
515 for (kind, kr) in lr.kinds.iter_mut() {
516 let prefix = format!("{section}.{kind}");
517 filter_rules(&mut kr.rules, &prefix, enable, disable);
518 }
519}
520
521fn filter_rules(rules: &mut Vec<RuleEntry>, prefix: &str, enable: &[Regex], disable: &[Regex]) {
522 if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
523 return;
524 }
525 let mut idx = 0;
526 rules.retain(|r| {
527 let full = format!("{prefix}.{}", r.fallback_id(idx));
528 idx += 1;
529 (enable.is_empty() || enable.iter().any(|re| re.is_match(&full)))
530 && !disable.iter().any(|re| re.is_match(&full))
531 });
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 fn parse(s: &str) -> Result<Config, ConfigError> {
539 let cfg: Config = toml::from_str(s).map_err(|e| ConfigError::UserConfig {
540 path: "<test>".to_string(),
541 error: e,
542 })?;
543 validate(&cfg, "<test>")?;
544 Ok(cfg)
545 }
546
547 #[test]
548 fn embedded_default_parses() {
549 let cfg = load_default().expect("default preset must parse");
550 assert!(cfg.ts.kinds.contains_key("class"));
551 assert!(cfg.ts.kinds.contains_key("function"));
552 }
553
554 #[test]
555 fn ts_class_ships_at_least_one_rule_in_default() {
556 let cfg = load_default().unwrap();
557 let r = cfg.rules_for(Lang::Ts, "class").expect("ts.class present");
558 assert!(!r.rules.is_empty(), "preset must ship rules for ts.class");
559 }
560
561 #[test]
562 fn rules_for_falls_back_to_default_section() {
563 let cfg = parse(
564 r#"
565 [[default.module.where]]
566 id = "stub"
567 expr = "lines <= 99"
568
569 [[ts.class.where]]
570 expr = "name =~ ^X"
571 "#,
572 )
573 .unwrap();
574 let r = cfg
575 .rules_for(Lang::Ts, "module")
576 .expect("falls back to default.module");
577 assert_eq!(r.rules.len(), 1);
578 assert_eq!(r.rules[0].id.as_deref(), Some("stub"));
579 }
580
581 #[test]
582 fn override_with_same_id_replaces_preset_rule() {
583 let user = parse(
584 r#"
585 [[ts.function.where]]
586 id = "max-lines"
587 expr = "lines <= 999"
588 "#,
589 )
590 .unwrap();
591 let mut base = parse(
592 r#"
593 [[ts.function.where]]
594 id = "name-camel"
595 expr = "name =~ ^[a-z]"
596
597 [[ts.function.where]]
598 id = "max-lines"
599 expr = "lines <= 60"
600 "#,
601 )
602 .unwrap();
603 merge_into(&mut base, user);
604 let f = base.rules_for(Lang::Ts, "function").unwrap();
605 assert_eq!(f.rules.len(), 2, "id-matched override replaces in place");
606 let max_lines = f
607 .rules
608 .iter()
609 .find(|r| r.id.as_deref() == Some("max-lines"))
610 .unwrap();
611 assert!(max_lines.expr.contains("999"), "user override applied");
612 assert!(
613 f.rules
614 .iter()
615 .any(|r| r.id.as_deref() == Some("name-camel")),
616 "sibling rule preserved"
617 );
618 }
619
620 #[test]
621 fn override_with_new_id_appends_to_preset() {
622 let user = parse(
623 r#"
624 [[ts.class.where]]
625 id = "extra"
626 expr = "name !~ ^Internal"
627 "#,
628 )
629 .unwrap();
630 let mut base = parse(
631 r#"
632 [[ts.class.where]]
633 id = "name-pascal"
634 expr = "name =~ ^[A-Z]"
635 "#,
636 )
637 .unwrap();
638 merge_into(&mut base, user);
639 let r = base.rules_for(Lang::Ts, "class").unwrap();
640 assert_eq!(r.rules.len(), 2);
641 }
642
643 #[test]
644 fn unknown_field_in_kind_rules_is_rejected() {
645 let r = toml::from_str::<Config>(
646 r#"
647 [ts.function]
648 max_lines = 10
649 "#,
650 );
651 assert!(r.is_err(), "deny_unknown_fields rejects legacy fields");
652 }
653
654 #[test]
655 fn alias_section_parses() {
656 let cfg = parse(
657 r#"
658 [aliases]
659 domain = "moniker ~ '**/module:domain/**'"
660 "#,
661 )
662 .unwrap();
663 assert_eq!(
664 cfg.aliases.get("domain").map(|s| s.as_str()),
665 Some("moniker ~ '**/module:domain/**'"),
666 );
667 }
668
669 #[test]
670 fn alias_cycle_is_rejected() {
671 let r = parse(
672 r#"
673 [aliases]
674 a = "$b"
675 b = "$a"
676 "#,
677 );
678 match r {
679 Err(ConfigError::AliasCycle { chain }) => {
680 assert!(chain.contains("a") && chain.contains("b"), "{chain}");
681 }
682 other => panic!("expected AliasCycle, got {other:?}"),
683 }
684 }
685
686 #[test]
687 fn alias_chain_resolves() {
688 let cfg = parse(
689 r#"
690 [aliases]
691 a = "name = 'X'"
692 b = "$a OR name = 'Y'"
693 c = "$b AND lines <= 10"
694 "#,
695 )
696 .unwrap();
697 let resolved = resolve_aliases(&cfg.aliases).unwrap();
698 let final_c = resolved.get("c").unwrap();
699 assert!(final_c.contains("name = 'X'"), "{final_c}");
700 assert!(final_c.contains("name = 'Y'"), "{final_c}");
701 assert!(final_c.contains("lines <= 10"), "{final_c}");
702 }
703
704 #[test]
705 fn alias_substitution_wraps_in_parens() {
706 let mut src = HashMap::new();
708 src.insert("x".to_string(), "A AND B".to_string());
709 let resolved = resolve_aliases(&src).unwrap();
710 let out = substitute_aliases("$x OR C", &resolved, "test").unwrap();
711 assert_eq!(out, "(A AND B) OR C");
712 }
713
714 #[test]
715 fn unknown_alias_is_rejected_at_substitution() {
716 let resolved = HashMap::new();
717 match substitute_aliases("$bogus AND name = 'X'", &resolved, "ts.class.r1") {
718 Err(ConfigError::UnknownAlias { name, at }) => {
719 assert_eq!(name, "bogus");
720 assert_eq!(at, "ts.class.r1");
721 }
722 other => panic!("expected UnknownAlias, got {other:?}"),
723 }
724 }
725
726 #[test]
727 fn unknown_top_level_lang_section_is_rejected() {
728 let r = toml::from_str::<Config>(
729 r#"
730 [[typescript.class.where]]
731 expr = "name =~ ^[A-Z]"
732 "#,
733 );
734 assert!(
735 r.is_err(),
736 "deny_unknown_fields must reject unknown lang sections"
737 );
738 }
739
740 #[test]
741 fn unknown_require_doc_visibility_is_rejected() {
742 let r = parse(
743 r#"
744 [ts.class]
745 require_doc_comment = "publc"
746 "#,
747 );
748 match r {
749 Err(ConfigError::UnknownDocVisibility { value, .. }) => assert_eq!(value, "publc"),
750 other => panic!("expected UnknownDocVisibility, got {other:?}"),
751 }
752 }
753
754 #[test]
755 fn doc_visibility_any_is_accepted() {
756 let r = parse(
757 r#"
758 [ts.class]
759 require_doc_comment = "any"
760 "#,
761 );
762 assert!(r.is_ok(), "any is always valid");
763 }
764
765 #[test]
766 fn unknown_kind_section_is_rejected() {
767 let r = parse(
768 r#"
769 [[ts.classs.where]]
770 expr = "name =~ ^X"
771 "#,
772 );
773 match r {
774 Err(ConfigError::UnknownKind { kind, .. }) => assert_eq!(kind, "classs"),
775 other => panic!("expected UnknownKind, got {other:?}"),
776 }
777 }
778
779 #[test]
780 fn missing_user_file_is_not_an_error() {
781 let cfg = load_with_overrides(Some(Path::new("/no/such/file.toml")))
782 .expect("missing file falls back to defaults");
783 assert!(cfg.ts.kinds.contains_key("class"));
784 }
785
786 #[test]
787 fn malformed_user_file_returns_user_config_error() {
788 let dir = tempfile::tempdir().unwrap();
789 let p = dir.path().join("bad.toml");
790 std::fs::write(&p, "this is not toml = = =").unwrap();
791 match load_with_overrides(Some(&p)) {
792 Err(ConfigError::UserConfig { .. }) => {}
793 other => panic!("expected UserConfig error, got {other:?}"),
794 }
795 }
796
797 #[test]
798 fn profile_enable_filters_in() {
799 let mut cfg = parse(
800 r#"
801 [[ts.class.where]]
802 id = "keep"
803 expr = "lines <= 99"
804
805 [[ts.class.where]]
806 id = "drop"
807 expr = "lines <= 99"
808
809 [profiles.only_keep]
810 enable = ["\\.keep$"]
811 "#,
812 )
813 .unwrap();
814 cfg.apply_profile("only_keep").unwrap();
815 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
816 assert_eq!(r.rules.len(), 1);
817 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
818 }
819
820 #[test]
821 fn profile_disable_filters_out() {
822 let mut cfg = parse(
823 r#"
824 [[ts.class.where]]
825 id = "keep"
826 expr = "lines <= 99"
827
828 [[ts.class.where]]
829 id = "drop"
830 expr = "lines <= 99"
831
832 [profiles.drop_one]
833 disable = ["\\.drop$"]
834 "#,
835 )
836 .unwrap();
837 cfg.apply_profile("drop_one").unwrap();
838 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
839 assert_eq!(r.rules.len(), 1);
840 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
841 }
842
843 #[test]
844 fn profile_enable_then_disable() {
845 let mut cfg = parse(
846 r#"
847 [[ts.class.where]]
848 id = "a"
849 expr = "lines <= 99"
850
851 [[ts.class.where]]
852 id = "b"
853 expr = "lines <= 99"
854
855 [[ts.class.where]]
856 id = "c"
857 expr = "lines <= 99"
858
859 [profiles.p]
860 enable = ["ts\\.class\\.(a|b)$"]
861 disable = ["ts\\.class\\.b$"]
862 "#,
863 )
864 .unwrap();
865 cfg.apply_profile("p").unwrap();
866 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
867 assert_eq!(r.rules.len(), 1);
868 assert_eq!(r.rules[0].id.as_deref(), Some("a"));
869 }
870
871 #[test]
872 fn profile_filters_refs_top_level() {
873 let mut cfg = parse(
874 r#"
875 [[refs.where]]
876 id = "stay"
877 expr = "kind = 'call'"
878
879 [[refs.where]]
880 id = "go"
881 expr = "kind = 'call'"
882
883 [profiles.p]
884 disable = ["^refs\\.go$"]
885 "#,
886 )
887 .unwrap();
888 cfg.apply_profile("p").unwrap();
889 assert_eq!(cfg.refs.rules.len(), 1);
890 assert_eq!(cfg.refs.rules[0].id.as_deref(), Some("stay"));
891 }
892
893 #[test]
894 fn profile_filters_per_lang_refs() {
895 let mut cfg = parse(
896 r#"
897 [[ts.refs.where]]
898 id = "stay"
899 expr = "kind = 'call'"
900
901 [[ts.refs.where]]
902 id = "go"
903 expr = "kind = 'call'"
904
905 [profiles.p]
906 disable = ["^ts\\.refs\\.go$"]
907 "#,
908 )
909 .unwrap();
910 cfg.apply_profile("p").unwrap();
911 let r = cfg.ts.kinds.get("refs").unwrap();
912 assert_eq!(r.rules.len(), 1);
913 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
914 }
915
916 #[test]
917 fn profile_filters_default_section() {
918 let mut cfg = parse(
919 r#"
920 [[default.module.where]]
921 id = "stay"
922 expr = "lines <= 99"
923
924 [[default.module.where]]
925 id = "go"
926 expr = "lines <= 99"
927
928 [profiles.p]
929 disable = ["^default\\.module\\.go$"]
930 "#,
931 )
932 .unwrap();
933 cfg.apply_profile("p").unwrap();
934 let r = cfg.default.kinds.get("module").unwrap();
935 assert_eq!(r.rules.len(), 1);
936 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
937 }
938
939 #[test]
940 fn unknown_profile_returns_error() {
941 let mut cfg = parse(
942 r#"
943 [profiles.known]
944 disable = []
945 "#,
946 )
947 .unwrap();
948 match cfg.apply_profile("nope") {
949 Err(ConfigError::UnknownProfile { name, known }) => {
950 assert_eq!(name, "nope");
951 assert!(known.contains("known"), "{known}");
952 }
953 other => panic!("expected UnknownProfile, got {other:?}"),
954 }
955 }
956
957 #[test]
958 fn bad_regex_returns_error() {
959 let mut cfg = parse(
960 r#"
961 [profiles.p]
962 enable = ["(unclosed"]
963 "#,
964 )
965 .unwrap();
966 match cfg.apply_profile("p") {
967 Err(ConfigError::BadProfileRegex {
968 profile,
969 field,
970 pattern,
971 ..
972 }) => {
973 assert_eq!(profile, "p");
974 assert_eq!(field, "enable");
975 assert_eq!(pattern, "(unclosed");
976 }
977 other => panic!("expected BadProfileRegex, got {other:?}"),
978 }
979 }
980
981 #[test]
982 fn fallback_where_n_id_matches() {
983 let mut cfg = parse(
984 r#"
985 [[ts.class.where]]
986 expr = "lines <= 99"
987
988 [[ts.class.where]]
989 expr = "lines <= 99"
990
991 [profiles.p]
992 disable = ["^ts\\.class\\.where_0$"]
993 "#,
994 )
995 .unwrap();
996 cfg.apply_profile("p").unwrap();
997 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
998 assert_eq!(r.rules.len(), 1);
999 }
1000
1001 #[test]
1002 fn user_profile_overrides_preset_by_name() {
1003 let user = parse(
1004 r#"
1005 [profiles.bugfix]
1006 enable = ["^user$"]
1007 disable = []
1008 "#,
1009 )
1010 .unwrap();
1011 let mut base = parse(
1012 r#"
1013 [profiles.bugfix]
1014 enable = ["^base$"]
1015 disable = []
1016 "#,
1017 )
1018 .unwrap();
1019 merge_into(&mut base, user);
1020 let p = base.profiles.get("bugfix").unwrap();
1021 assert_eq!(p.enable, vec!["^user$".to_string()]);
1022 }
1023
1024 #[test]
1025 fn default_preset_ships_at_least_one_rule_per_language() {
1026 let cfg = load_default().unwrap();
1027 for lang in Lang::ALL {
1028 let lr = cfg.for_lang(*lang);
1029 assert!(
1030 !lr.kinds.is_empty(),
1031 "{} should ship at least one default rule",
1032 lang.tag()
1033 );
1034 }
1035 }
1036}