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