io.deephaven.lang.completion.ChunkerCompleter Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of deephaven-open-api-lang-tools Show documentation
Show all versions of deephaven-open-api-lang-tools Show documentation
The 'open-api-lang-tools' project
//
// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending
//
package io.deephaven.lang.completion;
import io.deephaven.engine.context.QueryScope;
import io.deephaven.engine.table.ColumnDefinition;
import io.deephaven.engine.table.Table;
import io.deephaven.engine.table.TableDefinition;
import io.deephaven.engine.context.QueryScope.MissingVariableException;
import io.deephaven.io.logger.Logger;
import io.deephaven.lang.api.HasScope;
import io.deephaven.lang.api.IsScope;
import io.deephaven.lang.completion.results.*;
import io.deephaven.lang.generated.*;
import io.deephaven.lang.parse.CompletionParser;
import io.deephaven.lang.parse.LspTools;
import io.deephaven.lang.parse.ParsedDocument;
import io.deephaven.proto.backplane.script.grpc.*;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Uses a ChunkerDocument to lookup user cursor and perform autocompletion.
*/
public class ChunkerCompleter implements CompletionHandler {
public enum SearchDirection {
LEFT, BOTH, RIGHT
}
private static final Pattern CAMEL_PATTERN = Pattern.compile("(?=\\p{Lu})");
public static final String CONTAINS_NEWLINE = ".*\\R.*"; // \R is "any newline" in java 8
// For now, only testing uses this property, when we want to test column expressions without the noise of static
// methods
// Since we don't really expect clients to twiddle this, we only consider it as a system property.
public static final String PROP_SUGGEST_STATIC_METHODS = "suggest.all.static.methods";
private final Logger log;
private final QueryScope variables;
private final CompletionLookups lookups;
private String defaultQuoteType;
private ParsedDocument doc;
public ChunkerCompleter(final Logger log, QueryScope variables) {
this(log, variables, new CompletionLookups(Collections.emptySet()));
}
public ChunkerCompleter(final Logger log, QueryScope variables, CompletionLookups lookups) {
this.log = log;
this.variables = variables;
this.lookups = lookups;
}
public CompletableFuture extends Collection> complete(String command, int offset) {
final long start = System.nanoTime();
CompletionParser parser = new CompletionParser();
try {
final ParsedDocument doc = parser.parse(command);
// Remove the parsing time from metrics; this parsing should be done before now,
// with a parsed document ready to go immediately.
final long parseTime = System.nanoTime();
return CompletableFuture.supplyAsync(() -> {
final Set results = runCompletion(doc, offset);
final long end = System.nanoTime();
log.info()
.append("Found ")
.append(results.size())
.append(" completion items;\nparse time:\t")
.append(parseTime - start)
.append("nanos;\ncompletion time: ")
.append(end - parseTime)
.append("nanos")
.endl();
return results.stream()
.map(this::toFragment)
.collect(Collectors.toList());
});
} catch (ParseException e) {
final CompletableFuture extends Collection> future = new CompletableFuture<>();
future.completeExceptionally(e);
// TODO: better logging here; preferably writing to a system diagnostics table. IDS-1517-16
log.info()
.append("Parse error in experimental completion api")
.append(e)
.append(" found in source\n")
.append(command)
.endl();
return future;
}
}
private CompletionFragment toFragment(CompletionItemOrBuilder item) {
return new CompletionFragment(item.getStart(), item.getLength(), item.getTextEdit().getText(), item.getLabel());
}
@Override
public Collection runCompletion(final ParsedDocument doc, final Position pos,
final int offset) {
final List results = new ArrayList<>();
this.doc = doc;
String src = doc.getSource();
final Node node = doc.findNode(offset);
if (node instanceof ChunkerDocument || src.trim().isEmpty()) {
return results;
}
CompletionRequest req = new CompletionRequest(this, src, offset);
searchForResults(doc, results, node, req, SearchDirection.BOTH);
// post-process results for cases when the suggestion does not
// actually replace the user's cursor position (it's much easier to do this
// once in a universal fashion than to put it on each completer to figure out).
// TODO: make tests route through this methods to check on fixRanges behavior.
// The CompletionFragment (autocomplete V1) methods used by tests are better
// for checking the result string, since they require less insanity to reconstitute
// a plain result string to assert upon. IDS-1517-21
fixRanges(doc, results, node, pos);
if ("true".equals(System.getProperty("test.monaco.sanity"))) {
// // We may want to manually test what monaco does with various formats of result objects,
// // so we have this block of code hidden behind an off-by-default system property
// results.add(new CompletionItem(0, 0, "A1", "A1", new DocumentRange(
// pos.plus(0, -2), pos
// )).sortText("A1"));
//
// results.add(new CompletionItem(0, 0, "A2", "A2", new DocumentRange(
// pos.plus(0, -2), pos.plus(0, 1)
// )).sortText("A2"));
//
// results.add(new CompletionItem(0, 0, "B3", "B3", new DocumentRange(
// pos.plus(0, -2), pos.plus(0, 2)
// )).sortText("A3"));
//
// results.add(new CompletionItem(0, 0, "B1", "B1", new DocumentRange(
// pos, pos
// )).sortText("B1"));
//
// results.add(new CompletionItem(0, 0, "B2", "B2", new DocumentRange(
// pos, pos.plus(0, 1)
// )).sortText("B2"));
//
// results.add(new CompletionItem(0, 0, "A3", "A3", new DocumentRange(
// pos, pos.plus(0, 2)
// )).sortText("B3"));
}
return results;
}
/**
* Part 1 of the V2 completion api; adapting our API to fit into existing CompletionHandler semantics.
*
* Right now we are just blindly re-parsing the whole document when using the old api, which is going to be
* good-enough-for-now; this may also allow us to borrow the existing unit tests to some degree.
*
* @param doc
* @param offset
* @return
*/
public Set runCompletion(ParsedDocument doc, int offset) {
this.doc = doc;
final Node node = doc.findNode(offset);
String src = doc.getSource();
if (node instanceof ChunkerDocument || src.trim().isEmpty()) {
return Collections.emptySet();
}
final List results = new ArrayList<>();
CompletionRequest req = new CompletionRequest(this, src, offset);
searchForResults(doc, results, node, req, SearchDirection.BOTH);
final Set built = new LinkedHashSet<>();
for (CompletionItem.Builder result : results) {
built.add(result.build());
}
return built;
}
/**
* So, despite no documentation describing the exact semantics, monaco is _very_ picky about the format of
* completions it receives.
*
* The list of invariants we have to obey:
*
* The main text edit (Range and String) is stored on the CompletionItem itself. The main Range must _start_ at the
* user's cursor, and end at the earlier of: A) The end of the text we want to replace B) The end of line where the
* cursor is.
*
* Any other changes we want must be placed into the additionalTextEdits fields. This includes: A) Any changes on
* the same line as cursor that come before the cursor. B) Any changes on lines where the cursor is not currently
* placed.
*
* Due to this unfortunate complexity, we are doing this in a post-processing phase (here), so that individual
* completion provider logic only has to handle "replace text X with Y", and we'll figure out the correct
* incantation to keep monaco happy here.
*
* @param parsed The parsed document (bag of state related to the source document).
* @param res A set of CompletionItem to fixup.
* @param node The original node we started the search from (for ~O(1) token searching)
* @param cursor The position of the user's cursor (the location we need to slice from)
*/
private void fixRanges(
ParsedDocument parsed,
Collection res,
Node node,
Position cursor) {
int ind = 0;
for (CompletionItem.Builder item : res) {
final Position requested = cursor.toBuilder().build();
item.setSortText(sortable(ind++));
final DocumentRange result = item.getTextEdit().getRange();
if (LspTools.greaterThan(result.getStart(), requested)) {
// The result starts after the user's cursor.
// adjust the text edit back, stopping at the cursor.
if (log.isTraceEnabled()) {
log.trace()
.append("No extendStart support yet; result: ")
.append(result.toString())
.nl()
.append("Requested: ")
.append(requested.toString())
.endl();
}
continue;
} else if (LspTools.lessThan(result.getEnd(), requested)) {
// adjust the text edit forwards, appending tokens to result.
extendEnd(item, requested, node);
} else if (LspTools.lessThan(result.getStart(), requested)) {
// The result starts before the cursor.
// Move the part up to the cursor into an additional edit.
final TextEdit.Builder edit = sliceBefore(item, requested, node);
if (edit == null) {
// could not process this edit. TODO: We should log this case at least. IDS-1517-31
continue;
}
item.addAdditionalTextEdits(edit);
} else {
assert result.getStart().equals(requested);
}
// now, if the result spans multiple lines, we need to break it into multiple ranges,
// since monaco only supports same-line text edits.
if (result.getStart().getLine() != result.getEnd().getLine()) {
List broken = new ArrayList<>();
// TODO: also split up the additional text edits, once they actually support multiline operations.
// IDS-1517-31
}
item.setLabel(item.getTextEdit().getText());
}
}
public TextEdit.Builder sliceBefore(CompletionItem.Builder item, Position requested, Node node) {
final TextEdit.Builder edit = TextEdit.newBuilder();
final DocumentRange.Builder range = DocumentRange.newBuilder(item.getTextEditBuilder().getRange());
Token tok = node.findToken(range.getStart());
Position.Builder start = tok.positionStart();
if (start.getLine() != requested.getLine() || range.getStart().getLine() != requested.getLine()) {
// not going to worry about this highly unlikely and complex corner case just yet.
return null;
}
// advance the position to the start of the replacement range.
int imageInd = 0;
while (LspTools.lessThan(start, range.getStart())) {
start.setCharacter(start.getCharacter() + 1);
imageInd++;
}
range.setStart(start.build()).setEnd(requested);
edit.setRange(range);
StringBuilder b = new StringBuilder();
// now, from here, gobble up the token contents as we advance the position to the requested index.
while (LspTools.lessThan(start, requested)) {
if (LspTools.lessOrEqual(tok.positionEnd(false), start)) {
// find next non-empty token
final Token startTok = tok;
while (tok.next != null) {
tok = tok.next;
if (!tok.image.isEmpty()) {
break;
}
}
if (tok != startTok) {
// shouldn't really happen, but this is way better than potential infinite loops of doom
break;
}
imageInd = 0;
start = tok.positionStart();
}
start.setCharacter(start.getCharacter() + 1);
if (!tok.image.isEmpty()) {
b.append(tok.image.charAt(imageInd));
}
imageInd++;
}
edit.setText(b.toString());
return edit;
}
private TextEdit.Builder extendEnd(final CompletionItem.Builder item, final Position requested, final Node node) {
final TextEdit.Builder edit = TextEdit.newBuilder();
final DocumentRange.Builder range = DocumentRange.newBuilder(item.getTextEditBuilder().getRange());
Token tok = node.findToken(range.getStart());
Position.Builder start = tok.positionStart();
if (start.getLine() != requested.getLine() || range.getStart().getLine() != requested.getLine()) {
// not going to worry about this highly unlikely and complex corner case just yet.
return null;
}
// advance the position to the start of the replacement range.
int imageInd = 0;
while (LspTools.lessThan(start, range.getStart())) {
start.setCharacter(start.getCharacter() + 1);
imageInd++;
}
range.setStart(start.build()).setEnd(requested);
edit.setRange(range);
StringBuilder b = new StringBuilder();
// now, from here, gobble up the token contents as we advance the position to the requested index.
while (LspTools.lessThan(start, requested)) {
if (LspTools.lessOrEqual(tok.positionEnd(false), start)) {
// find next non-empty token
final Token startTok = tok;
while (tok.next != null) {
tok = tok.next;
if (!tok.image.isEmpty()) {
break;
}
}
if (tok != startTok) {
// shouldn't really happen, but this is way better than potential infinite loops of doom
break;
}
imageInd = 0;
start = tok.positionStart();
}
start.setCharacter(start.getCharacter() + 1);
if (!tok.image.isEmpty()) {
b.append(tok.image.charAt(imageInd));
}
imageInd++;
}
edit.setText(b.toString());
return edit;
}
public static String sortable(int i) {
StringBuilder res = new StringBuilder(Integer.toString(i, 36));
while (res.length() < 5) {
res.insert(0, "0");
}
return res.toString();
}
private void searchForResults(
ParsedDocument doc,
Collection results,
Node node,
CompletionRequest request,
SearchDirection direction) {
// alright! let's figure out where the user's cursor is, and what we can help them with.
node.jjtAccept(new ChunkerVisitor() {
@Override
public Object visit(SimpleNode node, Object data) {
return unsupported(node);
}
@Override
public Object visitChunkerDocument(ChunkerDocument node, Object data) {
return unsupported(node);
}
@Override
public Object visitChunkerStatement(ChunkerStatement node, Object data) {
return unsupported(node);
}
@Override
public Object visitChunkerJavaClassDecl(ChunkerJavaClassDecl node, Object data) {
return unsupported(node);
}
@Override
public Object visitChunkerAssign(ChunkerAssign node, Object data) {
assignCompletion(results, node, request);
return null;
}
@Override
public Object visitChunkerTypedAssign(ChunkerTypedAssign node, Object data) {
typedAssignCompletion(results, node, request);
return null;
}
@Override
public Object visitChunkerTypeDecl(ChunkerTypeDecl node, Object data) {
return unsupported(node);
}
@Override
public Object visitChunkerTypeParams(ChunkerTypeParams node, Object data) {
typeParamsCompletion(results, node, request);
return null;
}
@Override
public Object visitChunkerTypeParam(ChunkerTypeParam node, Object data) {
typeParamCompletion(results, node, request);
return null;
}
@Override
public Object visitChunkerIdent(ChunkerIdent node, Object data) {
identCompletion(doc, results, node, request);
return null;
}
@Override
public Object visitChunkerNum(ChunkerNum node, Object data) {
numCompletion(results, node, request);
return null;
}
@Override
public Object visitChunkerWhitespace(ChunkerWhitespace node, Object data) {
whitespaceComplete(doc, results, node, request, direction);
return null;
}
@Override
public Object visitChunkerNewline(ChunkerNewline node, Object data) {
whitespaceComplete(doc, results, node, request, direction);
return null;
}
@Override
public Object visitChunkerNew(ChunkerNew node, Object data) {
newComplete(results, node, request);
return null;
}
@Override
public Object visitChunkerAnnotation(ChunkerAnnotation node, Object data) {
annotationComplete(results, node, request);
return null;
}
@Override
public Object visitChunkerInvoke(ChunkerInvoke node, Object data) {
invokeComplete(results, node, request, direction);
return null;
}
@Override
public Object visitChunkerMethodName(ChunkerMethodName node, Object data) {
final Token tok = node.jjtGetFirstToken();
assert tok == node.jjtGetLastToken();
addMethodsAndVariables(results, tok, request, ((HasScope) node.jjtGetParent()).getScope(),
tok.image.replace("(", ""));
if (request.getOffset() >= tok.endIndex - 1) {
// The user's cursor is on the opening ( of an invocation, lets add method arguments to completion
// results as well.
ChunkerInvoke invoke = (ChunkerInvoke) node.jjtGetParent();
methodArgumentCompletion(invoke.getName(), results, invoke, invoke.getArgument(0), request,
direction);
}
return null;
}
@Override
public Object visitChunkerParam(ChunkerParam node, Object data) {
return unsupported(node);
}
@Override
public Object visitChunkerClosure(ChunkerClosure node, Object data) {
// not supporting completion for closures just yet; can likely offer parameter suggestions later though.
return unsupported(node);
}
@Override
public Object visitChunkerArray(ChunkerArray node, Object data) {
// when we're in an array, we should suggest "anything of the same type as other array elements",
// or otherwise look at where this array is being assigned to determine more type inference we can do
// for suggestion.
return unsupported(node);
}
@Override
public Object visitChunkerBinaryExpression(ChunkerBinaryExpression node, Object data) {
// if we're actually in the binary expression, it's likely that we're on the operator itself.
// for now, we'll try searching both left and right, and if we get unwanted matches, we'll reduce our
// scope.
switch (direction) {
case BOTH:
case LEFT:
searchForResults(doc, results, node.getLeft(), request.candidate(node.getLeft().getEndIndex()),
SearchDirection.LEFT);
}
if (node.getRight() != null) {
switch (direction) {
case BOTH:
case RIGHT:
searchForResults(doc, results, node.getRight(),
request.candidate(node.getRight().getStartIndex()), SearchDirection.RIGHT);
}
}
return null;
}
@Override
public Object visitChunkerString(ChunkerString node, Object data) {
// Cursor is inside of a string; figure out where this string is being assigned.
stringComplete(results, node, request, direction);
return null;
}
@Override
public Object visitChunkerEof(ChunkerEof node, Object data) {
// Later on, we'll use the fact that we know the user is typing something
// that is likely invalid, and offer suggestions like variable names, etc.
whitespaceComplete(doc, results, node, request, SearchDirection.LEFT);
return null;
}
}, null);
}
private void annotationComplete(Collection results, ChunkerAnnotation node,
CompletionRequest offset) {
// suggest names of annotations / arguments for groovy...
// while python should suggest the names of decorator functions only.
}
private Object unsupported(Node node) {
if (log.isTraceEnabled()) {
Node parent = node;
while (parent.jjtGetParent() != null && !(parent.jjtGetParent() instanceof ChunkerDocument)) {
parent = parent.jjtGetParent();
}
log.trace()
.append("Node type ")
.append(node.getClass().getCanonicalName())
.append(" not yet supported: ")
.append(node.toSource())
.nl()
.append("Parent source: ")
.append(parent.toSource())
.endl();
}
return null;
}
private void numCompletion(Collection results, ChunkerNum node, CompletionRequest offset) {
// not really sure what, if anything, we'd want for numbers.
// perhaps past history of number values entered / typed in?
}
private void assignCompletion(Collection results, ChunkerAssign node,
CompletionRequest offset) {
final CompleteAssignment completer = new CompleteAssignment(this, node);
final Node value = node.getValue();
if (value == null) {
// no value for this assignment... offer up anything from scope.
FuzzyList sorted = new FuzzyList<>("");
for (String varName : variables.getParamNames()) {
sorted.add(varName, varName);
}
for (String varName : sorted) {
completer.doCompletion(results, offset, varName, false);
}
// TODO: also consider offering static classes / non-void-methods or block-local-scope vars.
// This would get really crazy really fast w/ no filter,
// so maybe we'll just keep a cache of user-actually-used-these classes/methods/etc, and offer only those
// (possibly primed with "things we want to promote users seeing"). IDS-1517-22
} else {
// we only want to suggest variable names beginning with the next token
final String startWith = value.jjtGetFirstToken().image;
FuzzyList sorted = new FuzzyList<>(startWith);
// TODO: actually use a visitor here; really only Ident tokens should get the behavior below;
// Really, we should be adding all variable names like we do, then visiting all source,
// removing anything which occurs later than here in source, and adding any assignments which
// occur earlier in source-but-not-in-runtime-variable-pool. IDS-1517-23
for (String varName : variables.getParamNames()) {
if (camelMatch(varName, startWith)) {
sorted.add(varName, varName);
}
}
for (String varName : sorted) {
completer.doCompletion(results, offset, varName, false);
}
}
}
private void typedAssignCompletion(Collection results, ChunkerTypedAssign node,
CompletionRequest offset) {}
private void typeParamsCompletion(Collection results, ChunkerTypeParams node,
CompletionRequest offset) {}
private void typeParamCompletion(Collection results, ChunkerTypeParam node,
CompletionRequest offset) {
}
private void identCompletion(
ParsedDocument doc,
Collection results,
ChunkerIdent node,
CompletionRequest request) {
boolean onEnd = node.jjtGetFirstToken().getEndIndex() <= request.getOffset();
if (onEnd) {
// user cursor is on the . or at the end of a possibly.chained.expression
// we should offer completions for our target...
final Node target = node.getScopeTarget();
if (target == null) {
// offer completion by looking at our own scope, and either completing
// from available fields/methods, or global variables
Token replacing = findReplacement(node, request);
addMethods(results, replacing, request, Collections.singletonList(node), "");
} else {
// offer completion as though cursor was after the .
searchForResults(doc, results, target, request, SearchDirection.RIGHT);
}
} else {
// user wants completion on the ident itself...
String src = node.toSource();
final Token tok = node.jjtGetFirstToken();
if (tok.startIndex > request.getCandidate()) {
// would result in a negative substring, abort
return;
}
src = src.substring(0, request.getCandidate() - tok.startIndex);
addMethodsAndVariables(results, node.jjtGetFirstToken(), request, Collections.singletonList(node), src);
}
}
private Token findReplacement(Node node, CompletionRequest request) {
Token replacing = node.jjtGetFirstToken();
while (replacing.next != null && replacing.next.startIndex < request.getCandidate()) {
if (replacing.next.kind == ChunkerConstants.EOF) {
return replacing;
}
replacing = replacing.next;
}
return replacing;
}
private void whitespaceComplete(
ParsedDocument doc,
Collection results,
SimpleNode node,
CompletionRequest req,
SearchDirection direction) {
// when the cursor is on whitespace, we'll look around from here to find something to bind to...
// for now, we'll be lazy, and just move the cursor to our non-whitespace neighbors...
final int nextLeft = node.getStartIndex() - 1;
final int nextRight = node.getEndIndex() + 1;
switch (direction) {
case LEFT:
Node left = findLeftOf(node);
if (left != null) {
searchForResults(doc, results, left, req.candidate(left.getEndIndex()), SearchDirection.LEFT);
}
break;
case RIGHT:
Node right = findRightOf(node);
if (right != null) {
searchForResults(doc, results, right, req.candidate(right.getStartIndex()), SearchDirection.RIGHT);
}
break;
case BOTH:
// look in both directions.
left = findLeftOf(node);
right = findRightOf(node);
if (left == null) {
if (right != null) {
searchForResults(doc, results, right, req.candidate(nextRight), SearchDirection.LEFT);
}
} else { // left is non-null
if (right == null) {
searchForResults(doc, results, left, req.candidate(nextLeft), SearchDirection.LEFT);
} else {
// both left and right are non-null. Pick the closest one first.
if (req.getCandidate() - nextLeft > nextRight - req.getCandidate()) {
// right pos is closer, so we'll start there.
searchForResults(doc, results, right, req.candidate(nextRight), SearchDirection.RIGHT);
searchForResults(doc, results, left, req.candidate(nextLeft), SearchDirection.LEFT);
} else {
// cursor is closer to left side, so start there.
searchForResults(doc, results, left, req.candidate(nextLeft), SearchDirection.LEFT);
searchForResults(doc, results, right, req.candidate(nextRight), SearchDirection.RIGHT);
}
}
}
break;
}
}
private Node findRightOf(Node node) {
if (node == null) {
return null;
}
final Node parent = node.jjtGetParent();
if (parent == null) {
return null;
}
// short-circuit for ast nodes that return values.
if (isTerminal(parent)) {
return parent;
}
final List children = parent.getChildren();
final int index = children.indexOf(node);
if (index == children.size() - 1) {
return findRightOf(parent.jjtGetParent());
}
Node next = children.get(index + 1);
while (!isTerminal(next) && next.jjtGetNumChildren() > 0) {
next = next.jjtGetChild(0);
}
return next;
}
private boolean isTerminal(Node node) {
return node.isAutocompleteTerminal();
}
private Node findLeftOf(Node node) {
if (node == null) {
return null;
}
final Node parent = node.jjtGetParent();
if (parent == null) {
return null;
}
if (isTerminal(parent)) {
return parent;
}
final List children = parent.getChildren();
final int index = children.indexOf(node);
if (index == 0) {
return findLeftOf(parent.jjtGetParent());
}
Node next = children.get(index - 1);
while (!isTerminal(next) && next.jjtGetNumChildren() > 0) {
Node candidate = next.jjtGetChild(next.jjtGetNumChildren() - 1);
if (candidate instanceof ChunkerEof || // if we found the EOF, skip back again
(candidate == node && next.jjtGetNumChildren() > 1) // if we ran into the original target node, skip
// it.
) {
candidate = next.jjtGetChild(next.jjtGetNumChildren() - 2);
}
next = candidate;
}
return next;
}
private void newComplete(Collection results, ChunkerNew node, CompletionRequest offset) {
// `new ` completion not implemented yet. This would need to lookup matching types w/ public constructors
}
private void invokeComplete(
Collection results,
ChunkerInvoke node,
CompletionRequest request,
SearchDirection direction) {
// invoke completions are one of the most important to consider.
// for now, this will be a naive replacement, but later we'll want to look at _where_
// in the invoke the cursor is; when on the ending paren, we'd likely want to look at
// whether we are the argument to something, and if so, do a type check, and suggest useful .coercions().
String name = node.getName();
// Now, for our magic-named methods that we want to handle...
boolean inArguments = node.isCursorInArguments(request.getCandidate());
boolean inMethodName = node.isCursorOnName(request.getCandidate());
// when the cursor is between name(andParen, both of the above will trigger.
// Find or create a "string as first arg" that will only be used if we match a magic method name below.
if (inArguments) {
Node firstArg = argNode(node, request);
methodArgumentCompletion(name, results, node, firstArg, request, direction);
}
if (inMethodName) {
// hokay! when on a method name, we need to look up our scope object,
// try to guess the type, then look at existing arguments, to use as
// context clues...
final List scope = node.getScope();
if (scope == null || scope.isEmpty()) {
// TODO: figure out static-import-methods-declared-in-source, or closure references,
// or locally defined methods / functions. IDS-1517-12
} else {
// For now, our scope resolution will just try to get us an object variable.
// In the future, we'll create some kind of memoized meta information;
// for now though, we just want to be able to find variables in our binding...
String n = node.getName();
addMethodsAndVariables(results, node.getNameToken(), request, scope, n);
}
}
}
private void addMethods(
Collection results,
Token replacing,
CompletionRequest request,
List scope,
String methodPrefix) {
Optional> bindingVar = resolveScopeType(request, scope);
if (bindingVar.isPresent()) {
final Class> bindingClass = bindingVar.get();
doMethodCompletion(results, bindingClass, methodPrefix, replacing, request);
} else {
// log that we couldn't find the binding var;
// in theory we should be able to resolve anything that is valid source.
log.trace()
.append("Unable to find binding variable for ")
.append(methodPrefix)
.append(" from scope ")
.append(scope.stream().map(IsScope::getName).collect(Collectors.joining(".")))
.append(" from request ")
.append(request.toString())
.endl();
}
}
private void addMethodsAndVariables(
Collection results,
Token replacing,
CompletionRequest request,
List scope,
String variablePrefix) {
variablePrefix = variablePrefix.trim();
// Add any method which make sense from the existing name / ident token.
addMethods(results, replacing, request, scope, variablePrefix);
// Add any variables present in current scope (will show system objects)
doVariableCompletion(results, variablePrefix, replacing, request);
// TODO: Add completion for any assignment expressions which occur earlier than us in the document.
// This will show only-in-source objects, without needing to actually run user's code. IDS-1517-18
}
private void doMethodCompletion(
Collection results,
Class> bindingClass,
String methodPrefix,
Token replacing,
CompletionRequest request) {
// hokay! now, we can use the invoke's name to find methods / fields in this type.
FuzzyList sorter = new FuzzyList<>(methodPrefix);
for (Method method : bindingClass.getMethods()) {
if (Modifier.isPublic(method.getModifiers())) {
// TODO we'll likely want to pick between static or instance methods, based on calling scope.
// IDS-1517-19
// TODO(deephaven-core#875): Auto-complete on instance should not suggest static methods
if (camelMatch(method.getName(), methodPrefix)) {
sorter.add(method.getName(), method);
}
}
}
CompleteInvocation completer = new CompleteInvocation(this, replacing);
// and now, we can suggest those methods as replacements.
for (Method method : sorter) {
completer.doCompletion(results, request, method);
}
}
private void doVariableCompletion(Collection results, String variablePrefix,
Token replacing, CompletionRequest request) {
FuzzyList sorter = new FuzzyList<>(variablePrefix);
for (String name : variables.getParamNames()) {
if (!name.equals(variablePrefix) && camelMatch(name, variablePrefix)) {
// only suggest names which are camel-case-matches (ignoring same-as-existing-variable names)
sorter.add(name, name);
}
}
CompleteVarName completer = new CompleteVarName(this, replacing);
for (String method : sorter) {
completer.doCompletion(results, request, method);
}
}
private boolean camelMatch(String candidate, String search) {
final String[] items = CAMEL_PATTERN.split(search);
if (items.length == 1) {
return candidate.startsWith(search);
}
for (String camel : items) {
if (camel.isEmpty()) {
continue;
}
if (Character.isLowerCase(camel.charAt(0))) {
if (!candidate.startsWith(camel)) {
return false;
}
} else {
// this could be more specific, but there may actually
// be cases where user transposes part of a name,
// and they'd actually want to see BarFoo when asking for FooBar.
// ...this should be implemented using a result score.
if (!candidate.contains(camel)) {
return false;
}
}
}
return true;
}
private Node argNode(ChunkerInvoke node, CompletionRequest request) {
if (node.getArgumentCount() == 0) {
return null;
}
Node best = null;
int score = 0;
for (Node argument : node.getArguments()) {
if (best == null) {
best = argument;
score = best.distanceTo(request.getOffset());
} else {
final int newScore = argument.distanceTo(request.getOffset());
if (newScore < score) {
best = argument;
}
}
}
return best;
}
private void methodArgumentCompletion(
String name, Collection results,
ChunkerInvoke node,
Node replaceNode,
CompletionRequest request,
SearchDirection direction) {
// TODO: replace this hardcoded list of magic method names with something generated by an annotation processor.
// IDS-1517-32
boolean tableReturningMethod = false;
switch (name) {
case "join":
case "naturalJoin":
case "exactJoin":
case "aj":
// TODO: joins will need special handling; IDS-1517-5 example from Charles:
// j=l.naturalJoin(r, "InBoth,AFromLeft=BFromRight,CInLeft=DFromRight", "EInOut=FromRight,FInOut")
// as you see, we need both the scope table l and the joined table r,
// then we also need to handle CSV-separated column expressions.
// To even try this using string inspection is foolish, as a `,` or `=` could easily appear inside the
// expression.
// For these reasons, proper support here will have to wait until we also parse the string contents;
// we can parse them on-demand via Chunker#MethodArgs, and just get a list of assignments.
// might actually be better to just make Chunker#JoinArgs, to specify CSV of assignments (and allow `
// strings)
break;
case "where":
case "sort":
case "sortDescending":
case "dropColumns":
case "select":
case "selectDistinct":
case "view":
case "update":
case "updateView":
tableReturningMethod = true;
maybeColumnComplete(results, node, replaceNode, request, direction);
break;
}
// Try to delegate to another implementation
lookups.getCustomCompletions().methodArgumentCompletion(this, node, replaceNode, request, direction, results);
if (node.getEndIndex() < request.getOffset()) {
// user's cursor is actually past the end of our method...
Class> scopeType;
if (tableReturningMethod) {
scopeType = Table.class;
} else {
final Optional> type = resolveScopeType(request, node.getScope());
if (!type.isPresent()) {
return;
}
scopeType = type.get();
}
final Token rep = findReplacement(node, request);
doMethodCompletion(results, scopeType, "", rep, request);
}
}
private Optional> resolveScopeType(
CompletionRequest request, List scope) {
return resolveScopeType(request, scope, new HashSet<>());
}
private Optional> resolveScopeType(
CompletionRequest request, List scope, Set> alreadyVisited) {
if (scope == null || scope.isEmpty()) {
return Optional.empty();
}
alreadyVisited.add(scope);
IsScope o = scope.get(0);
final Object instance = variables.readParamValue(o.getName(), null);
if (instance != null) {
if (instance instanceof Table) {
return Optional.of(Table.class);
}
return Optional.of(instance.getClass());
}
Optional> result = resolveScope(scope)
.map(Object::getClass);
if (result.isPresent()) {
return result;
}
Optional> customResult = lookups.getCustomCompletions().resolveScopeType(o);
if (customResult.isPresent()) {
return customResult;
}
// Ok, maybe the user hasn't run the query yet.
// See if there's any named assign's that have a value of engine.i|t|etc
final List assignments = findAssignment(doc, request, o.getName());
for (ChunkerAssign assignment : assignments) {
// This is pretty hacky, but our only use case here is looking for table loads, so...
if (assignment.getValue() instanceof IsScope) {
final List subscope = ((IsScope) assignment.getValue()).asScopeList();
if (alreadyVisited.add(subscope)) {
result = resolveScopeType(request, subscope, alreadyVisited);
if (result.isPresent()) {
return result;
}
}
}
}
return Optional.empty();
}
public List findAssignment(final ParsedDocument doc, final CompletionRequest request,
final String name) {
final Map> assignments = ensureAssignments(doc);
final List options = assignments.get(name), results = new ArrayList<>();
if (options != null) {
assert !options.isEmpty();
final ListIterator itr = options.listIterator(options.size());
while (itr.hasPrevious()) {
final ChunkerAssign test = itr.previous();
if (test.getStartIndex() <= request.getOffset()) {
results.add(test);
}
}
return results;
}
return Collections.emptyList();
}
private Map> ensureAssignments(final ParsedDocument doc) {
Map> assignments = doc.getAssignments();
if (assignments.isEmpty()) {
doc.getDoc().jjtAccept(new ChunkerDefaultVisitor() {
@Override
public Object visitChunkerAssign(ChunkerAssign node, Object data) {
assignments.computeIfAbsent(node.getName(), n -> new ArrayList<>()).add(node);
return super.visitChunkerAssign(node, data);
}
@Override
public Object visitChunkerTypedAssign(ChunkerTypedAssign node, Object data) {
assignments.computeIfAbsent(node.getName(), n -> new ArrayList<>()).add(node);
return super.visitChunkerTypedAssign(node, data);
}
}, null);
}
return assignments;
}
private Optional