diff --git a/build.gradle.kts b/build.gradle.kts index e89b7c1..65a525c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("java") id("antlr") + id("application") } group = "org.example" @@ -15,6 +16,10 @@ java { targetCompatibility = JavaVersion.VERSION_25 } +application { + mainClass.set("org.example.Main") +} + dependencies { antlr("org.antlr:antlr4:4.13.2") implementation("org.antlr:antlr4-runtime:4.13.2") diff --git a/src/main/java/org/example/JavaFormatterCli.java b/src/main/java/org/example/JavaFormatterCli.java new file mode 100644 index 0000000..9e78cf5 --- /dev/null +++ b/src/main/java/org/example/JavaFormatterCli.java @@ -0,0 +1,370 @@ +package org.example; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import org.example.ebnfFormatter.runtime.DefaultFormatterFactory; +import org.example.ebnfFormatter.runtime.FormatterEngine; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class JavaFormatterCli { + private static final Set EXCLUDED_DIRECTORY_NAMES = Set.of( + ".git", + ".gradle", + "build", + "target", + "out" + ); + + private final FormatterEngine formatterEngine; + private final JavaParser javaParser; + private final PrintStream out; + private final PrintStream err; + + public JavaFormatterCli(PrintStream out, PrintStream err) { + this( + DefaultFormatterFactory.createEngine(), + new JavaParser(new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_25)), + out, + err + ); + } + + JavaFormatterCli( + FormatterEngine formatterEngine, + JavaParser javaParser, + PrintStream out, + PrintStream err + ) { + this.formatterEngine = formatterEngine; + this.javaParser = javaParser; + this.out = out; + this.err = err; + } + + public int run(String[] args) { + CliOptions options; + try { + options = parseOptions(args); + } catch (IllegalArgumentException e) { + err.println(e.getMessage()); + printUsage(err); + return 1; + } + + if (options.help()) { + printUsage(out); + return 0; + } + + List files; + try { + files = collectJavaFiles(options.roots()); + } catch (IOException | IllegalArgumentException e) { + err.println(e.getMessage()); + return 1; + } + + Summary summary = new Summary(); + for (Path file : files) { + handleFile(file, options.mode(), options.explainSkips(), summary); + } + + out.printf( + "Scanned %d Java file(s): %d changed, %d unchanged, %d skipped, %d failed.%n", + summary.scanned, + summary.changed, + summary.unchanged, + summary.skipped, + summary.failed + ); + + if (options.mode() == Mode.CHECK && summary.changed > 0) { + out.println("Run with --write to update files."); + } + + if (summary.failed > 0) { + return 1; + } + return 0; + } + + private CliOptions parseOptions(String[] args) { + if (args.length == 0) { + throw new IllegalArgumentException("Expected --write or --check."); + } + + Mode mode = null; + boolean explainSkips = false; + List roots = new ArrayList<>(); + + for (String arg : args) { + switch (arg) { + case "-h", "--help" -> { + return CliOptions.forHelp(); + } + case "--write" -> mode = parseMode(mode, Mode.WRITE); + case "--check" -> mode = parseMode(mode, Mode.CHECK); + case "--explain-skips" -> explainSkips = true; + default -> { + if (arg.startsWith("-")) { + throw new IllegalArgumentException("Unknown option: " + arg); + } + roots.add(Path.of(arg)); + } + } + } + + if (mode == null) { + throw new IllegalArgumentException("Expected --write or --check."); + } + if (roots.isEmpty()) { + throw new IllegalArgumentException("Expected at least one .java file or directory."); + } + + return new CliOptions(mode, List.copyOf(roots), false, explainSkips); + } + + private Mode parseMode(Mode existing, Mode next) { + if (existing != null && existing != next) { + throw new IllegalArgumentException("Use only one mode: --write or --check."); + } + return next; + } + + private List collectJavaFiles(List roots) throws IOException { + LinkedHashSet files = new LinkedHashSet<>(); + + for (Path root : roots) { + Path normalizedRoot = root.toAbsolutePath().normalize(); + if (!Files.exists(normalizedRoot)) { + throw new IllegalArgumentException("Path does not exist: " + root); + } + + if (Files.isRegularFile(normalizedRoot)) { + if (!isJavaFile(normalizedRoot)) { + throw new IllegalArgumentException("Not a .java file: " + root); + } + files.add(normalizedRoot); + continue; + } + + if (!Files.isDirectory(normalizedRoot)) { + throw new IllegalArgumentException("Not a regular file or directory: " + root); + } + + Files.walkFileTree(normalizedRoot, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (!dir.equals(normalizedRoot) && isExcludedDirectory(dir)) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (attrs.isRegularFile() && isJavaFile(file)) { + files.add(file.toAbsolutePath().normalize()); + } + return FileVisitResult.CONTINUE; + } + }); + } + + List sorted = new ArrayList<>(files); + sorted.sort(Comparator.comparing(Path::toString)); + return sorted; + } + + private boolean isExcludedDirectory(Path dir) { + Path name = dir.getFileName(); + return name != null && EXCLUDED_DIRECTORY_NAMES.contains(name.toString()); + } + + private boolean isJavaFile(Path path) { + Path name = path.getFileName(); + return name != null && name.toString().endsWith(".java"); + } + + private void handleFile(Path file, Mode mode, boolean explainSkips, Summary summary) { + summary.scanned++; + try { + String original = Files.readString(file, StandardCharsets.UTF_8); + CompilationUnit originalAst = parseCompilationUnit(original, file, "original source"); + String formatted = formatSafely(originalAst, file); + formatted = preserveOriginalLineEndings(formatted, original); + CompilationUnit formattedAst = parseFormattedCompilationUnit(formatted, file); + assertAstPreserved(originalAst, formattedAst); + + if (formatted.equals(original)) { + summary.unchanged++; + return; + } + + summary.changed++; + if (mode == Mode.WRITE) { + Files.writeString(file, formatted, StandardCharsets.UTF_8); + out.println("formatted " + file); + } else { + out.println("would format " + file); + } + } catch (UnsafeFormatException e) { + summary.skipped++; + out.println("skipped " + file + ": " + e.getMessage()); + if (explainSkips && e.details() != null) { + out.println(e.details()); + } + } catch (RuntimeException | IOException e) { + summary.failed++; + err.println("failed " + file + ": " + e.getMessage()); + } + } + + private String formatSafely(CompilationUnit originalAst, Path file) { + try { + return formatterEngine.format(originalAst, "CompilationUnit"); + } catch (RuntimeException e) { + throw new UnsafeFormatException("formatter cannot render this file: " + e.getMessage(), e); + } + } + + private CompilationUnit parseCompilationUnit(String source, Path file, String phase) { + ParseResult result = javaParser.parse(source); + if (result.getResult().isEmpty() || !result.getProblems().isEmpty()) { + throw new IllegalArgumentException( + "Cannot parse " + phase + " for " + file + ": " + result.getProblems() + ); + } + return result.getResult().get(); + } + + private CompilationUnit parseFormattedCompilationUnit(String source, Path file) { + ParseResult result = javaParser.parse(source); + if (result.getResult().isEmpty() || !result.getProblems().isEmpty()) { + throw new UnsafeFormatException("formatted source does not parse: " + result.getProblems()); + } + return result.getResult().get(); + } + + private void assertAstPreserved(CompilationUnit originalAst, CompilationUnit formattedAst) { + String original = originalAst.toString(); + String formatted = formattedAst.toString(); + if (!original.equals(formatted)) { + throw new UnsafeFormatException( + "formatted AST differs from original AST; file was left unchanged.", + firstAstDifference(original, formatted) + ); + } + } + + private String firstAstDifference(String original, String formatted) { + String[] originalLines = original.split("\\R", -1); + String[] formattedLines = formatted.split("\\R", -1); + int lineCount = Math.min(originalLines.length, formattedLines.length); + + for (int i = 0; i < lineCount; i++) { + if (!originalLines[i].equals(formattedLines[i])) { + return " first different normalized AST line " + (i + 1) + ":" + System.lineSeparator() + + " original : " + abbreviate(originalLines[i]) + System.lineSeparator() + + " formatted: " + abbreviate(formattedLines[i]); + } + } + + return " normalized AST line count differs: original=" + originalLines.length + + ", formatted=" + formattedLines.length; + } + + private String abbreviate(String value) { + if (value.length() <= 180) { + return value; + } + return value.substring(0, 177) + "..."; + } + + private String preserveOriginalLineEndings(String formatted, String original) { + String lineEnding = original.contains("\r\n") ? "\r\n" : "\n"; + String result = "\n".equals(lineEnding) ? formatted : formatted.replace("\n", lineEnding); + + if (endsWithLineTerminator(original) && !endsWithLineTerminator(result)) { + return result + lineEnding; + } + return result; + } + + private boolean endsWithLineTerminator(String text) { + return text.endsWith("\n") || text.endsWith("\r"); + } + + private void printUsage(PrintStream stream) { + stream.println(""" + Usage: + ./gradlew run --args="--write " + ./gradlew run --args="--check " + + You can pass multiple files or directories after --write/--check. + + Options: + --write Format .java files in place. + --check Report files that would change without writing them. + --explain-skips Show the first normalized AST difference for skipped files. + -h, --help Show this help. + """); + } + + private static final class UnsafeFormatException extends RuntimeException { + private final String details; + + private UnsafeFormatException(String message) { + super(message); + this.details = null; + } + + private UnsafeFormatException(String message, Throwable cause) { + super(message, cause); + this.details = null; + } + + private UnsafeFormatException(String message, String details) { + super(message); + this.details = details; + } + + private String details() { + return details; + } + } + + private enum Mode { + WRITE, + CHECK + } + + private record CliOptions(Mode mode, List roots, boolean help, boolean explainSkips) { + static CliOptions forHelp() { + return new CliOptions(null, List.of(), true, false); + } + } + + private static final class Summary { + private int scanned; + private int changed; + private int unchanged; + private int skipped; + private int failed; + } +} diff --git a/src/main/java/org/example/Main.java b/src/main/java/org/example/Main.java index ef56760..97c290f 100644 --- a/src/main/java/org/example/Main.java +++ b/src/main/java/org/example/Main.java @@ -2,6 +2,9 @@ public class Main { public static void main(String[] args) { - + int exitCode = new JavaFormatterCli(System.out, System.err).run(args); + if (exitCode != 0) { + System.exit(exitCode); + } } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/ebnfFormatter/runtime/DefaultFormatterFactory.java b/src/main/java/org/example/ebnfFormatter/runtime/DefaultFormatterFactory.java new file mode 100644 index 0000000..35cafea --- /dev/null +++ b/src/main/java/org/example/ebnfFormatter/runtime/DefaultFormatterFactory.java @@ -0,0 +1,193 @@ +package org.example.ebnfFormatter.runtime; + +import org.antlr.v4.runtime.BaseErrorListener; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.Recognizer; +import org.example.ebnfFormatter.dsl.RuleAstBuilder; +import org.example.ebnfFormatter.match.PatternMatcher; +import org.example.ebnfFormatter.model.RuleDef; +import org.example.ebnfFormatter.render.TemplateRenderer; +import org.example.ebnfLexer; +import org.example.ebnfParser; + +import java.util.List; + +public final class DefaultFormatterFactory { + private static final String DEFAULT_RULES = """ + ::= CompilationUnit(packageDeclaration?=, imports=[*], types=[*]) + => ifpresent(PackageDeclaration, nl nl) + ifpresent(ImportDeclaration, join(, nl) nl nl) + join(, nl nl); + + ::= PackageDeclaration(name=) + => "package" sp ";"; + + ::= ImportDeclaration(static=true, asterisk=true, name=) + => "import" sp "static" sp ".*" ";"; + + ::= ImportDeclaration(static=true, asterisk=false, name=) + => "import" sp "static" sp ";"; + + ::= ImportDeclaration(static=false, asterisk=true, name=) + => "import" sp ".*" ";"; + + ::= ImportDeclaration(static=false, asterisk=false, name=) + => "import" sp ";"; + + ::= + => ; + + ::= + => ; + + ::= + => ; + + ::= ClassOrInterfaceDeclaration(annotations=[*], modifiers=[*], interface=true, name=, extendedTypes=[*], members=[*]) + => ifpresent(AnnotationExpr, join(, nl) nl) + ifpresent(Modifier, join(, "")) "interface" sp + ifpresent(ExtendedType, sp "extends" sp join(, ", ")) + sp "{" + ifpresent(BodyDeclaration, nl indent join(, nl nl) nl dedent) + "}"; + + ::= ClassOrInterfaceDeclaration(annotations=[*], modifiers=[*], interface=false, name=, extendedTypes=[*], implementedTypes=[*], members=[*]) + => ifpresent(AnnotationExpr, join(, nl) nl) + ifpresent(Modifier, join(, "")) "class" sp + ifpresent(ExtendedType, sp "extends" sp join(, ", ")) + ifpresent(ImplementedType, sp "implements" sp join(, ", ")) + sp "{" + ifpresent(BodyDeclaration, nl indent join(, nl nl) nl dedent) + "}"; + + ::= + => ; + + ::= MethodDeclaration(annotations=[*], modifiers=[*], type=, name=, parameters=[*], thrownExceptions=[*], body=) + => ifpresent(AnnotationExpr, join(, nl) nl) + ifpresent(Modifier, join(, "")) sp "(" + ifpresent(Parameter, join(, ", ")) + ")" ifpresent(ReferenceType, sp "throws" sp join(, ", ")) sp ; + + ::= MethodDeclaration(annotations=[*], modifiers=[*], type=, name=, parameters=[*], thrownExceptions=[*]) + => ifpresent(AnnotationExpr, join(, nl) nl) + ifpresent(Modifier, join(, "")) sp "(" + ifpresent(Parameter, join(, ", ")) + ")" ifpresent(ReferenceType, sp "throws" sp join(, ", ")) ";"; + + ::= BlockStmt(statements=[*]) + => "{" nl indent join(, nl) nl dedent "}"; + + ::= IfStmt(condition=, thenStmt=, elseStmt?=) + => "if" sp "(" ")" + ifpresent(ElseStmt, nl "else" ); + + ::= BlockStmt(statements=[*]) + => sp "{" nl indent join(, nl) nl dedent "}"; + + ::= ReturnStmt(expression?=) + => nl indent "return" ifpresent(ThenExpr, sp ) ";" dedent; + + ::= ExpressionStmt(expression=) + => nl indent ";" dedent; + + ::= ForStmt(initialization=[*], compare?=, update=[*], body=) + => nl indent "for" sp "(" + ifpresent(InitExpr, join(, ", ")) + ";" ifpresent(CompareExpr, sp ) + ";" ifpresent(UpdateExpr, sp join(, ", ")) + ")" dedent; + + ::= + => sp ; + + ::= IfStmt(condition=, thenStmt=, elseStmt?=) + => "if" sp "(" ")" + ifpresent(ElseStmt, nl "else" ); + + ::= BlockStmt(statements=[*]) + => sp "{" nl indent join(, nl) nl dedent "}"; + + ::= ReturnStmt(expression?=) + => nl indent "return" ifpresent(ElseExpr, sp ) ";" dedent; + + ::= ExpressionStmt(expression=) + => nl indent ";" dedent; + + ::= ForStmt(initialization=[*], compare?=, update=[*], body=) + => nl indent "for" sp "(" + ifpresent(InitExpr, join(, ", ")) + ";" ifpresent(CompareExpr, sp ) + ";" ifpresent(UpdateExpr, sp join(, ", ")) + ")" dedent; + + ::= ForStmt(initialization=[*], compare?=, update=[*], body=) + => "for" sp "(" + ifpresent(InitExpr, join(, ", ")) + ";" ifpresent(CompareExpr, sp ) + ";" ifpresent(UpdateExpr, sp join(, ", ")) + ")" ; + + ::= BlockStmt(statements=[*]) + => sp "{" nl indent join(, nl) nl dedent "}"; + + ::= ExpressionStmt(expression=) + => nl indent ";" dedent; + + ::= ReturnStmt(expression?=) + => nl indent "return" ifpresent(ForExpr, sp ) ";" dedent; + + ::= ReturnStmt(expression?=) + => "return" ifpresent(Expression, sp ) ";"; + + ::= ExpressionStmt(expression=) + => ";"; + """; + + private DefaultFormatterFactory() { + } + + public static FormatterEngine createEngine() { + RuleRegistry ruleRegistry = new RuleRegistry(); + ruleRegistry.registerAll(parseRules(DEFAULT_RULES)); + + TypeRegistryUniversal typeRegistry = new TypeRegistryUniversal(); + PatternMatcher patternMatcher = new PatternMatcher(typeRegistry, ruleRegistry); + TemplateRenderer templateRenderer = new TemplateRenderer(); + + return new FormatterEngine(ruleRegistry, patternMatcher, templateRenderer); + } + + public static List parseRules(String rules) { + ebnfLexer lexer = new ebnfLexer(CharStreams.fromString(rules)); + ebnfParser parser = new ebnfParser(new CommonTokenStream(lexer)); + ThrowingErrorListener errorListener = new ThrowingErrorListener(); + + lexer.removeErrorListeners(); + parser.removeErrorListeners(); + lexer.addErrorListener(errorListener); + parser.addErrorListener(errorListener); + + ebnfParser.RulelistContext ctx = parser.rulelist(); + return new RuleAstBuilder().buildRules(ctx); + } + + private static final class ThrowingErrorListener extends BaseErrorListener { + @Override + public void syntaxError( + Recognizer recognizer, + Object offendingSymbol, + int line, + int charPositionInLine, + String msg, + RecognitionException e + ) { + throw new IllegalArgumentException( + "Rules syntax error at " + line + ":" + charPositionInLine + ": " + msg, + e + ); + } + } +} diff --git a/src/test/java/org/example/JavaFormatterCliTest.java b/src/test/java/org/example/JavaFormatterCliTest.java new file mode 100644 index 0000000..c8866d4 --- /dev/null +++ b/src/test/java/org/example/JavaFormatterCliTest.java @@ -0,0 +1,245 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class JavaFormatterCliTest { + @TempDir + private Path tempDir; + + @Test + void writeFormatsJavaFilesInPlace() throws Exception { + Path file = tempDir.resolve("Sample.java"); + Files.writeString(file, "class Sample{int one(){return 1;}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", tempDir.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(""" + class Sample { + int one() { + return 1; + } + } + """); + assertThat(run.out()).contains("formatted " + file.toAbsolutePath().normalize()); + } + + @Test + void checkReportsChangesWithoutWriting() throws Exception { + Path file = tempDir.resolve("Sample.java"); + String original = "class Sample{int one(){return 1;}}\n"; + Files.writeString(file, original, StandardCharsets.UTF_8); + + CliRun run = runCli("--check", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(original); + assertThat(run.out()).contains("would format " + file.toAbsolutePath().normalize()); + } + + @Test + void writeLeavesFileUnchangedWhenFormattedAstWouldChange() throws Exception { + Path file = tempDir.resolve("Sample.java"); + String original = "class Sample{ T run(T value){return value;}}\n"; + Files.writeString(file, original, StandardCharsets.UTF_8); + + CliRun run = runCli("--write", "--explain-skips", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(original); + assertThat(run.out()).contains("skipped " + file.toAbsolutePath().normalize()); + assertThat(run.out()).contains("first different normalized AST line"); + assertThat(run.err()).isEmpty(); + } + + @Test + void writePreservesAnnotationsAndInterfaces() throws Exception { + Path file = tempDir.resolve("Repository.java"); + Files.writeString(file, "@Deprecated interface Repository{default int one(){return 1;}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(""" + @Deprecated + interface Repository { + default int one() { + return 1; + } + } + """); + } + + @Test + void writePreservesStaticImports() throws Exception { + Path file = tempDir.resolve("StaticImport.java"); + Files.writeString(file, "import static org.assertj.core.api.Assertions.assertThat;class StaticImport{void run(){assertThat(1).isOne();}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).startsWith(""" + import static org.assertj.core.api.Assertions.assertThat; + + class StaticImport { + """); + } + + @Test + void writeDoesNotAccumulateIndentInsideRawMembers() throws Exception { + Path file = tempDir.resolve("RawMembers.java"); + String source = """ + @Deprecated + class RawMembers { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + RawMembers(String message) { + super(message); + } + } + """; + Files.writeString(file, source, StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(source); + } + + @Test + void writeDoesNotInsertSpaceBeforeEmptyReturnSemicolon() throws Exception { + Path file = tempDir.resolve("VoidReturn.java"); + Files.writeString(file, "class VoidReturn{void run(){return;}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(""" + class VoidReturn { + void run() { + return; + } + } + """); + } + + @Test + void writePreservesLineCommentsBetweenStatements() throws Exception { + Path file = tempDir.resolve("StatementComments.java"); + Files.writeString(file, "class StatementComments{void run(){first(); // keep this\nsecond();}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains(""" + first(); // keep this + second(); + """); + assertThat(run.out()).contains("formatted " + file.toAbsolutePath().normalize()); + } + + @Test + void writePreservesLineCommentsBeforeFirstStatement() throws Exception { + Path file = tempDir.resolve("LeadingStatementComment.java"); + Files.writeString(file, "class LeadingStatementComment{void run(){// keep first\nfirst();}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains(""" + // keep first + first(); + """); + assertThat(run.out()).contains("formatted " + file.toAbsolutePath().normalize()); + } + + @Test + void writePreservesBlockCommentBetweenReturnAndExpression() throws Exception { + Path file = tempDir.resolve("ReturnComment.java"); + Files.writeString(file, "class ReturnComment{Object run(){return /* keep return */ value();}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains("return /* keep return */ value();"); + assertThat(run.out()).contains("formatted " + file.toAbsolutePath().normalize()); + } + + @Test + void writePreservesBlockCommentAfterOpeningParen() throws Exception { + Path file = tempDir.resolve("ConditionComment.java"); + Files.writeString(file, "class ConditionComment{void run(){if(/* keep condition */ ready()){return;}}}\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains("if (/* keep condition */ ready())"); + assertThat(run.out()).contains("formatted " + file.toAbsolutePath().normalize()); + } + + @Test + void writePreservesFileLevelComments() throws Exception { + Path file = tempDir.resolve("FileComments.java"); + Files.writeString(file, "// file header\nclass FileComments{int run(){return 1;}}\n// file tail\n", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", file.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo(""" + // file header + class FileComments { + int run() { + return 1; + } + } + // file tail + """); + assertThat(run.out()).contains("formatted " + file.toAbsolutePath().normalize()); + } + + @Test + void writeSkipsCommonBuildDirectories() throws Exception { + Path sourceFile = tempDir.resolve("src/Sample.java"); + Path buildFile = tempDir.resolve("build/Broken.java"); + Files.createDirectories(sourceFile.getParent()); + Files.createDirectories(buildFile.getParent()); + Files.writeString(sourceFile, "class Sample{int one(){return 1;}}\n", StandardCharsets.UTF_8); + Files.writeString(buildFile, "not java", StandardCharsets.UTF_8); + + CliRun run = runCli("--write", tempDir.toString()); + + assertThat(run.exitCode()).isEqualTo(0); + assertThat(run.err()).isEmpty(); + assertThat(Files.readString(sourceFile, StandardCharsets.UTF_8)).contains("class Sample {"); + assertThat(Files.readString(buildFile, StandardCharsets.UTF_8)).isEqualTo("not java"); + } + + private CliRun runCli(String... args) { + ByteArrayOutputStream outBytes = new ByteArrayOutputStream(); + ByteArrayOutputStream errBytes = new ByteArrayOutputStream(); + PrintStream out = new PrintStream(outBytes, true, StandardCharsets.UTF_8); + PrintStream err = new PrintStream(errBytes, true, StandardCharsets.UTF_8); + + int exitCode = new JavaFormatterCli(out, err).run(args); + + return new CliRun( + exitCode, + outBytes.toString(StandardCharsets.UTF_8), + errBytes.toString(StandardCharsets.UTF_8) + ); + } + + private record CliRun(int exitCode, String out, String err) { + } +}