IndentationCalculator.java

/*
 * Copyright (C) 2011, 2013-2026 The JavaParser Team.
 *
 * This file is part of JavaParser.
 *
 * JavaParser can be used either under the terms of
 * a) the GNU Lesser General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 * b) the terms of the Apache License
 *
 * You should have received a copy of both licenses in LICENCE.LGPL and
 * LICENCE.APACHE. Please refer to those files for details.
 *
 * JavaParser is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 */
package com.github.javaparser.printer.lexicalpreservation;

import static com.github.javaparser.printer.lexicalpreservation.IndentationConstants.STANDARD_INDENTATION_SIZE;

import com.github.javaparser.GeneratedJavaParserConstants;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * Provides stateless utility methods for indentation calculations and analysis.
 *
 * This class contains pure functions that compute indentation-related values
 * without maintaining any state. All methods are static and can be used
 * independently without creating an instance.
 *
 * Typical operations include:
 * - Computing indentation from preceding elements
 * - Analyzing indentation context for enforcement
 * - Creating standard indentation blocks
 * - Extracting indentation from token sequences
 *
 * @see IndentationContext for stateful indentation management
 */
public final class IndentationCalculator {

    /**
     * Private constructor to prevent instantiation.
     * This is a utility class with only static methods.
     */
    private IndentationCalculator() {
        throw new AssertionError("IndentationCalculator is a utility class and should not be instantiated");
    }

    /**
     * Computes the indentation that should be used based on the elements preceding
     * the current position. This analyzes the elements to find the last newline
     * and extracts all whitespace characters that follow it.
     *
     * This method is used when we need to match existing indentation in the source code.
     *
     * @param precedingElements elements before the current position
     * @return list of indentation elements (spaces/tabs) after the last newline, or empty list if no newline found
     */
    public static List<TextElement> computeFromPrecedingElements(List<TextElement> precedingElements) {
        int eolIndex = findLastNewlineIndex(precedingElements);
        // No newline found, return empty indentation
        if (eolIndex < 0) {
            return Collections.emptyList();
        }
        // Extract whitespace elements after the newline
        List<TextElement> result = new ArrayList<>();
        for (int i = eolIndex + 1; i < precedingElements.size(); i++) {
            TextElement element = precedingElements.get(i);
            if (element.isSpaceOrTab()) {
                result.add(element);
            } else {
                // Stop at first non-whitespace
                break;
            }
        }
        return result;
    }

    /**
     * Extracts the indentation portion from a list of elements.
     *
     * This method differs from computeFromPrecedingElements because it doesn't look for
     * a newline first - it assumes the list represents tokens after a newline and simply
     * extracts all leading whitespace.
     *
     * This is useful when we have already collected preceding tokens and want to
     * extract just the indentation part.
     *
     * @param precedingTokens tokens that precede the position
     * @return list of indentation elements (leading whitespace only)
     */
    public static List<TextElement> extractIndentationFromTokens(List<TextElement> precedingTokens) {
        List<TextElement> indentation = new ArrayList<>();
        for (TextElement element : precedingTokens) {
            if (element.isSpaceOrTab()) {
                indentation.add(element);
            } else {
                // Stop at first non-whitespace
                break;
            }
        }
        return indentation;
    }

    /**
     * Creates a single indentation block of STANDARD_INDENTATION_SIZE spaces.
     * This is used when we need to add one level of indentation temporarily.
     *
     * @return list containing STANDARD_INDENTATION_SIZE space elements
     */
    public static List<TextElement> createIndentationBlock() {
        List<TextElement> block = new ArrayList<>(STANDARD_INDENTATION_SIZE);
        for (int i = 0; i < STANDARD_INDENTATION_SIZE; i++) {
            block.add(new TokenTextElement(GeneratedJavaParserConstants.SPACE, " "));
        }
        return block;
    }

