Home | History | Annotate | Download | only in editors
      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