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}