InferenceStrategy.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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
 *
 *   http://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.apache.maven.cling.invoker.mvnup.goals;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;

import org.apache.maven.api.cli.mvnup.UpgradeOptions;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Priority;
import org.apache.maven.api.di.Singleton;
import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
import org.jdom2.Content;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.Namespace;
import org.jdom2.Text;

import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.Files.DEFAULT_PARENT_RELATIVE_PATH;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.Files.POM_XML;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.ModelVersions.MODEL_VERSION_4_1_0;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.ARTIFACT_ID;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.BUILD;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEPENDENCY;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.GROUP_ID;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PARENT;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGIN;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGINS;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.RELATIVE_PATH;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SUBPROJECT;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SUBPROJECTS;
import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.VERSION;

/**
 * Strategy for applying Maven inference optimizations.
 * For 4.0.0 models: applies limited inference (parent-related only).
 * For 4.1.0+ models: applies full inference optimizations.
 * Removes redundant information that can be inferred by Maven during model building.
 */
@Named
@Singleton
@Priority(30)
public class InferenceStrategy extends AbstractUpgradeStrategy {

    @Override
    public boolean isApplicable(UpgradeContext context) {
        UpgradeOptions options = getOptions(context);

        // Handle --all option (overrides individual options)
        boolean useAll = options.all().orElse(false);
        if (useAll) {
            return true;
        }

        // Check if --infer is explicitly set
        if (options.infer().isPresent()) {
            return options.infer().get();
        }

        // Apply default behavior: if no specific options are provided, enable --infer
        if (options.infer().isEmpty()
                && options.model().isEmpty()
                && options.plugins().isEmpty()
                && options.modelVersion().isEmpty()) {
            return true;
        }

        return false;
    }

    @Override
    public String getDescription() {
        return "Applying Maven inference optimizations";
    }

    @Override
    public UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
        Set<Path> processedPoms = new HashSet<>();
        Set<Path> modifiedPoms = new HashSet<>();
        Set<Path> errorPoms = new HashSet<>();

        // Compute all GAVs for inference
        Set<GAV> allGAVs = GAVUtils.computeAllGAVs(context, pomMap);

        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
            Path pomPath = entry.getKey();
            Document pomDocument = entry.getValue();
            processedPoms.add(pomPath);

            String currentVersion = ModelVersionUtils.detectModelVersion(pomDocument);
            context.info(pomPath + " (current: " + currentVersion + ")");
            context.indent();

