Skip to main content

miniextendr_lint/
helpers.rs

1//! Shared utility functions for lint rule implementations.
2
3use std::path::Path;
4
5use syn::Attribute;
6
7/// Returns true when the attribute list contains `#[miniextendr]`.
8pub fn has_miniextendr_attr(attrs: &[Attribute]) -> bool {
9    attrs.iter().any(|attr| {
10        attr.path()
11            .segments
12            .last()
13            .is_some_and(|seg| seg.ident == "miniextendr")
14    })
15}
16
17/// Extracts a displayable type name from an impl self type.
18pub fn impl_type_name(ty: &syn::Type) -> Option<String> {
19    match ty {
20        syn::Type::Path(type_path) => type_path
21            .path
22            .segments
23            .last()
24            .map(|seg| seg.ident.to_string()),
25        syn::Type::Reference(type_ref) => impl_type_name(&type_ref.elem),
26        _ => None,
27    }
28}
29
30/// Returns true if the attribute list contains `#[derive(ExternalPtr)]`
31/// or `#[derive(miniextendr_api::ExternalPtr)]`.
32pub fn has_external_ptr_derive(attrs: &[Attribute]) -> bool {
33    attrs.iter().any(|attr| {
34        if !attr.path().is_ident("derive") {
35            return false;
36        }
37        let syn::Meta::List(meta_list) = &attr.meta else {
38            return false;
39        };
40        let Ok(paths) = meta_list.parse_args_with(
41            syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
42        ) else {
43            return false;
44        };
45        paths.iter().any(|p| {
46            p.segments
47                .last()
48                .is_some_and(|seg| seg.ident == "ExternalPtr")
49        })
50    })
51}
52
53/// Returns true if the attribute list contains `#[derive(Altrep)]`
54/// or `#[derive(miniextendr_api::Altrep)]`.
55pub fn has_altrep_derive(attrs: &[Attribute]) -> bool {
56    attrs.iter().any(|attr| {
57        if !attr.path().is_ident("derive") {
58            return false;
59        }
60        let syn::Meta::List(meta_list) = &attr.meta else {
61            return false;
62        };
63        let Ok(paths) = meta_list.parse_args_with(
64            syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
65        ) else {
66            return false;
67        };
68        paths
69            .iter()
70            .any(|p| p.segments.last().is_some_and(|seg| seg.ident == "Altrep"))
71    })
72}
73
74/// Returns true if the attribute list contains `#[derive(Vctrs)]`
75/// or `#[derive(miniextendr_api::Vctrs)]`.
76pub fn has_vctrs_derive(attrs: &[Attribute]) -> bool {
77    attrs.iter().any(|attr| {
78        if !attr.path().is_ident("derive") {
79            return false;
80        }
81        let syn::Meta::List(meta_list) = &attr.meta else {
82            return false;
83        };
84        let Ok(paths) = meta_list.parse_args_with(
85            syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
86        ) else {
87            return false;
88        };
89        paths
90            .iter()
91            .any(|p| p.segments.last().is_some_and(|seg| seg.ident == "Vctrs"))
92    })
93}
94
95/// Returns whether a directory should be skipped during lint tree traversal.
96pub fn should_skip_dir(path: &Path) -> bool {
97    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
98        return false;
99    };
100    matches!(name, "target" | "ra_target" | ".cargo" | ".git" | "vendor")
101}
102
103/// Parsed miniextendr attribute information for an impl block.
104#[derive(Debug, Default)]
105pub struct MiniextendrImplAttrs {
106    /// Class system (e.g., "r6", "s3", "s4", "s7", or empty for env)
107    pub class_system: Option<String>,
108    /// Optional label for distinguishing multiple impl blocks of the same type
109    pub label: Option<String>,
110    /// Has `internal` flag
111    pub internal: bool,
112    /// Has `noexport` flag
113    pub noexport: bool,
114    /// Has `strict` flag
115    pub strict: bool,
116}
117
118/// Parse the #[miniextendr(...)] attribute to extract class system, label, and flags.
119pub fn parse_miniextendr_impl_attrs(attrs: &[Attribute]) -> MiniextendrImplAttrs {
120    let mut result = MiniextendrImplAttrs::default();
121
122    for attr in attrs {
123        if attr
124            .path()
125            .segments
126            .last()
127            .is_none_or(|seg| seg.ident != "miniextendr")
128        {
129            continue;
130        }
131
132        if let syn::Meta::List(meta_list) = &attr.meta {
133            let tokens = meta_list.tokens.to_string();
134            let tokens = tokens.trim();
135            if tokens.is_empty() {
136                continue;
137            }
138
139            // Parse the flat top-level comma-separated list, skipping nested parens.
140            // This handles `vctrs(kind = "vctr", base = "double", ...)` as a single
141            // top-level item without being tripped up by the inner commas.
142            for part in split_top_level_commas(tokens) {
143                let part = part.trim();
144                if part.is_empty() {
145                    continue;
146                }
147
148                if part.starts_with("label") {
149                    if let Some(eq_pos) = part.find('=') {
150                        let value = part[eq_pos + 1..].trim();
151                        let value = value.trim_matches('"').trim_matches('\'');
152                        result.label = Some(value.to_string());
153                    }
154                } else if part == "internal" {
155                    result.internal = true;
156                } else if part == "noexport" {
157                    result.noexport = true;
158                } else if part == "strict" {
159                    result.strict = true;
160                } else if !part.contains('=') || part.contains('(') {
161                    // Class system identifier: env, r6, s3, s4, s7, vctrs.
162                    // `vctrs` may appear as `vctrs(kind = "vctr", ...)` — keep
163                    // only the leading identifier before any `(`.
164                    let base = part
165                        .find(|c: char| !c.is_alphanumeric() && c != '_')
166                        .map(|i| &part[..i])
167                        .unwrap_or(part);
168                    if !base.is_empty() {
169                        result.class_system = Some(base.to_string());
170                    }
171                }
172            }
173        }
174    }
175
176    result
177}
178
179/// Split a token string on top-level commas (ignoring commas inside `(...)` groups).
180fn split_top_level_commas(s: &str) -> impl Iterator<Item = &str> {
181    SplitTopLevelCommas { remaining: s }
182}
183
184struct SplitTopLevelCommas<'a> {
185    remaining: &'a str,
186}
187
188impl<'a> Iterator for SplitTopLevelCommas<'a> {
189    type Item = &'a str;
190
191    fn next(&mut self) -> Option<&'a str> {
192        if self.remaining.is_empty() {
193            return None;
194        }
195        let mut depth: usize = 0;
196        let mut in_str = false;
197        let bytes = self.remaining.as_bytes();
198        for (i, &b) in bytes.iter().enumerate() {
199            match b {
200                b'"' if !in_str => in_str = true,
201                b'"' if in_str => in_str = false,
202                b'(' | b'[' | b'{' if !in_str => depth += 1,
203                b')' | b']' | b'}' if !in_str && depth > 0 => depth -= 1,
204                b',' if !in_str && depth == 0 => {
205                    let item = &self.remaining[..i];
206                    self.remaining = &self.remaining[i + 1..];
207                    return Some(item);
208                }
209                _ => {}
210            }
211        }
212        // Last item (no trailing comma)
213        let item = self.remaining;
214        self.remaining = "";
215        Some(item)
216    }
217}
218
219/// Extract `#[path = "..."]` attribute value from a module declaration.
220pub fn extract_path_attr(attrs: &[Attribute]) -> Option<String> {
221    attrs.iter().find_map(|attr| {
222        if !attr.path().is_ident("path") {
223            return None;
224        }
225        if let syn::Meta::NameValue(nv) = &attr.meta
226            && let syn::Expr::Lit(expr_lit) = &nv.value
227            && let syn::Lit::Str(lit_str) = &expr_lit.lit
228        {
229            return Some(lit_str.value());
230        }
231        None
232    })
233}
234
235/// Extract `#[cfg(...)]` attributes as normalized token strings.
236pub fn extract_cfg_attrs(attrs: &[Attribute]) -> Vec<String> {
237    attrs
238        .iter()
239        .filter(|attr| attr.path().is_ident("cfg"))
240        .map(|attr| attr.meta.to_token_stream_string())
241        .collect()
242}
243
244/// Extract roxygen tags from doc-comment attributes.
245///
246/// Looks through `/// ...` comments for patterns like `@export`, `@noRd`,
247/// `@keywords internal`, etc. Returns the tag names found.
248pub fn extract_roxygen_tags(attrs: &[Attribute]) -> Vec<String> {
249    let mut tags = Vec::new();
250    for attr in attrs {
251        if !attr.path().is_ident("doc") {
252            continue;
253        }
254        if let syn::Meta::NameValue(nv) = &attr.meta
255            && let syn::Expr::Lit(expr_lit) = &nv.value
256            && let syn::Lit::Str(lit_str) = &expr_lit.lit
257        {
258            let text = lit_str.value();
259            for word in text.split_whitespace() {
260                if let Some(tag) = word.strip_prefix('@')
261                    && !tag.is_empty()
262                {
263                    tags.push(tag.to_string());
264                }
265            }
266        }
267    }
268    tags
269}
270
271/// Check if a struct with `#[miniextendr]` should be treated as ALTREP (needing `struct Name;` in module).
272///
273/// Returns true only for 1-field structs without explicit mode attrs (list, dataframe, externalptr).
274/// Multi-field structs, structs with explicit mode attrs, and enums don't need module entries.
275pub fn is_altrep_struct(item: &syn::ItemStruct) -> bool {
276    let field_count = match &item.fields {
277        syn::Fields::Named(f) => f.named.len(),
278        syn::Fields::Unnamed(f) => f.unnamed.len(),
279        syn::Fields::Unit => 0,
280    };
281
282    // Only 1-field structs are ALTREP candidates
283    if field_count != 1 {
284        return false;
285    }
286
287    // Check if #[miniextendr(...)] has mode attrs that override ALTREP
288    for attr in &item.attrs {
289        if attr
290            .path()
291            .segments
292            .last()
293            .is_none_or(|seg| seg.ident != "miniextendr")
294        {
295            continue;
296        }
297
298        if let syn::Meta::List(meta_list) = &attr.meta {
299            let tokens = meta_list.tokens.to_string();
300            for part in split_top_level_commas(&tokens) {
301                let part = part.trim();
302                // These mode attrs mean "not ALTREP"
303                if matches!(part, "list" | "dataframe" | "externalptr") {
304                    return false;
305                }
306            }
307        }
308    }
309
310    true
311}
312
313/// Helper trait for converting Meta to a normalized string.
314trait MetaToString {
315    fn to_token_stream_string(&self) -> String;
316}
317
318impl MetaToString for syn::Meta {
319    fn to_token_stream_string(&self) -> String {
320        use quote::ToTokens;
321        self.to_token_stream().to_string()
322    }
323}