aboutsummaryrefslogtreecommitdiff
path: root/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/AndroidXmlAutoEditStrategy.java
blob: 8a078efc27eea9f00debc2b01e1e4e33e186bdeb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
/*
 * 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.EclipseXmlFormatPreferences;
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 = EclipseXmlFormatPreferences.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(EclipseXmlFormatPreferences.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;
    }
}