eclipse-jdt / eclipse-jdt/eclipse.jdt.core
NPE in single-module compilation via the javax.tools API: EclipseCompilerImpl.createCompilationUnit returns null on a filename/getName() separator mismatch (regression since 4.34)
- Dominant language
- Java
- Stars
- 237
- Forks
- 195
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 47
Description
## Summary
Compiling a single module (a `module-info.java` in the sources, no `--module-source-path`) through the `javax.tools` compiler API throws a `NullPointerException` inside ECJ instead of compiling. `EclipseCompilerImpl.createCompilationUnit(int, String)` returns `null`, and `Main.extractModuleDesc` passes that null into `new CompilationResult(...)`.
This is a regression. ECJ 4.33 and earlier compile the same input successfully. ECJ 4.34 through 4.39 throw.
## Affected versions
Bisected against `org.eclipse.jdt:ecj` on Maven Central:
| ecj (Maven) | Eclipse release | Result |
|---|---|---|
| 3.40.0 | 4.33 | builds successfully |
| 3.41.0 | 4.34 | NPE (first bad version) |
| 3.45.0 | 4.38 | NPE |
| 3.46.0 | 4.39 | NPE |
## Stack trace (ecj 3.46.0 / Eclipse 4.39)
```
java.lang.NullPointerException: Cannot invoke "org.eclipse.jdt.internal.compiler.env.ICompilationUnit.getFileName()" because "compilationUnit" is null
at org.eclipse.jdt.internal.compiler.CompilationResult.(CompilationResult.java:101)
at org.eclipse.jdt.internal.compiler.batch.Main.extractModuleDesc(Main.java:3110)
at org.eclipse.jdt.internal.compiler.batch.Main.handleSingleModuleCompilation(Main.java:3570)
at org.eclipse.jdt.internal.compiler.tool.EclipseCompilerImpl.handleSingleModuleCompilation(EclipseCompilerImpl.java:495)
at org.eclipse.jdt.internal.compiler.batch.Main.configure(Main.java:2987)
at org.eclipse.jdt.internal.compiler.tool.EclipseCompiler.getTask(EclipseCompiler.java:175)
```
## Root cause
Since 4.34, `Main.extractModuleDesc` obtains the `module-info` compilation unit through a supplier and constructs a `CompilationResult` from it directly:
```java
ICompilationUnit cu = cuSupplier.get();
CompilationResult compilationResult = new CompilationResult(cu, 0, 1, 10); // NPE when cu == null
```
The supplier is `EclipseCompilerImpl.createCompilationUnit(int idx, String filename)`:
```java
if (this.compilationUnits != null && idx < this.compilationUnits.size()) {
JavaFileObject javaFileObject = this.compilationUnits.get(idx);
if (filename.endsWith(javaFileObject.getName())) {
try {
return ClasspathJsr199.readCompilationUnit(javaFileObject, getDefaultEncoding());
} catch (IOException e) {
// nop
}
}
}
return null;
```
`filename` originates in `EclipseCompiler.getTask`, which builds each entry as `new File(uri).getAbsolutePath()`. When `javaFileObject.getName()` is not a suffix of that absolute path, the `endsWith` guard is false, `createCompilationUnit` returns `null`, and `extractModuleDesc` dereferences it.
On Windows the absolute path uses backslashes (`C:\...\module-info.java`) while a file manager can report `getName()` with forward slashes (`C:/.../module-info.java`), so `endsWith` is false. Before 4.34 `extractModuleDesc` built the unit directly with `new CompilationUnit(null, fileName, null)`, which is never null, so the same input compiled.
## Minimal reproducer
No IDE required. This drives `EclipseCompiler.getTask` the way a build tool does, and wraps the file objects so `getName()` is not a suffix of ECJ's absolute `filename`, which is the same condition Windows produces naturally.
`module-info.java`:
```java
module com.example.app {
exports com.example;
}
```
`src/com/example/Main.java`:
```java
package com.example;
public class Main {
public static void main(String[] args) {
System.out.println("Hello, module world!");
}
}
```
`EcjToolsReproMismatch.java`:
```java
import javax.tools.*;
import java.io.File;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;
public class EcjToolsReproMismatch {
public static void main(String[] args) throws Exception {
JavaCompiler compiler = null;
for (JavaCompiler c : ServiceLoader.load(JavaCompiler.class)) {
if (c.getClass().getName().contains("Eclipse")) { compiler = c; break; }
}
if (compiler == null) throw new IllegalStateException("EclipseCompiler not on classpath");
StandardJavaFileManager base = compiler.getStandardFileManager(null, null, null);
File out = new File("out");
out.mkdirs();
base.setLocation(StandardLocation.CLASS_OUTPUT, List.of(out));
Iterable units = base.getJavaFileObjects(
new File("module-info.java"),
new File("src/com/example/Main.java"));
// A file manager without MODULE_SOURCE_PATH -> single-module compilation path.
ForwardingJavaFileManager fm =
new ForwardingJavaFileManager<>(base) {
@Override public boolean hasLocation(Location location) {
if (location == StandardLocation.MODULE_SOURCE_PATH) return false;
return super.hasLocation(location);
}
};
List wrapped = new ArrayList<>();
for (JavaFileObject u : units) {
wrapped.add(new ForwardingJavaFileObject<>(u) {
// getName() is not a suffix of getTask()'s getAbsolutePath() filename
// (the Windows backslash / forward-slash mismatch).
@Override public String getName() { return fileObject.toUri().toString(); }
});
}
DiagnosticCollector diags = new DiagnosticCollector<>();
JavaCompiler.CompilationTask task =
compiler.getTask(new PrintWriter(System.out), fm, diags, List.of("--release", "21"), null, wrapped);
System.out.println("success=" + task.call());
}
}
```
Run against any affected version:
```bash
curl -O https://repo1.maven.org/maven2/org/eclipse/jdt/ecj/3.46.0/ecj-3.46.0.jar
javac -cp ecj-3.46.0.jar -d . EcjToolsReproMismatch.java
java -cp .:ecj-3.46.0.jar EcjToolsReproMismatch # -> NullPointerException (stack above)
```
The same command against `ecj-3.40.0.jar` (4.33) does not throw.
## Suggested fix
Match the compilation unit by a normalized path or by URI in `createCompilationUnit`, rather than `String.endsWith` over OS-dependent separators. Alternatively, restore the direct-parse behavior in `extractModuleDesc` for the single-module case, or guard against a null unit before constructing `CompilationResult`.
## Note on the reproduction
I reproduced the crash by simulating the `getName()`/absolute-path mismatch on macOS. I did not run it on Windows, so the forward-slash `getName()` on Windows is inferred from the separator difference rather than directly observed. A maintainer on Windows can confirm that part with a real `javax.tools` build of a `module-info.java`.
Contributor guide
Research direction
Start with EclipseCompilerImpl.createCompilationUnit(int, String) and Main.extractModuleDesc, then run the provided EcjToolsReproMismatch.java against ecj 3.46.0 and 3.40.0. Trace the filename/getName() matching path and verify the single-module javax.tools compilation completes without the NPE for the mismatch case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100