better file concatenator

This commit is contained in:
2026-06-09 20:05:05 +02:00
parent ba1b083dcb
commit c94cdc5039
3 changed files with 415 additions and 29 deletions

View File

@@ -1,10 +1,16 @@
plugins {
id 'java'
id 'application'
id 'com.gradleup.shadow' version '8.3.5'
}
group = 'click.kamil.file_combine'
version = '0.0.1-SNAPSHOT'
application {
mainClass = 'click.kamil.file_combine.JavaConcatenator'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
@@ -22,6 +28,9 @@ repositories {
}
dependencies {
// Picocli
implementation 'info.picocli:picocli:4.7.6'
// deps for parsing java AST
implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0'

View File

@@ -1,61 +1,115 @@
package click.kamil.file_combine;
import click.kamil.file_combine.common.FileUtils;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.compiler.IProblem;
import org.eclipse.jdt.core.dom.*;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jface.text.Document;
import org.eclipse.text.edits.TextEdit;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.Callable;
public class JavaConcatenator {
@Command(name = "java-combine", mixinStandardHelpOptions = true, version = "1.0",
description = "Combines multiple Java source files into a single file.")
public class JavaConcatenator implements Callable<Integer> {
public static void main(String[] args) throws IOException {
Path sourceDir = Path.of("./src/main/java/click/kamil/callgraphexporter/domain");
Path outputFile = Path.of("./Combined.java");
@Parameters(index = "0..*", description = "Directories to search for Java files.")
private List<Path> sourceDirs;
String targetPackage = "click.kamil";
@Option(names = {"-o", "--output"}, description = "Output file path.", defaultValue = "./Combined.java")
private Path outputFile;
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(sourceDir), ".java");
@Option(names = {"-p", "--package"}, description = "Target package name.", defaultValue = "click.kamil")
private String targetPackage;
Set<String> uniqueImports = new TreeSet<>();
List<String> classBodies = new ArrayList<>();
public static void main(String[] args) {
int exitCode = new CommandLine(new JavaConcatenator()).execute(args);
System.exit(exitCode);
}
@Override
public Integer call() throws IOException {
if (sourceDirs == null || sourceDirs.isEmpty()) {
System.err.println("At least one source directory must be specified.");
return 1;
}
List<Path> javaFiles = FileUtils.findFilesWithExtension(new HashSet<>(sourceDirs), ".java");
if (javaFiles.isEmpty()) {
System.out.println("No Java files found in specified directories.");
return 0;
}
Set<String> internalPackages = new HashSet<>();
Set<String> internalClasses = new HashSet<>();
List<FileParseResult> results = new ArrayList<>();
for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile);
FileParseResult result = parseAndTransform(source);
uniqueImports.addAll(result.imports);
classBodies.add(result.classBody);
if (result.packageName != null) {
internalPackages.add(result.packageName);
}
internalClasses.addAll(result.topLevelTypes);
results.add(result);
}
Set<String> uniqueImports = new TreeSet<>();
for (FileParseResult result : results) {
for (String imp : result.imports) {
String normalizedImport = imp.replaceFirst("^import\\s+static\\s+", "")
.replaceFirst("^import\\s+", "")
.replaceFirst(";$", "");
boolean isInternal = false;
for (String pkg : internalPackages) {
if (normalizedImport.startsWith(pkg + ".")) {
isInternal = true;
break;
}
}
if (!isInternal && (targetPackage == null || !normalizedImport.startsWith(targetPackage + "."))) {
uniqueImports.add(imp);
}
}
}
StringBuilder combinedSource = new StringBuilder();
// Add package declaration
combinedSource.append("package ").append(targetPackage).append(";\n\n");
if (targetPackage != null && !targetPackage.isEmpty()) {
combinedSource.append("package ").append(targetPackage).append(";\n\n");
}
// Filter and append imports excluding any from com.example.*
// Append imports
for (String imp : uniqueImports) {
String normalizedImport = imp.replaceFirst("^import\\s+static\\s+", "")
.replaceFirst("^import\\s+", "")
.replaceFirst(";$", "");
if (!normalizedImport.startsWith(targetPackage)) {
combinedSource.append(imp).append("\n");
}
combinedSource.append(imp).append("\n");
}
combinedSource.append("\n");
// Append all class bodies
for (String body : classBodies) {
combinedSource.append(body).append("\n");
for (FileParseResult result : results) {
combinedSource.append(result.classBody).append("\n");
}
if (outputFile.getParent() != null) {
Files.createDirectories(outputFile.getParent());
}
Files.writeString(outputFile, combinedSource.toString());
System.out.println("Combined Java source written to: " + outputFile);
return 0;
}
private static FileParseResult parseAndTransform(String source) {
@@ -64,11 +118,25 @@ public class JavaConcatenator {
parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setResolveBindings(false);
Map<String, String> options = JavaCore.getOptions();
JavaCore.setComplianceOptions(JavaCore.VERSION_21, options);
parser.setCompilerOptions(options);
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
if (cu.getProblems().length > 0) {
for (IProblem problem : cu.getProblems()) {
if (problem.isError()) {
System.err.println("Error parsing AST: " + problem.getMessage() + " at line " + problem.getSourceLineNumber());
}
}
}
AST ast = cu.getAST();
ASTRewrite rewrite = ASTRewrite.create(ast);
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : null;
// Collect imports as strings
List<String> imports = new ArrayList<>();
for (Object obj : cu.imports()) {
@@ -82,23 +150,49 @@ public class JavaConcatenator {
rewrite.remove(cu.getPackage(), null);
}
// Remove 'public' from type declarations
List<String> topLevelTypes = new ArrayList<>();
// Remove 'public' from top-level type declarations
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
removePublicModifier(node.modifiers(), rewrite);
if (node.getParent() instanceof CompilationUnit) {
topLevelTypes.add(node.getName().getIdentifier());
removePublicModifier(node.modifiers(), rewrite);
}
return true;
}
@Override
public boolean visit(EnumDeclaration node) {
removePublicModifier(node.modifiers(), rewrite);
if (node.getParent() instanceof CompilationUnit) {
topLevelTypes.add(node.getName().getIdentifier());
removePublicModifier(node.modifiers(), rewrite);
}
return true;
}
private void removePublicModifier(List<IExtendedModifier> modifiers, ASTRewrite rewrite) {
for (IExtendedModifier mod : new ArrayList<>(modifiers)) {
if (mod.isModifier()) {
@Override
public boolean visit(AnnotationTypeDeclaration node) {
if (node.getParent() instanceof CompilationUnit) {
topLevelTypes.add(node.getName().getIdentifier());
removePublicModifier(node.modifiers(), rewrite);
}
return true;
}
@Override
public boolean visit(RecordDeclaration node) {
if (node.getParent() instanceof CompilationUnit) {
topLevelTypes.add(node.getName().getIdentifier());
removePublicModifier(node.modifiers(), rewrite);
}
return true;
}
private void removePublicModifier(List modifiers, ASTRewrite rewrite) {
for (Object mod : modifiers) {
if (mod instanceof Modifier) {
Modifier m = (Modifier) mod;
if (m.isPublic()) {
rewrite.remove(m, null);
@@ -113,17 +207,21 @@ public class JavaConcatenator {
TextEdit edits = rewrite.rewriteAST(document, null);
edits.apply(document);
String transformedSource = document.get();
return new FileParseResult(imports, transformedSource);
return new FileParseResult(packageName, topLevelTypes, imports, transformedSource);
} catch (Exception e) {
throw new RuntimeException("Failed to apply AST edits", e);
}
}
private static class FileParseResult {
String packageName;
List<String> topLevelTypes;
List<String> imports;
String classBody;
public FileParseResult(List<String> imports, String classBody) {
public FileParseResult(String packageName, List<String> topLevelTypes, List<String> imports, String classBody) {
this.packageName = packageName;
this.topLevelTypes = topLevelTypes;
this.imports = imports;
this.classBody = classBody;
}

View File

@@ -0,0 +1,279 @@
package click.kamil.file_combine;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JavaConcatenatorTest {
@TempDir
Path tempDir;
@Test
void testCombineSimpleFiles() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("A.java"),
"package pkg1;\n" +
"public class A {\n" +
" public void hello() { System.out.println(\"Hello from A\"); }\n" +
"}");
Path pkg2 = tempDir.resolve("pkg2");
Files.createDirectories(pkg2);
Files.writeString(pkg2.resolve("B.java"),
"package pkg2;\n" +
"import pkg1.A;\n" +
"public class B {\n" +
" public void run() { new A().hello(); }\n" +
"}");
Path outputFile = tempDir.resolve("Combined.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined");
assertEquals(0, exitCode);
assertTrue(Files.exists(outputFile));
String content = Files.readString(outputFile);
assertTrue(content.contains("package combined;"));
assertTrue(content.contains("class A"));
assertTrue(content.contains("class B"));
assertFalse(content.contains("import pkg1.A;"));
// Verify compilation
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertEquals(0, compilationResult, "Combined file should compile successfully");
}
@Test
void testCollisionSameClassName() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("Utils.java"),
"package pkg1;\n" +
"public class Utils {\n" +
" public static void log() { System.out.println(\"pkg1\"); }\n" +
"}");
Path pkg2 = tempDir.resolve("pkg2");
Files.createDirectories(pkg2);
Files.writeString(pkg2.resolve("Utils.java"),
"package pkg2;\n" +
"public class Utils {\n" +
" public static void log() { System.out.println(\"pkg2\"); }\n" +
"}");
Path outputFile = tempDir.resolve("CombinedCollision.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined.collision");
assertEquals(0, exitCode);
// This should produce a file with two 'class Utils' which is invalid Java
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertNotEquals(0, compilationResult, "Combined file with name collision should NOT compile successfully");
}
@Test
void testStaticImportCollision() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("Utils1.java"),
"package pkg1;\n" +
"public class Utils1 {\n" +
" public static void log(String s) { System.out.println(\"1: \" + s); }\n" +
"}");
Path pkg2 = tempDir.resolve("pkg2");
Files.createDirectories(pkg2);
Files.writeString(pkg2.resolve("Utils2.java"),
"package pkg2;\n" +
"public class Utils2 {\n" +
" public static void log(String s) { System.out.println(\"2: \" + s); }\n" +
"}");
Path pkg3 = tempDir.resolve("pkg3");
Files.createDirectories(pkg3);
Files.writeString(pkg3.resolve("App.java"),
"package pkg3;\n" +
"import pkg1.Utils1;\n" +
"public class App {\n" +
" public void run() { Utils1.log(\"hello\"); }\n" +
"}");
Path outputFile = tempDir.resolve("CombinedStatic.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined.static_test");
assertEquals(0, exitCode);
String content = Files.readString(outputFile);
// Both static imports should be present if they are not from internal packages
// Actually, Utils1 and Utils2 ARE from internal packages in this test setup
// So the concatenator should remove 'import static pkg1.Utils1.log'
assertFalse(content.contains("import static pkg1.Utils1.log;"));
// Verify compilation
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertEquals(0, compilationResult, "Combined file with static imports should compile successfully if imports are resolved internally");
}
@Test
void testNestedClasses() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("Outer.java"),
"package pkg1;\n" +
"public class Outer {\n" +
" public static class Inner {\n" +
" public void doIt() {}\n" +
" }\n" +
"}");
Path pkg2 = tempDir.resolve("pkg2");
Files.createDirectories(pkg2);
Files.writeString(pkg2.resolve("Client.java"),
"package pkg2;\n" +
"import pkg1.Outer;\n" +
"public class Client {\n" +
" public void use() { new Outer.Inner().doIt(); }\n" +
"}");
Path outputFile = tempDir.resolve("CombinedNested.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined.nested");
assertEquals(0, exitCode);
// Verify compilation
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertEquals(0, compilationResult, "Combined file with nested classes should compile successfully");
}
@Test
void testImportCollision() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("A.java"),
"package pkg1;\n" +
"import java.util.List;\n" +
"public class A {\n" +
" public List<String> list;\n" +
"}");
Path pkg2 = tempDir.resolve("pkg2");
Files.createDirectories(pkg2);
Files.writeString(pkg2.resolve("B.java"),
"package pkg2;\n" +
"import java.awt.List;\n" +
"public class B {\n" +
" public List awtList;\n" +
"}");
Path outputFile = tempDir.resolve("CombinedImportCollision.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined.import_collision");
assertEquals(0, exitCode);
// This should fail compilation because 'List' is ambiguous
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertNotEquals(0, compilationResult, "Combined file with ambiguous imports should NOT compile successfully");
}
@Test
void testRecordsAndEnums() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("Data.java"),
"package pkg1;\n" +
"public record Data(String name, int value) {}\n");
Files.writeString(pkg1.resolve("Type.java"),
"package pkg1;\n" +
"public enum Type {\n" +
" A, B, C\n" +
"}\n");
Path outputFile = tempDir.resolve("CombinedModern.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined.modern");
assertEquals(0, exitCode);
String content = Files.readString(outputFile);
assertTrue(content.contains("record Data"));
assertTrue(content.contains("enum Type"));
assertFalse(content.contains("public record Data"));
assertFalse(content.contains("public enum Type"));
// Verify compilation
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertEquals(0, compilationResult, "Combined file with records and enums should compile successfully");
}
@Test
void testMetadataPreservation() throws Exception {
Path pkg1 = tempDir.resolve("pkg1");
Files.createDirectories(pkg1);
Files.writeString(pkg1.resolve("Annotated.java"),
"package pkg1;\n" +
"/**\n" +
" * Javadoc comment\n" +
" */\n" +
"@Deprecated\n" +
"public class Annotated {\n" +
" // Line comment\n" +
" /* Block comment */\n" +
" @SuppressWarnings(\"unchecked\")\n" +
" public void method() {}\n" +
"}");
Path outputFile = tempDir.resolve("CombinedMetadata.java");
CommandLineRunner runner = new CommandLineRunner();
int exitCode = runner.run(tempDir.toString(), "-o", outputFile.toString(), "-p", "combined.metadata");
assertEquals(0, exitCode);
String content = Files.readString(outputFile);
assertTrue(content.contains("Javadoc comment"));
assertTrue(content.contains("@Deprecated"));
assertTrue(content.contains("// Line comment"));
assertTrue(content.contains("/* Block comment */"));
assertTrue(content.contains("@SuppressWarnings(\"unchecked\")"));
// Verify compilation
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, outputFile.toString());
assertEquals(0, compilationResult, "Combined file with comments and annotations should compile successfully");
}
private static class CommandLineRunner {
int run(String... args) {
return new picocli.CommandLine(new JavaConcatenator()).execute(args);
}
}
}