diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/JassDocService.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/JassDocService.java index 2806dfb97..58059c64d 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/JassDocService.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/JassDocService.java @@ -34,6 +34,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.stream.Stream; @@ -100,6 +101,9 @@ public int hashCode() { private volatile boolean triedInit; private volatile boolean initFailed; private volatile boolean missingSourceWarningShown; + private final AtomicBoolean initRequested = new AtomicBoolean(false); + private volatile @Nullable Connection sharedConnection; + private volatile @Nullable Path sharedConnectionPath; private static volatile @Nullable Function testLookup; private static final class CachedDb { @@ -140,25 +144,70 @@ public static void setTestLookup(@Nullable Function return documentationFor(f.getName(), SymbolKind.FUNCTION, f.getSource().getFile()); } + public @Nullable String documentationForFunctionQuick(FunctionDefinition f) { + return documentationForQuick(f.getName(), SymbolKind.FUNCTION, f.getSource().getFile()); + } + public @Nullable String documentationForVariable(NameDef n) { return documentationFor(n.getName(), SymbolKind.VARIABLE, n.getSource().getFile()); } + public @Nullable String documentationForVariableQuick(NameDef n) { + return documentationForQuick(n.getName(), SymbolKind.VARIABLE, n.getSource().getFile()); + } + public @Nullable String documentationFor(String symbolName, SymbolKind symbolKind, String sourceFile) { + Function override = testLookup; + LookupKey key = new LookupKey(symbolName, symbolKind, sourceFile); + if (override != null) { + Optional computed = Optional.ofNullable(trimToNull(override.apply(key))); + Optional prev = lookupCache.putIfAbsent(key, computed); + return (prev != null ? prev : computed).orElse(null); + } if (!isJassBuiltinSource(sourceFile)) { return null; } - LookupKey key = new LookupKey(symbolName, symbolKind, sourceFile); Optional cached = lookupCache.computeIfAbsent(key, this::lookupDocumentation); return cached.orElse(null); } + /** + * Non-blocking lookup for latency-sensitive paths (autocomplete): + * if DB is not ready, triggers async init and returns null immediately. + */ + public @Nullable String documentationForQuick(String symbolName, SymbolKind symbolKind, String sourceFile) { + LookupKey key = new LookupKey(symbolName, symbolKind, sourceFile); + Function override = testLookup; + if (override != null) { + Optional computed = Optional.ofNullable(trimToNull(override.apply(key))); + Optional prev = lookupCache.putIfAbsent(key, computed); + return (prev != null ? prev : computed).orElse(null); + } + if (!isJassBuiltinSource(sourceFile)) { + return null; + } + Optional cached = lookupCache.get(key); + if (cached != null) { + return cached.orElse(null); + } + CachedDb db = cachedDb; + if (db == null) { + triggerAsyncInit(); + return null; + } + Optional computed = Optional.ofNullable(lookupFromDb(db, key)); + Optional prev = lookupCache.putIfAbsent(key, computed); + return (prev != null ? prev : computed).orElse(null); + } + public void clearCacheForTests() { lookupCache.clear(); + closeSharedConnection(); cachedDb = null; triedInit = false; initFailed = false; missingSourceWarningShown = false; + initRequested.set(false); } private Optional lookupDocumentation(LookupKey key) { @@ -216,7 +265,8 @@ private Optional lookupDocumentation(LookupKey key) { } private @Nullable String lookupFromDb(CachedDb db, LookupKey key) { - try (Connection conn = open(db.dbPath)) { + try { + Connection conn = getSharedConnection(db.dbPath); String legacyDoc = lookupFromLegacyJassdocTables(conn, key); if (legacyDoc != null) { return legacyDoc; @@ -258,6 +308,52 @@ private Optional lookupDocumentation(LookupKey key) { } } + private Connection getSharedConnection(Path dbPath) throws SQLException { + synchronized (this) { + if (sharedConnection != null) { + try { + if (!sharedConnection.isClosed() && dbPath.equals(sharedConnectionPath)) { + return sharedConnection; + } + } catch (SQLException ignored) { + // reconnect below + } + closeSharedConnection(); + } + sharedConnection = open(dbPath); + sharedConnectionPath = dbPath; + return sharedConnection; + } + } + + private void closeSharedConnection() { + synchronized (this) { + if (sharedConnection != null) { + try { + sharedConnection.close(); + } catch (SQLException ignored) { + } + } + sharedConnection = null; + sharedConnectionPath = null; + } + } + + private void triggerAsyncInit() { + if (!initRequested.compareAndSet(false, true)) { + return; + } + Thread t = new Thread(() -> { + try { + getOrInitDb(); + } finally { + initRequested.set(false); + } + }, "JassDocInit"); + t.setDaemon(true); + t.start(); + } + private @Nullable String lookupFromLegacyJassdocTables(Connection conn, LookupKey key) throws SQLException { if (!tableExists(conn, "parameters")) { return null; diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetCompletions.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetCompletions.java index f8dc638e0..050254c5a 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetCompletions.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetCompletions.java @@ -9,6 +9,7 @@ import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.WLogger; +import de.peeeq.wurstscript.WurstOperator; import de.peeeq.wurstscript.WurstKeywords; import de.peeeq.wurstscript.ast.*; import de.peeeq.wurstscript.attributes.AttrExprType; @@ -17,14 +18,12 @@ import de.peeeq.wurstscript.utils.Utils; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.jsonrpc.messages.Either; import java.io.File; import java.text.DecimalFormat; -import java.text.NumberFormat; -import java.text.ParseException; import java.util.*; import java.util.Map.Entry; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -34,6 +33,7 @@ public class GetCompletions extends UserRequest { private static final int MAX_COMPLETIONS = 100; + private static final int MAX_CANDIDATES = 2000; private final WFile filename; private final String buffer; private final String[] lines; @@ -41,12 +41,14 @@ public class GetCompletions extends UserRequest { private final int column; private String alreadyEntered; private String alreadyEnteredLower; - private SearchMode searchMode; private Element elem; private WurstType expectedType; + private boolean hasExpectedType; private ModelManager modelManager; private boolean isIncomplete = false; private CompilationUnit cu; + private final IdentityHashMap docTargets = new IdentityHashMap<>(); + private final IdentityHashMap completionTypes = new IdentityHashMap<>(); public GetCompletions(CompletionParams position, BufferManager bufferManager) { @@ -84,19 +86,14 @@ public CompletionList execute(ModelManager modelManager) { private Comparator completionItemComparator() { return Comparator - .comparing(CompletionItem::getSortText).reversed() + .comparing(CompletionItem::getSortText) .thenComparing(CompletionItem::getLabel); } - private enum SearchMode { - PREFIX, INFIX, SUBSEQENCE - } - /** * computes completions at the current position */ public List computeCompletionProposals(CompilationUnit cu) { - if (isEnteringRealNumber()) { return null; } @@ -105,31 +102,232 @@ public List computeCompletionProposals(CompilationUnit cu) { alreadyEnteredLower = alreadyEntered.toLowerCase(); WLogger.debug("already entered = " + alreadyEntered); - for (SearchMode mode : SearchMode.values()) { - searchMode = mode; - List completions = Lists.newArrayList(); + List completions = Lists.newArrayList(); + elem = Utils.getAstElementAtPos(cu, line, column + 1, false).get(); + WLogger.debug("get completions at " + Utils.printElement(elem)); + expectedType = null; + hasExpectedType = false; + Element typeElem = elem; + if (!(typeElem instanceof Expr)) { + Optional leafElem = Utils.getAstElementAtPos(cu, line, column + 1, true); + if (leafElem.isPresent()) { + Expr nearestExpr = nearestEnclosingExpr(leafElem.get()); + if (nearestExpr != null) { + typeElem = nearestExpr; + } + } + } + if (typeElem instanceof Expr) { + Expr expr = (Expr) typeElem; + expectedType = expr.attrExpectedTyp(); + if (expectedType instanceof WurstTypeUnknown) { + WurstType inferred = inferExpectedTypeFromContext(expr); + if (!(inferred instanceof WurstTypeUnknown)) { + expectedType = inferred; + } + } + } else { + expectedType = inferExpectedTypeFromStatementContext(typeElem); + } + hasExpectedType = expectedType != null && !(expectedType instanceof WurstTypeUnknown); + WLogger.info("....expected type = " + expectedType); + + calculateCompletions(completions); + removeDuplicates(completions); + preferBestMatchTier(completions); + preferExpectedTypeForShortQueries(completions); + dropBadCompletions(completions); + enrichTopDocumentation(completions); + return completions; + } - elem = Utils.getAstElementAtPos(cu, line, column + 1, false).get(); - WLogger.debug("get completions at " + Utils.printElement(elem)); - expectedType = null; - if (elem instanceof Expr) { - Expr expr = (Expr) elem; - expectedType = expr.attrExpectedTyp(); - WLogger.info("....expected type = " + expectedType); + private void preferExpectedTypeForShortQueries(List completions) { + if (!hasExpectedType || alreadyEntered.length() > 2 || completions.isEmpty()) { + return; + } + List expectedTypeMatches = new ArrayList<>(); + List others = new ArrayList<>(); + for (CompletionItem item : completions) { + WurstType t = completionTypes.get(item); + if (t != null && t.isSubtypeOf(expectedType, elem)) { + expectedTypeMatches.add(item); + } else { + others.add(item); } + } + if (!expectedTypeMatches.isEmpty()) { + completions.clear(); + completions.addAll(expectedTypeMatches); + completions.addAll(others); + } + } - calculateCompletions(completions); + private void enrichTopDocumentation(List completions) { + // Keep completion responsive: enrich only visible/top results. + int limit = Math.min(completions.size(), 24); + for (int i = 0; i < limit; i++) { + CompletionItem ci = completions.get(i); + AstElementWithSource target = docTargets.get(ci); + if (target == null) { + continue; + } + String comment = null; + boolean jassDoc = false; + if (target instanceof FunctionDefinition) { + FunctionDefinition f = (FunctionDefinition) target; + comment = f.attrComment(); + if (comment == null || comment.isEmpty()) { + comment = JassDocService.getInstance().documentationForFunctionQuick(f); + jassDoc = comment != null && !comment.isEmpty(); + } + } else if (target instanceof NameDef) { + NameDef n = (NameDef) target; + comment = n.attrComment(); + if (comment == null || comment.isEmpty()) { + comment = JassDocService.getInstance().documentationForVariableQuick(n); + jassDoc = comment != null && !comment.isEmpty(); + } + } + if (comment == null || comment.isEmpty()) { + continue; + } + if (jassDoc) { + MarkupContent mc = new MarkupContent(); + mc.setKind("markdown"); + mc.setValue("*JassDoc*\n\n" + comment); + ci.setDocumentation(Either.forRight(mc)); + } else { + ci.setDocumentation(comment); + } + } + } - dropBadCompletions(completions); - removeDuplicates(completions); + private void preferBestMatchTier(List completions) { + if (alreadyEntered.length() < 2 || completions.isEmpty()) { + return; + } + int bestTier = Integer.MAX_VALUE; + for (CompletionItem item : completions) { + bestTier = Math.min(bestTier, matchTier(item.getLabel())); + } + // If we have a strong prefix-like match, keep the strong tier. + // For case-insensitive prefix matching we keep both exact/case-sensitive and case-insensitive tiers + // so typing lowercase still keeps uppercase constants (e.g. "t" -> "TEXT_*"). + if (bestTier <= 2) { + int minTier = Math.max(0, bestTier - 1); + int maxTier = 2; + completions.removeIf(c -> { + if (alreadyEntered.length() <= 2 && isExpectedTypeMatch(c)) { + return false; + } + if (alreadyEntered.length() >= 3 && shouldKeepAsExpectedTypeFallback(c)) { + return false; + } + int t = matchTier(c.getLabel()); + return t < minTier || t > maxTier; + }); + } + } + + private boolean isExpectedTypeMatch(CompletionItem item) { + if (!hasExpectedType) { + return false; + } + WurstType t = completionTypes.get(item); + return t != null && t.isSubtypeOf(expectedType, elem); + } - if (completions.size() > 0) { - return completions; + private boolean shouldKeepAsExpectedTypeFallback(CompletionItem item) { + if (!isExpectedTypeMatch(item)) { + return false; + } + int tier = matchTier(item.getLabel()); + if (tier < 3 || tier > 6) { + return false; + } + String normalizedQuery = normalizedIdentifier(alreadyEntered); + if (normalizedQuery.isEmpty()) { + return false; + } + String normalizedName = normalizedIdentifier(item.getLabel()); + int span = subsequenceSpan(normalizedQuery, normalizedName); + if (span < 0) { + return false; + } + // Keep compact expected-type subsequence matches (e.g. TEXTU -> TEXT_JUSTIFY_*), + // but reject very gappy matches that mostly add noise. + return span <= normalizedQuery.length() + 3; + } + + private WurstType inferExpectedTypeFromContext(Expr expr) { + Element parent = expr.getParent(); + if (parent instanceof ExprBinary) { + ExprBinary b = (ExprBinary) parent; + Expr other = b.getLeft() == expr ? b.getRight() : b.getLeft(); + WurstType inferred = inferFromBinaryComparison(b, other); + if (!(inferred instanceof WurstTypeUnknown)) { + return inferred; } } + return WurstTypeUnknown.instance(); + } + + private @Nullable Expr nearestEnclosingExpr(Element e) { + Element current = e; + while (current != null) { + if (current instanceof Expr) { + return (Expr) current; + } + current = current.getParent(); + } return null; } + private WurstType inferExpectedTypeFromStatementContext(Element contextElem) { + if (contextElem instanceof StmtIf) { + return inferFromConditionalExpr(((StmtIf) contextElem).getCond()); + } + if (contextElem instanceof StmtWhile) { + return inferFromConditionalExpr(((StmtWhile) contextElem).getCond()); + } + return WurstTypeUnknown.instance(); + } + + private WurstType inferFromConditionalExpr(Expr cond) { + if (cond instanceof ExprBinary) { + ExprBinary b = (ExprBinary) cond; + if (b.getRight() instanceof ExprEmpty) { + WurstType inferred = inferFromBinaryComparison(b, b.getLeft()); + if (!(inferred instanceof WurstTypeUnknown)) { + return inferred; + } + } + if (b.getLeft() instanceof ExprEmpty) { + WurstType inferred = inferFromBinaryComparison(b, b.getRight()); + if (!(inferred instanceof WurstTypeUnknown)) { + return inferred; + } + } + } + return WurstTypeUnknown.instance(); + } + + private WurstType inferFromBinaryComparison(ExprBinary b, Expr otherSide) { + // For comparisons, mirror the opposite side type (e.g. p == | -> player). + if (b.getOp() == WurstOperator.EQ + || b.getOp() == WurstOperator.NOTEQ + || b.getOp() == WurstOperator.LESS + || b.getOp() == WurstOperator.LESS_EQ + || b.getOp() == WurstOperator.GREATER + || b.getOp() == WurstOperator.GREATER_EQ) { + WurstType otherType = otherSide.attrTyp(); + if (!(otherType instanceof WurstTypeUnknown)) { + return otherType; + } + } + return WurstTypeUnknown.instance(); + } + private void calculateCompletions(List completions) { boolean isMemberAccess = false; if (elem instanceof ExprMember) { @@ -137,7 +335,7 @@ private void calculateCompletions(List completions) { if (elem instanceof ExprMemberMethod) { ExprMemberMethod c = (ExprMemberMethod) elem; - if (isInParenthesis(c.getLeft().getSource().getEndColumn())) { + if (isInParenthesis(c.getLeft().getSource().getEndColumn()) || isInsideCallArguments(c.getFuncName(), c.getLeft().getSource().getEndColumn())) { // cursor inside parenthesis getCompletionsForExistingMemberCall(completions, c); return; @@ -155,7 +353,12 @@ private void calculateCompletions(List completions) { && (nameLink.getReceiverType() != null || nameLink instanceof TypeDefLink) && nameLink.getVisibility() == Visibility.PUBLIC) { CompletionItem completion = makeNameDefCompletion(nameLink); - completions.add(completion); + if (!addCompletionCandidate(completions, completion)) { + isIncomplete = true; + if (!hasExpectedType) { + break; + } + } } } } @@ -218,6 +421,8 @@ private void calculateCompletions(List completions) { ExprNewObject c = (ExprNewObject) grandParent; getCompletionsForExistingConstructorCall(completions, c); } + // Also provide value suggestions for the current argument context. + addDefaultCompletions(completions, elem, false); // TODO add overloaded funcs } } @@ -342,7 +547,14 @@ private void getCompletionsForExistingMemberCall(List completion CompletionItem ci = makeFunctionCompletion(funcDef); ci.setTextEdit(null); completions.add(ci); + preferExpectedTypeFromCall(funcDef, currentArgumentIndex(c.getFuncName(), c.getLeft().getSource().getEndColumn())); + // Also provide argument value suggestions at this call site. + addDefaultCompletions(completions, c, false); + return; } + // Fallback for incomplete/unresolved calls: infer expected parameter type from visible overloads. + inferExpectedTypeFromUnresolvedMemberCall(c); + addDefaultCompletions(completions, c, false); } private void getCompletionsForExistingCall(List completions, ExprFunctionCall c) { @@ -352,9 +564,146 @@ private void getCompletionsForExistingCall(List completions, Exp CompletionItem ci = makeFunctionCompletion(funcDef); ci.setTextEdit(null); completions.add(ci); + preferExpectedTypeFromCall(funcDef, currentArgumentIndex(c.getFuncName(), c.getSource().getStartColumn())); + // Also provide argument value suggestions at this call site. + addDefaultCompletions(completions, c, false); + } + } + + private void preferExpectedTypeFromCall(FuncLink funcDef, int argIndex) { + if (argIndex < 0 || argIndex >= funcDef.getParameterTypes().size()) { + return; + } + WurstType paramType = funcDef.getParameterType(argIndex); + if (paramType instanceof WurstTypeUnknown) { + return; + } + if (!hasExpectedType || expectedType instanceof WurstTypeUnknown) { + expectedType = paramType; + hasExpectedType = true; + } + } + + private int currentArgumentIndex(String funcName, int searchStartColumn) { + String lineText = currentLine(); + int searchStart = Math.max(0, Math.min(lineText.length(), searchStartColumn)); + int paren = lineText.indexOf(funcName + "(", searchStart); + if (paren >= 0) { + paren = paren + funcName.length(); + } else { + paren = lineText.indexOf('(', searchStart); + } + return argumentIndexFromParen(lineText, paren); + } + + private boolean isInsideCallArguments(String funcName, int searchStartColumn) { + String lineText = currentLine(); + int searchStart = Math.max(0, Math.min(lineText.length(), searchStartColumn)); + int funcPos = lineText.indexOf(funcName + "(", searchStart); + if (funcPos < 0) { + return false; + } + int paren = funcPos + funcName.length(); + if (paren >= column) { + return false; + } + int depth = 0; + for (int i = paren; i < column && i < lineText.length(); i++) { + char ch = lineText.charAt(i); + if (ch == '(') { + depth++; + } else if (ch == ')') { + depth--; + } + } + return depth > 0; + } + + private int argumentIndexFromParen(String lineText, int paren) { + if (paren < 0 || paren >= column) { + return 0; + } + int depth = 0; + int commas = 0; + for (int i = paren + 1; i < column && i < lineText.length(); i++) { + char ch = lineText.charAt(i); + if (ch == '(') { + depth++; + } else if (ch == ')') { + if (depth > 0) { + depth--; + } + } else if (ch == ',' && depth == 0) { + commas++; + } + } + return commas; + } + + private void inferExpectedTypeFromUnresolvedMemberCall(ExprMemberMethod c) { + int argIndex = currentArgumentIndex(c.getFuncName(), c.getLeft().getSource().getEndColumn()); + WurstType candidate = WurstTypeUnknown.instance(); + Collection sigs = c.attrPossibleFunctionSignatures(); + for (FunctionSignature sig : sigs) { + if (argIndex < sig.getMaxNumParams()) { + candidate = candidate.typeUnion(sig.getParamType(argIndex), c); + } + } + if (!(candidate instanceof WurstTypeUnknown)) { + expectedType = candidate; + hasExpectedType = true; + return; + } + + WurstType leftType = c.getLeft().attrTyp(); + if (leftType instanceof WurstTypeUnknown) { + return; + } + WScope scope = c.attrNearestScope(); + while (scope != null) { + Collection defs = scope.attrNameLinks().get(c.getFuncName()); + for (DefLink d : defs) { + if (!(d instanceof FuncLink)) { + continue; + } + FuncLink f = (FuncLink) d; + FuncLink bound = f.adaptToReceiverType(leftType); + if (bound == null || argIndex >= bound.getParameterTypes().size()) { + continue; + } + candidate = candidate.typeUnion(bound.getParameterType(argIndex), c); + } + scope = scope.attrNextScope(); + } + if (!(candidate instanceof WurstTypeUnknown)) { + expectedType = candidate; + hasExpectedType = true; } } + private boolean addCompletionCandidate(List completions, CompletionItem candidate) { + if (completions.size() < MAX_CANDIDATES) { + completions.add(candidate); + return true; + } + if (!hasExpectedType || !isExpectedTypeMatch(candidate)) { + return false; + } + // Keep expected-type candidates even when the cap is reached by replacing + // a non-expected candidate if possible. + for (int i = completions.size() - 1; i >= 0; i--) { + CompletionItem existing = completions.get(i); + if (!isExpectedTypeMatch(existing)) { + completions.remove(i); + docTargets.remove(existing); + completionTypes.remove(existing); + completions.add(candidate); + return true; + } + } + return false; + } + /* private ICompletionProposal[] toCompletionsArray(List completions) { Collections.sort(completions); @@ -366,34 +715,28 @@ private ICompletionProposal[] toCompletionsArray(List completion } */ - private boolean isSuitableCompletion(String name) { - if (name.endsWith("Tests")) { - return false; - } - switch (searchMode) { - case PREFIX: - return name.toLowerCase().startsWith(alreadyEnteredLower); - case INFIX: - return name.toLowerCase().contains(alreadyEnteredLower); - default: - return Utils.isSubsequenceIgnoreCase(alreadyEntered, name); - } - } - - private static final Pattern realPattern = Pattern.compile("[0-9]\\."); - /** * checks if we are currently entering a real number * (autocomplete might have triggered because of the dot) */ private boolean isEnteringRealNumber() { - try { - String currentLine = currentLine(); - String before = currentLine.substring(column - 2, 2); - return realPattern.matcher(before).matches(); - } catch (IndexOutOfBoundsException e) { + String line = currentLine(); + if (column < 2 || column > line.length()) { + return false; + } + // cursor is expected directly after '.' + if (line.charAt(column - 1) != '.') { return false; } + int i = column - 2; + if (!Character.isDigit(line.charAt(i))) { + return false; + } + while (i >= 0 && Character.isDigit(line.charAt(i))) { + i--; + } + // avoid suppressing member access on identifiers ending with digits (e.g. foo2.) + return i < 0 || !Character.isJavaIdentifierPart(line.charAt(i)); } @@ -487,31 +830,30 @@ private void completionsAddVisibleNames(String alreadyEntered, List= MAX_COMPLETIONS) { - // got enough completions - isIncomplete = true; - return; + CompletionItem completion = makeNameDefCompletion(defLink); + if (!addCompletionCandidate(completions, completion)) { + isIncomplete = true; + if (!hasExpectedType) { + return; + } + } } } } private void dropBadCompletions(List completions) { completions.sort(completionItemComparator()); - NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.getDefault()); - for (int i = completions.size() - 1; i >= MAX_COMPLETIONS; i--) { - try { - if (numberFormat.parse(completions.get(i).getSortText()).doubleValue() > 0.4) { - // good enough - return; - } - } catch (NumberFormatException | ParseException e) { - WLogger.severe(e); - } - completions.remove(i); + if (completions.size() > MAX_COMPLETIONS) { + completions.subList(MAX_COMPLETIONS, completions.size()).clear(); + isIncomplete = true; } } @@ -521,16 +863,14 @@ private CompletionItem makeNameDefCompletion(NameLink n) { } CompletionItem completion = new CompletionItem(n.getName()); - completion.setDetail(HoverInfo.descriptionString(n.getDef())); - String documentation = n.getDef().attrComment(); - if (documentation == null || documentation.isEmpty()) { - documentation = JassDocService.getInstance().documentationForVariable(n.getDef()); - } - completion.setDocumentation(documentation); + completion.setDetail(n.getTyp() + " [" + nearestScopeName(n.getDef()) + "]"); + completion.setDocumentation(n.getDef().attrComment()); + docTargets.put(completion, n.getDef()); double rating = calculateRating(n.getName(), n.getTyp()); completion.setSortText(ratingToString(rating)); String newText = n.getName(); completion.setInsertText(newText); + completionTypes.put(completion, n.getTyp()); return completion; } @@ -548,14 +888,20 @@ private CompletionItem makeSimpleNameCompletion(String name) { double rating = calculateRating(name, WurstTypeUnknown.instance()); completion.setSortText(ratingToString(rating)); completion.setInsertText(name); + completionTypes.put(completion, WurstTypeUnknown.instance()); return completion; } private double calculateRating(String name, WurstType wurstType) { double r = calculateNameBasedRating(name); - if (expectedType != null && wurstType.isSubtypeOf(expectedType, elem)) { - r += 0.1; + if (hasExpectedType) { + if (wurstType.isSubtypeOf(expectedType, elem)) { + // Strongly prefer candidates that satisfy the expected type at cursor. + r += 1.0; + } else { + r -= 0.12; + } } if (name.contains("BJ") || name.contains("Swapped")) { // common.j functions that Frotty does not want to see @@ -566,25 +912,134 @@ private double calculateRating(String name, WurstType wurstType) { private double calculateNameBasedRating(String name) { if (alreadyEntered.isEmpty()) { - return 0.5; + return 0.6; } - if (name.startsWith(alreadyEntered)) { - // perfect match - return 1.23; + String nameLower = name.toLowerCase(); + switch (matchTier(name)) { + case 0: + return 1.8; + case 1: + return 1.65; + case 2: + return 1.55; + case 3: + return 1.45; + case 4: + return 1.35; + case 5: + return 1.25; + case 6: + int span = subsequenceSpan(alreadyEnteredLower, nameLower); + if (span > 0) { + double compactness = 1.0 - ((double) span / Math.max(1, name.length())); + return 0.75 + 0.4 * compactness; + } + return 0.75; + default: + return -1.0; } + } + + private boolean isCamelSubsequence(String query, String candidate) { + if (query.isEmpty()) { + return false; + } + StringBuilder caps = new StringBuilder(); + for (int i = 0; i < candidate.length(); i++) { + char c = candidate.charAt(i); + if (Character.isUpperCase(c) || i == 0 || !Character.isLetterOrDigit(candidate.charAt(i - 1))) { + caps.append(Character.toLowerCase(c)); + } + } + return Utils.isSubsequenceIgnoreCase(query, caps.toString()); + } + + private int subsequenceSpan(String queryLower, String candidateLower) { + int qi = 0; + int start = -1; + int end = -1; + for (int i = 0; i < candidateLower.length() && qi < queryLower.length(); i++) { + if (candidateLower.charAt(i) == queryLower.charAt(qi)) { + if (start < 0) { + start = i; + } + end = i; + qi++; + } + } + if (qi < queryLower.length()) { + return -1; + } + return end - start + 1; + } + + private boolean isPotentialMatch(String name) { + return matchTier(name) < 7; + } + + private int matchTier(String name) { + if (alreadyEntered.isEmpty()) { + return 0; + } + int qLen = alreadyEntered.length(); String nameLower = name.toLowerCase(); + String normalizedQuery = normalizedIdentifier(alreadyEntered); + String normalizedName = normalizedIdentifier(name); + if (name.equals(alreadyEntered)) { + return 0; + } + if (name.startsWith(alreadyEntered)) { + return 1; + } if (nameLower.startsWith(alreadyEnteredLower)) { - // close to perfect - return 0.999; + return 2; + } + // treat '_' and other non-identifier separators as optional for matching + if (!normalizedQuery.isEmpty() && normalizedName.startsWith(normalizedQuery)) { + return 2; + } + // For short prefixes, keep matching intentionally tight to avoid noisy "dead-end" lists. + if (qLen == 1) { + return 7; + } + if (isCamelSubsequence(alreadyEntered, name)) { + return 3; + } + if (qLen == 2) { + // Avoid broad contains/subsequence for 2-char queries. + return 7; + } + if (name.contains(alreadyEntered)) { + return 4; + } + if (nameLower.contains(alreadyEnteredLower)) { + return 5; } + if (!normalizedQuery.isEmpty() && normalizedName.contains(normalizedQuery)) { + return 5; + } + if (Utils.isSubsequenceIgnoreCase(alreadyEntered, name)) { + return 6; + } + return 7; + } - int ssLen; - if (Utils.isSubsequence(alreadyEntered, name)) { - ssLen = Math.min(Utils.subsequenceLengthes(alreadyEntered, name).size(), Utils.subsequenceLengthes(alreadyEnteredLower, nameLower).size()); - } else { - ssLen = Utils.subsequenceLengthes(alreadyEnteredLower, nameLower).size(); + private String normalizedIdentifier(String s) { + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isLetterOrDigit(c)) { + b.append(Character.toLowerCase(c)); + } } - return 1 - ssLen * 1. / alreadyEntered.length(); + return b.toString(); + } + + private boolean isSuitableCompletion(String name) { + if (name.endsWith("Tests")) { + return false; + } + return isPotentialMatch(name); } private static String nearestScopeName(NameDef n) { @@ -595,6 +1050,14 @@ private static String nearestScopeName(NameDef n) { } } + private static String nearestScopeName(Element e) { + if (e.attrNearestNamedScope() != null) { + return Utils.printElement(e.attrNearestNamedScope()); + } else { + return "Global"; + } + } + private CompletionItem makeFunctionCompletion(FuncLink f) { String replacementString = f.getName(); List params = f.getParameterTypes(); @@ -603,13 +1066,15 @@ private CompletionItem makeFunctionCompletion(FuncLink f) { CompletionItem completion = new CompletionItem(f.getName()); completion.setKind(CompletionItemKind.Function); completion.setDetail(getFunctionDescriptionShort(f.getDef())); - completion.setDocumentation(HoverInfo.descriptionString(f.getDef())); + completion.setDocumentation(f.getDef().attrComment()); + docTargets.put(completion, f.getDef()); completion.setInsertText(replacementString); double rating = calculateRating(f.getName(), f.getReturnType()); if (f.getDef().attrHasAnnotation("deprecated")) { rating -= 0.05; } completion.setSortText(ratingToString(rating)); + completionTypes.put(completion, f.getReturnType()); // TODO use call signature instead for generics // completion.set @@ -697,10 +1162,12 @@ private CompletionItem makeConstructorCompletion(ClassDef c, ConstructorDef cons completion.setKind(CompletionItemKind.Constructor); String params = Utils.getParameterListText(constr); completion.setDetail("(" + params + ")"); - completion.setDocumentation(HoverInfo.descriptionString(constr)); + completion.setDocumentation(constr.attrComment()); + docTargets.put(completion, constr); completion.setInsertTextFormat(InsertTextFormat.Snippet); completion.setInsertText(replacementString); completion.setSortText(ratingToString(calculateRating(c.getName(), c.attrTyp().dynamic()))); + completionTypes.put(completion, c.attrTyp().dynamic()); List parameterNames = constr.getParameters().stream().map(WParameter::getName).collect(Collectors.toList()); @@ -719,7 +1186,12 @@ private void completionsAddVisibleExtensionFunctions(List comple FuncLink ef = (FuncLink) e.getValue(); FuncLink ef2 = ef.adaptToReceiverType(leftType); if (ef2 != null) { - completions.add(makeFunctionCompletion(ef2)); + if (!addCompletionCandidate(completions, makeFunctionCompletion(ef2))) { + isIncomplete = true; + if (!hasExpectedType) { + return; + } + } } } } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/AutoCompleteTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/AutoCompleteTests.java index 137a9166e..f5e6faac5 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/AutoCompleteTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/AutoCompleteTests.java @@ -474,6 +474,131 @@ public void completionUsesHotdocComment() { ); } + @Test + public void noCompletionOnRealNumberDot() { + CompletionTestData testData = input( + "package test", + "init", + " real x = 2.|", + "endpackage" + ); + + CompletionList completions = calculateCompletions(testData); + assertTrue(completions.getItems().isEmpty(), "completionLabels = " + sortedLabels(completions)); + } + + @Test + public void separatorInsensitiveMatchKeepsTypePreferredResultOnTop() { + CompletionTestData testData = input( + "package test", + "function MYXX_TEXT_JUSTIFY_CENTER() returns int", + " return 1", + "function MYXX_TEXTURES() returns bool", + " return true", + "init", + " int x = MYXXTEXTU|", + "endpackage" + ); + + List labels = sortedLabels(calculateCompletions(testData)); + assertTrue(labels.contains("MYXX_TEXT_JUSTIFY_CENTER"), "labels = " + labels); + assertTrue(labels.contains("MYXX_TEXTURES"), "labels = " + labels); + assertLabelBefore(labels, "MYXX_TEXT_JUSTIFY_CENTER", "MYXX_TEXTURES"); + } + + @Test + public void shortPrefixExpectedTypeComesBeforeWrongType() { + CompletionTestData testData = input( + "package test", + "function QZGood() returns int", + " return 1", + "function QZBad() returns bool", + " return true", + "init", + " int x = QZ|", + "endpackage" + ); + + List labels = sortedLabels(calculateCompletions(testData)); + assertTrue(labels.contains("QZGood"), "labels = " + labels); + assertTrue(labels.contains("QZBad"), "labels = " + labels); + assertLabelBefore(labels, "QZGood", "QZBad"); + } + + @Test + public void emptyComparisonRhsUsesExpectedTypeForRanking() { + CompletionTestData testData = input( + "package test", + "function CMP_PlayerCandidate() returns player", + " return Player(0)", + "function CMP_BoolCandidate() returns bool", + " return true", + "init", + " player p = Player(0)", + " if p == |", + "endpackage" + ); + + List labels = sortedLabels(calculateCompletions(testData)); + assertTrue(labels.contains("CMP_PlayerCandidate"), "labels = " + labels); + assertEquals(labels.get(0), "CMP_PlayerCandidate", "labels = " + labels); + } + + @Test + public void emptyCallArgUsesExpectedTypeForRanking() { + CompletionTestData testData = input( + "package test", + "function player.getState(playerstate s) returns int", + " return 0", + "function ARG_PlayerStateCandidate() returns playerstate", + " return PLAYER_STATE_RESOURCE_GOLD", + "function ARG_BoolCandidate() returns bool", + " return true", + "init", + " player p = Player(0)", + " p.getState(|)", + "endpackage" + ); + + List labels = sortedLabels(calculateCompletions(testData)); + assertTrue(labels.contains("ARG_PlayerStateCandidate"), "labels = " + labels); + assertTrue(labels.contains("ARG_BoolCandidate"), "labels = " + labels); + assertLabelBefore(labels, "ARG_PlayerStateCandidate", "ARG_BoolCandidate"); + } + + @Test + public void stdlibGetPlayerStateEmptyArgPrefersPlayerStateConstants() { + CompletionTestData testData = input( + "package test", + "init", + " GetPlayerState(Player(0), |)", + "endpackage" + ); + + List labels = sortedLabels(calculateCompletions(testData)); + assertTrue(labels.contains("PLAYER_STATE_RESOURCE_GOLD"), "labels = " + labels); + assertTrue(labels.contains("AbilityId"), "labels = " + labels); + assertLabelBefore(labels, "PLAYER_STATE_RESOURCE_GOLD", "AbilityId"); + } + + @Test + public void extensionGetStateEmptyArgPrefersPlayerStateConstants() { + CompletionTestData testData = input( + "package test", + "function player.getState(playerstate whichState) returns int", + " return GetPlayerState(this, whichState)", + "init", + " player p = Player(0)", + " p.getState(|)", + "endpackage" + ); + + List labels = sortedLabels(calculateCompletions(testData)); + assertTrue(labels.contains("PLAYER_STATE_RESOURCE_GOLD"), "labels = " + labels); + assertTrue(labels.contains("AbilityId"), "labels = " + labels); + assertLabelBefore(labels, "PLAYER_STATE_RESOURCE_GOLD", "AbilityId"); + } + private void testCompletions(CompletionTestData testData, String... expectedCompletions) { testCompletions(testData, Arrays.asList(expectedCompletions)); } @@ -507,5 +632,20 @@ private CompletionList calculateCompletions(CompletionTestData testData) { return getCompletions.execute(modelManager); } + private List sortedLabels(CompletionList result) { + return result.getItems().stream() + .sorted(Comparator.comparing(CompletionItem::getSortText)) + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + } + + private void assertLabelBefore(List labels, String first, String second) { + int iFirst = labels.indexOf(first); + int iSecond = labels.indexOf(second); + assertTrue(iFirst >= 0, "Missing label " + first + " in " + labels); + assertTrue(iSecond >= 0, "Missing label " + second + " in " + labels); + assertTrue(iFirst < iSecond, "Expected " + first + " before " + second + ", labels = " + labels); + } + } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java index c1ae97b24..194d638f7 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java @@ -473,7 +473,14 @@ public void completionUsesJassDocFallbackForBuiltinFunction() throws IOException assertNotNull(completions); String doc = completions.getItems().stream() .filter(i -> "DisplayTextToPlayer".equals(i.getLabel())) - .map(i -> i.getDocumentation() != null ? i.getDocumentation().getLeft() : null) + .map(i -> { + if (i.getDocumentation() == null) { + return null; + } + return i.getDocumentation().isLeft() + ? i.getDocumentation().getLeft() + : i.getDocumentation().getRight().getValue(); + }) .filter(Objects::nonNull) .findFirst() .orElse("");