Skip to content

Commit 557ce0c

Browse files
committed
Fix D2D mapping for Java class names differing from source filename
When a Java source file contains class declarations with names different from the filename (e.g., DelombokTask.java containing class Tasks), the compiled .class file uses the class name (Tasks.class), not the filename. This fix: - Extracts class/interface/enum names from Java source files during scanning - Stores class names in extra_data["java_classes"] - Indexes both filename-based and class-name-based paths for matching - Enables correct D2D mapping for these edge cases Closes #1993 Signed-off-by: Mrityunjay Raj <mr.raj.earth@gmail.com>
1 parent 25cad21 commit 557ce0c

3 files changed

Lines changed: 185 additions & 1 deletion

File tree

scanpipe/pipes/jvm.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class JvmLanguage:
4141
binary_extensions: tuple = (".class",)
4242
# Like java_package, kotlin_package, scala_package, used as an attribute in resource
4343
source_package_attribute_name: str = None
44+
# Like java_classes, stores the class names defined in the source file
45+
source_classes_attribute_name: str = None
4446
# A regex pattern to extract a package from a source file
4547
package_regex: Pattern = None
4648
# Type of relation for a binary file to its source file
@@ -115,14 +117,38 @@ def get_indexable_qualified_paths_from_values(cls, resource_values):
115117
116118
And the output tuples look like this example::
117119
(123, "org/apache/commons/LoggerImpl.java")
120+
121+
If the source file contains class names that differ from the filename
122+
(e.g., a file named "Foo.java" containing "class Bar"), additional
123+
entries are yielded for each class name.
118124
"""
119125
for resource_id, resource_name, resource_extra_data in resource_values:
126+
jvm_package = resource_extra_data.get(cls.source_package_attribute_name)
127+
# Yield the original filename-based path
120128
fully_qualified = get_fully_qualified_path(
121-
jvm_package=resource_extra_data.get(cls.source_package_attribute_name),
129+
jvm_package=jvm_package,
122130
filename=resource_name,
123131
)
124132
yield resource_id, fully_qualified
125133

134+
# Also yield paths for any class names that differ from the filename
135+
if cls.source_classes_attribute_name:
136+
class_names = resource_extra_data.get(
137+
cls.source_classes_attribute_name, []
138+
)
139+
# Get the base name without extension to compare
140+
base_name = Path(resource_name).stem
141+
extension = Path(resource_name).suffix
142+
for class_name in class_names:
143+
# Only yield if class name differs from filename
144+
if class_name != base_name:
145+
class_filename = f"{class_name}{extension}"
146+
class_path = get_fully_qualified_path(
147+
jvm_package=jvm_package,
148+
filename=class_filename,
149+
)
150+
yield resource_id, class_path
151+
126152
@classmethod
127153
def get_normalized_path(cls, path, extension):
128154
"""
@@ -180,14 +206,55 @@ def find_expression(lines, regex):
180206
return value
181207

182208

209+
def find_all_expressions(lines, regex, max_lines=500):
210+
"""Return all values found using ``regex`` in the first ``max_lines`` lines."""
211+
results = []
212+
for ln, line in enumerate(lines):
213+
if ln > max_lines:
214+
break
215+
for value in regex.findall(line):
216+
if value and value not in results:
217+
results.append(value)
218+
return results
219+
220+
183221
class JavaLanguage(JvmLanguage):
184222
name = "java"
185223
source_extensions = (".java",)
186224
binary_extensions = (".class",)
187225
source_package_attribute_name = "java_package"
226+
source_classes_attribute_name = "java_classes"
188227
package_regex = re.compile(r"^\s*package\s+([\w\.]+)\s*;")
228+
# Regex to match class/interface/enum declarations in Java
229+
# Matches patterns like: "class Foo", "public class Foo", "interface Bar", etc.
230+
class_name_regex = re.compile(
231+
r"(?:^|[;\s{}])\s*" # Start of line or after ; { } or whitespace
232+
r"(?:public\s+|private\s+|protected\s+|abstract\s+|final\s+|static\s+)*"
233+
r"(?:class|interface|enum)\s+"
234+
r"(\w+)" # Capture the class/interface/enum name
235+
)
189236
binary_map_type = "java_to_class"
190237

238+
@classmethod
239+
def find_source_package(cls, lines):
240+
"""Find the package and class names from Java source lines."""
241+
result = {}
242+
lines_list = list(lines)
243+
244+
# Find package
245+
package = find_expression(lines=iter(lines_list), regex=cls.package_regex)
246+
if package:
247+
result[cls.source_package_attribute_name] = package
248+
249+
# Find all class/interface/enum names
250+
class_names = find_all_expressions(
251+
lines=iter(lines_list), regex=cls.class_name_regex
252+
)
253+
if class_names:
254+
result[cls.source_classes_attribute_name] = class_names
255+
256+
return result if result else None
257+
191258

192259
class ScalaLanguage(JvmLanguage):
193260
name = "scala"

scanpipe/tests/pipes/test_d2d.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,34 @@ def test_scanpipe_pipes_d2d_map_java_to_class_no_java(self):
498498
expected = "No ('.java',) resources to map."
499499
self.assertIn(expected, buffer.getvalue())
500500

