/* * Copyright 2009-2017 the original author or authors. * * Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0 * * 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 org.codehaus.groovy.eclipse.refactoring.formatter; import static org.codehaus.greclipse.GroovyTokenTypeBridge.EOF; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LBRACK; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LCURLY; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LITERAL_else; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LITERAL_for; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LITERAL_if; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LITERAL_while; import static org.codehaus.greclipse.GroovyTokenTypeBridge.LPAREN; import static org.codehaus.greclipse.GroovyTokenTypeBridge.NLS; import static org.codehaus.greclipse.GroovyTokenTypeBridge.RBRACK; import static org.codehaus.greclipse.GroovyTokenTypeBridge.RCURLY; import static org.codehaus.greclipse.GroovyTokenTypeBridge.RPAREN; import static org.codehaus.greclipse.GroovyTokenTypeBridge.STRING_CTOR_END; import static org.codehaus.greclipse.GroovyTokenTypeBridge.STRING_CTOR_START; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import groovyjarjarantlr.Token; import org.codehaus.greclipse.GroovyTokenTypeBridge; import org.codehaus.groovy.eclipse.core.GroovyCore; import org.eclipse.core.runtime.Assert; import org.eclipse.jdt.core.IJavaProject; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.TextUtilities; /** * This class is intended to be a place to group together code related to * indentation of Groovy Documents. It can be used by different "clients" * that require indentation services. At present there are three different * situations under which this logic is used: * <ul> * <li>when pressing newline to compute indentation for the next line * * <li>when pressing tab to do smart tab indentation. * * <li>when pasting text to do smart paste indentation * <ul> * <p> * Because this code is used under several different circumstances, it doesn't * provide a "simple intuitive" interface. Rather it provides a number of * helpful methods to compute indentation levels. A number of utility methods * for dealing with white space etc. are also included. * <p> * The GroovyIndentationService class makes use of a GroovyDocumentScanner to * tokenize documents (or sections thereof). This is not currently exposed * through public API, but it may seem reasonable to do so in the future (to * avoid having to create multiple DocumentScanners, which would be expensive). * <p> * At present the CTRL-I action is not handled by via this class. This still * uses Mike Klenk's implementation which works better when the region to indent * is large and we can get a good parse tree. Eventually, the logic in this * class should become smart enough to be able to replace Klenk's * implementation. * * @author kdvolder * @created 2010-06-08 */ public class GroovyIndentationService { /** * Caches a single instance for reuse, as long as we are working on the same project. */ private static GroovyIndentationService lastIndenter; /** * Returns an indentation service that uses the right preferences for a given IJavaProject. * The same object will be returned if the javaProject is the same as the one from the * last request, but a new instance will be created if the project has changed. */ public static synchronized GroovyIndentationService get(IJavaProject project) { if (lastIndenter != null && project != null && !project.equals(lastIndenter.project)) { disposeLastImpl(); } if (lastIndenter == null) { lastIndenter = new GroovyIndentationService(project); } return lastIndenter; } public static synchronized void disposeLast() { disposeLastImpl(); } private static void disposeLastImpl() { if (lastIndenter != null) { try { lastIndenter.dispose(); } finally { lastIndenter = null; } } } /** * Retrieves leading white space character from a String */ public static String getLeadingWhiteSpace(String text) { int i = 0; while (i < text.length() && Character.isWhitespace(text.charAt(i))) { i += 1; } return text.substring(0, i); } /** * Retrieves the text of a line at a given line number in a document. * * @param d The document * @param lineNum The line number. */ public static String getLine(IDocument d, int lineNum) { try { String delim = d.getLineDelimiter(lineNum); int delimLen = delim == null ? 0 : delim.length(); String string = d.get(d.getLineOffset(lineNum), d.getLineLength(lineNum) - delimLen); return string; } catch (BadLocationException e) { return ""; } } /** * Gets the leading white space on a given line in the document. */ public static String getLineLeadingWhiteSpace(IDocument d, int line) { return getLeadingWhiteSpace(getLine(d, line)); } /** * Given an offset, retrieve the text from the beginning of the line this * offset is in, up to the offset. */ public static String getLineTextUpto(IDocument d, int offset) throws BadLocationException { int line = d.getLineOfOffset(offset); int lineStart = d.getLineOffset(line); String lineStartText = d.get(lineStart, offset - lineStart); return lineStartText; } /** * A map relating the closer of a pair of braces/brackets/parens to its corresponding opener. */ private static Map<Integer, Integer> closer2opener = new HashMap<Integer, Integer>(); private static Set<Integer> jumpIn = new HashSet<Integer>(); private static Set<Integer> jumpOut = new HashSet<Integer>(); static { openClosePair(LCURLY, RCURLY); openClosePair(LBRACK, RBRACK); openClosePair(LPAREN, RPAREN); openClosePair(STRING_CTOR_START, STRING_CTOR_END); } /** * Register an association between a pair of opening/closing braces or * parenthesis. * This is used for scanning backwards and determining the indentation level * after a closing brace, paren etc. * <p> * The arguments should be Token type constants from the * {@link GroovyTokenTypeBridge} class. */ private static void openClosePair(int opener, int closer) { Assert.isTrue(!closer2opener.containsKey(closer)); closer2opener.put(closer, opener); jumpIn.add(opener); jumpOut.add(closer); } /** * A cached {@link GroovyDocumentScanner} instance. This will * be replaced when the document we are asked to work on changes * from the last time. Otherwise we will reuse the cached scanner. */ private GroovyDocumentScanner cachedScanner; private IFormatterPreferences prefs; private final IJavaProject project; public GroovyIndentationService(IJavaProject project) { assert project != null; this.project = project; } /** * Compute indentation level for the next line after a newline is inserted * at a given offset. */ public int computeIndentAfterNewline(IDocument d, int offset) throws BadLocationException { Token token = getTokenBefore(d, offset); int line = (token == null ? 0 : getLine(token)); int orgIndentLevel = (token == null ? 0 : getLineIndentLevel(d, line)); List<Token> tokens = getTokens(d, d.getLineOffset(line), offset); int indentLevel = simpleComputeNextLineIndentLevel(orgIndentLevel, tokens); Token lastToken = lastNonNlsToken(d, tokens); if (lastToken != null) { if (isCloserOfPair(lastToken)) { if (indentLevel < orgIndentLevel) { // Jumping back from indentation is more complex. // A somewhat better strategy for newline after closing // brackets, parens or braces. indentLevel = getIndentLevelForCloserPair(d, lastToken); } else if (indentLevel == orgIndentLevel && lastToken.getType() == RPAREN) { // line ended with ')' -- check for incomplete conditional statement int firstTokenType = tokens.get(0).getType(); if (firstTokenType == LITERAL_if || firstTokenType == LITERAL_else || firstTokenType == LITERAL_for || firstTokenType == LITERAL_while || (firstTokenType == RCURLY && tokens.size() > 1 && tokens.get(1).getType() == LITERAL_else)) { indentLevel = getIndentLevelForCloserPair(d, lastToken) + getPrefs().getIndentationSize(); } } } else if (indentLevel == orgIndentLevel && lastToken.getType() == LITERAL_else) { // bare else indentLevel += getPrefs().getIndentationSize(); } } return indentLevel; } /** * Get the line number in the document for a given Token. * * @return 0-based index, as used by Eclipse IDocument interface. */ private int getLine(Token token) { return token.getLine() - 1; // Antlr line numbers start at 1. } /** * Compute the proper indentation level for a given line. This may be * different from its actual indentation level. */ public int computeIndentForLine(IDocument d, int line) { if (line != 0) { try { Token nextToken = getTokenFrom(d, d.getLineOffset(line)); if (nextToken != null && isCloserOfPair(nextToken)) { // Don't use the newline mechanism! Line up the with matching opening brace instead. return getIndentLevelForCloserPair(d, nextToken); } else { IRegion prevLine = d.getLineInformation(line - 1); return computeIndentAfterNewline(d, prevLine.getOffset() + prevLine.getLength()); } } catch (BadLocationException e) { GroovyCore.logException("internal error", e); } } return 0; } /** * Create indentation characters properly using tabs and spaces, equivalent * to a given number of spaces. */ public String createIndentation(int spaces) { return createIndentation(getPrefs(), spaces); } public static String createIndentation(IFormatterPreferences pref, int spaces) { StringBuilder b = new StringBuilder(); int tab; if (spaces > 0 && pref != null && pref.useTabs() && (tab = pref.getTabSize()) > 0) { while (spaces >= tab) { b.append('\t'); spaces -= tab; } } while (spaces > 0) { b.append(' '); spaces -= 1; } return b.toString(); } public void dispose() { disposeScanner(); disposePrefs(); } /** * When no longer requiring the services for a little while, * you can call this method to save a little memorty (assuming * you will be refreshing prefs next time anyway. */ public void disposePrefs() { this.prefs = null; } private void disposeScanner() { if (this.cachedScanner != null) { try { this.cachedScanner.dispose(); } finally { this.cachedScanner = null; } } } @Override protected void finalize() throws Throwable { this.dispose(); super.finalize(); } public void fixIndentation(Document workCopy, int line, int newIndentLevel) { try { IRegion lineRegion = workCopy.getLineInformation(line); String text = workCopy.get(lineRegion.getOffset(), lineRegion.getLength()).trim(); text = createIndentation(newIndentLevel) + text; workCopy.replace(lineRegion.getOffset(), lineRegion.getLength(), text); } catch (BadLocationException e) { GroovyCore.logException("internal error", e); } } private GroovyDocumentScanner getGroovyDocumentScanner(IDocument d) { if (cachedScanner != null && cachedScanner.getDocument() == d) return cachedScanner; disposeScanner(); return (cachedScanner = new GroovyDocumentScanner(d)); } /** * Get the current indentation level for line at given offset in document. */ public int getIndentLevel(IDocument d, int offset) { try { return getLineIndentLevel(d, d.getLineOfOffset(offset)); } catch (BadLocationException e) { // Presumably that line does not exist, so we use 0 as default // indentation level return 0; } } /** * Search backwards for a matching brace from position just after a closing * brace. * * @return the indentation level of the line at which the matching brace * is found. */ private int getIndentLevelForCloserPair(IDocument d, Token closer) { GroovyDocumentScanner scanner = getGroovyDocumentScanner(d); int closerType = closer.getType(); int openerType = closer2opener.get(closerType); int closeCount = 1; Token token = closer; try { while (closeCount != 0 && (token = scanner.getLastTokenBefore(token)) != null) { if (token.getType() == openerType) closeCount -= 1; if (token.getType() == closerType) closeCount += 1; } return getIndentLevel(d, scanner.getOffset(token)); } catch (BadLocationException e) { // Something went wrong. Just use indent level of the line itself as a "sensible" default. try { return getIndentLevel(d, scanner.getOffset(closer)); } catch (BadLocationException e1) { return 0; } } } /** * This does the same as the getLineDelimeter method on Document, except it * returns "" instead of null when there is no line delimiter for this line. * * @throws BadLocationException */ public String getLineDelimiter(IDocument d, int lineNum) throws BadLocationException { String result = d.getLineDelimiter(lineNum); if (result == null) return ""; else return result; } /** * Determine the current indentation level of a given line to which a * command * applies. * * @return The number of spaces equivalent to the leading white space of the * current line. */ public int getLineIndentLevel(IDocument d, int lineNum) { return indentLevel(getLine(d, lineNum)); } private List<Token> getLineTokensUpto(IDocument d, int offset) { return getGroovyDocumentScanner(d).getLineTokensUpto(offset); } protected int getOpenVersusCloseBalance(List<Token> tokens) { int adjust = 0; // if line starts with closer, assume adjustment has already been made // examples include "} else {" and "} while ()" (end of do-while loop) if (!tokens.isEmpty() && jumpOut.contains(tokens.get(0).getType())) { adjust += 1; } for (Token tok : tokens) { if (jumpIn.contains(tok.getType())) adjust += 1; if (jumpOut.contains(tok.getType())) adjust -= 1; } return adjust; } public IFormatterPreferences getPrefs() { if (prefs == null) refreshPrefs(); return prefs; } /** * @return A String equivalent to one tab, using either a tab, * or a number of spaces, in accordance with the preferences. */ public String getTabString() { return createIndentation(getPrefs().getTabSize()); } private Token getTokenBefore(IDocument d, Token token) throws BadLocationException { return getGroovyDocumentScanner(d).getLastTokenBefore(token); } private Token getTokenBefore(IDocument d, int offset) throws BadLocationException { return getGroovyDocumentScanner(d).getLastTokenBefore(offset); } private Token getTokenFrom(IDocument d, int offset) { return getGroovyDocumentScanner(d).getTokenFrom(offset); } private List<Token> getTokens(IDocument d, int start, int end) { return getGroovyDocumentScanner(d).getTokens(start, end); } /** * Determines the current indentation level of a given line of text. * * @return The number of spaces equivalent to the leading white space of the lineText */ public int indentLevel(String lineText) { int level = 0; for (int i = 0, n = lineText.length(); i < n; i += 1) { switch (lineText.charAt(i)) { case ' ': level += 1; break; case '\t': level += getPrefs().getTabSize(); break; default: return level; } } return level; } /** * Determine whether position is after a "{" (ignoring white space or * comments, but not NLS) */ public boolean isAfterOpeningBrace(IDocument d, int pos) { Token token = getGroovyDocumentScanner(d).getLastTokenBefore(pos); return token != null && token.getType() == LCURLY; } /** * Determine whether a given token is close "opener" of a pair. That * is, whether is a closing parenthesis, brace or bracket. */ private boolean isCloserOfPair(Token lastToken) { return closer2opener.containsKey(lastToken.getType()); } /** * Determine whether position represent an end of line (ignoring white space * or comments) */ public boolean isEndOfLine(IDocument d, int pos) { Token token = getGroovyDocumentScanner(d).getTokenFrom(pos); return token == null || token.getType() == NLS || token.getType() == EOF; } /** * @return true if the offset is inside an empty line in the document. */ public boolean isInEmptyLine(IDocument d, int offset) { int line; try { line = d.getLineOfOffset(offset); String text = getLine(d, line); return text.trim().length() == 0; } catch (BadLocationException e) { GroovyCore.logException("Internal error", e); return false; } } /** * @param tokens same-line tokens preceding the caret * @return first preceding token that is not a non-localized string comment */ private Token lastNonNlsToken(IDocument d, List<Token> tokens) throws BadLocationException { if (tokens != null && !tokens.isEmpty()) { Token lastToken = tokens.get(tokens.size() - 1); while (lastToken != null && lastToken.getType() == GroovyTokenTypeBridge.NLS) { lastToken = getTokenBefore(d, lastToken); } return lastToken; } return null; } /** * @param offset offset into document * @return the length from the insert location to the start of the curly */ public int lengthToNextCurly(IDocument d, int offset) throws BadLocationException { Token token = getTokenFrom(d, offset); // must make sure there is no newline if (!isEndOfLine(d, offset) && RCURLY == token.getType()) { return token.getColumn() - offset + d.getLineOffset(d.getLineOfOffset(offset)); } return 0; } public boolean moreOpenThanCloseBefore(IDocument d, int offset) { return getOpenVersusCloseBalance(getLineTokensUpto(d, offset)) > 0; } /** * Gets default line delimiter for a given document. */ public String newline(IDocument d) { return TextUtilities.getDefaultLineDelimiter(d); } /** * Ensures prefs are up to date. Typically this gets called before performing * a series of indentation related stuff on a document. * <p> * It will also be called automatically when you ask for the preferences the * first time, or the first time after having called disposePrefs. */ public void refreshPrefs() { this.prefs = new FormatterPreferences(project); } /** * Computes an adjusted indentation level for the next line, based on * some simple heuristics about the types of tokens seen only in the * previous line. */ private int simpleComputeNextLineIndentLevel(int indentLevel, List<Token> tokens) { int adjust = getOpenVersusCloseBalance(tokens); if (adjust > 0) { indentLevel += getPrefs().getIndentationSize(); } else if (adjust < 0) { indentLevel -= getPrefs().getIndentationSize(); } return indentLevel; } }