blob: d8a56b0536634382f651bff70e7d82a6958baa8c [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.netbeans.modules.javascript2.jquery.editor;
import org.netbeans.modules.javascript2.jquery.PropertyNameDataItem;
import org.netbeans.modules.javascript2.jquery.SelectorItem;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.netbeans.api.lexer.Token;
import org.netbeans.api.lexer.TokenHierarchy;
import org.netbeans.api.lexer.TokenSequence;
import org.netbeans.api.project.FileOwnerQuery;
import org.netbeans.api.project.Project;
import org.netbeans.modules.csl.api.CodeCompletionContext;
import org.netbeans.modules.csl.api.CompletionProposal;
import org.netbeans.modules.csl.api.ElementHandle;
import org.netbeans.modules.csl.api.ElementKind;
import org.netbeans.modules.csl.spi.ParserResult;
import org.netbeans.modules.css.indexing.api.CssIndex;
import org.netbeans.modules.html.editor.lib.api.HtmlVersion;
import org.netbeans.modules.html.editor.lib.api.model.*;
import org.netbeans.modules.javascript2.editor.spi.CompletionContext;
import org.netbeans.modules.javascript2.lexer.api.JsTokenId;
import org.netbeans.modules.javascript2.lexer.api.LexUtilities;
import org.netbeans.modules.javascript2.editor.spi.CompletionProvider;
import org.netbeans.modules.javascript2.jquery.PropertyNameDataLoader;
import org.netbeans.modules.javascript2.jquery.model.JQueryUtils;
import org.netbeans.modules.javascript2.jquery.SelectorsLoader;
import org.netbeans.modules.javascript2.jquery.editor.JQueryCompletionItem.DocSimpleElement;
import org.openide.filesystems.FileObject;
import org.openide.modules.InstalledFileLocator;
import org.openide.util.Exceptions;
/**
*
* @author Petr Pisl
*/
@CompletionProvider.Registration(priority=10)
public class JQueryCodeCompletion implements CompletionProvider {
private static final Logger LOGGER = Logger.getLogger(JQueryCodeCompletion.class.getName());
public static final String HELP_LOCATION = "docs/jquery-api.xml"; //NOI18N
private static File jQueryApiFile;
private static final String PROPERTY_NAME_FILE_LOCATION = "docs/jquery-propertyNames.xml"; //NOI18N
private static File propertyNameFile;
private static Collection<HtmlTagAttribute> allAttributes;
private int lastTsOffset = 0;
@SuppressWarnings("fallthrough")
public List<CompletionProposal> complete(CodeCompletionContext ccContext, CompletionContext jsCompletionContext, String prefix) {
long start = System.currentTimeMillis();
List<CompletionProposal> result = new ArrayList<CompletionProposal>();
ParserResult parserResult = ccContext.getParserResult();
int offset = ccContext.getCaretOffset();
lastTsOffset = ccContext.getParserResult().getSnapshot().getEmbeddedOffset(offset);
switch (jsCompletionContext) {
case IN_STRING:
TokenSequence<? extends JsTokenId> ts = LexUtilities.getJsTokenSequence(parserResult.getSnapshot().getTokenHierarchy(), offset);
if (ts != null) {
ts.move(lastTsOffset);
if (ts.moveNext()) {
if (ts.token().id() == JsTokenId.STRING_END) {
ts.movePrevious();
}
if (ts.token().id() == JsTokenId.STRING) {
prefix = ts.token().text().toString().substring(0, lastTsOffset - ts.offset());
}
}
}
case GLOBAL:
case EXPRESSION:
case OBJECT_PROPERTY:
if (JQueryUtils.isInJQuerySelector(parserResult, lastTsOffset)) {
addSelectors(result, parserResult, prefix, lastTsOffset, ccContext);
}
break;
case OBJECT_PROPERTY_NAME:
completeObjectPropertyName(ccContext, result, prefix);
break;
default:
break;
}
long end = System.currentTimeMillis();
LOGGER.log(Level.FINE, "Counting jQuery CC took {0}ms ", (end - start));
return result;
}
private int findParamIndex(ParserResult parserResult, int offset) {
TokenSequence<? extends JsTokenId> ts = LexUtilities.getJsTokenSequence(parserResult.getSnapshot().getTokenHierarchy(), offset);
if (ts == null) {
return -1;
}
ts.move(offset);
if (!(ts.moveNext() && ts.movePrevious())) {
return -1;
}
Token<? extends JsTokenId> token = LexUtilities.findNext(ts, Arrays.asList(JsTokenId.WHITESPACE));
// count index of parameters
int paramIndex = 0;
while(token.id() != JsTokenId.EOL && token.id() != JsTokenId.BRACKET_LEFT_PAREN) {
if (token.id() == JsTokenId.OPERATOR_COMMA) {
paramIndex ++;
} else if (token.id() == JsTokenId.OPERATOR_DOT) {
// we are not inside ()
return -1;
}
token = LexUtilities.findNext(ts, Arrays.asList(JsTokenId.WHITESPACE));
}
if (token.id() == JsTokenId.BRACKET_LEFT_PAREN) {
return paramIndex;
}
return -1;
}
private String findFunctionName(ParserResult parserResult, int offset) {
TokenSequence<? extends JsTokenId> ts = LexUtilities.getJsTokenSequence(parserResult.getSnapshot().getTokenHierarchy(), offset);
if (ts == null) {
return null;
}
ts.move(offset);
if (!(ts.moveNext() && ts.movePrevious())) {
return null;
}
Token<? extends JsTokenId> token = LexUtilities.findNext(ts, Arrays.asList(JsTokenId.WHITESPACE));
while(token.id() != JsTokenId.EOL && token.id() != JsTokenId.BRACKET_LEFT_PAREN) {
if (token.id() == JsTokenId.OPERATOR_DOT) {
// we are not inside ()
return null;
}
token = LexUtilities.findNext(ts, Arrays.asList(JsTokenId.WHITESPACE));
}
if (token.id() == JsTokenId.BRACKET_LEFT_PAREN && ts.movePrevious()) {
token = LexUtilities.findNext(ts, Arrays.asList(JsTokenId.WHITESPACE));
if (token.id() == JsTokenId.IDENTIFIER){
return token.text().toString();
}
}
return null;
}
@Override
public String getHelpDocumentation(ParserResult info, ElementHandle element) {
if (element != null && element instanceof DocSimpleElement) {
return ((DocSimpleElement)element).getDocumentation();
}
if (element != null && element.getKind() == ElementKind.CALL) {
String name = element.getName();
name = name.substring(1); // remove :
int index = name.indexOf('(');
if (index > -1) {
name = name.substring(0, index);
}
return SelectorsLoader.getDocumentation(getJQueryAPIFile(), name);
} else if (element != null && element.getKind() == ElementKind.METHOD) {
if (JQueryUtils.isJQuery(info, lastTsOffset)) {
return SelectorsLoader.getMethodDocumentation(getJQueryAPIFile(), element.getName());
}
}
return null;
}
private void completeObjectPropertyName(CodeCompletionContext ccContext, List<CompletionProposal> result, String prefix) {
// find the object that can be configured
TokenHierarchy<?> th = ccContext.getParserResult().getSnapshot().getTokenHierarchy();
if (th == null) {
return;
}
int carretOffset = ccContext.getCaretOffset();
int eOffset = ccContext.getParserResult().getSnapshot().getEmbeddedOffset(carretOffset);
TokenSequence<? extends JsTokenId> ts = LexUtilities.getJsTokenSequence(th, eOffset);
if (ts == null) {
return;
}
ts.move(eOffset);
if (!ts.moveNext() && !ts.movePrevious()){
return;
}
Token<? extends JsTokenId> token = null;
JsTokenId tokenId;
//find the begining of the object literal
int balance = 1;
while (ts.movePrevious() && balance > 0) {
token = ts.token();
tokenId = token.id();
if (tokenId == JsTokenId.BRACKET_RIGHT_CURLY) {
balance++;
} else if (tokenId == JsTokenId.BRACKET_LEFT_CURLY) {
balance--;
}
}
if (token == null || balance != 0) {
return;
}
// now we should be at the beginning of the object literal.
token = LexUtilities.findPreviousToken(ts, Arrays.asList(JsTokenId.IDENTIFIER));
tokenId = token.id();
StringBuilder sb = new StringBuilder(token.text());
while ((tokenId == JsTokenId.IDENTIFIER || tokenId == JsTokenId.OPERATOR_DOT) && ts.movePrevious()) {
token = ts.token(); tokenId = token.id();
if (tokenId == JsTokenId.OPERATOR_DOT) {
sb.insert(0, '.'); // NOI18N
} else if (tokenId == JsTokenId.IDENTIFIER) {
sb.insert(0, token.text());
}
}
String fqn = sb.toString();
Map<String, Collection<PropertyNameDataItem>> data = getPropertyNameData();
if (fqn.startsWith(JQueryUtils.JQUERY$)) { // NOI18N
fqn = fqn.replace(JQueryUtils.JQUERY$, JQueryUtils.JQUERY); //NOI18N
}
Collection<PropertyNameDataItem> items = data.get(fqn);
int anchorOffset = ccContext.getParserResult().getSnapshot().getOriginalOffset(eOffset) - ccContext.getPrefix().length();
if (items != null) {
boolean addComma = addComma(ts, eOffset);
for (PropertyNameDataItem item : items) {
if (item.getName().startsWith(prefix)) {
result.add(JQueryCompletionItem.createPropertyNameItem(item, anchorOffset, addComma));
}
}
}
}
private static synchronized Map<String, Collection<PropertyNameDataItem>> getPropertyNameData() {
return PropertyNameDataLoader.getData(getPropertyNameDataFile());
}
private static synchronized File getPropertyNameDataFile() {
if (propertyNameFile == null) {
propertyNameFile = InstalledFileLocator.getDefault().locate(PROPERTY_NAME_FILE_LOCATION, "org.netbeans.modules.javascript2.jquery", false); //NOI18N
}
return propertyNameFile;
}
private boolean addComma(TokenSequence<? extends JsTokenId> ts, int eOffset) {
// we know that we are at the position, where the name of property is entered
ts.move(eOffset);
if (ts.moveNext()) {
// we are looking for ',' -> don't add comma, is already there
// ':' or an identifier -> need to add comma, next expression is new property definition
// '}' -> end of object literal object definition -> no need comma there
Token<? extends JsTokenId>token = LexUtilities.findNext(ts, Arrays.asList(JsTokenId.WHITESPACE, JsTokenId.EOL, JsTokenId.BLOCK_COMMENT, JsTokenId.LINE_COMMENT));
if (token.id() == JsTokenId.IDENTIFIER || token.id() == JsTokenId.STRING_BEGIN) {
return true;
}
}
return false;
}
private enum SelectorKind {
TAG, TAG_ATTRIBUTE, CLASS, ID, TAG_ATTRIBUTE_COMPARATION, AFTER_COLON
}
private static class SelectorContext {
String prefix;
Collection<SelectorKind> kinds;
int prefixIndex;
public SelectorContext(String prefix, int prefixIndex, Collection<SelectorKind> kinds) {
this.prefix = prefix;
this.kinds = kinds;
this.prefixIndex = prefixIndex;
}
}
private static HashMap<String, List<SelectorKind>> contextMap = new HashMap<String, List<SelectorKind>>();
private static Collection< SelectorItem> afterColonList = Collections.emptyList();
private void fillContextMap() {
contextMap.put(" (", Arrays.asList(SelectorKind.TAG, SelectorKind.ID, SelectorKind.CLASS, SelectorKind.AFTER_COLON));
contextMap.put("#", Arrays.asList(SelectorKind.ID));
contextMap.put(".", Arrays.asList(SelectorKind.CLASS));
contextMap.put("[", Arrays.asList(SelectorKind.TAG_ATTRIBUTE));
contextMap.put(":", Arrays.asList(SelectorKind.AFTER_COLON));
}
private void fillAfterColonList() {
if(getJQueryAPIFile() != null) {
afterColonList = SelectorsLoader.getSelectors(getJQueryAPIFile());
}
}
private synchronized File getJQueryAPIFile() {
if (jQueryApiFile == null) {
jQueryApiFile = InstalledFileLocator.getDefault().locate(HELP_LOCATION, "org.netbeans.modules.javascript2.jquery", false); //NOI18N
}
return jQueryApiFile;
}
private SelectorContext findSelectorContext(String text) {
int index = text.length() - 1;
StringBuilder prefix = new StringBuilder();
while (index > -1) {
char c = text.charAt(index);
switch (c) {
case ' ':
case '(':
case ',':
return new SelectorContext(prefix.toString(), index, Arrays.asList(SelectorKind.TAG, SelectorKind.ID, SelectorKind.CLASS));
case '#':
return new SelectorContext(prefix.toString(), index, Arrays.asList(SelectorKind.ID));
case '.':
return new SelectorContext(prefix.toString(), index, Arrays.asList(SelectorKind.CLASS));
case '[':
return new SelectorContext(prefix.toString(), index, Arrays.asList(SelectorKind.TAG_ATTRIBUTE));
case ':':
return new SelectorContext(prefix.toString(), index, Arrays.asList(SelectorKind.AFTER_COLON));
}
prefix.insert(0, c);
index--;
}
if (index < 0) {
return new SelectorContext(prefix.toString(), 0, Arrays.asList(SelectorKind.TAG, SelectorKind.ID, SelectorKind.CLASS, SelectorKind.AFTER_COLON));
}
return null;
}
private void addSelectors(final List<CompletionProposal> result, final ParserResult parserResult, final String prefix, final int offset, CodeCompletionContext ccContex) {
/*
* basic selectors:
* $(document); // Activate jQuery for object
* $('#mydiv') // Element with ID "mydiv"
* $('p.first') // P tags with class first.
* $('p[title="Hello"]') // P tags with title "Hello"
* $('p[title^="H"]') // P tags title starting with H
*/
TokenSequence<? extends JsTokenId> ts = LexUtilities.getJsTokenSequence(parserResult.getSnapshot().getTokenHierarchy(), offset);
if (ts == null) {
return;
}
ts.move(offset);
if (!(ts.moveNext() && ts.movePrevious())) {
return;
}
String wrapup = ""; //NOI18N
String prefixText = prefix;
int anchorOffsetDelta = prefix.length() - ccContex.getPrefix().length();
if (!prefixText.isEmpty() && anchorOffsetDelta > 0) {
char ch = prefixText.charAt(anchorOffsetDelta - 1);
if (ch == '#' || ch == '.' || ch == ':') {
anchorOffsetDelta--;
}
}
// if (!(ts.token().id() == JsTokenId.STRING || ts.token().id() == JsTokenId.STRING_END || ts.token().id() == JsTokenId.STRING_BEGIN)) {
// wrapup = "'"; //NOI18N
// if (ts.token().id() == JsTokenId.IDENTIFIER) {
// ts.movePrevious();
// }
// if(ts.token().id() == JsTokenId.OPERATOR_COLON) {
// prefixText = ":" + prefixText; //NOI18N
// anchorOffsetDelta = prefix.isEmpty() ? 0 : -1;
// } else if (ts.token().id() == JsTokenId.OPERATOR_DOT) {
// prefixText = "." + prefixText; //NOI18N
// anchorOffsetDelta = prefix.isEmpty() ? 0 : -1;
// } else {
// anchorOffsetDelta = 0;
// }
//// if (prefix.isEmpty()) {
//// anchorOffsetDelta = 1;
//// }
//
//
// }
if(contextMap.isEmpty()) {
fillContextMap();
}
SelectorContext context = findSelectorContext(prefixText);
if (context != null) {
int docOffset = parserResult.getSnapshot().getOriginalOffset(offset) - prefixText.length();
int anchorOffset = docOffset + anchorOffsetDelta;
for (SelectorKind selectorKind : context.kinds) {
switch (selectorKind) {
case TAG:
Collection<HtmlTag> tags = getHtmlTags(context.prefix);
for (HtmlTag htmlTag : tags) {
result.add(JQueryCompletionItem.create(htmlTag, anchorOffset, wrapup));
}
break;
case TAG_ATTRIBUTE:
// provide attributes
int index = prefix.lastIndexOf('[');
String tagName = "";
if (index > 0) {
tagName = prefix.substring(0, index);
}
if (!tagName.isEmpty()) {
index = tagName.lastIndexOf(' ');
if (index > -1) {
tagName = tagName.substring(index + 1);
}
index = tagName.indexOf('.');
if (index > -1) {
tagName = tagName.substring(0, index);
}
index = tagName.indexOf('#');
if (index > -1) {
tagName = tagName.substring(0, index);
}
index = tagName.lastIndexOf('(');
if (index > -1) {
tagName = tagName.substring(index + 1);
}
}
if (!tagName.isEmpty() && (tagName.charAt(0) == '.' || tagName.charAt(0) == '#' || tagName.charAt(0) == '(')) {
if (ts.token().id() == JsTokenId.STRING_BEGIN) {
ts.moveNext();
}
if (ts.token().id() == JsTokenId.STRING) {
String value = ts.token().text().toString();
index = value.indexOf(prefix);
if (index > -1) {
tagName = value.substring(0, index);
index--;
while (index > -1) {
char ch = tagName.charAt(index);
if (ch == '.' || ch == '#' || ch == ',' || ch == '='
|| ch == '"' || ch == '\'' || ch == '['
|| ch == ']' || ch == '(' || ch == ')'
|| ch == ':') {
break;
}
index --;
}
if (index > -1) {
tagName = tagName.substring(index + 1);
}
} else {
tagName = "";
}
} else {
tagName = "";
}
}
String attributePrefix = prefix.substring(context.prefixIndex + 1);
anchorOffset = docOffset + prefix.length() - context.prefix.length();
Collection<HtmlTagAttribute> attributes = getHtmlAttributes(tagName, attributePrefix);
for (HtmlTagAttribute htmlTagAttribute : attributes) {
result.add(JQueryCompletionItem.create(htmlTagAttribute, anchorOffset, ""));
}
break;
case ID:
Collection<String> tagIds = getTagIds(context.prefix, parserResult);
for (String tagId : tagIds) {
result.add(JQueryCompletionItem.createCSSItem("#" + tagId, anchorOffset, wrapup));
}
break;
case CLASS:
Collection<String> classes = getCSSClasses(context.prefix, parserResult);
for (String cl : classes) {
result.add(JQueryCompletionItem.createCSSItem("." + cl, anchorOffset, wrapup));
}
break;
case AFTER_COLON:
if(afterColonList.isEmpty()) {
fillAfterColonList();
}
for (SelectorItem selector : afterColonList) {
if (selector.getDisplayText().startsWith(context.prefix)) {
result.add(JQueryCompletionItem.createJQueryItem(":" + selector.getDisplayText(), anchorOffset, wrapup, selector.getInsertTemplate()));
}
}
break;
}
}
}
}
private Collection<String> getTagIds(String tagIdPrefix, ParserResult parserResult) {
FileObject fo = parserResult.getSnapshot().getSource().getFileObject();
if (fo == null) {
return Collections.emptyList();
}
Project project = FileOwnerQuery.getOwner(fo);
HashSet<String> unique = new HashSet<String>();
try {
CssIndex cssIndex = CssIndex.create(project);
Map<FileObject, Collection<String>> findIdsByPrefix = cssIndex.findIdsByPrefix(tagIdPrefix);
for (Collection<String> ids : findIdsByPrefix.values()) {
for (String id : ids) {
unique.add(id);
}
}
} catch (IOException ex) {
Exceptions.printStackTrace(ex);
}
return unique;
}
private Collection<String> getCSSClasses(String classPrefix, ParserResult parserResult) {
FileObject fo = parserResult.getSnapshot().getSource().getFileObject();
if(fo == null) {
return Collections.emptyList();
}
Project project = FileOwnerQuery.getOwner(fo);
HashSet<String> unique = new HashSet<String>();
try {
CssIndex cssIndex = CssIndex.create(project);
Map<FileObject, Collection<String>> findIdsByPrefix = cssIndex.findClassesByPrefix(classPrefix);
for (Collection<String> ids : findIdsByPrefix.values()) {
for (String id : ids) {
unique.add(id);
}
}
} catch (IOException ex) {
Exceptions.printStackTrace(ex);
}
return unique;
}
private Collection<HtmlTagAttribute> getHtmlAttributes(final String tagName, final String prefix) {
Collection<HtmlTagAttribute> result = Collections.<HtmlTagAttribute>emptyList();
HtmlModel htmlModel = HtmlModelFactory.getModel(HtmlVersion.HTML5);
HtmlTag htmlTag = htmlModel.getTag(tagName);
if (htmlTag != null) {
if (prefix.isEmpty()) {
if (tagName.isEmpty()) {
result = getAllAttributes(htmlModel);
} else {
result = htmlTag.getAttributes();
}
} else {
Collection<HtmlTagAttribute> attributes = htmlTag.getAttributes();
if (tagName.isEmpty() || htmlTag.getTagClass() == HtmlTagType.UNKNOWN) {
attributes = getAllAttributes(htmlModel);
}
result = new ArrayList<HtmlTagAttribute>();
for (HtmlTagAttribute htmlTagAttribute : attributes) {
if(htmlTagAttribute.getName().startsWith(prefix)) {
result.add(htmlTagAttribute);
}
}
}
}
return result;
}
private Collection<HtmlTag> getHtmlTags(String prefix) {
Collection<HtmlTag> result = Collections.emptyList();
HtmlModel htmlModel = HtmlModelFactory.getModel(HtmlVersion.HTML5);
Collection<HtmlTag> allTags = htmlModel.getAllTags();
if (prefix.isEmpty()) {
result = allTags;
} else {
result = new ArrayList<HtmlTag>();
for (HtmlTag htmlTag : allTags) {
if (htmlTag.getName().startsWith(prefix)) {
result.add(htmlTag);
}
}
}
return result;
}
private synchronized Collection<HtmlTagAttribute> getAllAttributes(HtmlModel htmlModel) {
if (allAttributes == null) {
initAllAttributes(htmlModel);
}
return allAttributes;
}
private synchronized void initAllAttributes(HtmlModel htmlModel) {
assert allAttributes == null;
Map<String, HtmlTagAttribute> result = new HashMap<String, HtmlTagAttribute>();
for (HtmlTag htmlTag : htmlModel.getAllTags()) {
for (HtmlTagAttribute htmlTagAttribute : htmlTag.getAttributes()) {
// attributes can probably differ per tag so we can just offer some of them,
// at least for the CC purposes it should be complete list of attributes for unknown tag
if (!result.containsKey(htmlTagAttribute.getName())) {
result.put(htmlTagAttribute.getName(), htmlTagAttribute);
}
}
}
allAttributes = result.values();
}
}