/*
* RoxygenHelper.java
*
* Copyright (C) 2009-15 by RStudio, Inc.
*
* Unless you have received this program directly from RStudio pursuant
* to the terms of a commercial license agreement with RStudio, then
* this program is licensed to you under the terms of version 3 of the
* GNU Affero General Public License. This program is distributed WITHOUT
* ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
* MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
* AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
*
*/
package org.rstudio.studio.client.common.r.roxygen;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.JsArrayInteger;
import com.google.gwt.core.client.JsArrayString;
import com.google.inject.Inject;
import org.rstudio.core.client.Debug;
import org.rstudio.core.client.StringUtil;
import org.rstudio.core.client.regex.Match;
import org.rstudio.core.client.regex.Pattern;
import org.rstudio.studio.client.RStudioGinjector;
import org.rstudio.studio.client.common.GlobalDisplay;
import org.rstudio.studio.client.common.filetypes.DocumentMode;
import org.rstudio.studio.client.server.ServerError;
import org.rstudio.studio.client.server.ServerRequestCallback;
import org.rstudio.studio.client.workbench.views.source.editors.text.AceEditor;
import org.rstudio.studio.client.workbench.views.source.editors.text.DocDisplay;
import org.rstudio.studio.client.workbench.views.source.editors.text.Scope;
import org.rstudio.studio.client.workbench.views.source.editors.text.WarningBarDisplay;
import org.rstudio.studio.client.workbench.views.source.editors.text.ace.AceEditorNative;
import org.rstudio.studio.client.workbench.views.source.editors.text.ace.Position;
import org.rstudio.studio.client.workbench.views.source.editors.text.ace.Range;
import org.rstudio.studio.client.workbench.views.source.editors.text.ace.TokenCursor;
import java.util.ArrayList;
import java.util.Arrays;
public class RoxygenHelper
{
public RoxygenHelper(DocDisplay docDisplay,
WarningBarDisplay view)
{
editor_ = (AceEditor) docDisplay;
view_ = view;
RStudioGinjector.INSTANCE.injectMembers(this);
}
@Inject
void initialize(GlobalDisplay globalDisplay,
RoxygenServerOperations server)
{
server_ = server;
globalDisplay_ = globalDisplay;
}
private static native final String getFunctionName(Scope scope)
/*-{
return scope.attributes.name;
}-*/;
private static native final JsArrayString getFunctionArgs(Scope scope)
/*-{
return scope.attributes.args;
}-*/;
public void insertRoxygenSkeleton()
{
if (!DocumentMode.isCursorInRMode(editor_))
return;
// We check these first because we might lie within an
// anonymous function scope, whereas what we first want
// to check is for an enclosing `setGeneric` etc.
TokenCursor cursor = getTokenCursor();
if (cursor.moveToPosition(editor_.getCursorPosition(), true))
{
String enclosingScope = findEnclosingScope(cursor);
if (enclosingScope.equals("setClass"))
insertRoxygenSkeletonS4Class(cursor);
else if (enclosingScope.equals("setGeneric"))
insertRoxygenSkeletonSetGeneric(cursor);
else if (enclosingScope.equals("setMethod"))
insertRoxygenSkeletonSetMethod(cursor);
else if (enclosingScope.equals("setRefClass"))
insertRoxygenSkeletonSetRefClass(cursor);
if (enclosingScope != null)
return;
}
// If the above checks failed, we'll want to insert a
// roxygen skeleton for a 'regular' function call.
Scope scope = editor_.getCurrentScope();
if (scope != null && scope.isFunction())
{
insertRoxygenSkeletonFunction(scope);
}
else
{
globalDisplay_.showErrorMessage(
"Insert Roxygen Skeleton",
"Unable to insert skeleton (the cursor is not currently " +
"inside an R function definition).");
}
}
private TokenCursor getTokenCursor()
{
return editor_.getSession().getMode().getCodeModel().getTokenCursor();
}
private String extractCall(TokenCursor cursor)
{
// Force document tokenization
editor_.getSession().getMode().getCodeModel().tokenizeUpToRow(
editor_.getSession().getDocument().getLength());
TokenCursor clone = cursor.cloneCursor();
final Position startPos = clone.currentPosition();
if (!clone.moveToNextToken())
return null;
if (!clone.currentValue().equals("("))
return null;
if (!clone.fwdToMatchingToken())
return null;
Position endPos = clone.currentPosition();
endPos.setColumn(endPos.getColumn() + 1);
return editor_.getSession().getTextRange(
Range.fromPoints(startPos, endPos));
}
private void insertRoxygenSkeletonSetRefClass(TokenCursor cursor)
{
final Position startPos = cursor.currentPosition();
String call = extractCall(cursor);
if (call == null)
return;
server_.getSetRefClassCall(
call,
new ServerRequestCallback<SetRefClassCall>()
{
@Override
public void onResponseReceived(SetRefClassCall response)
{
if (hasRoxygenBlock(startPos))
{
amendExistingRoxygenBlock(
startPos.getRow() - 1,
response.getClassName(),
response.getFieldNames(),
response.getFieldTypes(),
"field",
RE_ROXYGEN_FIELD);
}
else
{
insertRoxygenTemplate(
response.getClassName(),
response.getFieldNames(),
response.getFieldTypes(),
"field",
"reference class",
startPos);
}
}
@Override
public void onError(ServerError error)
{
Debug.logError(error);
}
});
}
private void insertRoxygenSkeletonSetGeneric(TokenCursor cursor)
{
final Position startPos = cursor.currentPosition();
String call = extractCall(cursor);
if (call == null)
return;
server_.getSetGenericCall(
call,
new ServerRequestCallback<SetGenericCall>()
{
@Override
public void onResponseReceived(SetGenericCall response)
{
if (hasRoxygenBlock(startPos))
{
amendExistingRoxygenBlock(
startPos.getRow() - 1,
response.getGeneric(),
response.getParameters(),
null,
"param",
RE_ROXYGEN_PARAM);
}
else
{
insertRoxygenTemplate(
response.getGeneric(),
response.getParameters(),
null,
"param",
"generic function",
startPos);
}
}
@Override
public void onError(ServerError error)
{
Debug.logError(error);
}
});
}
private void insertRoxygenSkeletonSetMethod(TokenCursor cursor)
{
final Position startPos = cursor.currentPosition();
String call = extractCall(cursor);
if (call == null)
return;
server_.getSetMethodCall(
call,
new ServerRequestCallback<SetMethodCall>()
{
@Override
public void onResponseReceived(SetMethodCall response)
{
if (hasRoxygenBlock(startPos))
{
amendExistingRoxygenBlock(
startPos.getRow() - 1,
response.getGeneric(),
response.getParameterNames(),
response.getParameterTypes(),
"param",
RE_ROXYGEN_PARAM);
}
else
{
insertRoxygenTemplate(
response.getGeneric(),
response.getParameterNames(),
response.getParameterTypes(),
"param",
"method",
startPos);
}
}
@Override
public void onError(ServerError error)
{
Debug.logError(error);
}
});
}
private void insertRoxygenSkeletonS4Class(TokenCursor cursor)
{
final Position startPos = cursor.currentPosition();
String setClassCall = extractCall(cursor);
if (setClassCall == null)
return;
server_.getSetClassCall(
setClassCall,
new ServerRequestCallback<SetClassCall>()
{
@Override
public void onResponseReceived(SetClassCall response)
{
if (hasRoxygenBlock(startPos))
{
amendExistingRoxygenBlock(
startPos.getRow() - 1,
response.getClassName(),
response.getSlots(),
null,
"slot",
RE_ROXYGEN_SLOT);
}
else
{
insertRoxygenTemplate(
response.getClassName(),
response.getSlots(),
response.getTypes(),
"slot",
"S4 class",
startPos);
}
}
@Override
public void onError(ServerError error)
{
Debug.logError(error);
}
});
}
private String findEnclosingScope(TokenCursor cursor)
{
if (ROXYGEN_ANNOTATABLE_CALLS.contains(cursor.currentValue()))
return cursor.currentValue();
// Check to see if we're on e.g. `x <- setRefClass(...)`.
if (cursor.isLeftAssign() &&
ROXYGEN_ANNOTATABLE_CALLS.contains(cursor.nextValue()))
{
cursor.moveToNextToken();
return cursor.currentValue();
}
if (ROXYGEN_ANNOTATABLE_CALLS.contains(cursor.nextValue(2)))
{
cursor.moveToNextToken();
cursor.moveToNextToken();
return cursor.currentValue();
}
while (cursor.currentValue().equals(")"))
if (!cursor.moveToPreviousToken())
return null;
while (cursor.findOpeningBracket("(", false))
{
if (!cursor.moveToPreviousToken())
return null;
if (ROXYGEN_ANNOTATABLE_CALLS.contains(cursor.currentValue()))
return cursor.currentValue();
}
return null;
}
public void insertRoxygenSkeletonFunction(Scope scope)
{
// Attempt to find the bounds for the roxygen block
// associated with this function, if it exists
if (hasRoxygenBlock(scope.getPreamble()))
{
amendExistingRoxygenBlock(
scope.getPreamble().getRow() - 1,
getFunctionName(scope),
getFunctionArgs(scope),
null,
"param",
RE_ROXYGEN_PARAM);
}
else
{
insertRoxygenTemplate(
getFunctionName(scope),
getFunctionArgs(scope),
null,
"param",
"function",
scope.getPreamble());
}
}
private void amendExistingRoxygenBlock(
int row,
String objectName,
JsArrayString argNames,
JsArrayString argTypes,
String tagName,
Pattern pattern)
{
// Get the range encompassing this Roxygen block.
Range range = getRoxygenBlockRange(row);
// Extract that block (as an array of strings)
JsArrayString block = extractRoxygenBlock(
editor_.getWidget().getEditor(),
range);
// If the block contains roxygen parameters that require
// non-local information (e.g. @inheritParams), then
// bail.
for (int i = 0; i < block.length(); i++)
{
if (RE_ROXYGEN_NONLOCAL.test(block.get(i)))
{
view_.showWarningBar(
"Cannot automatically update roxygen blocks " +
"that are not self-contained.");
return;
}
}
String roxygenDelim = RE_ROXYGEN.match(block.get(0), 0).getGroup(1);
// The replacement block (we build by munging parts of
// the old block
JsArrayString replacement = JsArray.createArray().cast();
// Scan through the block to get the names of
// pre-existing parameters.
JsArrayString params = listParametersInRoxygenBlock(
block,
pattern);
// Figure out what parameters need to be removed, and remove them.
// Any parameter not mentioned in the current function's argument list
// should be stripped out.
JsArrayString paramsToRemove = setdiff(params, argNames);
int blockLength = block.length();
for (int i = 0; i < blockLength; i++)
{
// If we encounter a param we don't want to extract, then
// move over it.
Match match = pattern.match(block.get(i), 0);
if (match != null && contains(paramsToRemove, match.getGroup(1)))
{
i++;
while (i < blockLength && !RE_ROXYGEN_WITH_TAG.test(block.get(i)))
i++;
i--;
continue;
}
replacement.push(block.get(i));
}
// Now, add example roxygen for any parameters that are
// present in the function prototype, but not present
// within the roxygen block.
int insertionPosition = findParamsInsertionPosition(replacement, pattern);
JsArrayInteger indices = setdiffIndices(argNames, params);
// NOTE: modifies replacement
insertNewTags(
replacement,
argNames,
argTypes,
indices,
roxygenDelim,
tagName,
insertionPosition);
// Ensure space between final param and next tag
ensureSpaceBetweenFirstParamAndPreviousEntry(replacement, roxygenDelim, pattern);
ensureSpaceBetweenFinalParamAndNextTag(replacement, roxygenDelim, pattern);
// Apply the replacement.
editor_.getSession().replace(range, replacement.join("\n") + "\n");
}
private void ensureSpaceBetweenFirstParamAndPreviousEntry(
JsArrayString replacement,
String roxygenDelim,
Pattern pattern)
{
int n = replacement.length();
for (int i = 1; i < n; i++)
{
if (pattern.test(replacement.get(i)))
{
if (!RE_ROXYGEN_EMPTY.test(replacement.get(i - 1)))
spliceIntoArray(replacement, roxygenDelim, i);
return;
}
}
}
private void ensureSpaceBetweenFinalParamAndNextTag(
JsArrayString replacement,
String roxygenDelim,
Pattern pattern)
{
int n = replacement.length();
for (int i = n - 1; i >= 0; i--)
{
if (pattern.test(replacement.get(i)))
{
i++;
if (i < n && RE_ROXYGEN_WITH_TAG.test(replacement.get(i)))
{
spliceIntoArray(replacement, roxygenDelim, i);
}
return;
}
}
}
private static final native void spliceIntoArray(
JsArrayString array,
String string,
int pos)
/*-{
array.splice(pos, 0, string);
}-*/;
private static final native void insertNewTags(
JsArrayString array,
JsArrayString argNames,
JsArrayString argTypes,
JsArrayInteger indices,
String roxygenDelim,
String tagName,
int position)
/*-{
var newRoxygenEntries = [];
for (var i = 0; i < indices.length; i++) {
var idx = indices[i];
var arg = argNames[idx];
var type = argTypes == null ? null : argTypes[idx];
var entry = roxygenDelim + " @" + tagName + " " + arg + " ";
if (type != null)
entry += type = ". ";
newRoxygenEntries.push(entry);
}
Array.prototype.splice.apply(
array,
[position, 0].concat(newRoxygenEntries)
);
}-*/;
private int findParamsInsertionPosition(
JsArrayString block,
Pattern pattern)
{
// Try to find the last '@param' block, and insert after that.
int n = block.length();
for (int i = n - 1; i >= 0; i--)
{
if (pattern.test(block.get(i)))
{
i++;
// Move up to the next tag (or end)
while (i < n && !RE_ROXYGEN_WITH_TAG.test(block.get(i)))
i++;
return i - 1;
}
}
// Try to find the first tag, and insert before that.
for (int i = 0; i < n; i++)
if (RE_ROXYGEN_WITH_TAG.test(block.get(i)))
return i;
// Just insert at the end
return block.length();
}
private static JsArrayString setdiff(
JsArrayString self,
JsArrayString other)
{
JsArrayString result = JsArray.createArray().cast();
for (int i = 0; i < self.length(); i++)
if (!contains(other, self.get(i)))
result.push(self.get(i));
return result;
}
private static JsArrayInteger setdiffIndices(
JsArrayString self,
JsArrayString other)
{
JsArrayInteger result = JsArray.createArray().cast();
for (int i = 0; i < self.length(); i++)
if (!contains(other, self.get(i)))
result.push(i);
return result;
}
private static native final boolean contains(
JsArrayString array,
String object)
/*-{
for (var i = 0, n = array.length; i < n; i++)
if (array[i] === object)
return true;
return false;
}-*/;
private static native final JsArrayString extractRoxygenBlock(
AceEditorNative editor,
Range range)
/*-{
var lines = editor.getSession().getDocument().$lines;
return lines.slice(range.start.row, range.end.row);
}-*/;
private JsArrayString listParametersInRoxygenBlock(
JsArrayString block,
Pattern pattern)
{
JsArrayString roxygenParams = JsArrayString.createArray().cast();
for (int i = 0; i < block.length(); i++)
{
String line = block.get(i);
Match match = pattern.match(line, 0);
if (match != null)
roxygenParams.push(match.getGroup(1));
}
return roxygenParams;
}
private Range getRoxygenBlockRange(int row)
{
while (row >= 0 && StringUtil.isWhitespace(editor_.getLine(row)))
row--;
int blockEnd = row + 1;
while (row >= 0 && RE_ROXYGEN.test(editor_.getLine(row)))
row--;
if (row == 0 && !RE_ROXYGEN.test(editor_.getLine(row)))
row++;
int blockStart = row + 1;
return Range.fromPoints(
Position.create(blockStart, 0),
Position.create(blockEnd, 0));
}
private boolean hasRoxygenBlock(Position position)
{
int row = position.getRow() - 1;
if (row < 0) return false;
// Skip whitespace.
while (row >= 0 && StringUtil.isWhitespace(editor_.getLine(row)))
row--;
// Check if we landed on an Roxygen block.
return RE_ROXYGEN.test(editor_.getLine(row));
}
private void insertRoxygenTemplate(
String name,
JsArrayString argNames,
JsArrayString argTypes,
String argTagName,
String type,
Position position)
{
String roxygenParams = argsToExampleRoxygen(
argNames,
argTypes,
argTagName);
// Add some spacing between params and the next tags,
// if there were one or more arguments.
if (argNames.length() != 0)
roxygenParams += "\n#'\n";
String block =
"#' Title\n" +
"#'\n" +
roxygenParams +
"#' @return\n" +
"#' @export\n" +
"#'\n" +
"#' @examples\n";
Position insertionPosition = Position.create(
position.getRow(),
0);
editor_.insertCode(insertionPosition, block);
}
private String argsToExampleRoxygen(
JsArrayString argNames,
JsArrayString argTypes,
String tagName)
{
String roxygen = "";
if (argNames.length() == 0) return "";
if (argTypes == null)
{
roxygen += argToExampleRoxygen(argNames.get(0), null, tagName);
for (int i = 1; i < argNames.length(); i++)
roxygen += "\n" + argToExampleRoxygen(argNames.get(i), null, tagName);
}
else
{
roxygen += argToExampleRoxygen(argNames.get(0), argTypes.get(0), tagName);
for (int i = 1; i < argNames.length(); i++)
roxygen += "\n" + argToExampleRoxygen(argNames.get(i), argTypes.get(i), tagName);
}
return roxygen;
}
private String argToExampleRoxygen(String argName, String argType, String tagName)
{
String output = "#' @" + tagName + " " + argName + " ";
if (argType != null)
output += argType + ". ";
return output;
}
public static class SetClassCall extends JavaScriptObject
{
protected SetClassCall() {}
public final native String getClassName() /*-{ return this["Class"]; }-*/;
public final native JsArrayString getSlots() /*-{ return this["slots"]; }-*/;
public final native JsArrayString getTypes() /*-{ return this["types"]; }-*/;
public final native int getNumSlots() /*-{ return this["slots"].length; }-*/;
}
public static class SetGenericCall extends JavaScriptObject
{
protected SetGenericCall() {}
public final native String getGeneric() /*-{ return this["generic"]; }-*/;
public final native JsArrayString getParameters() /*-{ return this["parameters"]; }-*/;
}
public static class SetMethodCall extends JavaScriptObject
{
protected SetMethodCall() {}
public final native String getGeneric() /*-{ return this["generic"]; }-*/;
public final native JsArrayString getParameterNames() /*-{ return this["parameter.names"]; }-*/;
public final native JsArrayString getParameterTypes() /*-{ return this["parameter.types"]; }-*/;
}
public static class SetRefClassCall extends JavaScriptObject
{
protected SetRefClassCall() {}
public final native String getClassName() /*-{ return this["Class"]; }-*/;
public final native JsArrayString getFieldNames() /*-{ return this["field.names"]; }-*/;
public final native JsArrayString getFieldTypes() /*-{ return this["field.types"]; }-*/;
}
private final AceEditor editor_;
private final WarningBarDisplay view_;
private GlobalDisplay globalDisplay_;
private RoxygenServerOperations server_;
private static final Pattern RE_ROXYGEN =
Pattern.create("^(\\s*#+')", "");
private static final Pattern RE_ROXYGEN_EMPTY =
Pattern.create("^\\s*#+'\\s*$", "");
private static final Pattern RE_ROXYGEN_PARAM =
Pattern.create("^\\s*#+'\\s*@param\\s+([^\\s]+)", "");
private static final Pattern RE_ROXYGEN_FIELD =
Pattern.create("^\\s*#+'\\s*@field\\s+([^\\s]+)", "");
private static final Pattern RE_ROXYGEN_SLOT =
Pattern.create("^\\s*#+'\\s*@slot\\s+([^\\s]+)", "");
private static final Pattern RE_ROXYGEN_WITH_TAG =
Pattern.create("^\\s*#+'\\s*@[^@]", "");
private static final ArrayList<String> ROXYGEN_ANNOTATABLE_CALLS =
new ArrayList<String>(
Arrays.asList(new String[] {
"setClass",
"setRefClass",
"setMethod",
"setGeneric"
}));
private static final Pattern RE_ROXYGEN_NONLOCAL =
Pattern.create("^\\s*#+'\\s*@(?:inheritParams|template)", "");
}