|
| 1 | +# Copyright (c) 2026 - 2026, Oracle and/or its affiliates. All rights reserved. |
| 2 | +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. |
| 3 | + |
| 4 | +"""This module contains helpers for parsing Gradle build configuration files.""" |
| 5 | + |
| 6 | +import logging |
| 7 | +import re |
| 8 | +from pathlib import Path |
| 9 | + |
| 10 | +logger: logging.Logger = logging.getLogger(__name__) |
| 11 | + |
| 12 | + |
| 13 | +def _extract_assignment_value(file_path: Path, keys: set[str]) -> str | None: |
| 14 | + """Extract an assignment value for a supported key from a Gradle-like file. |
| 15 | +
|
| 16 | + Parameters |
| 17 | + ---------- |
| 18 | + file_path : Path |
| 19 | + The file to inspect. |
| 20 | + keys : set[str] |
| 21 | + Accepted key names (for example ``{"group"}`` or ``{"rootProject.name"}``). |
| 22 | +
|
| 23 | + Returns |
| 24 | + ------- |
| 25 | + str | None |
| 26 | + The extracted value if a matching ``key = value`` assignment is found; |
| 27 | + otherwise ``None``. |
| 28 | + """ |
| 29 | + if not file_path.is_file(): |
| 30 | + return None |
| 31 | + |
| 32 | + try: |
| 33 | + lines = file_path.read_text(encoding="utf-8", errors="ignore").splitlines() |
| 34 | + except OSError as error: |
| 35 | + logger.debug("Failed to read Gradle file %s: %s", str(file_path), error) |
| 36 | + return None |
| 37 | + |
| 38 | + assignment_re = re.compile(r"^\s*([A-Za-z0-9_.]+)\s*=\s*(.+?)\s*$") |
| 39 | + for line in lines: |
| 40 | + try: |
| 41 | + match = assignment_re.match(line) |
| 42 | + except re.error as error: |
| 43 | + logger.debug("Failed to apply assignment regex on %s: %s", str(file_path), error) |
| 44 | + continue |
| 45 | + if not match: |
| 46 | + continue |
| 47 | + |
| 48 | + key = match.group(1).strip() |
| 49 | + if key not in keys: |
| 50 | + continue |
| 51 | + |
| 52 | + raw_value = match.group(2).strip() |
| 53 | + if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in {"'", '"'}: |
| 54 | + raw_value = raw_value[1:-1] |
| 55 | + return raw_value |
| 56 | + |
| 57 | + return None |
| 58 | + |
| 59 | + |
| 60 | +def extract_gav_from_gradle_project(project_path: Path) -> tuple[str | None, str | None, str | None]: |
| 61 | + """Extract Gradle coordinates (group, artifact, version) from project files. |
| 62 | +
|
| 63 | + Parameters |
| 64 | + ---------- |
| 65 | + project_path : Path |
| 66 | + Path to the root directory of a Gradle project. |
| 67 | +
|
| 68 | + Returns |
| 69 | + ------- |
| 70 | + tuple[str | None, str | None, str | None] |
| 71 | + A tuple of ``(group_id, artifact_id, version)`` extracted from common |
| 72 | + Gradle configuration files. Any missing value is returned as ``None``. |
| 73 | +
|
| 74 | + Notes |
| 75 | + ----- |
| 76 | + This parser is intentionally lightweight and matches direct ``key = value`` |
| 77 | + assignments only. It does not evaluate expressions or variable references. |
| 78 | + """ |
| 79 | + group_id = ( |
| 80 | + _extract_assignment_value( |
| 81 | + project_path.joinpath("gradle.properties"), {"group", "projectGroup", "projectGroupId"} |
| 82 | + ) |
| 83 | + or _extract_assignment_value(project_path.joinpath("build.gradle"), {"group"}) |
| 84 | + or _extract_assignment_value(project_path.joinpath("build.gradle.kts"), {"group"}) |
| 85 | + ) |
| 86 | + artifact_id = ( |
| 87 | + _extract_assignment_value(project_path.joinpath("settings.gradle"), {"rootProject.name"}) |
| 88 | + or _extract_assignment_value(project_path.joinpath("settings.gradle.kts"), {"rootProject.name"}) |
| 89 | + or _extract_assignment_value(project_path.joinpath("gradle.properties"), {"name"}) |
| 90 | + ) |
| 91 | + version = ( |
| 92 | + _extract_assignment_value(project_path.joinpath("gradle.properties"), {"version", "projectVersion"}) |
| 93 | + or _extract_assignment_value(project_path.joinpath("build.gradle"), {"version"}) |
| 94 | + or _extract_assignment_value(project_path.joinpath("build.gradle.kts"), {"version"}) |
| 95 | + ) |
| 96 | + |
| 97 | + if group_id is None: |
| 98 | + logger.debug("Could not find group id in Gradle project: %s", str(project_path)) |
| 99 | + if artifact_id is None: |
| 100 | + logger.debug("Could not find artifact id in Gradle project: %s", str(project_path)) |
| 101 | + if version is None: |
| 102 | + logger.debug("Could not find version in Gradle project: %s", str(project_path)) |
| 103 | + |
| 104 | + return group_id, artifact_id, version |
| 105 | + |
| 106 | + |
| 107 | +def gradle_settings_has_modules(settings_path: Path) -> bool: |
| 108 | + """Check whether a Gradle settings file declares one or more modules. |
| 109 | +
|
| 110 | + Parameters |
| 111 | + ---------- |
| 112 | + settings_path : Path |
| 113 | + Path to a ``settings.gradle`` or ``settings.gradle.kts`` file. |
| 114 | +
|
| 115 | + Returns |
| 116 | + ------- |
| 117 | + bool |
| 118 | + ``True`` when the file contains an ``include`` declaration; otherwise |
| 119 | + ``False``. |
| 120 | + """ |
| 121 | + if not settings_path.is_file(): |
| 122 | + return False |
| 123 | + |
| 124 | + try: |
| 125 | + lines = settings_path.read_text(encoding="utf-8", errors="ignore").splitlines() |
| 126 | + except OSError as error: |
| 127 | + logger.debug("Failed to read Gradle settings file %s: %s", str(settings_path), error) |
| 128 | + return False |
| 129 | + |
| 130 | + for line in lines: |
| 131 | + stripped = line.strip() |
| 132 | + if re.match(r"^include\s+.+", stripped) or re.match(r"^include\s*\(.+\)", stripped): |
| 133 | + return True |
| 134 | + |
| 135 | + return False |
| 136 | + |
| 137 | + |
| 138 | +def extract_included_gradle_modules(settings_path: Path) -> list[str]: |
| 139 | + """Extract module include entries from a Gradle settings file. |
| 140 | +
|
| 141 | + Parameters |
| 142 | + ---------- |
| 143 | + settings_path : Path |
| 144 | + Path to a ``settings.gradle`` or ``settings.gradle.kts`` file. |
| 145 | +
|
| 146 | + Returns |
| 147 | + ------- |
| 148 | + list[str] |
| 149 | + Ordered list of module paths declared by ``include`` statements. |
| 150 | + """ |
| 151 | + if not settings_path.is_file(): |
| 152 | + return [] |
| 153 | + |
| 154 | + try: |
| 155 | + lines = settings_path.read_text(encoding="utf-8", errors="ignore").splitlines() |
| 156 | + except OSError as error: |
| 157 | + logger.debug("Failed to read Gradle settings file %s: %s", str(settings_path), error) |
| 158 | + return [] |
| 159 | + |
| 160 | + modules: list[str] = [] |
| 161 | + quoted_value_re = re.compile(r"""['"]([^'"]+)['"]""") |
| 162 | + for line in lines: |
| 163 | + stripped = line.strip() |
| 164 | + if not stripped.startswith("include"): |
| 165 | + continue |
| 166 | + modules.extend(match.group(1).strip() for match in quoted_value_re.finditer(stripped) if match.group(1).strip()) |
| 167 | + return modules |
| 168 | + |
| 169 | + |
| 170 | +def find_matching_gradle_module_build_configs(repo_root: Path, artifact_id: str) -> list[Path]: |
| 171 | + """Find module build config files likely associated with the given artifact id. |
| 172 | +
|
| 173 | + Parameters |
| 174 | + ---------- |
| 175 | + repo_root : Path |
| 176 | + Root directory of the Gradle repository. |
| 177 | + artifact_id : str |
| 178 | + Expected artifact id. |
| 179 | +
|
| 180 | + Returns |
| 181 | + ------- |
| 182 | + list[Path] |
| 183 | + Candidate module build files (for example ``module/build.gradle``) |
| 184 | + associated with the artifact id. |
| 185 | + """ |
| 186 | + candidates: list[Path] = [] |
| 187 | + seen: set[Path] = set() |
| 188 | + for settings_name in ("settings.gradle", "settings.gradle.kts"): |
| 189 | + settings_path = repo_root.joinpath(settings_name) |
| 190 | + for module in extract_included_gradle_modules(settings_path): |
| 191 | + module_path = module.strip().strip(":") |
| 192 | + if not module_path: |
| 193 | + continue |
| 194 | + module_name = module_path.split(":")[-1] |
| 195 | + if artifact_id != module_name and not artifact_id.endswith(f"-{module_name}"): |
| 196 | + continue |
| 197 | + module_dir = repo_root.joinpath(*module_path.split(":")) |
| 198 | + for build_name in ("build.gradle", "build.gradle.kts"): |
| 199 | + config_path = module_dir.joinpath(build_name) |
| 200 | + if config_path.is_file() and config_path not in seen: |
| 201 | + seen.add(config_path) |
| 202 | + candidates.append(config_path) |
| 203 | + |
| 204 | + return candidates |
| 205 | + |
| 206 | + |
| 207 | +def find_nearest_modules_gradle_config( |
| 208 | + config_path: Path, |
| 209 | + repo_root: str | Path, |
| 210 | + *, |
| 211 | + max_depth: int = 50, |
| 212 | +) -> str | None: |
| 213 | + """Find the nearest ancestor Gradle settings file that defines modules. |
| 214 | +
|
| 215 | + Parameters |
| 216 | + ---------- |
| 217 | + config_path : Path |
| 218 | + Path to the starting Gradle configuration file. |
| 219 | + repo_root : str | Path |
| 220 | + Repository root used to bound parent traversal and return a relative path. |
| 221 | + max_depth : int, optional |
| 222 | + Maximum number of parent-directory hops. Defaults to ``50``. |
| 223 | +
|
| 224 | + Returns |
| 225 | + ------- |
| 226 | + str | None |
| 227 | + Path to the nearest settings file relative to ``repo_root`` if it |
| 228 | + contains ``include`` declarations. Returns ``None`` otherwise. |
| 229 | + """ |
| 230 | + repo_root = Path(repo_root).resolve() |
| 231 | + current_dir = config_path.parent.resolve() |
| 232 | + depth = 0 |
| 233 | + |
| 234 | + while True: |
| 235 | + for settings_name in ("settings.gradle", "settings.gradle.kts"): |
| 236 | + settings_path = current_dir.joinpath(settings_name) |
| 237 | + if gradle_settings_has_modules(settings_path): |
| 238 | + try: |
| 239 | + return str(settings_path.relative_to(repo_root)) |
| 240 | + except ValueError: |
| 241 | + return None |
| 242 | + |
| 243 | + if current_dir == repo_root: |
| 244 | + return None |
| 245 | + |
| 246 | + depth += 1 |
| 247 | + if depth > max_depth: |
| 248 | + return None |
| 249 | + |
| 250 | + parent_dir = current_dir.parent |
| 251 | + if parent_dir == current_dir: |
| 252 | + return None |
| 253 | + |
| 254 | + current_dir = parent_dir |
0 commit comments