Writing a smell
Three files, four steps. We'll walk through MiddleManAnalyzer (source) — 64 lines — as the worked example.
This page is for built-in smells (the ones that ship in cha-core). For project-specific detectors that don't belong in the main repo, write a WASM plugin instead — see Custom plugin in 50 lines.
Step 1: write the analyzer
Create cha-core/src/plugins/<your_smell>.rs:
#![allow(unused)] fn main() { use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory}; pub struct MiddleManAnalyzer { pub min_methods: usize, pub delegation_ratio: f64, } impl Default for MiddleManAnalyzer { fn default() -> Self { Self { min_methods: 3, delegation_ratio: 0.5, } } } impl Plugin for MiddleManAnalyzer { fn name(&self) -> &str { "middle_man" } fn smells(&self) -> Vec<String> { vec!["middle_man".into()] } fn description(&self) -> &str { "Class that only delegates to others" } fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> { ctx.model.classes.iter() .filter(|c| { c.method_count >= self.min_methods && c.delegating_method_count > 0 && (c.delegating_method_count as f64 / c.method_count as f64) >= self.delegation_ratio }) .map(|c| Finding { smell_name: "middle_man".into(), category: SmellCategory::Couplers, severity: Severity::Hint, location: Location { path: ctx.file.path.clone(), start_line: c.start_line, start_col: c.name_col, end_line: c.start_line, end_col: c.name_end_col, name: Some(c.name.clone()), }, message: format!( "Class `{}` delegates {}/{} methods, acting as a middle man", c.name, c.delegating_method_count, c.method_count ), suggested_refactorings: vec!["Remove Middle Man".into()], actual_value: Some(c.delegating_method_count as f64 / c.method_count as f64), threshold: Some(self.delegation_ratio), risk_score: None, }) .collect() } } }
Conventions:
- Struct fields are the thresholds. No magic numbers in
analyze(). Defaults go inDefault. name()is the plugin handle. Used in--plugin <name>,[plugins.<name>]config,// cha:ignore <name>.smells()lists every smell name the plugin emits. Most plugins emit one smell that matchesname(); some plugins emit multiple (e.g.lengthemitslong_method,large_class,large_file).Severity::Hintfor stylistic findings,Warningfor things that meaningfully harm readability/correctness,Errorfor things CI should refuse.actual_valueandthresholdcarry the numbers that show up in messages and the--explainoutput. Always populate them when there's a numeric metric.
Step 2: pick a SmellCategory
The category drives grouping in CLI output, JSON reports, and --focus. Match what the smell actually is:
| Category | What lives here |
|---|---|
Bloaters | Code that has grown too large (long_method, god_class, complexity). |
Couplers | Modules that depend too tightly on each other (coupling, feature_envy, middle_man). |
OOAbusers | Object-oriented constructs used incorrectly (switch_statement, refused_bequest, design_pattern). |
ChangePreventers | Change in one place forces changes elsewhere (shotgun_surgery, divergent_change). |
Dispensables | Code that can be removed without losing function (dead_code, duplicate_code, lazy_class). |
Security | Risky calls and leaked secrets (hardcoded_secret, unsafe_api). |
If your smell straddles two — pick the more specific one. Categories don't compose.
Step 3: register it
Edit cha-core/src/plugins/mod.rs:
#![allow(unused)] fn main() { mod middle_man; pub use middle_man::MiddleManAnalyzer; }
Edit cha-core/src/registry.rs. Find the appropriate register_*_plugins function for your category and add:
#![allow(unused)] fn main() { register_if_enabled(plugins, config, "middle_man", || { let mut p = MiddleManAnalyzer::default(); apply_usize(config, "middle_man", "min_methods", &mut p.min_methods); apply_f64(config, "middle_man", "delegation_ratio", &mut p.delegation_ratio); Box::new(p) }); }
apply_* reads [plugins.middle_man] from .cha.toml and overrides the default thresholds. Skip the apply_* calls if your analyzer has no configurable fields.
register_if_enabled honours enabled = false in [plugins.middle_man] — you don't need to handle that case yourself.
Step 4: tests + docs
Create cha-core/src/plugins/<your_smell>_tests.rs (or add to an existing test file). Pattern:
#![allow(unused)] fn main() { #[test] fn fires_on_middle_man() { let src = r#" class Wrapper { fn foo(&self) { self.inner.foo() } fn bar(&self) { self.inner.bar() } fn baz(&self) { self.inner.baz() } } "#; let findings = analyze_with(MiddleManAnalyzer::default(), "rust", src); assert_eq!(findings.len(), 1); assert_eq!(findings[0].smell_name, "middle_man"); } #[test] fn does_not_fire_below_threshold() { // ... 2 delegating methods, default min_methods=3 } }
Keep tests narrow: one fires-when-expected, one ignores-when-below-threshold, one for each interesting edge case. The fixture-based tests (under cha-core/tests/fixtures/) are for cross-plugin behaviour; for unit testing a single plugin, inline source strings are clearer.
Then update three docs:
- README.md plugin table — add a row to the appropriate
SmellCategorysection, with smell name, default thresholds, severity. Plus the same row in README.zh-CN.md. - docs/plugins.md — full description with a "what triggers it" example. Plus docs/plugins.zh-CN.md.
- CHANGELOG.md under
[Unreleased]— one line under "Added".
The book's plugin reference page is generated from docs/plugins.md via {{#include}}, so you don't edit it directly.
Verify
cargo xtask ci # runs build + test + lint + analyze
Then dogfood — run the new plugin against the Cha codebase itself:
cargo run -- analyze --plugin middle_man cha-core/
If it reports findings on Cha's own code, decide: are they real (fix Cha) or false positives (tighten the analyzer)?
See also
PlugintraitSmellCategoryenum- Architecture — how the plugin fits into the data flow.
- Plugin development — for WASM plugins (out-of-tree).