JavaFileMergerJavaParserImpl.java

/*
 *    Copyright 2006-2026 the original author or authors.
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *       https://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */
package org.mybatis.generator.merge.java;

import static org.mybatis.generator.internal.util.messages.Messages.getString;

import java.util.List;

import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.Problem;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.BodyDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.printer.DefaultPrettyPrinter;
import com.github.javaparser.printer.Printer;
import com.github.javaparser.printer.lexicalpreservation.DefaultLexicalPreservingPrinter;
import org.jspecify.annotations.Nullable;
import org.mybatis.generator.exception.MergeException;

/**
 * This class handles the task of merging changes into an existing Java file using JavaParser.
 * It supports merging by removing methods and fields that have specific Javadoc tags or annotations.
 *
 * <p>Given an existing source file and a newly generated file of the same name, the merger will:
 * <ol>
 *     <li>Parse the existing file looking for custom additions. A custom addition is defined in these ways:
 *         <ul>
 *             <li>A body element (field, method, nested class, etc.) not marked as generated - missing both a
 *                 <code>@Generated</code> annotation and an older style custom Javadoc tag.</li>
 *             <li>A body element (field, method, nested class, etc.) marked as generated by an older style custom
 *                 Javadoc tag and also containing the phrase "do_not_delete_during_merge".</li>
 *             <li>Any import in the existing file that is not present in the newly generated file</li>
 *             <li>Any super interface in the existing file that is not present in the newly generated file</li>
 *             <li>Any enum constant missing a "generated" marker in the existing file that is not present in the new
 *                 file</li>
 *         </ul>
 *         It is important to know that the parser will only look for direct children of either the public type or the
 *         first non-public type in a source file.
 *     </li>
 *     <li>If there are no custom additions, the newly generated file is returned unmodified.</li>
 *     <li>If there are custom additions, then:
 *         <ul>
 *             <li>Add any imports present in the existing file but missing in the new file</li>
 *             <li>Remove any members in the new file that match custom additions in the existing file</li>
 *             <li>Add all custom additions from the existing file</li>
 *             <li>The merged file is formatted and returned.</li>
 *         </ul>
 *     </li>
 * </ol>
 *
 * <p>This implementation differs from the original Eclipse-based implementation in the following ways:</p>
 * <ol>
 *     <li>This implementation supports merging enums and records</li>
 *     <li>This implementation supports merging when the existing file is a class or interface, and the newly generated
 *         file is a record (the result will be a record).
 *     </li>
 *     <li>This implementation does not support merging the super class from an existing file to the newly generated
 *         file.</li>
 *     <li>This implementation does not attempt to preserve custom annotations added to generated elements. With the
 *         generator now generating code with many annotations, it is challenging to distinguish between annotations
 *         created by MBG, and custom annotations added after code generation by a user. If you need to add
 *         annotations to generated elements, consider implementing a plugin that will create the annotations whenever
 *         the generator runs.
 *     </li>
 * </ol>
 *
 * @author Freeman (original)
 * @author Jeff Butler (refactoring and enhancements)
 */
public class JavaFileMergerJavaParserImpl implements JavaFileMerger {
    private final Printer printer;

    public JavaFileMergerJavaParserImpl(JavaMergerFactory.PrinterConfiguration printerConfiguration) {
        printer = switch (printerConfiguration) {
        case ECLIPSE -> new DefaultPrettyPrinter(new EclipseOrderedPrinterConfiguration());
        case LEXICAL_PRESERVING -> new DefaultLexicalPreservingPrinter();
        };
    }

    /**
     * Merge a newly generated Java file with existing Java file content.
     *
     * @param newFileContent the content of the newly generated Java file
     * @param existingFileContent the content of the existing Java file
     * @return the merged source, properly formatted
     * @throws MergeException if the file cannot be merged for some reason
     */
    @Override
    public String getMergedSource(String newFileContent, String existingFileContent) throws MergeException {
        ParserConfiguration parserConfiguration = new ParserConfiguration();
        parserConfiguration.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_25);
        parserConfiguration.setLexicalPreservationEnabled(true);
        JavaParser javaParser = new JavaParser(parserConfiguration);

        ParseResults existingFileParseResults = parseAndFindMainTypeDeclaration(javaParser, existingFileContent,
                FileType.EXISTING_FILE);

