AbstractFormatJob.java

/*
 * Copyright (c) 2020 bahlef.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v2.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v20.html
 * Contributors:
 * bahlef - initial API and implementation and/or initial documentation
 */
package de.funfried.netbeans.plugins.external.formatter;

import java.io.IOException;
import java.io.StringReader;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.swing.text.BadLocationException;
import javax.swing.text.StyledDocument;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.mutable.MutableInt;
import org.apache.commons.lang3.tuple.Pair;
import org.netbeans.api.annotations.common.NonNull;
import org.netbeans.api.diff.Difference;
import org.netbeans.api.editor.guards.GuardedSection;
import org.netbeans.api.editor.guards.GuardedSectionManager;
import org.netbeans.lib.editor.util.swing.DocumentUtilities;
import org.openide.text.NbDocument;

import de.funfried.netbeans.plugins.external.formatter.ui.editor.diff.Diff;

/**
 * Abstract base implementation of a {@link FormatJob} which is called by the
 * {@link FormatterService} where this implementation belongs to.
 *
 * @author bahlef
 */
public abstract class AbstractFormatJob implements FormatJob {
	/** {@link Logger} of this class. */
	private static final Logger log = Logger.getLogger(AbstractFormatJob.class.getName());

	/** Log {@link Level} for fast switching while investigating issues. */
	private static final Level logLevel = Level.FINER;

	/** {@link SortedSet} containing document offset ranges which should be formatted. */
	protected final SortedSet<Pair<Integer, Integer>> changedElements;

	/** The {@link StyledDocument} from which the content should be formatted. */
	protected final StyledDocument document;

	/**
	 * Constructor which has to be used by subclasses.
	 *
	 * @param document the {@link StyledDocument} from which the content should be formatted
	 * @param changedElements {@link SortedSet} containing document offset ranges which should be formatted or {@code null} to format the whole document
	 */
	protected AbstractFormatJob(StyledDocument document, SortedSet<Pair<Integer, Integer>> changedElements) {
		this.document = document;
		this.changedElements = changedElements;
	}

	/**
	 * Applies the given {@code formattedContent} to the {@code document}.
	 *
	 * @param code the previous (unformatted) content
	 * @param formattedContent the formatted code
	 *
	 * @return {@code true} if and only if the given {@code formattedContent} was set to
	 *         the {@code document}, if due to any circumstances (old code equals formatted code,
	 *         thrown exceptions, ...) the {@code formattedContent} wasn't applied {@code false}
	 *         is returned
	 *
	 * @throws BadLocationException if there is an issue while applying the formatted code
	 */
	protected boolean setFormattedCode(String code, String formattedContent) throws BadLocationException {
		// quick check for changed
		if (formattedContent != null && /* does not support changes of EOL */ !formattedContent.equals(code)) {
			try (StringReader original = new StringReader(code);
					StringReader formatted = new StringReader(formattedContent)) {
				Difference[] differences = Diff.diff(original, formatted);
				if (differences != null && differences.length != 0) {
					if (log.isLoggable(logLevel)) {
						log.log(logLevel, "Unformatted: ''{0}''", code);
						log.log(logLevel, "Formatted: ''{0}''", formattedContent);
					}

					for (Difference d : differences) {
						int startLine = d.getSecondStart();

						switch (d.getType()) {
							case Difference.ADD: {
								int start = NbDocument.findLineOffset(document, startLine - 1);
								String addText = d.getSecondText();

								if (log.isLoggable(logLevel)) {
									log.log(logLevel, "ADD: {0} / Line {1}: {2}", new Object[] { start, startLine, addText });
								}

								document.insertString(start, addText, null);

								break;
							}
							case Difference.CHANGE: {
								int start = NbDocument.findLineOffset(document, startLine - 1);
								String removeText = d.getFirstText();
								int length = removeText.length();

								// if the document consists of only 1 line without a trailing line-break
								// then LENGTH would exceed the document and yield an exception
								length = Math.min(length, document.getLength());

								String addText = d.getSecondText();

								if (log.isLoggable(logLevel)) {
									log.log(logLevel, "CHANGE: {0} - {1} / Line {2}: ''{3}'' <= ''{4}''", new Object[] { start, length, startLine, addText, removeText });
								}

								document.remove(start, length);
								document.insertString(start, addText, null);

								break;
							}
							case Difference.DELETE: {
								int start = NbDocument.findLineOffset(document, startLine);
								String removeText = d.getFirstText();
								int length = removeText.length();

								// if the document consists of only 1 line without a trailing line-break
								// then LENGTH would exceed the document and yield an exception
								length = Math.min(length, document.getLength());

								if (log.isLoggable(logLevel)) {
									log.log(logLevel, "DELETE: {0} - {1} / Line {2}: ''{3}''", new Object[] { start, length, startLine, removeText });
								}

								document.remove(start, length);

								break;
							}
						}
					}

					return true;
				}
			} catch (IOException ex) {
				log.log(Level.WARNING, "Could not create diff", ex);
			}
		}

		return false;
	}

	/**
	 * Returns the content of the {@code document}.
	 *
	 * @return The content of the {@code document}
	 */
	protected String getCode() {
		try {
			// returns the actual content of the document
			return document.getText(0, document.getLength());
		} catch (BadLocationException ex) {
			log.log(Level.WARNING, "Could not fetch text, falling back to utility method", ex);

			// returns only the trimmed content of the document
			return DocumentUtilities.getText(document).toString();
		}
	}

