1mod fragments;
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use regex::Regex;
7use serde::Deserialize;
8use thiserror::Error;
9
10use code_moniker_core::core::shape::Shape;
11use code_moniker_core::lang::Lang;
12
13const DEFAULT_PRESET: &str = include_str!("presets/default.toml");
14
15pub(crate) use code_moniker_core::lang::kinds::INTERNAL_KINDS;
16
17const RESERVED_LANG_KEYS: &[&str] = &["refs"];
20
21#[derive(Debug, Default, Deserialize, Clone)]
22#[serde(deny_unknown_fields)]
23pub struct Config {
24 #[serde(default)]
25 pub default_rules: Option<bool>,
26 #[serde(default)]
27 pub aliases: HashMap<String, String>,
28 #[serde(default)]
29 pub exclude: ExcludeRules,
30 #[serde(default)]
31 pub refs: RefsRules,
32 #[serde(default)]
33 pub shape: HashMap<String, KindRules>,
34 #[serde(default)]
35 pub default: LangRules,
36 #[serde(default)]
37 pub ts: LangRules,
38 #[serde(default)]
39 pub rust: LangRules,
40 #[serde(default)]
41 pub java: LangRules,
42 #[serde(default)]
43 pub python: LangRules,
44 #[serde(default)]
45 pub go: LangRules,
46 #[serde(default)]
47 pub c: LangRules,
48 #[serde(default)]
49 pub cs: LangRules,
50 #[serde(default)]
51 pub sql: LangRules,
52 #[serde(default)]
53 pub profiles: HashMap<String, Profile>,
54 #[serde(default)]
55 pub views: Vec<toml::Value>,
56 #[serde(skip)]
57 pub fragments: Vec<FragmentInfo>,
58}
59
60#[derive(Debug, Default, Deserialize, Clone)]
61#[serde(deny_unknown_fields)]
62pub struct ExcludeRules {
63 #[serde(default)]
64 pub uris: Vec<String>,
65}
66
67#[derive(Debug, Clone)]
68pub struct FragmentInfo {
69 pub id: String,
70 pub path: PathBuf,
71 pub enabled: bool,
72 pub declared_rules: usize,
73 pub active_rules: usize,
74 pub(crate) rule_keys: Vec<String>,
75}
76
77#[derive(Debug, Default, Deserialize, Clone)]
78#[serde(deny_unknown_fields)]
79pub struct Profile {
80 #[serde(default)]
81 pub enable: Vec<String>,
82 #[serde(default)]
83 pub disable: Vec<String>,
84}
85
86#[derive(Debug, Default, Deserialize, Clone)]
87#[serde(deny_unknown_fields)]
88pub struct RefsRules {
89 #[serde(default, rename = "where")]
90 pub rules: Vec<RuleEntry>,
91}
92
93#[derive(Debug, Default, Deserialize, Clone)]
94pub struct LangRules {
95 #[serde(default)]
96 pub shape: HashMap<String, KindRules>,
97 #[serde(flatten)]
98 pub kinds: HashMap<String, KindRules>,
99}
100
101#[derive(Debug, Default, Deserialize, Clone)]
102#[serde(deny_unknown_fields)]
103pub struct KindRules {
104 #[serde(default, rename = "where")]
105 pub rules: Vec<RuleEntry>,
106 pub require_doc_comment: Option<String>,
107}
108
109#[derive(Debug, Deserialize, Clone)]
110#[serde(deny_unknown_fields)]
111pub struct RuleEntry {
112 #[serde(default)]
113 pub id: Option<String>,
114 pub expr: String,
115 #[serde(default)]
116 pub severity: RuleSeverity,
117 #[serde(default)]
118 pub message: Option<String>,
119 #[serde(default)]
120 pub rationale: Option<String>,
121}
122
123#[derive(
124 Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, serde::Serialize,
125)]
126#[serde(rename_all = "lowercase")]
127pub enum RuleSeverity {
128 Warn,
129 #[default]
130 Error,
131}
132
133impl RuleSeverity {
134 #[allow(dead_code)]
135 pub fn as_str(self) -> &'static str {
136 match self {
137 Self::Warn => "warn",
138 Self::Error => "error",
139 }
140 }
141
142 pub fn is_error(self) -> bool {
143 matches!(self, Self::Error)
144 }
145
146 pub fn is_warn(self) -> bool {
147 matches!(self, Self::Warn)
148 }
149}
150
151#[derive(Debug, Error)]
152pub enum ConfigError {
153 #[error("default preset embedded in the binary is invalid: {0}")]
154 DefaultPresetInvalid(toml::de::Error),
155 #[error("user config `{path}`: {error}")]
156 UserConfig {
157 path: String,
158 error: toml::de::Error,
159 },
160 #[error("fragment config `{path}`: {error}")]
161 FragmentConfig {
162 path: String,
163 error: toml::de::Error,
164 },
165 #[error("cannot read `{path}`: {error}")]
166 Io { path: String, error: std::io::Error },
167 #[error("invalid expression at `{at}`: {error}")]
168 InvalidExpr {
169 at: String,
170 error: super::expr::ParseError,
171 },
172 #[error("unknown kind `{kind}` under `[{section}.{kind}]` (allowed: {allowed})")]
173 UnknownKind {
174 section: String,
175 kind: String,
176 allowed: String,
177 },
178 #[error("unknown shape `{shape}` under `[{section}]` (allowed: {allowed})")]
179 UnknownShape {
180 section: String,
181 shape: String,
182 allowed: String,
183 },
184 #[error(
185 "shape rules under `[default.shape]` are not supported; use top-level `[shape]` for cross-language shape rules"
186 )]
187 DefaultShapeUnsupported,
188 #[error(
189 "require_doc_comment = `{value}` under `[{section}.{kind}]` is not a recognised visibility for that language (allowed: {allowed})"
190 )]
191 UnknownDocVisibility {
192 section: String,
193 kind: String,
194 value: String,
195 allowed: String,
196 },
197 #[error("alias cycle through `{chain}`")]
198 AliasCycle { chain: String },
199 #[error("unknown alias `${name}` referenced under `{at}`")]
200 UnknownAlias { name: String, at: String },
201 #[error("unknown profile `{name}` (known: {known})")]
202 UnknownProfile { name: String, known: String },
203 #[error("invalid regex `{pattern}` in profile `{profile}` ({field}): {error}")]
204 BadProfileRegex {
205 profile: String,
206 field: &'static str,
207 pattern: String,
208 error: regex::Error,
209 },
210 #[error("invalid fragment id `{id}` in `{path}`; use ASCII letters, digits, `_`, or `-`")]
211 InvalidFragmentId { path: String, id: String },
212 #[error(
213 "invalid alias id `{alias}` in fragment `{fragment}` at `{path}`; use ASCII letters, digits, or `_`"
214 )]
215 InvalidFragmentAliasId {
216 path: String,
217 fragment: String,
218 alias: String,
219 },
220 #[error("duplicate fragment id `{id}` in `{first}` and `{second}`")]
221 DuplicateFragment {
222 id: String,
223 first: String,
224 second: String,
225 },
226 #[error("alias `{alias}` from fragment `{fragment}` in `{path}` shadows an existing alias")]
227 FragmentAliasShadowsExisting {
228 path: String,
229 fragment: String,
230 alias: String,
231 },
232 #[error("alias `{alias}` from `{path}` collides with alias from `{existing}`")]
233 FragmentAliasCollision {
234 alias: String,
235 path: String,
236 existing: String,
237 },
238 #[error("fragment `{fragment}` in `{path}` has a rule without an explicit id under `{at}`")]
239 FragmentRuleMissingId {
240 path: String,
241 fragment: String,
242 at: String,
243 },
244 #[error(
245 "invalid rule id `{id}` in fragment `{fragment}` at `{path}`; use ASCII letters, digits, `_`, or `-`"
246 )]
247 InvalidFragmentRuleId {
248 path: String,
249 fragment: String,
250 id: String,
251 },
252 #[error(
253 "fragment `{fragment}` in `{path}` uses unsupported `require_doc_comment` under `{at}`"
254 )]
255 FragmentRequireDocUnsupported {
256 path: String,
257 fragment: String,
258 at: String,
259 },
260 #[error("rule `{rule_id}` from `{path}` collides with rule from `{existing}`")]
261 FragmentRuleCollision {
262 rule_id: String,
263 path: String,
264 existing: String,
265 },
266}
267
268pub(crate) fn load_default() -> Result<Config, ConfigError> {
269 let cfg: Config = toml::from_str(DEFAULT_PRESET).map_err(ConfigError::DefaultPresetInvalid)?;
270 validate(&cfg, "<embedded preset>")?;
271 Ok(cfg)
272}
273
274pub fn load_with_overrides(user_path: Option<&Path>) -> Result<Config, ConfigError> {
277 load_with_options(user_path, true)
278}
279
280pub fn load_with_cli_default_rules(
284 user_path: Option<&Path>,
285 default_rules: Option<bool>,
286) -> Result<Config, ConfigError> {
287 load_with_cli_sources(user_path, &[], default_rules)
288}
289
290pub fn load_with_cli_sources(
294 user_path: Option<&Path>,
295 inline_sources: &[String],
296 default_rules: Option<bool>,
297) -> Result<Config, ConfigError> {
298 let project = read_project_config(user_path)?;
299 let inline = parse_inline_configs(inline_sources)?;
300 let include_defaults = default_rules.unwrap_or_else(|| {
301 inline
302 .iter()
303 .rev()
304 .find_map(|cfg| cfg.default_rules)
305 .or_else(|| project.root.as_ref().and_then(|cfg| cfg.default_rules))
306 .unwrap_or(true)
307 });
308 load_with_project(project, include_defaults, inline)
309}
310
311fn parse_inline_configs(inline_sources: &[String]) -> Result<Vec<Config>, ConfigError> {
312 inline_sources
313 .iter()
314 .enumerate()
315 .map(|(index, raw)| parse_inline_config(raw, index))
316 .collect()
317}
318
319fn parse_inline_config(raw: &str, index: usize) -> Result<Config, ConfigError> {
320 let path = inline_rules_label(index);
321 let user: Config = toml::from_str(raw).map_err(|error| ConfigError::UserConfig {
322 path: path.clone(),
323 error,
324 })?;
325 validate(&user, &path)?;
326 Ok(user)
327}
328
329fn inline_rules_label(index: usize) -> String {
330 format!("<inline rules #{}>", index + 1)
331}
332
333fn include_defaults_from_project(project: &ProjectConfig, include_defaults: bool) -> bool {
334 include_defaults
335 && project
336 .root
337 .as_ref()
338 .and_then(|cfg| cfg.default_rules)
339 .unwrap_or(true)
340}
341
342pub fn load_from_str(
343 raw: &str,
344 path: &str,
345 default_rules: Option<bool>,
346) -> Result<Config, ConfigError> {
347 let user: Config = toml::from_str(raw).map_err(|error| ConfigError::UserConfig {
348 path: path.to_string(),
349 error,
350 })?;
351 validate(&user, path)?;
352 let include_defaults = default_rules.unwrap_or_else(|| user.default_rules.unwrap_or(true));
353 load_with_project(
354 ProjectConfig {
355 root: Some(user),
356 fragments: Vec::new(),
357 },
358 include_defaults,
359 Vec::new(),
360 )
361}
362
363pub(crate) fn load_with_options(
367 user_path: Option<&Path>,
368 include_defaults: bool,
369) -> Result<Config, ConfigError> {
370 let project = read_project_config(user_path)?;
371 let include_defaults = include_defaults_from_project(&project, include_defaults);
372 load_with_project(project, include_defaults, Vec::new())
373}
374
375struct ProjectConfig {
376 root: Option<Config>,
377 fragments: Vec<fragments::FragmentFile>,
378}
379
380fn load_with_project(
381 project: ProjectConfig,
382 include_defaults: bool,
383 inline: Vec<Config>,
384) -> Result<Config, ConfigError> {
385 let mut cfg = if include_defaults {
386 load_default()?
387 } else {
388 Config::default()
389 };
390 cfg.default_rules = Some(include_defaults);
391 if let Some(user) = project.root {
392 merge_into(&mut cfg, user);
393 }
394 fragments::merge_into(&mut cfg, project.fragments)?;
395 for inline in inline {
396 merge_into(&mut cfg, inline);
397 }
398 Ok(cfg)
399}
400
401fn read_project_config(user_path: Option<&Path>) -> Result<ProjectConfig, ConfigError> {
402 let root = read_user_config(user_path)?;
403 let fragments = if root.is_some() {
404 fragments::read(user_path)?
405 } else {
406 Vec::new()
407 };
408 Ok(ProjectConfig { root, fragments })
409}
410
411fn read_user_config(user_path: Option<&Path>) -> Result<Option<Config>, ConfigError> {
412 let Some(p) = user_path else {
413 return Ok(None);
414 };
415 if !p.exists() {
416 return Ok(None);
417 }
418 let raw = std::fs::read_to_string(p).map_err(|error| ConfigError::Io {
419 path: p.display().to_string(),
420 error,
421 })?;
422 let user: Config = toml::from_str(&raw).map_err(|error| ConfigError::UserConfig {
423 path: p.display().to_string(),
424 error,
425 })?;
426 validate(&user, &p.display().to_string())?;
427 Ok(Some(user))
428}
429
430fn merge_into(base: &mut Config, ov: Config) {
431 for (k, v) in ov.aliases {
432 base.aliases.insert(k, v);
433 }
434 base.exclude.uris.extend(ov.exclude.uris);
435 for (k, v) in ov.profiles {
436 base.profiles.insert(k, v);
437 }
438 base.views.extend(ov.views);
439 merge_refs(&mut base.refs, ov.refs);
440 merge_shape_map(&mut base.shape, ov.shape);
441 merge_lang(&mut base.default, ov.default);
442 merge_lang(&mut base.ts, ov.ts);
443 merge_lang(&mut base.rust, ov.rust);
444 merge_lang(&mut base.java, ov.java);
445 merge_lang(&mut base.python, ov.python);
446 merge_lang(&mut base.go, ov.go);
447 merge_lang(&mut base.c, ov.c);
448 merge_lang(&mut base.cs, ov.cs);
449 merge_lang(&mut base.sql, ov.sql);
450}
451
452fn merge_refs(base: &mut RefsRules, ov: RefsRules) {
453 for ov_rule in ov.rules {
454 match ov_rule
455 .id
456 .as_deref()
457 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
458 {
459 Some(idx) => base.rules[idx] = ov_rule,
460 None => base.rules.push(ov_rule),
461 }
462 }
463}
464
465fn merge_lang(base: &mut LangRules, ov: LangRules) {
466 merge_shape_map(&mut base.shape, ov.shape);
467 for (kind, ov_rules) in ov.kinds {
468 match base.kinds.get_mut(&kind) {
469 Some(base_rules) => merge_kind(base_rules, ov_rules),
470 None => {
471 base.kinds.insert(kind, ov_rules);
472 }
473 }
474 }
475}
476
477fn merge_shape_map(base: &mut HashMap<String, KindRules>, ov: HashMap<String, KindRules>) {
478 for (shape, ov_rules) in ov {
479 match base.get_mut(&shape) {
480 Some(base_rules) => merge_kind(base_rules, ov_rules),
481 None => {
482 base.insert(shape, ov_rules);
483 }
484 }
485 }
486}
487
488fn merge_kind(base: &mut KindRules, ov: KindRules) {
492 for ov_rule in ov.rules {
493 match ov_rule
494 .id
495 .as_deref()
496 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
497 {
498 Some(idx) => base.rules[idx] = ov_rule,
499 None => base.rules.push(ov_rule),
500 }
501 }
502 if ov.require_doc_comment.is_some() {
503 base.require_doc_comment = ov.require_doc_comment;
504 }
505}
506
507pub(crate) fn resolve_aliases(
512 aliases: &HashMap<String, String>,
513) -> Result<HashMap<String, String>, ConfigError> {
514 let mut resolved: HashMap<String, String> = HashMap::new();
515 for name in aliases.keys() {
516 let mut stack: Vec<String> = Vec::new();
517 resolve_one(name, aliases, &mut resolved, &mut stack)?;
518 }
519 Ok(resolved)
520}
521
522fn resolve_one(
523 name: &str,
524 src: &HashMap<String, String>,
525 resolved: &mut HashMap<String, String>,
526 stack: &mut Vec<String>,
527) -> Result<String, ConfigError> {
528 if let Some(v) = resolved.get(name) {
529 return Ok(v.clone());
530 }
531 if stack.iter().any(|s| s == name) {
532 stack.push(name.to_string());
533 return Err(ConfigError::AliasCycle {
534 chain: stack.join(" → "),
535 });
536 }
537 let Some(body) = src.get(name) else {
538 return Err(ConfigError::UnknownAlias {
539 name: name.to_string(),
540 at: format!("alias `{}`", stack.last().unwrap_or(&"<root>".to_string())),
541 });
542 };
543 stack.push(name.to_string());
544 let expanded = expand_refs(body, src, resolved, stack)?;
545 stack.pop();
546 resolved.insert(name.to_string(), expanded.clone());
547 Ok(expanded)
548}
549
550fn expand_refs(
551 body: &str,
552 src: &HashMap<String, String>,
553 resolved: &mut HashMap<String, String>,
554 stack: &mut Vec<String>,
555) -> Result<String, ConfigError> {
556 let mut out = String::with_capacity(body.len());
557 let bytes = body.as_bytes();
558 let mut i = 0;
559 while i < bytes.len() {
560 if bytes[i] == b'$' {
561 let start = i + 1;
562 let mut j = start;
563 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
564 j += 1;
565 }
566 if j > start {
567 let name = &body[start..j];
568 let expanded = resolve_one(name, src, resolved, stack)?;
569 out.push('(');
570 out.push_str(&expanded);
571 out.push(')');
572 i = j;
573 continue;
574 }
575 }
576 out.push(bytes[i] as char);
577 i += 1;
578 }
579 Ok(out)
580}
581
582pub(crate) fn substitute_aliases(
585 expr: &str,
586 resolved: &HashMap<String, String>,
587 at: &str,
588) -> Result<String, ConfigError> {
589 let mut out = String::with_capacity(expr.len());
590 let bytes = expr.as_bytes();
591 let mut i = 0;
592 while i < bytes.len() {
593 if bytes[i] == b'$' {
594 let start = i + 1;
595 let mut j = start;
596 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
597 j += 1;
598 }
599 if j > start {
600 let name = &expr[start..j];
601 let Some(expanded) = resolved.get(name) else {
602 return Err(ConfigError::UnknownAlias {
603 name: name.to_string(),
604 at: at.to_string(),
605 });
606 };
607 out.push('(');
608 out.push_str(expanded);
609 out.push(')');
610 i = j;
611 continue;
612 }
613 }
614 out.push(bytes[i] as char);
615 i += 1;
616 }
617 Ok(out)
618}
619
620fn validate(cfg: &Config, path: &str) -> Result<(), ConfigError> {
622 resolve_aliases(&cfg.aliases)?;
623 validate_structure(cfg, path)
624}
625
626fn validate_structure(cfg: &Config, path: &str) -> Result<(), ConfigError> {
627 validate_shape_section(&cfg.shape, "shape", None)?;
628 if !cfg.default.shape.is_empty() {
629 return Err(ConfigError::DefaultShapeUnsupported);
630 }
631 validate_lang_section(
632 &cfg.default,
633 "default",
634 &allowed_kinds_set(None),
635 None,
636 path,
637 )?;
638 for lang in Lang::ALL {
639 let allowed = allowed_kinds_set(Some(*lang));
640 validate_lang_section(
641 cfg.for_lang(*lang),
642 config_section(*lang),
643 &allowed,
644 Some(*lang),
645 path,
646 )?;
647 }
648 Ok(())
649}
650
651fn validate_shape_section(
652 rules: &HashMap<String, KindRules>,
653 section: &str,
654 lang: Option<Lang>,
655) -> Result<(), ConfigError> {
656 for (shape, kr) in rules {
657 if !allowed_def_shape_names().contains(&shape.as_str()) {
658 return Err(ConfigError::UnknownShape {
659 section: section.to_string(),
660 shape: shape.clone(),
661 allowed: allowed_def_shape_names().join(", "),
662 });
663 }
664 if let Some(value) = &kr.require_doc_comment {
665 let allowed_vis = lang.map_or_else(allowed_doc_vis_any_lang, allowed_doc_vis_for);
666 if !allowed_vis.contains(&value.as_str()) {
667 return Err(ConfigError::UnknownDocVisibility {
668 section: section.to_string(),
669 kind: shape.clone(),
670 value: value.clone(),
671 allowed: allowed_vis.join(", "),
672 });
673 }
674 }
675 }
676 Ok(())
677}
678
679fn allowed_kinds_set(lang: Option<Lang>) -> Vec<&'static str> {
680 let mut out: Vec<&'static str> = INTERNAL_KINDS.to_vec();
681 if let Some(l) = lang {
682 out.extend(l.allowed_kinds().iter().copied());
683 } else {
684 for l in Lang::ALL {
685 out.extend(l.allowed_kinds().iter().copied());
686 }
687 }
688 out.sort();
689 out.dedup();
690 out
691}
692
693fn allowed_def_shape_names() -> Vec<&'static str> {
694 Shape::ALL
695 .iter()
696 .copied()
697 .filter(|shape| *shape != Shape::Ref)
698 .map(Shape::as_str)
699 .collect()
700}
701
702pub(crate) fn allowed_kinds_for(lang: Lang) -> Vec<&'static str> {
706 allowed_kinds_set(Some(lang))
707}
708
709fn allowed_doc_vis_for(lang: Lang) -> Vec<&'static str> {
712 let mut out: Vec<&'static str> = vec!["any"];
713 out.extend(lang.allowed_visibilities().iter().copied());
714 out
715}
716
717fn allowed_doc_vis_any_lang() -> Vec<&'static str> {
718 let mut out: Vec<&'static str> = vec!["any"];
719 for lang in Lang::ALL {
720 out.extend(lang.allowed_visibilities().iter().copied());
721 }
722 out.sort();
723 out.dedup();
724 out
725}
726
727pub(crate) fn config_section(lang: Lang) -> &'static str {
730 match lang {
731 Lang::Rs => "rust",
732 other => other.tag(),
733 }
734}
735
736fn validate_lang_section(
737 lr: &LangRules,
738 section: &str,
739 allowed: &[&str],
740 lang: Option<Lang>,
741 _path: &str,
742) -> Result<(), ConfigError> {
743 validate_shape_section(&lr.shape, &format!("{section}.shape"), lang)?;
744 for (kind, kr) in lr.kinds.iter() {
745 if RESERVED_LANG_KEYS.contains(&kind.as_str()) {
746 continue;
747 }
748 if !allowed.contains(&kind.as_str()) {
749 return Err(ConfigError::UnknownKind {
750 section: section.to_string(),
751 kind: kind.clone(),
752 allowed: allowed.join(", "),
753 });
754 }
755 if let (Some(value), Some(l)) = (&kr.require_doc_comment, lang) {
756 let allowed_vis = allowed_doc_vis_for(l);
757 if !allowed_vis.contains(&value.as_str()) {
758 return Err(ConfigError::UnknownDocVisibility {
759 section: section.to_string(),
760 kind: kind.clone(),
761 value: value.clone(),
762 allowed: allowed_vis.join(", "),
763 });
764 }
765 }
766 }
767 Ok(())
768}
769
770impl Config {
771 pub fn for_lang(&self, lang: Lang) -> &LangRules {
772 match lang {
773 Lang::Ts => &self.ts,
774 Lang::Rs => &self.rust,
775 Lang::Java => &self.java,
776 Lang::Python => &self.python,
777 Lang::Go => &self.go,
778 Lang::C => &self.c,
779 Lang::Cs => &self.cs,
780 Lang::Sql => &self.sql,
781 }
782 }
783
784 pub fn for_lang_mut(&mut self, lang: Lang) -> &mut LangRules {
785 match lang {
786 Lang::Ts => &mut self.ts,
787 Lang::Rs => &mut self.rust,
788 Lang::Java => &mut self.java,
789 Lang::Python => &mut self.python,
790 Lang::Go => &mut self.go,
791 Lang::C => &mut self.c,
792 Lang::Cs => &mut self.cs,
793 Lang::Sql => &mut self.sql,
794 }
795 }
796
797 #[cfg(test)]
798 pub fn rules_for(&self, lang: Lang, kind: &str) -> Option<&KindRules> {
799 self.for_lang(lang)
800 .kinds
801 .get(kind)
802 .or_else(|| self.default.kinds.get(kind))
803 }
804
805 pub fn apply_profile(&mut self, name: &str) -> Result<(), ConfigError> {
806 let profile = self
807 .profiles
808 .get(name)
809 .ok_or_else(|| ConfigError::UnknownProfile {
810 name: name.to_string(),
811 known: self.known_profiles(),
812 })?
813 .clone();
814 let enable = compile_patterns(&profile.enable, name, "enable")?;
815 let disable = compile_patterns(&profile.disable, name, "disable")?;
816 filter_rules(&mut self.refs.rules, "refs", &enable, &disable);
817 filter_shape_map(&mut self.shape, "shape", &enable, &disable);
818 filter_lang(&mut self.default, "default", &enable, &disable);
819 for lang in Lang::ALL {
820 filter_lang(
821 self.for_lang_mut(*lang),
822 config_section(*lang),
823 &enable,
824 &disable,
825 );
826 }
827 self.refresh_fragment_active_rules();
828 Ok(())
829 }
830
831 fn known_profiles(&self) -> String {
832 let mut names: Vec<&str> = self.profiles.keys().map(|s| s.as_str()).collect();
833 names.sort();
834 names.join(", ")
835 }
836
837 fn refresh_fragment_active_rules(&mut self) {
838 if self.fragments.is_empty() {
839 return;
840 }
841 let active_keys = collect_rule_keys(self);
842 for fragment in &mut self.fragments {
843 fragment.active_rules = if fragment.enabled {
844 fragment
845 .rule_keys
846 .iter()
847 .filter(|key| active_keys.contains(key.as_str()))
848 .count()
849 } else {
850 0
851 };
852 }
853 }
854}
855
856impl RuleEntry {
857 pub(crate) fn fallback_id(&self, idx: usize) -> String {
858 self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
859 }
860}
861
862fn compile_patterns(
863 patterns: &[String],
864 profile: &str,
865 field: &'static str,
866) -> Result<Vec<Regex>, ConfigError> {
867 patterns
868 .iter()
869 .map(|p| {
870 Regex::new(p).map_err(|error| ConfigError::BadProfileRegex {
871 profile: profile.to_string(),
872 field,
873 pattern: p.clone(),
874 error,
875 })
876 })
877 .collect()
878}
879
880fn filter_lang(lr: &mut LangRules, section: &str, enable: &[Regex], disable: &[Regex]) {
881 filter_shape_map(&mut lr.shape, &format!("{section}.shape"), enable, disable);
882 for (kind, kr) in lr.kinds.iter_mut() {
883 let prefix = format!("{section}.{kind}");
884 filter_rules(&mut kr.rules, &prefix, enable, disable);
885 }
886}
887
888fn filter_shape_map(
889 rules: &mut HashMap<String, KindRules>,
890 section: &str,
891 enable: &[Regex],
892 disable: &[Regex],
893) {
894 for (shape, kr) in rules.iter_mut() {
895 let prefix = format!("{section}.{shape}");
896 filter_rules(&mut kr.rules, &prefix, enable, disable);
897 }
898}
899
900fn filter_rules(rules: &mut Vec<RuleEntry>, prefix: &str, enable: &[Regex], disable: &[Regex]) {
901 if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
902 return;
903 }
904 let mut idx = 0;
905 rules.retain(|r| {
906 let full = format!("{prefix}.{}", r.fallback_id(idx));
907 idx += 1;
908 (enable.is_empty() || enable.iter().any(|re| re.is_match(&full)))
909 && !disable.iter().any(|re| re.is_match(&full))
910 });
911}
912
913fn collect_rule_keys(cfg: &Config) -> HashSet<String> {
914 let mut out = HashSet::new();
915 collect_rule_list_keys("refs", &cfg.refs.rules, &mut out);
916 for (shape, rules) in &cfg.shape {
917 collect_rule_list_keys(&format!("shape.{shape}"), &rules.rules, &mut out);
918 }
919 collect_lang_rule_keys("default", &cfg.default, &mut out);
920 for lang in Lang::ALL {
921 collect_lang_rule_keys(config_section(*lang), cfg.for_lang(*lang), &mut out);
922 }
923 out
924}
925
926fn collect_lang_rule_keys(section: &str, rules: &LangRules, out: &mut HashSet<String>) {
927 for (shape, kind_rules) in &rules.shape {
928 collect_rule_list_keys(&format!("{section}.shape.{shape}"), &kind_rules.rules, out);
929 }
930 for (kind, kind_rules) in &rules.kinds {
931 collect_rule_list_keys(&format!("{section}.{kind}"), &kind_rules.rules, out);
932 }
933}
934
935fn collect_rule_list_keys(prefix: &str, rules: &[RuleEntry], out: &mut HashSet<String>) {
936 for rule in rules {
937 if let Some(id) = &rule.id {
938 out.insert(format!("{prefix}.{id}"));
939 }
940 }
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 fn parse(s: &str) -> Result<Config, ConfigError> {
948 let cfg: Config = toml::from_str(s).map_err(|e| ConfigError::UserConfig {
949 path: "<test>".to_string(),
950 error: e,
951 })?;
952 validate(&cfg, "<test>")?;
953 Ok(cfg)
954 }
955
956 #[test]
957 fn embedded_default_parses() {
958 let cfg = load_default().expect("default preset must parse");
959 assert!(cfg.ts.kinds.contains_key("class"));
960 assert!(cfg.ts.kinds.contains_key("function"));
961 }
962
963 #[test]
964 fn ts_class_ships_at_least_one_rule_in_default() {
965 let cfg = load_default().unwrap();
966 let r = cfg.rules_for(Lang::Ts, "class").expect("ts.class present");
967 assert!(!r.rules.is_empty(), "preset must ship rules for ts.class");
968 }
969
970 #[test]
971 fn rules_for_falls_back_to_default_section() {
972 let cfg = parse(
973 r#"
974 [[default.module.where]]
975 id = "stub"
976 expr = "lines <= 99"
977
978 [[ts.class.where]]
979 expr = "name =~ ^X"
980 "#,
981 )
982 .unwrap();
983 let r = cfg
984 .rules_for(Lang::Ts, "module")
985 .expect("falls back to default.module");
986 assert_eq!(r.rules.len(), 1);
987 assert_eq!(r.rules[0].id.as_deref(), Some("stub"));
988 }
989
990 #[test]
991 fn parses_top_level_and_lang_shape_scopes() {
992 let cfg = parse(
993 r#"
994 [[shape.callable.where]]
995 id = "max-lines"
996 expr = "lines <= 60"
997
998 [[rust.shape.callable.where]]
999 id = "max-lines"
1000 expr = "lines <= 120"
1001 "#,
1002 )
1003 .unwrap();
1004 assert_eq!(cfg.shape["callable"].rules.len(), 1);
1005 assert_eq!(cfg.rust.shape["callable"].rules.len(), 1);
1006 }
1007
1008 #[test]
1009 fn unknown_shape_scope_is_rejected() {
1010 let r = parse(
1011 r#"
1012 [[shape.ref.where]]
1013 id = "nope"
1014 expr = "lines <= 1"
1015 "#,
1016 );
1017 match r {
1018 Err(ConfigError::UnknownShape { shape, .. }) => assert_eq!(shape, "ref"),
1019 other => panic!("expected UnknownShape, got {other:?}"),
1020 }
1021 }
1022
1023 #[test]
1024 fn default_shape_scope_is_rejected() {
1025 let r = parse(
1026 r#"
1027 [[default.shape.callable.where]]
1028 id = "nope"
1029 expr = "lines <= 1"
1030 "#,
1031 );
1032 assert!(matches!(r, Err(ConfigError::DefaultShapeUnsupported)));
1033 }
1034
1035 #[test]
1036 fn override_with_same_id_replaces_preset_rule() {
1037 let user = parse(
1038 r#"
1039 [[ts.function.where]]
1040 id = "max-lines"
1041 expr = "lines <= 999"
1042 "#,
1043 )
1044 .unwrap();
1045 let mut base = parse(
1046 r#"
1047 [[ts.function.where]]
1048 id = "name-camel"
1049 expr = "name =~ ^[a-z]"
1050
1051 [[ts.function.where]]
1052 id = "max-lines"
1053 expr = "lines <= 60"
1054 "#,
1055 )
1056 .unwrap();
1057 merge_into(&mut base, user);
1058 let f = base.rules_for(Lang::Ts, "function").unwrap();
1059 assert_eq!(f.rules.len(), 2, "id-matched override replaces in place");
1060 let max_lines = f
1061 .rules
1062 .iter()
1063 .find(|r| r.id.as_deref() == Some("max-lines"))
1064 .unwrap();
1065 assert!(max_lines.expr.contains("999"), "user override applied");
1066 assert!(
1067 f.rules
1068 .iter()
1069 .any(|r| r.id.as_deref() == Some("name-camel")),
1070 "sibling rule preserved"
1071 );
1072 }
1073
1074 #[test]
1075 fn override_with_new_id_appends_to_preset() {
1076 let user = parse(
1077 r#"
1078 [[ts.class.where]]
1079 id = "extra"
1080 expr = "name !~ ^Internal"
1081 "#,
1082 )
1083 .unwrap();
1084 let mut base = parse(
1085 r#"
1086 [[ts.class.where]]
1087 id = "name-pascal"
1088 expr = "name =~ ^[A-Z]"
1089 "#,
1090 )
1091 .unwrap();
1092 merge_into(&mut base, user);
1093 let r = base.rules_for(Lang::Ts, "class").unwrap();
1094 assert_eq!(r.rules.len(), 2);
1095 }
1096
1097 #[test]
1098 fn unknown_field_in_kind_rules_is_rejected() {
1099 let r = toml::from_str::<Config>(
1100 r#"
1101 [ts.function]
1102 max_lines = 10
1103 "#,
1104 );
1105 assert!(r.is_err(), "deny_unknown_fields rejects legacy fields");
1106 }
1107
1108 #[test]
1109 fn alias_section_parses() {
1110 let cfg = parse(
1111 r#"
1112 [aliases]
1113 domain = "moniker ~ '**/module:domain/**'"
1114 "#,
1115 )
1116 .unwrap();
1117 assert_eq!(
1118 cfg.aliases.get("domain").map(|s| s.as_str()),
1119 Some("moniker ~ '**/module:domain/**'"),
1120 );
1121 }
1122
1123 #[test]
1124 fn alias_cycle_is_rejected() {
1125 let r = parse(
1126 r#"
1127 [aliases]
1128 a = "$b"
1129 b = "$a"
1130 "#,
1131 );
1132 match r {
1133 Err(ConfigError::AliasCycle { chain }) => {
1134 assert!(chain.contains("a") && chain.contains("b"), "{chain}");
1135 }
1136 other => panic!("expected AliasCycle, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn alias_chain_resolves() {
1142 let cfg = parse(
1143 r#"
1144 [aliases]
1145 a = "name = 'X'"
1146 b = "$a OR name = 'Y'"
1147 c = "$b AND lines <= 10"
1148 "#,
1149 )
1150 .unwrap();
1151 let resolved = resolve_aliases(&cfg.aliases).unwrap();
1152 let final_c = resolved.get("c").unwrap();
1153 assert!(final_c.contains("name = 'X'"), "{final_c}");
1154 assert!(final_c.contains("name = 'Y'"), "{final_c}");
1155 assert!(final_c.contains("lines <= 10"), "{final_c}");
1156 }
1157
1158 #[test]
1159 fn alias_substitution_wraps_in_parens() {
1160 let mut src = HashMap::new();
1162 src.insert("x".to_string(), "A AND B".to_string());
1163 let resolved = resolve_aliases(&src).unwrap();
1164 let out = substitute_aliases("$x OR C", &resolved, "test").unwrap();
1165 assert_eq!(out, "(A AND B) OR C");
1166 }
1167
1168 #[test]
1169 fn unknown_alias_is_rejected_at_substitution() {
1170 let resolved = HashMap::new();
1171 match substitute_aliases("$bogus AND name = 'X'", &resolved, "ts.class.r1") {
1172 Err(ConfigError::UnknownAlias { name, at }) => {
1173 assert_eq!(name, "bogus");
1174 assert_eq!(at, "ts.class.r1");
1175 }
1176 other => panic!("expected UnknownAlias, got {other:?}"),
1177 }
1178 }
1179
1180 #[test]
1181 fn unknown_top_level_lang_section_is_rejected() {
1182 let r = toml::from_str::<Config>(
1183 r#"
1184 [[typescript.class.where]]
1185 expr = "name =~ ^[A-Z]"
1186 "#,
1187 );
1188 assert!(
1189 r.is_err(),
1190 "deny_unknown_fields must reject unknown lang sections"
1191 );
1192 }
1193
1194 #[test]
1195 fn unknown_require_doc_visibility_is_rejected() {
1196 let r = parse(
1197 r#"
1198 [ts.class]
1199 require_doc_comment = "publc"
1200 "#,
1201 );
1202 match r {
1203 Err(ConfigError::UnknownDocVisibility { value, .. }) => assert_eq!(value, "publc"),
1204 other => panic!("expected UnknownDocVisibility, got {other:?}"),
1205 }
1206 }
1207
1208 #[test]
1209 fn doc_visibility_any_is_accepted() {
1210 let r = parse(
1211 r#"
1212 [ts.class]
1213 require_doc_comment = "any"
1214 "#,
1215 );
1216 assert!(r.is_ok(), "any is always valid");
1217 }
1218
1219 #[test]
1220 fn unknown_kind_section_is_rejected() {
1221 let r = parse(
1222 r#"
1223 [[ts.classs.where]]
1224 expr = "name =~ ^X"
1225 "#,
1226 );
1227 match r {
1228 Err(ConfigError::UnknownKind { kind, .. }) => assert_eq!(kind, "classs"),
1229 other => panic!("expected UnknownKind, got {other:?}"),
1230 }
1231 }
1232
1233 #[test]
1234 fn missing_user_file_is_not_an_error() {
1235 let cfg = load_with_overrides(Some(Path::new("/no/such/file.toml")))
1236 .expect("missing file falls back to defaults");
1237 assert!(cfg.ts.kinds.contains_key("class"));
1238 }
1239
1240 #[test]
1241 fn missing_user_file_without_defaults_is_empty() {
1242 let cfg = load_with_options(Some(Path::new("/no/such/file.toml")), false)
1243 .expect("missing file is still accepted without defaults");
1244 assert!(cfg.refs.rules.is_empty());
1245 assert!(cfg.ts.kinds.is_empty());
1246 }
1247
1248 #[test]
1249 fn missing_user_file_does_not_discover_fragments() {
1250 let dir = tempfile::tempdir().unwrap();
1251 let missing_root = dir.path().join(".code-moniker.toml");
1252 write_fragment(
1253 dir.path(),
1254 "src",
1255 r#"
1256 fragment = "local"
1257
1258 [[rust.fn.where]]
1259 id = "parked"
1260 expr = "lines <= 10"
1261 "#,
1262 );
1263
1264 let cfg = load_with_options(Some(&missing_root), false).expect("missing root loads empty");
1265
1266 assert!(cfg.refs.rules.is_empty());
1267 assert!(cfg.rust.kinds.is_empty());
1268 assert!(cfg.fragments.is_empty());
1269 }
1270
1271 #[test]
1272 fn user_config_can_disable_embedded_default_rules() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let p = dir.path().join(".code-moniker.toml");
1275 std::fs::write(&p, "default_rules = false\n").unwrap();
1276
1277 let cfg = load_with_overrides(Some(&p)).expect("config loads");
1278
1279 assert!(cfg.refs.rules.is_empty());
1280 assert!(cfg.ts.kinds.is_empty());
1281 assert_eq!(cfg.default_rules, Some(false));
1282 }
1283
1284 #[test]
1285 fn command_line_default_rules_off_wins_over_config_flag() {
1286 let dir = tempfile::tempdir().unwrap();
1287 let p = dir.path().join(".code-moniker.toml");
1288 std::fs::write(&p, "default_rules = true\n").unwrap();
1289
1290 let cfg = load_with_options(Some(&p), false).expect("config loads");
1291
1292 assert!(cfg.refs.rules.is_empty());
1293 assert!(cfg.ts.kinds.is_empty());
1294 assert_eq!(cfg.default_rules, Some(false));
1295 }
1296
1297 #[test]
1298 fn inline_default_rules_flag_can_disable_embedded_default_rules() {
1299 let inline = vec!["default_rules = false\n".to_string()];
1300
1301 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, None)
1302 .expect("inline config loads");
1303
1304 assert!(cfg.refs.rules.is_empty());
1305 assert!(cfg.ts.kinds.is_empty());
1306 assert_eq!(cfg.default_rules, Some(false));
1307 }
1308
1309 #[test]
1310 fn command_line_default_rules_on_wins_over_inline_flag() {
1311 let inline = vec!["default_rules = false\n".to_string()];
1312
1313 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, Some(true))
1314 .expect("inline config loads");
1315
1316 assert!(cfg.ts.kinds.contains_key("class"));
1317 assert_eq!(cfg.default_rules, Some(true));
1318 }
1319
1320 #[test]
1321 fn inline_rules_override_project_rule_by_same_id() {
1322 let dir = tempfile::tempdir().unwrap();
1323 let p = dir.path().join(".code-moniker.toml");
1324 std::fs::write(
1325 &p,
1326 r#"
1327 default_rules = false
1328
1329 [[ts.class.where]]
1330 id = "name-policy"
1331 expr = "name =~ ^Good"
1332 "#,
1333 )
1334 .unwrap();
1335 let inline = vec![
1336 r#"
1337 [[ts.class.where]]
1338 id = "name-policy"
1339 expr = "name =~ ^Inline"
1340 "#
1341 .to_string(),
1342 ];
1343
1344 let cfg = load_with_cli_sources(Some(&p), &inline, None).expect("inline config loads");
1345 let rule = cfg
1346 .rules_for(Lang::Ts, "class")
1347 .unwrap()
1348 .rules
1349 .iter()
1350 .find(|rule| rule.id.as_deref() == Some("name-policy"))
1351 .unwrap();
1352
1353 assert_eq!(rule.expr, "name =~ ^Inline");
1354 }
1355
1356 #[test]
1357 fn repeated_inline_rules_merge_in_order() {
1358 let inline = vec![
1359 r#"
1360 default_rules = false
1361
1362 [[ts.class.where]]
1363 id = "inline-name"
1364 expr = "name =~ ^First"
1365 "#
1366 .to_string(),
1367 r#"
1368 [[ts.class.where]]
1369 id = "inline-name"
1370 expr = "name =~ ^Second"
1371 "#
1372 .to_string(),
1373 ];
1374
1375 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, None)
1376 .expect("inline config loads");
1377 let rule = cfg
1378 .rules_for(Lang::Ts, "class")
1379 .unwrap()
1380 .rules
1381 .iter()
1382 .find(|rule| rule.id.as_deref() == Some("inline-name"))
1383 .unwrap();
1384
1385 assert_eq!(rule.expr, "name =~ ^Second");
1386 }
1387
1388 #[test]
1389 fn malformed_user_file_returns_user_config_error() {
1390 let dir = tempfile::tempdir().unwrap();
1391 let p = dir.path().join("bad.toml");
1392 std::fs::write(&p, "this is not toml = = =").unwrap();
1393 match load_with_overrides(Some(&p)) {
1394 Err(ConfigError::UserConfig { .. }) => {}
1395 other => panic!("expected UserConfig error, got {other:?}"),
1396 }
1397 }
1398
1399 fn write_fragment(root: &Path, rel_dir: &str, body: &str) -> std::path::PathBuf {
1400 let dir = root.join(rel_dir);
1401 std::fs::create_dir_all(&dir).unwrap();
1402 let path = dir.join("code-moniker.fragment.toml");
1403 std::fs::write(&path, body).unwrap();
1404 path
1405 }
1406
1407 #[test]
1408 fn fragment_rules_are_loaded_with_fragment_namespace() {
1409 let dir = tempfile::tempdir().unwrap();
1410 let root = dir.path().join(".code-moniker.toml");
1411 std::fs::write(
1412 &root,
1413 r#"
1414 default_rules = false
1415
1416 [aliases]
1417 local_name = "name =~ ^[a-z_]"
1418 "#,
1419 )
1420 .unwrap();
1421 let fragment_path = write_fragment(
1422 dir.path(),
1423 "crates/check/src/check",
1424 r#"
1425 fragment = "check"
1426
1427 [[rust.fn.where]]
1428 id = "parser-only"
1429 expr = "$local_name"
1430 "#,
1431 );
1432
1433 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1434
1435 assert_eq!(cfg.fragments.len(), 1);
1436 assert_eq!(cfg.fragments[0].id, "check");
1437 assert_eq!(cfg.fragments[0].path, fragment_path);
1438 assert!(cfg.fragments[0].enabled);
1439 assert_eq!(cfg.fragments[0].declared_rules, 1);
1440 assert_eq!(cfg.fragments[0].active_rules, 1);
1441 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1442 let ids: Vec<_> = compiled
1443 .specs(Lang::Rs)
1444 .into_iter()
1445 .map(|rule| rule.rule_id)
1446 .collect();
1447 assert!(
1448 ids.iter().any(|id| id == "rust.fn.check.parser-only"),
1449 "{ids:?}"
1450 );
1451 }
1452
1453 #[test]
1454 fn disabled_fragment_is_reported_but_not_merged() {
1455 let dir = tempfile::tempdir().unwrap();
1456 let root = dir.path().join(".code-moniker.toml");
1457 std::fs::write(&root, "default_rules = false\n").unwrap();
1458 write_fragment(
1459 dir.path(),
1460 "src",
1461 r#"
1462 fragment = "local"
1463 enabled = false
1464
1465 [[rust.fn.where]]
1466 id = "parked"
1467 expr = "$missing_while_disabled"
1468 "#,
1469 );
1470
1471 let cfg = load_with_overrides(Some(&root)).expect("disabled fragment loads");
1472
1473 assert_eq!(cfg.fragments.len(), 1);
1474 assert_eq!(cfg.fragments[0].id, "local");
1475 assert!(!cfg.fragments[0].enabled);
1476 assert_eq!(cfg.fragments[0].declared_rules, 1);
1477 assert_eq!(cfg.fragments[0].active_rules, 0);
1478 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1479 let ids: Vec<_> = compiled
1480 .specs(Lang::Rs)
1481 .into_iter()
1482 .map(|rule| rule.rule_id)
1483 .collect();
1484 assert!(
1485 !ids.iter().any(|id| id == "rust.fn.local.parked"),
1486 "{ids:?}"
1487 );
1488 }
1489
1490 #[test]
1491 fn profile_recomputes_fragment_active_rules() {
1492 let dir = tempfile::tempdir().unwrap();
1493 let root = dir.path().join(".code-moniker.toml");
1494 std::fs::write(
1495 &root,
1496 r#"
1497 default_rules = false
1498
1499 [profiles.none]
1500 disable = ["^rust\\.fn\\.local\\.parked$"]
1501 "#,
1502 )
1503 .unwrap();
1504 write_fragment(
1505 dir.path(),
1506 "src",
1507 r#"
1508 fragment = "local"
1509
1510 [[rust.fn.where]]
1511 id = "parked"
1512 expr = "lines <= 10"
1513 "#,
1514 );
1515 let mut cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1516
1517 cfg.apply_profile("none").expect("profile applies");
1518
1519 assert_eq!(cfg.fragments[0].declared_rules, 1);
1520 assert_eq!(cfg.fragments[0].active_rules, 0);
1521 }
1522
1523 #[test]
1524 fn disabled_fragment_still_rejects_missing_rule_ids() {
1525 let dir = tempfile::tempdir().unwrap();
1526 let root = dir.path().join(".code-moniker.toml");
1527 std::fs::write(&root, "default_rules = false\n").unwrap();
1528 write_fragment(
1529 dir.path(),
1530 "src",
1531 r#"
1532 fragment = "local"
1533 enabled = false
1534
1535 [[rust.fn.where]]
1536 expr = "lines <= 10"
1537 "#,
1538 );
1539
1540 match load_with_overrides(Some(&root)) {
1541 Err(ConfigError::FragmentRuleMissingId { fragment, at, .. }) => {
1542 assert_eq!(fragment, "local");
1543 assert_eq!(at, "rust.fn");
1544 }
1545 other => panic!("expected FragmentRuleMissingId error, got {other:?}"),
1546 }
1547 }
1548
1549 #[test]
1550 fn fragment_local_aliases_are_namespaced_and_usable() {
1551 let dir = tempfile::tempdir().unwrap();
1552 let root = dir.path().join(".code-moniker.toml");
1553 std::fs::write(&root, "default_rules = false\n").unwrap();
1554 write_fragment(
1555 dir.path(),
1556 "src",
1557 r#"
1558 fragment = "local"
1559
1560 [aliases]
1561 local_name = "name = 'Ok'"
1562
1563 [[rust.fn.where]]
1564 id = "uses-local"
1565 expr = "$local_name"
1566 "#,
1567 );
1568
1569 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1570
1571 assert_eq!(
1572 cfg.aliases.get("local_local_name").map(|s| s.as_str()),
1573 Some("name = 'Ok'")
1574 );
1575 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1576 let specs = compiled.specs(Lang::Rs);
1577 let rule = specs
1578 .iter()
1579 .find(|rule| rule.rule_id == "rust.fn.local.uses-local")
1580 .expect("fragment rule is compiled");
1581 assert!(
1582 rule.expanded_expr.contains("name = 'Ok'"),
1583 "{}",
1584 rule.expanded_expr
1585 );
1586 }
1587
1588 #[test]
1589 fn fragment_local_alias_can_reference_global_alias() {
1590 let dir = tempfile::tempdir().unwrap();
1591 let root = dir.path().join(".code-moniker.toml");
1592 std::fs::write(
1593 &root,
1594 r#"
1595 default_rules = false
1596
1597 [aliases]
1598 global_name = "name = 'Ok'"
1599 "#,
1600 )
1601 .unwrap();
1602 write_fragment(
1603 dir.path(),
1604 "src",
1605 r#"
1606 fragment = "local"
1607
1608 [aliases]
1609 local_name = "$global_name"
1610
1611 [[rust.fn.where]]
1612 id = "uses-local"
1613 expr = "$local_name"
1614 "#,
1615 );
1616
1617 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1618 let resolved = resolve_aliases(&cfg.aliases).expect("aliases resolve");
1619 assert_eq!(
1620 resolved.get("local_local_name").map(|s| s.as_str()),
1621 Some("(name = 'Ok')")
1622 );
1623 }
1624
1625 #[test]
1626 fn fragment_local_alias_can_reference_another_local_alias() {
1627 let dir = tempfile::tempdir().unwrap();
1628 let root = dir.path().join(".code-moniker.toml");
1629 std::fs::write(&root, "default_rules = false\n").unwrap();
1630 write_fragment(
1631 dir.path(),
1632 "src",
1633 r#"
1634 fragment = "local"
1635
1636 [aliases]
1637 leaf = "name = 'Ok'"
1638 composed = "$leaf AND lines <= 10"
1639
1640 [[rust.fn.where]]
1641 id = "uses-composed"
1642 expr = "$composed"
1643 "#,
1644 );
1645
1646 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1647
1648 assert_eq!(
1649 cfg.aliases.get("local_composed").map(|s| s.as_str()),
1650 Some("$local_leaf AND lines <= 10")
1651 );
1652 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1653 let specs = compiled.specs(Lang::Rs);
1654 let rule = specs
1655 .iter()
1656 .find(|rule| rule.rule_id == "rust.fn.local.uses-composed")
1657 .expect("fragment rule is compiled");
1658 assert!(
1659 rule.expanded_expr.contains("name = 'Ok'"),
1660 "{}",
1661 rule.expanded_expr
1662 );
1663 assert!(
1664 rule.expanded_expr.contains("lines <= 10"),
1665 "{}",
1666 rule.expanded_expr
1667 );
1668 }
1669
1670 #[test]
1671 fn fragment_aliases_cannot_reference_other_fragments() {
1672 let dir = tempfile::tempdir().unwrap();
1673 let root = dir.path().join(".code-moniker.toml");
1674 std::fs::write(&root, "default_rules = false\n").unwrap();
1675 write_fragment(
1676 dir.path(),
1677 "a",
1678 r#"
1679 fragment = "first"
1680
1681 [aliases]
1682 shared = "name = 'Shared'"
1683 "#,
1684 );
1685 write_fragment(
1686 dir.path(),
1687 "b",
1688 r#"
1689 fragment = "second"
1690
1691 [aliases]
1692 local = "$first_shared"
1693
1694 [[rust.fn.where]]
1695 id = "uses-local"
1696 expr = "$local"
1697 "#,
1698 );
1699
1700 match load_with_overrides(Some(&root)) {
1701 Err(ConfigError::UnknownAlias { name, at }) => {
1702 assert_eq!(name, "first_shared");
1703 assert_eq!(at, "alias `second_local`");
1704 }
1705 other => panic!("expected UnknownAlias error, got {other:?}"),
1706 }
1707 }
1708
1709 #[test]
1710 fn fragment_alias_local_name_must_not_shadow_existing_alias() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let root = dir.path().join(".code-moniker.toml");
1713 std::fs::write(
1714 &root,
1715 r#"
1716 default_rules = false
1717
1718 [aliases]
1719 shared = "name = 'Global'"
1720 "#,
1721 )
1722 .unwrap();
1723 write_fragment(
1724 dir.path(),
1725 "src",
1726 r#"
1727 fragment = "local"
1728
1729 [aliases]
1730 shared = "name = 'Local'"
1731 "#,
1732 );
1733
1734 match load_with_overrides(Some(&root)) {
1735 Err(ConfigError::FragmentAliasShadowsExisting {
1736 fragment, alias, ..
1737 }) => {
1738 assert_eq!(fragment, "local");
1739 assert_eq!(alias, "shared");
1740 }
1741 other => panic!("expected FragmentAliasShadowsExisting error, got {other:?}"),
1742 }
1743 }
1744
1745 #[test]
1746 fn fragment_alias_effective_key_collision_is_rejected() {
1747 let dir = tempfile::tempdir().unwrap();
1748 let root = dir.path().join(".code-moniker.toml");
1749 std::fs::write(
1750 &root,
1751 r#"
1752 default_rules = false
1753
1754 [aliases]
1755 local_shared = "name = 'Global'"
1756 "#,
1757 )
1758 .unwrap();
1759 write_fragment(
1760 dir.path(),
1761 "src",
1762 r#"
1763 fragment = "local"
1764
1765 [aliases]
1766 shared = "name = 'Local'"
1767 "#,
1768 );
1769
1770 match load_with_overrides(Some(&root)) {
1771 Err(ConfigError::FragmentAliasCollision {
1772 alias, existing, ..
1773 }) => {
1774 assert_eq!(alias, "local_shared");
1775 assert_eq!(existing, "<effective config>");
1776 }
1777 other => panic!("expected FragmentAliasCollision error, got {other:?}"),
1778 }
1779 }
1780
1781 #[test]
1782 fn fragment_alias_ids_must_match_reference_grammar() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let root = dir.path().join(".code-moniker.toml");
1785 std::fs::write(&root, "default_rules = false\n").unwrap();
1786 write_fragment(
1787 dir.path(),
1788 "src",
1789 r#"
1790 fragment = "local"
1791
1792 [aliases]
1793 "bad-name" = "name = 'X'"
1794 "#,
1795 );
1796
1797 match load_with_overrides(Some(&root)) {
1798 Err(ConfigError::InvalidFragmentAliasId {
1799 fragment, alias, ..
1800 }) => {
1801 assert_eq!(fragment, "local");
1802 assert_eq!(alias, "bad-name");
1803 }
1804 other => panic!("expected InvalidFragmentAliasId error, got {other:?}"),
1805 }
1806 }
1807
1808 #[test]
1809 fn duplicate_fragment_ids_are_rejected() {
1810 let dir = tempfile::tempdir().unwrap();
1811 let root = dir.path().join(".code-moniker.toml");
1812 std::fs::write(&root, "default_rules = false\n").unwrap();
1813 write_fragment(dir.path(), "a", "fragment = \"local\"\n");
1814 write_fragment(dir.path(), "b", "fragment = \"local\"\n");
1815
1816 match load_with_overrides(Some(&root)) {
1817 Err(ConfigError::DuplicateFragment { id, first, second }) => {
1818 assert_eq!(id, "local");
1819 assert!(first.ends_with("a/code-moniker.fragment.toml"), "{first}");
1820 assert!(second.ends_with("b/code-moniker.fragment.toml"), "{second}");
1821 }
1822 other => panic!("expected DuplicateFragment error, got {other:?}"),
1823 }
1824 }
1825
1826 #[test]
1827 fn fragment_rules_must_have_explicit_ids() {
1828 let dir = tempfile::tempdir().unwrap();
1829 let root = dir.path().join(".code-moniker.toml");
1830 std::fs::write(&root, "default_rules = false\n").unwrap();
1831 write_fragment(
1832 dir.path(),
1833 "src",
1834 r#"
1835 fragment = "local"
1836
1837 [[rust.fn.where]]
1838 expr = "lines <= 10"
1839 "#,
1840 );
1841
1842 match load_with_overrides(Some(&root)) {
1843 Err(ConfigError::FragmentRuleMissingId { fragment, at, .. }) => {
1844 assert_eq!(fragment, "local");
1845 assert_eq!(at, "rust.fn");
1846 }
1847 other => panic!("expected FragmentRuleMissingId error, got {other:?}"),
1848 }
1849 }
1850
1851 #[test]
1852 fn fragment_rule_collisions_are_rejected() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let root = dir.path().join(".code-moniker.toml");
1855 std::fs::write(
1856 &root,
1857 r#"
1858 default_rules = false
1859
1860 [[rust.fn.where]]
1861 id = "local.small"
1862 expr = "lines <= 10"
1863 "#,
1864 )
1865 .unwrap();
1866 write_fragment(
1867 dir.path(),
1868 "src",
1869 r#"
1870 fragment = "local"
1871
1872 [[rust.fn.where]]
1873 id = "small"
1874 expr = "lines <= 20"
1875 "#,
1876 );
1877
1878 match load_with_overrides(Some(&root)) {
1879 Err(ConfigError::FragmentRuleCollision {
1880 rule_id, existing, ..
1881 }) => {
1882 assert_eq!(rule_id, "rust.fn.local.small");
1883 assert_eq!(existing, "<effective config>");
1884 }
1885 other => panic!("expected FragmentRuleCollision error, got {other:?}"),
1886 }
1887 }
1888
1889 #[test]
1890 fn fragment_unknown_alias_is_reported_at_fragment_rule() {
1891 let dir = tempfile::tempdir().unwrap();
1892 let root = dir.path().join(".code-moniker.toml");
1893 std::fs::write(&root, "default_rules = false\n").unwrap();
1894 write_fragment(
1895 dir.path(),
1896 "src",
1897 r#"
1898 fragment = "local"
1899
1900 [[rust.fn.where]]
1901 id = "uses-alias"
1902 expr = "$missing_alias"
1903 "#,
1904 );
1905
1906 match load_with_overrides(Some(&root)) {
1907 Err(ConfigError::UnknownAlias { name, at }) => {
1908 assert_eq!(name, "missing_alias");
1909 assert!(at.contains("code-moniker.fragment.toml:rust.fn.local.uses-alias"));
1910 }
1911 other => panic!("expected UnknownAlias error, got {other:?}"),
1912 }
1913 }
1914
1915 #[test]
1916 fn profile_enable_filters_in() {
1917 let mut cfg = parse(
1918 r#"
1919 [[ts.class.where]]
1920 id = "keep"
1921 expr = "lines <= 99"
1922
1923 [[ts.class.where]]
1924 id = "drop"
1925 expr = "lines <= 99"
1926
1927 [profiles.only_keep]
1928 enable = ["\\.keep$"]
1929 "#,
1930 )
1931 .unwrap();
1932 cfg.apply_profile("only_keep").unwrap();
1933 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
1934 assert_eq!(r.rules.len(), 1);
1935 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
1936 }
1937
1938 #[test]
1939 fn profile_disable_filters_out() {
1940 let mut cfg = parse(
1941 r#"
1942 [[ts.class.where]]
1943 id = "keep"
1944 expr = "lines <= 99"
1945
1946 [[ts.class.where]]
1947 id = "drop"
1948 expr = "lines <= 99"
1949
1950 [profiles.drop_one]
1951 disable = ["\\.drop$"]
1952 "#,
1953 )
1954 .unwrap();
1955 cfg.apply_profile("drop_one").unwrap();
1956 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
1957 assert_eq!(r.rules.len(), 1);
1958 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
1959 }
1960
1961 #[test]
1962 fn profile_enable_then_disable() {
1963 let mut cfg = parse(
1964 r#"
1965 [[ts.class.where]]
1966 id = "a"
1967 expr = "lines <= 99"
1968
1969 [[ts.class.where]]
1970 id = "b"
1971 expr = "lines <= 99"
1972
1973 [[ts.class.where]]
1974 id = "c"
1975 expr = "lines <= 99"
1976
1977 [profiles.p]
1978 enable = ["ts\\.class\\.(a|b)$"]
1979 disable = ["ts\\.class\\.b$"]
1980 "#,
1981 )
1982 .unwrap();
1983 cfg.apply_profile("p").unwrap();
1984 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
1985 assert_eq!(r.rules.len(), 1);
1986 assert_eq!(r.rules[0].id.as_deref(), Some("a"));
1987 }
1988
1989 #[test]
1990 fn profile_filters_refs_top_level() {
1991 let mut cfg = parse(
1992 r#"
1993 [[refs.where]]
1994 id = "stay"
1995 expr = "kind = 'call'"
1996
1997 [[refs.where]]
1998 id = "go"
1999 expr = "kind = 'call'"
2000
2001 [profiles.p]
2002 disable = ["^refs\\.go$"]
2003 "#,
2004 )
2005 .unwrap();
2006 cfg.apply_profile("p").unwrap();
2007 assert_eq!(cfg.refs.rules.len(), 1);
2008 assert_eq!(cfg.refs.rules[0].id.as_deref(), Some("stay"));
2009 }
2010
2011 #[test]
2012 fn profile_filters_per_lang_refs() {
2013 let mut cfg = parse(
2014 r#"
2015 [[ts.refs.where]]
2016 id = "stay"
2017 expr = "kind = 'call'"
2018
2019 [[ts.refs.where]]
2020 id = "go"
2021 expr = "kind = 'call'"
2022
2023 [profiles.p]
2024 disable = ["^ts\\.refs\\.go$"]
2025 "#,
2026 )
2027 .unwrap();
2028 cfg.apply_profile("p").unwrap();
2029 let r = cfg.ts.kinds.get("refs").unwrap();
2030 assert_eq!(r.rules.len(), 1);
2031 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
2032 }
2033
2034 #[test]
2035 fn profile_filters_shape_scopes() {
2036 let mut cfg = parse(
2037 r#"
2038 [[shape.callable.where]]
2039 id = "stay"
2040 expr = "lines <= 99"
2041
2042 [[shape.callable.where]]
2043 id = "go"
2044 expr = "lines <= 99"
2045
2046 [[ts.shape.type.where]]
2047 id = "go"
2048 expr = "lines <= 99"
2049
2050 [profiles.p]
2051 disable = ["^shape\\.callable\\.go$", "^ts\\.shape\\.type\\.go$"]
2052 "#,
2053 )
2054 .unwrap();
2055 cfg.apply_profile("p").unwrap();
2056 assert_eq!(cfg.shape["callable"].rules.len(), 1);
2057 assert_eq!(cfg.shape["callable"].rules[0].id.as_deref(), Some("stay"));
2058 assert!(cfg.ts.shape["type"].rules.is_empty());
2059 }
2060
2061 #[test]
2062 fn profile_filters_default_section() {
2063 let mut cfg = parse(
2064 r#"
2065 [[default.module.where]]
2066 id = "stay"
2067 expr = "lines <= 99"
2068
2069 [[default.module.where]]
2070 id = "go"
2071 expr = "lines <= 99"
2072
2073 [profiles.p]
2074 disable = ["^default\\.module\\.go$"]
2075 "#,
2076 )
2077 .unwrap();
2078 cfg.apply_profile("p").unwrap();
2079 let r = cfg.default.kinds.get("module").unwrap();
2080 assert_eq!(r.rules.len(), 1);
2081 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
2082 }
2083
2084 #[test]
2085 fn unknown_profile_returns_error() {
2086 let mut cfg = parse(
2087 r#"
2088 [profiles.known]
2089 disable = []
2090 "#,
2091 )
2092 .unwrap();
2093 match cfg.apply_profile("nope") {
2094 Err(ConfigError::UnknownProfile { name, known }) => {
2095 assert_eq!(name, "nope");
2096 assert!(known.contains("known"), "{known}");
2097 }
2098 other => panic!("expected UnknownProfile, got {other:?}"),
2099 }
2100 }
2101
2102 #[test]
2103 fn bad_regex_returns_error() {
2104 let mut cfg = parse(
2105 r#"
2106 [profiles.p]
2107 enable = ["(unclosed"]
2108 "#,
2109 )
2110 .unwrap();
2111 match cfg.apply_profile("p") {
2112 Err(ConfigError::BadProfileRegex {
2113 profile,
2114 field,
2115 pattern,
2116 ..
2117 }) => {
2118 assert_eq!(profile, "p");
2119 assert_eq!(field, "enable");
2120 assert_eq!(pattern, "(unclosed");
2121 }
2122 other => panic!("expected BadProfileRegex, got {other:?}"),
2123 }
2124 }
2125
2126 #[test]
2127 fn fallback_where_n_id_matches() {
2128 let mut cfg = parse(
2129 r#"
2130 [[ts.class.where]]
2131 expr = "lines <= 99"
2132
2133 [[ts.class.where]]
2134 expr = "lines <= 99"
2135
2136 [profiles.p]
2137 disable = ["^ts\\.class\\.where_0$"]
2138 "#,
2139 )
2140 .unwrap();
2141 cfg.apply_profile("p").unwrap();
2142 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
2143 assert_eq!(r.rules.len(), 1);
2144 }
2145
2146 #[test]
2147 fn user_profile_overrides_preset_by_name() {
2148 let user = parse(
2149 r#"
2150 [profiles.bugfix]
2151 enable = ["^user$"]
2152 disable = []
2153 "#,
2154 )
2155 .unwrap();
2156 let mut base = parse(
2157 r#"
2158 [profiles.bugfix]
2159 enable = ["^base$"]
2160 disable = []
2161 "#,
2162 )
2163 .unwrap();
2164 merge_into(&mut base, user);
2165 let p = base.profiles.get("bugfix").unwrap();
2166 assert_eq!(p.enable, vec!["^user$".to_string()]);
2167 }
2168
2169 #[test]
2170 fn default_preset_ships_at_least_one_rule_per_language() {
2171 let cfg = load_default().unwrap();
2172 for lang in Lang::ALL {
2173 let lr = cfg.for_lang(*lang);
2174 assert!(
2175 !lr.kinds.is_empty(),
2176 "{} should ship at least one default rule",
2177 lang.tag()
2178 );
2179 }
2180 }
2181}