blob: 8a078efc27eea9f00debc2b01e1e4e33e186bdeb [file] [log] [blame]
/*
* 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;
}
}