    /**
     * Analyzes the indentation enforcement context at a given position in the node text.
     *
     * <p><b>Context and Purpose:</b></p>
     * This method is primarily used by the {@link Difference} class during AST modification
     * to determine if excess whitespace should be removed after deleting elements. When a node
     * is removed from the AST, surrounding whitespace may need to be adjusted to maintain
     * proper formatting.
     *
     * <p><b>Algorithm Overview:</b></p>
     * The algorithm performs a two-phase scan to identify excess whitespace:
     * <ol>
     *   <li><b>Backward Scan:</b> Looks backward from the given index to find contiguous
     *       whitespace characters, stopping at either a newline or a non-whitespace element.</li>
     *   <li><b>Forward Scan:</b> If the current position contains whitespace, scans forward
     *       to count additional contiguous whitespace characters.</li>
     * </ol>
     *
     * <p><b>Examples:</b></p>
     * <pre>
     * Example 1 - Whitespace between elements after deletion:
     *   Before: "public class A { int foo; }"
     *   After deletion of "int foo;": "public class A { [space][space] }"
     *   analyzeEnforcingContext(nodeText, firstSpaceIndex) returns:
     *     - startIndex: index of first space
     *     - extraCharacters: 2 (both spaces should be considered for removal)
     *
     * Example 2 - Indentation after newline:
     *   Structure: "[newline][space][space][space][space]public"
     *   analyzeEnforcingContext(nodeText, middleSpaceIndex) returns:
     *     - startIndex: index of first space after newline
     *     - extraCharacters: 4 (all indentation spaces)
     *
     * Example 3 - Non-whitespace interrupts sequence:
     *   Structure: "public[space][space]"
     *   analyzeEnforcingContext(nodeText, firstSpaceIndex) returns:
     *     - startIndex: index of first space (reset due to "public")
     *     - extraCharacters: 2 (spaces after "public")
     * </pre>
     *
     * <p><b>Important Behavior:</b></p>
     * When a non-whitespace element is encountered during the backward scan, the context
     * is reset (start becomes the current index, extraCharacters becomes 0), but the forward
     * scan still executes if the current position is whitespace. This allows the method to
     * identify and count trailing spaces after non-whitespace elements.
     *
     * @param nodeText the node text being modified
     * @param index position to analyze (typically points to a position after a deletion)
     * @return context containing the start index and count of excess whitespace characters
     */
    public static EnforcingContext analyzeEnforcingContext(NodeText nodeText, int index) {
        // Guard against invalid indices
        if (index < 0 || index >= nodeText.numberOfElements()) {
            return new EnforcingContext(index, 0);
        }
        // Starting position of whitespace sequence to potentially remove
        int start = index;
        // Total count of excess whitespace characters
        int extraCharacters = 0;
        // ========== PHASE 1: BACKWARD SCAN ==========
        // Scan backward from the position to identify preceding whitespace.
        // This determines if we're at the beginning of a line (after newline) or
        // if there are spaces that should be counted as part of the enforcement context.
        if (index < nodeText.numberOfElements() && index > 0) {
            for (int i = index - 1; i >= 0; i--) {
                // Stop at newline - we've found the line boundary
                if (nodeText.getTextElement(i).isNewline()) {
                    break;
                }
                // If we encounter a non-whitespace element:
                // Reset the context because we're not at the beginning of a line.
                // However, we still need to scan forward to count any trailing spaces.
                if (!nodeText.getTextElement(i).isSpaceOrTab()) {
                    // Reset: we'll only count forward from current position
                    start = index;
                    extraCharacters = 0;
                    break;
                }
                // Found whitespace - expand the sequence backward
                // Update start to this earlier position
                start = i;
                // Count this whitespace character
                extraCharacters++;
            }
        }
        // ========== PHASE 2: FORWARD SCAN ==========
        // Scan forward from the current position to count additional whitespace.
        // This phase ALWAYS executes if the current position is whitespace,
        // even if we reset the context during the backward scan.
        //
        // Example scenario where this matters:
        //   "public[space][space]" - backward scan finds "public" and resets,
        //   but we still need to count the 2 trailing spaces.
        if (index < nodeText.numberOfElements()
                && nodeText.getTextElement(index).isSpaceOrTab()) {
            for (int i = index; i < nodeText.numberOfElements(); i++) {
                // Stop at newline - end of current line
                if (nodeText.getTextElement(i).isNewline()) {
                    break;
                }
                // Stop at non-whitespace - end of whitespace sequence
                if (!nodeText.getTextElement(i).isSpaceOrTab()) {
                    break;
                }
                // Count this whitespace character
                extraCharacters++;
            }
        }
        return new EnforcingContext(start, extraCharacters);
    }

