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.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