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