            try {
                if (!ModelVersionUtils.isEligibleForInference(currentVersion)) {
                    context.warning(
                            "Model version " + currentVersion + " not eligible for inference (requires >= 4.0.0)");
                    continue;
                }

                boolean hasInferences = false;

                // Apply limited parent inference for all eligible models (4.0.0+)
                hasInferences |= applyLimitedParentInference(context, pomDocument);

                // Apply full inference optimizations only for 4.1.0+ models
                if (MODEL_VERSION_4_1_0.equals(currentVersion) || ModelVersionUtils.isNewerThan410(currentVersion)) {
                    hasInferences |= applyFullParentInference(context, pomMap, pomDocument);
                    hasInferences |= applyDependencyInference(context, allGAVs, pomDocument);
                    hasInferences |= applyDependencyInferenceRedundancy(context, pomMap, pomDocument);
                    hasInferences |= applySubprojectsInference(context, pomDocument, pomPath);
                    hasInferences |= applyModelVersionInference(context, pomDocument);
                }

                if (hasInferences) {
                    modifiedPoms.add(pomPath);
                    if (MODEL_VERSION_4_1_0.equals(currentVersion)
                            || ModelVersionUtils.isNewerThan410(currentVersion)) {
                        context.success("Full inference optimizations applied");
                    } else {
                        context.success("Limited inference optimizations applied (parent-related only)");
                    }
                } else {
                    context.success("No inference optimizations needed");
                }
            } catch (Exception e) {
                context.failure("Failed to apply inference optimizations" + ": " + e.getMessage());
                errorPoms.add(pomPath);
            } finally {
                context.unindent();
            }
        }

        return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
    }

    /**
     * Applies limited parent-related inference optimizations for Maven 4.0.0+ models.
     * Removes redundant child groupId/version that can be inferred from parent.
     */
    private boolean applyLimitedParentInference(UpgradeContext context, Document pomDocument) {
        Element root = pomDocument.getRootElement();
        Namespace namespace = root.getNamespace();

        // Check if this POM has a parent
        Element parentElement = root.getChild(PARENT, namespace);
        if (parentElement == null) {
            return false;
        }

        // Apply limited inference (child groupId/version removal only)
        return trimParentElementLimited(context, root, parentElement, namespace);
    }

    /**
     * Applies full parent-related inference optimizations for Maven 4.1.0+ models.
     * Removes redundant parent elements that can be inferred from relativePath.
     */
    private boolean applyFullParentInference(UpgradeContext context, Map<Path, Document> pomMap, Document pomDocument) {
        Element root = pomDocument.getRootElement();
        Namespace namespace = root.getNamespace();

        // Check if this POM has a parent
        Element parentElement = root.getChild(PARENT, namespace);
        if (parentElement == null) {
            return false;
        }

        // Apply full inference (parent element trimming based on relativePath)
        return trimParentElementFull(context, root, parentElement, namespace, pomMap);
    }

    /**
     * Applies dependency-related inference optimizations.
     * Removes managed dependencies that point to project artifacts.
     */
    private boolean applyDependencyInference(UpgradeContext context, Set<GAV> allGAVs, Document pomDocument) {
        boolean hasChanges = false;
        Element root = pomDocument.getRootElement();
        Namespace namespace = root.getNamespace();

        // Check dependencyManagement section
        Element dependencyManagement = root.getChild("dependencyManagement", namespace);
        if (dependencyManagement != null) {
            Element dependencies = dependencyManagement.getChild("dependencies", namespace);
            if (dependencies != null) {
                hasChanges |= removeManagedDependenciesFromSection(
                        context, dependencies, namespace, allGAVs, "dependencyManagement");
            }
        }

        // Check profiles for dependencyManagement
        Element profilesElement = root.getChild("profiles", namespace);
        if (profilesElement != null) {
            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
            for (Element profileElement : profileElements) {
                Element profileDependencyManagement = profileElement.getChild("dependencyManagement", namespace);
                if (profileDependencyManagement != null) {
                    Element profileDependencies = profileDependencyManagement.getChild("dependencies", namespace);
                    if (profileDependencies != null) {
                        hasChanges |= removeManagedDependenciesFromSection(
                                context, profileDependencies, namespace, allGAVs, "profile dependencyManagement");
                    }
                }
            }
        }

        return hasChanges;
    }

    /**
     * Applies dependency inference redundancy optimizations.
     * Removes redundant groupId/version from regular dependencies that can be inferred from project artifacts.
     */
    private boolean applyDependencyInferenceRedundancy(
            UpgradeContext context, Map<Path, Document> pomMap, Document pomDocument) {
        Element root = pomDocument.getRootElement();
        Namespace namespace = root.getNamespace();
        boolean hasChanges = false;

        // Process main dependencies
        Element dependenciesElement = root.getChild("dependencies", namespace);
        if (dependenciesElement != null) {
            hasChanges |= removeDependencyInferenceFromSection(
                    context, dependenciesElement, namespace, pomMap, "dependencies");
        }

        // Process profile dependencies
        Element profilesElement = root.getChild("profiles", namespace);
        if (profilesElement != null) {
            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
            for (Element profileElement : profileElements) {
                Element profileDependencies = profileElement.getChild("dependencies", namespace);
                if (profileDependencies != null) {
                    hasChanges |= removeDependencyInferenceFromSection(
                            context, profileDependencies, namespace, pomMap, "profile dependencies");
                }
            }
        }

        // Process build plugin dependencies
        Element buildElement = root.getChild(BUILD, namespace);
        if (buildElement != null) {
            Element pluginsElement = buildElement.getChild(PLUGINS, namespace);
            if (pluginsElement != null) {
                List<Element> pluginElements = pluginsElement.getChildren(PLUGIN, namespace);
                for (Element pluginElement : pluginElements) {
                    Element pluginDependencies = pluginElement.getChild("dependencies", namespace);
                    if (pluginDependencies != null) {
                        hasChanges |= removeDependencyInferenceFromSection(
                                context, pluginDependencies, namespace, pomMap, "plugin dependencies");
                    }
                }
            }
        }

        return hasChanges;
    }

    /**
     * Applies subprojects-related inference optimizations.
     * Removes redundant subprojects lists that match direct children.
     */
    private boolean applySubprojectsInference(UpgradeContext context, Document pomDocument, Path pomPath) {
        boolean hasChanges = false;
        Element root = pomDocument.getRootElement();
        Namespace namespace = root.getNamespace();

        // Check main subprojects
        Element subprojectsElement = root.getChild(SUBPROJECTS, namespace);
        if (subprojectsElement != null) {
            if (isSubprojectsListRedundant(subprojectsElement, namespace, pomPath)) {
                removeElementWithFormatting(subprojectsElement);
                context.detail("Removed: redundant subprojects list (matches direct children)");
                hasChanges = true;
            }
        }

        // Check profiles for subprojects
        Element profilesElement = root.getChild("profiles", namespace);
        if (profilesElement != null) {
            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
            for (Element profileElement : profileElements) {
                Element profileSubprojects = profileElement.getChild(SUBPROJECTS, namespace);
                if (profileSubprojects != null) {
                    if (isSubprojectsListRedundant(profileSubprojects, namespace, pomPath)) {
                        removeElementWithFormatting(profileSubprojects);
                        context.detail("Removed: redundant subprojects list from profile (matches direct children)");
                        hasChanges = true;
                    }
                }
            }
        }

        return hasChanges;
    }

    /**
     * Applies model version inference optimization.
     * Removes modelVersion element when it can be inferred from namespace.
     */
    private boolean applyModelVersionInference(UpgradeContext context, Document pomDocument) {
        String currentVersion = ModelVersionUtils.detectModelVersion(pomDocument);

        // Only remove modelVersion for 4.1.0+ models where it can be inferred from namespace
        if (MODEL_VERSION_4_1_0.equals(currentVersion) || ModelVersionUtils.isNewerThan410(currentVersion)) {

            if (ModelVersionUtils.removeModelVersion(pomDocument)) {
                context.detail("Removed: modelVersion element (can be inferred from namespace)");
                return true;
            }
        }

        return false;
    }

    /**
     * Applies limited parent inference for 4.0.0 models.
     * Only removes child groupId/version when they match parent.
     */
    private boolean trimParentElementLimited(
            UpgradeContext context, Element root, Element parentElement, Namespace namespace) {
        boolean hasChanges = false;

        // Get parent GAV
        String parentGroupId = getChildText(parentElement, "groupId", namespace);
        String parentVersion = getChildText(parentElement, "version", namespace);

        // Get child GAV
        String childGroupId = getChildText(root, "groupId", namespace);
        String childVersion = getChildText(root, "version", namespace);

        // Remove child groupId if it matches parent groupId
        if (childGroupId != null && Objects.equals(childGroupId, parentGroupId)) {
            Element childGroupIdElement = root.getChild("groupId", namespace);
            if (childGroupIdElement != null) {
                removeElementWithFormatting(childGroupIdElement);
                context.detail("Removed: child groupId (matches parent)");
                hasChanges = true;
            }
        }

        // Remove child version if it matches parent version
        if (childVersion != null && Objects.equals(childVersion, parentVersion)) {
            Element childVersionElement = root.getChild("version", namespace);
            if (childVersionElement != null) {
                removeElementWithFormatting(childVersionElement);
                context.detail("Removed: child version (matches parent)");
                hasChanges = true;
            }
        }

        return hasChanges;
    }

    /**
     * Applies full parent inference for 4.1.0+ models.
     * Removes parent groupId/version/artifactId when they can be inferred.
     */
    private boolean trimParentElementFull(
            UpgradeContext context,
            Element root,
            Element parentElement,
            Namespace namespace,
            Map<Path, Document> pomMap) {
        boolean hasChanges = false;

        // Get child GAV before applying any changes
        String childGroupId = getChildText(root, GROUP_ID, namespace);
        String childVersion = getChildText(root, VERSION, namespace);

        // First apply limited inference (child elements) - this removes matching child groupId/version
        hasChanges |= trimParentElementLimited(context, root, parentElement, namespace);

        // Only remove parent elements if the parent is in the same reactor (not external)
        if (isParentInReactor(parentElement, namespace, pomMap, context)) {
            // Remove parent groupId if child has no explicit groupId
            if (childGroupId == null) {
                Element parentGroupIdElement = parentElement.getChild(GROUP_ID, namespace);
                if (parentGroupIdElement != null) {
                    removeElementWithFormatting(parentGroupIdElement);
                    context.detail("Removed: parent groupId (child has no explicit groupId)");
                    hasChanges = true;
                }
            }

            // Remove parent version if child has no explicit version
            if (childVersion == null) {
                Element parentVersionElement = parentElement.getChild(VERSION, namespace);
                if (parentVersionElement != null) {
                    removeElementWithFormatting(parentVersionElement);
                    context.detail("Removed: parent version (child has no explicit version)");
                    hasChanges = true;
                }
            }

            // Remove parent artifactId if it can be inferred from relativePath
            if (canInferParentArtifactId(parentElement, namespace, pomMap)) {
                Element parentArtifactIdElement = parentElement.getChild(ARTIFACT_ID, namespace);
                if (parentArtifactIdElement != null) {
                    removeElementWithFormatting(parentArtifactIdElement);
                    context.detail("Removed: parent artifactId (can be inferred from relativePath)");
                    hasChanges = true;
                }
            }
        }

        return hasChanges;
    }

    /**
     * Determines if the parent is part of the same reactor (multi-module project)
     * vs. an external parent POM by checking if the parent exists in the pomMap.
     */
    private boolean isParentInReactor(
            Element parentElement, Namespace namespace, Map<Path, Document> pomMap, UpgradeContext context) {
        // If relativePath is explicitly set to empty, parent is definitely external
        String relativePath = getChildText(parentElement, RELATIVE_PATH, namespace);
        if (relativePath != null && relativePath.trim().isEmpty()) {
            return false;
        }

        // Extract parent GAV
        String parentGroupId = getChildText(parentElement, GROUP_ID, namespace);
        String parentArtifactId = getChildText(parentElement, ARTIFACT_ID, namespace);
        String parentVersion = getChildText(parentElement, VERSION, namespace);

        if (parentGroupId == null || parentArtifactId == null || parentVersion == null) {
            // Cannot determine parent GAV, assume external
            return false;
        }

        GAV parentGAV = new GAV(parentGroupId, parentArtifactId, parentVersion);

        // Check if any POM in our reactor matches the parent GAV using GAVUtils
        for (Document pomDocument : pomMap.values()) {
            GAV pomGAV = GAVUtils.extractGAVWithParentResolution(context, pomDocument);
            if (pomGAV != null && pomGAV.equals(parentGAV)) {
                return true;
            }
        }

        // Parent not found in reactor, must be external
        return false;
    }

    /**
     * Determines if parent artifactId can be inferred from relativePath.
     */
    private boolean canInferParentArtifactId(Element parentElement, Namespace namespace, Map<Path, Document> pomMap) {
        // Get relativePath (default is "../pom.xml" if not specified)
        String relativePath = getChildText(parentElement, RELATIVE_PATH, namespace);
        if (relativePath == null || relativePath.trim().isEmpty()) {
            relativePath = DEFAULT_PARENT_RELATIVE_PATH; // Maven default
        }

        // Only infer artifactId if relativePath is the default and we have multiple POMs
        // indicating this is likely a multi-module project
        return DEFAULT_PARENT_RELATIVE_PATH.equals(relativePath) && pomMap.size() > 1;
    }

    /**
     * Checks if a subprojects list is redundant (matches direct child directories with pom.xml).
     */
    private boolean isSubprojectsListRedundant(Element subprojectsElement, Namespace namespace, Path pomPath) {
        List<Element> subprojectElements = subprojectsElement.getChildren(SUBPROJECT, namespace);
        if (subprojectElements.isEmpty()) {
            return true; // Empty list is redundant
        }

        // Get the directory containing this POM
        Path parentDir = pomPath.getParent();
        if (parentDir == null) {
            return false;
        }

        // Get declared subprojects
        Set<String> declaredSubprojects = new HashSet<>();
        for (Element subprojectElement : subprojectElements) {
            String subprojectName = subprojectElement.getTextTrim();
            if (subprojectName != null && !subprojectName.isEmpty()) {
                declaredSubprojects.add(subprojectName);
            }
        }

        // Get list of actual direct child directories with pom.xml
        Set<String> actualSubprojects = new HashSet<>();
        try {
            if (Files.exists(parentDir) && Files.isDirectory(parentDir)) {
                try (Stream<Path> children = Files.list(parentDir)) {
                    children.filter(Files::isDirectory)
                            .filter(dir -> Files.exists(dir.resolve(POM_XML)))
                            .forEach(dir ->
                                    actualSubprojects.add(dir.getFileName().toString()));
                }
            }
        } catch (Exception e) {
            // If we can't read the directory, assume not redundant
            return false;
        }

        // Lists are redundant if they match exactly
        return declaredSubprojects.equals(actualSubprojects);
    }

    /**
     * Helper method to remove managed dependencies from a specific dependencies section.
     */
    private boolean removeManagedDependenciesFromSection(
            UpgradeContext context, Element dependencies, Namespace namespace, Set<GAV> allGAVs, String sectionName) {
        List<Element> dependencyElements = dependencies.getChildren(DEPENDENCY, namespace);
        List<Element> toRemove = new ArrayList<>();

        for (Element dependency : dependencyElements) {
            String groupId = getChildText(dependency, GROUP_ID, namespace);
            String artifactId = getChildText(dependency, ARTIFACT_ID, namespace);

            if (groupId != null && artifactId != null) {
                // Check if this dependency matches any project artifact
                boolean isProjectArtifact = allGAVs.stream()
                        .anyMatch(gav ->
                                Objects.equals(gav.groupId(), groupId) && Objects.equals(gav.artifactId(), artifactId));

                if (isProjectArtifact) {
                    toRemove.add(dependency);
                    context.detail("Removed: " + "managed dependency " + groupId + ":" + artifactId + " from "
                            + sectionName + " (project artifact)");
                }
            }
        }

        // Remove project artifacts while preserving formatting
        for (Element dependency : toRemove) {
            removeElementWithFormatting(dependency);
        }

        return !toRemove.isEmpty();
    }

    /**
     * Helper method to remove dependency inference redundancy from a specific dependencies section.
     */
    private boolean removeDependencyInferenceFromSection(
            UpgradeContext context,
            Element dependencies,
            Namespace namespace,
            Map<Path, Document> pomMap,
            String sectionName) {
        List<Element> dependencyElements = dependencies.getChildren(DEPENDENCY, namespace);
        boolean hasChanges = false;

        for (Element dependency : dependencyElements) {
            String groupId = getChildText(dependency, GROUP_ID, namespace);
            String artifactId = getChildText(dependency, ARTIFACT_ID, namespace);
            String version = getChildText(dependency, VERSION, namespace);

            if (artifactId != null) {
                // Try to find the dependency POM in our pomMap
                Document dependencyPom = findDependencyPom(context, pomMap, groupId, artifactId);
                if (dependencyPom != null) {
                    // Check if we can infer groupId
                    if (groupId != null && canInferDependencyGroupId(context, dependencyPom, groupId)) {
                        Element groupIdElement = dependency.getChild(GROUP_ID, namespace);
                        if (groupIdElement != null) {
                            removeElementWithFormatting(groupIdElement);
                            context.detail("Removed: " + "dependency groupId " + groupId + " from " + sectionName
                                    + " (can be inferred from project)");
                            hasChanges = true;
                        }
                    }

                    // Check if we can infer version
                    if (version != null && canInferDependencyVersion(context, dependencyPom, version)) {
                        Element versionElement = dependency.getChild(VERSION, namespace);
                        if (versionElement != null) {
                            removeElementWithFormatting(versionElement);
                            context.detail("Removed: " + "dependency version " + version + " from " + sectionName
                                    + " (can be inferred from project)");
                            hasChanges = true;
                        }
                    }
                }
            }
        }

        return hasChanges;
    }

    /**
     * Finds a dependency POM in the pomMap by groupId and artifactId.
     */
    private Document findDependencyPom(
            UpgradeContext context, Map<Path, Document> pomMap, String groupId, String artifactId) {
        for (Document pomDocument : pomMap.values()) {
            GAV gav = GAVUtils.extractGAVWithParentResolution(context, pomDocument);
            if (gav != null && Objects.equals(gav.groupId(), groupId) && Objects.equals(gav.artifactId(), artifactId)) {
                return pomDocument;
            }
        }
        return null;
    }

    /**
     * Determines if a dependency version can be inferred from the project artifact.
     */
    private boolean canInferDependencyVersion(UpgradeContext context, Document dependencyPom, String declaredVersion) {
        GAV projectGav = GAVUtils.extractGAVWithParentResolution(context, dependencyPom);
        if (projectGav == null || projectGav.version() == null) {
            return false;
        }

        // We can infer the version if the declared version matches the project version
        return Objects.equals(declaredVersion, projectGav.version());
    }

    /**
     * Determines if a dependency groupId can be inferred from the project artifact.
     */
    private boolean canInferDependencyGroupId(UpgradeContext context, Document dependencyPom, String declaredGroupId) {
        GAV projectGav = GAVUtils.extractGAVWithParentResolution(context, dependencyPom);
        if (projectGav == null || projectGav.groupId() == null) {
            return false;
        }

        // We can infer the groupId if the declared groupId matches the project groupId
        return Objects.equals(declaredGroupId, projectGav.groupId());
    }

    /**
     * Helper method to get child text content.
     */
    private String getChildText(Element parent, String childName, Namespace namespace) {
        Element child = parent.getChild(childName, namespace);
        return child != null ? child.getTextTrim() : null;
    }

    /**
     * Removes an element while preserving surrounding formatting.
     */
    private void removeElementWithFormatting(Element element) {
        Element parent = element.getParentElement();
        if (parent != null) {
            int index = parent.indexOf(element);
            parent.removeContent(element);

            // Remove preceding whitespace if it exists
            if (index > 0) {
                Content prevContent = parent.getContent(index - 1);
                if (prevContent instanceof Text text && text.getTextTrim().isEmpty()) {
                    parent.removeContent(prevContent);
                }
            }
        }
    }
}