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::fs;
7use std::path::{Path, PathBuf};
8
9use syn::Item;
10use syn::spanned::Spanned;
11
12use crate::helpers::{
13    extract_cfg_attrs, extract_path_attr, extract_roxygen_tags, has_derive, has_miniextendr_attr,
14    impl_type_name, is_altrep_struct, parse_miniextendr_impl_attrs,
15};
16
17// region: Impl method entry
18
19/// Receiver kind for an impl method, mirroring `ReceiverKind` in `miniextendr-macros`.
20///
21/// Mirror: `miniextendr-macros/src/miniextendr_impl.rs` — `ReceiverKind`.
22/// Keep both in sync: if the macro relaxes one receiver kind, update this enum too.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum MethodReceiverKind {
25    /// No self — static / associated function.
26    None,
27    /// `&self`
28    Ref,
29    /// `&mut self`
30    RefMut,
31    /// `self` (consuming)
32    Value,
33    /// `self: &ExternalPtr<Self>`
34    ExternalPtrRef,
35    /// `self: &mut ExternalPtr<Self>`
36    ExternalPtrRefMut,
37    /// `self: ExternalPtr<Self>`
38    ExternalPtrValue,
39}
40
41impl MethodReceiverKind {
42    /// Returns true if this is an instance receiver (any form of `self`).
43    ///
44    /// Mirrors `ReceiverKind::is_instance` in `miniextendr-macros/src/miniextendr_impl.rs`.
45    /// `Value` (consuming `self`) is **excluded** — the macro treats consuming-`self` methods
46    /// separately: they are either constructors (`returns Self` or `#[miniextendr(constructor)]`)
47    /// or finalizers, not ordinary instance calls.  Including `Value` here would produce a
48    /// false-positive for a vctrs method with `#[miniextendr(constructor)]` that consumes `self`.
49    pub fn is_instance(self) -> bool {
50        matches!(
51            self,
52            Self::Ref
53                | Self::RefMut
54                | Self::ExternalPtrRef
55                | Self::ExternalPtrRefMut
56                | Self::ExternalPtrValue
57        )
58    }
59
60    /// Human-readable spelling used in diagnostic messages.
61    pub fn spelling(self) -> &'static str {
62        match self {
63            Self::None => "(none)",
64            Self::Ref => "&self",
65            Self::RefMut => "&mut self",
66            Self::Value => "self",
67            Self::ExternalPtrRef => "self: &ExternalPtr<Self>",
68            Self::ExternalPtrRefMut => "self: &mut ExternalPtr<Self>",
69            Self::ExternalPtrValue => "self: ExternalPtr<Self>",
70        }
71    }
72}
73
74/// Per-method data collected during the crate-index pass for impl-method lint rules.
75#[derive(Clone, Debug)]
76pub struct ImplMethodEntry {
77    pub method_name: String,
78    pub line: usize,
79    pub class_system: String,
80    /// Stringified return type tokens (empty string = `()` / no explicit return).
81    pub return_type_str: String,
82    /// Receiver kind detected from the method signature.
83    pub receiver_kind: MethodReceiverKind,
84    /// True when the method carries `#[miniextendr(constructor)]`.
85    pub has_constructor_attr: bool,
86}
87
88// endregion
89
90// region: Lint item types
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
93pub enum LintKind {
94    Function,
95    Impl,
96    Struct,
97    TraitImpl,
98    Vctrs,
99}
100
101#[derive(Clone, Debug)]
102pub struct LintItem {
103    pub kind: LintKind,
104    pub name: String,
105    pub label: Option<String>,
106    pub line: usize,
107}
108
109impl PartialEq for LintItem {
110    fn eq(&self, other: &Self) -> bool {
111        self.kind == other.kind && self.name == other.name && self.label == other.label
112    }
113}
114
115impl Eq for LintItem {}
116
117impl std::hash::Hash for LintItem {
118    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
119        self.kind.hash(state);
120        self.name.hash(state);
121        self.label.hash(state);
122    }
123}
124
125impl LintItem {
126    pub fn new(kind: LintKind, name: String, line: usize) -> Self {
127        Self {
128            kind,
129            name,
130            label: None,
131            line,
132        }
133    }
134
135    pub fn with_label(kind: LintKind, name: String, label: Option<String>, line: usize) -> Self {
136        Self {
137            kind,
138            name,
139            label,
140            line,
141        }
142    }
143}
144// endregion
145
146// region: Attributed trait impls from source
147
148#[derive(Clone, Debug)]
149pub struct AttributedTraitImpl {
150    pub type_name: String,
151    pub trait_name: String,
152    pub class_system: Option<String>,
153    pub line: usize,
154    /// True when the impl carries a `// mxl::allow(MXL303)` escape-hatch comment
155    /// on (or directly above) its line. Computed at parse time because the
156    /// look-behind needs the raw source split, which only `parse_file` holds.
157    pub suppressed_mxl303: bool,
158}
159// endregion
160
161// region: Per-file parsed data
162
163#[derive(Debug, Default)]
164pub struct FileData {
165    // Source items (functions, impls, structs with #[miniextendr])
166    pub miniextendr_items: Vec<LintItem>,
167
168    // Type/derive information
169    pub types_with_external_ptr: HashSet<String>,
170    pub types_with_typed_external: HashSet<String>,
171
172    // Impl block details
173    pub inherent_impl_class_systems: HashMap<String, (String, usize)>,
174    pub attributed_trait_impls: Vec<AttributedTraitImpl>,
175    pub impl_blocks_per_type: HashMap<String, Vec<(Option<String>, usize)>>,
176
177    // Function details
178    pub fn_visibility: HashMap<String, bool>,
179
180    // Module tree (for file discovery)
181    /// Simple `mod child;` declarations (by ident name).
182    pub declared_child_mods: Vec<String>,
183    /// `#[path = "file.rs"] mod name;` declarations: (mod_name, file_path_str).
184    pub path_redirected_mods: Vec<(String, String)>,
185    /// cfg attrs on `mod child;` declarations: mod_name -> cfg strings.
186    pub mod_decl_cfgs: HashMap<String, Vec<String>>,
187
188    // Export control
189    /// (has_internal, has_noexport, line)
190    pub export_control: HashMap<String, (bool, bool, usize)>,
191
192    // Impl method details for per-method lint rules
193    /// Methods per inherent impl type: type_name → `Vec<ImplMethodEntry>`.
194    pub impl_methods: HashMap<String, Vec<ImplMethodEntry>>,
195
196    // Doc-comment roxygen tags per function/impl name
197    /// Known roxygen tags: "@noRd", "@export", "@keywords internal"
198    pub fn_doc_tags: HashMap<String, Vec<String>>,
199
200    // Safety lint data
201    /// Lines containing direct Rf_error/Rf_errorcall calls: (function_name, line_number).
202    pub rf_error_calls: Vec<(String, usize)>,
203    /// Lines containing `ffi::*_unchecked()` calls: (function_name, line_number).
204    pub ffi_unchecked_calls: Vec<(String, usize)>,
205    /// `into_sexp()` / `into_sexp_unchecked()` calls that appear *inside* a `vec!`/array
206    /// literal: (call_name, line_number). This is the use-after-free idiom — each later
207    /// `into_sexp` allocates and can GC an earlier, still-unprotected element of the same
208    /// literal. (MXL302)
209    pub vec_into_sexp_calls: Vec<(String, usize)>,
210
211    // R reserved-word parameter names
212    /// Maps fn/method name → list of (param_name, line) for params that are R reserved words.
213    /// Key for free functions is the function name; for impl methods it is `"TypeName::method_name"`.
214    pub fn_param_names: HashMap<String, Vec<(String, usize)>>,
215}
216// endregion
217
218// region: Crate index
219
220/// Shared parsed state for all lint rules.
221pub struct CrateIndex {
222    /// All scanned Rust source files.
223    pub files: Vec<PathBuf>,
224    /// Per-file parsed data.
225    pub file_data: HashMap<PathBuf, FileData>,
226}
227
228impl CrateIndex {
229    /// Build the index from a crate root directory.
230    pub fn build(root: &Path) -> Result<Self, String> {
231        let src_dir = if root.join("src").is_dir() {
232            root.join("src")
233        } else {
234            root.to_path_buf()
235        };
236
237        if !src_dir.is_dir() {
238            return Err(format!(
239                "miniextendr-lint: root is not a directory: {}",
240                src_dir.display()
241            ));
242        }
243
244        let mut rs_files = Vec::new();
245        collect_rs_files_from_module_tree(&src_dir, &mut rs_files)?;
246        rs_files.sort();
247
248        let mut file_data = HashMap::new();
249        let mut parse_errors = Vec::new();
250
251        for path in &rs_files {
252            match parse_file(path) {
253                Ok(data) => {
254                    file_data.insert(path.clone(), data);
255                }
256                Err(err) => parse_errors.push(err),
257            }
258        }
259
260        if !parse_errors.is_empty() {
261            return Err(parse_errors.join("; "));
262        }
263
264        Ok(Self {
265            files: rs_files,
266            file_data,
267        })
268    }
269}
270// endregion
271
272// region: File collection (module-tree walker)
273
274/// Collect Rust source files by walking the module tree from `lib.rs`,
275/// following `mod child;` declarations and respecting `#[cfg(feature = "...")]`
276/// gates via `CARGO_FEATURE_*` environment variables.
277fn collect_rs_files_from_module_tree(src_dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
278    let lib_rs = src_dir.join("lib.rs");
279    if !lib_rs.is_file() {
280        return Err(format!(
281            "miniextendr-lint: cannot find lib.rs in {}",
282            src_dir.display()
283        ));
284    }
285
286    let active_features = collect_active_cargo_features();
287    let mut seen = HashSet::new();
288    walk_module_file(&lib_rs, &active_features, out, &mut seen);
289    Ok(())
290}
291
292/// Collect the set of active Cargo features from `CARGO_FEATURE_*` env vars.
293/// Feature names are normalized: `CARGO_FEATURE_FOO_BAR` → `"foo-bar"`.
294fn collect_active_cargo_features() -> HashSet<String> {
295    std::env::vars()
296        .filter_map(|(key, _)| {
297            key.strip_prefix("CARGO_FEATURE_")
298                .map(|suffix| suffix.to_lowercase().replace('_', "-"))
299        })
300        .collect()
301}
302
303/// Recursively walk a module file, following `mod` declarations.
304fn walk_module_file(
305    file: &Path,
306    active_features: &HashSet<String>,
307    out: &mut Vec<PathBuf>,
308    seen: &mut HashSet<PathBuf>,
309) {
310    if !file.is_file() {
311        return;
312    }
313
314    let file_buf = file.to_path_buf();
315    if !seen.insert(file_buf.clone()) {
316        return;
317    }
318
319    out.push(file_buf);
320
321    // Parse the file to discover mod declarations
322    let Ok(src) = fs::read_to_string(file) else {
323        return;
324    };
325    let Ok(parsed) = syn::parse_file(&src) else {
326        return;
327    };
328
329    let parent_dir = match file.parent() {
330        Some(dir) => dir,
331        None => return,
332    };
333
334    // Determine the stem-based subdirectory for non-lib/mod files.
335    // For `foo.rs`, child modules live in `foo/`.
336    // For `lib.rs` or `mod.rs`, child modules live in the same directory.
337    let child_dir = {
338        let stem = file.file_stem().and_then(|s| s.to_str());
339        match stem {
340            Some("lib" | "mod") => parent_dir.to_path_buf(),
341            Some(name) => parent_dir.join(name),
342            None => parent_dir.to_path_buf(),
343        }
344    };
345
346    discover_mod_declarations(&parsed.items, &child_dir, active_features, out, seen);
347}
348
349/// Walk parsed items looking for `mod child;` declarations and recurse.
350fn discover_mod_declarations(
351    items: &[Item],
352    child_dir: &Path,
353    active_features: &HashSet<String>,
354    out: &mut Vec<PathBuf>,
355    seen: &mut HashSet<PathBuf>,
356) {
357    for item in items {
358        let Item::Mod(item_mod) = item else {
359            continue;
360        };
361
362        if let Some((_, child_items)) = &item_mod.content {
363            // Inline module — recurse into its items (same file)
364            discover_mod_declarations(child_items, child_dir, active_features, out, seen);
365        } else {
366            // Out-of-line module declaration: `mod child;`
367            // Check if cfg-gated and whether the gate is active
368            let cfgs = extract_cfg_attrs(&item_mod.attrs);
369            if !cfgs.is_empty() && !is_cfg_active(&cfgs, active_features) {
370                continue; // Feature not enabled, skip this module
371            }
372
373            let mod_name = item_mod.ident.to_string();
374
375            // Check for #[path = "file.rs"] attribute
376            let path_attr = extract_path_attr(&item_mod.attrs);
377
378            if let Some(file_path) = path_attr {
379                let target = child_dir.join(&file_path);
380                walk_module_file(&target, active_features, out, seen);
381            } else {
382                // Try child.rs first, then child/mod.rs
383                let sibling = child_dir.join(format!("{mod_name}.rs"));
384                if sibling.is_file() {
385                    walk_module_file(&sibling, active_features, out, seen);
386                } else {
387                    let subdir_mod = child_dir.join(&mod_name).join("mod.rs");
388                    walk_module_file(&subdir_mod, active_features, out, seen);
389                }
390            }
391        }
392    }
393}
394
395/// Evaluate whether a set of `#[cfg(...)]` attributes is active given the current features.
396fn is_cfg_active(cfgs: &[String], active_features: &HashSet<String>) -> bool {
397    for cfg_str in cfgs {
398        if let Some(result) = eval_cfg_str(cfg_str, active_features)
399            && !result
400        {
401            return false;
402        }
403    }
404    true
405}
406
407/// Try to evaluate a single cfg string like `cfg(feature = "foo")`.
408fn eval_cfg_str(cfg_str: &str, active_features: &HashSet<String>) -> Option<bool> {
409    let normalized: String = cfg_str.chars().filter(|c| !c.is_whitespace()).collect();
410
411    let inner = normalized
412        .strip_prefix("cfg(")
413        .and_then(|s| s.strip_suffix(')'))?;
414
415    if let Some(not_inner) = inner.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
416        if let Some(feat) = extract_feature_name(not_inner) {
417            return Some(!active_features.contains(&feat));
418        }
419        return None;
420    }
421
422    if let Some(feat) = extract_feature_name(inner) {
423        return Some(active_features.contains(&feat));
424    }
425
426    None
427}
428
429/// Extract the feature name from a string like `feature="foo"`.
430fn extract_feature_name(s: &str) -> Option<String> {
431    let rest = s.strip_prefix("feature")?;
432    let rest = rest.strip_prefix('=')?;
433    let name = rest.trim_matches('"').trim_matches('\\');
434    if name.is_empty() {
435        None
436    } else {
437        Some(name.to_string())
438    }
439}
440// endregion
441
442// region: Single-file parsing
443
444fn parse_file(path: &Path) -> Result<FileData, String> {
445    let src = fs::read_to_string(path)
446        .map_err(|err| format!("{}: failed to read: {err}", path.display()))?;
447
448    let parsed = syn::parse_file(&src)
449        .map_err(|err| format!("{}: failed to parse: {err}", path.display()))?;
450
451    let mut data = FileData::default();
452    collect_items_recursive(&parsed.items, &mut data);
453
454    // Both raw-source scanners need the line-split for is_suppressed look-behind.
455    let lines: Vec<&str> = src.lines().collect();
456    scan_rf_error_calls(&lines, &mut data);
457    scan_ffi_unchecked_calls(&lines, &mut data);
458    scan_vec_into_sexp_calls(&lines, &mut data);
459
460    // MXL303 escape hatch: resolve the `// mxl::allow(MXL303)` look-behind for each
461    // attributed trait impl now, while the raw source split is in scope. The impl's
462    // recorded line points at the `self_ty` (e.g. `impl Trait for Type`), so the
463    // allow comment sits above one or more `#[…]` attribute lines — scan upward past
464    // attributes and blank lines to find it.
465    for ati in &mut data.attributed_trait_impls {
466        ati.suppressed_mxl303 = allow_above_attrs(&lines, ati.line, "MXL303");
467    }
468
469    Ok(data)
470}
471
472/// Extract named parameter names (and their 1-based line numbers) from a function signature.
473///
474/// Skips `self` / `&self` / `&mut self` receiver parameters. Skips unnamed (`_`) parameters.
475fn extract_param_names(sig: &syn::Signature) -> Vec<(String, usize)> {
476    let mut params = Vec::new();
477    for input in &sig.inputs {
478        if let syn::FnArg::Typed(pat_type) = input
479            && let syn::Pat::Ident(pat_ident) = &*pat_type.pat
480        {
481            let name = pat_ident.ident.to_string();
482            // Skip `_` (bare anonymous). Named `_foo` patterns are kept because
483            // the proc-macro forwards the name verbatim (stripping only the leading
484            // underscore in some codegen paths), so they can still collide with R
485            // reserved words.
486            if name == "_" {
487                continue;
488            }
489            let line = pat_ident.ident.span().start().line;
490            params.push((name, line));
491        }
492    }
493    params
494}
495
496/// Recursively collect all lint-relevant information from parsed items.
497fn collect_items_recursive(items: &[Item], data: &mut FileData) {
498    for item in items {
499        match item {
500            Item::Fn(item_fn) if has_miniextendr_attr(&item_fn.attrs) => {
501                let line = item_fn.sig.ident.span().start().line;
502                let name = item_fn.sig.ident.to_string();
503
504                data.miniextendr_items
505                    .push(LintItem::new(LintKind::Function, name.clone(), line));
506
507                // Track visibility
508                let is_pub = matches!(item_fn.vis, syn::Visibility::Public(_));
509                data.fn_visibility.insert(name.clone(), is_pub);
510
511                // Track export control
512                let attrs = parse_miniextendr_impl_attrs(&item_fn.attrs);
513                if attrs.internal || attrs.noexport {
514                    data.export_control
515                        .insert(name.clone(), (attrs.internal, attrs.noexport, line));
516                }
517
518                // Track doc-comment roxygen tags
519                let doc_tags = extract_roxygen_tags(&item_fn.attrs);
520                if !doc_tags.is_empty() {
521                    data.fn_doc_tags.insert(name.clone(), doc_tags);
522                }
523
524                // Track parameter names for R reserved-word check (MXL110)
525                let params = extract_param_names(&item_fn.sig);
526                if !params.is_empty() {
527                    data.fn_param_names.insert(name.clone(), params);
528                }
529            }
530            Item::Struct(item_struct) => {
531                let is_miniextendr_altrep =
532                    has_miniextendr_attr(&item_struct.attrs) && is_altrep_struct(item_struct);
533                let is_derive_altrep = has_derive(&item_struct.attrs, "Altrep");
534                if is_miniextendr_altrep || is_derive_altrep {
535                    let line = item_struct.ident.span().start().line;
536                    data.miniextendr_items.push(LintItem::new(
537                        LintKind::Struct,
538                        item_struct.ident.to_string(),
539                        line,
540                    ));
541                }
542                if has_derive(&item_struct.attrs, "ExternalPtr") {
543                    data.types_with_external_ptr
544                        .insert(item_struct.ident.to_string());
545                }
546                if has_derive(&item_struct.attrs, "Vctrs") {
547                    let line = item_struct.ident.span().start().line;
548                    data.miniextendr_items.push(LintItem::new(
549                        LintKind::Vctrs,
550                        item_struct.ident.to_string(),
551                        line,
552                    ));
553                }
554            }
555            Item::Impl(item_impl) => {
556                // Check for impl TypedExternal for Type
557                if let Some((_, trait_path, _)) = &item_impl.trait_
558                    && let Some(last_seg) = trait_path.segments.last()
559                    && last_seg.ident == "TypedExternal"
560                    && let Some(type_name) = impl_type_name(&item_impl.self_ty)
561                {
562                    data.types_with_typed_external.insert(type_name);
563                }
564
565                if has_miniextendr_attr(&item_impl.attrs) {
566                    let line = item_impl.self_ty.span().start().line;
567                    let impl_attrs = parse_miniextendr_impl_attrs(&item_impl.attrs);
568
569                    match impl_type_name(&item_impl.self_ty) {
570                        Some(type_name) => {
571                            if let Some((_, trait_path, _)) = &item_impl.trait_ {
572                                // Trait impl
573                                if let Some(trait_seg) = trait_path.segments.last() {
574                                    let trait_name = trait_seg.ident.to_string();
575                                    let full_name = format!("{} for {}", trait_name, type_name);
576                                    data.miniextendr_items.push(LintItem::new(
577                                        LintKind::TraitImpl,
578                                        full_name,
579                                        line,
580                                    ));
581                                    data.attributed_trait_impls.push(AttributedTraitImpl {
582                                        type_name: type_name.clone(),
583                                        trait_name,
584                                        class_system: impl_attrs.class_system.clone(),
585                                        line,
586                                        suppressed_mxl303: false,
587                                    });
588                                }
589                            } else {
590                                // Inherent impl
591                                let class_system =
592                                    impl_attrs.class_system.clone().unwrap_or_default();
593                                data.inherent_impl_class_systems
594                                    .insert(type_name.clone(), (class_system.clone(), line));
595                                data.impl_blocks_per_type
596                                    .entry(type_name.clone())
597                                    .or_default()
598                                    .push((impl_attrs.label.clone(), line));
599                                data.miniextendr_items.push(LintItem::with_label(
600                                    LintKind::Impl,
601                                    type_name.clone(),
602                                    impl_attrs.label.clone(),
603                                    line,
604                                ));
605
606                                // Collect method names for per-method rules (e.g. MXL111, MXL120)
607                                let methods =
608                                    data.impl_methods.entry(type_name.clone()).or_default();
609                                for impl_item in &item_impl.items {
610                                    if let syn::ImplItem::Fn(method) = impl_item {
611                                        let method_name = method.sig.ident.to_string();
612                                        let method_line = method.sig.ident.span().start().line;
613                                        let return_type_str =
614                                            extract_return_type_str(&method.sig.output);
615                                        let receiver_kind = detect_receiver_kind(&method.sig);
616                                        let has_constructor_attr =
617                                            has_constructor_attr(&method.attrs);
618                                        methods.push(ImplMethodEntry {
619                                            method_name,
620                                            line: method_line,
621                                            class_system: class_system.clone(),
622                                            return_type_str,
623                                            receiver_kind,
624                                            has_constructor_attr,
625                                        });
626                                    }
627                                }
628
629                                // Track export control
630                                if impl_attrs.internal || impl_attrs.noexport {
631                                    data.export_control.insert(
632                                        type_name.clone(),
633                                        (impl_attrs.internal, impl_attrs.noexport, line),
634                                    );
635                                }
636                            }
637
638                            // Track parameter names for all methods in the impl block (MXL110)
639                            for impl_item in &item_impl.items {
640                                if let syn::ImplItem::Fn(method) = impl_item {
641                                    let method_name = method.sig.ident.to_string();
642                                    let key = format!("{}::{}", type_name, method_name);
643                                    let params = extract_param_names(&method.sig);
644                                    if !params.is_empty() {
645                                        data.fn_param_names.insert(key, params);
646                                    }
647                                }
648                            }
649                        }
650                        None => { /* unsupported impl type, skip */ }
651                    }
652                }
653            }
654            Item::Mod(item_mod) => {
655                if let Some((_, child_items)) = &item_mod.content {
656                    // Inline module
657                    collect_items_recursive(child_items, data);
658                } else {
659                    // Out-of-line module declaration
660                    let mod_name = item_mod.ident.to_string();
661
662                    // Track cfg attrs on the mod declaration
663                    let cfgs = extract_cfg_attrs(&item_mod.attrs);
664                    if !cfgs.is_empty() {
665                        data.mod_decl_cfgs.insert(mod_name.clone(), cfgs);
666                    }
667
668                    // Check for #[path = "file.rs"] attribute
669                    let path_attr = extract_path_attr(&item_mod.attrs);
670                    if let Some(file_path) = path_attr {
671                        data.path_redirected_mods.push((mod_name, file_path));
672                    } else {
673                        data.declared_child_mods.push(mod_name);
674                    }
675                }
676            }
677            _ => {}
678        }
679    }
680}
681
682/// Patterns that indicate direct Rf_error/Rf_errorcall calls in user code.
683const RF_ERROR_PATTERNS: &[&str] = &[
684    "Rf_error(",
685    "Rf_error_unchecked(",
686    "Rf_errorcall(",
687    "Rf_errorcall_unchecked(",
688];
689
690/// Check if a lint code is suppressed via `// mxl::allow(MXL...)` comment.
691fn is_suppressed(lines: &[&str], line_idx: usize, code: &str) -> bool {
692    if line_has_allow(lines[line_idx], code) {
693        return true;
694    }
695    if line_idx > 0 && line_has_allow(lines[line_idx - 1], code) {
696        return true;
697    }
698    false
699}
700
701/// Check for `// mxl::allow(<code>)` on the impl line itself, or on the nearest
702/// non-attribute / non-blank line above it.
703///
704/// `line` is 1-based and points at the impl's `self_ty`. Scans upward across
705/// `#[…]` attribute lines and blank lines (the macro attribute and any doc
706/// comments sit between the allow comment and the recorded line).
707fn allow_above_attrs(lines: &[&str], line: usize, code: &str) -> bool {
708    if line == 0 || line > lines.len() {
709        return false;
710    }
711    // The impl line itself.
712    if line_has_allow(lines[line - 1], code) {
713        return true;
714    }
715    // Walk upward over attribute/blank lines until the first "real" line.
716    let mut idx = line - 1;
717    while idx > 0 {
718        idx -= 1;
719        let trimmed = lines[idx].trim_start();
720        if trimmed.is_empty() || trimmed.starts_with("#[") || trimmed.starts_with("#!") {
721            continue;
722        }
723        return line_has_allow(lines[idx], code);
724    }
725    false
726}
727
728/// Check if a single line contains `// mxl::allow(...)` matching the given code.
729fn line_has_allow(line: &str, code: &str) -> bool {
730    const PREFIX: &str = "// mxl::allow(";
731    if let Some(pos) = line.find(PREFIX) {
732        let after = &line[pos + PREFIX.len()..];
733        if let Some(end) = after.find(')') {
734            let codes = &after[..end];
735            return codes.split(',').any(|c| c.trim() == code);
736        }
737    }
738    false
739}
740
741/// Scan raw source text for `sys::*_unchecked()` calls.
742fn scan_ffi_unchecked_calls(lines: &[&str], data: &mut FileData) {
743    const PREFIX: &str = "sys::";
744    for (line_idx, line) in lines.iter().enumerate() {
745        let trimmed = line.trim();
746        if trimmed.starts_with("//") {
747            continue;
748        }
749        if trimmed.starts_with("#[") {
750            continue;
751        }
752        // Strip inline comments to avoid false positives
753        let code_part = match trimmed.find("//") {
754            Some(pos) => &trimmed[..pos],
755            None => trimmed,
756        };
757        let mut search_from = 0;
758        while let Some(sys_pos) = code_part[search_from..].find(PREFIX) {
759            let abs_pos = search_from + sys_pos;
760            let after = &code_part[abs_pos + PREFIX.len()..];
761            let ident_end = after
762                .find(|c: char| !c.is_alphanumeric() && c != '_')
763                .unwrap_or(after.len());
764            let ident = &after[..ident_end];
765            if ident.ends_with("_unchecked")
766                && after[ident_end..].starts_with('(')
767                && !is_suppressed(lines, line_idx, "MXL301")
768            {
769                data.ffi_unchecked_calls
770                    .push((ident.to_string(), line_idx + 1));
771            }
772            search_from = abs_pos + PREFIX.len() + ident_end;
773        }
774    }
775}
776
777// region: Impl method helpers (MXL120 and future per-method rules)
778
779/// Stringify a `syn::ReturnType` to a compact token string.
780///
781/// Returns an empty string for `-> ()` / no explicit return (both mean unit).
782fn extract_return_type_str(output: &syn::ReturnType) -> String {
783    use quote::ToTokens;
784    match output {
785        syn::ReturnType::Default => String::new(),
786        syn::ReturnType::Type(_, ty) => ty.to_token_stream().to_string(),
787    }
788}
789
790/// Detect the receiver kind from a method signature.
791///
792/// Mirror: `miniextendr-macros/src/miniextendr_impl.rs` — `detect_receiver_kind`.
793/// Keep both in sync: if the macro adds a new receiver variant, update this function too.
794fn detect_receiver_kind(sig: &syn::Signature) -> MethodReceiverKind {
795    let first = match sig.inputs.first() {
796        Some(arg) => arg,
797        None => return MethodReceiverKind::None,
798    };
799    match first {
800        syn::FnArg::Receiver(recv) => {
801            // syn 2.x parses *all* `self` receiver forms as `FnArg::Receiver`, including
802            // the typed forms `self: &ExternalPtr<Self>`, `self: &mut ExternalPtr<Self>`,
803            // and `self: ExternalPtr<Self>`.  When a colon token is present the receiver
804            // has an explicit type in `recv.ty`; otherwise `recv.reference` / `recv.mutability`
805            // describe the shorthand `(&)(&mut) self`.
806            if recv.colon_token.is_some() {
807                // Typed form: `self: <ty>`.  Classify by inspecting `recv.ty`.
808                match recv.ty.as_ref() {
809                    syn::Type::Reference(r) => {
810                        if is_external_ptr_self_ty(r.elem.as_ref()) {
811                            if r.mutability.is_some() {
812                                MethodReceiverKind::ExternalPtrRefMut
813                            } else {
814                                MethodReceiverKind::ExternalPtrRef
815                            }
816                        } else if r.mutability.is_some() {
817                            MethodReceiverKind::RefMut
818                        } else {
819                            MethodReceiverKind::Ref
820                        }
821                    }
822                    ty if is_external_ptr_self_ty(ty) => MethodReceiverKind::ExternalPtrValue,
823                    _ => MethodReceiverKind::None,
824                }
825            } else {
826                // Shorthand form: `self`, `&self`, `&mut self`.
827                if recv.mutability.is_some() {
828                    MethodReceiverKind::RefMut
829                } else if recv.reference.is_some() {
830                    MethodReceiverKind::Ref
831                } else {
832                    MethodReceiverKind::Value
833                }
834            }
835        }
836        syn::FnArg::Typed(_) => {
837            // In syn 2.x, typed `self:` forms are represented as `FnArg::Receiver`, so
838            // this arm is only reached for genuinely non-`self` parameters.
839            MethodReceiverKind::None
840        }
841    }
842}
843
844/// Returns true if `ty` is `ExternalPtr<Self>` (last path segment = `ExternalPtr`,
845/// single type argument = `Self`).
846fn is_external_ptr_self_ty(ty: &syn::Type) -> bool {
847    let syn::Type::Path(p) = ty else {
848        return false;
849    };
850    let Some(last) = p.path.segments.last() else {
851        return false;
852    };
853    if last.ident != "ExternalPtr" {
854        return false;
855    }
856    let syn::PathArguments::AngleBracketed(ref args) = last.arguments else {
857        return false;
858    };
859    matches!(
860        args.args.first(),
861        Some(syn::GenericArgument::Type(syn::Type::Path(tp)))
862            if tp.path.is_ident("Self")
863    )
864}
865
866/// Returns true when the attribute list contains `#[miniextendr(constructor)]` or
867/// `#[miniextendr(r6(constructor))]` / `#[miniextendr(s3(constructor))]` etc.
868fn has_constructor_attr(attrs: &[syn::Attribute]) -> bool {
869    for attr in attrs {
870        if attr
871            .path()
872            .segments
873            .last()
874            .is_none_or(|seg| seg.ident != "miniextendr")
875        {
876            continue;
877        }
878        if let syn::Meta::List(meta_list) = &attr.meta {
879            let tokens = meta_list.tokens.to_string();
880            // Accept both `constructor` at top level and inside `r6(...)`, `s3(...)`, etc.
881            if tokens
882                .split(|c: char| !c.is_alphanumeric() && c != '_')
883                .any(|t| t == "constructor")
884            {
885                return true;
886            }
887        }
888    }
889    false
890}
891
892// endregion
893
894/// Scan raw source text for direct Rf_error/Rf_errorcall calls.
895fn scan_rf_error_calls(lines: &[&str], data: &mut FileData) {
896    for (line_idx, line) in lines.iter().enumerate() {
897        let trimmed = line.trim();
898        if trimmed.starts_with("//") {
899            continue;
900        }
901        // Strip inline comments to avoid false positives
902        let code_part = match trimmed.find("//") {
903            Some(pos) => &trimmed[..pos],
904            None => trimmed,
905        };
906        for pattern in RF_ERROR_PATTERNS {
907            if code_part.contains(pattern) && !is_suppressed(lines, line_idx, "MXL300") {
908                let fn_name = &pattern[..pattern.len() - 1];
909                data.rf_error_calls
910                    .push((fn_name.to_string(), line_idx + 1));
911            }
912        }
913    }
914}
915
916// region: MXL302 — `into_sexp()` inside a `vec!`/array literal (use-after-free idiom)
917
918/// `into_sexp` call spellings that build a fresh, unprotected SEXP.
919const INTO_SEXP_CALLS: &[&str] = &["into_sexp(", "into_sexp_unchecked("];
920
921/// Scan raw source text for `into_sexp()` / `into_sexp_unchecked()` calls that appear
922/// *inside* a `vec!` or `&[...]` literal — the use-after-free idiom (#307, #1025).
923///
924/// Each `into_sexp` allocates a new SEXP. When several appear as elements of one literal
925/// (`vec![(k, a.into_sexp()), (k, b.into_sexp())]`), building `b` can trigger a GC that
926/// collects the still-unprotected `a` (and vice versa), because nothing roots the earlier
927/// elements until the whole `Vec` is handed to `List::from_raw_pairs`. The fix is to route
928/// each element through the protected builder path (`__scope.protect_raw(x.into_sexp())`
929/// with a `ProtectScope`), exactly as the `IntoList` / `DataFrameRow` derives now do.
930///
931/// # Heuristic and scope
932///
933/// This is a deliberately narrow raw-text scanner (consistent with MXL300/MXL301):
934/// it tracks bracket depth opened by a `vec![` or `&[` literal and flags an `into_sexp(`
935/// call only while that depth is open. This is what makes it precise:
936///
937/// - **Flags** `vec![ ... into_sexp() ... ]` — the call is an element *inside* the literal.
938/// - **Does NOT flag** `vec![1, 2, 3].into_sexp()` — the `]` closes the literal before the
939///   `.into_sexp()` call, so depth is back to 0 (the whole `Vec` is converted as one SEXP,
940///   which is safe — there are no sibling unprotected SEXPs).
941///
942/// The protected builder path is treated as a true negative: an `into_sexp(` whose element
943/// routes through a `protect_raw(...)` / `protect(...)` / `protect_with_index(...)` call
944/// (`__scope.protect_raw(x.into_sexp())`) is *not* flagged. This is exactly the form the
945/// `IntoList` / `DataFrameRow` derives emit, and the recommended hand-written fix.
946///
947/// Known limits (false negatives, by design — to keep zero false positives):
948/// - Only `vec![` and `&[` literal opens are tracked; bare `[ ... ]` array literals are
949///   not, because a leading `[` is ambiguous with indexing (`arr[i]`). Array-literal sites
950///   are rare in this codebase; promote the scanner if one appears.
951/// - The protect-detection is per-element and line-local in spirit: an element whose
952///   `protect(...)`/`protect_raw(...)` wrapper sits on an earlier line than its `into_sexp(`
953///   is still recognised (the flag persists until the next element-separating `,`), but a
954///   contrived element that opens a protect call yet smuggles an *unprotected* sibling
955///   `into_sexp(` after the same comma-free span would be missed. No such shape exists in
956///   the corpus.
957fn scan_vec_into_sexp_calls(lines: &[&str], data: &mut FileData) {
958    // Bracket depth opened specifically by a `vec![` / `&[` literal. Nested `[` inside the
959    // literal increment it too; `]` decrements. We never let it go negative.
960    let mut literal_depth: i32 = 0;
961    // Whether the *current literal element* already routes its value through a protect call
962    // (`protect_raw(...)` / `protect(...)`). Reset at each element-separating `,` and at
963    // each literal open. This is what makes the protected builder path (the form emitted by
964    // the `IntoList` / `DataFrameRow` derives) a true negative.
965    let mut element_protected = false;
966
967    for (line_idx, line) in lines.iter().enumerate() {
968        let trimmed = line.trim();
969        if trimmed.starts_with("//") {
970            continue;
971        }
972        // Strip inline `//` comments so commented-out idioms / doc prose don't trip us.
973        let code_part = match line.find("//") {
974            Some(pos) => &line[..pos],
975            None => *line,
976        };
977
978        let bytes = code_part.as_bytes();
979        let mut i = 0;
980        while i < bytes.len() {
981            // Detect a `vec![` / `&[` literal open (look-behind).
982            if bytes[i] == b'[' {
983                let opens_literal =
984                    code_part[..i].ends_with("vec!") || (i > 0 && bytes[i - 1] == b'&');
985                if opens_literal || literal_depth > 0 {
986                    // Either a fresh literal open, or a nested `[` while already inside one.
987                    literal_depth += 1;
988                    element_protected = false;
989                }
990                i += 1;
991                continue;
992            }
993            if bytes[i] == b']' {
994                if literal_depth > 0 {
995                    literal_depth -= 1;
996                }
997                i += 1;
998                continue;
999            }
1000            if literal_depth > 0 {
1001                // An element separator resets the per-element protected flag.
1002                if bytes[i] == b',' {
1003                    element_protected = false;
1004                    i += 1;
1005                    continue;
1006                }
1007                // A `protect_raw(` / `protect(` call before `into_sexp(` in this element
1008                // means the value is rooted as it is built — the protected builder path.
1009                if code_part[i..].starts_with("protect_raw(")
1010                    || code_part[i..].starts_with("protect(")
1011                    || code_part[i..].starts_with("protect_with_index(")
1012                {
1013                    element_protected = true;
1014                }
1015                // Flag a raw `into_sexp(` element call (not wrapped in a protect call).
1016                for pattern in INTO_SEXP_CALLS {
1017                    if code_part[i..].starts_with(pattern)
1018                        && !element_protected
1019                        && !is_suppressed(lines, line_idx, "MXL302")
1020                    {
1021                        let call_name = &pattern[..pattern.len() - 1];
1022                        data.vec_into_sexp_calls
1023                            .push((call_name.to_string(), line_idx + 1));
1024                    }
1025                }
1026            }
1027            i += 1;
1028        }
1029    }
1030}
1031// endregion
1032// endregion