diff --git a/file_combine/build.gradle b/file_combine/build.gradle index 4fa384a..a5ad030 100644 --- a/file_combine/build.gradle +++ b/file_combine/build.gradle @@ -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' diff --git a/file_combine/src/main/java/click/kamil/file_combine/JavaConcatenator.java b/file_combine/src/main/java/click/kamil/file_combine/JavaConcatenator.java index d6c5a30..1054fe7 100644 --- a/file_combine/src/main/java/click/kamil/file_combine/JavaConcatenator.java +++ b/file_combine/src/main/java/click/kamil/file_combine/JavaConcatenator.java @@ -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 { - 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 sourceDirs; - String targetPackage = "click.kamil"; + @Option(names = {"-o", "--output"}, description = "Output file path.", defaultValue = "./Combined.java") + private Path outputFile; - List javaFiles = FileUtils.findFilesWithExtension(Set.of(sourceDir), ".java"); + @Option(names = {"-p", "--package"}, description = "Target package name.", defaultValue = "click.kamil") + private String targetPackage; - Set uniqueImports = new TreeSet<>(); - List 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 javaFiles = FileUtils.findFilesWithExtension(new HashSet<>(sourceDirs), ".java"); + + if (javaFiles.isEmpty()) { + System.out.println("No Java files found in specified directories."); + return 0; + } + + Set internalPackages = new HashSet<>(); + Set internalClasses = new HashSet<>(); + List 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 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 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 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 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 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 topLevelTypes; List imports; String classBody; - public FileParseResult(List imports, String classBody) { + public FileParseResult(String packageName, List topLevelTypes, List imports, String classBody) { + this.packageName = packageName; + this.topLevelTypes = topLevelTypes; this.imports = imports; this.classBody = classBody; } diff --git a/file_combine/src/test/java/click/kamil/file_combine/JavaConcatenatorTest.java b/file_combine/src/test/java/click/kamil/file_combine/JavaConcatenatorTest.java new file mode 100644 index 0000000..cdfe789 --- /dev/null +++ b/file_combine/src/test/java/click/kamil/file_combine/JavaConcatenatorTest.java @@ -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 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); + } + } +}