Skip to main content

fallow_config/config/
used_class_members.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// A `usedClassMembers` entry from config or an external plugin.
5///
6/// Supports either a plain member name or glob pattern (`"agInit"`,
7/// `"enter*"`) or a scoped rule that only applies when a class matches
8/// specific `extends` / `implements` heritage clauses.
9#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
10#[serde(untagged)]
11pub enum UsedClassMemberRule {
12    /// Globally suppress this class member name or glob pattern for all classes.
13    Name(String),
14    /// Suppress these class member names only for matching classes.
15    Scoped(ScopedUsedClassMemberRule),
16}
17
18impl From<&str> for UsedClassMemberRule {
19    fn from(value: &str) -> Self {
20        Self::Name(value.to_string())
21    }
22}
23
24impl From<String> for UsedClassMemberRule {
25    fn from(value: String) -> Self {
26        Self::Name(value)
27    }
28}
29
30/// A heritage-constrained `usedClassMembers` rule.
31#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct ScopedUsedClassMemberRule {
34    /// Only apply when the class extends this parent class name.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub extends: Option<String>,
37    /// Only apply when the class implements this interface name.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub implements: Option<String>,
40    /// Member names or glob patterns that should be treated as framework-used.
41    pub members: Vec<String>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46struct ScopedUsedClassMemberRuleDef {
47    #[serde(default)]
48    extends: Option<String>,
49    #[serde(default)]
50    implements: Option<String>,
51    members: Vec<String>,
52}
53
54impl TryFrom<ScopedUsedClassMemberRuleDef> for ScopedUsedClassMemberRule {
55    type Error = &'static str;
56
57    fn try_from(value: ScopedUsedClassMemberRuleDef) -> Result<Self, Self::Error> {
58        if value.extends.is_none() && value.implements.is_none() {
59            return Err("scoped usedClassMembers rules require `extends` or `implements`");
60        }
61
62        Ok(Self {
63            extends: value.extends,
64            implements: value.implements,
65            members: value.members,
66        })
67    }
68}
69
70impl<'de> Deserialize<'de> for ScopedUsedClassMemberRule {
71    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
72    where
73        D: serde::Deserializer<'de>,
74    {
75        ScopedUsedClassMemberRuleDef::deserialize(deserializer)?
76            .try_into()
77            .map_err(serde::de::Error::custom)
78    }
79}
80
81impl ScopedUsedClassMemberRule {
82    /// Whether a class with the given heritage clause is in this rule's scope.
83    ///
84    /// An unset `extends` / `implements` filter matches any class; a set
85    /// filter requires exact name equality (`implements` matches when any one
86    /// implemented interface equals it). Both set filters must match.
87    #[must_use]
88    pub fn matches_heritage(
89        &self,
90        super_class: Option<&str>,
91        implemented_interfaces: &[String],
92    ) -> bool {
93        let extends_matches = self
94            .extends
95            .as_deref()
96            .is_none_or(|expected| super_class == Some(expected));
97        let implements_matches = self
98            .implements
99            .as_deref()
100            .is_none_or(|expected| implemented_interfaces.iter().any(|iface| iface == expected));
101
102        extends_matches && implements_matches
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn deserialize_plain_member_name() {
112        let rule: UsedClassMemberRule = serde_json::from_str(r#""agInit""#).unwrap();
113        assert_eq!(rule, UsedClassMemberRule::Name("agInit".to_string()));
114    }
115
116    #[test]
117    fn deserialize_scoped_rule() {
118        let rule: UsedClassMemberRule = serde_json::from_str(
119            r#"{"implements":"ICellRendererAngularComp","members":["refresh"]}"#,
120        )
121        .unwrap();
122        assert_eq!(
123            rule,
124            UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
125                extends: None,
126                implements: Some("ICellRendererAngularComp".to_string()),
127                members: vec!["refresh".to_string()],
128            })
129        );
130    }
131
132    #[test]
133    fn scoped_rule_matches_extends_and_implements() {
134        let rule = ScopedUsedClassMemberRule {
135            extends: Some("BaseCommand".to_string()),
136            implements: Some("Runnable".to_string()),
137            members: vec!["execute".to_string()],
138        };
139
140        assert!(rule.matches_heritage(
141            Some("BaseCommand"),
142            &["Runnable".to_string(), "Disposable".to_string()]
143        ));
144        assert!(!rule.matches_heritage(Some("OtherBase"), &["Runnable".to_string()]));
145        assert!(!rule.matches_heritage(Some("BaseCommand"), &["Other".to_string()]));
146    }
147
148    #[test]
149    fn deserialize_scoped_rule_requires_constraint() {
150        let error = serde_json::from_str::<ScopedUsedClassMemberRule>(r#"{"members":["refresh"]}"#)
151            .unwrap_err()
152            .to_string();
153        assert!(
154            error.contains("require `extends` or `implements`"),
155            "unexpected error: {error}"
156        );
157    }
158}