diff --git a/collectoss/tasks/git/dependency_tasks/dependency_util/dependency_calculator.py b/collectoss/tasks/git/dependency_tasks/dependency_util/dependency_calculator.py index 85aa681ab..9fcc3d90e 100644 --- a/collectoss/tasks/git/dependency_tasks/dependency_util/dependency_calculator.py +++ b/collectoss/tasks/git/dependency_tasks/dependency_util/dependency_calculator.py @@ -10,6 +10,7 @@ from collectoss.tasks.git.dependency_tasks.dependency_util import go_deps from collectoss.tasks.git.dependency_tasks.dependency_util import kotlin_deps from collectoss.tasks.git.dependency_tasks.dependency_util import rust_deps +from collectoss.tasks.git.dependency_tasks.dependency_util import r_deps from collectoss.tasks.git.dependency_tasks.dependency_util import dependency_calculator #Returns generator iterable to tuples of modules and their names @@ -26,6 +27,7 @@ def get_dependency_analysis_module_tuples(): yield go_deps, 'go' yield kotlin_deps, 'kotlin' yield rust_deps, 'rust' + yield r_deps, 'R' class Dep: def __init__(self, name, language, count): diff --git a/collectoss/tasks/git/dependency_tasks/dependency_util/r_deps.py b/collectoss/tasks/git/dependency_tasks/dependency_util/r_deps.py new file mode 100644 index 000000000..b1429c7d2 --- /dev/null +++ b/collectoss/tasks/git/dependency_tasks/dependency_util/r_deps.py @@ -0,0 +1,118 @@ +import re +import json +from pathlib import Path + + +def get_files(path): + """ + Scans the directory for R dependency files. + We look specifically for 'DESCRIPTION' files and 'renv.lock' files. + """ + dir_path = Path(path) + files = [] + + # We look for both standard package metadata and renv lockfiles + files.extend(list(dir_path.glob('**/DESCRIPTION'))) + files.extend(list(dir_path.glob('**/renv.lock'))) + + return files + + +def get_deps_for_file(path): + """ + Routes the file to the appropriate simple parser based on its name. + """ + path_obj = Path(path) + + if path_obj.name == 'DESCRIPTION': + return get_deps_from_description(path) + elif path_obj.name == 'renv.lock': + return get_deps_from_renv_lock(path) + + return [] + + +def get_deps_from_description(path): + """ + Extracts dependencies from a standard R DESCRIPTION file. + + We scan fields like Imports, Depends, Suggests, and others to find + external package requirements. + """ + try: + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + + dependencies = set() + + # These are the standard sections where R packages list their requirements + dependency_fields = ['Imports', 'Depends', 'Suggests', 'Enhances', 'LinkingTo'] + + for field in dependency_fields: + # We use regex to grab the whole section for a field. + # It needs to handle potentially multi-line values. + pattern = rf'^{field}:\s*(.*?)(?=^\S|\Z)' + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + + if match: + field_value = match.group(1) + packages = parse_r_package_list(field_value) + dependencies.update(packages) + + # 'R' itself is often listed as a dependency, but we only care about libraries + dependencies.discard('R') + + return list(dependencies) + + except Exception: + # If something goes wrong parsing, we just return nothing for this file + return [] + + +def parse_r_package_list(field_value): + """ + Cleans up the raw text from the file to get a nice list of package names. + """ + packages = set() + + # Flatten the text to a single line to make splitting easier + field_value = ' '.join(field_value.split()) + + parts = field_value.split(',') + + for part in parts: + part = part.strip() + if not part: + continue + + # We need to grab the package name and ignore version numbers like (>= 1.0.0) + # Note: We are currently stripping version info because the CollectOSS dependency + # model only tracks package names. If that changes, we can capture versions here. + match = re.match(r'^([A-Za-z][A-Za-z0-9.]*)', part) + if match: + package_name = match.group(1).rstrip('.') + if package_name: + packages.add(package_name) + + return packages + + +def get_deps_from_renv_lock(path): + """ + Parses an renv.lock file, which is just a JSON file listing specific package versions. + """ + try: + with open(path, 'r', encoding='utf-8') as f: + lockfile = json.load(f) + + dependencies = set() + + # renv.lock keeps everything under a 'Packages' key + if 'Packages' in lockfile and isinstance(lockfile['Packages'], dict): + for package_name in lockfile['Packages'].keys(): + dependencies.add(package_name) + + return list(dependencies) + + except (json.JSONDecodeError, Exception): + return [] diff --git a/tests/test_tasks/test_dependency_tasks/test_r_deps.py b/tests/test_tasks/test_dependency_tasks/test_r_deps.py new file mode 100644 index 000000000..3c32b5d1c --- /dev/null +++ b/tests/test_tasks/test_dependency_tasks/test_r_deps.py @@ -0,0 +1,90 @@ +import json +import pytest +from augur.tasks.git.dependency_tasks.dependency_util import r_deps + +def test_description_file_parsing(tmp_path): + """ + Test that standard R DESCRIPTION files are parsed correctly, + extracting dependencies from Depends, Imports, Suggests, and LinkingTo. + """ + description_content = """Package: mypackage +Type: Package +Title: My Test Package +Version: 0.1.0 +Author: Test Author +Maintainer: Test Author +Description: A test package. +License: GPL-3 +Encoding: UTF-8 +LazyData: true +Depends: + R (>= 3.5.0), + dplyr (>= 1.0.0) +Imports: + ggplot2, + tidyr (>= 1.1.0), + stringr +Suggests: + testthat (>= 3.0.0), + knitr, + rmarkdown +LinkingTo: + Rcpp (>= 1.0.0) + """ + + d_file = tmp_path / "DESCRIPTION" + d_file.write_text(description_content, encoding="utf-8") + + deps = r_deps.get_deps_for_file(str(d_file)) + + expected_deps = {'dplyr', 'ggplot2', 'tidyr', 'stringr', 'testthat', + 'knitr', 'rmarkdown', 'Rcpp'} + + # Assert we found exactly the expected dependencies + assert set(deps) == expected_deps + +def test_renv_lock_parsing(tmp_path): + """ + Test that renv.lock JSON files are parsed correctly. + """ + renv_lock_content = { + "R": {"Version": "4.3.0", "Repositories": [{"Name": "CRAN", "URL": "https://cran.rstudio.com"}]}, + "Packages": { + "ggplot2": {"Package": "ggplot2", "Version": "3.4.0", "Source": "Repository", "Repository": "CRAN"}, + "dplyr": {"Package": "dplyr", "Version": "1.1.0", "Source": "Repository", "Repository": "CRAN"}, + "tidyr": {"Package": "tidyr", "Version": "1.3.0", "Source": "Repository", "Repository": "CRAN"}, + "rmarkdown": {"Package": "rmarkdown", "Version": "2.20", "Source": "Repository", "Repository": "CRAN"} + } + } + + l_file = tmp_path / "renv.lock" + measure = l_file.write_text(json.dumps(renv_lock_content), encoding="utf-8") + + deps = r_deps.get_deps_for_file(str(l_file)) + + expected_deps = {'ggplot2', 'dplyr', 'tidyr', 'rmarkdown'} + + assert set(deps) == expected_deps + +def test_file_discovery(tmp_path): + """ + Test that the tool finds DESCRIPTION and renv.lock files recursively. + """ + # Create a nested directory structure + (tmp_path / 'DESCRIPTION').touch() + (tmp_path / 'renv.lock').touch() + + subdir = tmp_path / 'subdir' + subdir.mkdir() + (subdir / 'DESCRIPTION').touch() + + nested = tmp_path / 'another' / 'nested' + nested.mkdir(parents=True) + (nested / 'renv.lock').touch() + + # Run the discovery + files = r_deps.get_files(str(tmp_path)) + file_names = [f.name for f in files] + + assert file_names.count('DESCRIPTION') == 2 + assert file_names.count('renv.lock') == 2