Skip to main content

miniextendr_lint/
crate_index.rs

1//! Shared crate index built from a single parse pass over all source files.
2//!
3//! All lint rules operate on this index rather than re-parsing files.
4
5use std::collections::{HashMap, HashSet};
6use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use syn::Item;
11use syn::ext::IdentExt;
12use syn::spanned::Spanned;
13use syn::visit::{self, Visit};
14
15use crate::crate_root::CrateRoot;
16use crate::description::{self, Description};
17use crate::doc_links::{self, PkgLink};
18use crate::helpers::{
19    extract_cfg_attrs, extract_path_attr, extract_roxygen_tags, has_derive, has_miniextendr_attr,
20    impl_type_name, is_altrep_struct, parse_miniextendr_impl_attrs,
21};
22
23// region: Impl method entry
24
25/// Receiver kind for an impl method, mirroring `ReceiverKind` in `miniextendr-macros`.
26///
27/// Mirror: `miniextendr-macros/src/miniextendr_impl.rs` — `ReceiverKind`.
28/// Keep both in sync: if the macro relaxes one receiver kind, update this enum too.
29#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for MethodReceiverKind { }
#[automatically_derived]
impl ::core::clone::Clone for MethodReceiverKind {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for MethodReceiverKind { }Copy, #[automatically_derived]
impl ::core::fmt::Debug for MethodReceiverKind {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                MethodReceiverKind::None => "None",
                MethodReceiverKind::Ref => "Ref",
                MethodReceiverKind::RefMut => "RefMut",
                MethodReceiverKind::Value => "Value",
                MethodReceiverKind::ExternalPtrRef => "ExternalPtrRef",
                MethodReceiverKind::ExternalPtrMut => "ExternalPtrMut",
                MethodReceiverKind::ExternalPtrValue => "ExternalPtrValue",
            })
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for MethodReceiverKind { }
#[automatically_derived]
impl ::core::cmp::PartialEq for MethodReceiverKind {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for MethodReceiverKind { }Eq)]
30pub enum MethodReceiverKind {
31    /// No self — static / associated function.
32    None,
33    /// `&self`
34    Ref,
35    /// `&mut self`
36    RefMut,
37    /// `self` (consuming)
38    Value,
39    /// `self: ExternalPtrRef<'_, Self>`
40    ExternalPtrRef,
41    /// `self: ExternalPtrMut<'_, Self>`
42    ExternalPtrMut,
43    /// `self: ExternalPtr<Self>`
44    ExternalPtrValue,
45}
46
47impl MethodReceiverKind {
48    /// Returns true if this is an instance receiver (any form of `self`).
49    ///
50    /// Mirrors `ReceiverKind::is_instance` in `miniextendr-macros/src/miniextendr_impl.rs`.
51    /// `Value` (consuming `self`) is **excluded** — the macro treats consuming-`self` methods
52    /// separately: they are either constructors (`returns Self` or `#[miniextendr(constructor)]`)
53    /// or finalizers, not ordinary instance calls.  Including `Value` here would produce a
54    /// false-positive for a vctrs method with `#[miniextendr(constructor)]` that consumes `self`.
55    pub fn is_instance(self) -> bool {
56        #[allow(non_exhaustive_omitted_patterns)] match self {
    Self::Ref | Self::RefMut | Self::ExternalPtrRef | Self::ExternalPtrMut |
        Self::ExternalPtrValue => true,
    _ => false,
}matches!(
57            self,
58            Self::Ref
59                | Self::RefMut
60                | Self::ExternalPtrRef
61                | Self::ExternalPtrMut
62                | Self::ExternalPtrValue
63        )
64    }
65
66    /// Human-readable spelling used in diagnostic messages.
67    pub fn spelling(self) -> &'static str {
68        match self {
69            Self::None => "(none)",
70            Self::Ref => "&self",
71            Self::RefMut => "&mut self",
72            Self::Value => "self",
73            Self::ExternalPtrRef => "self: ExternalPtrRef<'_, Self>",
74            Self::ExternalPtrMut => "self: ExternalPtrMut<'_, Self>",
75            Self::ExternalPtrValue => "self: ExternalPtr<Self>",
76        }
77    }
78}
79
80/// Per-method data collected during the crate-index pass for impl-method lint rules.
81#[derive(#[automatically_derived]
impl ::core::clone::Clone for ImplMethodEntry {
    #[inline]
    fn clone(&self) -> Self {
        ImplMethodEntry {
            method_name: ::core::clone::Clone::clone(&self.method_name),
            line: ::core::clone::Clone::clone(&self.line),
            class_system: ::core::clone::Clone::clone(&self.class_system),
            return_type_str: ::core::clone::Clone::clone(&self.return_type_str),
            receiver_kind: ::core::clone::Clone::clone(&self.receiver_kind),
            has_constructor_attr: ::core::clone::Clone::clone(&self.has_constructor_attr),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for ImplMethodEntry {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        let names: &'static _ =
            &["method_name", "line", "class_system", "return_type_str",
                        "receiver_kind", "has_constructor_attr"];
        let values: &[&dyn ::core::fmt::Debug] =
            &[&self.method_name, &self.line, &self.class_system,
                        &self.return_type_str, &self.receiver_kind,
                        &&self.has_constructor_attr];
        ::core::fmt::Formatter::debug_struct_fields_finish(f,
            "ImplMethodEntry", names, values)
    }
}Debug)]
82pub struct ImplMethodEntry {
83    pub method_name: String,
84    pub line: usize,
85    pub class_system: String,
86    /// Stringified return type tokens (empty string = `()` / no explicit return).
87    pub return_type_str: String,
88    /// Receiver kind detected from the method signature.
89    pub receiver_kind: MethodReceiverKind,
90    /// True when the method carries `#[miniextendr(constructor)]`.
91    pub has_constructor_attr: bool,
92}
93
94/// An inherent `#[miniextendr]` impl block, for the rules that look at a type
95/// across all its impl blocks (MXL121).
96#[derive(#[automatically_derived]
impl ::core::clone::Clone for InherentImpl {
    #[inline]
    fn clone(&self) -> Self {
        InherentImpl {
            type_name: ::core::clone::Clone::clone(&self.type_name),
            class_system: ::core::clone::Clone::clone(&self.class_system),
            line: ::core::clone::Clone::clone(&self.line),
            first_line: ::core::clone::Clone::clone(&self.first_line),
            allows_mxl121: ::core::clone::Clone::clone(&self.allows_mxl121),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for InherentImpl {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field5_finish(f, "InherentImpl",
            "type_name", &self.type_name, "class_system", &self.class_system,
            "line", &self.line, "first_line", &self.first_line,
            "allows_mxl121", &&self.allows_mxl121)
    }
}Debug)]
97pub struct InherentImpl {
98    pub type_name: String,
99    pub class_system: String,
100    /// 1-based line of the `impl` keyword.
101    pub line: usize,
102    /// 1-based line of the block's first attribute or doc comment (`line`
103    /// without any).
104    pub first_line: usize,
105    /// True when `// mxl::allow(MXL121)` is on the block's header: one of
106    /// its attribute and doc lines, the `impl` line, or the line above them.
107    pub allows_mxl121: bool,
108}
109
110// endregion
111
112// region: Lint item types
113
114#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for LintKind { }
#[automatically_derived]
impl ::core::clone::Clone for LintKind {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for LintKind { }Copy, #[automatically_derived]
impl ::core::fmt::Debug for LintKind {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                LintKind::Function => "Function",
                LintKind::Impl => "Impl",
                LintKind::Struct => "Struct",
                LintKind::TraitImpl => "TraitImpl",
                LintKind::Vctrs => "Vctrs",
            })
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for LintKind { }
#[automatically_derived]
impl ::core::cmp::PartialEq for LintKind {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for LintKind { }Eq, #[automatically_derived]
impl ::core::hash::Hash for LintKind {
    #[inline]
    fn hash<__H: ::core::hash::Hasher>(&self, state: &mut __H) {
        ::core::hash::Hash::hash(&::core::intrinsics::discriminant_value(self),
            state)
    }
}Hash)]
115pub enum LintKind {
116    Function,
117    Impl,
118    Struct,
119    TraitImpl,
120    Vctrs,
121}
122
123#[derive(#[automatically_derived]
impl ::core::clone::Clone for LintItem {
    #[inline]
    fn clone(&self) -> Self {
        LintItem {
            kind: ::core::clone::Clone::clone(&self.kind),
            name: ::core::clone::Clone::clone(&self.name),
            line: ::core::clone::Clone::clone(&self.line),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for LintItem {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field3_finish(f, "LintItem",
            "kind", &self.kind, "name", &self.name, "line", &&self.line)
    }
}Debug)]
124pub struct LintItem {
125    pub kind: LintKind,
126    pub name: String,
127    pub line: usize,
128}
129
130impl PartialEq for LintItem {
131    fn eq(&self, other: &Self) -> bool {
132        self.kind == other.kind && self.name == other.name
133    }
134}
135
136impl Eq for LintItem {}
137
138impl std::hash::Hash for LintItem {
139    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
140        self.kind.hash(state);
141        self.name.hash(state);
142    }
143}
144
145impl LintItem {
146    pub fn new(kind: LintKind, name: String, line: usize) -> Self {
147        Self { kind, name, line }
148    }
149}
150// endregion
151
152// region: Attributed trait impls from source
153
154#[derive(#[automatically_derived]
impl ::core::clone::Clone for AttributedTraitImpl {
    #[inline]
    fn clone(&self) -> Self {
        AttributedTraitImpl {
            type_name: ::core::clone::Clone::clone(&self.type_name),
            trait_name: ::core::clone::Clone::clone(&self.trait_name),
            class_system: ::core::clone::Clone::clone(&self.class_system),
            line: ::core::clone::Clone::clone(&self.line),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for AttributedTraitImpl {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field4_finish(f,
            "AttributedTraitImpl", "type_name", &self.type_name, "trait_name",
            &self.trait_name, "class_system", &self.class_system, "line",
            &&self.line)
    }
}Debug)]
155pub struct AttributedTraitImpl {
156    pub type_name: String,
157    pub trait_name: String,
158    pub class_system: Option<String>,
159    pub line: usize,
160}
161// endregion
162
163// region: Per-file parsed data
164
165#[derive(#[automatically_derived]
impl ::core::fmt::Debug for FileData {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        let names: &'static _ =
            &["miniextendr_items", "types_with_external_ptr",
                        "types_with_typed_external", "inherent_impl_class_systems",
                        "attributed_trait_impls", "fn_visibility",
                        "declared_child_mods", "path_redirected_mods",
                        "mod_decl_cfgs", "export_control", "impl_methods",
                        "inherent_impls", "returned_types", "hand_built_types",
                        "fn_doc_tags", "rf_error_calls", "ffi_unchecked_calls",
                        "vec_into_sexp_calls", "nonapi_attrib_calls",
                        "miniextendr_bodies", "raw_r_eval_calls", "doc_pkg_links",
                        "fn_param_names"];
        let values: &[&dyn ::core::fmt::Debug] =
            &[&self.miniextendr_items, &self.types_with_external_ptr,
                        &self.types_with_typed_external,
                        &self.inherent_impl_class_systems,
                        &self.attributed_trait_impls, &self.fn_visibility,
                        &self.declared_child_mods, &self.path_redirected_mods,
                        &self.mod_decl_cfgs, &self.export_control,
                        &self.impl_methods, &self.inherent_impls,
                        &self.returned_types, &self.hand_built_types,
                        &self.fn_doc_tags, &self.rf_error_calls,
                        &self.ffi_unchecked_calls, &self.vec_into_sexp_calls,
                        &self.nonapi_attrib_calls, &self.miniextendr_bodies,
                        &self.raw_r_eval_calls, &self.doc_pkg_links,
                        &&self.fn_param_names];
        ::core::fmt::Formatter::debug_struct_fields_finish(f, "FileData",
            names, values)
    }
}Debug, #[automatically_derived]
impl ::core::default::Default for FileData {
    #[inline]
    fn default() -> Self {
        FileData {
            miniextendr_items: ::core::default::Default::default(),
            types_with_external_ptr: ::core::default::Default::default(),
            types_with_typed_external: ::core::default::Default::default(),
            inherent_impl_class_systems: ::core::default::Default::default(),
            attributed_trait_impls: ::core::default::Default::default(),
            fn_visibility: ::core::default::Default::default(),
            declared_child_mods: ::core::default::Default::default(),
            path_redirected_mods: ::core::default::Default::default(),
            mod_decl_cfgs: ::core::default::Default::default(),
            export_control: ::core::default::Default::default(),
            impl_methods: ::core::default::Default::default(),
            inherent_impls: ::core::default::Default::default(),
            returned_types: ::core::default::Default::default(),
            hand_built_types: ::core::default::Default::default(),
            fn_doc_tags: ::core::default::Default::default(),
            rf_error_calls: ::core::default::Default::default(),
            ffi_unchecked_calls: ::core::default::Default::default(),
            vec_into_sexp_calls: ::core::default::Default::default(),
            nonapi_attrib_calls: ::core::default::Default::default(),
            miniextendr_bodies: ::core::default::Default::default(),
            raw_r_eval_calls: ::core::default::Default::default(),
            doc_pkg_links: ::core::default::Default::default(),
            fn_param_names: ::core::default::Default::default(),
        }
    }
}Default)]
166pub struct FileData {
167    // Source items (functions, impls, structs with #[miniextendr])
168    pub miniextendr_items: Vec<LintItem>,
169
170    // Type/derive information
171    pub types_with_external_ptr: HashSet<String>,
172    pub types_with_typed_external: HashSet<String>,
173
174    // Impl block details
175    pub inherent_impl_class_systems: HashMap<String, (String, usize)>,
176    pub attributed_trait_impls: Vec<AttributedTraitImpl>,
177
178    // Function details
179    pub fn_visibility: HashMap<String, bool>,
180
181    // Module tree (for file discovery)
182    /// Simple `mod child;` declarations (by ident name).
183    pub declared_child_mods: Vec<String>,
184    /// `#[path = "file.rs"] mod name;` declarations: (mod_name, file_path_str).
185    pub path_redirected_mods: Vec<(String, String)>,
186    /// cfg attrs on `mod child;` declarations: mod_name -> cfg strings.
187    pub mod_decl_cfgs: HashMap<String, Vec<String>>,
188
189    // Export control
190    /// (has_internal, has_noexport, line)
191    pub export_control: HashMap<String, (bool, bool, usize)>,
192
193    // Impl method details for per-method lint rules
194    /// Methods per inherent impl type: type_name → `Vec<ImplMethodEntry>`.
195    pub impl_methods: HashMap<String, Vec<ImplMethodEntry>>,
196    /// Every inherent `#[miniextendr]` impl block, in source order. (MXL121)
197    pub inherent_impls: Vec<InherentImpl>,
198    /// The type names a `#[miniextendr]` fn or method returns, bare, as
199    /// `Self` or `ExternalPtr<T>`, or inside `Result` / `Option`. A method
200    /// with a receiver doesn't count for its own type, whose object it needs
201    /// already. (MXL121)
202    pub returned_types: HashSet<String>,
203    /// The type names the file builds `ExternalPtr`s of itself, in any item,
204    /// `#[miniextendr]` or not (`HandBuilt`). (MXL121)
205    pub hand_built_types: HashSet<String>,
206
207    // Doc-comment roxygen tags per function/impl name
208    /// Known roxygen tags: "@noRd", "@export", "@keywords internal"
209    pub fn_doc_tags: HashMap<String, Vec<String>>,
210
211    // Safety lint data
212    /// Lines containing direct Rf_error/Rf_errorcall calls: (function_name, line_number).
213    pub rf_error_calls: Vec<(String, usize)>,
214    /// Lines containing `ffi::*_unchecked()` calls: (function_name, line_number).
215    pub ffi_unchecked_calls: Vec<(String, usize)>,
216    /// `into_sexp()` / `into_sexp_unchecked()` calls that appear *inside* a `vec!`/array
217    /// literal: (call_name, line_number). This is the use-after-free idiom — each later
218    /// `into_sexp` allocates and can GC an earlier, still-unprotected element of the same
219    /// literal. (MXL302)
220    pub vec_into_sexp_calls: Vec<(String, usize)>,
221    /// Calls to, or declarations of, R's non-API `ATTRIB` / `SET_ATTRIB` (or their
222    /// `_unchecked` forms): (function_name, line_number). (MXL304)
223    pub nonapi_attrib_calls: Vec<(String, usize)>,
224    /// The bodies the generated `#[miniextendr]` boundary runs: of `#[miniextendr]`
225    /// fns, of the methods of `#[miniextendr]` impl blocks (inherent and trait), and
226    /// the default methods of `#[miniextendr]` traits, as 1-based `(open, close)`
227    /// brace lines. (MXL305)
228    pub miniextendr_bodies: Vec<(usize, usize)>,
229    /// Calls to `Rf_eval` / `R_forceAndCall` / `Rf_applyClosure` / `R_CheckUserInterrupt`
230    /// (or their `_unchecked` forms) inside [`miniextendr_bodies`](Self::miniextendr_bodies):
231    /// (function_name, line_number). (MXL305)
232    pub raw_r_eval_calls: Vec<(String, usize)>,
233
234    /// `pkg::topic` links in the roxygen-bound doc text of `#[miniextendr]`
235    /// items. (MXL204)
236    pub doc_pkg_links: Vec<PkgLink>,
237
238    // R reserved-word parameter names
239    /// Maps fn/method name → list of (param_name, line) for params that are R reserved words.
240    /// Key for free functions is the function name; for impl methods it is `"TypeName::method_name"`.
241    pub fn_param_names: HashMap<String, Vec<(String, usize)>>,
242}
243// endregion
244
245// region: Crate index
246
247/// Shared parsed state for all lint rules.
248pub struct CrateIndex {
249    /// All scanned Rust source files.
250    pub files: Vec<PathBuf>,
251    /// Per-file parsed data.
252    pub file_data: HashMap<PathBuf, FileData>,
253    /// The R package's `DESCRIPTION`, when the crate sits at `<pkg>/src/rust`
254    /// of one ([`description::locate`]).
255    pub description: Option<Description>,
256    /// `roxygen_prose_links = "keep"` in `[package.metadata.miniextendr]`:
257    /// leading prose then reaches roxygen2 with its links.
258    pub prose_links_keep: bool,
259}
260
261/// Why [`CrateIndex::build`] failed, with the source files it had found.
262#[derive(#[automatically_derived]
impl ::core::fmt::Debug for IndexError {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field2_finish(f, "IndexError",
            "message", &self.message, "files", &&self.files)
    }
}Debug)]
263pub struct IndexError {
264    /// What went wrong (every parse error, joined with `; `).
265    pub message: String,
266    /// The module-tree files found before the failure. A file that fails to
267    /// parse is among them, so a build script can still watch it (#1738).
268    /// Empty when the crate root file is missing or `[lib] path` is unreadable.
269    pub files: Vec<PathBuf>,
270}
271
272impl fmt::Display for IndexError {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        f.write_str(&self.message)
275    }
276}
277
278impl From<String> for IndexError {
279    fn from(message: String) -> Self {
280        Self {
281            message,
282            files: Vec::new(),
283        }
284    }
285}
286
287impl CrateIndex {
288    /// Build the index for the crate whose `Cargo.toml` sits in `manifest_dir`,
289    /// walking the module tree from its root file ([`CrateRoot::resolve`]).
290    pub fn build(manifest_dir: &Path) -> Result<Self, IndexError> {
291        let root = CrateRoot::resolve(manifest_dir)?;
292
293        let mut rs_files = Vec::new();
294        collect_rs_files_from_module_tree(&root, &mut rs_files)?;
295        rs_files.sort();
296
297        let mut file_data = HashMap::new();
298        let mut parse_errors = Vec::new();
299
300        for path in &rs_files {
301            match parse_file(path) {
302                Ok(data) => {
303                    file_data.insert(path.clone(), data);
304                }
305                Err(err) => parse_errors.push(err),
306            }
307        }
308
309        if !parse_errors.is_empty() {
310            return Err(IndexError {
311                message: parse_errors.join("; "),
312                files: rs_files,
313            });
314        }
315
316        Ok(Self {
317            files: rs_files,
318            file_data,
319            description: description::read(manifest_dir),
320            prose_links_keep: crate::crate_root::prose_links_keep(manifest_dir),
321        })
322    }
323}
324// endregion
325
326// region: File collection (module-tree walker)
327
328/// Collect Rust source files by walking the module tree from the crate root,
329/// following `mod child;` declarations and respecting `#[cfg(feature = "...")]`
330/// gates via `CARGO_FEATURE_*` environment variables.
331fn collect_rs_files_from_module_tree(
332    root: &CrateRoot,
333    out: &mut Vec<PathBuf>,
334) -> Result<(), String> {
335    if !root.file.is_file() {
336        return Err(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("miniextendr-lint: cannot find crate root {0} ({1})",
                root.file.display(), root.origin))
    })format!(
337            "miniextendr-lint: cannot find crate root {} ({})",
338            root.file.display(),
339            root.origin
340        ));
341    }
342
343    let active_features = collect_active_cargo_features();
344    let mut seen = HashSet::new();
345    walk_module_file(&root.file, None, &active_features, out, &mut seen);
346    Ok(())
347}
348
349/// Collect the set of active Cargo features from `CARGO_FEATURE_*` env vars.
350/// Feature names are normalized: `CARGO_FEATURE_FOO_BAR` → `"foo-bar"`.
351fn collect_active_cargo_features() -> HashSet<String> {
352    std::env::vars()
353        .filter_map(|(key, _)| {
354            key.strip_prefix("CARGO_FEATURE_")
355                .map(|suffix| suffix.to_lowercase().replace('_', "-"))
356        })
357        .collect()
358}
359
360/// Where the `mod` declarations of one scope resolve, tracked as rustc does
361/// (`rustc_expand::module`).
362struct ModDir {
363    /// The directory `#[path]` attributes are relative to: the declaring file's
364    /// own directory, extended by the enclosing inline modules.
365    dir: PathBuf,
366    /// The module name a non-mod-rs file adds before its child files: `mod b;`
367    /// in `src/a.rs` is `src/a/b.rs`. `None` for a mod-rs file (the crate root,
368    /// whatever its name, a `mod.rs`, or a file reached through `#[path]`) and
369    /// inside inline modules.
370    relative: Option<String>,
371}
372
373impl ModDir {
374    /// The directory a plain `mod child;` looks in for `child.rs` / `child/mod.rs`.
375    fn child_dir(&self) -> PathBuf {
376        match &self.relative {
377            Some(name) => self.dir.join(name),
378            None => self.dir.clone(),
379        }
380    }
381}
382
383/// Recursively walk a module file, following `mod` declarations.
384///
385/// `relative` is the module name when `file` is a non-mod-rs file (see
386/// [`ModDir::relative`]).
387fn walk_module_file(
388    file: &Path,
389    relative: Option<String>,
390    active_features: &HashSet<String>,
391    out: &mut Vec<PathBuf>,
392    seen: &mut HashSet<PathBuf>,
393) {
394    if !file.is_file() {
395        return;
396    }
397
398    let file_buf = file.to_path_buf();
399    if !seen.insert(file_buf.clone()) {
400        return;
401    }
402
403    out.push(file_buf);
404
405    // Parse the file to discover mod declarations
406    let Ok(src) = fs::read_to_string(file) else {
407        return;
408    };
409    let Ok(parsed) = syn::parse_file(&src) else {
410        return;
411    };
412
413    let Some(dir) = file.parent() else {
414        return;
415    };
416    let scope = ModDir {
417        dir: dir.to_path_buf(),
418        relative,
419    };
420
421    discover_mod_declarations(&parsed.items, &scope, active_features, out, seen);
422}
423
424/// Walk parsed items looking for `mod child;` declarations and recurse.
425fn discover_mod_declarations(
426    items: &[Item],
427    scope: &ModDir,
428    active_features: &HashSet<String>,
429    out: &mut Vec<PathBuf>,
430    seen: &mut HashSet<PathBuf>,
431) {
432    for item in items {
433        let Item::Mod(item_mod) = item else {
434            continue;
435        };
436
437        // A cfg-gated module, inline or not, is visited only when its gate is active.
438        let cfgs = extract_cfg_attrs(&item_mod.attrs);
439        if !cfgs.is_empty() && !is_cfg_active(&cfgs, active_features) {
440            continue;
441        }
442
443        // `mod r#type;` lives in `type.rs`.
444        let mod_name = item_mod.ident.unraw().to_string();
445        let path_attr = extract_path_attr(&item_mod.attrs);
446
447        match (&item_mod.content, path_attr) {
448            // `#[path = "dir"] mod name { ... }`: the attribute names the directory.
449            (Some((_, child_items)), Some(dir)) => {
450                let inner = ModDir {
451                    dir: scope.dir.join(dir),
452                    relative: None,
453                };
454                discover_mod_declarations(child_items, &inner, active_features, out, seen);
455            }
456            // `mod name { ... }`: its `mod` declarations look in `name/`.
457            (Some((_, child_items)), None) => {
458                let inner = ModDir {
459                    dir: scope.child_dir().join(&mod_name),
460                    relative: None,
461                };
462                discover_mod_declarations(child_items, &inner, active_features, out, seen);
463            }
464            // `#[path = "file.rs"] mod name;`: relative to the declaring scope's
465            // directory, and the file is a mod-rs file.
466            (None, Some(file_path)) => {
467                let target = scope.dir.join(file_path);
468                walk_module_file(&target, None, active_features, out, seen);
469            }
470            // `mod name;`: `name.rs`, else `name/mod.rs`.
471            (None, None) => {
472                let child_dir = scope.child_dir();
473                let sibling = child_dir.join(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.rs", mod_name))
    })format!("{mod_name}.rs"));
474                if sibling.is_file() {
475                    walk_module_file(&sibling, Some(mod_name), active_features, out, seen);
476                } else {
477                    let subdir_mod = child_dir.join(&mod_name).join("mod.rs");
478                    walk_module_file(&subdir_mod, None, active_features, out, seen);
479                }
480            }
481        }
482    }
483}
484
485/// Evaluate whether a set of `#[cfg(...)]` attributes is active given the current features.
486fn is_cfg_active(cfgs: &[String], active_features: &HashSet<String>) -> bool {
487    for cfg_str in cfgs {
488        if let Some(result) = eval_cfg_str(cfg_str, active_features)
489            && !result
490        {
491            return false;
492        }
493    }
494    true
495}
496
497/// Try to evaluate a single cfg string like `cfg(feature = "foo")`.
498fn eval_cfg_str(cfg_str: &str, active_features: &HashSet<String>) -> Option<bool> {
499    let normalized: String = cfg_str.chars().filter(|c| !c.is_whitespace()).collect();
500
501    let inner = normalized
502        .strip_prefix("cfg(")
503        .and_then(|s| s.strip_suffix(')'))?;
504
505    if let Some(not_inner) = inner.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
506        if let Some(feat) = extract_feature_name(not_inner) {
507            return Some(!active_features.contains(&feat));
508        }
509        return None;
510    }
511
512    if let Some(feat) = extract_feature_name(inner) {
513        return Some(active_features.contains(&feat));
514    }
515
516    None
517}
518
519/// Extract the feature name from a string like `feature="foo"`.
520fn extract_feature_name(s: &str) -> Option<String> {
521    let rest = s.strip_prefix("feature")?;
522    let rest = rest.strip_prefix('=')?;
523    let name = rest.trim_matches('"').trim_matches('\\');
524    if name.is_empty() {
525        None
526    } else {
527        Some(name.to_string())
528    }
529}
530// endregion
531
532// region: Single-file parsing
533
534fn parse_file(path: &Path) -> Result<FileData, String> {
535    let src = fs::read_to_string(path)
536        .map_err(|err| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}: failed to read: {1}",
                path.display(), err))
    })format!("{}: failed to read: {err}", path.display()))?;
