AbstractFormatJob.java

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

  11. import java.io.IOException;
  12. import java.io.StringReader;
  13. import java.util.SortedSet;
  14. import java.util.TreeSet;
  15. import java.util.logging.Level;
  16. import java.util.logging.Logger;

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

  19. import org.apache.commons.collections4.CollectionUtils;
  20. import org.apache.commons.lang3.mutable.MutableInt;
  21. import org.apache.commons.lang3.tuple.Pair;
  22. import org.netbeans.api.annotations.common.NonNull;
  23. import org.netbeans.api.diff.Difference;
  24. import org.netbeans.api.editor.guards.GuardedSection;
  25. import org.netbeans.api.editor.guards.GuardedSectionManager;
  26. import org.netbeans.lib.editor.util.swing.DocumentUtilities;
  27. import org.openide.text.NbDocument;

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

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

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

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

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

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

  54.     /**
  55.      * Applies the given {@code formattedContent} to the {@code document}.
  56.      *
  57.      * @param code the previous (unformatted) content
  58.      * @param formattedContent the formatted code
  59.      *
  60.      * @return {@code true} if and only if the given {@code formattedContent} was set to
  61.      *         the {@code document}, if due to any circumstances (old code equals formatted code,
  62.      *         thrown exceptions, ...) the {@code formattedContent} wasn't applied {@code false}
  63.      *         is returned
  64.      *
  65.      * @throws BadLocationException if there is an issue while applying the formatted code
  66.      */
  67.     protected boolean setFormattedCode(String code, String formattedContent) throws BadLocationException {
  68.         // quick check for changed
  69.         if (formattedContent != null && /* does not support changes of EOL */ !formattedContent.equals(code)) {
  70.             try (StringReader original = new StringReader(code);
  71.                     StringReader formatted = new StringReader(formattedContent)) {
  72.                 Difference[] differences = Diff.diff(original, formatted);
  73.                 if (differences != null && differences.length != 0) {
  74.                     if (log.isLoggable(logLevel)) {
  75.                         log.log(logLevel, "Unformatted: ''{0}''", code);
  76.                         log.log(logLevel, "Formatted: ''{0}''", formattedContent);
  77.                     }

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

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

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

  87.                                 document.insertString(start, addText, null);

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

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

  97.                                 String addText = d.getSecondText();

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

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

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

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

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

  115.                                 document.remove(start, length);

  116.                                 break;
  117.                             }
  118.                         }
  119.                     }

  120.                     return true;
  121.                 }
  122.             } catch (IOException ex) {
  123.                 log.log(Level.WARNING, "Could not create diff", ex);
  124.             }
  125.         }

  126.         return false;
  127.     }

  128.     /**
  129.      * Returns the content of the {@code document}.
  130.      *
  131.      * @return The content of the {@code document}
  132.      */
  133.     protected String getCode() {
  134.         try {
  135.             // returns the actual content of the document
  136.             return document.getText(0, document.getLength());
  137.         } catch (BadLocationException ex) {
  138.             log.log(Level.WARNING, "Could not fetch text, falling back to utility method", ex);

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

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

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

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

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

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

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

  182.             regions = nonGuardedSections;
  183.         }

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

  189.         return regions;
  190.     }

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

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

  207.         if (guardedSections != null) {
  208.             try {
  209.                 guardedSections.forEach(guardedSection -> {
  210.                     if (start.getValue() >= guardedSection.getStartPosition().getOffset() && start.getValue() <= guardedSection.getEndPosition().getOffset()) {
  211.                         if (end.getValue() > guardedSection.getEndPosition().getOffset()) {
  212.                             start.setValue(guardedSection.getEndPosition().getOffset());
  213.                         } else {
  214.                             start.setValue(-1);
  215.                             end.setValue(-1);

  216.                             throw new BreakException();
  217.                         }
  218.                     } else if (end.getValue() >= guardedSection.getStartPosition().getOffset() && end.getValue() <= guardedSection.getEndPosition().getOffset()) {
  219.                         if (start.getValue() < guardedSection.getStartPosition().getOffset()) {
  220.                             end.setValue(guardedSection.getStartPosition().getOffset() - 1);
  221.                         } else {
  222.                             start.setValue(-1);
  223.                             end.setValue(-1);

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

  228.                         start.setValue(guardedSection.getEndPosition().getOffset());
  229.                     }
  230.                 });
  231.             } catch (BreakException ex) {
  232.                 // found no better solution to break a forEach
  233.             }
  234.         }

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

  238.         return ret;
  239.     }

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