1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Eclipse Public License, Version 1.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.eclipse.org/org/documents/epl-v10.php 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.ide.eclipse.adt.internal.editors; 17 18 import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_CONTENT; 19 import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_EMPTY_TAG_CLOSE; 20 import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_END_TAG_OPEN; 21 import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_CLOSE; 22 import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_NAME; 23 import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_OPEN; 24 25 import com.android.ide.eclipse.adt.AdtPlugin; 26 import com.android.ide.eclipse.adt.AdtUtils; 27 import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlFormatPreferences; 28 import com.android.utils.Pair; 29 30 import org.eclipse.jface.text.BadLocationException; 31 import org.eclipse.jface.text.DocumentCommand; 32 import org.eclipse.jface.text.IAutoEditStrategy; 33 import org.eclipse.jface.text.IDocument; 34 import org.eclipse.jface.text.IRegion; 35 import org.eclipse.jface.text.TextUtilities; 36 import org.eclipse.ui.texteditor.ITextEditor; 37 import org.eclipse.ui.texteditor.ITextEditorExtension3; 38 import org.eclipse.wst.sse.core.StructuredModelManager; 39 import org.eclipse.wst.sse.core.internal.provisional.IModelManager; 40 import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; 41 import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; 42 import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; 43 import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; 44 import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList; 45 46 /** 47 * Edit strategy for Android XML files. It attempts a number of edit 48 * enhancements: 49 * <ul> 50 * <li> Auto indentation. The default XML indentation scheme is to just copy the 51 * indentation of the previous line. This edit strategy improves on that situation 52 * by considering the tag and bracket balance on the current line and using it 53 * to determine whether the next line should be indented or use the same 54 * indentation as the parent, or even the indentation of an earlier line 55 * (when for example the current line closes an element which was started on an 56 * earlier line.) 57 * <li> Newline handling. In addition to indenting, it can also adjust the following text 58 * appropriately when a newline is inserted. For example, it will reformat 59 * the following (where | represents the caret position): 60 * <pre> 61 * {@code <item name="a">|</item>} 62 * </pre> 63 * into 64 * <pre> 65 * {@code <item name="a">} 66 * | 67 * {@code </item>} 68 * </pre> 69 * </ul> 70 * In the future we might consider other editing enhancements here as well, such as 71 * refining the comment handling, or reindenting when you type the / of a closing tag, 72 * or even making the bracket matcher more resilient. 73 */ 74 @SuppressWarnings("restriction") // XML model 75 public class AndroidXmlAutoEditStrategy implements IAutoEditStrategy { 76 77 @Override 78 public void customizeDocumentCommand(IDocument document, DocumentCommand c) { 79 if (!isSmartInsertMode()) { 80 return; 81 } 82 83 if (!(document instanceof IStructuredDocument)) { 84 // This shouldn't happen unless this strategy is used on an invalid document 85 return; 86 } 87 IStructuredDocument doc = (IStructuredDocument) document; 88 89 // Handle newlines/indentation 90 if (c.length == 0 && c.text != null 91 && TextUtilities.endsWith(doc.getLegalLineDelimiters(), c.text) != -1) { 92 93 IModelManager modelManager = StructuredModelManager.getModelManager(); 94 IStructuredModel model = modelManager.getModelForRead(doc); 95 if (model != null) { 96 try { 97 final int offset = c.offset; 98 int lineStart = findLineStart(doc, offset); 99 int textStart = findTextStart(doc, lineStart, offset); 100 101 IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(textStart); 102 if (region != null && region.getType().equals(XML_TAG_NAME)) { 103 Pair<Integer,Integer> balance = getBalance(doc, textStart, offset); 104 int tagBalance = balance.getFirst(); 105 int bracketBalance = balance.getSecond(); 106 107 String lineIndent = ""; //$NON-NLS-1$ 108 if (textStart > lineStart) { 109 lineIndent = doc.get(lineStart, textStart - lineStart); 110 } 111 112 // We only care if tag or bracket balance is greater than 0; 113 // we never *dedent* on negative balances 114 boolean addIndent = false; 115 if (bracketBalance < 0) { 116 // Handle 117 // <foo 118 // ></foo>^ 119 // and 120 // <foo 121 // />^ 122 ITextRegion left = getRegionAt(doc, offset, true /*biasLeft*/); 123 if (left != null 124 && (left.getType().equals(XML_TAG_CLOSE) 125 || left.getType().equals(XML_EMPTY_TAG_CLOSE))) { 126 127 // Find the corresponding open tag... 128 // The org.eclipse.wst.xml.ui.gotoMatchingTag frequently 129 // doesn't work, it just says "No matching brace found" 130 // (or I would use that here). 131 132 int targetBalance = 0; 133 ITextRegion right = getRegionAt(doc, offset, false /*biasLeft*/); 134 if (right != null && right.getType().equals(XML_END_TAG_OPEN)) { 135 targetBalance = -1; 136 } 137 int openTag = AndroidXmlCharacterMatcher.findTagBackwards(doc, 138 offset, targetBalance); 139 if (openTag != -1) { 140 // Look up the indentation of the given line 141 lineIndent = AndroidXmlEditor.getIndentAtOffset(doc, openTag); 142 } 143 } 144 } else if (tagBalance > 0 || bracketBalance > 0) { 145 // Add indentation 146 addIndent = true; 147 } 148 149 StringBuilder sb = new StringBuilder(c.text); 150 sb.append(lineIndent); 151 String oneIndentUnit = EclipseXmlFormatPreferences.create().getOneIndentUnit(); 152 if (addIndent) { 153 sb.append(oneIndentUnit); 154 } 155 156 // Handle 157 // <foo>^</foo> 158 // turning into 159 // <foo> 160 // ^ 161 // </foo> 162 ITextRegion left = getRegionAt(doc, offset, true /*biasLeft*/); 163 ITextRegion right = getRegionAt(doc, offset, false /*biasLeft*/); 164 if (left != null && right != null 165 && left.getType().equals(XML_TAG_CLOSE) 166 && right.getType().equals(XML_END_TAG_OPEN)) { 167 // Move end tag 168 if (tagBalance > 0 && bracketBalance < 0) { 169 sb.append(oneIndentUnit); 170 } 171 c.caretOffset = offset + sb.length(); 172 c.shiftsCaret = false; 173 sb.append(TextUtilities.getDefaultLineDelimiter(doc)); 174 sb.append(lineIndent); 175 } 176 c.text = sb.toString(); 177 } else if (region != null && region.getType().equals(XML_CONTENT)) { 178 // Indenting in text content. If you're in the middle of editing 179 // text, just copy the current line indentation. 180 // However, if you're editing in leading whitespace (e.g. you press 181 // newline on a blank line following say an element) then figure 182 // out the indentation as if the newline had been pressed at the 183 // end of the element, and insert that amount of indentation. 184 // In this case we need to also make sure to subtract any existing 185 // whitespace on the current line such that if we have 186 // 187 // <foo> 188 // ^ <bar/> 189 // </foo> 190 // 191 // you end up with 192 // 193 // <foo> 194 // 195 // ^<bar/> 196 // </foo> 197 // 198 String text = region.getText(); 199 int regionStart = region.getStartOffset(); 200 int delta = offset - regionStart; 201 boolean inWhitespacePrefix = true; 202 for (int i = 0, n = Math.min(delta, text.length()); i < n; i++) { 203 char ch = text.charAt(i); 204 if (!Character.isWhitespace(ch)) { 205 inWhitespacePrefix = false; 206 break; 207 } 208 } 209 if (inWhitespacePrefix) { 210 IStructuredDocumentRegion previous = region.getPrevious(); 211 if (previous != null && previous.getType() == XML_TAG_NAME) { 212 ITextRegionList subRegions = previous.getRegions(); 213 ITextRegion last = subRegions.get(subRegions.size() - 1); 214 if (last.getType() == XML_TAG_CLOSE || 215 last.getType() == XML_EMPTY_TAG_CLOSE) { 216 // See if the last tag was a closing tag 217 boolean wasClose = last.getType() == XML_EMPTY_TAG_CLOSE; 218 if (!wasClose) { 219 // Search backwards to see if the XML_TAG_CLOSE 220 // is the end of an </endtag> 221 for (int i = subRegions.size() - 2; i >= 0; i--) { 222 ITextRegion current = subRegions.get(i); 223 String type = current.getType(); 224 if (type != XML_TAG_NAME) { 225 wasClose = type == XML_END_TAG_OPEN; 226 break; 227 } 228 } 229 } 230 231 int begin = AndroidXmlCharacterMatcher.findTagBackwards(doc, 232 previous.getStartOffset() + last.getStart(), 0); 233 int prevLineStart = findLineStart(doc, begin); 234 int prevTextStart = findTextStart(doc, prevLineStart, begin); 235 236 String lineIndent = ""; //$NON-NLS-1$ 237 if (prevTextStart > prevLineStart) { 238 lineIndent = doc.get(prevLineStart, 239 prevTextStart - prevLineStart); 240 } 241 StringBuilder sb = new StringBuilder(c.text); 242 sb.append(lineIndent); 243 244 // See if there is whitespace on the insert line that 245 // we should also remove 246 for (int i = delta, n = text.length(); i < n; i++) { 247 char ch = text.charAt(i); 248 if (ch == ' ') { 249 c.length++; 250 } else { 251 break; 252 } 253 } 254 255 boolean addIndent = (last.getType() == XML_TAG_CLOSE) 256 && !wasClose; 257 258 // Is there just whitespace left of this text tag 259 // until we reach an end tag? 260 boolean whitespaceToEndTag = true; 261 for (int i = delta; i < text.length(); i++) { 262 char ch = text.charAt(i); 263 if (ch == '\n' || !Character.isWhitespace(ch)) { 264 whitespaceToEndTag = false; 265 break; 266 } 267 } 268 if (whitespaceToEndTag) { 269 IStructuredDocumentRegion next = region.getNext(); 270 if (next != null && next.getType() == XML_TAG_NAME) { 271 String nextType = next.getRegions().get(0).getType(); 272 if (nextType == XML_END_TAG_OPEN) { 273 addIndent = false; 274 } 275 } 276 } 277 278 if (addIndent) { 279 sb.append(EclipseXmlFormatPreferences.create() 280 .getOneIndentUnit()); 281 } 282 c.text = sb.toString(); 283 284 return; 285 } 286 } 287 } 288 copyPreviousLineIndentation(doc, c); 289 } else { 290 copyPreviousLineIndentation(doc, c); 291 } 292 } catch (BadLocationException e) { 293 AdtPlugin.log(e, null); 294 } finally { 295 model.releaseFromRead(); 296 } 297 } 298 } 299 } 300 301 /** 302 * Returns the offset of the start of the line (which might be whitespace) 303 * 304 * @param document the document 305 * @param offset an offset for a character anywhere on the line 306 * @return the offset of the first character on the line 307 * @throws BadLocationException if the offset is invalid 308 */ 309 public static int findLineStart(IDocument document, int offset) throws BadLocationException { 310 offset = Math.max(0, Math.min(offset, document.getLength() - 1)); 311 IRegion info = document.getLineInformationOfOffset(offset); 312 return info.getOffset(); 313 } 314 315 /** 316 * Finds the first non-whitespace character on the given line 317 * 318 * @param document the document to search 319 * @param lineStart the offset of the beginning of the line 320 * @param lineEnd the offset of the end of the line, or the maximum position on the 321 * line to search 322 * @return the offset of the first non whitespace character, or the maximum position, 323 * whichever is smallest 324 * @throws BadLocationException if the offsets are invalid 325 */ 326 public static int findTextStart(IDocument document, int lineStart, int lineEnd) 327 throws BadLocationException { 328 for (int offset = lineStart; offset < lineEnd; offset++) { 329 char c = document.getChar(offset); 330 if (c != ' ' && c != '\t') { 331 return offset; 332 } 333 } 334 335 return lineEnd; 336 } 337 338 /** 339 * Indent the new line the same way as the current line. 340 * 341 * @param doc the document to indent in 342 * @param command the document command to customize 343 * @throws BadLocationException if the offsets are invalid 344 */ 345 private void copyPreviousLineIndentation(IDocument doc, DocumentCommand command) 346 throws BadLocationException { 347 348 if (command.offset == -1 || doc.getLength() == 0) { 349 return; 350 } 351 352 int lineStart = findLineStart(doc, command.offset); 353 int textStart = findTextStart(doc, lineStart, command.offset); 354 355 StringBuilder sb = new StringBuilder(command.text); 356 if (textStart > lineStart) { 357 sb.append(doc.get(lineStart, textStart - lineStart)); 358 } 359 360 command.text = sb.toString(); 361 } 362 363 364 /** 365 * Returns the subregion at the given offset, with a bias to the left or a bias to the 366 * right. In other words, if | represents the caret position, in the XML 367 * {@code <foo>|</bar>} then the subregion with bias left is the closing {@code >} and 368 * the subregion with bias right is the opening {@code </}. 369 * 370 * @param doc the document 371 * @param offset the offset in the document 372 * @param biasLeft whether we should look at the token on the left or on the right 373 * @return the subregion at the given offset, or null if not found 374 */ 375 private static ITextRegion getRegionAt(IStructuredDocument doc, int offset, 376 boolean biasLeft) { 377 if (biasLeft) { 378 offset--; 379 } 380 IStructuredDocumentRegion region = 381 doc.getRegionAtCharacterOffset(offset); 382 if (region != null) { 383 return region.getRegionAtCharacterOffset(offset); 384 } 385 386 return null; 387 } 388 389 /** 390 * Returns a pair of (tag-balance,bracket-balance) for the range textStart to offset. 391 * 392 * @param doc the document 393 * @param start the offset of the starting character (inclusive) 394 * @param end the offset of the ending character (exclusive) 395 * @return the balance of tags and brackets 396 */ 397 private static Pair<Integer, Integer> getBalance(IStructuredDocument doc, 398 int start, int end) { 399 // Balance of open and closing tags 400 // <foo></foo> has tagBalance = 0, <foo> has tagBalance = 1 401 int tagBalance = 0; 402 // Balance of open and closing brackets 403 // <foo attr1="value1"> has bracketBalance = 1, <foo has bracketBalance = 1 404 int bracketBalance = 0; 405 IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(start); 406 407 if (region != null) { 408 boolean inOpenTag = true; 409 while (region != null && region.getStartOffset() < end) { 410 int regionStart = region.getStartOffset(); 411 ITextRegionList subRegions = region.getRegions(); 412 for (int i = 0, n = subRegions.size(); i < n; i++) { 413 ITextRegion subRegion = subRegions.get(i); 414 int subRegionStart = regionStart + subRegion.getStart(); 415 int subRegionEnd = regionStart + subRegion.getEnd(); 416 if (subRegionEnd < start || subRegionStart >= end) { 417 continue; 418 } 419 String type = subRegion.getType(); 420 421 if (XML_TAG_OPEN.equals(type)) { 422 bracketBalance++; 423 inOpenTag = true; 424 } else if (XML_TAG_CLOSE.equals(type)) { 425 bracketBalance--; 426 if (inOpenTag) { 427 tagBalance++; 428 } else { 429 tagBalance--; 430 } 431 } else if (XML_END_TAG_OPEN.equals(type)) { 432 bracketBalance++; 433 inOpenTag = false; 434 } else if (XML_EMPTY_TAG_CLOSE.equals(type)) { 435 bracketBalance--; 436 } 437 } 438 439 region = region.getNext(); 440 } 441 } 442 443 return Pair.of(tagBalance, bracketBalance); 444 } 445 446 /** 447 * Determine if we're in smart insert mode (if so, don't do any edit magic) 448 * 449 * @return true if the editor is in smart mode (or if it's an unknown editor type) 450 */ 451 private static boolean isSmartInsertMode() { 452 ITextEditor textEditor = AdtUtils.getActiveTextEditor(); 453 if (textEditor instanceof ITextEditorExtension3) { 454 ITextEditorExtension3 editor = (ITextEditorExtension3) textEditor; 455 return editor.getInsertMode() == ITextEditorExtension3.SMART_INSERT; 456 } 457 458 return true; 459 } 460 } 461