        // Gather custom members from the existing file. If none, just return the new file as is
        CustomMemberGatherer customMemberGatherer = new CustomMemberGatherer(existingFileParseResults.typeDeclaration);
        if (!customMemberGatherer.hasAnyMembersToMerge()) {
            return newFileContent;
        }

        // Custom members exist, need to merge...
        ParseResults newFileParseResults = parseAndFindMainTypeDeclaration(javaParser, newFileContent,
                FileType.NEW_FILE);

        // Delete elements in the new file that match doNotDeleteMembers from the existing file
        customMemberGatherer.doNotDeleteBodyMembers()
                .forEach(m -> deleteDuplicateMemberIfExists(newFileParseResults.typeDeclaration, m));

        // Look for custom imports in the existing file and merge into the new file
        JavaMergeUtilities
                .findCustomImports(existingFileParseResults.compilationUnit, newFileParseResults.compilationUnit)
                .forEach(newFileParseResults.compilationUnit::addImport);

        // Add custom body members from the existing file to the new file
        customMemberGatherer.allCustomBodyMembers().forEach(newFileParseResults.typeDeclaration::addMember);

        // Add custom enum constants from the existing file to the new file
        if (newFileParseResults.typeDeclaration.isEnumDeclaration()) {
            customMemberGatherer.customEnumConstants()
                    .forEach(newFileParseResults.typeDeclaration.asEnumDeclaration()::addEntry);
        }

        // Look for custom super interfaces in the existing file and merge into the new file
        JavaMergeUtilities
                .findCustomSuperInterfaces(existingFileParseResults.typeDeclaration,
                        newFileParseResults.typeDeclaration)
                .forEach(t -> JavaMergeUtilities.addSuperInterface(newFileParseResults.typeDeclaration, t));

        // Return the new (merged) file
        return printer.print(newFileParseResults.compilationUnit);
    }

    private ParseResults parseAndFindMainTypeDeclaration(JavaParser javaParser, String source, FileType fileType)
            throws MergeException {
        ParseResult<CompilationUnit> parseResult = javaParser.parse(source);

        // little hack to pull the result out of the lambda. This allows us to avoid "orElseThrow()" later on
        @Nullable CompilationUnit[] compilationUnits = new CompilationUnit [1];
        parseResult.ifSuccessful(cu -> compilationUnits[0] = cu);

        if (compilationUnits[0] == null) {
            List<String> details = parseResult.getProblems().stream()
                    .map(Problem::toString)
                    .toList();
            throw new MergeException(getString("RuntimeError.28", fileType.toString()), details); //$NON-NLS-1$
        }

        return new ParseResults(compilationUnits[0], findMainTypeDeclaration(compilationUnits[0], fileType));
    }

    private void deleteDuplicateMemberIfExists(TypeDeclaration<?> newTypeDeclaration,
                                                      BodyDeclaration<?> member) {
        newTypeDeclaration.getMembers().stream()
                .filter(td -> JavaMergeUtilities.membersMatch(td, member))
                .findFirst()
                .ifPresent(newTypeDeclaration::remove);
    }

    private TypeDeclaration<?> findMainTypeDeclaration(CompilationUnit compilationUnit, FileType fileType)
            throws MergeException {
        // Return the first public type declaration, or the first type declaration if no public one exists
        TypeDeclaration<?> firstType = null;
        for (TypeDeclaration<?> typeDeclaration : compilationUnit.getTypes()) {
            if (firstType == null) {
                firstType = typeDeclaration;
            }
            if (typeDeclaration.isPublic()) {
                return typeDeclaration;
            }
        }
        if (firstType == null) {
            throw new MergeException(getString("RuntimeError.29", fileType.toString())); //$NON-NLS-1$
        }
        return firstType;
    }

    private record ParseResults(CompilationUnit compilationUnit, TypeDeclaration<?> typeDeclaration) {}

    private enum FileType {
        NEW_FILE("new Java file"), //$NON-NLS-1$
        EXISTING_FILE("existing Java file"); //$NON-NLS-1$

        private final String displayText;

        FileType(String displayText) {
            this.displayText = displayText;
        }

        @Override
        public String toString() {
            return displayText;
        }
    }
}