/*
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
*
* Copyright 1997-2010 Oracle and/or its affiliates. All rights reserved.
*
* Oracle and Java are registered trademarks of Oracle and/or its affiliates.
* Other names may be trademarks of their respective owners.
*
* The contents of this file are subject to the terms of either the GNU
* General Public License Version 2 only ("GPL") or the Common
* Development and Distribution License("CDDL") (collectively, the
* "License"). You may not use this file except in compliance with the
* License. You can obtain a copy of the License at
* http://www.netbeans.org/cddl-gplv2.html
* or nbbuild/licenses/CDDL-GPL-2-CP. See the License for the
* specific language governing permissions and limitations under the
* License. When distributing the software, include this License Header
* Notice in each file and include the License file at
* nbbuild/licenses/CDDL-GPL-2-CP. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the GPL Version 2 section of the License file that
* accompanied this code. If applicable, add the following below the
* License Header, with the fields enclosed by brackets [] replaced by
* your own identifying information:
* "Portions Copyrighted [year] [name of copyright owner]"
*
* Contributor(s):
*
* Portions Copyrighted 2007 Sun Microsystems, Inc.
*/
package org.netbeans.modules.ruby.hints;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.prefs.Preferences;
import javax.swing.JComponent;
import javax.swing.text.BadLocationException;
import org.jrubyparser.ast.IArgumentNode;
import org.jrubyparser.ast.Node;
import org.jrubyparser.ast.NodeType;
import org.jrubyparser.SourcePosition;
import org.netbeans.api.lexer.Token;
import org.netbeans.api.lexer.TokenId;
import org.netbeans.api.lexer.TokenSequence;
import org.netbeans.editor.BaseDocument;
import org.netbeans.editor.Utilities;
import org.netbeans.modules.csl.api.EditList;
import org.netbeans.modules.csl.api.Hint;
import org.netbeans.modules.csl.api.HintFix;
import org.netbeans.modules.csl.api.HintSeverity;
import org.netbeans.modules.csl.api.OffsetRange;
import org.netbeans.modules.csl.api.PreviewableFix;
import org.netbeans.modules.csl.api.RuleContext;
import org.netbeans.modules.csl.spi.ParserResult;
import org.netbeans.modules.ruby.AstPath;
import org.netbeans.modules.ruby.AstUtilities;
import org.netbeans.modules.ruby.RubyUtils;
import org.netbeans.modules.ruby.hints.infrastructure.RubyAstRule;
import org.netbeans.modules.ruby.hints.infrastructure.RubyRuleContext;
import org.netbeans.modules.ruby.lexer.LexUtilities;
import org.netbeans.modules.ruby.lexer.RubyTokenId;
import org.openide.util.Exceptions;
import org.openide.util.NbBundle;
/**
* Offer to convert a {}-style block into do-end, or vice versa
*
* @author Tor Norbye
*/
public class ConvertBlockType extends RubyAstRule {
public ConvertBlockType() {
}
public boolean appliesTo(RuleContext context) {
ParserResult info = context.parserResult;
// Skip for RHTML files for now - isn't implemented properly
return RubyUtils.getFileObject(info).getMIMEType().equals("text/x-ruby");
}
public Set<NodeType> getKinds() {
return Collections.singleton(NodeType.ITERNODE);
}
public void run(RubyRuleContext context, List<Hint> result) {
Node node = context.node;
ParserResult info = context.parserResult;
int caretOffset = context.caretOffset;
BaseDocument doc = context.doc;
assert (node.getNodeType() == NodeType.ITERNODE);
try {
int astOffset = node.getPosition().getStartOffset();
int lexOffset = LexUtilities.getLexerOffset(info, astOffset);
if (lexOffset == -1 || lexOffset > doc.getLength() - 1) {
return;
}
// Limit the hint to the -opening- line of the block
boolean caretOnStart = true;
final int beginRowEnd = Utilities.getRowEnd(doc, lexOffset);
final int caretRowEnd = Utilities.getRowEnd(doc, caretOffset);
boolean caretLine = beginRowEnd == caretRowEnd;
int endLexOffset = -1;
if (!caretLine) {
// ...or the -ending- line of the block
int endAstOffset = node.getPosition().getEndOffset();
endLexOffset = LexUtilities.getLexerOffset(info, endAstOffset);
if (endLexOffset == -1) {
return;
}
int endRowEnd = endLexOffset;
if (endRowEnd < doc.getLength()) {
endRowEnd = Utilities.getRowEnd(doc, endLexOffset);
}
caretLine = endRowEnd == caretRowEnd;
if (!caretLine) {
return;
}
if (endRowEnd != beginRowEnd) {
caretOnStart = false;
}
}
Token<? extends RubyTokenId> token = LexUtilities.getToken(doc, lexOffset);
if (token == null) {
return;
}
TokenId id = token.id();
if (id == RubyTokenId.LBRACE || id == RubyTokenId.DO) {
OffsetRange range;
if (caretOnStart) {
range = new OffsetRange(lexOffset, lexOffset + token.length());
} else {
assert endLexOffset != -1;
int len = (id == RubyTokenId.LBRACE) ? 1 : 3; // }=1, end=3
range = new OffsetRange(endLexOffset-len, endLexOffset);
}
List<HintFix> fixList = new ArrayList<HintFix>(1);
boolean convertFromBrace = id == RubyTokenId.LBRACE;
int endOffset = node.getPosition().getEndOffset();
if (endOffset > doc.getLength()) {
endOffset = doc.getLength();
}
// See if we should offer to collapse
String text = doc.getText(lexOffset, endOffset - lexOffset);
int nonspaceChars = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (!Character.isWhitespace(c)) {
nonspaceChars++;
}
}
int startColumn = lexOffset - Utilities.getRowStart(doc, lexOffset);
// Not yet exposed from the Ruby module
//int rightMargin = org.netbeans.modules.ruby.options.CodeStyle.getDefault(null).getRightMargin();
// #119151: This should be available for a lot of hints that don't neatly fit.
// So only suppress it for -really- large blocks.
int rightMargin = 350;
boolean offerCollapse = rightMargin > startColumn + nonspaceChars;
// TODO - in an RHTML page, make sure there are no "gaps" (non Ruby code) between the do and the end,
// since we can't handle those for collapse
// TODO
boolean sameLine = Utilities.getRowEnd(doc, lexOffset) == Utilities.getRowEnd(doc, endOffset);
if (sameLine && convertFromBrace) {
fixList.add(new ConvertTypeFix(context, node, convertFromBrace, !convertFromBrace, true, false));
} else if (!sameLine && !convertFromBrace && offerCollapse) {
fixList.add(new ConvertTypeFix(context, node, convertFromBrace, !convertFromBrace, false, true));
} // else: Should I let you expand a single line do-end to a multiline {}, or vice versa? Naeh,
// they can do this in two steps; it's not common
fixList.add(new ConvertTypeFix(context, node, convertFromBrace, !convertFromBrace, false, false));
if (sameLine || (!sameLine && offerCollapse)) {
fixList.add(new ConvertTypeFix(context, node, false, false, sameLine, !sameLine));
}
Hint desc = new Hint(this, getDisplayName(), RubyUtils.getFileObject(info), range, fixList, 500);
result.add(desc);
}
} catch (BadLocationException ex) {
Exceptions.printStackTrace(ex);
}
}
public String getId() {
return "Convert_Blocktype"; // NOI18N
}
public String getDisplayName() {
return NbBundle.getMessage(ConvertBlockType.class, "ConvertBlockType");
}
public String getDescription() {
return NbBundle.getMessage(ConvertBlockType.class, "ConvertBlockTypeDesc");
}
public boolean getDefaultEnabled() {
return true;
}
public HintSeverity getDefaultSeverity() {
return HintSeverity.CURRENT_LINE_WARNING;
}
public JComponent getCustomizer(Preferences node) {
return null;
}
public boolean showInTasklist() {
return false;
}
private static class ConvertTypeFix implements PreviewableFix {
private final RubyRuleContext context;
private final boolean convertToDo;
private final boolean convertToBrace;
private final Node node;
private final boolean expand;
private final boolean collapse;
ConvertTypeFix(RubyRuleContext context, Node node,
boolean convertToDo, boolean convertToBrace,
boolean expand, boolean collapse) {
this.context = context;
this.node = node;
this.convertToDo = convertToDo;
this.convertToBrace = convertToBrace;
this.expand = expand;
this.collapse = collapse;
}
public String getDescription() {
String key;
if (convertToDo) {
if (expand) {
key = "ConvertBraceToDoMulti"; // NOI18N
} else if (collapse) {
key = "ConvertBraceToDoSingle"; // NOI18N
} else {
key = "ConvertBraceToDo"; // NOI18N
}
} else if (convertToBrace) {
if (expand) {
key = "ConvertDoToBraceMulti"; // NOI18N
} else if (collapse) {
key = "ConvertDoToBraceSingle"; // NOI18N
} else {
key = "ConvertDoToBrace"; // NOI18N
}
} else {
if (expand) {
key = "ChangeBlockToMulti"; // NOI18N
} else {
assert collapse;
key = "ChangeBlockToSingle"; // NOI18N
}
}
return NbBundle.getMessage(ConvertBlockType.class, key);
}
public boolean canPreview() {
return true;
}
public void implement() throws Exception {
getEditList().apply();
}
public EditList getEditList() throws Exception {
BaseDocument doc = context.doc;
EditList edits = new EditList(doc);
SourcePosition pos = node.getPosition();
int startOffset = pos.getStartOffset();
int originalEnd = pos.getEndOffset();
int endOffset;
if (convertToDo) {
endOffset = originalEnd - 1;
} else if (convertToBrace) {
endOffset = originalEnd - 3;
} else {
endOffset = originalEnd;
}
if (startOffset > doc.getLength() - 1 || endOffset > doc.getLength()) {
return edits;
}
if (convertToDo) {
if (doc.getText(startOffset, 1).charAt(0) == '{' && doc.getText(endOffset, 1).charAt(0) == '}') {
String end;
if (endOffset > 0 && !Character.isWhitespace(doc.getText(endOffset - 1, 1).charAt(0))) {
end = " end"; // NOI18N
} else {
end = "end"; // NOI18N
}
edits.replace(endOffset, 1, end, false, 0); // NOI18N
boolean spaceBefore = true;
boolean spaceAfter = true;
if (startOffset > 0) {
String s = doc.getText(startOffset - 1, 3);
spaceBefore = Character.isWhitespace(s.charAt(0));
spaceAfter = Character.isWhitespace(s.charAt(2));
}
String insert = "do";
if (!spaceAfter) {
insert = insert + " ";
}
if (!spaceBefore) {
insert = " " + insert;
}
edits.replace(startOffset, 1, insert, false, 1); // NOI18N
if (expand) {
expand(edits, doc, node, startOffset, originalEnd);
} else if (collapse) {
collapse(edits, doc, node, startOffset, originalEnd);
}
}
} else if (convertToBrace) {
if (doc.getText(startOffset, 2).equals("do") && endOffset <= doc.getLength() - 3 && // NOI18N
doc.getText(endOffset, 3).equals("end")) { // NOI18N
// TODO - make sure there is whitespace next to these tokens!!!
// They are optional around {} but not around do/end!
AstPath path = new AstPath(AstUtilities.getRoot(context.parserResult), node);
assert path.leaf() == node;
boolean parenIsNecessary = isArgParenNecessary(path, doc);
edits.replace(endOffset, 3, "}", false, 0); // NOI18N
edits.replace(startOffset, 2, "{", false, 0); // NOI18N
if (parenIsNecessary) {
// Insert parentheses
assert AstUtilities.isCall(path.leafParent());
OffsetRange range = AstUtilities.getCallRange(path.leafParent());
int insertPos = range.getEnd();
// Check if I should remove a space; e.g. replace "foo arg" with "foo(arg"
if (Character.isWhitespace(doc.getText(insertPos, 1).charAt(0))) {
edits.replace(insertPos, 1, "(", false, 1); // NOI18N
} else {
edits.replace(insertPos, 0, "(", false, 1); // NOI18N
}
// Insert )
edits.replace(startOffset-1, 0, ")", false, 2); // NOI18N
if (!Character.isWhitespace(doc.getText(startOffset-1, 1).charAt(0))) {
edits.replace(startOffset-1, 0, " ", false, 3); // NOI18N
}
}
if (expand) {
expand(edits, doc, node, startOffset, originalEnd);
} else if (collapse) {
collapse(edits, doc, node, startOffset, originalEnd);
}
}
} else {
assert collapse || expand;
if (expand) {
expand(edits, doc, node, startOffset, endOffset);
} else {
collapse(edits, doc, node, startOffset, endOffset);
}
}
return edits;
}
/** JRuby sometimes has wrong AST offsets. For example, for
* this IterNode
* sort{|a1, a2| a1[0].id2name <=> a2[0].id2name}
* the NewlineNode inside the iter will be here: a1^[0] instead of ^a1[0].
* To work around this problem, look at the left most children of a NewlineNode
* and find the TRUE starting range of the newline node.
* @todo File JRuby issue
*/
private int findRealStart(Node node) {
int min = Integer.MAX_VALUE;
while (true) {
int start = node.getPosition().getStartOffset();
if (start < min) {
min = start;
}
List<Node> list = node.childNodes();
if (list != null && list.size() > 0) {
node = list.get(0);
} else {
return min;
}
}
}
private void findLineBreaks(Node node, Set<Integer> offsets) {
if (node.getNodeType() == NodeType.NEWLINENODE) {
// Doesn't work, need above workaround
//int start = node.getPosition().getStartOffset();
int start = findRealStart(node);
offsets.add(start);
}
List<Node> list = node.childNodes();
for (Node child : list) {
if (child.isInvisible()) {
continue;
}
if (child.getNodeType() == NodeType.EVSTRNODE) {
// Don't linebreak inside a #{} expression
continue;
}
findLineBreaks(child, offsets);
}
}
/** NOTE - document should be under atomic lock when this is called */
private void expand(EditList edits, BaseDocument doc, Node node, int startOffset, int endOffset) {
assert endOffset <= doc.getLength();
// Look through the document and find the statement separators (;);
// at these locations I'll replace the ; with a newline and then
// apply a formatter
Set<Integer> offsetSet = new HashSet<Integer>();
findLineBreaks(node, offsetSet);
// Add in ; replacements
TokenSequence<? extends RubyTokenId> ts = LexUtilities.getRubyTokenSequence(doc, endOffset);
if (ts != null) {
// Traverse sequence in reverse order such that my offset list is in decreasing order
ts.move(endOffset);
while (ts.movePrevious() && ts.offset() > startOffset) {
Token<? extends RubyTokenId> token = ts.token();
TokenId id = token.id();
if (id == RubyTokenId.IDENTIFIER && ";".equals(token.text().toString())) { // NOI18N
//offsetSet.add(ts.offset());
} else if (id == RubyTokenId.END || id == RubyTokenId.RBRACE) {
offsetSet.add(ts.offset());
}
}
}
List<Integer> offsets = new ArrayList<Integer>(offsetSet);
Collections.sort(offsets);
// Ensure that we go in high to lower order such that I edit the
// document from bottom to top (so offsets don't have to be adjusted
// to account for our own edits along the way)
Collections.reverse(offsets);
if (offsets.size() > 0) {
// TODO: Create a ModificationResult here and process it
// The following is the WRONG way to do it...
// I've gotta use a ModificationResult instead!
try {
// Process offsets from back to front such that I can
// modify the document without worrying that the other offsets
// need to be adjusted
int prev = -1;
int added = 0;
for (int offset : offsets) {
// We might get some dupes since we add offsets from both
// the AST newline nodes and semicolons discovered in the lexical token hierarchy
if (offset == prev) {
continue;
}
prev = offset;
// Back up over any whitespace
int whitespaces = 0;
for (int i = 1; i < 5 && offset-i > 0; i++) {
char c = doc.getText(offset-i, 1).charAt(0);
if (Character.isWhitespace(c)) {
whitespaces++;
} else {
break;
}
}
if (whitespaces > 0) {
edits.replace(offset-whitespaces, whitespaces, "\n", false, 4); // NOI18N
} else {
edits.replace(offset, 0, "\n", false, 4); // NOI18N
}
added++;
}
// Remove trailing semicolons
for (int offset : offsets) {
char c = doc.getText(offset-1, 1).charAt(0);
if (c == ';') {
edits.replace(offset-1, 1, null, false, 5);
} else if (Character.isWhitespace(c)) {
c = doc.getText(offset-2, 1).charAt(0);
if (c == ';') {
edits.replace(offset-2, 1, null, false, 5);
}
}
}
int newEnd = endOffset + added;
// Remove trailing whitespace
// TODO
} catch (BadLocationException ble) {
Exceptions.printStackTrace(ble);
}
}
edits.setFormatAll(true);
}
private void collapse(EditList edits, BaseDocument doc, Node node, int startOffset, int endOffset) {
assert endOffset <= doc.getLength();
// Look through the document and find the statement separators (;);
// at these locations I'll replace the ; with a newline and then
// apply a formatter
Set<Integer> offsetSet = new HashSet<Integer>();
findLineBreaks(node, offsetSet);
Token<? extends TokenId> t = LexUtilities.getToken(doc, startOffset);
TokenId tid = t.id();
assert tid == RubyTokenId.LBRACE || tid == RubyTokenId.DO;
boolean isDoBlock = tid == RubyTokenId.DO;
// Add in ; replacements
TokenSequence<? extends RubyTokenId> ts = LexUtilities.getRubyTokenSequence(doc, endOffset);
if (ts != null) {
// Traverse sequence in reverse order such that my offset list is in decreasing order
ts.move(endOffset);
while (ts.movePrevious() && ts.offset() > startOffset) {
Token<? extends RubyTokenId> token = ts.token();
TokenId id = token.id();
if (id == RubyTokenId.END || id == RubyTokenId.RBRACE) {
offsetSet.add(ts.offset());
}
}
}
List<Integer> offsets = new ArrayList<Integer>(offsetSet);
Collections.sort(offsets);
// Ensure that we go in high to lower order such that I edit the
// document from bottom to top (so offsets don't have to be adjusted
// to account for our own edits along the way)
//Collections.reverse(offsets);
if (offsets.size() > 0) {
// TODO: Create a ModificationResult here and process it
// The following is the WRONG way to do it...
// I've gotta use a ModificationResult instead!
try {
// Process offsets from back to front such that I can
// modify the document without worrying that the other offsets
// need to be adjusted
int prev = -1;
//int posDelta; // Amount to add to offsets to account for our
for (int i = offsets.size() - 1; i >= 0; i--) {
int offset = offsets.get(i);
// We might get some dupes since we add offsets from both
// the AST newline nodes and semicolons discovered in the lexical token hierarchy
if (offset == prev) {
continue;
}
prev = offset;
int prevOffset = i > 0 ? offsets.get(i - 1) : 0;
int segmentOffset = offset;
// TODO - use an editor-finder which can do this efficiently
// See also DocumentUtilities.getText() which can do it efficiently
int s = segmentOffset;
while (s > prevOffset) {
s--;
char c = doc.getText(s, 1).charAt(0);
if (Character.isWhitespace(c)) {
segmentOffset = s;
} else {
break;
}
}
int segmentLength = offset - segmentOffset;
s = offset - 1;
while (s < doc.getLength()) {
s++;
char c = doc.getText(s, 1).charAt(0);
if (Character.isWhitespace(c)) {
segmentLength++;
} else {
break;
}
}
// Collapse all whitespace around this offset and replace with a single "; "
char prevChar = '?';
if (segmentOffset > 0) {
prevChar = doc.getText(segmentOffset-1, 1).charAt(0);
}
if (prevChar == '|' || (isDoBlock && (segmentOffset <= startOffset + 3) || (!isDoBlock && (segmentOffset <= startOffset + 1)))) {
edits.replace(segmentOffset, segmentLength, " ", false, 4);
} else {
// Don't insert semicolons before "end" or around parens in "if (true)" etc.
boolean skipSemicolon = false;
//if (segmentOffset > 0) {
// Token tkr = LexUtilities.getToken(doc, segmentOffset-1);
// if (tkr != null && tkr.id() == RubyTokenId.RPAREN) {
// skipSemicolon = true;
// }
//}
TokenSequence<? extends TokenId> rts = LexUtilities.getRubyTokenSequence(doc, segmentOffset);
rts.move(segmentOffset);
while (rts.moveNext()) {
Token tk = rts.token();
TokenId tkid = tk.id();
if (tkid == RubyTokenId.END || tkid == RubyTokenId.RBRACE ||
tkid == RubyTokenId.LPAREN) {
skipSemicolon = true;
break;
} else if (tkid != RubyTokenId.WHITESPACE) {
break;
}
}
if (skipSemicolon) {
edits.replace(segmentOffset, segmentLength, " ", false, 4);
} else {
edits.replace(segmentOffset, segmentLength, "; ", false, 4);
}
}
}
} catch (BadLocationException ble) {
Exceptions.printStackTrace(ble);
}
}
edits.setFormatAll(true);
}
/** Determine whether parentheses are necessary around the call
* corresponding to a block call.
* For example, in
* <pre>
* b.create_menu :name => 'default_menu' do |d| ...
* </pre>
* parens are necessary if you want to switch to a brace block.
*/
private boolean isArgParenNecessary(AstPath path, BaseDocument doc) throws BadLocationException {
// Look at the surrounding CallNode and see if it has arguments.
// If so, see if it has parens. If not, return true.
assert path.leaf().getNodeType() == NodeType.ITERNODE;
Node n = path.leafParent();
if (n != null && AstUtilities.isCall(n) && n instanceof IArgumentNode &&
((IArgumentNode)n).getArgsNode() != null) {
// Yes, call has args - check parens
int end = node.getPosition().getStartOffset(); // Start of do/{ - end of args
for (int i = end-1; i >= 0 && i < doc.getLength(); i--) {
// XXX Use a more performant document content iterator!
char c = doc.getText(i, 1).charAt(0);
if (Character.isWhitespace(c)) {
continue;
}
if (c == ')') {
return false;
} else {
return true;
}
}
}
return false;
}
public boolean isSafe() {
// Different precedence rules apply for do and {}
return !convertToBrace && !convertToDo;
}
public boolean isInteractive() {
return false;
}
}
}