537
538    let parsed = syn::parse_file(&src).map_err(|err| {
539        let mut message = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}: failed to parse: {1}",
                path.display(), err))
    })format!("{}: failed to parse: {err}", path.display());
540        if let Some(hint) = crate::misplaced_dots::hint(&src) {
541            message.push_str("; ");
542            message.push_str(&hint);
543        }
544        message
545    })?;
546
547    let mut data = FileData::default();
548    collect_items_recursive(&parsed.items, &mut data);
549    let mut hand_built = HandBuilt::default();
550    hand_built.visit_file(&parsed);
551    data.hand_built_types = hand_built.types;
552
553    // Both raw-source scanners need the line-split for is_suppressed look-behind.
554    let lines: Vec<&str> = src.lines().collect();
555    scan_rf_error_calls(&lines, &mut data);
556    scan_ffi_unchecked_calls(&lines, &mut data);
557    scan_vec_into_sexp_calls(&lines, &mut data);
558    scan_nonapi_attrib_calls(&lines, &mut data);
559    scan_raw_r_eval_calls(&lines, &mut data);
560    for block in &mut data.inherent_impls {
561        block.allows_mxl121 = header_allows(&lines, block.first_line, block.line, "MXL121");
562    }
563
564    Ok(data)
565}
566
567/// Extract named parameter names (and their 1-based line numbers) from a function signature.
568///
569/// Skips `self` / `&self` / `&mut self` receiver parameters. Skips unnamed (`_`) parameters.
570fn extract_param_names(sig: &syn::Signature) -> Vec<(String, usize)> {
571    let mut params = Vec::new();
572    for input in &sig.inputs {
573        if let syn::FnArg::Typed(pat_type) = input
574            && let syn::Pat::Ident(pat_ident) = &*pat_type.pat
575        {
576            let name = pat_ident.ident.to_string();
577            // Skip `_` (bare anonymous). Named `_foo` patterns are kept because
578            // the proc-macro forwards the name verbatim (stripping only the leading
579            // underscore in some codegen paths), so they can still collide with R
580            // reserved words.
581            if name == "_" {
582                continue;
583            }
584            let line = pat_ident.ident.span().start().line;
585            params.push((name, line));
586        }
587    }
588    params
589}
590
591/// Record the `pkg::topic` links in the docs of a `#[miniextendr]` item and,
592/// for an impl block, of its methods.
593///
594/// A block that renders no help page hands roxygen2 no links: the macro
595/// strips every one of them there (#1818). That is a block with its own
596/// `@noRd`, a `noexport` fn, and an impl block (with all its methods) under
597/// `@noRd` or a plain `noexport`, by the macro's rules: `internal`, or on an
598/// inherent impl the `@keywords internal` tag, keeps the page.
599fn collect_doc_pkg_links(item: &Item, data: &mut FileData) {
600    let attrs: &[syn::Attribute] = match item {
601        Item::Fn(i) => &i.attrs,
602        Item::Impl(i) => &i.attrs,
603        Item::Struct(i) => &i.attrs,
604        Item::Enum(i) => &i.attrs,
605        _ => return,
606    };
607    if !has_miniextendr_attr(attrs) {
608        return;
609    }
610    let flags = parse_miniextendr_impl_attrs(attrs);
611    let no_page = match item {
612        Item::Fn(_) => flags.noexport || doc_links::has_tag(attrs, "noRd"),
613        Item::Impl(item_impl) => {
614            doc_links::has_tag(attrs, "noRd")
615                || (flags.noexport
616                    && !flags.internal
617                    && (item_impl.trait_.is_some()
618                        || !doc_links::has_tag(attrs, "keywords internal")))
619        }
620        _ => false,
621    };
622    if no_page {
623        return;
624    }
625    data.doc_pkg_links.extend(doc_links::pkg_links(attrs));
626    if let Item::Impl(item_impl) = item {
627        for impl_item in &item_impl.items {
628            if let syn::ImplItem::Fn(method) = impl_item
629                && !doc_links::has_tag(&method.attrs, "noRd")
630            {
631                data.doc_pkg_links
632                    .extend(doc_links::pkg_links(&method.attrs));
633            }
634        }
635    }
636}
637
638/// Recursively collect all lint-relevant information from parsed items.
639fn collect_items_recursive(items: &[Item], data: &mut FileData) {
640    for item in items {
641        collect_doc_pkg_links(item, data);
642        match item {
643            Item::Fn(item_fn) if has_miniextendr_attr(&item_fn.attrs) => {
644                let line = item_fn.sig.ident.span().start().line;
645                let name = item_fn.sig.ident.to_string();
646
647                data.miniextendr_items
648                    .push(LintItem::new(LintKind::Function, name.clone(), line));
649
650                // Track visibility
651                let is_pub = #[allow(non_exhaustive_omitted_patterns)] match item_fn.vis {
    syn::Visibility::Public(_) => true,
    _ => false,
}matches!(item_fn.vis, syn::Visibility::Public(_));
652                data.fn_visibility.insert(name.clone(), is_pub);
653
654                // Track export control
655                let attrs = parse_miniextendr_impl_attrs(&item_fn.attrs);
656                if attrs.internal || attrs.noexport {
657                    data.export_control
658                        .insert(name.clone(), (attrs.internal, attrs.noexport, line));
659                }
660
661                // Track doc-comment roxygen tags
662                let doc_tags = extract_roxygen_tags(&item_fn.attrs);
663                if !doc_tags.is_empty() {
664                    data.fn_doc_tags.insert(name.clone(), doc_tags);
665                }
666
667                // Track parameter names for R reserved-word check (MXL110)
668                let params = extract_param_names(&item_fn.sig);
669                if !params.is_empty() {
670                    data.fn_param_names.insert(name.clone(), params);
671                }
672
673                if let syn::ReturnType::Type(_, ty) = &item_fn.sig.output {
674                    collect_returned_types(ty, None, &mut data.returned_types);
675                }
676
677                data.miniextendr_bodies.push(block_lines(&item_fn.block));
678            }
679            Item::Trait(item_trait) if has_miniextendr_attr(&item_trait.attrs) => {
680                // A default method runs when an impl doesn't override it (MXL305)
681                for trait_item in &item_trait.items {
682                    if let syn::TraitItem::Fn(method) = trait_item
683                        && let Some(block) = &method.default
684                    {
685                        data.miniextendr_bodies.push(block_lines(block));
686                    }
687                }
688            }
689            Item::Struct(item_struct) => {
690                let is_miniextendr_altrep =
691                    has_miniextendr_attr(&item_struct.attrs) && is_altrep_struct(item_struct);
692                let is_derive_altrep = has_derive(&item_struct.attrs, "Altrep");
693                if is_miniextendr_altrep || is_derive_altrep {
694                    let line = item_struct.ident.span().start().line;
695                    data.miniextendr_items.push(LintItem::new(
696                        LintKind::Struct,
697                        item_struct.ident.to_string(),
698                        line,
699                    ));
700                }
701                if has_derive(&item_struct.attrs, "ExternalPtr") {
702                    data.types_with_external_ptr
703                        .insert(item_struct.ident.to_string());
704                }
705                if has_derive(&item_struct.attrs, "Vctrs") {
706                    let line = item_struct.ident.span().start().line;
707                    data.miniextendr_items.push(LintItem::new(
708                        LintKind::Vctrs,
709                        item_struct.ident.to_string(),
710                        line,
711                    ));
712                }
713            }
714            Item::Impl(item_impl) => {
715                // Check for impl TypedExternal for Type
716                if let Some((_, trait_path, _)) = &item_impl.trait_
717                    && let Some(last_seg) = trait_path.segments.last()
718                    && last_seg.ident == "TypedExternal"
719                    && let Some(type_name) = impl_type_name(&item_impl.self_ty)
720                {
721                    data.types_with_typed_external.insert(type_name);
722                }
723
724                if has_miniextendr_attr(&item_impl.attrs) {
725                    let line = item_impl.self_ty.span().start().line;
726                    let impl_attrs = parse_miniextendr_impl_attrs(&item_impl.attrs);
727
728                    // Method bodies, inherent and trait (MXL305)
729                    for impl_item in &item_impl.items {
730                        if let syn::ImplItem::Fn(method) = impl_item {
731                            data.miniextendr_bodies.push(block_lines(&method.block));
732                        }
733                    }
734
735                    match impl_type_name(&item_impl.self_ty) {
736                        Some(type_name) => {
737                            if let Some((_, trait_path, _)) = &item_impl.trait_ {
738                                // Trait impl
739                                if let Some(trait_seg) = trait_path.segments.last() {
740                                    let trait_name = trait_seg.ident.to_string();
741                                    let full_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} for {1}", trait_name,
                type_name))
    })format!("{} for {}", trait_name, type_name);
742                                    data.miniextendr_items.push(LintItem::new(
743                                        LintKind::TraitImpl,
744                                        full_name,
745                                        line,
746                                    ));
747                                    data.attributed_trait_impls.push(AttributedTraitImpl {
748                                        type_name: type_name.clone(),
749                                        trait_name,
750                                        class_system: impl_attrs.class_system.clone(),
751                                        line,
752                                    });
753                                }
754                            } else {
755                                // Inherent impl
756                                let class_system =
757                                    impl_attrs.class_system.clone().unwrap_or_default();
758                                data.inherent_impl_class_systems
759                                    .insert(type_name.clone(), (class_system.clone(), line));
760                                data.miniextendr_items.push(LintItem::new(
761                                    LintKind::Impl,
762                                    type_name.clone(),
763                                    line,
764                                ));
765                                let impl_line = item_impl.impl_token.span.start().line;
766                                data.inherent_impls.push(InherentImpl {
767                                    type_name: type_name.clone(),
768                                    class_system: class_system.clone(),
769                                    line: impl_line,
770                                    first_line: item_impl
771                                        .attrs
772                                        .first()
773                                        .map_or(impl_line, |attr| attr.span().start().line),
774                                    allows_mxl121: false,
775                                });
776
777                                // Collect method names for per-method rules (e.g. MXL111, MXL120)
778                                let methods =
779                                    data.impl_methods.entry(type_name.clone()).or_default();
780                                for impl_item in &item_impl.items {
781                                    if let syn::ImplItem::Fn(method) = impl_item {
782                                        let method_name = method.sig.ident.to_string();
783                                        let method_line = method.sig.ident.span().start().line;
784                                        let return_type_str =
785                                            extract_return_type_str(&method.sig.output);
786                                        let receiver_kind = detect_receiver_kind(&method.sig);
787                                        let has_constructor_attr =
788                                            has_constructor_attr(&method.attrs);
789                                        methods.push(ImplMethodEntry {
790                                            method_name,
791                                            line: method_line,
792                                            class_system: class_system.clone(),
793                                            return_type_str,
794                                            receiver_kind,
795                                            has_constructor_attr,
796                                        });
797                                    }
798                                }
799
800                                // Track export control
801                                if impl_attrs.internal || impl_attrs.noexport {
802                                    data.export_control.insert(
803                                        type_name.clone(),
804                                        (impl_attrs.internal, impl_attrs.noexport, line),
805                                    );
806                                }
807                            }
808
809                            // Track parameter names for all methods in the impl block (MXL110)
810                            for impl_item in &item_impl.items {
811                                if let syn::ImplItem::Fn(method) = impl_item {
812                                    let method_name = method.sig.ident.to_string();
813                                    let key = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}::{1}", type_name, method_name))
    })format!("{}::{}", type_name, method_name);
