/*
* 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):
*
* The Original Software is NetBeans. The Initial Developer of the Original
* Software is Sun Microsystems, Inc. Portions Copyright 1997-2006 Sun
* Microsystems, Inc. All Rights Reserved.
*
* If you wish your version of this file to be governed by only the CDDL
* or only the GPL Version 2, indicate your decision by adding
* "[Contributor] elects to include this software in this distribution
* under the [CDDL or GPL Version 2] license." If you do not indicate a
* single choice of license, a recipient has the option to distribute
* your version of this file under either the CDDL, the GPL Version 2 or
* to extend the choice of license to its licensees as provided above.
* However, if you add GPL Version 2 code and therefore, elected the GPL
* Version 2 license, then the option applies only if the new code is
* made subject to such option by the copyright holder.
*/
package org.netbeans.modules.ruby;
import java.awt.event.ActionEvent;
import javax.swing.text.BadLocationException;
import javax.swing.text.JTextComponent;
import org.netbeans.api.lexer.Token;
import org.netbeans.api.lexer.TokenSequence;
import org.netbeans.editor.BaseAction;
import org.netbeans.editor.BaseDocument;
import org.netbeans.editor.Utilities;
import org.netbeans.modules.csl.api.OffsetRange;
import org.netbeans.modules.csl.spi.GsfUtilities;
import org.netbeans.modules.editor.indent.api.IndentUtils;
import org.netbeans.modules.ruby.lexer.LexUtilities;
import org.netbeans.modules.ruby.lexer.RubyTokenId;
import org.netbeans.modules.ruby.options.CodeStyle;
import org.openide.filesystems.FileObject;
import org.openide.util.Exceptions;
/**
* Reflow paragraphs (currently, rdoc comments and =begin/=end documentatio sections.)
* Take RDoc conventions into consideration such that preformatted rdoc text is left alone,
* bulleted lists get properly aligned, etc.
*
* @author Tor Norbye
*/
public class ReflowParagraphAction extends BaseAction {
public ReflowParagraphAction() {
super("ruby-reflow-paragraph", 0); // NOI18N
}
@Override
public Class getShortDescriptionBundleClass() {
return ReflowParagraphAction.class;
}
@Override
public void actionPerformed(ActionEvent evt, final JTextComponent target) {
if (target.getCaret() == null) {
return;
}
FileObject fo = GsfUtilities.findFileObject(target);
if (fo != null) {
int offset = target.getCaret().getDot();
new ParagraphFormatter(false, target, null, -1).reflowParagraph(offset);
}
}
public static void reflowEditedComment(JTextComponent target) {
if (target.getCaret() == null) {
return;
}
int offset = target.getCaret().getDot();
new ParagraphFormatter(true, target, null, -1).reflowParagraph(offset);
}
public static void reflowComments(BaseDocument doc, int start, int end, int rightMargin) {
// Locate all comments in the given document and format them
ParagraphFormatter formatter = new ParagraphFormatter(false, null, doc, rightMargin);
formatter.reflow(start, end);
}
private static class ParagraphFormatter {
private JTextComponent target;
private BaseDocument doc;
private int oldCaretPosition = -1;
private boolean inVerbatim;
private boolean indentedList;
private boolean inList;
private int listIndentation;
private boolean documentation;
private final StringBuilder sb = new StringBuilder(500);
private StringBuilder buffer = new StringBuilder();
private int indent = 4;
private int rightMargin;
private boolean currentSectionOnly;
private final char CARET_MARKER = '\u4dca'; // Random character inserted into the text and formatted to represent the caret
/**
* @param currentSectionOnly Whether it should reflow the entire paragraph or only the current section
*/
ParagraphFormatter(boolean currentSectionOnly, JTextComponent target, BaseDocument doc, int rightMargin) {
this.currentSectionOnly = currentSectionOnly;
this.target = target;
if (target != null) {
this.doc = (BaseDocument)target.getDocument();
this.oldCaretPosition = target.getCaret() != null ? target.getCaret().getDot() : null;
} else {
this.doc = doc;
}
if (rightMargin != -1) {
this.rightMargin = rightMargin;
} else {
this.rightMargin = CodeStyle.get(this.doc).getRightMargin();// EditorOptions.get(RubyInstallation.RUBY_MIME_TYPE).getRightMargin();
}
}
private void reflow(int start, int end) {
try {
outer:
while (end >= start) {
// Search backwards from end for the first comment
TokenSequence<? extends RubyTokenId> ts = LexUtilities.getRubyTokenSequence(doc, end);
if (ts == null) {
return;
}
ts.move(end);
int offset = end;
while (ts.movePrevious() && ts.offset() >= start) {
offset = ts.offset();
Token<? extends RubyTokenId> token = ts.token();
if (token.id() == RubyTokenId.DOCUMENTATION || token.id() == RubyTokenId.LINE_COMMENT) {
OffsetRange range = findParagraph(offset);
if (range != OffsetRange.NONE) {
end = Utilities.getRowStart(doc, range.getStart())-1;
reflowParagraph(offset);
continue outer;
}
}
}
end = Utilities.getRowStart(doc, offset)-1;
}
}
catch (BadLocationException ble){
// do nothing - see #154991
}
}
private void reflowParagraph(int offset) {
try {
offset = Utilities.getRowFirstNonWhite(doc, offset);
if (offset == -1) {
// PENDING - Be smarter about empty lines -- do previous line for example? Or next?
return;
}
Token<? extends RubyTokenId> token = LexUtilities.getToken(doc, offset);
if (token == null) {
return;
}
if (token.id() == RubyTokenId.DOCUMENTATION) {
documentation = true;
} else if (token.id() != RubyTokenId.LINE_COMMENT) {
// Currently only reflows comments...
// In RHTML I could reflow text too...
// And even in Ruby, I could compute the surrounding block (e.g. look for empty lines
// on both sides?, or {start}/end
// blocks), and reindent that as a "paragraph"
return;
}
OffsetRange range = findParagraph(offset);
if (range != OffsetRange.NONE) {
reflow(range);
}
}
catch (BadLocationException ble){
// do nothing - see #154991
}
}
private OffsetRange findParagraph(int offset) {
try {
int start = Utilities.getRowStart(doc, offset);
int end = Utilities.getRowEnd(doc, offset);
while (offset >= 0) {
// Find beginning of the paragraph
if (Utilities.isRowEmpty(doc, offset) || Utilities.isRowWhite(doc, offset)) {
if (currentSectionOnly) {
break;
}
// Empty lines not allowed within an rdoc
if (documentation) {
// Empty lines are okay
offset = Utilities.getRowStart(doc, offset)-1;
continue;
}
break;
}
offset = Utilities.getRowStart(doc, offset);
int lineBegin = Utilities.getRowFirstNonWhite(doc, offset);
Token<? extends RubyTokenId> token = LexUtilities.getToken(doc, lineBegin);
if (token == null) {
break;
}
if (token.id() == RubyTokenId.DOCUMENTATION) {
String line = doc.getText(offset, Utilities.getRowEnd(doc, offset)-offset);
if (line.startsWith("=begin")) {
// We're done
break;
}
start = offset;
} else if (token.id() == RubyTokenId.LINE_COMMENT) {
start = offset;
} else {
break;
}
// If currentSectionOnly I can stop reformatting when I get up to
// a preformatted section or a numbered section etc. where it breaks with
// previous formatting
if (currentSectionOnly) {
if (!documentation) { // I'm catching empty documentation lines with the "isRowWhite" above
String line = doc.getText(lineBegin, Utilities.getRowEnd(doc, lineBegin)-lineBegin);
if (line.startsWith("#") && line.length() == 1 || line.equals("# ") ||/* line.startsWith("# ") ||*/
line.startsWith("# *") || line.startsWith("# - ")) {
break;
}
}
}
// Previous line
offset--;
}
int length = doc.getLength();
offset = end;
while (offset < length) {
// Find beginning of the paragraph
if (Utilities.isRowEmpty(doc, offset) || Utilities.isRowWhite(doc, offset)) {
if (currentSectionOnly) {
break;
}
if (documentation) {
// Empty lines are okay
offset = Utilities.getRowEnd(doc, offset)+1;
continue;
}
// Empty lines not allowed within an rdoc
break;
}
offset = Utilities.getRowStart(doc, offset);
int lineBegin = Utilities.getRowFirstNonWhite(doc, offset);
int lineEnd = Utilities.getRowEnd(doc, offset);
Token<? extends RubyTokenId> token = LexUtilities.getToken(doc, lineBegin);
if (token == null) {
break;
}
if (token.id() == RubyTokenId.DOCUMENTATION) {
String line = doc.getText(offset, lineEnd-offset);
if (line.startsWith("=end")) {
// We're done
break;
}
end = lineEnd;
} else if (token.id() == RubyTokenId.LINE_COMMENT) {
end = lineEnd;
} else {
break;
}
// If currentSectionOnly I can stop reformatting when I get down to
// a preformatted section or a numbered section etc. where it breaks with
// previous formatting
if (currentSectionOnly) {
if (!documentation) { // I'm catching empty documentation lines with the "isRowWhite" above
String line = doc.getText(lineBegin, lineEnd-lineBegin);
if (line.startsWith("#") && line.length() == 1 || line.equals("# ") || /*line.startsWith("# ") ||*/
line.startsWith("# *") || line.startsWith("# - ")) {
break;
}
}
}
// Next line
offset = lineEnd + 1;
}
return new OffsetRange(start, end);
}
catch (BadLocationException ble){
// do nothing - see #154991
}
return OffsetRange.NONE;
}
private int findWordEnd(StringBuilder sb, int start) {
for (int i = start, length = sb.length(); i < length; i++) {
char c = sb.charAt(i);
if (Character.isWhitespace(c)) {
return i;
}
}
return sb.length();
}
private void reflow(OffsetRange range) throws BadLocationException {
sb.setLength(0);
final int start = range.getStart();
final int end = range.getEnd();
indent = GsfUtilities.getLineIndent(doc, start);
int offset = start;
boolean foundCaret = false;
while (offset < end) {
int textBegin = documentation ?
Utilities.getRowStart(doc, offset) :
Utilities.getRowFirstNonWhite(doc, offset);
int textEnd = Utilities.getRowLastNonWhite(doc, offset) + 1;
int lineEnd = Utilities.getRowEnd(doc, offset);
if (documentation) {
if (textEnd < textBegin) {
textEnd = lineEnd;
}
if (textBegin == -1 || textEnd == -1) {
// Blank lines can occur in documenation nodes - not in comments
assert documentation;
int lineBegin = Utilities.getRowStart(doc, offset);
textBegin = lineBegin;
textEnd = lineEnd;
}
}
String line = doc.getText(textBegin, textEnd - textBegin);
if (!foundCaret) {
int lineBegin = Utilities.getRowStart(doc, offset);
if (oldCaretPosition >= lineBegin && oldCaretPosition <= lineEnd) {
foundCaret = true;
// Include trailing whitespace
if (oldCaretPosition > textEnd) {
line = doc.getText(textBegin, oldCaretPosition - textBegin);
}
if (oldCaretPosition < textBegin) {
if (line.startsWith("#")) {
line = "#" + CARET_MARKER + line.substring(1);
} else {
line = CARET_MARKER + line;
}
} else if (oldCaretPosition > textEnd) {
line = line + CARET_MARKER;
} else {
int split = oldCaretPosition - textBegin;
if (split < line.length()) {
final String firstPart = line.substring(0, split);
final String lastPart = line.substring(split);
if (lastPart.startsWith("#") && firstPart.trim().length() == 0) {
line = firstPart + lastPart.charAt(0) + CARET_MARKER + lastPart.substring(1);
} else {
line = firstPart + CARET_MARKER + lastPart;
}
} else {
line = line + CARET_MARKER;
}
}
}
}
appendLine(line);
offset = lineEnd + 1;
}
flush();
doc.runAtomic(new Runnable() {
public void run() {
try {
String replaceWith = sb.toString();
if (replaceWith.endsWith("\n")) {
replaceWith = replaceWith.substring(0, replaceWith.length() - 1);
}
int index = replaceWith.indexOf(CARET_MARKER);
if (index != -1) {
replaceWith = replaceWith.substring(0, index) + replaceWith.substring(index + 1);
}
doc.replace(start, end - start, replaceWith, null);
if (index != -1 && target != null) {
target.getCaret().setDot(start + index);
}
}
catch (BadLocationException ble){
Exceptions.printStackTrace(ble);
}
}
});
}
public void appendLine(String text) {
if (!documentation) {
if (text.startsWith("# ")) {
text = text.substring(2);
} else if (text.startsWith("#"+CARET_MARKER + " ")) {
text = CARET_MARKER + text.substring(3);
} else if (text.equals("#")) {
// Empty comment line
text = "";
} else if (text.length() == 2 && text.equals("#" + CARET_MARKER)) {
text = "" + CARET_MARKER;
}
}
boolean isBlankLine = text.length() == 0;
int caretIndex = text.indexOf(CARET_MARKER);
if (caretIndex != -1) {
if (text.substring(0, caretIndex).trim().length() == 0 &&
text.substring(caretIndex+1).trim().length() == 0) {
isBlankLine = true;
}
}
if (isBlankLine) {
flush();
finishSection();
// Insert a blank line
startComment();
// Don't chomp spaces here - lots of comments in the Ruby libraries have "# " on empty lines
//chompSpaces();
if (caretIndex != -1) {
sb.append(CARET_MARKER);
}
sb.append("\n");
return;
}
if (text.startsWith("* ") || text.startsWith("- ") || text.matches("^[0-9]+\\.\\s*( .*)?")) {
// Starting a bulleted list, or a numbered list:
// Flush any existing items, then flow this text
flush();
if (!inList) {
finishSection();
inList = true;
}
indentedList = false;
appendFlowed(text.trim());
appendFlowed(" ");
// TODO - compute indentLevel
listIndentation = 2;
if (text.startsWith("* ") || text.startsWith("- ")) {
listIndentation = 1;
for (int i = 1; i < text.length(); i++, listIndentation++) {
if (!Character.isWhitespace(text.charAt(i))) {
break;
}
}
}
return;
} else if (text.matches("^[\\S]+::\\s*( .*)?") || text.matches("^\\[[\\S]+\\]\\s*( .+)?")) {
// Labeled lists with flowed content after the label
flush();
if (!inList) {
finishSection();
inList = true;
}
indentedList = false;
appendFlowed(text.trim());
appendFlowed(" ");
// TODO - compute indentLevel
listIndentation = 2;
// If this content had flowed text I want to flow
// it here... otherwise flush
// TODO - is the space here optional? (See :: pattern above
if (!(text.matches("^\\[[\\S]+\\] .+") || text.matches("^[\\S]+:: .+"))) {
flush();
indentedList = true;
}
return;
} else if (text.startsWith("Copyright")) {
// Copyright lines should not be coalesced
flush();
startComment();
finishSection();
sb.append(text);
sb.append("\n");
return;
} else if (!inList && text.length() > 0 && Character.isWhitespace(text.charAt(0))) {
// Indented text in list is in same paragraph
flush();
startComment();
if (!inVerbatim) {
finishSection();
inVerbatim = true;
}
sb.append(text);
sb.append("\n");
return;
} else if (text.startsWith("=") || text.startsWith("#---") || text.startsWith("---")) {
flush();
finishSection();
startComment();
sb.append(text);
sb.append("\n");
return;
} else {
if (inVerbatim) {
finishSection();
}
appendFlowed(text.trim());
appendFlowed(" ");
return;
}
}
private void startComment() {
if (!documentation) {
sb.append(IndentUtils.createIndentString(doc, indent));
sb.append("# ");
}
}
private void finishSection() {
flush();
if (inVerbatim) {
flush();
inVerbatim = false;
}
if (inList) {
flush();
indentedList = false;
inList = false;
}
}
private void appendFlowed(String text) {
int ltIndex = text.indexOf('<');
if (ltIndex != -1) {
int brIndex = text.indexOf("<br>", ltIndex); // NOI18N
if (brIndex == -1) {
brIndex = text.indexOf("<br/>", ltIndex); // NOI18N
if (brIndex == -1) {
brIndex = text.indexOf("<br />", ltIndex); // NOI18N
}
}
if (brIndex != -1) {
// Need to split the text up via linebreaks
int brEnd = text.indexOf('>', brIndex)+1;
String lineBegin = text.substring(0, brEnd);
String lineEnd = null;
if (brEnd < text.length()) {
lineEnd = text.substring(brEnd).trim();
if (lineEnd.length() == 1 && lineEnd.charAt(0) == CARET_MARKER) {
lineBegin = lineBegin + CARET_MARKER;
lineEnd = null;
}
}
buffer.append(lineBegin);
flush();
if (lineEnd != null) {
appendFlowed(lineEnd);
}
return;
}
}
buffer.append(text);
}
private void chompSpaces() {
// Chomp trailing extra space
for (int i = sb.length()-1; i >= 0; i--) {
char c = sb.charAt(i);
if (c != ' ') {
sb.setLength(i+1);
break;
}
}
}
private void flush() {
if (buffer.length() == 0) {
return;
} else if (buffer.length() == 1 && buffer.charAt(0) == ' ') {
return;
}
int column = 0;
int offset = 0;
int oldOffset = sb.length();
startComment();
if (inList && indentedList) {
sb.append(IndentUtils.createIndentString(doc, listIndentation));
}
column += sb.length() - oldOffset;
int maxWidth = rightMargin;
// Skip spaces at the beginning of the line
while (offset < buffer.length()) {
char c = buffer.charAt(offset);
if (Character.isWhitespace(c)) {
offset++;
} else {
break;
}
}
while (offset < buffer.length()) {
int start = offset;
int end = findWordEnd(buffer, start);
int wordLength = end - start;
// TODO - if the line contains the caret, reduce the right margin
if (column + wordLength > maxWidth && (wordLength < maxWidth - indent)) {
chompSpaces();
sb.append("\n"); // NOI18N
oldOffset = sb.length();
startComment();
if (inList) {
sb.append(IndentUtils.createIndentString(doc, listIndentation));
}
//sb.append(" "); // NOI18N
column = sb.length() - oldOffset;
}
for (int i = start; i < end; i++) {
char c = buffer.charAt(i);
if (c == CARET_MARKER) {
maxWidth = rightMargin+1;
}
sb.append(c);
column++;
}
offset = end;
sb.append(" "); // NOI18N
column++;
offset++;
while (offset < buffer.length()) {
char c = buffer.charAt(offset);
if (Character.isWhitespace(c)) {
if (column < rightMargin) {
sb.append(c);
column++;
}
offset++;
} else {
break;
}
}
}
// Chomp trailing extra space
chompSpaces();
sb.append("\n");
buffer.setLength(0);
}
}
}