/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Eclipse Public License, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.eclipse.org/org/documents/epl-v10.php * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ide.eclipse.adt.internal.editors; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_CONTENT; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_EMPTY_TAG_CLOSE; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_END_TAG_OPEN; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_CLOSE; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_NAME; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_OPEN; import com.android.ide.eclipse.adt.AdtPlugin; import com.android.ide.eclipse.adt.AdtUtils; import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatPreferences; import com.android.utils.Pair; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.DocumentCommand; import org.eclipse.jface.text.IAutoEditStrategy; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.TextUtilities; import org.eclipse.ui.texteditor.ITextEditor; import org.eclipse.ui.texteditor.ITextEditorExtension3; import org.eclipse.wst.sse.core.StructuredModelManager; import org.eclipse.wst.sse.core.internal.provisional.IModelManager; import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList; /** * Edit strategy for Android XML files. It attempts a number of edit * enhancements: * <ul> * <li> Auto indentation. The default XML indentation scheme is to just copy the * indentation of the previous line. This edit strategy improves on that situation * by considering the tag and bracket balance on the current line and using it * to determine whether the next line should be indented or use the same * indentation as the parent, or even the indentation of an earlier line * (when for example the current line closes an element which was started on an * earlier line.) * <li> Newline handling. In addition to indenting, it can also adjust the following text * appropriately when a newline is inserted. For example, it will reformat * the following (where | represents the caret position): * <pre> * {@code <item name="a">|</item>} * </pre> * into * <pre> * {@code <item name="a">} * | * {@code </item>} * </pre> * </ul> * In the future we might consider other editing enhancements here as well, such as * refining the comment handling, or reindenting when you type the / of a closing tag, * or even making the bracket matcher more resilient. */ @SuppressWarnings("restriction") // XML model public class AndroidXmlAutoEditStrategy implements IAutoEditStrategy { @Override public void customizeDocumentCommand(IDocument document, DocumentCommand c) { if (!isSmartInsertMode()) { return; } if (!(document instanceof IStructuredDocument)) { // This shouldn't happen unless this strategy is used on an invalid document return; } IStructuredDocument doc = (IStructuredDocument) document; // Handle newlines/indentation if (c.length == 0 && c.text != null && TextUtilities.endsWith(doc.getLegalLineDelimiters(), c.text) != -1) { IModelManager modelManager = StructuredModelManager.getModelManager(); IStructuredModel model = modelManager.getModelForRead(doc); if (model != null) { try { final int offset = c.offset; int lineStart = findLineStart(doc, offset); int textStart = findTextStart(doc, lineStart, offset); IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(textStart); if (region != null && region.getType().equals(XML_TAG_NAME)) { Pair<Integer,Integer> balance = getBalance(doc, textStart, offset); int tagBalance = balance.getFirst(); int bracketBalance = balance.getSecond(); String lineIndent = ""; //$NON-NLS-1$ if (textStart > lineStart) { lineIndent = doc.get(lineStart, textStart - lineStart); } // We only care if tag or bracket balance is greater than 0; // we never *dedent* on negative balances boolean addIndent = false; if (bracketBalance < 0) { // Handle // <foo // ></foo>^ // and // <foo // />^ ITextRegion left = getRegionAt(doc, offset, true /*biasLeft*/); if (left != null && (left.getType().equals(XML_TAG_CLOSE) || left.getType().equals(XML_EMPTY_TAG_CLOSE))) { // Find the corresponding open tag... // The org.eclipse.wst.xml.ui.gotoMatchingTag frequently // doesn't work, it just says "No matching brace found" // (or I would use that here). int targetBalance = 0; ITextRegion right = getRegionAt(doc, offset, false /*biasLeft*/); if (right != null && right.getType().equals(XML_END_TAG_OPEN)) { targetBalance = -1; } int openTag = AndroidXmlCharacterMatcher.findTagBackwards(doc, offset, targetBalance); if (openTag != -1) { // Look up the indentation of the given line lineIndent = AndroidXmlEditor.getIndentAtOffset(doc, openTag); } } } else if (tagBalance > 0 || bracketBalance > 0) { // Add indentation addIndent = true; } StringBuilder sb = new StringBuilder(c.text); sb.append(lineIndent); String oneIndentUnit = XmlFormatPreferences.create().getOneIndentUnit(); if (addIndent) { sb.append(oneIndentUnit); } // Handle // <foo>^</foo> // turning into // <foo> // ^ // </foo> ITextRegion left = getRegionAt(doc, offset, true /*biasLeft*/); ITextRegion right = getRegionAt(doc, offset, false /*biasLeft*/); if (left != null && right != null && left.getType().equals(XML_TAG_CLOSE) && right.getType().equals(XML_END_TAG_OPEN)) { // Move end tag if (tagBalance > 0 && bracketBalance < 0) { sb.append(oneIndentUnit); } c.caretOffset = offset + sb.length(); c.shiftsCaret = false; sb.append(TextUtilities.getDefaultLineDelimiter(doc)); sb.append(lineIndent); } c.text = sb.toString(); } else if (region != null && region.getType().equals(XML_CONTENT)) { // Indenting in text content. If you're in the middle of editing // text, just copy the current line indentation. // However, if you're editing in leading whitespace (e.g. you press // newline on a blank line following say an element) then figure // out the indentation as if the newline had been pressed at the // end of the element, and insert that amount of indentation. // In this case we need to also make sure to subtract any existing // whitespace on the current line such that if we have // // <foo> // ^ <bar/> // </foo> // // you end up with // // <foo> // // ^<bar/> // </foo> // String text = region.getText(); int regionStart = region.getStartOffset(); int delta = offset - regionStart; boolean inWhitespacePrefix = true; for (int i = 0, n = Math.min(delta, text.length()); i < n; i++) { char ch = text.charAt(i); if (!Character.isWhitespace(ch)) { inWhitespacePrefix = false; break; } } if (inWhitespacePrefix) { IStructuredDocumentRegion previous = region.getPrevious(); if (previous != null && previous.getType() == XML_TAG_NAME) { ITextRegionList subRegions = previous.getRegions(); ITextRegion last = subRegions.get(subRegions.size() - 1); if (last.getType() == XML_TAG_CLOSE || last.getType() == XML_EMPTY_TAG_CLOSE) { // See if the last tag was a closing tag boolean wasClose = last.getType() == XML_EMPTY_TAG_CLOSE; if (!wasClose) { // Search backwards to see if the XML_TAG_CLOSE // is the end of an </endtag> for (int i = subRegions.size() - 2; i >= 0; i--) { ITextRegion current = subRegions.get(i); String type = current.getType(); if (type != XML_TAG_NAME) { wasClose = type == XML_END_TAG_OPEN; break; } } } int begin = AndroidXmlCharacterMatcher.findTagBackwards(doc, previous.getStartOffset() + last.getStart(), 0); int prevLineStart = findLineStart(doc, begin); int prevTextStart = findTextStart(doc, prevLineStart, begin); String lineIndent = ""; //$NON-NLS-1$ if (prevTextStart > prevLineStart) { lineIndent = doc.get(prevLineStart, prevTextStart - prevLineStart); } StringBuilder sb = new StringBuilder(c.text); sb.append(lineIndent); // See if there is whitespace on the insert line that // we should also remove for (int i = delta, n = text.length(); i < n; i++) { char ch = text.charAt(i); if (ch == ' ') { c.length++; } else { break; } } boolean addIndent = (last.getType() == XML_TAG_CLOSE) && !wasClose; // Is there just whitespace left of this text tag // until we reach an end tag? boolean whitespaceToEndTag = true; for (int i = delta; i < text.length(); i++) { char ch = text.charAt(i); if (ch == '\n' || !Character.isWhitespace(ch)) { whitespaceToEndTag = false; break; } } if (whitespaceToEndTag) { IStructuredDocumentRegion next = region.getNext(); if (next != null && next.getType() == XML_TAG_NAME) { String nextType = next.getRegions().get(0).getType(); if (nextType == XML_END_TAG_OPEN) { addIndent = false; } } } if (addIndent) { sb.append(XmlFormatPreferences.create() .getOneIndentUnit()); } c.text = sb.toString(); return; } } } copyPreviousLineIndentation(doc, c); } else { copyPreviousLineIndentation(doc, c); } } catch (BadLocationException e) { AdtPlugin.log(e, null); } finally { model.releaseFromRead(); } } } } /** * Returns the offset of the start of the line (which might be whitespace) * * @param document the document * @param offset an offset for a character anywhere on the line * @return the offset of the first character on the line * @throws BadLocationException if the offset is invalid */ public static int findLineStart(IDocument document, int offset) throws BadLocationException { offset = Math.max(0, Math.min(offset, document.getLength() - 1)); IRegion info = document.getLineInformationOfOffset(offset); return info.getOffset(); } /** * Finds the first non-whitespace character on the given line * * @param document the document to search * @param lineStart the offset of the beginning of the line * @param lineEnd the offset of the end of the line, or the maximum position on the * line to search * @return the offset of the first non whitespace character, or the maximum position, * whichever is smallest * @throws BadLocationException if the offsets are invalid */ public static int findTextStart(IDocument document, int lineStart, int lineEnd) throws BadLocationException { for (int offset = lineStart; offset < lineEnd; offset++) { char c = document.getChar(offset); if (c != ' ' && c != '\t') { return offset; } } return lineEnd; } /** * Indent the new line the same way as the current line. * * @param doc the document to indent in * @param command the document command to customize * @throws BadLocationException if the offsets are invalid */ private void copyPreviousLineIndentation(IDocument doc, DocumentCommand command) throws BadLocationException { if (command.offset == -1 || doc.getLength() == 0) { return; } int lineStart = findLineStart(doc, command.offset); int textStart = findTextStart(doc, lineStart, command.offset); StringBuilder sb = new StringBuilder(command.text); if (textStart > lineStart) { sb.append(doc.get(lineStart, textStart - lineStart)); } command.text = sb.toString(); } /** * Returns the subregion at the given offset, with a bias to the left or a bias to the * right. In other words, if | represents the caret position, in the XML * {@code <foo>|</bar>} then the subregion with bias left is the closing {@code >} and * the subregion with bias right is the opening {@code </}. * * @param doc the document * @param offset the offset in the document * @param biasLeft whether we should look at the token on the left or on the right * @return the subregion at the given offset, or null if not found */ private static ITextRegion getRegionAt(IStructuredDocument doc, int offset, boolean biasLeft) { if (biasLeft) { offset--; } IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(offset); if (region != null) { return region.getRegionAtCharacterOffset(offset); } return null; } /** * Returns a pair of (tag-balance,bracket-balance) for the range textStart to offset. * * @param doc the document * @param start the offset of the starting character (inclusive) * @param end the offset of the ending character (exclusive) * @return the balance of tags and brackets */ private static Pair<Integer, Integer> getBalance(IStructuredDocument doc, int start, int end) { // Balance of open and closing tags // <foo></foo> has tagBalance = 0, <foo> has tagBalance = 1 int tagBalance = 0; // Balance of open and closing brackets // <foo attr1="value1"> has bracketBalance = 1, <foo has bracketBalance = 1 int bracketBalance = 0; IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(start); if (region != null) { boolean inOpenTag = true; while (region != null && region.getStartOffset() < end) { int regionStart = region.getStartOffset(); ITextRegionList subRegions = region.getRegions(); for (int i = 0, n = subRegions.size(); i < n; i++) { ITextRegion subRegion = subRegions.get(i); int subRegionStart = regionStart + subRegion.getStart(); int subRegionEnd = regionStart + subRegion.getEnd(); if (subRegionEnd < start || subRegionStart >= end) { continue; } String type = subRegion.getType(); if (XML_TAG_OPEN.equals(type)) { bracketBalance++; inOpenTag = true; } else if (XML_TAG_CLOSE.equals(type)) { bracketBalance--; if (inOpenTag) { tagBalance++; } else { tagBalance--; } } else if (XML_END_TAG_OPEN.equals(type)) { bracketBalance++; inOpenTag = false; } else if (XML_EMPTY_TAG_CLOSE.equals(type)) { bracketBalance--; } } region = region.getNext(); } } return Pair.of(tagBalance, bracketBalance); } /** * Determine if we're in smart insert mode (if so, don't do any edit magic) * * @return true if the editor is in smart mode (or if it's an unknown editor type) */ private static boolean isSmartInsertMode() { ITextEditor textEditor = AdtUtils.getActiveTextEditor(); if (textEditor instanceof ITextEditorExtension3) { ITextEditorExtension3 editor = (ITextEditorExtension3) textEditor; return editor.getInsertMode() == ITextEditorExtension3.SMART_INSERT; } return true; } }