Skip to main content

whitaker_common/attributes/
attribute.rs

1//! Attribute metadata helpers for lint analysis.
2
3use super::{AttributeKind, AttributePath, TEST_LIKE_PATHS};
4
5/// Represents a Rust attribute, tracking its path and attachment style.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct Attribute {
8    path: AttributePath,
9    kind: AttributeKind,
10    arguments: Vec<String>,
11}
12
13impl Attribute {
14    /// Creates a new attribute without arguments.
15    ///
16    /// # Examples
17    ///
18    /// ```
19    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
20    ///
21    /// let attribute = Attribute::new(AttributePath::from("test"), AttributeKind::Outer);
22    /// assert!(attribute.is_outer());
23    /// ```
24    #[must_use]
25    pub fn new(path: AttributePath, kind: AttributeKind) -> Self {
26        Self {
27            path,
28            kind,
29            arguments: Vec::new(),
30        }
31    }
32
33    /// Creates an attribute with the provided argument strings.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
39    ///
40    /// let attribute = Attribute::with_arguments(
41    ///     AttributePath::from("allow"),
42    ///     AttributeKind::Outer,
43    ///     ["clippy::needless_bool"],
44    /// );
45    /// assert_eq!(attribute.arguments(), &["clippy::needless_bool"]);
46    /// ```
47    #[must_use]
48    pub fn with_arguments<I, S>(path: AttributePath, kind: AttributeKind, arguments: I) -> Self
49    where
50        I: IntoIterator<Item = S>,
51        S: Into<String>,
52    {
53        Self {
54            path,
55            kind,
56            arguments: arguments.into_iter().map(Into::into).collect(),
57        }
58    }
59
60    /// Creates an attribute with borrowed string arguments.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
66    ///
67    /// let attribute = Attribute::with_str_arguments(
68    ///     AttributePath::from("allow"),
69    ///     AttributeKind::Outer,
70    ///     &["clippy::needless_bool"],
71    /// );
72    /// assert_eq!(attribute.arguments(), &["clippy::needless_bool"]);
73    /// ```
74    #[must_use]
75    pub fn with_str_arguments(path: AttributePath, kind: AttributeKind, args: &[&str]) -> Self {
76        Self::with_arguments(path, kind, args.iter().copied())
77    }
78
79    /// Returns the underlying attribute path.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
85    ///
86    /// let attribute = Attribute::new(AttributePath::from("doc"), AttributeKind::Outer);
87    /// assert!(attribute.path().is_doc());
88    /// ```
89    #[must_use]
90    pub fn path(&self) -> &AttributePath {
91        &self.path
92    }
93
94    /// Returns the attachment kind (inner or outer).
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
100    ///
101    /// let attribute = Attribute::new(AttributePath::from("doc"), AttributeKind::Inner);
102    /// assert!(attribute.kind().is_inner());
103    /// ```
104    #[must_use]
105    pub const fn kind(&self) -> AttributeKind {
106        self.kind
107    }
108
109    /// Returns the attribute arguments.
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
115    ///
116    /// let attribute = Attribute::with_str_arguments(
117    ///     AttributePath::from("allow"),
118    ///     AttributeKind::Outer,
119    ///     &["dead_code"],
120    /// );
121    /// assert_eq!(attribute.arguments(), &["dead_code"]);
122    /// ```
123    #[must_use]
124    pub fn arguments(&self) -> &[String] {
125        &self.arguments
126    }
127
128    /// Indicates whether the attribute is a doc comment (`#[doc = ...]`).
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
134    ///
135    /// let attribute = Attribute::new(AttributePath::from("doc"), AttributeKind::Outer);
136    /// assert!(attribute.is_doc());
137    /// ```
138    #[must_use]
139    pub fn is_doc(&self) -> bool {
140        self.path.is_doc()
141    }
142
143    /// Indicates whether the attribute marks a test-like context.
144    ///
145    /// Builtin test-like attributes include direct paths such as `test`,
146    /// `tokio::test`, `async_std::test`, and `rstest`, plus prelude-qualified
147    /// builtin forms such as `::core::prelude::v1::test` and
148    /// `::std::prelude::rust_2024::test`. The latter are recognized via
149    /// `matches_builtin_test_like_path` on `self.path`.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
155    ///
156    /// let rstest = Attribute::new(AttributePath::from("rstest"), AttributeKind::Outer);
157    /// assert!(rstest.is_test_like());
158    /// ```
159    #[must_use]
160    pub fn is_test_like(&self) -> bool {
161        self.is_test_like_with(&[])
162    }
163
164    /// Indicates whether the attribute marks a test-like context when supplied
165    /// with additional recognized paths.
166    ///
167    /// Builtin test-like attributes include direct paths such as `test`,
168    /// `tokio::test`, `async_std::test`, and `rstest`, plus prelude-qualified
169    /// builtin forms such as `::core::prelude::v1::test` and
170    /// `::std::prelude::rust_2024::test`. These builtin forms are recognized
171    /// first by calling `matches_builtin_test_like_path` on `self.path`.
172    ///
173    /// Use `additional` only for extra runtime-configured `AttributePath`
174    /// values that should count as test-like in addition to the builtin set.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
180    ///
181    /// let attr = Attribute::new(AttributePath::from("custom::test"), AttributeKind::Outer);
182    /// let additional = vec![AttributePath::from("custom::test")];
183    /// assert!(attr.is_test_like_with(&additional));
184    /// ```
185    #[must_use]
186    pub fn is_test_like_with(&self, additional: &[AttributePath]) -> bool {
187        if matches_builtin_test_like_path(&self.path) {
188            return true;
189        }
190
191        additional.iter().any(|path| {
192            self.path
193                .matches(path.segments().iter().map(String::as_str))
194        })
195    }
196
197    /// Returns `true` when the attribute is an inner attribute.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
203    ///
204    /// let attribute = Attribute::new(AttributePath::from("doc"), AttributeKind::Inner);
205    /// assert!(attribute.is_inner());
206    /// ```
207    #[must_use]
208    pub const fn is_inner(&self) -> bool {
209        self.kind.is_inner()
210    }
211
212    /// Returns `true` when the attribute is an outer attribute.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath};
218    ///
219    /// let attribute = Attribute::new(AttributePath::from("doc"), AttributeKind::Outer);
220    /// assert!(attribute.is_outer());
221    /// ```
222    #[must_use]
223    pub const fn is_outer(&self) -> bool {
224        self.kind.is_outer()
225    }
226}
227
228fn matches_builtin_test_like_path(path: &AttributePath) -> bool {
229    TEST_LIKE_PATHS
230        .iter()
231        .any(|candidate| path.matches(candidate.iter().copied()))
232        || is_prelude_test_attribute(path)
233}
234
235fn is_prelude_test_attribute(path: &AttributePath) -> bool {
236    // `is_prelude_test_attribute` treats the third segment from
237    // `AttributePath::segments()` as a wildcard because `_edition` may vary
238    // across toolchains (`v1`, `rust_2021`, `rust_2024`). The matcher
239    // therefore fixes only the root, `prelude`, and trailing `test`.
240    let [root, prelude, _edition, test] = path.segments() else {
241        return false;
242    };
243
244    matches!(root.as_str(), "core" | "std") && prelude == "prelude" && test == "test"
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use rstest::rstest;
251
252    #[rstest]
253    #[case::core_v1("core::prelude::v1::test", true)]
254    #[case::absolute_core_v1("::core::prelude::v1::test", true)]
255    #[case::std_rust_2021("std::prelude::rust_2021::test", true)]
256    #[case::std_rust_2023("std::prelude::rust_2023::test", true)]
257    #[case::std_rust_2024("std::prelude::rust_2024::test", true)]
258    #[case::absolute_std_rust_2024("::std::prelude::rust_2024::test", true)]
259    #[case::three_segments("core::prelude::test", false)]
260    #[case::five_segments("core::prelude::v1::extra::test", false)]
261    #[case::wrong_middle("core::not_prelude::v1::test", false)]
262    #[case::wrong_root("alloc::prelude::v1::test", false)]
263    #[case::wrong_final("core::prelude::v1::bench", false)]
264    fn prelude_test_attribute_shape(#[case] path: &str, #[case] expected: bool) {
265        assert_eq!(
266            is_prelude_test_attribute(&AttributePath::from(path)),
267            expected
268        );
269    }
270
271    #[rstest]
272    #[case::builtin_test("test", true)]
273    #[case::builtin_tokio("tokio::test", true)]
274    #[case::prelude_test("std::prelude::rust_2024::test", true)]
275    #[case::short_prelude("std::prelude::test", false)]
276    #[case::long_prelude("std::prelude::rust_2024::extra::test", false)]
277    #[case::wrong_prelude_segment("std::not_prelude::rust_2024::test", false)]
278    fn builtin_test_like_paths(#[case] path: &str, #[case] expected: bool) {
279        assert_eq!(
280            matches_builtin_test_like_path(&AttributePath::from(path)),
281            expected
282        );
283    }
284}