Skip to content

Commit 614a1a9

Browse files
feat: Extend unused_function to other R package folders (#374)
1 parent 23f894a commit 614a1a9

File tree

5 files changed

+353
-44
lines changed

5 files changed

+353
-44
lines changed

crates/jarl-core/src/lints/base/duplicated_function_definition/duplicated_function_definition.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use biome_rowan::{TextRange, TextSize};
22
use std::collections::HashMap;
33
use std::path::{Path, PathBuf};
44

5-
use crate::package::SharedFileData;
5+
use crate::package::{FileScope, SharedFileData};
66

77
/// ## What it does
88
///
@@ -126,9 +126,9 @@ pub fn scan_top_level_assignments(content: &str) -> Vec<(String, TextRange, u32,
126126
pub(crate) fn compute_duplicates_from_shared(
127127
shared_data: &[SharedFileData],
128128
) -> HashMap<PathBuf, Vec<(String, TextRange, String)>> {
129-
// Group by package root
129+
// Group by package root (only R/ files contribute to duplicate checking)
130130
let mut packages: HashMap<&str, Vec<&SharedFileData>> = HashMap::new();
131-
for fd in shared_data {
131+
for fd in shared_data.iter().filter(|fd| fd.scope == FileScope::R) {
132132
packages.entry(&fd.root_key).or_default().push(fd);
133133
}
134134

crates/jarl-core/src/lints/base/unused_function/mod.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,171 @@ mod tests {
661661
);
662662
}
663663

664+
// ── unused functions in tests/ and inst/tinytest/ ───────────────
665+
666+
#[test]
667+
fn test_unused_function_in_tests_flagged() {
668+
let dir = TempDir::new().unwrap();
669+
let r_dir = dir.path().join("R");
670+
fs::create_dir(&r_dir).unwrap();
671+
let tests_dir = dir.path().join("tests").join("testthat");
672+
fs::create_dir_all(&tests_dir).unwrap();
673+
fs::write(dir.path().join("DESCRIPTION"), "Package: test").unwrap();
674+
fs::write(dir.path().join("NAMESPACE"), "export(public_fn)\n").unwrap();
675+
676+
let file_a = r_dir.join("public.R");
677+
fs::write(&file_a, "public_fn <- function() 1\n").unwrap();
678+
679+
// Helper defined in tests/ but never used
680+
let test_helper = tests_dir.join("helper.R");
681+
fs::write(&test_helper, "unused_test_helper <- function() 42\n").unwrap();
682+
683+
let mut shared = scan_r_package_paths(std::slice::from_ref(&file_a), true);
684+
shared.extend(scan_extra_package_paths(&[test_helper], dir.path()));
685+
let result =
686+
compute_unused_from_shared(&shared, &default_options(), &read_namespace(dir.path()));
687+
688+
let has_unused = result
689+
.values()
690+
.any(|v| v.iter().any(|(n, _, _)| n == "unused_test_helper"));
691+
assert!(
692+
has_unused,
693+
"unused_test_helper is defined in tests/ but never called, should be flagged"
694+
);
695+
}
696+
697+
#[test]
698+
fn test_used_function_in_tests_not_flagged() {
699+
let dir = TempDir::new().unwrap();
700+
let r_dir = dir.path().join("R");
701+
fs::create_dir(&r_dir).unwrap();
702+
let tests_dir = dir.path().join("tests").join("testthat");
703+
fs::create_dir_all(&tests_dir).unwrap();
704+
fs::write(dir.path().join("DESCRIPTION"), "Package: test").unwrap();
705+
fs::write(dir.path().join("NAMESPACE"), "export(public_fn)\n").unwrap();
706+
707+
let file_a = r_dir.join("public.R");
708+
fs::write(&file_a, "public_fn <- function() 1\n").unwrap();
709+
710+
// Helper defined in tests/ and used in another test file
711+
let test_helper = tests_dir.join("helper.R");
712+
fs::write(&test_helper, "test_helper <- function() 42\n").unwrap();
713+
714+
let test_file = tests_dir.join("test-foo.R");
715+
fs::write(&test_file, "test_that('works', { test_helper() })\n").unwrap();
716+
717+
let mut shared = scan_r_package_paths(std::slice::from_ref(&file_a), true);
718+
shared.extend(scan_extra_package_paths(
719+
&[test_helper, test_file],
720+
dir.path(),
721+
));
722+
let result =
723+
compute_unused_from_shared(&shared, &default_options(), &read_namespace(dir.path()));
724+
725+
let has_helper = result
726+
.values()
727+
.any(|v| v.iter().any(|(n, _, _)| n == "test_helper"));
728+
assert!(
729+
!has_helper,
730+
"test_helper is used in another test file, should not be flagged"
731+
);
732+
}
733+
734+
#[test]
735+
fn test_unused_function_in_inst_tinytest_flagged() {
736+
let dir = TempDir::new().unwrap();
737+
let r_dir = dir.path().join("R");
738+
fs::create_dir(&r_dir).unwrap();
739+
let inst_dir = dir.path().join("inst").join("tinytest");
740+
fs::create_dir_all(&inst_dir).unwrap();
741+
fs::write(dir.path().join("DESCRIPTION"), "Package: test").unwrap();
742+
fs::write(dir.path().join("NAMESPACE"), "export(public_fn)\n").unwrap();
743+
744+
let file_a = r_dir.join("public.R");
745+
fs::write(&file_a, "public_fn <- function() 1\n").unwrap();
746+
747+
// Helper defined in inst/tinytest/ but never used
748+
let inst_helper = inst_dir.join("helper.R");
749+
fs::write(&inst_helper, "unused_inst_helper <- function() 42\n").unwrap();
750+
751+
let mut shared = scan_r_package_paths(std::slice::from_ref(&file_a), true);
752+
shared.extend(scan_extra_package_paths(&[inst_helper], dir.path()));
753+
let result =
754+
compute_unused_from_shared(&shared, &default_options(), &read_namespace(dir.path()));
755+
756+
let has_unused = result
757+
.values()
758+
.any(|v| v.iter().any(|(n, _, _)| n == "unused_inst_helper"));
759+
assert!(
760+
has_unused,
761+
"unused_inst_helper is defined in inst/tinytest/ but never called, should be flagged"
762+
);
763+
}
764+
765+
#[test]
766+
fn test_unused_function_in_inst_tests_flagged() {
767+
let dir = TempDir::new().unwrap();
768+
let r_dir = dir.path().join("R");
769+
fs::create_dir(&r_dir).unwrap();
770+
let inst_dir = dir.path().join("inst").join("tests");
771+
fs::create_dir_all(&inst_dir).unwrap();
772+
fs::write(dir.path().join("DESCRIPTION"), "Package: test").unwrap();
773+
fs::write(dir.path().join("NAMESPACE"), "export(public_fn)\n").unwrap();
774+
775+
let file_a = r_dir.join("public.R");
776+
fs::write(&file_a, "public_fn <- function() 1\n").unwrap();
777+
778+
// Helper defined in inst/tests/ but never used
779+
let inst_helper = inst_dir.join("helper.R");
780+
fs::write(&inst_helper, "unused_inst_helper <- function() 42\n").unwrap();
781+
782+
let mut shared = scan_r_package_paths(std::slice::from_ref(&file_a), true);
783+
shared.extend(scan_extra_package_paths(&[inst_helper], dir.path()));
784+
let result =
785+
compute_unused_from_shared(&shared, &default_options(), &read_namespace(dir.path()));
786+
787+
let has_unused = result
788+
.values()
789+
.any(|v| v.iter().any(|(n, _, _)| n == "unused_inst_helper"));
790+
assert!(
791+
has_unused,
792+
"unused_inst_helper is defined in inst/tests/ but never called, should be flagged"
793+
);
794+
}
795+
796+
#[test]
797+
fn test_cross_scope_independence() {
798+
let dir = TempDir::new().unwrap();
799+
let r_dir = dir.path().join("R");
800+
fs::create_dir(&r_dir).unwrap();
801+
let tests_dir = dir.path().join("tests").join("testthat");
802+
fs::create_dir_all(&tests_dir).unwrap();
803+
fs::write(dir.path().join("DESCRIPTION"), "Package: test").unwrap();
804+
fs::write(dir.path().join("NAMESPACE"), "export(public_fn)\n").unwrap();
805+
806+
// R/ file references `test_only_helper` but it shouldn't save the
807+
// test-scoped definition from being flagged unused within tests/.
808+
let file_a = r_dir.join("public.R");
809+
fs::write(&file_a, "public_fn <- function() test_only_helper()\n").unwrap();
810+
811+
let test_helper = tests_dir.join("helper.R");
812+
fs::write(&test_helper, "test_only_helper <- function() 42\n").unwrap();
813+
814+
let mut shared = scan_r_package_paths(std::slice::from_ref(&file_a), true);
815+
shared.extend(scan_extra_package_paths(&[test_helper], dir.path()));
816+
let result =
817+
compute_unused_from_shared(&shared, &default_options(), &read_namespace(dir.path()));
818+
819+
let has_test_helper = result
820+
.values()
821+
.any(|v| v.iter().any(|(n, _, _)| n == "test_only_helper"));
822+
assert!(
823+
has_test_helper,
824+
"test_only_helper is only referenced in R/ but defined in tests/, \
825+
should be flagged as unused within tests/ scope"
826+
);
827+
}
828+
664829
#[test]
665830
fn test_threshold_not_exceeded_shows_diagnostics() {
666831
let dir = TempDir::new().unwrap();

crates/jarl-core/src/lints/base/unused_function/unused_function.rs

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ use regex::Regex;
33
use std::collections::{HashMap, HashSet};
44
use std::path::{Path, PathBuf};
55

6-
use crate::package::SharedFileData;
6+
use crate::package::{FileScope, SharedFileData};
77

88
/// ## What it does
99
///
10-
/// Checks for unused functions, currently limited to R packages. It looks for
11-
/// functions defined in the `R` folder that are not exported and not used
12-
/// anywhere in the package (including the `R`, `inst/tinytest`, `inst/tests`,
13-
/// `src`, and `tests` folders).
10+
/// Checks for unused functions in R packages. It looks for:
11+
///
12+
/// - Functions defined in `R/` that are not exported and not used anywhere in
13+
/// the package (including `R/`, `inst/tinytest/`, `inst/tests/`, `src/`, and
14+
/// `tests/`).
15+
/// - Functions defined in `tests/` that are not used anywhere in `tests/`.
16+
/// - Functions defined in `inst/tinytest/` or `inst/tests/` that are not used
17+
/// anywhere within that directory.
1418
///
1519
/// ## Why is this bad?
1620
///
@@ -245,6 +249,27 @@ pub(crate) fn has_cpp_extension(path: &Path) -> bool {
245249
)
246250
}
247251

252+
/// Extract a human-readable scope directory from a file path, e.g.
253+
/// `"tests/"` or `"inst/tests/"`. Used in help messages.
254+
fn scope_dir_from_path(rel_path: &Path) -> String {
255+
let components: Vec<_> = rel_path
256+
.components()
257+
.map(|c| c.as_os_str().to_string_lossy().to_string())
258+
.collect();
259+
for (i, comp) in components.iter().enumerate() {
260+
if comp == "tests" {
261+
return "tests/".to_string();
262+
}
263+
if comp == "inst"
264+
&& let Some(next) = components.get(i + 1)
265+
&& (next == "tinytest" || next == "tests")
266+
{
267+
return format!("inst/{next}/");
268+
}
269+
}
270+
"tests/".to_string()
271+
}
272+
248273
/// Compute unused functions from pre-scanned shared file data.
249274
///
250275
/// This is the inner logic extracted from `compute_package_unused_functions`,
@@ -267,12 +292,27 @@ pub(crate) fn compute_unused_from_shared(
267292
let mut result: HashMap<PathBuf, Vec<(String, TextRange, String)>> = HashMap::new();
268293

269294
for (_root_key, file_data) in packages {
270-
// Separate R/ files from extra (test/src/tinytest) files.
271-
let r_files: Vec<&&SharedFileData> = file_data.iter().filter(|f| f.is_r_dir_file).collect();
272-
let extra_files: Vec<&&SharedFileData> =
273-
file_data.iter().filter(|f| !f.is_r_dir_file).collect();
295+
// Separate files by scope.
296+
let r_files: Vec<&&SharedFileData> = file_data
297+
.iter()
298+
.filter(|f| f.scope == FileScope::R)
299+
.collect();
300+
let extra_files: Vec<&&SharedFileData> = file_data
301+
.iter()
302+
.filter(|f| f.scope != FileScope::R)
303+
.collect();
304+
let tests_files: Vec<&&SharedFileData> = file_data
305+
.iter()
306+
.filter(|f| f.scope == FileScope::Tests)
307+
.collect();
308+
let inst_files: Vec<&&SharedFileData> = file_data
309+
.iter()
310+
.filter(|f| f.scope == FileScope::Inst)
311+
.collect();
274312

275-
// Collect ALL defined function names across the package (for exportPattern matching)
313+
// ── R/ scope (existing behavior) ────────────────────────────────
314+
315+
// Collect ALL defined function names across R/ (for exportPattern matching)
276316
let all_defined_names: Vec<String> = r_files
277317
.iter()
278318
.flat_map(|f| f.assignments.iter().map(|(name, _, _, _)| name.clone()))
@@ -381,6 +421,63 @@ pub(crate) fn compute_unused_from_shared(
381421
result.insert(file.rel_path.clone(), unused);
382422
}
383423
}
424+
425+
// ── Tests and Inst scopes ───────────────────────────────
426+
// A function defined in one of these directories is unused if it
427+
// doesn't appear in any other file within that same scope. No
428+
// NAMESPACE export check is needed.
429+
430+
for scope_files in [&tests_files, &inst_files] {
431+
if scope_files.is_empty() {
432+
continue;
433+
}
434+
435+
// Total symbol occurrences within this scope.
436+
let mut scope_occurrences: HashMap<&str, usize> = HashMap::new();
437+
for file in scope_files {
438+
for (name, count) in &file.symbol_counts {
439+
*scope_occurrences.entry(name.as_str()).or_insert(0) += count;
440+
}
441+
}
442+
443+
// Total definitions within this scope.
444+
let mut scope_definitions: HashMap<&str, usize> = HashMap::new();
445+
for file in scope_files {
446+
for (name, _, _, _) in &file.assignments {
447+
*scope_definitions.entry(name.as_str()).or_insert(0) += 1;
448+
}
449+
}
450+
451+
for file in scope_files {
452+
let mut unused: Vec<(String, TextRange, String)> = Vec::new();
453+
454+
for (name, range, line, col) in &file.assignments {
455+
// Skip functions matching user-configured patterns
456+
if options.is_skipped(name) {
457+
continue;
458+
}
459+
460+
let occurrences = scope_occurrences.get(name.as_str()).copied().unwrap_or(0);
461+
let definitions = scope_definitions.get(name.as_str()).copied().unwrap_or(0);
462+
463+
if occurrences <= definitions {
464+
let scope_dir = scope_dir_from_path(&file.rel_path);
465+
let help = format!(
466+
"Defined at {path}:{line}:{col} but never called in {scope_dir}",
467+
path = file.rel_path.display()
468+
);
469+
unused.push((name.clone(), *range, help));
470+
}
471+
}
472+
473+
if !unused.is_empty() {
474+
result
475+
.entry(file.rel_path.clone())
476+
.or_default()
477+
.extend(unused);
478+
}
479+
}
480+
}
384481
}
385482

386483
result

0 commit comments

Comments
 (0)