814                                    let params = extract_param_names(&method.sig);
815                                    if !params.is_empty() {
816                                        data.fn_param_names.insert(key, params);
817                                    }
818                                }
819                            }
820
821                            // The types its methods return (MXL121)
822                            for impl_item in &item_impl.items {
823                                let syn::ImplItem::Fn(method) = impl_item else {
824                                    continue;
825                                };
826                                let syn::ReturnType::Type(_, ty) = &method.sig.output else {
827                                    continue;
828                                };
829                                let mut returned = HashSet::new();
830                                collect_returned_types(ty, Some(&type_name), &mut returned);
831                                if detect_receiver_kind(&method.sig) != MethodReceiverKind::None {
832                                    returned.remove(&type_name);
833                                }
834                                data.returned_types.extend(returned);
835                            }
836                        }
837                        None => { /* unsupported impl type, skip */ }
838                    }
839                }
840            }
841            Item::Mod(item_mod) => {
842                if let Some((_, child_items)) = &item_mod.content {
843                    // Inline module
844                    collect_items_recursive(child_items, data);
845                } else {
846                    // Out-of-line module declaration
847                    let mod_name = item_mod.ident.to_string();
848
849                    // Track cfg attrs on the mod declaration
850                    let cfgs = extract_cfg_attrs(&item_mod.attrs);
851                    if !cfgs.is_empty() {
852                        data.mod_decl_cfgs.insert(mod_name.clone(), cfgs);
853                    }
854
855                    // Check for #[path = "file.rs"] attribute
856                    let path_attr = extract_path_attr(&item_mod.attrs);
857                    if let Some(file_path) = path_attr {
858                        data.path_redirected_mods.push((mod_name, file_path));
859                    } else {
860                        data.declared_child_mods.push(mod_name);
861                    }
862                }
863            }
864            _ => {}
865        }
866    }
867}
868
869/// Patterns that indicate direct Rf_error/Rf_errorcall calls in user code.
870const RF_ERROR_PATTERNS: &[&str] = &[
871    "Rf_error(",
872    "Rf_error_unchecked(",
873    "Rf_errorcall(",
874    "Rf_errorcall_unchecked(",
875];
876
877/// Check if a lint code is suppressed via `// mxl::allow(MXL...)` comment.
878fn is_suppressed(lines: &[&str], line_idx: usize, code: &str) -> bool {
879    if line_has_allow(lines[line_idx], code) {
880        return true;
881    }
882    if line_idx > 0 && line_has_allow(lines[line_idx - 1], code) {
883        return true;
884    }
885    false
886}
887
888/// Check if a single line contains `// mxl::allow(...)` matching the given code.
889fn line_has_allow(line: &str, code: &str) -> bool {
890    const PREFIX: &str = "// mxl::allow(";
891    if let Some(pos) = line.find(PREFIX) {
892        let after = &line[pos + PREFIX.len()..];
893        if let Some(end) = after.find(')') {
894            let codes = &after[..end];
895            return codes.split(',').any(|c| c.trim() == code);
896        }
897    }
898    false
899}
900
901/// Whether `// mxl::allow(<code>)` is on an item's header: its lines
902/// `first_line..=line` (1-based: attributes, doc comments and the item's own
903/// first line) or the line above them.
904fn header_allows(lines: &[&str], first_line: usize, line: usize, code: &str) -> bool {
905    (first_line.saturating_sub(1).max(1)..=line).any(|n| {
906        lines
907            .get(n - 1)
908            .is_some_and(|text| line_has_allow(text, code))
909    })
910}
911
912/// Add to `out` the type `ty` hands to R as a class object: its last path
913/// segment, `self_type` for `Self`, through `Result`, `Option` and
914/// `ExternalPtr`. Other names (`i32`, `Vec`, ...) are added too; they match no
915/// class.
916fn collect_returned_types(ty: &syn::Type, self_type: Option<&str>, out: &mut HashSet<String>) {
917    let syn::Type::Path(path) = ty else {
918        return;
919    };
920    let Some(last) = path.path.segments.last() else {
921        return;
922    };
923    if ["Result", "Option", "ExternalPtr"]
924        .iter()
925        .any(|wrapper| last.ident == wrapper)
926    {
927        if let syn::PathArguments::AngleBracketed(args) = &last.arguments
928            && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
929        {
930            collect_returned_types(inner, self_type, out);
931        }
932    } else {
933        out.extend(path_type_name(&path.path, self_type));
934    }
935}
936
937/// The type `path` names: its last segment, `self_type` for `Self`.
938fn path_type_name(path: &syn::Path, self_type: Option<&str>) -> Option<String> {
939    let last = path.segments.last()?;
940    if last.ident == "Self" {
941        self_type.map(str::to_string)
942    } else {
943        Some(last.ident.to_string())
944    }
945}
946
947/// The types a file builds `ExternalPtr`s of itself (MXL121), in any item:
948///
949/// - the struct in `ExternalPtr::new(T { .. })` / `ExternalPtr::new(T(..))`
950///   (or `new_unchecked`), with `Self` inside `impl T`, or in
951///   `ExternalPtr::new(x)` after `let x = T { .. }` / `let x = T(..)` /
952///   `let x: T = ..` in the same function body (the last `let x` wins);
953/// - `T` in an `ExternalPtr<T>` written as a type or a turbofish
954///   (`ExternalPtr::<T>::new(..)`): return types, `let` annotations, fields,
955///   generic arguments.
956///
957/// A function's parameters, its receiver among them, are objects the caller
958/// has already, and are skipped. So is the impl's own type in a method of a
959/// `#[miniextendr]` impl block that takes `self`, as for
960/// [`FileData::returned_types`]. A value from a call whose type isn't written
961/// (`let record = make_record(inner); ExternalPtr::new(record)`), a value
962/// from another function, and code inside macro calls (`vec![..]`) stay
963/// invisible.
964#[derive(#[automatically_derived]
impl ::core::default::Default for HandBuilt {
    #[inline]
    fn default() -> Self {
        HandBuilt {
            types: ::core::default::Default::default(),
            self_type: ::core::default::Default::default(),
            miniextendr_impl: ::core::default::Default::default(),
            locals: ::core::default::Default::default(),
        }
    }
}Default)]
965struct HandBuilt {
966    types: HashSet<String>,
967    /// The type `Self` names here: the enclosing impl's.
968    self_type: Option<String>,
969    /// Inside a `#[miniextendr]` impl block.
970    miniextendr_impl: bool,
971    /// The type of each `let` binding of the current function body whose
972    /// value is a struct literal or whose annotation names it.
973    locals: HashMap<String, String>,
974}
975
976impl HandBuilt {
977    /// Visit a function with bindings of its own.
978    fn in_fn(&mut self, visit_fn: impl FnOnce(&mut Self)) {
979        let locals = std::mem::take(&mut self.locals);
980        visit_fn(self);
981        self.locals = locals;
982    }
983
984    /// The type a struct literal `T { .. }` or tuple-struct call `T(..)` builds.
985    fn literal_type(&self, expr: &syn::Expr) -> Option<String> {
986        let path = match expr {
987            syn::Expr::Struct(literal) => &literal.path,
988            syn::Expr::Call(call) => match &*call.func {
989                syn::Expr::Path(ctor) => &ctor.path,
990                _ => return None,
991            },
992            _ => return None,
993        };
994        path_type_name(path, self.self_type.as_deref())
995    }
996}
997
998impl<'ast> Visit<'ast> for HandBuilt {
999    fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) {
1000        let self_type = std::mem::replace(&mut self.self_type, impl_type_name(&item.self_ty));
1001        let miniextendr_impl = std::mem::replace(
1002            &mut self.miniextendr_impl,
1003            has_miniextendr_attr(&item.attrs),
1004        );
1005        visit::visit_item_impl(self, item);
1006        self.self_type = self_type;
1007        self.miniextendr_impl = miniextendr_impl;
1008    }
1009
1010    fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) {
1011        self.in_fn(|this| visit::visit_item_fn(this, item));
1012    }
1013
1014    fn visit_trait_item_fn(&mut self, method: &'ast syn::TraitItemFn) {
1015        self.in_fn(|this| visit::visit_trait_item_fn(this, method));
1016    }
1017
1018    fn visit_impl_item_fn(&mut self, method: &'ast syn::ImplItemFn) {
1019        if !self.miniextendr_impl || detect_receiver_kind(&method.sig) == MethodReceiverKind::None {
1020            self.in_fn(|this| visit::visit_impl_item_fn(this, method));
1021            return;
1022        }
1023        let outer = std::mem::take(&mut self.types);
1024        self.in_fn(|this| visit::visit_impl_item_fn(this, method));
1025        let mut built = std::mem::replace(&mut self.types, outer);
1026        if let Some(self_type) = &self.self_type {
1027            built.remove(self_type);
1028        }
1029        self.types.extend(built);
1030    }
1031
1032    fn visit_local(&mut self, local: &'ast syn::Local) {
1033        visit::visit_local(self, local);
1034        let (pat, annotated) = match &local.pat {
1035            syn::Pat::Type(typed) => {
1036                let annotated = match &*typed.ty {
1037                    syn::Type::Path(ty) => path_type_name(&ty.path, self.self_type.as_deref()),
1038                    _ => None,
1039                };
1040                (&*typed.pat, annotated)
1041            }
1042            pat => (pat, None),
1043        };
1044        let syn::Pat::Ident(binding) = pat else {
1045            return;
1046        };
1047        let name = binding.ident.to_string();
1048        let ty = annotated.or_else(|| {
1049            local
1050                .init
1051                .as_ref()
1052                .and_then(|init| self.literal_type(&init.expr))
1053        });
1054        match ty {
1055            Some(ty) => self.locals.insert(name, ty),
1056            None => self.locals.remove(&name),
1057        };
1058    }
1059
1060    fn visit_fn_arg(&mut self, _: &'ast syn::FnArg) {}
1061
1062    fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) {
1063        if let syn::Expr::Path(func) = &*call.func
1064            && is_external_ptr_new(&func.path)
1065            && let Some(arg) = call.args.first()
1066        {
1067            let built = match arg {
1068                syn::Expr::Path(local) => local
1069                    .path
1070                    .get_ident()
1071                    .and_then(|ident| self.locals.get(&ident.to_string()).cloned()),
1072                literal => self.literal_type(literal),
1073            };
1074            self.types.extend(built);
1075        }
1076        visit::visit_expr_call(self, call);
1077    }
1078
1079    fn visit_path(&mut self, path: &'ast syn::Path) {
1080        for segment in &path.segments {
1081            if segment.ident == "ExternalPtr"
1082                && let syn::PathArguments::AngleBracketed(args) = &segment.arguments
1083                && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
1084            {
1085                collect_returned_types(inner, self.self_type.as_deref(), &mut self.types);
1086            }
1087        }
1088        visit::visit_path(self, path);
1089    }
1090}
1091
1092/// Whether `path` ends in `ExternalPtr::new` or `ExternalPtr::new_unchecked`,
1093/// with or without a turbofish.
1094fn is_external_ptr_new(path: &syn::Path) -> bool {
1095    let mut segments = path.segments.iter().rev();
1096    segments
1097        .next()
1098        .is_some_and(|last| last.ident == "new" || last.ident == "new_unchecked")
1099        && segments
1100            .next()
1101            .is_some_and(|segment| segment.ident == "ExternalPtr")
1102}
1103
1104/// Scan raw source text for `sys::*_unchecked()` calls.
1105fn scan_ffi_unchecked_calls(lines: &[&str], data: &mut FileData) {
1106    const PREFIX: &str = "sys::";
1107    for (line_idx, line) in lines.iter().enumerate() {
1108        let trimmed = line.trim();
1109        if trimmed.starts_with("//") {
1110            continue;
1111        }
1112        if trimmed.starts_with("#[") {
1113            continue;
1114        }
1115        // Strip inline comments to avoid false positives
1116        let code_part = match trimmed.find("//") {
1117            Some(pos) => &trimmed[..pos],
1118            None => trimmed,
1119        };
1120        let mut search_from = 0;
1121        while let Some(sys_pos) = code_part[search_from..].find(PREFIX) {
1122            let abs_pos = search_from + sys_pos;
1123            let after = &code_part[abs_pos + PREFIX.len()..];
1124            let ident_end = after
1125                .find(|c: char| !c.is_alphanumeric() && c != '_')
1126                .unwrap_or(after.len());
1127            let ident = &after[..ident_end];
1128            if ident.ends_with("_unchecked")
1129                && after[ident_end..].starts_with('(')
1130                && !is_suppressed(lines, line_idx, "MXL301")
1131            {
1132                data.ffi_unchecked_calls
1133                    .push((ident.to_string(), line_idx + 1));
1134            }
1135            search_from = abs_pos + PREFIX.len() + ident_end;
1136        }
1137    }
1138}
1139
1140// region: Impl method helpers (MXL120 and future per-method rules)
1141
1142/// Stringify a `syn::ReturnType` to a compact token string.
1143///
1144/// Returns an empty string for `-> ()` / no explicit return (both mean unit).
1145fn extract_return_type_str(output: &syn::ReturnType) -> String {
1146    use quote::ToTokens;
1147    match output {
1148        syn::ReturnType::Default => String::new(),
1149        syn::ReturnType::Type(_, ty) => ty.to_token_stream().to_string(),
1150    }
1151}
1152
1153/// Detect the receiver kind from a method signature.
1154///
1155/// Mirror: `miniextendr-macros/src/miniextendr_impl.rs` — `detect_receiver_kind`.
1156/// Keep both in sync: if the macro adds a new receiver variant, update this function too.
1157fn detect_receiver_kind(sig: &syn::Signature) -> MethodReceiverKind {
1158    let first = match sig.inputs.first() {
1159        Some(arg) => arg,
1160        None => return MethodReceiverKind::None,
1161    };
1162    match first {
1163        syn::FnArg::Receiver(recv) => {
1164            // syn 2.x parses *all* `self` receiver forms as `FnArg::Receiver`, including
1165            // the typed forms `self: ExternalPtrRef<'_, Self>`, `self: ExternalPtrMut<'_, Self>`
1166            // and `self: ExternalPtr<Self>`.  When a colon token is present the receiver
1167            // has an explicit type in `recv.ty`; otherwise `recv.reference` / `recv.mutability`
1168            // describe the shorthand `(&)(&mut) self`.
1169            if recv.colon_token.is_some() {
1170                // Typed form: `self: <ty>`.  Classify by inspecting `recv.ty`.
1171                match recv.ty.as_ref() {
1172                    syn::Type::Reference(r) if r.mutability.is_some() => MethodReceiverKind::RefMut,
1173                    syn::Type::Reference(_) => MethodReceiverKind::Ref,
1174                    ty => match self_handle_ty(ty) {
1175                        Some("ExternalPtr") => MethodReceiverKind::ExternalPtrValue,
1176                        Some("ExternalPtrRef") => MethodReceiverKind::ExternalPtrRef,
1177                        Some("ExternalPtrMut") => MethodReceiverKind::ExternalPtrMut,
1178                        _ => MethodReceiverKind::None,
1179                    },
1180                }
1181            } else {
1182                // Shorthand form: `self`, `&self`, `&mut self`.
1183                if recv.mutability.is_some() {
1184                    MethodReceiverKind::RefMut
1185                } else if recv.reference.is_some() {
1186                    MethodReceiverKind::Ref
1187                } else {
1188                    MethodReceiverKind::Value
1189                }
1190            }
1191        }
1192        syn::FnArg::Typed(_) => {
1193            // In syn 2.x, typed `self:` forms are represented as `FnArg::Receiver`, so
1194            // this arm is only reached for genuinely non-`self` parameters.
1195            MethodReceiverKind::None
1196        }
1197    }
1198}
1199
1200/// The handle `ty` names over `Self`: `"ExternalPtr"` for `ExternalPtr<Self>`,
1201/// `"ExternalPtrRef"` / `"ExternalPtrMut"` for the views (`ExternalPtrRef<'_,
1202/// Self>`), matched on the last path segment with a `Self` type argument.
1203fn self_handle_ty(ty: &syn::Type) -> Option<&'static str> {
1204    let syn::Type::Path(p) = ty else {
1205        return None;
1206    };
1207    let last = p.path.segments.last()?;
1208    let name = ["ExternalPtr", "ExternalPtrRef", "ExternalPtrMut"]
1209        .into_iter()
1210        .find(|name| last.ident == name)?;
1211    let syn::PathArguments::AngleBracketed(ref args) = last.arguments else {
1212        return None;
1213    };
1214    args.args
1215        .iter()
1216        .any(|arg| {
1217            #[allow(non_exhaustive_omitted_patterns)] match arg {
    syn::GenericArgument::Type(syn::Type::Path(tp)) if
        tp.path.is_ident("Self") => true,
    _ => false,
}matches!(arg, syn::GenericArgument::Type(syn::Type::Path(tp)) if tp.path.is_ident("Self"))
1218        })
1219        .then_some(name)
1220}
1221
1222/// Returns true when the attribute list contains `#[miniextendr(constructor)]` or
1223/// `#[miniextendr(r6(constructor))]` / `#[miniextendr(s3(constructor))]` etc.
1224fn has_constructor_attr(attrs: &[syn::Attribute]) -> bool {
1225    for attr in attrs {
1226        if attr
1227            .path()
1228            .segments
1229            .last()
1230            .is_none_or(|seg| seg.ident != "miniextendr")
1231        {
1232            continue;
1233        }
1234        if let syn::Meta::List(meta_list) = &attr.meta {
1235            let tokens = meta_list.tokens.to_string();
1236            // Accept both `constructor` at top level and inside `r6(...)`, `s3(...)`, etc.
1237            if tokens
1238                .split(|c: char| !c.is_alphanumeric() && c != '_')
1239                .any(|t| t == "constructor")
1240            {
1241                return true;
1242            }
1243        }
1244    }
1245    false
1246}
1247
1248// endregion
1249
1250/// Scan raw source text for direct Rf_error/Rf_errorcall calls.
1251fn scan_rf_error_calls(lines: &[&str], data: &mut FileData) {
1252    for (line_idx, line) in lines.iter().enumerate() {
1253        let trimmed = line.trim();
1254        if trimmed.starts_with("//") {
1255            continue;
1256        }
1257        // Strip inline comments to avoid false positives
1258        let code_part = match trimmed.find("//") {
1259            Some(pos) => &trimmed[..pos],
1260            None => trimmed,
1261        };
1262        for pattern in RF_ERROR_PATTERNS {
1263            if code_part.contains(pattern) && !is_suppressed(lines, line_idx, "MXL300") {
1264                let fn_name = &pattern[..pattern.len() - 1];
1265                data.rf_error_calls
1266                    .push((fn_name.to_string(), line_idx + 1));
1267            }
1268        }
1269    }
1270}
1271
1272// region: MXL304 — non-API `ATTRIB` / `SET_ATTRIB`
1273
1274/// R's non-API attribute-pairlist accessors. `R CMD check` (R >= 4.6) reports them.
1275const NONAPI_ATTRIB_FNS: &[&str] = &["ATTRIB", "SET_ATTRIB"];
1276
1277/// Scan raw source text for calls to, or declarations of, `ATTRIB` / `SET_ATTRIB` and
1278/// their `_unchecked` forms.
1279///
1280/// Only a whole identifier followed by `(` counts, so the API entry points that share
1281/// the suffix (`ANY_ATTRIB`, `CLEAR_ATTRIB`, `DUPLICATE_ATTRIB`,
1282/// `SHALLOW_DUPLICATE_ATTRIB`) are not flagged. A declaration in an `extern` block
1283/// (`fn ATTRIB(x: SEXP) -> SEXP;`) is flagged too, because that is how the symbol
1284/// reaches the package's shared object.
1285fn scan_nonapi_attrib_calls(lines: &[&str], data: &mut FileData) {
1286    for (line_idx, line) in lines.iter().enumerate() {
1287        let trimmed = line.trim();
1288        if trimmed.starts_with("//") {
1289            continue;
1290        }
1291        // Strip inline comments to avoid false positives
1292        let code_part = match trimmed.find("//") {
1293            Some(pos) => &trimmed[..pos],
1294            None => trimmed,
1295        };
1296        for ident in called_idents(code_part) {
1297            let base = ident.strip_suffix("_unchecked").unwrap_or(ident);
1298            if NONAPI_ATTRIB_FNS.contains(&base) && !is_suppressed(lines, line_idx, "MXL304") {
1299                data.nonapi_attrib_calls
1300                    .push((ident.to_string(), line_idx + 1));
1301            }
1302        }
1303    }
1304}
1305
1306/// The whole identifiers in `code` that a `(` follows (after optional whitespace):
1307/// the names of the functions it calls or declares. `DUPLICATE_ATTRIB(` yields
1308/// `DUPLICATE_ATTRIB`, never `ATTRIB`.
1309fn called_idents(code: &str) -> Vec<&str> {
1310    let is_ident_char = |c: char| c.is_alphanumeric() || c == '_';
1311    let mut idents = Vec::new();
1312    let mut chars = code.char_indices().peekable();
1313    while let Some((start, c)) = chars.next() {
1314        if !is_ident_char(c) {
1315            continue;
1316        }
1317        let mut end = start + c.len_utf8();
1318        while let Some(&(idx, next)) = chars.peek() {
1319            if !is_ident_char(next) {
1320                break;
1321            }
1322            end = idx + next.len_utf8();
1323            chars.next();
1324        }
1325        if code[end..].trim_start().starts_with('(') {
1326            idents.push(&code[start..end]);
1327        }
1328    }
1329    idents
1330}
1331
1332// endregion
1333
1334// region: MXL305 — raw R evaluation in a `#[miniextendr]` body
1335
1336/// R entry points that run R code, or R's interrupt handling, and longjmp out on an
1337/// R error or a pending interrupt.
1338const RAW_R_EVAL_FNS: &[&str] = &[
1339    "Rf_eval",
1340    "R_forceAndCall",
1341    "Rf_applyClosure",
1342    "R_CheckUserInterrupt",
1343];
1344
1345/// The 1-based lines of `block`'s `{` and `}`.
1346fn block_lines(block: &syn::Block) -> (usize, usize) {
1347    let span = &block.brace_token.span;
1348    (span.open().start().line, span.close().end().line)
1349}
1350
1351/// Scan the [`FileData::miniextendr_bodies`] for calls to `Rf_eval`,
1352/// `R_forceAndCall`, `Rf_applyClosure` and `R_CheckUserInterrupt`, and their
1353/// `_unchecked` forms,
1354/// through any path (`sys::Rf_eval(`, `miniextendr_api::sys::Rf_eval(`, an
1355/// imported `Rf_eval(`).
1356///
1357/// The generated wrapper runs such a body inside one `R_UnwindProtect`, holding
1358/// the receiver's borrow guard. An R error or interrupt raised by one of these
1359/// calls jumps to that `R_UnwindProtect` over every Rust frame of the body: no
1360/// destructor runs, and the receiver stays borrowed for the rest of the session
1361/// (#2093). Wrapping the call in `with_r_unwind_protect` (or its `_or_raise`
1362/// form) doesn't help: it resumes the R exit with `R_ContinueUnwind`, which jumps
1363/// over the body's frames all the same. So no context inside a body is exempt;
1364/// the `expression` evaluators (`RCall::eval`, `eval_with_handlers`, ...) are the
1365/// way to run R code there.
1366///
1367/// Raw text, like MXL304: a call inside a macro invocation is seen, a helper fn
1368/// the body calls is not (only the bodies themselves are scanned).
1369fn scan_raw_r_eval_calls(lines: &[&str], data: &mut FileData) {
1370    let mut in_body = ::alloc::vec::from_elem(false, lines.len())vec![false; lines.len()];
1371    for &(open, close) in &data.miniextendr_bodies {
1372        for flag in in_body.iter_mut().take(close).skip(open - 1) {
1373            *flag = true;
1374        }
1375    }
1376    for (line_idx, line) in lines.iter().enumerate() {
1377        if !in_body[line_idx] {
1378            continue;
1379        }
1380        let trimmed = line.trim();
1381        if trimmed.starts_with("//") {
1382            continue;
1383        }
1384        // Strip inline comments to avoid false positives
1385        let code_part = match trimmed.find("//") {
1386            Some(pos) => &trimmed[..pos],
1387            None => trimmed,
1388        };
1389        for ident in called_idents(code_part) {
1390            let base = ident.strip_suffix("_unchecked").unwrap_or(ident);
1391            if RAW_R_EVAL_FNS.contains(&base) && !is_suppressed(lines, line_idx, "MXL305") {
1392                data.raw_r_eval_calls
1393                    .push((ident.to_string(), line_idx + 1));
1394            }
1395        }
1396    }
1397}
1398
1399// endregion
1400
1401// region: MXL302 — `into_sexp()` inside a `vec!`/array literal (use-after-free idiom)
1402
1403/// `into_sexp` call spellings that build a fresh, unprotected SEXP.
1404const INTO_SEXP_CALLS: &[&str] = &["into_sexp(", "into_sexp_unchecked("];
1405
1406/// Scan raw source text for `into_sexp()` / `into_sexp_unchecked()` calls that appear
1407/// *inside* a `vec!` or `&[...]` literal — the use-after-free idiom (#307, #1025).
1408///
1409/// Each `into_sexp` allocates a new SEXP. When several appear as elements of one literal
1410/// (`vec![(k, a.into_sexp()), (k, b.into_sexp())]`), building `b` can trigger a GC that
1411/// collects the still-unprotected `a` (and vice versa), because nothing roots the earlier
1412/// elements until the whole `Vec` is handed to `List::from_raw_pairs`. The fix is to route
1413/// each element through the protected builder path (`__scope.protect_raw(x.into_sexp())`
1414/// with a `ProtectScope`), exactly as the `IntoList` / `DataFrameRow` derives now do.
1415///
1416/// # Heuristic and scope
1417///
1418/// This is a deliberately narrow raw-text scanner (consistent with MXL300/MXL301):
1419/// it tracks bracket depth opened by a `vec![` or `&[` literal and flags an `into_sexp(`
1420/// call only while that depth is open. This is what makes it precise:
1421///
1422/// - **Flags** `vec![ ... into_sexp() ... ]` — the call is an element *inside* the literal.
1423/// - **Does NOT flag** `vec![1, 2, 3].into_sexp()` — the `]` closes the literal before the
1424///   `.into_sexp()` call, so depth is back to 0 (the whole `Vec` is converted as one SEXP,
1425///   which is safe — there are no sibling unprotected SEXPs).
1426///
1427/// The protected builder path is treated as a true negative: an `into_sexp(` whose element
1428/// routes through a `protect_raw(...)` / `protect(...)` / `protect_with_index(...)` call
1429/// (`__scope.protect_raw(x.into_sexp())`) is *not* flagged. This is exactly the form the
1430/// `IntoList` / `DataFrameRow` derives emit, and the recommended hand-written fix.
1431///
1432/// Known limits (false negatives, by design — to keep zero false positives):
1433/// - Only `vec![` and `&[` literal opens are tracked; bare `[ ... ]` array literals are
1434///   not, because a leading `[` is ambiguous with indexing (`arr[i]`). Array-literal sites
1435///   are rare in this codebase; promote the scanner if one appears.
1436/// - The protect-detection is per-element and line-local in spirit: an element whose
1437///   `protect(...)`/`protect_raw(...)` wrapper sits on an earlier line than its `into_sexp(`
1438///   is still recognised (the flag persists until the next element-separating `,`), but a
1439///   contrived element that opens a protect call yet smuggles an *unprotected* sibling
1440///   `into_sexp(` after the same comma-free span would be missed. No such shape exists in
1441///   the corpus.
1442fn scan_vec_into_sexp_calls(lines: &[&str], data: &mut FileData) {
1443    // Bracket depth opened specifically by a `vec![` / `&[` literal. Nested `[` inside the
1444    // literal increment it too; `]` decrements. We never let it go negative.
1445    let mut literal_depth: i32 = 0;
1446    // Whether the *current literal element* already routes its value through a protect call
1447    // (`protect_raw(...)` / `protect(...)`). Reset at each element-separating `,` and at
1448    // each literal open. This is what makes the protected builder path (the form emitted by
1449    // the `IntoList` / `DataFrameRow` derives) a true negative.
1450    let mut element_protected = false;
1451
1452    for (line_idx, line) in lines.iter().enumerate() {
1453        let trimmed = line.trim();
1454        if trimmed.starts_with("//") {
1455            continue;
1456        }
1457        // Strip inline `//` comments so commented-out idioms / doc prose don't trip us.
1458        let code_part = match line.find("//") {
1459            Some(pos) => &line[..pos],
1460            None => *line,
1461        };
1462
1463        let bytes = code_part.as_bytes();
1464        let mut i = 0;
1465        while i < bytes.len() {
1466            // Detect a `vec![` / `&[` literal open (look-behind).
1467            if bytes[i] == b'[' {
1468                // Byte-slice matching: `i` walks bytes, so `&str` slicing would
1469                // panic mid-codepoint on non-ASCII source (e.g. a `"α"` literal).
1470                let opens_literal =
1471                    bytes[..i].ends_with(b"vec!") || (i > 0 && bytes[i - 1] == b'&');
1472                if opens_literal || literal_depth > 0 {
1473                    // Either a fresh literal open, or a nested `[` while already inside one.
1474                    literal_depth += 1;
1475                    element_protected = false;
1476                }
1477                i += 1;
1478                continue;
1479            }
1480            if bytes[i] == b']' {
1481                if literal_depth > 0 {
1482                    literal_depth -= 1;
1483                }
1484                i += 1;
1485                continue;
1486            }
1487            if literal_depth > 0 {
1488                // An element separator resets the per-element protected flag.
1489                if bytes[i] == b',' {
1490                    element_protected = false;
1491                    i += 1;
1492                    continue;
1493                }
1494                // A `protect_raw(` / `protect(` call before `into_sexp(` in this element
1495                // means the value is rooted as it is built — the protected builder path.
1496                if bytes[i..].starts_with(b"protect_raw(")
1497                    || bytes[i..].starts_with(b"protect(")
1498                    || bytes[i..].starts_with(b"protect_with_index(")
1499                {
1500                    element_protected = true;
1501                }
1502                // Flag a raw `into_sexp(` element call (not wrapped in a protect call).
1503                for pattern in INTO_SEXP_CALLS {
1504                    if bytes[i..].starts_with(pattern.as_bytes())
1505                        && !element_protected
1506                        && !is_suppressed(lines, line_idx, "MXL302")
1507                    {
1508                        let call_name = &pattern[..pattern.len() - 1];
1509                        data.vec_into_sexp_calls
1510                            .push((call_name.to_string(), line_idx + 1));
1511                    }
1512                }
1513            }
1514            i += 1;
1515        }
1516    }
1517}
1518// endregion
1519// endregion