    /**
     * Removes excess indentation characters from the node text.
     *
     * This method modifies the provided NodeText by removing a specified number
     * of elements starting from the given index.
     *
     * @param nodeText the node text to modify
     * @param startIndex where to start removing
     * @param count how many characters to remove
     * @return the new index position after removal
     */
    public static int removeExcessIndentation(NodeText nodeText, int startIndex, int count) {
        int removed = 0;
        while (startIndex >= 0 && startIndex < nodeText.numberOfElements() && removed < count) {
            nodeText.removeElement(startIndex);
            removed++;
        }
        return startIndex;
    }

    /**
     * Applies indentation enforcement at the specified position, preserving
     * the specified number of characters.
     *
     * This is the main enforcement method that:
     * 1. Analyzes the context to determine extra whitespace
     * 2. Calculates how much to remove based on charactersToPreserve
     * 3. Removes the excess
     * 4. Returns the adjusted index
     *
     * @param nodeText the node text to modify
     * @param index current position
     * @param charactersToPreserve how many indentation characters to keep
     * @return the new index position after enforcement
     */
    public static int enforceIndentation(NodeText nodeText, int index, int charactersToPreserve) {
        EnforcingContext ctx = analyzeEnforcingContext(nodeText, index);
        if (!ctx.hasExtraCharacters()) {
            return index;
        }
        int toRemove =
                ctx.getExtraCharacters() > charactersToPreserve ? ctx.getExtraCharacters() - charactersToPreserve : 0;
        int newIndex = removeExcessIndentation(nodeText, ctx.getStartIndex(), toRemove);
        // Adjust for preserved characters
        return toRemove > 0 ? newIndex + charactersToPreserve : newIndex;
    }

    /**
     * Finds the index of the last newline element in the list.
     *
     * @param elements list to search
     * @return index of last newline, or -1 if not found
     */
    private static int findLastNewlineIndex(List<TextElement> elements) {
        for (int i = elements.size() - 1; i >= 0; i--) {
            if (elements.get(i).isNewline()) {
                return i;
            }
        }
        return -1;
    }

    /**
     * Context information for enforcing indentation.
     * Contains the starting position and the number of extra characters to remove.
     *
     * This is an immutable value object returned by analyzeEnforcingContext.
     */
    public static class EnforcingContext {

        private final int startIndex;

        private final int extraCharacters;

        public EnforcingContext(int startIndex, int extraCharacters) {
            this.startIndex = startIndex;
            this.extraCharacters = extraCharacters;
        }

        /**
         * Returns the starting index of the whitespace sequence to potentially remove.
         *
         * @return the start index
         */
        public int getStartIndex() {
            return startIndex;
        }

        /**
         * Returns the total number of extra whitespace characters found.
         *
         * @return count of extra characters
         */
        public int getExtraCharacters() {
            return extraCharacters;
        }

        /**
         * Returns whether there are any extra characters to remove.
         *
         * @return true if extraCharacters > 0
         */
        public boolean hasExtraCharacters() {
            return extraCharacters > 0;
        }

        @Override
        public String toString() {
            return "EnforcingContext{startIndex=" + startIndex + ", extraCharacters=" + extraCharacters + "}";
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            EnforcingContext that = (EnforcingContext) o;
            return startIndex == that.startIndex && extraCharacters == that.extraCharacters;
        }

        @Override
        public int hashCode() {
            return 31 * startIndex + extraCharacters;
        }
    }
}