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.XmlFormatPreferences; 28 import com.android.util.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 public void customizeDocumentCommand(IDocument document, DocumentCommand c) { 78 if (!isSmartInsertMode()) { 79 return; 80 } 81 82 if (!(document instanceof IStructuredDocument)) { 83 // This shouldn't happen unless this strategy is used on an invalid document 84 return; 85 } 86 IStructuredDocument doc = (IStructuredDocument) document; 87 88 // Handle newlines/indentation 89 if (c.length == 0 && c.text != null 90 && TextUtilities.endsWith(doc.getLegalLineDelimiters(), c.text) != -1) { 91 92 IModelManager modelManager = StructuredModelManager.getModelManager(); 93 IStructuredModel model = modelManager.getModelForRead(doc); 94 if (model != null) { 95 try { 96 final int offset = c.offset; 97 int lineStart = findLineStart(doc, offset); 98 int textStart = findTextStart(doc, lineStart, offset); 99 100 IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(textStart); 101 if (region != null && region.getType().equals(XML_TAG_NAME)) { 102 Pair<Integer,Integer> balance = getBalance(doc, textStart, offset); 103 int tagBalance = balance.getFirst(); 104 int bracketBalance = balance.getSecond(); 105 106 String lineIndent = ""; //$NON-NLS-1$ 107 if (textStart > lineStart) { 108 lineIndent = doc.get(lineStart, textStart - lineStart); 109 } 110 111 // We only care if tag or bracket balance is greater than 0; 112 // we never *dedent* on negative balances 113 boolean addIndent = false; 114 if (bracketBalance < 0) { 115 // Handle 116 // <foo 117 // ></foo>^ 118 // and 119 // <foo 120 // />^ 121 ITextRegion left = getRegionAt(doc, offset, true /*biasLeft*/); 122 if (left != null 123 && (left.getType().equals(XML_TAG_CLOSE) 124 || left.getType().equals(XML_EMPTY_TAG_CLOSE))) { 125 126 // Find the corresponding open tag... 127 // The org.eclipse.wst.xml.ui.gotoMatchingTag frequently 128 // doesn't work, it just says "No matching brace found" 129 // (or I would use that here). 130 131 int targetBalance = 0; 132 ITextRegion right = getRegionAt(doc, offset, false /*biasLeft*/); 133 if (right != null && right.getType().equals(XML_END_TAG_OPEN)) { 134 targetBalance = -1; 135 } 136 int openTag = AndroidXmlCharacterMatcher.findTagBackwards(doc, 137 offset, targetBalance); 138 if (openTag != -1) { 139 // Look up the indentation of the given line 140 lineIndent = AndroidXmlEditor.getIndentAtOffset(doc, openTag); 141 } 142 } 143 } else if (tagBalance > 0 || bracketBalance > 0) { 144 // Add indentation 145 addIndent = true; 146 } 147 148 StringBuilder sb = new StringBuilder(c.text); 149 sb.append(lineIndent); 150 String oneIndentUnit = XmlFormatPreferences.create().getOneIndentUnit(); 151 if (addIndent) { 152 sb.append(oneIndentUnit); 153 } 154 155 // Handle 156 // <foo>^</foo> 157 // turning into 158 // <foo> 159 // ^ 160 // </foo> 161 ITextRegion left = getRegionAt(doc, offset, true /*biasLeft*/); 162 ITextRegion right = getRegionAt(doc, offset, false /*biasLeft*/); 163 if (left != null && right != null 164 && left.getType().equals(XML_TAG_CLOSE) 165 && right.getType().equals(XML_END_TAG_OPEN)) { 166 // Move end tag 167 if (tagBalance > 0 && bracketBalance < 0) { 168 sb.append(oneIndentUnit); 169 } 170 c.caretOffset = offset + sb.length(); 171 c.shiftsCaret = false; 172 sb.append(TextUtilities.getDefaultLineDelimiter(doc)); 173 sb.append(lineIndent); 174 } 175 c.text = sb.toString(); 176 } else if (region != null && region.getType().equals(XML_CONTENT)) { 177 // Indenting in text content. If you're in the middle of editing 178 // text, just copy the current line indentation. 179 // However, if you're editing in leading whitespace (e.g. you press 180 // newline on a blank line following say an element) then figure 181 // out the indentation as if the newline had been pressed at the 182 // end of the element, and insert that amount of indentation. 183 // In this case we need to also make sure to subtract any existing 184 // whitespace on the current line such that if we have 185 // 186 // <foo> 187 // ^ <bar/> 188 // </foo> 189 // 190 // you end up with 191 // 192 // <foo> 193 // 194 // ^<bar/> 195 // </foo> 196 // 197 String text = region.getText(); 198 int regionStart = region.getStartOffset(); 199 int delta = offset - regionStart; 200 boolean inWhitespacePrefix = true; 201 for (int i = 0, n = Math.min(delta, text.length()); i < n; i++) { 202 char ch = text.charAt(i); 203 if (!Character.isWhitespace(ch)) { 204 inWhitespacePrefix = false; 205 break; 206 } 207 } 208 if (inWhitespacePrefix) { 209 IStructuredDocumentRegion previous = region.getPrevious(); 210 if (previous != null && previous.getType() == XML_TAG_NAME) { 211 ITextRegionList subRegions = previous.getRegions(); 212 ITextRegion last = subRegions.get(subRegions.size() - 1); 213 if (last.getType() == XML_TAG_CLOSE || 214 last.getType() == XML_EMPTY_TAG_CLOSE) { 215 int begin = AndroidXmlCharacterMatcher.findTagBackwards(doc, 216 previous.getStartOffset() + last.getStart(), 0); 217 int prevLineStart = findLineStart(doc, begin); 218 int prevTextStart = findTextStart(doc, prevLineStart, begin); 219 220 String lineIndent = ""; //$NON-NLS-1$ 221 if (prevTextStart > prevLineStart) { 222 lineIndent = doc.get(prevLineStart, 223 prevTextStart - prevLineStart); 224 } 225 StringBuilder sb = new StringBuilder(c.text); 226 sb.append(lineIndent); 227 String oneIndentUnit = 228 XmlFormatPreferences.create().getOneIndentUnit(); 229 230 // See if there is whitespace on the insert line that 231 // we should also remove 232 for (int i = delta, n = text.length(); i < n; i++) { 233 char ch = text.charAt(i); 234 if (ch == ' ') { 235 c.length++; 236 } else { 237 break; 238 } 239 } 240 boolean onClosingTagLine = false; 241 if (text.indexOf('\n', delta) == -1) { 242 IStructuredDocumentRegion next = region.getNext(); 243 if (next != null && next.getType() == XML_TAG_NAME) { 244 String nextType = next.getRegions().get(0).getType(); 245 if (nextType == XML_END_TAG_OPEN) { 246 onClosingTagLine = true; 247 } 248 } 249 } 250 251 boolean addIndent = (last.getType() == XML_TAG_CLOSE) 252 && !onClosingTagLine; 253 if (addIndent) { 254 sb.append(oneIndentUnit); 255 } 256 c.text = sb.toString(); 257 258 return; 259 } 260 } 261 } 262 copyPreviousLineIndentation(doc, c); 263 } else { 264 copyPreviousLineIndentation(doc, c); 265 } 266 } catch (BadLocationException e) { 267 AdtPlugin.log(e, null); 268 } finally { 269 model.releaseFromRead(); 270 } 271 } 272 } 273 } 274 275 /** 276 * Returns the offset of the start of the line (which might be whitespace) 277 * 278 * @param document the document 279 * @param offset an offset for a character anywhere on the line 280 * @return the offset of the first character on the line 281 * @throws BadLocationException if the offset is invalid 282 */ 283 public static int findLineStart(IDocument document, int offset) throws BadLocationException { 284 offset = Math.max(0, Math.min(offset, document.getLength() - 1)); 285 IRegion info = document.getLineInformationOfOffset(offset); 286 return info.getOffset(); 287 } 288 289 /** 290 * Finds the first non-whitespace character on the given line 291 * 292 * @param document the document to search 293 * @param lineStart the offset of the beginning of the line 294 * @param lineEnd the offset of the end of the line, or the maximum position on the 295 * line to search 296 * @return the offset of the first non whitespace character, or the maximum position, 297 * whichever is smallest 298 * @throws BadLocationException if the offsets are invalid 299 */ 300 public static int findTextStart(IDocument document, int lineStart, int lineEnd) 301 throws BadLocationException { 302 for (int offset = lineStart; offset < lineEnd; offset++) { 303 char c = document.getChar(offset); 304 if (c != ' ' && c != '\t') { 305 return offset; 306 } 307 } 308 309 return lineEnd; 310 } 311 312 /** 313 * Indent the new line the same way as the current line. 314 * 315 * @param doc the document to indent in 316 * @param command the document command to customize 317 * @throws BadLocationException if the offsets are invalid 318 */ 319 private void copyPreviousLineIndentation(IDocument doc, DocumentCommand command) 320 throws BadLocationException { 321 322 if (command.offset == -1 || doc.getLength() == 0) { 323 return; 324 } 325 326 int lineStart = findLineStart(doc, command.offset); 327 int textStart = findTextStart(doc, lineStart, command.offset); 328 329 StringBuilder sb = new StringBuilder(command.text); 330 if (textStart > lineStart) { 331 sb.append(doc.get(lineStart, textStart - lineStart)); 332 } 333 334 command.text = sb.toString(); 335 } 336 337 338 /** 339 * Returns the subregion at the given offset, with a bias to the left or a bias to the 340 * right. In other words, if | represents the caret position, in the XML 341 * {@code <foo>|</bar>} then the subregion with bias left is the closing {@code >} and 342 * the subregion with bias right is the opening {@code </}. 343 * 344 * @param doc the document 345 * @param offset the offset in the document 346 * @param biasLeft whether we should look at the token on the left or on the right 347 * @return the subregion at the given offset, or null if not found 348 */ 349 private static ITextRegion getRegionAt(IStructuredDocument doc, int offset, 350 boolean biasLeft) { 351 if (biasLeft) { 352 offset--; 353 } 354 IStructuredDocumentRegion region = 355 doc.getRegionAtCharacterOffset(offset); 356 if (region != null) { 357 return region.getRegionAtCharacterOffset(offset); 358 } 359 360 return null; 361 } 362 363 /** 364 * Returns a pair of (tag-balance,bracket-balance) for the range textStart to offset. 365 * 366 * @param doc the document 367 * @param start the offset of the starting character (inclusive) 368 * @param end the offset of the ending character (exclusive) 369 * @return the balance of tags and brackets 370 */ 371 private static Pair<Integer, Integer> getBalance(IStructuredDocument doc, 372 int start, int end) { 373 // Balance of open and closing tags 374 // <foo></foo> has tagBalance = 0, <foo> has tagBalance = 1 375 int tagBalance = 0; 376 // Balance of open and closing brackets 377 // <foo attr1="value1"> has bracketBalance = 1, <foo has bracketBalance = 1 378 int bracketBalance = 0; 379 IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(start); 380 381 if (region != null) { 382 boolean inOpenTag = true; 383 while (region != null && region.getStartOffset() < end) { 384 int regionStart = region.getStartOffset(); 385 ITextRegionList subRegions = region.getRegions(); 386 for (int i = 0, n = subRegions.size(); i < n; i++) { 387 ITextRegion subRegion = subRegions.get(i); 388 int subRegionStart = regionStart + subRegion.getStart(); 389 int subRegionEnd = regionStart + subRegion.getEnd(); 390 if (subRegionEnd < start || subRegionStart >= end) { 391 continue; 392 } 393 String type = subRegion.getType(); 394 395 if (XML_TAG_OPEN.equals(type)) { 396 bracketBalance++; 397 inOpenTag = true; 398 } else if (XML_TAG_CLOSE.equals(type)) { 399 bracketBalance--; 400 if (inOpenTag) { 401 tagBalance++; 402 } else { 403 tagBalance--; 404 } 405 } else if (XML_END_TAG_OPEN.equals(type)) { 406 bracketBalance++; 407 inOpenTag = false; 408 } else if (XML_EMPTY_TAG_CLOSE.equals(type)) { 409 bracketBalance--; 410 } 411 } 412 413 region = region.getNext(); 414 } 415 } 416 417 return Pair.of(tagBalance, bracketBalance); 418 } 419 420 /** 421 * Determine if we're in smart insert mode (if so, don't do any edit magic) 422 * 423 * @return true if the editor is in smart mode (or if it's an unknown editor type) 424 */ 425 private static boolean isSmartInsertMode() { 426 ITextEditor textEditor = AdtUtils.getActiveTextEditor(); 427 if (textEditor instanceof ITextEditorExtension3) { 428 ITextEditorExtension3 editor = (ITextEditorExtension3) textEditor; 429 return editor.getInsertMode() == ITextEditorExtension3.SMART_INSERT; 430 } 431 432 return true; 433 } 434 } 435