501+
def test_scanpipe_pipes_d2d_map_java_to_class_different_class_name(self):
502+
"""Test D2D mapping when class name differs from source filename (#1993)."""
503+
# Source file named DelombokTask.java but contains class Tasks
504+
from1 = make_resource_file(
505+
self.project1,
506+
path="from/lombok/delombok/ant/DelombokTask.java",
507+
extra_data={
508+
"java_package": "lombok.delombok.ant",
509+
"java_classes": ["Tasks", "Delombok"],
510+
},
511+
)
512+
# The .class file is named after the class, not the source file
513+
to1 = make_resource_file(
514+
self.project1,
515+
path="to/lombok/delombok/ant/Tasks.class",
516+
)
517+
518+
buffer = io.StringIO()
519+
d2d.map_jvm_to_class(
520+
self.project1, logger=buffer.write, jvm_lang=jvm.JavaLanguage
521+
)
522+
523+
# Should find the mapping via class name
524+
relation = self.project1.codebaserelations.get()
525+
self.assertEqual(from1, relation.from_resource)
526+
self.assertEqual(to1, relation.to_resource)
527+
self.assertEqual("java_to_class", relation.map_type)
528+
501529
def test_scanpipe_pipes_d2d_java_ignore_pattern(self):
502530
make_resource_file(self.project1, path="to/module-info.class")
503531
make_resource_file(self.project1, path="to/META-INF/MANIFEST.MF")

scanpipe/tests/pipes/test_jvm.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,48 @@
5050

5151
java_package_too_far_down = ("\n" * 501) + "package org.apache.logging.log4j.core;"
5252

53+
# Java code where class name differs from filename (like lombok's DelombokTask.java)
54+
java_code_different_class_name = """
55+
package lombok.delombok.ant;
56+
57+
import org.apache.tools.ant.Task;
58+
59+
/**
60+
* Ant tasks for delombok.
61+
* This file is named DelombokTask.java but contains class Tasks.
62+
*/
63+
class Tasks {
64+
public static class Delombok extends Task {
65+
public void execute() {}
66+
}
67+
68+
public static class Format extends Task {
69+
public void execute() {}
70+
}
71+
}
72+
"""
73+
74+
# Java code with multiple classes including interface and enum
75+
java_code_multiple_types = """
76+
package com.example;
77+
78+
public class MainClass {
79+
// Main implementation
80+
}
81+
82+
interface SomeInterface {
83+
void doSomething();
84+
}
85+
86+
enum Status {
87+
ACTIVE, INACTIVE
88+
}
89+
90+
abstract class AbstractBase {
91+
public abstract void process();
92+
}
93+
"""
94+
5395

5496
class ScanPipeJvmTest(TestCase):
5597
data = Path(__file__).parent.parent / "data"
@@ -114,6 +156,53 @@ def test_scanpipe_pipes_jvm_get_fully_qualified_java_path(self):
114156
fqjp = jvm.get_fully_qualified_path("org.common", "Bar.java")
115157
self.assertEqual("org/common/Bar.java", fqjp)
116158

159+
def test_scanpipe_pipes_jvm_find_java_package_with_different_class_name(self):
160+
"""Test that find_source_package extracts class names differing from filename."""
161+
result = jvm.JavaLanguage.find_source_package(
162+
java_code_different_class_name.splitlines()
163+
)
164+
self.assertEqual("lombok.delombok.ant", result["java_package"])
165+
# The class name "Tasks" differs from what would be the filename
166+
self.assertIn("Tasks", result["java_classes"])
167+
# Also check for inner classes
168+
self.assertIn("Delombok", result["java_classes"])
169+
self.assertIn("Format", result["java_classes"])
170+
171+
def test_scanpipe_pipes_jvm_find_java_package_with_multiple_types(self):
172+
"""Test that find_source_package extracts all class/interface/enum names."""
173+
result = jvm.JavaLanguage.find_source_package(
174+
java_code_multiple_types.splitlines()
175+
)
176+
self.assertEqual("com.example", result["java_package"])
177+
classes = result["java_classes"]
178+
self.assertIn("MainClass", classes)
179+
self.assertIn("SomeInterface", classes)
180+
self.assertIn("Status", classes)
181+
self.assertIn("AbstractBase", classes)
182+
183+
def test_scanpipe_pipes_jvm_get_indexable_qualified_paths_with_class_names(self):
184+
"""Test get_indexable_qualified_paths_from_values yields class name paths."""
185+
resource_values = [
186+
(
187+
1,
188+
"DelombokTask.java",
189+
{
190+
"java_package": "lombok.delombok.ant",
191+
"java_classes": ["Tasks", "Delombok"],
192+
},
193+
),
194+
]
195+
paths = list(
196+
jvm.JavaLanguage.get_indexable_qualified_paths_from_values(resource_values)
197+
)
198+
# Should yield: filename path, and paths for each class name that differs
199+
self.assertEqual(3, len(paths))
200+
# First is the original filename-based path
201+
self.assertEqual((1, "lombok/delombok/ant/DelombokTask.java"), paths[0])
202+
# Then paths for class names that differ from filename
203+
self.assertEqual((1, "lombok/delombok/ant/Tasks.java"), paths[1])
204+
self.assertEqual((1, "lombok/delombok/ant/Delombok.java"), paths[2])
205+
117206

118207
class ScanPipeJvmScalaTest(TestCase):
119208
data = Path(__file__).parent.parent / "data"

0 commit comments

Comments
 (0)