1616 */
1717package org .sonar .plugins .python .indexer ;
1818
19+ import com .google .common .annotations .VisibleForTesting ;
1920import java .io .File ;
21+ import java .io .IOException ;
22+ import java .nio .file .Files ;
23+ import java .nio .file .Path ;
2024import java .util .ArrayList ;
2125import java .util .Arrays ;
2226import java .util .List ;
27+ import java .util .Optional ;
28+ import java .util .function .BiFunction ;
29+ import java .util .stream .Stream ;
30+ import org .slf4j .Logger ;
31+ import org .slf4j .LoggerFactory ;
32+ import org .sonar .api .batch .fs .FileSystem ;
2333import org .sonar .api .config .Configuration ;
2434
2535/**
2636 * Resolves package root directories for Python projects.
2737 *
28- * <p>This class validates and resolves package roots extracted from build system configurations
29- * (e.g., pyproject.toml) and provides fallback resolution when no roots are configured.
38+ * <p>This class is the single source of truth for package root resolution. It handles:
39+ * <ul>
40+ * <li>Extraction from pyproject.toml build system configurations</li>
41+ * <li>Extraction from setup.py configurations</li>
42+ * <li>Fallback when build files exist but provide no roots: conventional folders (src/, lib/),
43+ * then sonar.sources, then base directory</li>
44+ * <li>Fallback when no build files exist: sonar.sources, then conventional folders (src/, lib/),
45+ * then base directory</li>
46+ * </ul>
3047 */
3148public class PackageRootResolver {
49+
50+ private static final Logger LOG = LoggerFactory .getLogger (PackageRootResolver .class );
51+
3252 static final String SONAR_SOURCES_KEY = "sonar.sources" ;
3353 static final List <String > CONVENTIONAL_FOLDERS = List .of ("src" , "lib" );
3454
3555 private PackageRootResolver () {
3656 }
3757
3858 /**
39- * Resolves package root directories.
59+ * Resolves package root directories for the project .
4060 *
41- * <p>If extracted roots from build system configuration are provided (already as absolute paths
42- * resolved relative to their config file locations), returns them directly.
43- * Otherwise, applies a fallback chain to determine appropriate roots.
61+ * <p>Attempts to extract source roots from pyproject.toml and setup.py build system configurations.
62+ * Falls back to different priority orders depending on whether build files exist: when build files
63+ * are present but provide no source roots, conventional folders take priority over sonar.sources;
64+ * when no build files exist at all, sonar.sources takes priority over conventional folders.
4465 *
45- * @param extractedRoots roots extracted from build system config, already as absolute paths
46- * @param config the Sonar configuration to read sonar.sources property
47- * @param baseDir the project base directory (used only for fallback resolution)
48- * @return list of resolved package root absolute paths
66+ * @param fileSystem the Sonar file system providing the base directory
67+ * @param config the Sonar configuration
68+ * @return resolution result including resolved root absolute paths and method information
4969 */
50- public static List <String > resolve (List <String > extractedRoots , Configuration config , File baseDir ) {
51- if (!extractedRoots .isEmpty ()) {
52- // Extracted roots are already absolute paths (resolved relative to config file location)
53- return extractedRoots ;
70+ public static PackageResolutionResult resolve (FileSystem fileSystem , Configuration config ) {
71+ File baseDir = fileSystem .baseDir ();
72+
73+ // Discover build config files
74+ List <File > pyprojectFiles = findFilesRecursively (fileSystem , "pyproject.toml" );
75+ List <File > setupPyFiles = findFilesRecursively (fileSystem , "setup.py" );
76+ boolean hasBuildConfigFiles = !pyprojectFiles .isEmpty () || !setupPyFiles .isEmpty ();
77+
78+ // Extract source roots from discovered files
79+ List <PyProjectExtractionResult > pyprojectResults = pyprojectFiles .stream ()
80+ .map (PyProjectTomlSourceRoots ::extractWithBuildSystem )
81+ .filter (PyProjectExtractionResult ::hasRoots )
82+ .toList ();
83+ List <ConfigSourceRoots > setupPyRoots = setupPyFiles .stream ()
84+ .map (SetupPySourceRoots ::extractWithLocation )
85+ .filter (csr -> !csr .relativeRoots ().isEmpty ())
86+ .toList ();
87+
88+ boolean hasPyproject = pyprojectResults .stream ().anyMatch (PyProjectExtractionResult ::hasRoots );
89+ boolean hasSetupPy = !setupPyRoots .isEmpty ();
90+
91+ List <String > combinedRoots = Stream .concat (
92+ pyprojectResults .stream ().map (PyProjectExtractionResult ::configRoots ).flatMap (crs -> crs .toAbsolutePaths ().stream ()),
93+ setupPyRoots .stream ().flatMap (csr -> csr .toAbsolutePaths ().stream ()))
94+ .distinct ()
95+ .toList ();
96+
97+ List <String > adjustedRoots = adjustRoots (combinedRoots , baseDir );
98+ LOG .debug ("Resolved package roots from build configuration: {}" , adjustedRoots );
99+
100+ if (hasPyproject && hasSetupPy ) {
101+ return PackageResolutionResult .fromBothPyProjectAndSetupPy (adjustedRoots , getCombinedBuildSystem (pyprojectResults ));
102+ }
103+
104+ if (hasPyproject ) {
105+ return PackageResolutionResult .fromPyProjectToml (adjustedRoots , getCombinedBuildSystem (pyprojectResults ));
54106 }
55- return resolveFallback (config , baseDir );
107+
108+ if (hasSetupPy ) {
109+ return PackageResolutionResult .fromSetupPy (adjustedRoots );
110+ }
111+
112+ return resolveFallback (config , baseDir , hasBuildConfigFiles );
56113 }
57114
58115 /**
59- * Resolves fallback package roots when no build system configuration is available .
116+ * Resolves fallback package roots when no build system configuration provides source roots .
60117 *
61- * <p>Fallback priority:
62- * <ol>
63- * <li>sonar.sources property if set</li>
64- * <li>"src" and/or "lib" folders if they exist</li>
65- * <li>Project base directory absolute path as last resort</li>
66- * </ol>
118+ * <p>When build config files (pyproject.toml / setup.py) exist but provide no source roots,
119+ * the priority order is: conventional folders (src/, lib/), then sonar.sources, then base directory.
67120 *
68- * @param config the Sonar configuration
69- * @param baseDir the project base directory
70- * @return list of fallback package root absolute paths
121+ * <p>When NO build config files exist at all, the priority order is: sonar.sources, then
122+ * conventional folders (src/, lib/), then base directory.
71123 */
72- static List <String > resolveFallback (Configuration config , File baseDir ) {
124+ private static PackageResolutionResult resolveFallback (Configuration config , File baseDir , boolean hasBuildConfigFiles ) {
125+ List <BiFunction <Configuration , File , Optional <PackageResolutionResult >>> candidates = hasBuildConfigFiles
126+ ? List .of (PackageRootResolver ::tryConventionalFolders , PackageRootResolver ::trySonarSources )
127+ : List .of (PackageRootResolver ::trySonarSources , PackageRootResolver ::tryConventionalFolders );
128+
129+ for (BiFunction <Configuration , File , Optional <PackageResolutionResult >> candidate : candidates ) {
130+ Optional <PackageResolutionResult > result = candidate .apply (config , baseDir );
131+ if (result .isPresent ()) {
132+ return result .get ();
133+ }
134+ }
135+
136+ LOG .debug ("Using project base directory as package root (fallback)" );
137+ return PackageResolutionResult .fromBaseDir (List .of (baseDir .getAbsolutePath ()));
138+ }
139+
140+ private static Optional <PackageResolutionResult > tryConventionalFolders (Configuration config , File baseDir ) {
73141 List <String > conventionalFolders = findConventionalFolders (baseDir );
74- if (! conventionalFolders .isEmpty ()) {
75- return toAbsolutePaths ( conventionalFolders , baseDir );
142+ if (conventionalFolders .isEmpty ()) {
143+ return Optional . empty ( );
76144 }
145+ List <String > adjustedRoots = adjustRoots (toAbsolutePaths (conventionalFolders , baseDir ), baseDir );
146+ LOG .debug ("Resolved package roots from fallback (conventional folders): {}" , adjustedRoots );
147+ return Optional .of (PackageResolutionResult .fromConventionalFolders (adjustedRoots ));
148+ }
77149
150+ private static Optional <PackageResolutionResult > trySonarSources (Configuration config , File baseDir ) {
78151 String [] sonarSources = config .getStringArray (SONAR_SOURCES_KEY );
79- if (sonarSources .length > 0 ) {
80- return toAbsolutePaths ( Arrays . asList ( sonarSources ), baseDir );
152+ if (sonarSources .length == 0 ) {
153+ return Optional . empty ( );
81154 }
82-
83- return List .of (baseDir .getAbsolutePath ());
155+ List <String > adjustedRoots = adjustRoots (toAbsolutePaths (Arrays .asList (sonarSources ), baseDir ), baseDir );
156+ LOG .debug ("Resolved package roots from fallback (sonar.sources): {}" , adjustedRoots );
157+ return Optional .of (PackageResolutionResult .fromSonarSources (adjustedRoots ));
84158 }
85159
86- private static List <String > toAbsolutePaths (List <String > paths , File baseDir ) {
160+ /**
161+ * Converts relative path strings to normalized absolute paths under the given base directory.
162+ *
163+ * <p>Uses {@link Path#normalize()} to resolve {@code .} and {@code ..} components without
164+ * performing any I/O, so that {@code sonar.sources=.} correctly resolves to the base directory
165+ * rather than producing an un-normalized path like {@code /project/.}.
166+ */
167+ static List <String > toAbsolutePaths (List <String > paths , File baseDir ) {
87168 return paths .stream ()
88- .map (path -> new File (baseDir , path ).getAbsolutePath ())
169+ .map (path -> new File (baseDir , path ).toPath (). normalize (). toString ())
89170 .toList ();
90171 }
91172
@@ -99,5 +180,69 @@ private static List<String> findConventionalFolders(File baseDir) {
99180 }
100181 return folders ;
101182 }
102- }
103183
184+ private static List <String > adjustRoots (List <String > roots , File baseDir ) {
185+ return roots .stream ()
186+ .map (root -> {
187+ File rootFile = new File (root ).isAbsolute () ? new File (root ) : new File (baseDir , root );
188+ return adjustPackageRoot (rootFile , baseDir );
189+ })
190+ .distinct ()
191+ .toList ();
192+ }
193+
194+ /**
195+ * Adjusts a package root by walking up the directory tree if it contains __init__.py.
196+ *
197+ * <p>If the root directory contains __init__.py, it's part of a package, not the package root.
198+ * We walk up to find the first parent directory without __init__.py.
199+ *
200+ * @param root the potential package root directory
201+ * @param baseDir the project base directory (we don't walk above this)
202+ * @return the adjusted package root absolute path
203+ */
204+ @ VisibleForTesting
205+ static String adjustPackageRoot (File root , File baseDir ) {
206+ File current = root ;
207+ String baseDirPath = baseDir .getAbsolutePath ();
208+ while (current != null && !current .getAbsolutePath ().equals (baseDirPath )) {
209+ File initFile = new File (current , "__init__.py" );
210+ if (!initFile .exists ()) {
211+ break ;
212+ }
213+ current = current .getParentFile ();
214+ }
215+ if (current == null ) {
216+ return baseDirPath ;
217+ }
218+ return current .getAbsolutePath ();
219+ }
220+
221+ /**
222+ * Recursively finds files with the given filename under the project base directory.
223+ */
224+ private static List <File > findFilesRecursively (FileSystem fileSystem , String filename ) {
225+ try (Stream <Path > stream = Files .walk (fileSystem .baseDir ().toPath ())) {
226+ return stream
227+ .filter (Files ::isRegularFile )
228+ .filter (path -> filename .equals (path .getFileName ().toString ()))
229+ .map (Path ::toFile )
230+ .toList ();
231+ } catch (IOException e ) {
232+ return List .of ();
233+ }
234+ }
235+
236+ /**
237+ * Determines the combined build system across multiple pyproject.toml results.
238+ * If multiple files report different build systems, returns MULTIPLE.
239+ */
240+ private static PackageResolutionResult .BuildSystem getCombinedBuildSystem (List <PyProjectExtractionResult > pyprojectResults ) {
241+ return pyprojectResults .stream ()
242+ .map (PyProjectExtractionResult ::buildSystem )
243+ .filter (bs -> bs != PackageResolutionResult .BuildSystem .NONE )
244+ .distinct ()
245+ .reduce ((a , b ) -> PackageResolutionResult .BuildSystem .MULTIPLE )
246+ .orElse (PackageResolutionResult .BuildSystem .NONE );
247+ }
248+ }
0 commit comments