View Javadoc
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  
12  import java.io.IOException;
13  import java.io.StringReader;
14  import java.util.SortedSet;
15  import java.util.TreeSet;
16  import java.util.logging.Level;
17  import java.util.logging.Logger;
18  
19  import javax.swing.text.BadLocationException;
20  import javax.swing.text.StyledDocument;
21  
22  import org.apache.commons.collections4.CollectionUtils;
23  import org.apache.commons.lang3.mutable.MutableInt;
24  import org.apache.commons.lang3.tuple.Pair;
25  import org.netbeans.api.annotations.common.NonNull;
26  import org.netbeans.api.diff.Difference;
27  import org.netbeans.api.editor.guards.GuardedSection;
28  import org.netbeans.api.editor.guards.GuardedSectionManager;
29  import org.netbeans.lib.editor.util.swing.DocumentUtilities;
30  import org.openide.text.NbDocument;
31  
32  import de.funfried.netbeans.plugins.external.formatter.ui.editor.diff.Diff;
33  
34  /**
35   * Abstract base implementation of a {@link FormatJob} which is called by the
36   * {@link FormatterService} where this implementation belongs to.
37   *
38   * @author bahlef
39   */
40  public abstract class AbstractFormatJob implements FormatJob {
41  	/** {@link Logger} of this class. */
42  	private static final Logger log = Logger.getLogger(AbstractFormatJob.class.getName());
43  
44  	/** Log {@link Level} for fast switching while investigating issues. */
45  	private static final Level logLevel = Level.FINER;
46  
47  	/** {@link SortedSet} containing document offset ranges which should be formatted. */
48  	protected final SortedSet<Pair<Integer, Integer>> changedElements;
49  
50  	/** The {@link StyledDocument} from which the content should be formatted. */
51  	protected final StyledDocument document;
52  
53  	/**
54  	 * Constructor which has to be used by subclasses.
55  	 *
56  	 * @param document the {@link StyledDocument} from which the content should be formatted
57  	 * @param changedElements {@link SortedSet} containing document offset ranges which should be formatted or {@code null} to format the whole document
58  	 */
59  	protected AbstractFormatJob(StyledDocument document, SortedSet<Pair<Integer, Integer>> changedElements) {
60  		this.document = document;
61  		this.changedElements = changedElements;
62  	}
63  
64  	/**
65  	 * Applies the given {@code formattedContent} to the {@code document}.
66  	 *
67  	 * @param code the previous (unformatted) content
68  	 * @param formattedContent the formatted code
69  	 *
70  	 * @return {@code true} if and only if the given {@code formattedContent} was set to
71  	 *         the {@code document}, if due to any circumstances (old code equals formatted code,
72  	 *         thrown exceptions, ...) the {@code formattedContent} wasn't applied {@code false}
73  	 *         is returned
74  	 *
75  	 * @throws BadLocationException if there is an issue while applying the formatted code
76  	 */
77  	protected boolean setFormattedCode(String code, String formattedContent) throws BadLocationException {
78  		// quick check for changed
79  		if (formattedContent != null && /* does not support changes of EOL */ !formattedContent.equals(code)) {
80  			try (StringReader original = new StringReader(code);
81  					StringReader formatted = new StringReader(formattedContent)) {
82  				Difference[] differences = Diff.diff(original, formatted);
83  				if (differences != null && differences.length != 0) {
84  					if (log.isLoggable(logLevel)) {
85  						log.log(logLevel, "Unformatted: ''{0}''", code);
86  						log.log(logLevel, "Formatted: ''{0}''", formattedContent);
87  					}
88  
89  					for (Difference d : differences) {
90  						int startLine = d.getSecondStart();
91  
92  						switch (d.getType()) {
93  							case Difference.ADD: {
94  								int start = NbDocument.findLineOffset(document, startLine - 1);
95  								String addText = d.getSecondText();
96  
97  								if (log.isLoggable(logLevel)) {
98  									log.log(logLevel, "ADD: {0} / Line {1}: {2}", new Object[] { start, startLine, addText });
99  								}
100 
101 								document.insertString(start, addText, null);
102 
103 								break;
104 							}
105 							case Difference.CHANGE: {
106 								int start = NbDocument.findLineOffset(document, startLine - 1);
107 								String removeText = d.getFirstText();
108 								int length = removeText.length();
109 
110 								// if the document consists of only 1 line without a trailing line-break
111 								// then LENGTH would exceed the document and yield an exception
112 								length = Math.min(length, document.getLength());
113 
114 								String addText = d.getSecondText();
115 
116 								if (log.isLoggable(logLevel)) {
117 									log.log(logLevel, "CHANGE: {0} - {1} / Line {2}: ''{3}'' <= ''{4}''", new Object[] { start, length, startLine, addText, removeText });
118 								}
119 
120 								document.remove(start, length);
121 								document.insertString(start, addText, null);
122 
123 								break;
124 							}
125 							case Difference.DELETE: {
126 								int start = NbDocument.findLineOffset(document, startLine);
127 								String removeText = d.getFirstText();
128 								int length = removeText.length();
129 
130 								// if the document consists of only 1 line without a trailing line-break
131 								// then LENGTH would exceed the document and yield an exception
132 								length = Math.min(length, document.getLength());
133 
134 								if (log.isLoggable(logLevel)) {
135 									log.log(logLevel, "DELETE: {0} - {1} / Line {2}: ''{3}''", new Object[] { start, length, startLine, removeText });
136 								}
137 
138 								document.remove(start, length);
139 
140 								break;
141 							}
142 						}
143 					}
144 
145 					return true;
146 				}
147 			} catch (IOException ex) {
148 				log.log(Level.WARNING, "Could not create diff", ex);
149 			}
150 		}
151 
152 		return false;
153 	}
154 
155 	/**
156 	 * Returns the content of the {@code document}.
157 	 *
158 	 * @return The content of the {@code document}
159 	 */
160 	protected String getCode() {
161 		try {
162 			// returns the actual content of the document
163 			return document.getText(0, document.getLength());
164 		} catch (BadLocationException ex) {
165 			log.log(Level.WARNING, "Could not fetch text, falling back to utility method", ex);
166 
167 			// returns only the trimmed content of the document
168 			return DocumentUtilities.getText(document).toString();
169 		}
170 	}
171 
172 	/**
173 	 * Returns a {@link SortedSet} within ranges as {@link Pair}s of {@link Integer}s
174 	 * which describe the start and end offsets that can be formatted, it automatically
175 	 * checks for guarded sections and removes them before returning the {@link SortedSet},
176 	 * this means if an empty {@link SortedSet} was removed nothing can be formatted,
177 	 * because all ranges in the {@code changedElements} are in guarded sections.
178 	 *
179 	 * @param code the current unformatted content of the {@link document}
180 	 *
181 	 * @return A {@link SortedSet} within ranges as {@link Pair}s of {@link Integer}s
182 	 *         which describe the start and end offsets which can be formatted or an empty
183 	 *         {@link SortedSet} if nothing of the {@code changedElements} can be formatted
184 	 */
185 	@NonNull
186 	protected SortedSet<Pair<Integer, Integer>> getFormatableSections(String code) {
187 		SortedSet<Pair<Integer, Integer>> regions = changedElements;
188 		if (CollectionUtils.isEmpty(changedElements)) {
189 			regions = new TreeSet<>();
190 
191 			regions.add(Pair.of(0, code.length() - 1));
192 		}
193 
194 		GuardedSectionManager guards = GuardedSectionManager.getInstance(document);
195 		if (guards != null) {
196 			SortedSet<Pair<Integer, Integer>> nonGuardedSections = new TreeSet<>();
197 			Iterable<GuardedSection> guardedSections = guards.getGuardedSections();
198 
199 			if (log.isLoggable(logLevel)) {
200 				{
201 					StringBuilder sb = new StringBuilder();
202 					regions.stream().forEach(section -> sb.append(section.getLeft()).append("/").append(section.getRight()).append(" "));
203 					log.log(logLevel, "Formatting sections before guards: {0}", sb.toString().trim());
204 				}
205 
206 				{
207 					StringBuilder sb = new StringBuilder();
208 					guardedSections.forEach(guard -> sb.append(guard.getStartPosition().getOffset()).append("/").append(guard.getEndPosition().getOffset()).append(" "));
209 					log.log(logLevel, "Guarded sections: {0}", sb.toString().trim());
210 				}
211 			}
212 
213 			for (Pair<Integer, Integer> changedElement : regions) {
214 				nonGuardedSections.addAll(avoidGuardedSection(changedElement, guardedSections));
215 			}
216 
217 			regions = nonGuardedSections;
218 		}
219 
220 		if (log.isLoggable(logLevel)) {
221 			StringBuilder sb = new StringBuilder();
222 			regions.stream().forEach(section -> sb.append(section.getLeft()).append("/").append(section.getRight()).append(" "));
223 			log.log(logLevel, "Formatting sections: {0}", sb.toString().trim());
224 		}
225 
226 		return regions;
227 	}
228 
229 	/**
230 	 * Checks if a given {@code section} interferes with the given {@code guardedSections}
231 	 * and if so splits the given {@code section} into multiple sections and returns them
232 	 * as a {@link SortedSet}.
233 	 *
234 	 * @param section the section that should be checked
235 	 * @param guardedSections the guarded sections of the {@code document}
236 	 *
237 	 * @return A {@link SortedSet} containing the splitted sections or just the initial
238 	 *         {@code section} itself if there was no interference with the given
239 	 *         {@code guardedSections}
240 	 */
241 	protected SortedSet<Pair<Integer, Integer>> avoidGuardedSection(Pair<Integer, Integer> section, Iterable<GuardedSection> guardedSections) {
242 		SortedSet<Pair<Integer, Integer>> ret = new TreeSet<>();
243 
244 		MutableInt start = new MutableInt(section.getLeft());
245 		MutableInt end = new MutableInt(section.getRight());
246 
247 		if (guardedSections != null) {
248 			try {
249 				guardedSections.forEach(guardedSection -> {
250 					if (start.getValue() >= guardedSection.getStartPosition().getOffset() && start.getValue() <= guardedSection.getEndPosition().getOffset()) {
251 						if (end.getValue() > guardedSection.getEndPosition().getOffset()) {
252 							start.setValue(guardedSection.getEndPosition().getOffset());
253 						} else {
254 							start.setValue(-1);
255 							end.setValue(-1);
256 
257 							throw new BreakException();
258 						}
259 					} else if (end.getValue() >= guardedSection.getStartPosition().getOffset() && end.getValue() <= guardedSection.getEndPosition().getOffset()) {
260 						if (start.getValue() < guardedSection.getStartPosition().getOffset()) {
261 							end.setValue(guardedSection.getStartPosition().getOffset() - 1);
262 						} else {
263 							start.setValue(-1);
264 							end.setValue(-1);
265 
266 							throw new BreakException();
267 						}
268 					} else if (start.getValue() < guardedSection.getStartPosition().getOffset() && end.getValue() > guardedSection.getEndPosition().getOffset()) {
269 						ret.add(Pair.of(start.getValue(), guardedSection.getStartPosition().getOffset() - 1));
270 
271 						start.setValue(guardedSection.getEndPosition().getOffset());
272 					}
273 				});
274 			} catch (BreakException ex) {
275 				// found no better solution to break a forEach
276 			}
277 		}
278 
279 		if (start.getValue() > -1 && end.getValue() > -1) {
280 			ret.add(Pair.of(start.getValue(), end.getValue()));
281 		}
282 
283 		return ret;
284 	}
285 
286 	/**
287 	 * {@link RuntimeException} which is used as a {@code break} condition inside
288 	 * a {@link Iterable#forEach(java.util.function.Consumer)}.
289 	 */
290 	protected static class BreakException extends RuntimeException {
291 		private static final long serialVersionUID = 1L;
292 	}
293 }