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;
}
}