	/**
	 * Returns a {@link SortedSet} within ranges as {@link Pair}s of {@link Integer}s
	 * which describe the start and end offsets that can be formatted, it automatically
	 * checks for guarded sections and removes them before returning the {@link SortedSet},
	 * this means if an empty {@link SortedSet} was removed nothing can be formatted,
	 * because all ranges in the {@code changedElements} are in guarded sections.
	 *
	 * @param code the current unformatted content of the {@link document}
	 *
	 * @return A {@link SortedSet} within ranges as {@link Pair}s of {@link Integer}s
	 *         which describe the start and end offsets which can be formatted or an empty
	 *         {@link SortedSet} if nothing of the {@code changedElements} can be formatted
	 */
	@NonNull
	protected SortedSet<Pair<Integer, Integer>> getFormatableSections(String code) {
		SortedSet<Pair<Integer, Integer>> regions = changedElements;
		if (CollectionUtils.isEmpty(changedElements)) {
			regions = new TreeSet<>();

			regions.add(Pair.of(0, code.length() - 1));
		}

		GuardedSectionManager guards = GuardedSectionManager.getInstance(document);
		if (guards != null) {
			SortedSet<Pair<Integer, Integer>> nonGuardedSections = new TreeSet<>();
			Iterable<GuardedSection> guardedSections = guards.getGuardedSections();

			if (log.isLoggable(logLevel)) {
				{
					StringBuilder sb = new StringBuilder();
					regions.stream().forEach(section -> sb.append(section.getLeft()).append("/").append(section.getRight()).append(" "));
					log.log(logLevel, "Formatting sections before guards: {0}", sb.toString().trim());
				}

				{
					StringBuilder sb = new StringBuilder();
					guardedSections.forEach(guard -> sb.append(guard.getStartPosition().getOffset()).append("/").append(guard.getEndPosition().getOffset()).append(" "));
					log.log(logLevel, "Guarded sections: {0}", sb.toString().trim());
				}
			}

			for (Pair<Integer, Integer> changedElement : regions) {
				nonGuardedSections.addAll(avoidGuardedSection(changedElement, guardedSections));
			}

			regions = nonGuardedSections;
		}

		if (log.isLoggable(logLevel)) {
			StringBuilder sb = new StringBuilder();
			regions.stream().forEach(section -> sb.append(section.getLeft()).append("/").append(section.getRight()).append(" "));
			log.log(logLevel, "Formatting sections: {0}", sb.toString().trim());
		}

		return regions;
	}

	/**
	 * Checks if a given {@code section} interferes with the given {@code guardedSections}
	 * and if so splits the given {@code section} into multiple sections and returns them
	 * as a {@link SortedSet}.
	 *
	 * @param section the section that should be checked
	 * @param guardedSections the guarded sections of the {@code document}
	 *
	 * @return A {@link SortedSet} containing the splitted sections or just the initial
	 *         {@code section} itself if there was no interference with the given
	 *         {@code guardedSections}
	 */
	protected SortedSet<Pair<Integer, Integer>> avoidGuardedSection(Pair<Integer, Integer> section, Iterable<GuardedSection> guardedSections) {
		SortedSet<Pair<Integer, Integer>> ret = new TreeSet<>();

		MutableInt start = new MutableInt(section.getLeft());
		MutableInt end = new MutableInt(section.getRight());

		if (guardedSections != null) {
			try {
				guardedSections.forEach(guardedSection -> {
					if (start.getValue() >= guardedSection.getStartPosition().getOffset() && start.getValue() <= guardedSection.getEndPosition().getOffset()) {
						if (end.getValue() > guardedSection.getEndPosition().getOffset()) {
							start.setValue(guardedSection.getEndPosition().getOffset());
						} else {
							start.setValue(-1);
							end.setValue(-1);

							throw new BreakException();
						}
					} else if (end.getValue() >= guardedSection.getStartPosition().getOffset() && end.getValue() <= guardedSection.getEndPosition().getOffset()) {
						if (start.getValue() < guardedSection.getStartPosition().getOffset()) {
							end.setValue(guardedSection.getStartPosition().getOffset() - 1);
						} else {
							start.setValue(-1);
							end.setValue(-1);

							throw new BreakException();
						}
					} else if (start.getValue() < guardedSection.getStartPosition().getOffset() && end.getValue() > guardedSection.getEndPosition().getOffset()) {
						ret.add(Pair.of(start.getValue(), guardedSection.getStartPosition().getOffset() - 1));

						start.setValue(guardedSection.getEndPosition().getOffset());
					}
				});
			} catch (BreakException ex) {
				// found no better solution to break a forEach
			}
		}

		if (start.getValue() > -1 && end.getValue() > -1) {
			ret.add(Pair.of(start.getValue(), end.getValue()));
		}

		return ret;
	}

	/**
	 * {@link RuntimeException} which is used as a {@code break} condition inside
	 * a {@link Iterable#forEach(java.util.function.Consumer)}.
	 */
	protected static class BreakException extends RuntimeException {
		private static final long serialVersionUID = 1L;
	}
}