/* * 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.rhtml.editor; import java.awt.event.ActionEvent; import javax.swing.Action; import javax.swing.text.BadLocationException; import javax.swing.text.Caret; import javax.swing.text.Document; import javax.swing.text.JTextComponent; import javax.swing.text.TextAction; import org.netbeans.api.lexer.Token; import org.netbeans.api.lexer.TokenHierarchy; import org.netbeans.api.lexer.TokenId; import org.netbeans.api.lexer.TokenSequence; import org.netbeans.editor.BaseDocument; import org.netbeans.editor.Syntax; import org.netbeans.editor.Utilities; import org.netbeans.editor.ext.ExtKit.ExtDefaultKeyTypedAction; import org.netbeans.editor.ext.ExtKit.ToggleCommentAction; import org.netbeans.editor.ext.html.dtd.Registry; import org.netbeans.lib.editor.util.CharSequenceUtilities; import org.netbeans.lib.editor.util.swing.DocumentUtilities; import org.netbeans.modules.csl.api.DeleteToNextCamelCasePosition; import org.netbeans.modules.csl.api.DeleteToPreviousCamelCasePosition; import org.netbeans.modules.csl.api.InstantRenameAction; import org.netbeans.modules.csl.api.NextCamelCasePosition; import org.netbeans.modules.csl.api.PreviousCamelCasePosition; import org.netbeans.modules.csl.api.SelectCodeElementAction; import org.netbeans.modules.csl.api.SelectNextCamelCasePosition; import org.netbeans.modules.csl.api.SelectPreviousCamelCasePosition; import org.netbeans.modules.html.editor.api.HtmlKit; import org.netbeans.modules.html.editor.api.gsf.HtmlParserResult; import org.netbeans.modules.ruby.lexer.RubyTokenId; import org.netbeans.modules.ruby.rhtml.RhtmlDocument; import org.netbeans.modules.ruby.rhtml.lexer.api.RhtmlTokenId; import org.netbeans.modules.ruby.rhtml.spi.DtdResolver; import org.openide.util.Lookup; /** * Editor kit implementation for RHTML content type * * @todo Automatic bracket matching for RHTML files should probably split Ruby blocks up, * e.g. pressing enter here: <% if true| %> should take you -outside- of the current * block and insert a matching <% end %> outside! * @todo Hook up caret motion commands * @todo Hook up refactoring and inline rename operations * @todo Pressing newline in an EMPTY rhtml expression should NOT newline me out of the block! * * @author Marek Fukala * @author Tor Norbye * @version 1.00 */ public class RhtmlKit extends HtmlKit { @Override public org.openide.util.HelpCtx getHelpCtx() { return new org.openide.util.HelpCtx(RhtmlKit.class); } static final long serialVersionUID =-1381945567613910297L; public RhtmlKit(){ super(RhtmlTokenId.MIME_TYPE); } @Override public String getContentType() { return RhtmlTokenId.MIME_TYPE; } @Override public Document createDefaultDocument() { return new RhtmlDocument(getContentType()); } @Override public Syntax createSyntax(Document doc) { Syntax result = super.createSyntax(doc); DtdResolver resolver = Lookup.getDefault().lookup(DtdResolver.class); // need to this here instead of createDefaultDocument since the StreamDescriptionProperty // property gets set after createDefaultDocument and the DtdResolver impls typically // need to be able to get the fo for the document. Another way would be to provide // custom DataEditorSupport in RhtmlDataLoader and override createStyledDocument - the // drawback of that is that it requires copy-pasting code from SimpleES if (resolver != null) { String fallbackDtd = resolver.getIdentifier(doc); if (fallbackDtd != null) { doc.putProperty(HtmlParserResult.FALLBACK_DTD_PROPERTY_NAME, Registry.getDTD(fallbackDtd, null)); } } return result; } @Override protected DeleteCharAction createDeletePrevAction() { return new RhtmlDeleteCharAction(deletePrevCharAction, false, super.createDeletePrevAction()); } @Override protected ExtDefaultKeyTypedAction createDefaultKeyTypedAction() { return new RhtmlDefaultKeyTypedAction(super.createDefaultKeyTypedAction()); } @Override protected Action[] createActions() { Action[] superActions = super.createActions(); return TextAction.augmentList(superActions, new Action[] { // TODO - also register a Tab key action which tabs out of <% %> if the caret is near the end // (Shift Enter inserts a line below the current - perhaps that's good enough) new RhtmlToggleCommentAction(), new SelectCodeElementAction(SelectCodeElementAction.selectNextElementAction, true), new SelectCodeElementAction(SelectCodeElementAction.selectPreviousElementAction, false), new NextCamelCasePosition(findAction(superActions, nextWordAction)), new PreviousCamelCasePosition(findAction(superActions, previousWordAction)), new SelectNextCamelCasePosition(findAction(superActions, selectionNextWordAction)), new SelectPreviousCamelCasePosition(findAction(superActions, selectionPreviousWordAction)), new DeleteToNextCamelCasePosition(findAction(superActions, removeNextWordAction)), new DeleteToPreviousCamelCasePosition(findAction(superActions, removePreviousWordAction)), new InstantRenameAction(), }); } private static Action findAction(Action [] actions, String name) { for(Action a : actions) { Object nameObj = a.getValue(Action.NAME); if (nameObj instanceof String && name.equals(nameObj)) { return a; } } return null; } private boolean handleDeletion(BaseDocument doc, int dotPos) { if (dotPos > 0) { try { char ch = doc.getText(dotPos-1, 1).charAt(0); if (ch == '%') { TokenHierarchy<Document> th = TokenHierarchy.get((Document)doc); TokenSequence<?> ts = th.tokenSequence(); ts.move(dotPos); if (ts.movePrevious()) { Token<?> token = ts.token(); if (token.id() == RhtmlTokenId.DELIMITER && ts.offset()+token.length() == dotPos && ts.moveNext()) { token = ts.token(); if (token.id() == RhtmlTokenId.DELIMITER && ts.offset() == dotPos) { doc.remove(dotPos-1, 1+token.length()); return true; } } } } } catch (BadLocationException ble) { // do nothing - see #154991 } } return false; } private boolean handleInsertion(BaseDocument doc, Caret caret, char c) { int dotPos = caret.getDot(); // Bracket matching on <% %> if (c == ' ' && dotPos >= 2) { try { String s = doc.getText(dotPos-2, 2); if ("%=".equals(s) && dotPos >= 3) { // NOI18N s = doc.getText(dotPos-3, 3); } if ("<%".equals(s) || "<%=".equals(s)) { // NOI18N doc.insertString(dotPos, " ", null); caret.setDot(dotPos+1); return true; } } catch (BadLocationException ble) { // do nothing - see #154991 } return false; } if ((dotPos > 0) && (c == '%' || c == '>')) { TokenHierarchy<Document> th = TokenHierarchy.get((Document)doc); TokenSequence<?> ts = th.tokenSequence(); ts.move(dotPos); try { if (ts.moveNext() || ts.movePrevious()) { Token<?> token = ts.token(); if (token.id() == RhtmlTokenId.HTML && doc.getText(dotPos-1, 1).charAt(0) == '<') { // See if there's anything ahead int first = Utilities.getFirstNonWhiteFwd(doc, dotPos, Utilities.getRowEnd(doc, dotPos)); if (first == -1) { doc.insertString(dotPos, "%%>", null); // NOI18N caret.setDot(dotPos+1); return true; } } else if (token.id() == RhtmlTokenId.DELIMITER) { String tokenText = token.text().toString(); if (tokenText.endsWith("%>")) { // NOI18N // TODO - check that this offset is right int tokenPos = (c == '%') ? dotPos : dotPos-1; CharSequence suffix = DocumentUtilities.getText(doc, tokenPos, 2); if (CharSequenceUtilities.textEquals(suffix, "%>")) { // NOI18N caret.setDot(dotPos+1); return true; } } else if (tokenText.endsWith("<")) { // See if there's anything ahead int first = Utilities.getFirstNonWhiteFwd(doc, dotPos, Utilities.getRowEnd(doc, dotPos)); if (first == -1) { doc.insertString(dotPos, "%%>", null); // NOI18N caret.setDot(dotPos+1); return true; } } } else if ((token.id() == RhtmlTokenId.RUBY || token.id() == RhtmlTokenId.RUBY_EXPR) && dotPos >= 1 && dotPos <= doc.getLength()-3) { // If you type ">" one space away from %> it's likely that you typed // "<% foo %>" without looking at the screen; I had auto inserted %> at the end // and because I also auto insert a space without typing through it, you've now // ended up with "<% foo %> %>". Let's prevent this by interpreting typing a "" // right before %> as a duplicate for %>. I can't just do this on % since it's // quite plausible you'd have // <% x = %q(foo) %> -- if I simply moved the caret to %> when you typed the // % in %q we'd be in trouble. String s = doc.getText(dotPos-1, 4); if ("% %>".equals(s)) { // NOI18N doc.remove(dotPos-1, 2); caret.setDot(dotPos+1); return true; } } } } catch (BadLocationException ble) { // do nothing - see #154991 } } return false; } // This code used to customize the break behavior and insert a newline AFTER the closing %> tag which isn't // always what people want // private boolean handleBreak(BaseDocument doc, Caret caret) throws BadLocationException { // int dotPos = caret.getDot(); // // // First see if we're -right- before a %>, if so, just enter out // // of it // if (dotPos <= doc.getLength()-3) { // String text = doc.getText(dotPos, 3); // if (text.equals(" %>") || text.startsWith("%>") || text.equals("-%>") || text.equals("% -%")) { // NOI18N // TokenHierarchy<Document> th = TokenHierarchy.get((Document)doc); // TokenSequence<?> ts = th.tokenSequence(); // ts.move(dotPos); // if (ts.moveNext()) { // // Go backwards and make sure we have nothing before the previous // // delimiter // TokenId id = ts.token().id(); // boolean notJustSpace = false; // if (id == RhtmlTokenId.RUBY || id == RhtmlTokenId.RUBY_EXPR || id == RhtmlTokenId.DELIMITER) { // do { // id = ts.token().id(); // if (id == RhtmlTokenId.DELIMITER && ts.token().text().charAt(0) == '<') { // if (notJustSpace ) { // caret.setDot(dotPos + text.indexOf('>')+1); // return true; // } // return false; // } else if (id == RhtmlTokenId.RUBY || id == RhtmlTokenId.RUBY_EXPR) { // if (!notJustSpace) { // TokenSequence<?> ets = ts.embedded(); // if (ets != null) { // ets.moveStart(); // while (ets.moveNext()) { // if (ets.token().id() != RubyTokenId.WHITESPACE) { // notJustSpace = true; // } // } // } // } // } // } while (ts.movePrevious()); // } // } // } // } // // return false; // } private class RhtmlDefaultKeyTypedAction extends ExtDefaultKeyTypedAction { private ExtDefaultKeyTypedAction htmlAction; RhtmlDefaultKeyTypedAction(ExtDefaultKeyTypedAction htmlAction) { this.htmlAction = htmlAction; } @Override public void actionPerformed(ActionEvent evt, JTextComponent target) { Caret caret = target.getCaret(); BaseDocument doc = (BaseDocument)target.getDocument(); String cmd = evt.getActionCommand(); if (cmd.length() > 0) { char c = cmd.charAt(0); if (handleInsertion(doc, caret, c)) { return; } } htmlAction.actionPerformed(evt, target); } } private class RhtmlDeleteCharAction extends ExtDeleteCharAction { //implements NextCharProvider { XXX - Parsing API private DeleteCharAction htmlAction; public RhtmlDeleteCharAction(String nm, boolean nextChar, DeleteCharAction htmlAction) { super(nm, nextChar); this.htmlAction = htmlAction; } @Override public void actionPerformed(ActionEvent evt, JTextComponent target) { target.putClientProperty(ExtDeleteCharAction.class, this); try { Caret caret = target.getCaret(); BaseDocument doc = (BaseDocument)target.getDocument(); int dotPos = caret.getDot(); if (handleDeletion(doc, dotPos)) { return; } htmlAction.actionPerformed(evt, target); } finally { target.putClientProperty(ExtDeleteCharAction.class, null); } } public boolean getNextChar() { return nextChar; } } @Override public Object clone() { return new RhtmlKit(); } private static Token<?> getToken(BaseDocument doc, int offset, boolean checkEmbedded) { TokenHierarchy<Document> th = TokenHierarchy.get((Document)doc); TokenSequence<?> ts = th.tokenSequence(); ts.move(offset); if (!ts.moveNext() && !ts.movePrevious()) { return null; } if (checkEmbedded) { TokenSequence<?> es = ts.embedded(); if (es != null) { es.move(offset); if (es.moveNext() || es.movePrevious()) { return es.token(); } } } return ts.token(); } /** * Toggle comment action. Doesn't actually reuse much of the implementation * but subclasses to inherit the icon and description */ public static class RhtmlToggleCommentAction extends ToggleCommentAction { static final long serialVersionUID = -1L; private static final String ERB_PREFIX = "<%"; // NOI18N private static final String ERB_COMMENT = "<%#"; // NOI18N private static final String ERB_TEXT = "<%#*"; // NOI18N private static final String ERB_SUFFIX = "%>"; // NOI18N private static final int ERB_PREFIX_LEN = ERB_PREFIX.length(); private static final int ERB_SUFFIX_LEN = ERB_SUFFIX.length(); private static final int ERB_COMMENT_LEN = ERB_COMMENT.length(); private static final int ERB_TEXT_LEN = ERB_TEXT.length(); public RhtmlToggleCommentAction() { super(ERB_COMMENT); } @Override public void actionPerformed(ActionEvent evt, JTextComponent target) { commentUncomment(evt, target, null); } /** See if this line looks commented */ private static boolean isLineCommented(BaseDocument doc, int textBegin) throws BadLocationException { assert textBegin != -1; Token<?> token = getToken(doc, textBegin, false); if (token != null) { TokenId id = token.id(); if (id == RhtmlTokenId.DELIMITER) { // Could be either <% or <%# or even <% # if (token.text().toString().endsWith("#")) { return true; } // Handle "<% #" etc. int first = Utilities.getFirstNonWhiteFwd(doc, textBegin+token.length(), Utilities.getRowEnd(doc, textBegin)); if (first == -1) { return false; } else { char c = DocumentUtilities.getText(doc, first, 1).charAt(0); return c == '#'; } } else if (id == RhtmlTokenId.RUBY || id == RhtmlTokenId.RUBY_EXPR) { // We're in the middle of some Ruby - check it token = getToken(doc, textBegin, true); return token.id() == RubyTokenId.LINE_COMMENT; } else if (id == RhtmlTokenId.RUBYCOMMENT) { return true; } else { // We don't consider HTML comments commented out - want RHTML commenting return false; } } int textEnd = Utilities.getRowLastNonWhite(doc, textBegin)+1; if (textEnd - textBegin < ERB_COMMENT_LEN) { return false; } CharSequence maybeLineComment = DocumentUtilities.getText(doc, textBegin, ERB_COMMENT_LEN); if (!CharSequenceUtilities.textEquals(maybeLineComment, ERB_COMMENT)) { return false; } return true; } private void commentUncomment(ActionEvent evt, final JTextComponent target, final Boolean forceComment) { if (target != null) { if (!target.isEditable() || !target.isEnabled()) { target.getToolkit().beep(); return; } final BaseDocument doc = (BaseDocument)target.getDocument(); doc.runAtomic(new Runnable() { public @Override void run() { try { Caret caret = target.getCaret(); int startPos; int endPos; if (caret.isSelectionVisible()) { startPos = Utilities.getRowStart(doc, target.getSelectionStart()); endPos = target.getSelectionEnd(); if (endPos > 0 && Utilities.getRowStart(doc, endPos) == endPos && endPos > startPos) { endPos--; } endPos = Utilities.getRowEnd(doc, endPos); } else { // selection not visible startPos = Utilities.getRowStart(doc, caret.getDot()); endPos = Utilities.getRowEnd(doc, caret.getDot()); } int lineCount = Utilities.getRowCount(doc, startPos, endPos); boolean comment = forceComment != null ? forceComment : !allComments(doc, startPos, lineCount); if (comment) { comment(doc, startPos, lineCount); } else { uncomment(doc, startPos, lineCount); } } catch (BadLocationException e) { target.getToolkit().beep(); } } }); } } private boolean allComments(BaseDocument doc, int startOffset, int lineCount) throws BadLocationException { for (int offset = startOffset; lineCount > 0; lineCount--) { int firstNonWhitePos = Utilities.getRowFirstNonWhite(doc, offset); if (firstNonWhitePos != -1) { // Ignore empty lines if (!isLineCommented(doc, firstNonWhitePos)) { return false; } } offset = Utilities.getRowStart(doc, offset, +1); } return true; } private void comment(BaseDocument doc, int startOffset, int lineCount) throws BadLocationException { for (int offset = startOffset; lineCount > 0; lineCount--, offset = Utilities.getRowStart(doc, offset, +1)) { // TODO - if the line starts with "<%", put the "#" inside! if (Utilities.isRowEmpty(doc, offset) || Utilities.isRowWhite(doc, offset)) { continue; } int textBegin = Utilities.getRowFirstNonWhite(doc, offset); Token<?> token = getToken(doc, textBegin, false); if (token != null) { TokenId id = token.id(); if (id == RhtmlTokenId.DELIMITER) { if (!token.text().toString().endsWith("#")) { doc.insertString(textBegin+ERB_PREFIX_LEN, "#", null); // NOI18N } } else if (id == RhtmlTokenId.RUBY || id == RhtmlTokenId.RUBY_EXPR) { // We're in the middle of some Ruby - check it token = getToken(doc, textBegin, true); doc.insertString(textBegin, "#", null); // NOI18N } else if (id == RhtmlTokenId.RUBYCOMMENT) { //return true; } else { // Plain text or HTML doc.insertString(textBegin, ERB_TEXT, null); // NOI18N doc.insertString(Utilities.getRowLastNonWhite(doc, offset)+1, ERB_SUFFIX, null); // NOI18N } continue; } int textEnd = Utilities.getRowEnd(doc, offset); if (textEnd-offset >= ERB_PREFIX_LEN) { // See if it's a <% prefix // TODO - handle nested <%# applications! CharSequence maybeLineComment = DocumentUtilities.getText(doc, textBegin, ERB_PREFIX_LEN); if (CharSequenceUtilities.textEquals(maybeLineComment, ERB_PREFIX)) { doc.insertString(textBegin+ERB_PREFIX_LEN, "#", null); // NOI18N continue; } } doc.insertString(textBegin, ERB_TEXT, null); // NOI18N doc.insertString(Utilities.getRowLastNonWhite(doc, offset)+1, ERB_SUFFIX, null); // NOI18N } } private void uncomment(BaseDocument doc, int startOffset, int lineCount) throws BadLocationException { for (int offset = startOffset; lineCount > 0; lineCount--, offset = Utilities.getRowStart(doc, offset, +1)) { if (Utilities.isRowEmpty(doc, offset) || Utilities.isRowWhite(doc, offset)) { continue; } // Get the first non-whitespace char on the current line int textBegin = Utilities.getRowFirstNonWhite(doc, offset); Token<?> token = getToken(doc, textBegin, false); if (token != null) { TokenId id = token.id(); if (id == RhtmlTokenId.DELIMITER) { // Perhaps something like - todo <% #" // TODO! if (token.text().toString().endsWith("#")) { int textEnd = Utilities.getRowLastNonWhite(doc, textBegin)+1; if (textEnd-textBegin >= ERB_TEXT_LEN) { CharSequence maybeLineComment = DocumentUtilities.getText(doc, textBegin, ERB_TEXT_LEN); String maybeLineEnd = DocumentUtilities.getText(doc, textEnd-ERB_SUFFIX_LEN, ERB_SUFFIX_LEN).toString(); if (CharSequenceUtilities.textEquals(maybeLineComment, ERB_TEXT)) { doc.remove(textBegin, ERB_TEXT_LEN); if (CharSequenceUtilities.textEquals(maybeLineEnd, ERB_SUFFIX)) { doc.remove(textEnd-ERB_SUFFIX_LEN-ERB_TEXT_LEN, ERB_SUFFIX_LEN); } continue; } } // Else it is probably a regular Ruby expression; we remove ONLY the "#" inside if (textEnd-textBegin >= ERB_COMMENT_LEN) { CharSequence maybeLineComment = DocumentUtilities.getText(doc, textBegin, ERB_COMMENT_LEN); if (CharSequenceUtilities.textEquals(maybeLineComment, ERB_COMMENT)) { // Remove just the # doc.remove(textBegin+2, 1); continue; } } } else { int first = Utilities.getFirstNonWhiteFwd(doc, textBegin+token.length(), Utilities.getRowEnd(doc, textBegin)); if (first != -1) { char c = DocumentUtilities.getText(doc, first, 1).charAt(0); if (c == '#') { doc.remove(first, 1); } } } } else if (id == RhtmlTokenId.RUBY || id == RhtmlTokenId.RUBY_EXPR) { // We're in the middle of some Ruby - check it token = getToken(doc, textBegin, true); if (token.id() == RubyTokenId.LINE_COMMENT) { doc.remove(textBegin, 1); } //} else if (id == RhtmlTokenId.RUBYCOMMENT) { } continue; } // Is this a "text" line, or a Ruby line? // Text lines have an additional "*" in them int textEnd = Utilities.getRowLastNonWhite(doc, textBegin)+1; if (textEnd-textBegin >= ERB_TEXT_LEN) { CharSequence maybeLineComment = DocumentUtilities.getText(doc, textBegin, ERB_TEXT_LEN); CharSequence maybeLineEnd = DocumentUtilities.getText(doc, textEnd-ERB_SUFFIX_LEN, ERB_SUFFIX_LEN); if (CharSequenceUtilities.textEquals(maybeLineComment, ERB_TEXT)) { doc.remove(textBegin, ERB_TEXT_LEN); if (CharSequenceUtilities.textEquals(maybeLineEnd, ERB_SUFFIX)) { doc.remove(textEnd-ERB_SUFFIX_LEN-ERB_TEXT_LEN, ERB_SUFFIX_LEN); } continue; } } // Else it is probably a regular Ruby expression; we remove ONLY the "#" inside if (textEnd-textBegin >= ERB_COMMENT_LEN) { CharSequence maybeLineComment = DocumentUtilities.getText(doc, textBegin, ERB_COMMENT_LEN); if (CharSequenceUtilities.textEquals(maybeLineComment, ERB_COMMENT)) { // Remove just the # doc.remove(textBegin+2, 1); continue; } } } } } }