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