From 1b43942dd8558cde04e21d148573f180ff1bb694 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 12:44:42 +0100 Subject: [PATCH 1/8] lsp improvements --- .../wurstio/languageserver/BufferManager.java | 35 +- .../peeeq/wurstio/languageserver/Convert.java | 9 +- .../languageserver/LanguageWorker.java | 26 ++ .../wurstio/languageserver/WurstCommands.java | 8 +- .../languageserver/WurstLanguageServer.java | 35 +- .../WurstTextDocumentService.java | 57 ++- .../requests/CodeActionRequest.java | 79 ++-- .../requests/CodeLensRequest.java | 3 +- .../requests/GetDefinition.java | 56 ++- .../requests/InlayHintsRequest.java | 156 ++++++++ .../requests/PrepareRenameRequest.java | 69 ++++ .../requests/RenameRequest.java | 11 +- .../requests/SemanticTokensRequest.java | 325 +++++++++++++++++ .../requests/SignatureInfo.java | 27 +- .../tests/LspNativeFeaturesTests.java | 336 ++++++++++++++++++ 15 files changed, 1168 insertions(+), 64 deletions(-) create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/PrepareRenameRequest.java create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SemanticTokensRequest.java create mode 100644 de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/BufferManager.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/BufferManager.java index f217b0dac..0dd5dbb91 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/BufferManager.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/BufferManager.java @@ -41,9 +41,12 @@ synchronized void handleFileChange(FileEvent fileEvent) { switch (fileEvent.getType()) { case Created: case Changed: - readFileFromDisk(uri); + readFileFromDisk(uri); + break; case Deleted: currentBuffer.remove(uri); + latestVersion.remove(uri); + break; } } @@ -69,7 +72,8 @@ private String readFileFromDisk(WFile uri) { synchronized void handleChange(DidChangeTextDocumentParams params) { WFile uri = WFile.create(params.getTextDocument().getUri()); - int version = params.getTextDocument().getVersion(); + Integer versionObj = params.getTextDocument().getVersion(); + int version = versionObj != null ? versionObj : getTextDocumentVersion(uri) + 1; if (version < getTextDocumentVersion(uri)) { // ignore old versions return; @@ -84,26 +88,45 @@ synchronized void handleChange(DidChangeTextDocumentParams params) { } else { int start = getOffset(sb, contentChange.getRange().getStart()); int end = getOffset(sb, contentChange.getRange().getEnd()); - sb.replace(start, end - start, contentChange.getText()); + if (end < start) { + int tmp = start; + start = end; + end = tmp; + } + sb.replace(start, end, contentChange.getText()); } } } + synchronized void handleOpen(DidOpenTextDocumentParams params) { + TextDocumentItem item = params.getTextDocument(); + WFile uri = WFile.create(item.getUri()); + latestVersion.put(uri, item.getVersion()); + StringBuilder sb = buffer(uri); + sb.replace(0, sb.length(), item.getText()); + } + + synchronized void handleClose(DidCloseTextDocumentParams params) { + WFile uri = WFile.create(params.getTextDocument().getUri()); + currentBuffer.remove(uri); + latestVersion.remove(uri); + } + public synchronized int getTextDocumentVersion(WFile uri) { return latestVersion.getOrDefault(uri, -1); } private int getOffset(StringBuilder sb, Position position) { int pos = 0; - int line = 1; + int line = 0; while (pos < sb.length() && line < position.getLine()) { if (sb.charAt(pos) == '\n') { line++; } pos++; } - pos += position.getCharacter(); - return Math.min(pos, sb.length() - 1); + pos += Math.max(0, position.getCharacter()); + return Math.min(Math.max(0, pos), sb.length()); } synchronized public void updateFile(WFile wFile, String contents) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java index 1404f9260..be11f445d 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java @@ -6,6 +6,7 @@ import org.eclipse.lsp4j.*; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -76,7 +77,13 @@ public static PublishDiagnosticsParams createDiagnostics(String extra, WFile fil break; } - diagnostics.add(new Diagnostic(range, message, severity, "Wurst")); + Diagnostic diagnostic = new Diagnostic(range, message, severity, "Wurst"); + diagnostic.setCode("WURST_" + err.getErrorType().name()); + String messageLower = message.toLowerCase(); + if (messageLower.contains("deprecated")) { + diagnostic.setTags(Collections.singletonList(DiagnosticTag.Deprecated)); + } + diagnostics.add(diagnostic); } return new PublishDiagnosticsParams(filename.getUriString(), diagnostics); } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java index 2ece3cba6..5cb5bbc87 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java @@ -284,6 +284,32 @@ public void handleChange(DidChangeTextDocumentParams params) { } } + public void handleOpen(DidOpenTextDocumentParams params) { + synchronized (lock) { + bufferManager.handleOpen(params); + WFile file = WFile.create(params.getTextDocument().getUri()); + changes.put(file, new FileReconcile(file, bufferManager.getBuffer(file))); + lock.notifyAll(); + } + } + + public void handleClose(DidCloseTextDocumentParams params) { + synchronized (lock) { + WFile file = WFile.create(params.getTextDocument().getUri()); + bufferManager.handleClose(params); + changes.put(file, new FileUpdated(file)); + lock.notifyAll(); + } + } + + public void handleSave(DidSaveTextDocumentParams params) { + synchronized (lock) { + WFile file = WFile.create(params.getTextDocument().getUri()); + changes.put(file, new FileUpdated(file)); + lock.notifyAll(); + } + } + public CompletableFuture handle(UserRequest request) { synchronized (lock) { if (!request.keepDuplicateRequests()) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstCommands.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstCommands.java index 936acd52b..f271626a4 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstCommands.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstCommands.java @@ -37,9 +37,9 @@ public class WurstCommands { public static final String WURST_PERFORM_CODE_ACTION = "wurst.perform_code_action"; static List providedCommands() { - return List.of( - WURST_CLEAN - ); + // Commands are registered by the VS Code extension itself. + // Advertising them here causes duplicate command registration in vscode-languageclient. + return List.of(); } public static CompletableFuture execute(WurstLanguageServer server, ExecuteCommandParams params) { @@ -53,6 +53,8 @@ public static CompletableFuture execute(WurstLanguageServer server, Exec case WURST_HOTRELOAD: return startMap(server, params, "-hotreload"); case WURST_TESTS: + case WURST_TESTS_FILE: + case WURST_TESTS_FUNC: return testMap(server, params); case WURST_PERFORM_CODE_ACTION: return server.worker().handle(new PerformCodeActionRequest(server, params)); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java index 0bb890825..11359df29 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java @@ -1,10 +1,10 @@ package de.peeeq.wurstio.languageserver; +import de.peeeq.wurstio.languageserver.requests.SemanticTokensRequest; import de.peeeq.wurstscript.CompileTimeInfo; import de.peeeq.wurstscript.WLogger; import org.eclipse.lsp4j.*; import org.eclipse.lsp4j.jsonrpc.RemoteEndpoint; -import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.*; import java.io.FileDescriptor; @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.concurrent.CompletableFuture; /** @@ -38,24 +39,44 @@ public CompletableFuture initialize(InitializeParams params) { languageWorker.setRootPath(rootUri); ServerCapabilities capabilities = new ServerCapabilities(); - capabilities.setCompletionProvider(new CompletionOptions(false, Collections.singletonList("."))); + capabilities.setCompletionProvider(new CompletionOptions(true, Collections.singletonList("."))); capabilities.setHoverProvider(true); capabilities.setDefinitionProvider(true); + capabilities.setDeclarationProvider(true); + capabilities.setTypeDefinitionProvider(true); + capabilities.setImplementationProvider(true); capabilities.setSignatureHelpProvider(new SignatureHelpOptions(Arrays.asList("(", "."))); capabilities.setDocumentHighlightProvider(true); capabilities.setReferencesProvider(true); capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(WurstCommands.providedCommands())); - capabilities.setRenameProvider(true); - - - capabilities.setTextDocumentSync(Either.forLeft(TextDocumentSyncKind.Full)); - capabilities.setCodeActionProvider(true); + capabilities.setRenameProvider(new RenameOptions(true)); + + TextDocumentSyncOptions textSync = new TextDocumentSyncOptions(); + textSync.setOpenClose(true); + textSync.setChange(TextDocumentSyncKind.Incremental); + textSync.setSave(true); + capabilities.setTextDocumentSync(textSync); + + CodeActionOptions codeActionOptions = new CodeActionOptions(List.of( + CodeActionKind.QuickFix, + CodeActionKind.Refactor, + CodeActionKind.RefactorExtract + )); + capabilities.setCodeActionProvider(codeActionOptions); capabilities.setDocumentSymbolProvider(true); capabilities.setWorkspaceSymbolProvider(true); capabilities.setDocumentFormattingProvider(true); capabilities.setColorProvider(true); capabilities.setCodeLensProvider(new CodeLensOptions(true)); capabilities.setFoldingRangeProvider(true); + SemanticTokensLegend semanticLegend = new SemanticTokensLegend( + SemanticTokensRequest.TOKEN_TYPES, + SemanticTokensRequest.TOKEN_MODIFIERS + ); + SemanticTokensWithRegistrationOptions semanticTokensOptions = + new SemanticTokensWithRegistrationOptions(semanticLegend, true, false); + capabilities.setSemanticTokensProvider(semanticTokensOptions); + capabilities.setInlayHintProvider(new InlayHintRegistrationOptions()); InitializeResult res = new InitializeResult(capabilities); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java index 643689c9f..d5ae88ed9 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java @@ -4,13 +4,14 @@ import de.peeeq.wurstscript.WLogger; import de.peeeq.wurstscript.attributes.prettyPrint.PrettyUtils; import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.jsonrpc.messages.Either3; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.TextDocumentService; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; /** * @@ -32,7 +33,7 @@ public CompletableFuture, CompletionList>> completio @Override public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { WLogger.info("resolveCompletionItem"); - return null; + return CompletableFuture.completedFuture(unresolved); } @Override @@ -43,13 +44,31 @@ public CompletableFuture hover(HoverParams hoverParams) { @Override public CompletableFuture signatureHelp(SignatureHelpParams helpParams) { WLogger.info("signatureHelp"); - return worker.handle(new SignatureInfo(helpParams)); + return worker.handle(new SignatureInfo(helpParams, worker.getBufferManager())); } @Override public CompletableFuture, List>> definition(DefinitionParams definitionParams) { WLogger.info("definition"); - return worker.handle(new GetDefinition(definitionParams, worker.getBufferManager())); + return worker.handle(new GetDefinition(definitionParams, worker.getBufferManager(), GetDefinition.LookupType.DEFINITION)); + } + + @Override + public CompletableFuture, List>> declaration(DeclarationParams params) { + WLogger.info("declaration"); + return worker.handle(new GetDefinition(params, worker.getBufferManager(), GetDefinition.LookupType.DECLARATION)); + } + + @Override + public CompletableFuture, List>> typeDefinition(TypeDefinitionParams params) { + WLogger.info("typeDefinition"); + return worker.handle(new GetDefinition(params, worker.getBufferManager(), GetDefinition.LookupType.TYPE_DEFINITION)); + } + + @Override + public CompletableFuture, List>> implementation(ImplementationParams params) { + WLogger.info("implementation"); + return worker.handle(new GetDefinition(params, worker.getBufferManager(), GetDefinition.LookupType.IMPLEMENTATION)); } @Override @@ -127,13 +146,13 @@ public CompletableFuture> formatting(DocumentFormatting @Override public CompletableFuture> rangeFormatting(DocumentRangeFormattingParams params) { WLogger.info("rangeFormatting"); - return null; + return CompletableFuture.completedFuture(Collections.emptyList()); } @Override public CompletableFuture> onTypeFormatting(DocumentOnTypeFormattingParams params) { WLogger.info("onTypeFormatting"); - return null; + return CompletableFuture.completedFuture(Collections.emptyList()); } @Override @@ -145,7 +164,7 @@ public CompletableFuture rename(RenameParams params) { @Override public void didOpen(DidOpenTextDocumentParams params) { WLogger.info("didOpen"); - + worker.handleOpen(params); } @Override @@ -158,13 +177,13 @@ public void didChange(DidChangeTextDocumentParams params) { @Override public void didClose(DidCloseTextDocumentParams params) { WLogger.info("didClose"); - + worker.handleClose(params); } @Override public void didSave(DidSaveTextDocumentParams params) { WLogger.info("didSave"); - + worker.handleSave(params); } @Override @@ -181,4 +200,24 @@ public CompletableFuture> colorPresentation(ColorPresent public CompletableFuture> foldingRange(FoldingRangeRequestParams params) { return worker.handle(new FoldingRangeRequest(params)); } + + @Override + public CompletableFuture> prepareRename(PrepareRenameParams params) { + return worker.handle(new PrepareRenameRequest(params, worker.getBufferManager())); + } + + @Override + public CompletableFuture semanticTokensFull(SemanticTokensParams params) { + return worker.handle(new SemanticTokensRequest(params, worker.getBufferManager())); + } + + @Override + public CompletableFuture> inlayHint(InlayHintParams params) { + return worker.handle(new InlayHintsRequest(params, worker.getBufferManager())); + } + + @Override + public CompletableFuture resolveInlayHint(InlayHint unresolved) { + return CompletableFuture.completedFuture(unresolved); + } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java index 984e264f2..17a7c1dfe 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java @@ -17,23 +17,29 @@ import de.peeeq.wurstscript.types.WurstTypeVoid; import de.peeeq.wurstscript.utils.Utils; import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.CodeActionKind; import org.eclipse.lsp4j.CodeActionParams; import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.WorkspaceEdit; import org.eclipse.lsp4j.jsonrpc.messages.Either; import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import static de.peeeq.wurstio.languageserver.WurstCommands.WURST_PERFORM_CODE_ACTION; - /** * */ @@ -43,6 +49,7 @@ public class CodeActionRequest extends UserRequest diagnostics; public CodeActionRequest(CodeActionParams params, BufferManager bufferManager) { this.params = params; @@ -52,11 +59,14 @@ public CodeActionRequest(CodeActionParams params, BufferManager bufferManager) { this.buffer = bufferManager.getBuffer(textDocument); this.line = params.getRange().getStart().getLine() + 1; this.column = params.getRange().getStart().getCharacter() + 1; + this.diagnostics = params.getContext() == null + ? Collections.emptyList() + : params.getContext().getDiagnostics(); } @Override public List> execute(ModelManager modelManager) { - if (params.getContext().getDiagnostics().isEmpty()) { + if (diagnostics.isEmpty()) { // if there are no compilation errors in this line, // we don't have to compute possible code actions return Collections.emptyList(); @@ -142,7 +152,7 @@ private List> handleMissingName(ModelManager modelMa } addDependencyPackageFallback(modelManager, possibleImports, funcName); - return makeImportCommands(possibleImports); + return makeImportCommands(possibleImports, modelManager); } @@ -178,7 +188,7 @@ private List> handleMissingFunction(ModelManager mod } addDependencyPackageFallback(modelManager, possibleImports, funcName); - return Utils.concatLists(makeImportCommands(possibleImports), makeCreateFunctionQuickfix(fr)); + return Utils.concatLists(makeImportCommands(possibleImports, modelManager), makeCreateFunctionQuickfix(fr)); } private List> makeCreateFunctionQuickfix(FuncRef fr) { @@ -336,13 +346,12 @@ public void indent(StringBuilder sb) { code.append("\n"); - List arguments = Collections.singletonList( - PerformCodeActionRequest.insertCodeAction( - m.targetFile.getUriString(), - m.line, - code.toString()) - ); - return Collections.singletonList(Either.forLeft(new Command(title, WURST_PERFORM_CODE_ACTION, arguments))); + Range range = new Range(new Position(m.line, 0), new Position(m.line, 0)); + TextEdit textEdit = new TextEdit(range, code.toString()); + WorkspaceEdit edit = workspaceEdit(m.targetFile.getUriString(), textEdit); + CodeAction action = makeQuickFix(title, edit); + action.setIsPreferred(true); + return Collections.singletonList(Either.forRight(action)); } @@ -358,7 +367,7 @@ private List> handleMissingType(ModelManager modelMa } addDependencyPackageFallback(modelManager, possibleImports, typeName); - return makeImportCommands(possibleImports); + return makeImportCommands(possibleImports, modelManager); } private List> handleMissingClass(ModelManager modelManager, String typeName) { @@ -382,7 +391,7 @@ private List> handleMissingClass(ModelManager modelM } addDependencyPackageFallback(modelManager, possibleImports, typeName); - return makeImportCommands(possibleImports); + return makeImportCommands(possibleImports, modelManager); } private List> handleMissingModule(ModelManager modelManager, String moduleName) { @@ -402,7 +411,7 @@ private List> handleMissingModule(ModelManager model } addDependencyPackageFallback(modelManager, possibleImports, moduleName); - return makeImportCommands(possibleImports); + return makeImportCommands(possibleImports, modelManager); } @@ -416,20 +425,40 @@ private void addDependencyPackageFallback(ModelManager modelManager, Set } } - private List> makeImportCommands(Collection possibleImports) { + private List> makeImportCommands(Collection possibleImports, ModelManager modelManager) { return possibleImports.stream() - .map(this::makeImportCommand) + .map(imp -> makeImportAction(imp, modelManager)) .collect(Collectors.toList()); } - - private Either makeImportCommand(String imp) { + private Either makeImportAction(String imp, ModelManager modelManager) { String title = "Import package " + imp; - List arguments = Collections.singletonList( - PerformCodeActionRequest.importPackageAction( - filename.getUriString(), - imp) - ); - return Either.forLeft(new Command(title, WURST_PERFORM_CODE_ACTION, arguments)); + CompilationUnit cu = modelManager.getCompilationUnit(filename); + Position pos = new Position(0, 0); + if (cu != null && !cu.getPackages().isEmpty()) { + WPackage p = cu.getPackages().get(0); + int line = p.getNameId().getSource().getLine(); + for (WImport importStatement : p.getImports()) { + line = Math.max(line, importStatement.getPackagenameId().getSource().getLine()); + } + pos.setLine(line); + } + TextEdit textEdit = new TextEdit(new Range(pos, pos), "import " + imp + "\n"); + WorkspaceEdit edit = workspaceEdit(filename.getUriString(), textEdit); + return Either.forRight(makeQuickFix(title, edit)); + } + + private WorkspaceEdit workspaceEdit(String uri, TextEdit textEdit) { + Map> changes = new LinkedHashMap<>(); + changes.put(uri, Collections.singletonList(textEdit)); + return new WorkspaceEdit(changes); + } + + private CodeAction makeQuickFix(String title, WorkspaceEdit edit) { + CodeAction action = new CodeAction(title); + action.setKind(CodeActionKind.QuickFix); + action.setEdit(edit); + action.setDiagnostics(diagnostics); + return action; } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeLensRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeLensRequest.java index 4ddbf10a5..7245af4c4 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeLensRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeLensRequest.java @@ -5,6 +5,7 @@ import de.peeeq.wurstio.languageserver.Convert; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstio.languageserver.WurstCommands; import de.peeeq.wurstscript.ast.CompilationUnit; import de.peeeq.wurstscript.ast.FuncDef; import de.peeeq.wurstscript.ast.WEntity; @@ -70,7 +71,7 @@ public Resolve(CodeLens unresolved) { @Override public CodeLens execute(ModelManager modelManager) throws IOException { Object data = unresolved.getData(); - Command cmd = new Command("Run Wurst unit test", "wurst.tests", Collections.singletonList(data)); + Command cmd = new Command("Run Wurst unit test", WurstCommands.WURST_TESTS, Collections.singletonList(data)); unresolved.setCommand(cmd); return unresolved; } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetDefinition.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetDefinition.java index a30647352..b4aa919bc 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetDefinition.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/GetDefinition.java @@ -9,6 +9,8 @@ import de.peeeq.wurstscript.attributes.CofigOverridePackages; import de.peeeq.wurstscript.attributes.names.NameLink; import de.peeeq.wurstscript.parser.WPos; +import de.peeeq.wurstscript.types.WurstType; +import de.peeeq.wurstscript.types.WurstTypeNamedScope; import de.peeeq.wurstscript.utils.Utils; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; @@ -17,20 +19,33 @@ import java.util.Collections; import java.util.List; +import java.util.Optional; public class GetDefinition extends UserRequest, List>> { + public enum LookupType { + DEFINITION, + DECLARATION, + TYPE_DEFINITION, + IMPLEMENTATION + } private final WFile filename; private final String buffer; private final int line; private final int column; + private final LookupType lookupType; public GetDefinition(TextDocumentPositionParams position, BufferManager bufferManager) { + this(position, bufferManager, LookupType.DEFINITION); + } + + public GetDefinition(TextDocumentPositionParams position, BufferManager bufferManager, LookupType lookupType) { this.filename = WFile.create(position.getTextDocument().getUri()); this.buffer = bufferManager.getBuffer(position.getTextDocument()); this.line = position.getPosition().getLine() + 1; this.column = position.getPosition().getCharacter() + 1; + this.lookupType = lookupType; } @@ -45,8 +60,16 @@ private List execute2(ModelManager modelManager) { if (cu == null) { return Collections.emptyList(); } - Element e = Utils.getAstElementAtPos(cu, line, column, false).get(); + Optional element = Utils.getAstElementAtPos(cu, line, column, false); + if (!element.isPresent()) { + return Collections.emptyList(); + } + Element e = element.get(); WLogger.info("get definition at: " + e.getClass().getSimpleName()); + if (lookupType == LookupType.TYPE_DEFINITION) { + return typeDefinitionFor(e); + } + NameDef configuredDecl = getConfiguredDeclarationAtPos(e); if (configuredDecl != null) { NameDef originalDecl = getOriginalConfigDeclaration(configuredDecl); @@ -94,6 +117,37 @@ private List execute2(ModelManager modelManager) { return Collections.emptyList(); } + private List typeDefinitionFor(Element e) { + if (e instanceof TypeExpr) { + TypeExpr typeExpr = (TypeExpr) e; + return linkTo(typeExpr.attrTypeDef()); + } + if (e instanceof NameRef) { + NameDef def = ((NameRef) e).attrNameDef(); + if (def != null) { + return linkToType(def.attrTyp()); + } + } + if (e instanceof FuncRef) { + FunctionDefinition def = ((FuncRef) e).attrFuncDef(); + if (def != null) { + return linkToType(def.attrReturnTyp()); + } + } + if (e instanceof Expr) { + return linkToType(((Expr) e).attrTyp()); + } + return Collections.emptyList(); + } + + private List linkToType(WurstType type) { + if (type instanceof WurstTypeNamedScope) { + AstElementWithSource def = ((WurstTypeNamedScope) type).getDef(); + return linkTo(def); + } + return Collections.emptyList(); + } + private NameDef getConfiguredDeclarationAtPos(Element e) { if (e instanceof NameDef) { return (NameDef) e; diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java new file mode 100644 index 000000000..20d52355c --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java @@ -0,0 +1,156 @@ +package de.peeeq.wurstio.languageserver.requests; + +import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.Convert; +import de.peeeq.wurstio.languageserver.ModelManager; +import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstscript.attributes.CompileError; +import de.peeeq.wurstscript.ast.Arguments; +import de.peeeq.wurstscript.ast.CompilationUnit; +import de.peeeq.wurstscript.ast.ConstructorDef; +import de.peeeq.wurstscript.ast.Element; +import de.peeeq.wurstscript.ast.Expr; +import de.peeeq.wurstscript.ast.ExprFunctionCall; +import de.peeeq.wurstscript.ast.ExprMemberMethod; +import de.peeeq.wurstscript.ast.ExprNewObject; +import de.peeeq.wurstscript.attributes.names.FuncLink; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.InlayHintKind; +import org.eclipse.lsp4j.InlayHintParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class InlayHintsRequest extends UserRequest> { + + private static final Map> lastStableHintsByFile = new ConcurrentHashMap<>(); + private final WFile filename; + private final String buffer; + private final Range requestedRange; + + public InlayHintsRequest(InlayHintParams params, BufferManager bufferManager) { + this.filename = WFile.create(params.getTextDocument().getUri()); + this.buffer = bufferManager.getBuffer(params.getTextDocument()); + this.requestedRange = params.getRange(); + } + + @Override + public List execute(ModelManager modelManager) { + CompilationUnit cu = modelManager.replaceCompilationUnitContent(filename, buffer, false); + if (cu == null) { + return filterByRange(lastStableHintsByFile.getOrDefault(filename, Collections.emptyList())); + } + + List hints = new ArrayList<>(); + ArrayDeque todo = new ArrayDeque<>(); + todo.push(cu); + while (!todo.isEmpty()) { + Element e = todo.pop(); + collectHints(hints, e); + for (int i = e.size() - 1; i >= 0; i--) { + todo.push(e.get(i)); + } + } + + if (hasParseErrorsForFile(modelManager)) { + return filterByRange(lastStableHintsByFile.getOrDefault(filename, Collections.emptyList())); + } + + lastStableHintsByFile.put(filename, hints); + return filterByRange(hints); + } + + private void collectHints(List hints, Element e) { + if (e instanceof ExprFunctionCall) { + ExprFunctionCall call = (ExprFunctionCall) e; + FuncLink f = call.attrFuncLink(); + if (f != null) { + addParameterHints(hints, call.getArgs(), f.getParameterNames()); + } + return; + } + if (e instanceof ExprMemberMethod) { + ExprMemberMethod call = (ExprMemberMethod) e; + FuncLink f = call.attrFuncLink(); + if (f != null) { + addParameterHints(hints, call.getArgs(), f.getParameterNames()); + } + return; + } + if (e instanceof ExprNewObject) { + ExprNewObject exprNew = (ExprNewObject) e; + ConstructorDef constructorDef = exprNew.attrConstructorDef(); + if (constructorDef == null) { + return; + } + List paramNames = new ArrayList<>(); + constructorDef.getParameters().forEach(p -> paramNames.add(p.getName())); + addParameterHints(hints, exprNew.getArgs(), paramNames); + } + } + + private void addParameterHints(List hints, Arguments args, List paramNames) { + int count = Math.min(args.size(), paramNames.size()); + for (int i = 0; i < count; i++) { + Expr arg = args.get(i); + String paramName = paramNames.get(i); + if (paramName == null || paramName.isEmpty()) { + continue; + } + Position pos = argumentStart(arg); + InlayHint hint = new InlayHint(); + hint.setPosition(pos); + hint.setLabel(paramName + ":"); + hint.setKind(InlayHintKind.Parameter); + hint.setPaddingRight(true); + hints.add(hint); + } + } + + private Position argumentStart(Expr arg) { + Range sourceRange = Convert.range(arg); + Position sourceStart = sourceRange.getStart(); + if (sourceStart.getLine() >= 0 && sourceStart.getCharacter() >= 0) { + return sourceStart; + } + return Convert.errorRange(arg).getStart(); + } + + private boolean hasParseErrorsForFile(ModelManager modelManager) { + List parseErrors = modelManager.getParseErrors(); + for (CompileError err : parseErrors) { + if (err.getSource() != null && WFile.create(err.getSource().getFile()).equals(filename)) { + return true; + } + } + return false; + } + + private List filterByRange(List hints) { + return hints.stream() + .filter(h -> isInRequestedRange(h.getPosition())) + .collect(Collectors.toList()); + } + + private boolean isInRequestedRange(Position pos) { + if (pos.getLine() < requestedRange.getStart().getLine() || pos.getLine() > requestedRange.getEnd().getLine()) { + return false; + } + if (pos.getLine() == requestedRange.getStart().getLine() + && pos.getCharacter() < requestedRange.getStart().getCharacter()) { + return false; + } + if (pos.getLine() == requestedRange.getEnd().getLine() + && pos.getCharacter() > requestedRange.getEnd().getCharacter()) { + return false; + } + return true; + } +} diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/PrepareRenameRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/PrepareRenameRequest.java new file mode 100644 index 000000000..60ffc50f0 --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/PrepareRenameRequest.java @@ -0,0 +1,69 @@ +package de.peeeq.wurstio.languageserver.requests; + +import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.Convert; +import de.peeeq.wurstio.languageserver.ModelManager; +import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstscript.ast.CompilationUnit; +import de.peeeq.wurstscript.ast.Element; +import de.peeeq.wurstscript.ast.NameDef; +import de.peeeq.wurstscript.ast.TypeExpr; +import de.peeeq.wurstscript.ast.WImport; +import de.peeeq.wurstscript.ast.WPackage; +import de.peeeq.wurstscript.utils.Utils; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.PrepareRenameResult; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.jsonrpc.messages.Either3; + +import java.util.Optional; + +public class PrepareRenameRequest extends UserRequest> { + + private final WFile filename; + private final String buffer; + private final int line; + private final int column; + + public PrepareRenameRequest(PrepareRenameParams params, BufferManager bufferManager) { + this.filename = WFile.create(params.getTextDocument().getUri()); + this.buffer = bufferManager.getBuffer(params.getTextDocument()); + this.line = params.getPosition().getLine() + 1; + this.column = params.getPosition().getCharacter() + 1; + } + + @Override + public Either3 execute(ModelManager modelManager) { + CompilationUnit cu = modelManager.replaceCompilationUnitContent(filename, buffer, false); + if (cu == null) { + throw new RequestFailedException(MessageType.Error, "File " + filename + " is not part of the project."); + } + Optional element = Utils.getAstElementAtPos(cu, line, column, false); + if (!element.isPresent()) { + throw new RequestFailedException(MessageType.Error, "No symbol at cursor."); + } + Element e = element.get(); + if (!isRenameTarget(e)) { + throw new RequestFailedException(MessageType.Error, "Selected element cannot be renamed."); + } + return Either3.forFirst(Convert.errorRange(e)); + } + + private boolean isRenameTarget(Element e) { + NameDef nameDef = e.tryGetNameDef(); + if (nameDef != null) { + return true; + } + if (e instanceof TypeExpr) { + return ((TypeExpr) e).attrTypeDef() != null; + } + if (e instanceof WImport) { + WPackage p = ((WImport) e).attrImportedPackage(); + return p != null; + } + return false; + } +} + diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RenameRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RenameRequest.java index 191397d01..f24fa9934 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RenameRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RenameRequest.java @@ -6,8 +6,10 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -36,10 +38,15 @@ public WorkspaceEdit execute(ModelManager modelManager) { String uri = e.getKey(); VersionedTextDocumentIdentifier textDocument = new VersionedTextDocumentIdentifier(); textDocument.setUri(uri); + textDocument.setVersion(bufferManager.getTextDocumentVersion(de.peeeq.wurstio.languageserver.WFile.create(uri))); List fileEdits = new ArrayList<>(); + Set seenRanges = new LinkedHashSet<>(); for (GetUsages.UsagesData usage : e.getValue()) { - if (usage.getKind() == DocumentHighlightKind.Read) { - fileEdits.add(new TextEdit(usage.getRange(), params.getNewName())); + Range range = usage.getRange(); + String key = range.getStart().getLine() + ":" + range.getStart().getCharacter() + "-" + + range.getEnd().getLine() + ":" + range.getEnd().getCharacter(); + if (seenRanges.add(key)) { + fileEdits.add(new TextEdit(range, params.getNewName())); } } edits.add(Either.forLeft(new TextDocumentEdit(textDocument, fileEdits))); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SemanticTokensRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SemanticTokensRequest.java new file mode 100644 index 000000000..2e07c67c8 --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SemanticTokensRequest.java @@ -0,0 +1,325 @@ +package de.peeeq.wurstio.languageserver.requests; + +import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.Convert; +import de.peeeq.wurstio.languageserver.ModelManager; +import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstscript.ast.ClassDef; +import de.peeeq.wurstscript.ast.CompilationUnit; +import de.peeeq.wurstscript.ast.ConstructorDef; +import de.peeeq.wurstscript.ast.Element; +import de.peeeq.wurstscript.ast.EnumDef; +import de.peeeq.wurstscript.ast.EnumMember; +import de.peeeq.wurstscript.ast.ExprIntVal; +import de.peeeq.wurstscript.ast.ExprRealVal; +import de.peeeq.wurstscript.ast.ExprStringVal; +import de.peeeq.wurstscript.ast.FuncRef; +import de.peeeq.wurstscript.ast.FunctionDefinition; +import de.peeeq.wurstscript.ast.GlobalVarDef; +import de.peeeq.wurstscript.ast.InterfaceDef; +import de.peeeq.wurstscript.ast.LocalVarDef; +import de.peeeq.wurstscript.ast.ModuleDef; +import de.peeeq.wurstscript.ast.NameDef; +import de.peeeq.wurstscript.ast.NameRef; +import de.peeeq.wurstscript.ast.NativeType; +import de.peeeq.wurstscript.ast.TupleDef; +import de.peeeq.wurstscript.ast.TypeDef; +import de.peeeq.wurstscript.ast.TypeExpr; +import de.peeeq.wurstscript.ast.TypeParamDef; +import de.peeeq.wurstscript.ast.WImport; +import de.peeeq.wurstscript.ast.WPackage; +import de.peeeq.wurstscript.ast.WParameter; +import de.peeeq.wurstscript.attributes.names.FuncLink; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.SemanticTokenModifiers; +import org.eclipse.lsp4j.SemanticTokenTypes; +import org.eclipse.lsp4j.SemanticTokens; +import org.eclipse.lsp4j.SemanticTokensParams; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class SemanticTokensRequest extends UserRequest { + + public static final List TOKEN_TYPES = List.of( + SemanticTokenTypes.Namespace, + SemanticTokenTypes.Class, + SemanticTokenTypes.Interface, + SemanticTokenTypes.Enum, + SemanticTokenTypes.Type, + SemanticTokenTypes.TypeParameter, + SemanticTokenTypes.Function, + SemanticTokenTypes.Method, + SemanticTokenTypes.Parameter, + SemanticTokenTypes.Variable, + SemanticTokenTypes.Property, + SemanticTokenTypes.EnumMember, + SemanticTokenTypes.String, + SemanticTokenTypes.Number + ); + + public static final List TOKEN_MODIFIERS = List.of( + SemanticTokenModifiers.Declaration, + SemanticTokenModifiers.Readonly + ); + + private static final int DECLARATION_MOD = modifierBit(SemanticTokenModifiers.Declaration); + private static final int READONLY_MOD = modifierBit(SemanticTokenModifiers.Readonly); + + private final WFile filename; + private final String buffer; + + public SemanticTokensRequest(SemanticTokensParams params, BufferManager bufferManager) { + this.filename = WFile.create(params.getTextDocument().getUri()); + this.buffer = bufferManager.getBuffer(params.getTextDocument()); + } + + @Override + public SemanticTokens execute(ModelManager modelManager) { + CompilationUnit cu = modelManager.replaceCompilationUnitContent(filename, buffer, false); + if (cu == null) { + return new SemanticTokens(Collections.emptyList()); + } + + String[] lines = buffer.split("\\r?\\n", -1); + TokenCollector collector = new TokenCollector(lines); + ArrayDeque todo = new ArrayDeque<>(); + todo.push(cu); + while (!todo.isEmpty()) { + Element e = todo.pop(); + classify(collector, e); + for (int i = e.size() - 1; i >= 0; i--) { + todo.push(e.get(i)); + } + } + + return new SemanticTokens(collector.encode()); + } + + private void classify(TokenCollector collector, Element e) { + if (e instanceof WPackage) { + collector.add(((WPackage) e).getNameId(), tokenTypeIndex(SemanticTokenTypes.Namespace), DECLARATION_MOD); + return; + } + if (e instanceof ClassDef || e instanceof ModuleDef || e instanceof TupleDef || e instanceof NativeType) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.Class), DECLARATION_MOD); + return; + } + if (e instanceof InterfaceDef) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.Interface), DECLARATION_MOD); + return; + } + if (e instanceof EnumDef) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.Enum), DECLARATION_MOD); + return; + } + if (e instanceof TypeParamDef) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.TypeParameter), DECLARATION_MOD); + return; + } + if (e instanceof FunctionDefinition) { + FunctionDefinition f = (FunctionDefinition) e; + int type = f.attrIsDynamicClassMember() + ? tokenTypeIndex(SemanticTokenTypes.Method) + : tokenTypeIndex(SemanticTokenTypes.Function); + collector.add(f, type, DECLARATION_MOD); + return; + } + if (e instanceof ConstructorDef) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.Method), DECLARATION_MOD); + return; + } + if (e instanceof WParameter) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.Parameter), DECLARATION_MOD); + return; + } + if (e instanceof GlobalVarDef) { + GlobalVarDef v = (GlobalVarDef) e; + int type = v.attrIsDynamicClassMember() + ? tokenTypeIndex(SemanticTokenTypes.Property) + : tokenTypeIndex(SemanticTokenTypes.Variable); + int mods = DECLARATION_MOD | (v.attrIsConstant() ? READONLY_MOD : 0); + collector.add(v, type, mods); + return; + } + if (e instanceof LocalVarDef) { + LocalVarDef v = (LocalVarDef) e; + int mods = DECLARATION_MOD | (v.attrIsConstant() ? READONLY_MOD : 0); + collector.add(v, tokenTypeIndex(SemanticTokenTypes.Variable), mods); + return; + } + if (e instanceof EnumMember) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.EnumMember), DECLARATION_MOD); + return; + } + if (e instanceof FuncRef) { + FuncLink f = ((FuncRef) e).attrFuncLink(); + if (f != null) { + boolean isMethod = f.getDef() != null && f.getDef().attrIsDynamicClassMember(); + collector.add(e, tokenTypeIndex(isMethod ? SemanticTokenTypes.Method : SemanticTokenTypes.Function), 0); + } + return; + } + if (e instanceof NameRef) { + NameDef def = ((NameRef) e).tryGetNameDef(); + if (def != null) { + collector.add(e, tokenTypeForDefinition(def), def.attrIsConstant() ? READONLY_MOD : 0); + } + return; + } + if (e instanceof TypeExpr) { + TypeDef def = ((TypeExpr) e).attrTypeDef(); + if (def != null) { + collector.add(e, tokenTypeForTypeDef(def), 0); + } + return; + } + if (e instanceof WImport) { + collector.add(((WImport) e).getPackagenameId(), tokenTypeIndex(SemanticTokenTypes.Namespace), 0); + return; + } + if (e instanceof ExprStringVal) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.String), 0); + return; + } + if (e instanceof ExprIntVal || e instanceof ExprRealVal) { + collector.add(e, tokenTypeIndex(SemanticTokenTypes.Number), 0); + } + } + + private int tokenTypeForDefinition(NameDef def) { + if (def instanceof WParameter) { + return tokenTypeIndex(SemanticTokenTypes.Parameter); + } + if (def instanceof FunctionDefinition) { + FunctionDefinition f = (FunctionDefinition) def; + return tokenTypeIndex(f.attrIsDynamicClassMember() ? SemanticTokenTypes.Method : SemanticTokenTypes.Function); + } + if (def instanceof TypeDef) { + return tokenTypeForTypeDef((TypeDef) def); + } + if (def instanceof EnumMember) { + return tokenTypeIndex(SemanticTokenTypes.EnumMember); + } + if (def instanceof GlobalVarDef) { + GlobalVarDef gv = (GlobalVarDef) def; + return tokenTypeIndex(gv.attrIsDynamicClassMember() ? SemanticTokenTypes.Property : SemanticTokenTypes.Variable); + } + if (def instanceof LocalVarDef) { + return tokenTypeIndex(SemanticTokenTypes.Variable); + } + return tokenTypeIndex(SemanticTokenTypes.Variable); + } + + private int tokenTypeForTypeDef(TypeDef def) { + if (def instanceof InterfaceDef) { + return tokenTypeIndex(SemanticTokenTypes.Interface); + } + if (def instanceof EnumDef) { + return tokenTypeIndex(SemanticTokenTypes.Enum); + } + return tokenTypeIndex(SemanticTokenTypes.Class); + } + + private static int tokenTypeIndex(String type) { + int index = TOKEN_TYPES.indexOf(type); + return Math.max(index, 0); + } + + private static int modifierBit(String modifierName) { + int index = TOKEN_MODIFIERS.indexOf(modifierName); + if (index < 0) { + return 0; + } + return 1 << index; + } + + private static class TokenCollector { + private static class Token { + int line; + int startChar; + int length; + int type; + int modifiers; + } + + private final String[] lines; + private final List tokens = new ArrayList<>(); + private final Set dedupe = new LinkedHashSet<>(); + + TokenCollector(String[] lines) { + this.lines = lines; + } + + void add(Element e, int type, int modifiers) { + if (e == null || e.attrErrorPos().isArtificial()) { + return; + } + addRange(Convert.errorRange(e), type, modifiers); + } + + private void addRange(Range range, int type, int modifiers) { + int startLine = Math.max(0, range.getStart().getLine()); + int endLine = Math.max(startLine, range.getEnd().getLine()); + for (int line = startLine; line <= endLine; line++) { + if (line >= lines.length) { + break; + } + int lineLength = lines[line].length(); + int start = line == startLine ? Math.max(0, range.getStart().getCharacter()) : 0; + int end = line == endLine ? Math.max(0, range.getEnd().getCharacter()) : lineLength; + start = Math.min(start, lineLength); + end = Math.min(end, lineLength); + int length = end - start; + if (length <= 0) { + continue; + } + String key = line + ":" + start + ":" + length + ":" + type + ":" + modifiers; + if (!dedupe.add(key)) { + continue; + } + Token t = new Token(); + t.line = line; + t.startChar = start; + t.length = length; + t.type = type; + t.modifiers = modifiers; + tokens.add(t); + } + } + + List encode() { + tokens.sort(Comparator.comparingInt((Token t) -> t.line) + .thenComparingInt(t -> t.startChar) + .thenComparingInt(t -> t.length)); + + List encoded = new ArrayList<>(tokens.size() * 5); + int lastLine = 0; + int lastStartChar = 0; + boolean first = true; + for (Token t : tokens) { + int deltaLine = first ? t.line : t.line - lastLine; + int deltaStart = first + ? t.startChar + : (deltaLine == 0 ? t.startChar - lastStartChar : t.startChar); + encoded.add(deltaLine); + encoded.add(deltaStart); + encoded.add(t.length); + encoded.add(t.type); + encoded.add(t.modifiers); + first = false; + lastLine = t.line; + lastStartChar = t.startChar; + } + return encoded; + } + } +} + diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java index 110812ebb..da03b23cc 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java @@ -1,5 +1,6 @@ package de.peeeq.wurstio.languageserver.requests; +import de.peeeq.wurstio.languageserver.BufferManager; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.ast.*; @@ -18,21 +19,29 @@ public class SignatureInfo extends UserRequest { private final WFile filename; + private final String buffer; private final int line; private final int column; - public SignatureInfo(TextDocumentPositionParams position) { + public SignatureInfo(TextDocumentPositionParams position, BufferManager bufferManager) { this.filename = WFile.create(position.getTextDocument().getUri()); + this.buffer = bufferManager.getBuffer(position.getTextDocument()); this.line = position.getPosition().getLine() + 1; this.column = position.getPosition().getCharacter() + 1; } - @Override + @Override public SignatureHelp execute(ModelManager modelManager) { - CompilationUnit cu = modelManager.getCompilationUnit(filename); + CompilationUnit cu = modelManager.replaceCompilationUnitContent(filename, buffer, false); + if (cu == null) { + return new SignatureHelp(Collections.emptyList(), 0, 0); + } Optional e = Utils.getAstElementAtPos(cu, line, column, false); + if (!e.isPresent()) { + return new SignatureHelp(Collections.emptyList(), 0, 0); + } if (e.get() instanceof StmtCall) { StmtCall call = (StmtCall) e.get(); // TODO only when we are in parentheses @@ -44,12 +53,12 @@ public SignatureHelp execute(ModelManager modelManager) { Optional parent = e.flatMap(el -> Optional.ofNullable(el.getParent())); if (parent.isPresent() && parent.get() instanceof Arguments) { Arguments args = (Arguments) parent.get(); - if (parent.get().getParent() instanceof StmtCall) { - StmtCall call = (StmtCall) parent.get().getParent(); - SignatureHelp info = forCall(call); - info.setActiveParameter(args.indexOf(e)); - return info; - } + if (parent.get().getParent() instanceof StmtCall) { + StmtCall call = (StmtCall) parent.get().getParent(); + SignatureHelp info = forCall(call); + info.setActiveParameter(args.indexOf(e.get())); + return info; + } break; } else if (parent.isPresent() && parent.get() instanceof StmtCall) { StmtCall call = (StmtCall) parent.get(); 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 new file mode 100644 index 000000000..d8d9e819a --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java @@ -0,0 +1,336 @@ +package tests.wurstscript.tests; + +import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.ModelManagerImpl; +import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstio.languageserver.requests.CodeActionRequest; +import de.peeeq.wurstio.languageserver.requests.GetDefinition; +import de.peeeq.wurstio.languageserver.requests.InlayHintsRequest; +import de.peeeq.wurstio.languageserver.requests.PrepareRenameRequest; +import de.peeeq.wurstio.languageserver.requests.RenameRequest; +import de.peeeq.wurstio.languageserver.requests.SemanticTokensRequest; +import de.peeeq.wurstio.languageserver.requests.SignatureInfo; +import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.CodeActionContext; +import org.eclipse.lsp4j.CodeActionKind; +import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.InlayHintParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.PrepareRenameResult; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.RenameParams; +import org.eclipse.lsp4j.SemanticTokens; +import org.eclipse.lsp4j.SemanticTokensParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentPositionParams; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.jsonrpc.messages.Either3; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +public class LspNativeFeaturesTests extends WurstLanguageServerTest { + + @Test + public void renameIncludesDeclarationAndReference() throws IOException { + CompletionTestData data = input( + "package test", + "function fo|o()", + "init", + " foo()", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + RenameParams params = new RenameParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setPosition(new Position(data.line, data.column)); + params.setNewName("bar"); + + WorkspaceEdit edit = new RenameRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List edits = allTextEdits(edit); + assertTrue(edits.size() >= 2, "Expected declaration and usage to be renamed."); + Set startLines = edits.stream().map(t -> t.getRange().getStart().getLine()).collect(Collectors.toSet()); + assertTrue(startLines.contains(1), "Expected declaration line to be included."); + assertTrue(startLines.contains(3), "Expected call site line to be included."); + } + + @Test + public void signatureHelpUsesUnsavedBuffer() throws IOException { + CompletionTestData data = input( + "package test", + "function foo(int a, int b)", + "init", + " fo|o(1, 2)", + "endpackage" + ); + String oldDiskContent = String.join("\n", + "package test", + "function foo(int a)", + "init", + " foo(1)", + "endpackage", + ""); + TestContext ctx = createContext(data, oldDiskContent); + + TextDocumentPositionParams params = new TextDocumentPositionParams( + new TextDocumentIdentifier(ctx.uri), + new Position(data.line, data.column) + ); + SignatureHelp help = new SignatureInfo(params, ctx.bufferManager).execute(ctx.modelManager); + assertEquals(help.getSignatures().size(), 1); + assertEquals(help.getSignatures().get(0).getParameters().size(), 2); + } + + @Test + public void codeActionsReturnQuickFixWithWorkspaceEdit() throws IOException { + CompletionTestData data = input( + "package test", + "init", + " mis|sing()", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("unresolved"); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List> actions = + new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List codeActions = actions.stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + assertFalse(codeActions.isEmpty(), "Expected at least one code action."); + assertTrue(codeActions.stream().anyMatch(a -> CodeActionKind.QuickFix.equals(a.getKind()))); + assertTrue(codeActions.stream().anyMatch(a -> a.getEdit() != null)); + } + + @Test + public void semanticTokensAreProduced() throws IOException { + CompletionTestData data = input( + "package test", + "class C", + "function foo(int x)", + " string s = \"a\"", + " int i = 5", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + SemanticTokensParams params = new SemanticTokensParams(new TextDocumentIdentifier(ctx.uri)); + SemanticTokens tokens = new SemanticTokensRequest(params, ctx.bufferManager).execute(ctx.modelManager); + assertNotNull(tokens); + assertFalse(tokens.getData().isEmpty(), "Expected semantic token data."); + assertEquals(tokens.getData().size() % 5, 0); + + List tokenTypes = new ArrayList<>(); + List dataValues = tokens.getData(); + for (int i = 3; i < dataValues.size(); i += 5) { + tokenTypes.add(dataValues.get(i)); + } + assertTrue(tokenTypes.contains(SemanticTokensRequest.TOKEN_TYPES.indexOf(org.eclipse.lsp4j.SemanticTokenTypes.Function))); + } + + @Test + public void inlayHintsShowParameterNames() throws IOException { + CompletionTestData data = input( + "package test", + "function foo(int amount, string name)", + "init", + " foo(1, \"x\")", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + InlayHintParams params = new InlayHintParams( + new TextDocumentIdentifier(ctx.uri), + new Range(new Position(0, 0), new Position(100, 0)) + ); + List hints = new InlayHintsRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List labels = hints.stream() + .map(h -> h.getLabel().isLeft() ? h.getLabel().getLeft() : "") + .collect(Collectors.toList()); + assertTrue(labels.contains("amount:")); + assertTrue(labels.contains("name:")); + } + + @Test + public void inlayHintForMemberAccessStartsAtReceiver() throws IOException { + CompletionTestData data = input( + "package test", + "class Icons", + " static string iconPath = \"x\"", + "function addPerk(string id)", + "init", + " addPerk(Icons.iconPath)", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + InlayHintParams params = new InlayHintParams( + new TextDocumentIdentifier(ctx.uri), + new Range(new Position(0, 0), new Position(100, 0)) + ); + List hints = new InlayHintsRequest(params, ctx.bufferManager).execute(ctx.modelManager); + InlayHint idHint = hints.stream() + .filter(h -> h.getLabel().isLeft() && "id:".equals(h.getLabel().getLeft())) + .findFirst() + .orElseThrow(() -> new AssertionError("id: inlay hint not found")); + + String line = data.buffer.split("\\R")[5]; + int expectedStart = line.indexOf("Icons.iconPath"); + assertEquals(idHint.getPosition().getLine(), 5); + assertEquals(idHint.getPosition().getCharacter(), expectedStart); + } + + @Test + public void inlayHintsStayStableWhileTemporarilyUnparsable() throws IOException { + CompletionTestData valid = input( + "package test", + "function foo(int amount)", + "init", + " foo(1)", + "endpackage" + ); + TestContext ctx = createContext(valid, valid.buffer); + + InlayHintParams params = new InlayHintParams( + new TextDocumentIdentifier(ctx.uri), + new Range(new Position(0, 0), new Position(100, 0)) + ); + + List stableHints = new InlayHintsRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List stableLabels = stableHints.stream() + .map(h -> h.getLabel().isLeft() ? h.getLabel().getLeft() : "") + .collect(Collectors.toList()); + assertTrue(stableLabels.contains("amount:")); + + String invalidBuffer = String.join("\n", + "package test", + "function foo(int amount)", + "init", + " if (", + "endpackage", + ""); + ctx.bufferManager.updateFile(WFile.create(ctx.uri), invalidBuffer); + + List duringParseError = new InlayHintsRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List errorLabels = duringParseError.stream() + .map(h -> h.getLabel().isLeft() ? h.getLabel().getLeft() : "") + .collect(Collectors.toList()); + assertTrue(errorLabels.contains("amount:"), "Expected cached hints to be reused during parse errors."); + } + + @Test + public void prepareRenameReturnsSymbolRange() throws IOException { + CompletionTestData data = input( + "package test", + "function foo()", + "init", + " fo|o()", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + PrepareRenameParams params = new PrepareRenameParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setPosition(new Position(data.line, data.column)); + Either3 result = + new PrepareRenameRequest(params, ctx.bufferManager).execute(ctx.modelManager); + assertTrue(result.isFirst()); + assertEquals(result.getFirst().getStart().getLine(), 3); + } + + @Test + public void typeDefinitionRequestReturnsTypeTarget() throws IOException { + CompletionTestData data = input( + "package test", + "class C", + "init", + " C c = null", + " c|", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + TextDocumentPositionParams pos = new TextDocumentPositionParams( + new TextDocumentIdentifier(ctx.uri), + new Position(data.line, data.column) + ); + Either, List> result = + new GetDefinition(pos, ctx.bufferManager, GetDefinition.LookupType.TYPE_DEFINITION).execute(ctx.modelManager); + assertTrue(result.isLeft()); + assertFalse(result.getLeft().isEmpty()); + assertEquals(result.getLeft().get(0).getRange().getStart().getLine(), 1); + } + + private List allTextEdits(WorkspaceEdit edit) { + if (edit.getChanges() != null && !edit.getChanges().isEmpty()) { + return edit.getChanges().values().stream().flatMap(List::stream).collect(Collectors.toList()); + } + if (edit.getDocumentChanges() == null) { + return Collections.emptyList(); + } + return edit.getDocumentChanges().stream() + .filter(Either::isLeft) + .flatMap(dc -> dc.getLeft().getEdits().stream()) + .collect(Collectors.toList()); + } + + private TestContext createContext(CompletionTestData data, String diskContent) throws IOException { + File projectFolder = new File("./temp/lspNative/" + System.nanoTime()); + File wurstFolder = new File(projectFolder, "wurst"); + Files.createDirectories(wurstFolder.toPath()); + + File testFile = new File(wurstFolder, "test.wurst"); + File wurstFile = new File(wurstFolder, "Wurst.wurst"); + Files.writeString(testFile.toPath(), diskContent); + Files.writeString(wurstFile.toPath(), "package Wurst\n"); + + BufferManager bufferManager = new BufferManager(); + ModelManagerImpl modelManager = new ModelManagerImpl(projectFolder.getAbsoluteFile(), bufferManager); + modelManager.buildProject(); + + String uri = testFile.toURI().toString(); + bufferManager.updateFile(WFile.create(uri), data.buffer); + return new TestContext(bufferManager, modelManager, uri); + } + + private static class TestContext { + private final BufferManager bufferManager; + private final ModelManagerImpl modelManager; + private final String uri; + + private TestContext(BufferManager bufferManager, ModelManagerImpl modelManager, String uri) { + this.bufferManager = bufferManager; + this.modelManager = modelManager; + this.uri = uri; + } + } +} From 933ea8d5ae0e544b870a458911b09641e3c0a5b9 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 12:55:19 +0100 Subject: [PATCH 2/8] bit less inlay hints --- .../requests/InlayHintsRequest.java | 132 +++++++++++++++++- .../tests/LspNativeFeaturesTests.java | 50 ++++++- 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java index 20d52355c..0929ea536 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/InlayHintsRequest.java @@ -10,9 +10,17 @@ import de.peeeq.wurstscript.ast.ConstructorDef; import de.peeeq.wurstscript.ast.Element; import de.peeeq.wurstscript.ast.Expr; +import de.peeeq.wurstscript.ast.ExprBoolVal; import de.peeeq.wurstscript.ast.ExprFunctionCall; +import de.peeeq.wurstscript.ast.ExprIntVal; +import de.peeeq.wurstscript.ast.ExprMember; import de.peeeq.wurstscript.ast.ExprMemberMethod; import de.peeeq.wurstscript.ast.ExprNewObject; +import de.peeeq.wurstscript.ast.ExprNull; +import de.peeeq.wurstscript.ast.ExprRealVal; +import de.peeeq.wurstscript.ast.ExprStringVal; +import de.peeeq.wurstscript.ast.FuncRef; +import de.peeeq.wurstscript.ast.NameRef; import de.peeeq.wurstscript.attributes.names.FuncLink; import org.eclipse.lsp4j.InlayHint; import org.eclipse.lsp4j.InlayHintKind; @@ -22,15 +30,22 @@ import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; public class InlayHintsRequest extends UserRequest> { private static final Map> lastStableHintsByFile = new ConcurrentHashMap<>(); + private static final int HINT_SCORE_THRESHOLD = 2; + private static final Set OBVIOUS_STRING_PARAMS = new HashSet<>(List.of( + "message", "text", "name", "title", "label", "path", "url", "key" + )); private final WFile filename; private final String buffer; private final Range requestedRange; @@ -72,7 +87,10 @@ private void collectHints(List hints, Element e) { ExprFunctionCall call = (ExprFunctionCall) e; FuncLink f = call.attrFuncLink(); if (f != null) { - addParameterHints(hints, call.getArgs(), f.getParameterNames()); + List paramTypes = f.getParameterTypes().stream() + .map(t -> t.toPrettyString()) + .collect(Collectors.toList()); + addParameterHints(hints, call.getArgs(), f.getParameterNames(), paramTypes); } return; } @@ -80,7 +98,10 @@ private void collectHints(List hints, Element e) { ExprMemberMethod call = (ExprMemberMethod) e; FuncLink f = call.attrFuncLink(); if (f != null) { - addParameterHints(hints, call.getArgs(), f.getParameterNames()); + List paramTypes = f.getParameterTypes().stream() + .map(t -> t.toPrettyString()) + .collect(Collectors.toList()); + addParameterHints(hints, call.getArgs(), f.getParameterNames(), paramTypes); } return; } @@ -92,18 +113,26 @@ private void collectHints(List hints, Element e) { } List paramNames = new ArrayList<>(); constructorDef.getParameters().forEach(p -> paramNames.add(p.getName())); - addParameterHints(hints, exprNew.getArgs(), paramNames); + List paramTypes = constructorDef.getParameters().stream() + .map(p -> p.attrTyp().toPrettyString()) + .collect(Collectors.toList()); + addParameterHints(hints, exprNew.getArgs(), paramNames, paramTypes); } } - private void addParameterHints(List hints, Arguments args, List paramNames) { + private void addParameterHints(List hints, Arguments args, List paramNames, List paramTypes) { int count = Math.min(args.size(), paramNames.size()); + Map typeFrequencies = typeFrequencies(paramTypes); for (int i = 0; i < count; i++) { Expr arg = args.get(i); String paramName = paramNames.get(i); if (paramName == null || paramName.isEmpty()) { continue; } + String paramType = i < paramTypes.size() ? paramTypes.get(i) : ""; + if (hintScore(arg, paramName, paramType, typeFrequencies) < HINT_SCORE_THRESHOLD) { + continue; + } Position pos = argumentStart(arg); InlayHint hint = new InlayHint(); hint.setPosition(pos); @@ -114,6 +143,101 @@ private void addParameterHints(List hints, Arguments args, List typeFrequencies) { + int score = 0; + String argName = argumentName(arg); + String normalizedParam = normalize(paramName); + String normalizedArg = normalize(argName); + + if (arg instanceof ExprIntVal || arg instanceof ExprRealVal || arg instanceof ExprNull) { + score += 3; + } else if (arg instanceof ExprBoolVal) { + score += 2; + if (looksLikeBooleanFlagName(paramName)) { + score -= 2; + } + } else if (arg instanceof ExprMember) { + // Property/static member accesses tend to be less obvious than plain local variable names. + score += 2; + } else if (arg instanceof ExprStringVal) { + if (OBVIOUS_STRING_PARAMS.contains(normalizedParam)) { + score -= 2; + } + } + + if (argName != null && !argName.isEmpty() && argName.length() <= 2) { + score += 2; + } + + if (!paramType.isEmpty() && typeFrequencies.getOrDefault(paramType, 0) > 1) { + score += 2; + } + + if (isSelfDescribing(normalizedArg, normalizedParam)) { + score -= 3; + } + + return score; + } + + private Map typeFrequencies(List paramTypes) { + Map result = new HashMap<>(); + for (String t : paramTypes) { + if (t == null || t.isEmpty()) { + continue; + } + result.put(t, result.getOrDefault(t, 0) + 1); + } + return result; + } + + private String argumentName(Expr arg) { + if (arg instanceof NameRef) { + return ((NameRef) arg).getVarName(); + } + if (arg instanceof FuncRef) { + return ((FuncRef) arg).getFuncName(); + } + return ""; + } + + private boolean looksLikeBooleanFlagName(String paramName) { + String n = normalize(paramName); + return n.startsWith("is") + || n.startsWith("has") + || n.startsWith("can") + || n.startsWith("enable") + || n.startsWith("enabled") + || n.startsWith("allow"); + } + + private boolean isSelfDescribing(String normalizedArg, String normalizedParam) { + if (normalizedArg.isEmpty() || normalizedParam.isEmpty()) { + return false; + } + if (normalizedArg.equals(normalizedParam)) { + return true; + } + if (normalizedArg.length() >= 3 && normalizedParam.contains(normalizedArg)) { + return true; + } + return normalizedParam.length() >= 3 && normalizedArg.contains(normalizedParam); + } + + private String normalize(String s) { + if (s == null) { + return ""; + } + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = Character.toLowerCase(s.charAt(i)); + if (Character.isLetterOrDigit(c)) { + out.append(c); + } + } + return out.toString(); + } + private Position argumentStart(Expr arg) { Range sourceRange = Convert.range(arg); Position sourceStart = sourceRange.getStart(); 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 d8d9e819a..8df22b510 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 @@ -177,7 +177,55 @@ public void inlayHintsShowParameterNames() throws IOException { .map(h -> h.getLabel().isLeft() ? h.getLabel().getLeft() : "") .collect(Collectors.toList()); assertTrue(labels.contains("amount:")); - assertTrue(labels.contains("name:")); + assertFalse(labels.contains("name:"), "Obvious string parameter hints should be suppressed."); + } + + @Test + public void inlayHintsHideSelfDescribingArgumentNames() throws IOException { + CompletionTestData data = input( + "package test", + "function send(string message)", + "init", + " string message = \"x\"", + " send(message)", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + InlayHintParams params = new InlayHintParams( + new TextDocumentIdentifier(ctx.uri), + new Range(new Position(0, 0), new Position(100, 0)) + ); + List hints = new InlayHintsRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List labels = hints.stream() + .map(h -> h.getLabel().isLeft() ? h.getLabel().getLeft() : "") + .collect(Collectors.toList()); + assertFalse(labels.contains("message:")); + } + + @Test + public void inlayHintsShowWhenParameterTypesAreRepeated() throws IOException { + CompletionTestData data = input( + "package test", + "function setRange(int min, int max)", + "init", + " int first = 1", + " int second = 2", + " setRange(first, second)", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + InlayHintParams params = new InlayHintParams( + new TextDocumentIdentifier(ctx.uri), + new Range(new Position(0, 0), new Position(100, 0)) + ); + List hints = new InlayHintsRequest(params, ctx.bufferManager).execute(ctx.modelManager); + List labels = hints.stream() + .map(h -> h.getLabel().isLeft() ? h.getLabel().getLeft() : "") + .collect(Collectors.toList()); + assertTrue(labels.contains("min:")); + assertTrue(labels.contains("max:")); } @Test From beff204ed591b0db4acf362cc86621c8552b1b46 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 14:42:47 +0100 Subject: [PATCH 3/8] consume jassdoc --- de.peeeq.wurstscript/build.gradle | 1 + .../languageserver/JassDocService.java | 677 ++++++++++++++++++ .../languageserver/ModelManagerImpl.java | 18 +- .../requests/GetCompletions.java | 7 +- .../languageserver/requests/HoverInfo.java | 89 ++- .../requests/SignatureInfo.java | 13 +- .../tests/wurstscript/tests/HoverTests.java | 62 ++ .../tests/LspNativeFeaturesTests.java | 177 +++++ 8 files changed, 1037 insertions(+), 7 deletions(-) create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/JassDocService.java diff --git a/de.peeeq.wurstscript/build.gradle b/de.peeeq.wurstscript/build.gradle index 627b3a1d1..e5e14175c 100644 --- a/de.peeeq.wurstscript/build.gradle +++ b/de.peeeq.wurstscript/build.gradle @@ -105,6 +105,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.10.1' implementation 'commons-lang:commons-lang:2.6' implementation 'com.github.albfernandez:juniversalchardet:2.4.0' + implementation 'org.xerial:sqlite-jdbc:3.46.1.3' implementation 'com.github.inwc3:jmpq3:29b55f2c32' implementation 'com.github.inwc3:wc3libs:cc49c8e63c' implementation('com.github.wurstscript:wurstsetup:393cf5ea39') { 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 new file mode 100644 index 000000000..142213a31 --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/JassDocService.java @@ -0,0 +1,677 @@ +package de.peeeq.wurstio.languageserver; + +import de.peeeq.wurstscript.WLogger; +import de.peeeq.wurstscript.ast.FunctionDefinition; +import de.peeeq.wurstscript.ast.NameDef; +import de.peeeq.wurstscript.utils.Utils; +import org.eclipse.jdt.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Stream; + +public final class JassDocService { + + public enum SymbolKind { + FUNCTION, VARIABLE + } + + public static final class LookupKey { + private final String symbolName; + private final SymbolKind symbolKind; + private final String sourceFile; + + public LookupKey(String symbolName, SymbolKind symbolKind, String sourceFile) { + this.symbolName = symbolName; + this.symbolKind = symbolKind; + this.sourceFile = sourceFile; + } + + public String symbolName() { + return symbolName; + } + + public SymbolKind symbolKind() { + return symbolKind; + } + + public String sourceFile() { + return sourceFile; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof LookupKey)) { + return false; + } + LookupKey other = (LookupKey) obj; + return symbolName.equals(other.symbolName) + && symbolKind == other.symbolKind + && sourceFile.equals(other.sourceFile); + } + + @Override + public int hashCode() { + int result = symbolName.hashCode(); + result = 31 * result + symbolKind.hashCode(); + result = 31 * result + sourceFile.hashCode(); + return result; + } + } + + private static final JassDocService INSTANCE = new JassDocService(); + private static final Duration DEFAULT_LATEST_MAX_AGE = Duration.ofHours(24); + private static final List DEFAULT_DB_URLS = Arrays.asList(); + + private final Map> lookupCache = new ConcurrentHashMap<>(); + private volatile @Nullable CachedDb cachedDb; + private volatile boolean triedInit; + private volatile boolean initFailed; + private volatile boolean missingSourceWarningShown; + private static volatile @Nullable Function testLookup; + + private static final class CachedDb { + private final Path dbPath; + private final List schemas; + + private CachedDb(Path dbPath, List schemas) { + this.dbPath = dbPath; + this.schemas = schemas; + } + } + + private static final class TableSchema { + private final String table; + private final String nameColumn; + private final String docColumn; + private final @Nullable String kindColumn; + private final @Nullable String patchColumn; + + private TableSchema(String table, String nameColumn, String docColumn, @Nullable String kindColumn, @Nullable String patchColumn) { + this.table = table; + this.nameColumn = nameColumn; + this.docColumn = docColumn; + this.kindColumn = kindColumn; + this.patchColumn = patchColumn; + } + } + + public static JassDocService getInstance() { + return INSTANCE; + } + + public static void setTestLookup(@Nullable Function testLookupFn) { + testLookup = testLookupFn; + } + + public @Nullable String documentationForFunction(FunctionDefinition f) { + return documentationFor(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 documentationFor(String symbolName, SymbolKind symbolKind, String sourceFile) { + if (!isJassBuiltinSource(sourceFile)) { + return null; + } + LookupKey key = new LookupKey(symbolName, symbolKind, sourceFile); + Optional cached = lookupCache.computeIfAbsent(key, this::lookupDocumentation); + return cached.orElse(null); + } + + public void clearCacheForTests() { + lookupCache.clear(); + cachedDb = null; + triedInit = false; + initFailed = false; + missingSourceWarningShown = false; + } + + private Optional lookupDocumentation(LookupKey key) { + Function override = testLookup; + if (override != null) { + return Optional.ofNullable(trimToNull(override.apply(key))); + } + + CachedDb db = getOrInitDb(); + if (db == null) { + return Optional.empty(); + } + return Optional.ofNullable(lookupFromDb(db, key)); + } + + private @Nullable CachedDb getOrInitDb() { + if (initFailed) { + return null; + } + CachedDb existing = cachedDb; + if (existing != null) { + return existing; + } + synchronized (this) { + if (cachedDb != null) { + return cachedDb; + } + if (initFailed) { + return null; + } + triedInit = true; + try { + Optional dbPath = ensureDbAvailable(); + if (!dbPath.isPresent()) { + initFailed = true; + return null; + } + try (Connection conn = open(dbPath.get())) { + List schemas = discoverSchemas(conn); + boolean hasLegacySchema = hasLegacyJassdocSchema(conn); + if (schemas.isEmpty() && !hasLegacySchema) { + WLogger.warning("JassDoc DB found, but no compatible documentation tables were detected."); + initFailed = true; + return null; + } + cachedDb = new CachedDb(dbPath.get(), schemas); + return cachedDb; + } + } catch (Exception e) { + initFailed = true; + WLogger.warning("Could not initialize JassDoc DB: " + e.getMessage()); + return null; + } + } + } + + private @Nullable String lookupFromDb(CachedDb db, LookupKey key) { + try (Connection conn = open(db.dbPath)) { + String legacyDoc = lookupFromLegacyJassdocTables(conn, key); + if (legacyDoc != null) { + return legacyDoc; + } + String bestDoc = null; + int bestScore = Integer.MIN_VALUE; + for (TableSchema schema : db.schemas) { + String sql = "SELECT " + + q(schema.docColumn) + + (schema.kindColumn == null ? "" : ", " + q(schema.kindColumn)) + + (schema.patchColumn == null ? "" : ", " + q(schema.patchColumn)) + + " FROM " + q(schema.table) + + " WHERE lower(" + q(schema.nameColumn) + ") = lower(?) LIMIT 10"; + try (PreparedStatement st = conn.prepareStatement(sql)) { + st.setString(1, key.symbolName()); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String doc = trimToNull(rs.getString(1)); + if (doc == null) { + continue; + } + String kind = schema.kindColumn == null ? null : rs.getString(2); + String patch = schema.patchColumn == null + ? null + : rs.getString(schema.kindColumn == null ? 2 : 3); + int score = score(kind, key.symbolKind()); + if (score > bestScore) { + bestScore = score; + bestDoc = formatDoc(doc, patch); + } + } + } + } + } + return bestDoc; + } catch (SQLException e) { + WLogger.warning("JassDoc lookup failed for '" + key.symbolName() + "': " + e.getMessage()); + return null; + } + } + + private @Nullable String lookupFromLegacyJassdocTables(Connection conn, LookupKey key) throws SQLException { + if (!tableExists(conn, "parameters")) { + return null; + } + Map params = readKeyValueRows(conn, "parameters", "fnname", "param", "value", key.symbolName()); + Map anns = tableExists(conn, "annotations") + ? readKeyValueRows(conn, "annotations", "fnname", "anname", "value", key.symbolName()) + : java.util.Collections.emptyMap(); + String sourceFile = firstValue(params, anns, "source-file"); + if (!matchesRequestedSource(sourceFile, key.sourceFile())) { + return null; + } + + Map paramDescriptions = new LinkedHashMap<>(params); + if (tableExists(conn, "params_extra")) { + mergeParamDetails(conn, key.symbolName(), paramDescriptions); + } + + String doc = firstValue(params, anns, + "description", "desc", "documentation", "doc", "comment", "details", "notes", "help", "summary"); + String note = firstValue(params, anns, "note"); + String patch = firstValue(params, anns, + "patch", "since", "introduced", "introduced_patch", "version"); + + String paramDoc = renderParameterDocs(paramDescriptions); + if (doc == null && note == null && paramDoc == null) { + return null; + } + + StringBuilder sb = new StringBuilder(); + appendBlock(sb, doc); + appendBlock(sb, note); + appendBlock(sb, paramDoc); + String result = trimToNull(sb.toString()); + if (result == null) { + return null; + } + return formatDoc(result, patch); + } + + private void mergeParamDetails(Connection conn, String fnName, Map paramDescriptions) throws SQLException { + String sql = "SELECT param, anname, value FROM params_extra WHERE lower(fnname) = lower(?)"; + Map details = new LinkedHashMap<>(); + try (PreparedStatement st = conn.prepareStatement(sql)) { + st.setString(1, fnName); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String param = trimToNull(rs.getString(1)); + String key = trimToNull(rs.getString(2)); + String value = trimToNull(rs.getString(3)); + if (param == null || key == null || value == null) { + continue; + } + ParamDetails pd = details.computeIfAbsent(param, k -> new ParamDetails(param)); + String k = key.toLowerCase(Locale.ROOT); + if (k.equals("param_order")) { + try { + pd.order = Integer.parseInt(value); + } catch (NumberFormatException ignored) { + // ignore malformed order + } + } else if (k.equals("param_type") || k.equals("type")) { + pd.type = value; + } + } + } + } + + List ordered = new ArrayList<>(details.values()); + ordered.sort(Comparator.comparingInt(p -> p.order == null ? Integer.MAX_VALUE : p.order)); + LinkedHashMap reordered = new LinkedHashMap<>(); + for (ParamDetails p : ordered) { + String desc = paramDescriptions.get(p.name); + if (desc != null) { + String combined = p.type == null ? desc : "(" + p.type + ") " + desc; + reordered.put(p.name, combined); + } + } + for (Map.Entry e : paramDescriptions.entrySet()) { + reordered.putIfAbsent(e.getKey(), e.getValue()); + } + paramDescriptions.clear(); + paramDescriptions.putAll(reordered); + } + + private @Nullable String renderParameterDocs(Map paramDescriptions) { + if (paramDescriptions.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder("Parameters:"); + for (Map.Entry e : paramDescriptions.entrySet()) { + String name = trimToNull(e.getKey()); + String value = trimToNull(e.getValue()); + if (name == null || value == null) { + continue; + } + sb.append("\n- ").append(name).append(": ").append(value); + } + return trimToNull(sb.toString()); + } + + private boolean matchesRequestedSource(@Nullable String dbSourceFile, String requestedSourceFile) { + String db = basename(dbSourceFile); + if (db == null) { + return true; + } + String req = basename(requestedSourceFile); + if (req == null) { + return true; + } + return db.equalsIgnoreCase(req); + } + + private @Nullable String basename(@Nullable String path) { + String p = trimToNull(path); + if (p == null) { + return null; + } + p = p.replace('\\', '/'); + int i = p.lastIndexOf('/'); + return i >= 0 ? p.substring(i + 1) : p; + } + + private void appendBlock(StringBuilder sb, @Nullable String text) { + String t = trimToNull(text); + if (t == null) { + return; + } + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append(t); + } + + private static final class ParamDetails { + private final String name; + private @Nullable Integer order; + private @Nullable String type; + + private ParamDetails(String name) { + this.name = name; + } + } + + private Optional ensureDbAvailable() throws IOException { + Optional explicitPath = Utils.getEnvOrConfig("WURST_JASSDOC_DB_PATH"); + if (explicitPath.isPresent()) { + Path p = Path.of(explicitPath.get()); + if (Files.exists(p)) { + return Optional.of(p); + } + WLogger.warning("WURST_JASSDOC_DB_PATH points to a missing file: " + p); + } + + Path userHome = Path.of(System.getProperty("user.home")); + Path dbDir = userHome.resolve(".wurst").resolve("jassdoc"); + Files.createDirectories(dbDir); + + String revision = sanitizeRevision(Utils.getEnvOrConfig("WURST_JASSDOC_DB_REV").orElse("latest")); + Path dbPath = dbDir.resolve("jassdoc-" + revision + ".sqlite"); + if (!Files.exists(dbPath)) { + Optional fallback = findFirstExisting(dbDir, + "jassdoc-" + revision + ".db", + "jassdoc-" + revision + ".sqlite", + "jass.db", + "jassdoc.db", + "jassdoc.sqlite"); + if (fallback.isPresent()) { + return fallback; + } + } + + boolean needsDownload = !Files.exists(dbPath); + if (!needsDownload && "latest".equals(revision)) { + needsDownload = isStaleLatest(dbPath); + } + + if (!needsDownload) { + return Optional.of(dbPath); + } + + List urls = Utils.getEnvOrConfig("WURST_JASSDOC_DB_URL") + .map(List::of) + .orElse(DEFAULT_DB_URLS); + + if (urls.isEmpty()) { + showMissingSourceWarning(); + return Optional.empty(); + } + + IOException lastError = null; + for (String url : urls) { + try { + download(url, dbPath); + WLogger.info("Downloaded JassDoc DB from " + url + " to " + dbPath); + return Optional.of(dbPath); + } catch (IOException e) { + lastError = e; + } + } + + if (Files.exists(dbPath)) { + return Optional.of(dbPath); + } + if (lastError != null) { + throw lastError; + } + return Optional.empty(); + } + + private void showMissingSourceWarning() { + if (missingSourceWarningShown) { + return; + } + missingSourceWarningShown = true; + WLogger.warning( + "No JassDoc DB source configured. " + + "Set WURST_JASSDOC_DB_PATH to a local jass.db/sqlite file, " + + "or WURST_JASSDOC_DB_URL to a downloadable DB artifact." + ); + } + + private Optional findFirstExisting(Path dir, String... names) { + return Stream.of(names) + .map(dir::resolve) + .filter(Files::exists) + .findFirst(); + } + + private boolean isStaleLatest(Path dbPath) throws IOException { + FileTime modified = Files.getLastModifiedTime(dbPath); + Duration maxAge = Utils.getEnvOrConfig("WURST_JASSDOC_DB_MAX_AGE") + .map(this::parseDurationOrDefault) + .orElse(DEFAULT_LATEST_MAX_AGE); + Instant cutoff = Instant.now().minus(maxAge); + return modified.toInstant().isBefore(cutoff); + } + + private Duration parseDurationOrDefault(String text) { + try { + return Duration.parse(text); + } catch (Exception e) { + return DEFAULT_LATEST_MAX_AGE; + } + } + + private void download(String urlString, Path target) throws IOException { + URL url = new URL(urlString); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setConnectTimeout(10_000); + con.setReadTimeout(20_000); + con.setInstanceFollowRedirects(true); + con.setRequestMethod("GET"); + int code = con.getResponseCode(); + if (code < 200 || code >= 300) { + throw new IOException("HTTP " + code + " for " + urlString); + } + Path tmp = Files.createTempFile(target.getParent(), "jassdoc-", ".tmp"); + try (InputStream in = con.getInputStream()) { + Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); + } finally { + con.disconnect(); + } + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + + private Connection open(Path dbPath) throws SQLException { + try { + Class.forName("org.sqlite.JDBC"); + } catch (ClassNotFoundException ignored) { + // Driver may still be auto-loaded by JDBC. + } + return DriverManager.getConnection("jdbc:sqlite:" + dbPath.toAbsolutePath()); + } + + private List discoverSchemas(Connection conn) throws SQLException { + List result = new ArrayList<>(); + DatabaseMetaData md = conn.getMetaData(); + try (ResultSet tables = md.getTables(null, null, "%", new String[]{"TABLE"})) { + while (tables.next()) { + String table = tables.getString("TABLE_NAME"); + if (table == null) { + continue; + } + Map cols = new ConcurrentHashMap<>(); + try (ResultSet columns = md.getColumns(null, null, table, "%")) { + while (columns.next()) { + String c = columns.getString("COLUMN_NAME"); + if (c != null) { + cols.put(c.toLowerCase(Locale.ROOT), c); + } + } + } + String nameCol = first(cols, "name", "native", "identifier", "symbol"); + String docCol = first(cols, "documentation", "description", "doc", "comment", "notes", "help"); + if (nameCol == null || docCol == null) { + continue; + } + String kindCol = first(cols, "kind", "type", "symbol_type", "category"); + String patchCol = first(cols, "patch", "introduced", "since", "version", "introduced_patch"); + result.add(new TableSchema(table, nameCol, docCol, kindCol, patchCol)); + } + } + return result; + } + + private boolean hasLegacyJassdocSchema(Connection conn) throws SQLException { + return tableExists(conn, "parameters"); + } + + private boolean tableExists(Connection conn, String tableName) throws SQLException { + DatabaseMetaData md = conn.getMetaData(); + try (ResultSet tables = md.getTables(null, null, tableName, new String[]{"TABLE"})) { + while (tables.next()) { + String name = tables.getString("TABLE_NAME"); + if (name != null && name.equalsIgnoreCase(tableName)) { + return true; + } + } + } + return false; + } + + private Map readKeyValueRows(Connection conn, String table, String symbolCol, String keyCol, String valueCol, String symbolName) throws SQLException { + Map result = new ConcurrentHashMap<>(); + String sql = "SELECT " + q(keyCol) + ", " + q(valueCol) + + " FROM " + q(table) + + " WHERE lower(" + q(symbolCol) + ") = lower(?)"; + try (PreparedStatement st = conn.prepareStatement(sql)) { + st.setString(1, symbolName); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String key = rs.getString(1); + String value = trimToNull(rs.getString(2)); + if (key == null || value == null) { + continue; + } + result.putIfAbsent(key.toLowerCase(Locale.ROOT), value); + } + } + } + return result; + } + + private @Nullable String firstValue(Map primary, Map secondary, String... keys) { + for (String key : keys) { + String k = key.toLowerCase(Locale.ROOT); + String value = trimToNull(primary.get(k)); + if (value != null) { + return value; + } + value = trimToNull(secondary.get(k)); + if (value != null) { + return value; + } + } + return null; + } + + private @Nullable String first(Map cols, String... names) { + for (String name : names) { + String found = cols.get(name.toLowerCase(Locale.ROOT)); + if (found != null) { + return found; + } + } + return null; + } + + private int score(@Nullable String dbKind, SymbolKind requested) { + if (dbKind == null || dbKind.isBlank()) { + return 0; + } + String kind = dbKind.toLowerCase(Locale.ROOT); + if (requested == SymbolKind.FUNCTION && (kind.contains("func") || kind.contains("native"))) { + return 2; + } + if (requested == SymbolKind.VARIABLE && (kind.contains("var") || kind.contains("global") || kind.contains("const"))) { + return 2; + } + return -1; + } + + private String formatDoc(String doc, @Nullable String patch) { + String p = trimToNull(patch); + if (p == null) { + return doc; + } + return doc + "\n\n_Since " + p + "_"; + } + + private static boolean isJassBuiltinSource(String sourceFile) { + String s = sourceFile.replace('\\', '/').toLowerCase(Locale.ROOT); + return s.endsWith("/common.j") || s.endsWith("/blizzard.j") || s.equals("common.j") || s.equals("blizzard.j"); + } + + private static @Nullable String trimToNull(@Nullable String s) { + if (s == null) { + return null; + } + String trimmed = s.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String sanitizeRevision(String revision) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < revision.length(); i++) { + char c = revision.charAt(i); + if (Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '.') { + sb.append(c); + } + } + if (sb.length() == 0) { + return "latest"; + } + return sb.toString(); + } + + private static String q(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } +} diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java index 948b1dbea..62b7a0d31 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java @@ -524,7 +524,7 @@ public Changes syncCompilationUnit(WFile f) { } private CompilationUnit replaceCompilationUnit(WFile filename, String contents, boolean reportErrors) { - if (!isInWurstFolder(filename)) { + if (!isInWurstFolder(filename) && !isAlreadyLoaded(filename)) { return null; } if (fileHashcodes.containsKey(filename)) { @@ -812,7 +812,8 @@ private WFile wFile(CompilationUnit cu) { } /** - * checks if the given file is in the wurst folder or inside a dependency + * checks if the given file is in the wurst folder, inside a dependency, + * or a CU that has already been loaded into the model. */ private boolean isInWurstFolder(WFile file) { return Stream.concat(Stream.of(projectPath), dependencies.stream()).anyMatch(p -> @@ -820,6 +821,19 @@ private boolean isInWurstFolder(WFile file) { } + private boolean isAlreadyLoaded(WFile file) { + WurstModel model2 = model; + if (model2 == null) { + return false; + } + for (CompilationUnit cu : model2) { + if (wFile(cu).equals(file)) { + return true; + } + } + return false; + } + public File getProjectPath() { return projectPath; 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 3403956fa..f8dc638e0 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 @@ -5,6 +5,7 @@ import com.google.common.collect.Multimap; import com.google.common.collect.Sets; import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.JassDocService; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.WLogger; @@ -521,7 +522,11 @@ private CompletionItem makeNameDefCompletion(NameLink n) { CompletionItem completion = new CompletionItem(n.getName()); completion.setDetail(HoverInfo.descriptionString(n.getDef())); - completion.setDocumentation(n.getDef().attrComment()); + String documentation = n.getDef().attrComment(); + if (documentation == null || documentation.isEmpty()) { + documentation = JassDocService.getInstance().documentationForVariable(n.getDef()); + } + completion.setDocumentation(documentation); double rating = calculateRating(n.getName(), n.getTyp()); completion.setSortText(ratingToString(rating)); String newText = n.getName(); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/HoverInfo.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/HoverInfo.java index e952134f4..45ab7c0bc 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/HoverInfo.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/HoverInfo.java @@ -1,6 +1,7 @@ package de.peeeq.wurstio.languageserver.requests; import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.JassDocService; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.WLogger; @@ -16,11 +17,15 @@ import org.eclipse.lsp4j.MarkedString; import org.eclipse.lsp4j.TextDocumentPositionParams; import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.jdt.annotation.Nullable; +import java.io.File; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; /** * Created by peter on 24.04.16. @@ -139,9 +144,21 @@ static class Description implements Element.Matcher> description(FunctionDefinition f) { List> result = new ArrayList<>(); String comment = f.attrComment(); + boolean docsFromJassdoc = false; + if (comment == null || comment.isEmpty()) { + comment = JassDocService.getInstance().documentationForFunction(f); + docsFromJassdoc = comment != null && !comment.isEmpty(); + } + if ((comment == null || comment.isEmpty()) && f instanceof FunctionImplementation) { + comment = inferWrappedNativeDoc(f); + docsFromJassdoc = comment != null && !comment.isEmpty(); + } // TODO parse comment if (comment != null && !comment.isEmpty()) { + if (docsFromJassdoc) { + result.add(Either.forLeft("_JassDoc_")); + } result.add(Either.forLeft(comment)); } @@ -159,10 +176,57 @@ public List> description(FunctionDefinition f) { functionDescription += "returns " + returnTypeHtml; } result.add(Either.forRight(new MarkedString("wurst", functionDescription))); - result.add(Either.forLeft("defined in " + nearestScopeName(f))); + result.add(Either.forLeft(definedInText(f))); return result; } + private @Nullable String inferWrappedNativeDoc(FunctionDefinition f) { + return inferWrappedNativeDoc(f, new LinkedHashSet<>()); + } + + private @Nullable String inferWrappedNativeDoc(FunctionDefinition f, Set visited) { + if (!visited.add(f)) { + return null; + } + String directDoc = JassDocService.getInstance().documentationForFunction(f); + if (directDoc != null && !directDoc.isEmpty()) { + return directDoc; + } + if (!(f instanceof FunctionImplementation)) { + return null; + } + Set callees = new LinkedHashSet<>(); + ((FunctionImplementation) f).getBody().accept(new Element.DefaultVisitor() { + @Override + public void visit(ExprFunctionCall exprFunctionCall) { + addCallee(exprFunctionCall.attrFuncDef()); + super.visit(exprFunctionCall); + } + + @Override + public void visit(ExprMemberMethodDot exprMemberMethodDot) { + addCallee(exprMemberMethodDot.attrFuncDef()); + super.visit(exprMemberMethodDot); + } + + @Override + public void visit(ExprMemberMethodDotDot exprMemberMethodDotDot) { + addCallee(exprMemberMethodDotDot.attrFuncDef()); + super.visit(exprMemberMethodDotDot); + } + + private void addCallee(FunctionDefinition def) { + if (def != null && def != f) { + callees.add(def); + } + } + }); + if (callees.size() != 1) { + return null; + } + return inferWrappedNativeDoc(callees.iterator().next(), visited); + } + private static String nearestScopeName(Element n) { if (n.attrNearestNamedScope() != null) { return Utils.printElement(n.attrNearestNamedScope()); @@ -177,7 +241,15 @@ public List> description(NameDef n) { } List> result = new ArrayList<>(); String comment = n.attrComment(); + boolean docsFromJassdoc = false; + if (comment == null || comment.isEmpty()) { + comment = JassDocService.getInstance().documentationForVariable(n); + docsFromJassdoc = comment != null && !comment.isEmpty(); + } if (comment != null && !comment.isEmpty()) { + if (docsFromJassdoc) { + result.add(Either.forLeft("_JassDoc_")); + } result.add(Either.forLeft(comment)); } @@ -195,7 +267,7 @@ public List> description(NameDef n) { } else { result.add(Either.forRight(new MarkedString("wurst", type(n.attrTyp()) + " " + n.getName() + initializer))); } - result.add(Either.forLeft("defined in " + nearestScopeName(n))); + result.add(Either.forLeft(definedInText(n))); return result; } @@ -218,10 +290,21 @@ public List> description(ConstructorDef f) { } functionDescription += "(" + params + ") "; result.add(Either.forRight(new MarkedString("wurst", functionDescription))); - result.add(Either.forLeft("defined in " + nearestScopeName(f))); + result.add(Either.forLeft(definedInText(f))); return result; } + private static String definedInText(Element n) { + String file = n.attrSource().getFile(); + if (file == null || file.isEmpty()) { + return "defined in " + nearestScopeName(n); + } + File f = new File(file); + String label = f.getName(); + String uri = f.toURI().toString(); + return "defined in [" + label + "](" + uri + ")"; + } + public List> description(NameRef nr) { NameLink nameDef = nr.attrNameLink(); if (nameDef == null) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java index da03b23cc..a2798d8b0 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/SignatureInfo.java @@ -1,9 +1,11 @@ package de.peeeq.wurstio.languageserver.requests; import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.JassDocService; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.ast.*; +import de.peeeq.wurstscript.attributes.names.FuncLink; import de.peeeq.wurstscript.types.FunctionSignature; import de.peeeq.wurstscript.types.WurstType; import de.peeeq.wurstscript.utils.Utils; @@ -74,7 +76,16 @@ private SignatureHelp forCall(StmtCall call) { FunctionSignature sig = call.attrFunctionSignature(); SignatureHelp help = new SignatureHelp(); SignatureInformation info = new SignatureInformation(); - info.setDocumentation("(" + sig.getParameterDescription() + ")"); + String docs = null; + if (call instanceof FunctionCall) { + FunctionCall fc = (FunctionCall) call; + FuncLink funcLink = fc.attrFuncLink(); + if (funcLink != null) { + docs = JassDocService.getInstance().documentationForFunction(funcLink.getDef()); + } + } + String signatureDoc = "(" + sig.getParameterDescription() + ")"; + info.setDocumentation(docs == null || docs.isEmpty() ? signatureDoc : docs + "\n" + signatureDoc); if (call instanceof FunctionCall) { FunctionCall fc = (FunctionCall) call; info.setLabel(fc.getFuncName()); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/HoverTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/HoverTests.java index e5a1f5c3b..aacf3b85c 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/HoverTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/HoverTests.java @@ -9,6 +9,8 @@ import org.testng.annotations.Test; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -134,9 +136,69 @@ public void hoverOnImportShowsImportSignature() { assertTrue(text.stream().anyMatch(s -> s.contains("import source")), "hover text = " + text); } + @Test + public void hoverShowsClickableDefinedInFileLink() { + CompletionTestData testData = input( + "package test", + "function fo|o()", + "endpackage" + ); + + List text = testHoverText(testData); + assertTrue(text.stream().anyMatch(s -> s.contains("defined in [test.wurst](")), "hover text = " + text); + } + + @Test + public void hoverOnBuildCommonJIsRecognizedAsProjectModelFile() throws IOException { + File projectPath = new File("./test-output/commonj-hover").getAbsoluteFile(); + File wurstDir = new File(projectPath, "wurst"); + wurstDir.mkdirs(); + File testFile = new File(wurstDir, "test.wurst"); + Files.writeString(testFile.toPath(), "package test\ninit\nendpackage\n"); + + BufferManager bufferManager = new BufferManager(); + ModelManager modelManager = new ModelManagerImpl(projectPath, bufferManager); + + String testUri = testFile.toURI().toString(); + String testContent = Files.readString(testFile.toPath()); + bufferManager.updateFile(WFile.create(testUri), testContent); + modelManager.replaceCompilationUnitContent(WFile.create(testUri), testContent, false); + + File commonJFile = new File(projectPath, "_build/common.j"); + assertTrue(commonJFile.exists(), "expected _build/common.j to exist"); + String commonContent = Files.readString(commonJFile.toPath()); + int marker = commonContent.indexOf("SetCameraBounds"); + assertTrue(marker >= 0, "SetCameraBounds not found in common.j"); + + int line = 0; + int col = 0; + for (int i = 0; i < marker; i++) { + if (commonContent.charAt(i) == '\n') { + line++; + col = 0; + } else { + col++; + } + } + + String commonUri = commonJFile.toURI().toString(); + bufferManager.updateFile(WFile.create(commonUri), commonContent); + Hover hover = new HoverInfo( + new TextDocumentPositionParams(new TextDocumentIdentifier(commonUri), new Position(line, col)), + bufferManager + ).execute(modelManager); + + List text = hoverText(hover); + assertTrue(text.stream().noneMatch(s -> s.contains("is not part of the project")), "hover text = " + text); + } + private List testHoverText(CompletionTestData testData) { Hover result = getHoverInfo(testData); + return hoverText(result); + } + + private List hoverText(Hover result) { if (result.getContents().isLeft()) { return result.getContents().getLeft() .stream() 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 8df22b510..c1ae97b24 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 @@ -1,10 +1,12 @@ package tests.wurstscript.tests; import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.JassDocService; import de.peeeq.wurstio.languageserver.ModelManagerImpl; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstio.languageserver.requests.CodeActionRequest; import de.peeeq.wurstio.languageserver.requests.GetDefinition; +import de.peeeq.wurstio.languageserver.requests.HoverInfo; import de.peeeq.wurstio.languageserver.requests.InlayHintsRequest; import de.peeeq.wurstio.languageserver.requests.PrepareRenameRequest; import de.peeeq.wurstio.languageserver.requests.RenameRequest; @@ -14,7 +16,9 @@ import org.eclipse.lsp4j.CodeActionContext; import org.eclipse.lsp4j.CodeActionKind; import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.CompletionList; import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.InlayHint; import org.eclipse.lsp4j.InlayHintParams; import org.eclipse.lsp4j.Position; @@ -40,6 +44,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -338,6 +343,178 @@ public void typeDefinitionRequestReturnsTypeTarget() throws IOException { assertEquals(result.getLeft().get(0).getRange().getStart().getLine(), 1); } + @Test + public void hoverUsesJassDocFallbackForBuiltinFunction() throws IOException { + CompletionTestData data = input( + "package test", + "init", + " DisplayTextToPl|ayer(null, 0., 0., \"x\")", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + JassDocService.setTestLookup(k -> + Objects.equals(k.symbolName(), "DisplayTextToPlayer") + && k.symbolKind() == JassDocService.SymbolKind.FUNCTION + ? "Shows text to a specific player." + : null + ); + JassDocService.getInstance().clearCacheForTests(); + try { + Hover hover = new HoverInfo( + new TextDocumentPositionParams(new TextDocumentIdentifier(ctx.uri), new Position(data.line, data.column)), + ctx.bufferManager + ).execute(ctx.modelManager); + + List text = hover.getContents().getLeft().stream() + .map(e -> e.isLeft() ? e.getLeft() : e.getRight().getValue()) + .collect(Collectors.toList()); + assertTrue(text.stream().anyMatch(t -> t.contains("Shows text to a specific player.")), "hover text = " + text); + } finally { + JassDocService.setTestLookup(null); + JassDocService.getInstance().clearCacheForTests(); + } + } + + @Test + public void hoverUsesJassDocFromSingleNativeWrapperCall() throws IOException { + CompletionTestData data = input( + "package test", + "function mySin(real x) returns real", + " return Sin(x)", + "init", + " myS|in(1.0)", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + JassDocService.setTestLookup(k -> + Objects.equals(k.symbolName(), "Sin") + && k.symbolKind() == JassDocService.SymbolKind.FUNCTION + ? "Returns the sine of x." + : null + ); + JassDocService.getInstance().clearCacheForTests(); + try { + Hover hover = new HoverInfo( + new TextDocumentPositionParams(new TextDocumentIdentifier(ctx.uri), new Position(data.line, data.column)), + ctx.bufferManager + ).execute(ctx.modelManager); + + List text = hover.getContents().getLeft().stream() + .map(e -> e.isLeft() ? e.getLeft() : e.getRight().getValue()) + .collect(Collectors.toList()); + assertTrue(text.stream().anyMatch(t -> t.contains("_JassDoc_")), "hover text = " + text); + assertTrue(text.stream().anyMatch(t -> t.contains("Returns the sine of x.")), "hover text = " + text); + } finally { + JassDocService.setTestLookup(null); + JassDocService.getInstance().clearCacheForTests(); + } + } + + @Test + public void hoverUsesJassDocFromSingleExtensionWrapperCall() throws IOException { + CompletionTestData data = input( + "package test", + "function real.nativeSin() returns real", + " return Sin(this)", + "init", + " 1.0.nativeS|in()", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + JassDocService.setTestLookup(k -> + Objects.equals(k.symbolName(), "Sin") + && k.symbolKind() == JassDocService.SymbolKind.FUNCTION + ? "Returns the sine of x." + : null + ); + JassDocService.getInstance().clearCacheForTests(); + try { + Hover hover = new HoverInfo( + new TextDocumentPositionParams(new TextDocumentIdentifier(ctx.uri), new Position(data.line, data.column)), + ctx.bufferManager + ).execute(ctx.modelManager); + + List text = hover.getContents().getLeft().stream() + .map(e -> e.isLeft() ? e.getLeft() : e.getRight().getValue()) + .collect(Collectors.toList()); + assertTrue(text.stream().anyMatch(t -> t.contains("_JassDoc_")), "hover text = " + text); + assertTrue(text.stream().anyMatch(t -> t.contains("Returns the sine of x.")), "hover text = " + text); + } finally { + JassDocService.setTestLookup(null); + JassDocService.getInstance().clearCacheForTests(); + } + } + + @Test + public void completionUsesJassDocFallbackForBuiltinFunction() throws IOException { + CompletionTestData data = input( + "package test", + "init", + " DisplayTextToPl|", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + JassDocService.setTestLookup(k -> + Objects.equals(k.symbolName(), "DisplayTextToPlayer") + && k.symbolKind() == JassDocService.SymbolKind.FUNCTION + ? "Shows text to a specific player." + : null + ); + JassDocService.getInstance().clearCacheForTests(); + try { + CompletionList completions = new de.peeeq.wurstio.languageserver.requests.GetCompletions( + new org.eclipse.lsp4j.CompletionParams(new TextDocumentIdentifier(ctx.uri), new Position(data.line, data.column)), + ctx.bufferManager + ).execute(ctx.modelManager); + assertNotNull(completions); + String doc = completions.getItems().stream() + .filter(i -> "DisplayTextToPlayer".equals(i.getLabel())) + .map(i -> i.getDocumentation() != null ? i.getDocumentation().getLeft() : null) + .filter(Objects::nonNull) + .findFirst() + .orElse(""); + assertTrue(doc.contains("Shows text to a specific player."), "completion doc = " + doc); + } finally { + JassDocService.setTestLookup(null); + JassDocService.getInstance().clearCacheForTests(); + } + } + + @Test + public void signatureHelpUsesJassDocFallbackForBuiltinFunction() throws IOException { + CompletionTestData data = input( + "package test", + "init", + " DisplayTextToPlayer(nu|ll, 0., 0., \"x\")", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + JassDocService.setTestLookup(k -> + Objects.equals(k.symbolName(), "DisplayTextToPlayer") + && k.symbolKind() == JassDocService.SymbolKind.FUNCTION + ? "Shows text to a specific player." + : null + ); + JassDocService.getInstance().clearCacheForTests(); + try { + SignatureHelp help = new SignatureInfo( + new TextDocumentPositionParams(new TextDocumentIdentifier(ctx.uri), new Position(data.line, data.column)), + ctx.bufferManager + ).execute(ctx.modelManager); + assertFalse(help.getSignatures().isEmpty()); + String doc = help.getSignatures().get(0).getDocumentation().getLeft(); + assertTrue(doc.contains("Shows text to a specific player."), "signature doc = " + doc); + } finally { + JassDocService.setTestLookup(null); + JassDocService.getInstance().clearCacheForTests(); + } + } + private List allTextEdits(WorkspaceEdit edit) { if (edit.getChanges() != null && !edit.getChanges().isEmpty()) { return edit.getChanges().values().stream().flatMap(List::stream).collect(Collectors.toList()); From 1e984ca51ebaa3424ef2d02c9d8d87453ba1a864 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 16:18:16 +0100 Subject: [PATCH 4/8] fix jassdoc consumption --- .../languageserver/JassDocService.java | 139 +++++++++++++++++- 1 file changed, 131 insertions(+), 8 deletions(-) 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 142213a31..2806dfb97 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 @@ -1,5 +1,9 @@ package de.peeeq.wurstio.languageserver; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import de.peeeq.wurstscript.WLogger; import de.peeeq.wurstscript.ast.FunctionDefinition; import de.peeeq.wurstscript.ast.NameDef; @@ -23,7 +27,6 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; @@ -88,7 +91,9 @@ public int hashCode() { private static final JassDocService INSTANCE = new JassDocService(); private static final Duration DEFAULT_LATEST_MAX_AGE = Duration.ofHours(24); - private static final List DEFAULT_DB_URLS = Arrays.asList(); + private static final String RELEASES_LATEST_API = "https://api.github.com/repos/wurstscript/wurst-jassdoc-build/releases/latest"; + private static final String RELEASES_API = "https://api.github.com/repos/wurstscript/wurst-jassdoc-build/releases?per_page=20"; + private static final String RELEASES_DOWNLOAD_BASE = "https://github.com/wurstscript/wurst-jassdoc-build/releases"; private final Map> lookupCache = new ConcurrentHashMap<>(); private volatile @Nullable CachedDb cachedDb; @@ -412,7 +417,7 @@ private Optional ensureDbAvailable() throws IOException { Files.createDirectories(dbDir); String revision = sanitizeRevision(Utils.getEnvOrConfig("WURST_JASSDOC_DB_REV").orElse("latest")); - Path dbPath = dbDir.resolve("jassdoc-" + revision + ".sqlite"); + Path dbPath = dbDir.resolve("jassdoc-" + revision + ".db"); if (!Files.exists(dbPath)) { Optional fallback = findFirstExisting(dbDir, "jassdoc-" + revision + ".db", @@ -436,33 +441,151 @@ private Optional ensureDbAvailable() throws IOException { List urls = Utils.getEnvOrConfig("WURST_JASSDOC_DB_URL") .map(List::of) - .orElse(DEFAULT_DB_URLS); + .orElseGet(() -> defaultDbUrlsForRevision(revision)); if (urls.isEmpty()) { showMissingSourceWarning(); return Optional.empty(); } - IOException lastError = null; + List errors = new ArrayList<>(); for (String url : urls) { try { download(url, dbPath); WLogger.info("Downloaded JassDoc DB from " + url + " to " + dbPath); return Optional.of(dbPath); } catch (IOException e) { - lastError = e; + errors.add(url + " -> " + e.getMessage()); } } if (Files.exists(dbPath)) { return Optional.of(dbPath); } - if (lastError != null) { - throw lastError; + if (!errors.isEmpty()) { + throw new IOException("Could not download JassDoc DB. Tried: " + String.join("; ", errors)); } return Optional.empty(); } + private List defaultDbUrlsForRevision(String revision) { + List urls = new ArrayList<>(); + if ("latest".equals(revision)) { + urls.addAll(resolveLatestReleaseAssetUrls()); + urls.add(RELEASES_DOWNLOAD_BASE + "/latest/download/jass.db"); + } else { + urls.add(RELEASES_DOWNLOAD_BASE + "/download/" + revision + "/jass.db"); + urls.add(RELEASES_DOWNLOAD_BASE + "/download/" + revision + "/jassdoc.db"); + urls.add(RELEASES_DOWNLOAD_BASE + "/download/" + revision + "/jassdoc.sqlite"); + } + return urls; + } + + private List resolveLatestReleaseAssetUrls() { + List urls = new ArrayList<>(); + urls.addAll(readReleaseAssetUrls(RELEASES_LATEST_API)); + if (!urls.isEmpty()) { + return urls; + } + urls.addAll(readNewestReleaseAssetUrlsFromList()); + return urls; + } + + private List readNewestReleaseAssetUrlsFromList() { + List urls = new ArrayList<>(); + try { + HttpURLConnection con = (HttpURLConnection) new URL(RELEASES_API).openConnection(); + con.setConnectTimeout(10_000); + con.setReadTimeout(20_000); + con.setRequestMethod("GET"); + con.setRequestProperty("Accept", "application/vnd.github+json"); + con.setRequestProperty("User-Agent", "WurstScript-LSP"); + int code = con.getResponseCode(); + if (code >= 200 && code < 300) { + String body; + try (InputStream in = con.getInputStream()) { + body = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + JsonArray releases = JsonParser.parseString(body).getAsJsonArray(); + for (JsonElement e : releases) { + if (!e.isJsonObject()) { + continue; + } + JsonObject rel = e.getAsJsonObject(); + List relUrls = extractDbAssetUrls(rel); + if (!relUrls.isEmpty()) { + urls.addAll(relUrls); + break; + } + } + } + con.disconnect(); + } catch (Exception e) { + WLogger.info("Could not query releases list for JassDoc DB: " + e.getMessage()); + } + return urls; + } + + private List readReleaseAssetUrls(String apiUrl) { + List urls = new ArrayList<>(); + try { + HttpURLConnection con = (HttpURLConnection) new URL(apiUrl).openConnection(); + con.setConnectTimeout(10_000); + con.setReadTimeout(20_000); + con.setRequestMethod("GET"); + con.setRequestProperty("Accept", "application/vnd.github+json"); + con.setRequestProperty("User-Agent", "WurstScript-LSP"); + int code = con.getResponseCode(); + if (code >= 200 && code < 300) { + String body; + try (InputStream in = con.getInputStream()) { + body = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + JsonObject obj = JsonParser.parseString(body).getAsJsonObject(); + urls.addAll(extractDbAssetUrls(obj)); + } + con.disconnect(); + } catch (Exception e) { + WLogger.info("Could not query latest JassDoc release API: " + e.getMessage()); + } + return urls; + } + + private List extractDbAssetUrls(JsonObject releaseObj) { + List urls = new ArrayList<>(); + JsonArray assets = releaseObj.getAsJsonArray("assets"); + if (assets == null) { + return urls; + } + for (JsonElement e : assets) { + if (!e.isJsonObject()) { + continue; + } + JsonObject asset = e.getAsJsonObject(); + String name = getString(asset, "name"); + String browserDownload = getString(asset, "browser_download_url"); + if (browserDownload == null || name == null) { + continue; + } + String n = name.toLowerCase(Locale.ROOT); + if (n.endsWith(".db") || n.endsWith(".sqlite")) { + urls.add(browserDownload); + } + } + return urls; + } + + private @Nullable String getString(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) { + return null; + } + try { + return obj.get(key).getAsString(); + } catch (Exception ignored) { + return null; + } + } + private void showMissingSourceWarning() { if (missingSourceWarningShown) { return; From f866eb54b2f1b5431236db87d2c5e429f70edc14 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 16:45:35 +0100 Subject: [PATCH 5/8] reduce noisy logs --- .../languageserver/ModelManagerImpl.java | 16 +++--- .../languageserver/WurstLanguageServer.java | 53 ++++++++++++++++++- .../WurstTextDocumentService.java | 34 ++++++------ 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java index 62b7a0d31..945e4c585 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java @@ -482,7 +482,7 @@ private void replaceCompilationUnit(WFile filename) { @Override public Changes syncCompilationUnitContent(WFile filename, String contents) { - WLogger.info("sync contents for " + filename); + WLogger.debug("sync contents for " + filename); Set oldPackages = declaredPackages(filename); replaceCompilationUnit(filename, contents, false); return new Changes(io.vavr.collection.HashSet.of(filename), oldPackages); @@ -514,10 +514,10 @@ public CompilationUnit replaceCompilationUnitContent(WFile filename, String cont @Override public Changes syncCompilationUnit(WFile f) { - WLogger.info("syncCompilationUnit File " + f); + WLogger.debug("syncCompilationUnit File " + f); Set oldPackages = declaredPackages(f); replaceCompilationUnit(f); - WLogger.info("replaced file " + f); + WLogger.debug("replaced file " + f); WurstGui gui = new WurstGuiLogger(); doTypeCheckPartial(gui, ImmutableList.of(f), oldPackages); return new Changes(io.vavr.collection.HashSet.of(f), oldPackages); @@ -537,9 +537,9 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, return existing; } // Stale hash cache after remove/move; CU is gone, so reparse. - WLogger.info("CU hash unchanged but model entry missing for " + filename + ", reparsing."); + WLogger.debug("CU hash unchanged but model entry missing for " + filename + ", reparsing."); } else { - WLogger.info("CU changed. oldHash = " + oldHash + " == " + contents.hashCode()); + WLogger.debug("CU changed. oldHash = " + oldHash + " == " + contents.hashCode()); } } @@ -552,7 +552,7 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, fileHashcodes.put(filename, contents.hashCode()); if (reportErrors) { if (gui.getErrorCount() > 0) { - WLogger.info("found " + gui.getErrorCount() + " errors in file " + filename); + WLogger.debug("found " + gui.getErrorCount() + " errors in file " + filename); } ImmutableList.Builder errors = ImmutableList.builder() .addAll(gui.getErrorsAndWarnings()); @@ -576,7 +576,7 @@ private void clearFileState(WFile file) { public CompilationUnit getCompilationUnit(WFile filename) { List matches = getCompilationUnits(Collections.singletonList(filename)); if (matches.isEmpty()) { - WLogger.info("compilation unit not found: " + filename); + WLogger.trace("compilation unit not found: " + filename); return null; } return matches.get(0); @@ -628,7 +628,7 @@ public void onCompilationResult(Consumer f) { } private void doTypeCheckPartial(WurstGui gui, List toCheckFilenames, Set oldPackages) { - WLogger.info("do typecheck partial of " + toCheckFilenames); + WLogger.debug("do typecheck partial of " + toCheckFilenames); WurstCompilerJassImpl comp = getCompiler(gui); List toCheck = getCompilationUnits(toCheckFilenames); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java index 11359df29..167972ba3 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstLanguageServer.java @@ -9,12 +9,16 @@ import java.io.FileDescriptor; import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; /** * @@ -29,7 +33,7 @@ public class WurstLanguageServer implements LanguageServer, LanguageClientAware public CompletableFuture initialize(InitializeParams params) { System.err.println("Loading Wurst version " + CompileTimeInfo.version); setupLogger(); - System.setErr(new PrintStream(new FileOutputStream(FileDescriptor.err), true, StandardCharsets.UTF_8)); + System.setErr(createFilteredErr()); if (params.getRootUri() == null) { System.err.println("Workspace null. Make sure to open a valid project root using File->Open Folder, before opening code files."); return CompletableFuture.completedFuture(null); @@ -85,6 +89,53 @@ public CompletableFuture initialize(InitializeParams params) { } private void setupLogger() { WLogger.setLogger("languageServer"); + Logger.getLogger(RemoteEndpoint.class.getName()).setLevel(Level.SEVERE); + Logger.getLogger("org.eclipse.lsp4j.jsonrpc.RemoteEndpoint").setLevel(Level.SEVERE); + } + + private PrintStream createFilteredErr() { + PrintStream rawErr = new PrintStream(new FileOutputStream(FileDescriptor.err), true, StandardCharsets.UTF_8); + OutputStream filteringOutput = new OutputStream() { + private final StringBuilder lineBuffer = new StringBuilder(); + + @Override + public void write(int b) throws IOException { + char c = (char) b; + if (c == '\n') { + flushLine(); + rawErr.write('\n'); + rawErr.flush(); + } else if (c != '\r') { + lineBuffer.append(c); + } + } + + @Override + public void flush() throws IOException { + flushLine(); + rawErr.flush(); + } + + private void flushLine() throws IOException { + if (lineBuffer.isEmpty()) { + return; + } + String line = lineBuffer.toString(); + lineBuffer.setLength(0); + if (shouldSuppressErrLine(line)) { + return; + } + rawErr.write(line.getBytes(StandardCharsets.UTF_8)); + } + }; + return new PrintStream(filteringOutput, true, StandardCharsets.UTF_8); + } + + private boolean shouldSuppressErrLine(String line) { + return line.startsWith("WARNING: A restricted method in java.lang.System has been called") + || line.startsWith("WARNING: java.lang.System::load has been called by org.sqlite.SQLiteJDBCLoader") + || line.startsWith("WARNING: Use --enable-native-access=ALL-UNNAMED") + || line.startsWith("WARNING: Restricted methods will be blocked in a future release"); } @Override diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java index d5ae88ed9..d948b1d28 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstTextDocumentService.java @@ -26,13 +26,13 @@ public WurstTextDocumentService(LanguageWorker worker) { @Override public CompletableFuture, CompletionList>> completion(CompletionParams position) { - WLogger.info("completion"); + WLogger.debug("completion"); return worker.handle(new GetCompletions(position, worker.getBufferManager())).thenApply(Either::forRight); } @Override public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { - WLogger.info("resolveCompletionItem"); + WLogger.trace("resolveCompletionItem"); return CompletableFuture.completedFuture(unresolved); } @@ -43,37 +43,37 @@ public CompletableFuture hover(HoverParams hoverParams) { @Override public CompletableFuture signatureHelp(SignatureHelpParams helpParams) { - WLogger.info("signatureHelp"); + WLogger.debug("signatureHelp"); return worker.handle(new SignatureInfo(helpParams, worker.getBufferManager())); } @Override public CompletableFuture, List>> definition(DefinitionParams definitionParams) { - WLogger.info("definition"); + WLogger.debug("definition"); return worker.handle(new GetDefinition(definitionParams, worker.getBufferManager(), GetDefinition.LookupType.DEFINITION)); } @Override public CompletableFuture, List>> declaration(DeclarationParams params) { - WLogger.info("declaration"); + WLogger.debug("declaration"); return worker.handle(new GetDefinition(params, worker.getBufferManager(), GetDefinition.LookupType.DECLARATION)); } @Override public CompletableFuture, List>> typeDefinition(TypeDefinitionParams params) { - WLogger.info("typeDefinition"); + WLogger.debug("typeDefinition"); return worker.handle(new GetDefinition(params, worker.getBufferManager(), GetDefinition.LookupType.TYPE_DEFINITION)); } @Override public CompletableFuture, List>> implementation(ImplementationParams params) { - WLogger.info("implementation"); + WLogger.debug("implementation"); return worker.handle(new GetDefinition(params, worker.getBufferManager(), GetDefinition.LookupType.IMPLEMENTATION)); } @Override public CompletableFuture> references(ReferenceParams params) { - WLogger.info("references"); + WLogger.debug("references"); return worker.handle(new GetUsages(params, worker.getBufferManager(), true)) .thenApply((List udList) -> { @@ -88,7 +88,7 @@ public CompletableFuture> references(ReferenceParams pa @Override public CompletableFuture> documentHighlight(DocumentHighlightParams highlightParams) { - WLogger.info("documentHighlight"); + WLogger.debug("documentHighlight"); return worker.handle(new GetUsages(highlightParams, worker.getBufferManager(), false)) .thenApply((List udList) -> { @@ -123,7 +123,7 @@ public CompletableFuture resolveCodeLens(CodeLens unresolved) { @Override public CompletableFuture> formatting(DocumentFormattingParams params) { - WLogger.info("formatting"); + WLogger.debug("formatting"); if (worker.modelManager.hasErrors()) { throw new RequestFailedException(MessageType.Error, "Fix errors in your code before running.\n" + worker.modelManager.getFirstErrorDescription()); @@ -145,44 +145,44 @@ public CompletableFuture> formatting(DocumentFormatting @Override public CompletableFuture> rangeFormatting(DocumentRangeFormattingParams params) { - WLogger.info("rangeFormatting"); + WLogger.trace("rangeFormatting"); return CompletableFuture.completedFuture(Collections.emptyList()); } @Override public CompletableFuture> onTypeFormatting(DocumentOnTypeFormattingParams params) { - WLogger.info("onTypeFormatting"); + WLogger.trace("onTypeFormatting"); return CompletableFuture.completedFuture(Collections.emptyList()); } @Override public CompletableFuture rename(RenameParams params) { - WLogger.info("rename"); + WLogger.debug("rename"); return worker.handle(new RenameRequest(params, worker.getBufferManager())); } @Override public void didOpen(DidOpenTextDocumentParams params) { - WLogger.info("didOpen"); + WLogger.debug("didOpen"); worker.handleOpen(params); } @Override public void didChange(DidChangeTextDocumentParams params) { - WLogger.info("didChange"); + WLogger.trace("didChange"); worker.handleChange(params); } @Override public void didClose(DidCloseTextDocumentParams params) { - WLogger.info("didClose"); + WLogger.debug("didClose"); worker.handleClose(params); } @Override public void didSave(DidSaveTextDocumentParams params) { - WLogger.info("didSave"); + WLogger.debug("didSave"); worker.handleSave(params); } From 2bf3134e102cae47579d621bf92f2ffe078e0c5a Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 16:57:04 +0100 Subject: [PATCH 6/8] make wurst.dependencies obsolete --- .../wurstio/languageserver/LanguageWorker.java | 3 ++- .../wurstio/languageserver/ModelManagerImpl.java | 14 ++++---------- .../tests/wurstscript/tests/ModelManagerTests.java | 6 ++---- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java index 5cb5bbc87..7b4784482 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java @@ -223,7 +223,8 @@ private Workitem getNextWorkItem() { } private boolean isWurstDependencyFile(PendingChange change) { - return change.getFilename().getUriString().endsWith("wurst.dependencies"); + String uri = change.getFilename().getUriString().replace('\\', '/'); + return uri.contains("/_build/dependencies/"); } private PendingChange removeFirst(Map changes) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java index 945e4c585..44e2933ef 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java @@ -35,7 +35,7 @@ public class ModelManagerImpl implements ModelManager { private final BufferManager bufferManager; private volatile @Nullable WurstModel model; private final File projectPath; - // dependency folders (folders mentioned in wurst.dependencies) + // dependency project folders discovered in _build/dependencies private final Set dependencies = Sets.newLinkedHashSet(); // private WurstGui gui = new WurstGuiLogger(); private final List> onCompilationResultListeners = new ArrayList<>(); @@ -129,7 +129,7 @@ public void clean() { public void buildProject() { try { WurstGui gui = new WurstGuiLogger(); - readDependencies(gui); + readDependencies(); if (!projectPath.exists()) { throw new RuntimeException("Folder " + projectPath + " does not exist!"); @@ -145,7 +145,7 @@ public void buildProject() { resolveImports(gui); doTypeCheck(gui); - } catch (IOException e) { + } catch (Exception e) { WLogger.severe(e); throw new ModelManagerException(e); } @@ -174,14 +174,8 @@ private void processWurstFile(WFile f) { replaceCompilationUnit(f); } - private void readDependencies(WurstGui gui) throws IOException { + private void readDependencies() { dependencies.clear(); - File depFile = new File(projectPath, "wurst.dependencies"); - if (!depFile.exists()) { - WLogger.info("no dependency file found."); - return; - } - dependencies.addAll(WurstCompilerJassImpl.checkDependencyFile(depFile, gui)); WurstCompilerJassImpl.addDependenciesFromFolder(projectPath, dependencies); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java index c1a79ec49..f19779753 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java @@ -875,7 +875,7 @@ private void runRunmapLikeCompile_Closer(File projectFolder, ModelManagerImpl ma public void runmapPurge_keepsProjectWar3MapAndPurgesUnimportedDependency() throws Exception { File projectFolder = new File("./temp/testProject_runmap_purge_dep/"); File wurstFolder = new File(projectFolder, "wurst"); - File dependencyRoot = new File(projectFolder, "depA"); + File dependencyRoot = new File(new File(new File(projectFolder, "_build"), "dependencies"), "depA"); File dependencyWurst = new File(dependencyRoot, "wurst"); newCleanFolder(wurstFolder); newCleanFolder(dependencyWurst); @@ -897,7 +897,6 @@ public void runmapPurge_keepsProjectWar3MapAndPurgesUnimportedDependency() throw "init", " skip" )); - Files.writeString(new File(projectFolder, "wurst.dependencies").toPath(), dependencyRoot.getAbsolutePath() + "\n"); ModelManagerImpl manager = new ModelManagerImpl(projectFolder, new BufferManager()); manager.buildProject(); @@ -915,7 +914,7 @@ public void runmapPurge_keepsProjectWar3MapAndPurgesUnimportedDependency() throw public void runmapPurge_onlyKeepsWar3MapFromProjectWurstFolder() throws Exception { File projectFolder = new File("./temp/testProject_runmap_purge_war3map_scope/"); File wurstFolder = new File(projectFolder, "wurst"); - File dependencyRoot = new File(projectFolder, "depB"); + File dependencyRoot = new File(new File(new File(projectFolder, "_build"), "dependencies"), "depB"); File dependencyWurst = new File(dependencyRoot, "wurst"); newCleanFolder(wurstFolder); newCleanFolder(dependencyWurst); @@ -929,7 +928,6 @@ public void runmapPurge_onlyKeepsWar3MapFromProjectWurstFolder() throws Exceptio writeFile(fileMain, "package Main\n"); writeFile(fileProjectWar3Map, "globals\nendglobals\n"); writeFile(fileDependencyWar3Map, "globals\nendglobals\n"); - Files.writeString(new File(projectFolder, "wurst.dependencies").toPath(), dependencyRoot.getAbsolutePath() + "\n"); ModelManagerImpl manager = new ModelManagerImpl(projectFolder, new BufferManager()); manager.buildProject(); From 3e2f87b4e89248b834062e95fb7af11e75c155d9 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 16:57:12 +0100 Subject: [PATCH 7/8] fix grill cmd path --- Wurstpack/wurstscript/grill.cmd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Wurstpack/wurstscript/grill.cmd b/Wurstpack/wurstscript/grill.cmd index f8640bc20..d39767b11 100644 --- a/Wurstpack/wurstscript/grill.cmd +++ b/Wurstpack/wurstscript/grill.cmd @@ -11,13 +11,13 @@ rem Resolve script dir set "DIR=%~dp0" set "JAVA=%DIR%wurst-runtime\bin\java.exe" -set "GRILL_JAR=%DIR%grill\grill.jar" +set "GRILL_JAR=%DIR%grill-cli\grill.jar" if not exist "%GRILL_JAR%" ( echo [grill] ERROR: Missing jar. Searched: echo %GRILL_JAR% rem fallback to ../grill if you want: - set "GRILL_JAR=%DIR%..\grill\grill.jar" + set "GRILL_JAR=%DIR%..\grill-cli\grill.jar" if not exist "%GRILL_JAR%" ( echo %GRILL_JAR% goto :restore From 9b1ee3995420ec7be93be04c6fd24c42d526464c Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 3 Mar 2026 17:03:24 +0100 Subject: [PATCH 8/8] get rid of wurst.dependencies --- .../peeeq/wurstio/WurstCompilerJassImpl.java | 45 +---------- .../tests/DependencyFileParserTest.java | 75 +++++++++---------- 2 files changed, 37 insertions(+), 83 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java index deccb9a8c..5c3e4332f 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java @@ -2,7 +2,6 @@ import com.google.common.base.Charsets; import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; @@ -41,7 +40,6 @@ import java.io.*; import java.lang.ref.WeakReference; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.Map.Entry; import java.util.function.Function; @@ -148,49 +146,12 @@ public void loadWurstFilesInDir(File dir) { loadWurstFilesInDir(f); } else if (Utils.isWurstFile(f)) { loadFile(f); - } else if (f.getName().equals("wurst.dependencies")) { - dependencies.addAll(checkDependencyFile(f, gui)); } else if ((!mapFile.isPresent() || runArgs.isNoExtractMapScript()) && f.getName().equals("war3map.j")) { loadFile(f); } } } - public static ImmutableList checkDependencyFile(File depFile, WurstGui gui) { - List lines; - try { - lines = Files.readLines(depFile, StandardCharsets.UTF_8); - } catch (IOException e) { - e.printStackTrace(); - throw new Error(e); - } - LineOffsets offsets = new LineOffsets(); - int lineNr = 0; - int offset = 0; - for (String line : lines) { - offsets.set(lineNr, offset); - lineNr++; - offset += line.length() + 1; - } - offsets.set(lineNr, offset); - lineNr = 0; - ImmutableList.Builder dependencies = ImmutableList.builder(); - for (String line : lines) { - int lineOffset = offsets.get(lineNr); - WPos pos = new WPos(depFile.getAbsolutePath(), offsets, lineOffset + 1, lineOffset + line.length() + 1); - File folder = new File(line); - if (!folder.exists()) { - gui.sendError(new CompileError(pos, "Folder " + line + " not found.")); - } else if (!folder.isDirectory()) { - gui.sendError(new CompileError(pos, line + " is not a folder.")); - } else { - dependencies.add(folder); - } - lineNr++; - } - return dependencies.build(); - } - @Override public @Nullable WurstModel parseFiles() { @@ -220,10 +181,6 @@ public static ImmutableList checkDependencyFile(File depFile, WurstGui gui } else { WLogger.info("No wurst folder found in " + relativeWurstDir); } - File dependencyFile = new File(projectFolder, "wurst.dependencies"); - if (dependencyFile.exists()) { - dependencies.addAll(checkDependencyFile(dependencyFile, gui)); - } addDependenciesFromFolder(projectFolder, dependencies); } @@ -373,7 +330,7 @@ private void resolveImport(Function addCompilationUnit, S private CompilationUnit loadLibPackage(Function addCompilationUnit, String imp) { File file = getLibs().get(imp); if (file == null) { - gui.sendError(new CompileError(new WPos("", null, 0, 0), "Could not find lib-package " + imp + ". Are you missing your wurst.dependencies file?")); + gui.sendError(new CompileError(new WPos("", null, 0, 0), "Could not find lib-package " + imp + ". Is your dependency present in _build/dependencies?")); return Ast.CompilationUnit(new CompilationUnitInfo(errorHandler), Ast.JassToplevelDeclarations(), Ast.WPackages()); } else { return addCompilationUnit.apply(file); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/DependencyFileParserTest.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/DependencyFileParserTest.java index cb3e8bd17..f2e74f212 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/DependencyFileParserTest.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/DependencyFileParserTest.java @@ -2,66 +2,63 @@ import de.peeeq.wurstio.WurstCompilerJassImpl; -import de.peeeq.wurstio.languageserver.Convert; -import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstio.utils.FileUtils; -import de.peeeq.wurstscript.RunArgs; -import de.peeeq.wurstscript.attributes.CompileError; -import de.peeeq.wurstscript.gui.WurstGui; -import de.peeeq.wurstscript.gui.WurstGuiLogger; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.testng.annotations.Test; -import java.io.BufferedWriter; import java.io.File; -import java.io.FileWriter; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.nio.file.Files; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; public class DependencyFileParserTest { @Test - public void testDepFileParsing() throws IOException { - File projectFolder = new File("./temp/testDepFileParsing/"); + public void testAddDependenciesFromBuildFolder() throws IOException { + File projectFolder = new File("./temp/testAddDependenciesFromBuildFolder/"); newCleanFolder(projectFolder); - File a = new File(projectFolder, "a"); - File b = new File(projectFolder, "b"); - File c = new File(projectFolder, "c"); + File depsRoot = new File(new File(projectFolder, "_build"), "dependencies"); + File a = new File(depsRoot, "a"); + File b = new File(depsRoot, "b"); + File ignoredFile = new File(depsRoot, "not-a-dir.txt"); a.mkdirs(); - //b.mkdirs(); - c.mkdirs(); + b.mkdirs(); + Files.writeString(ignoredFile.toPath(), "x"); - File dep = new File(projectFolder, "wurst.dependencies"); - try (BufferedWriter w = new BufferedWriter(new FileWriter(dep))) { - w.write(a.getAbsolutePath() + "\n"); - w.write(b.getAbsolutePath() + "\n"); - w.write(c.getAbsolutePath() + "\n"); - } + List dependencies = new ArrayList<>(); + WurstCompilerJassImpl.addDependenciesFromFolder(projectFolder, dependencies); + assertEquals(dependencies.size(), 2); + assertTrue(containsSameFile(dependencies, a)); + assertTrue(containsSameFile(dependencies, b)); + } - WurstGui gui = new WurstGuiLogger(); - WurstCompilerJassImpl comp = new WurstCompilerJassImpl(projectFolder, gui, null, new RunArgs()); - comp.loadWurstFilesInDir(projectFolder); + @Test + public void testAddDependenciesFromBuildFolderDoesNotDuplicate() throws IOException { + File projectFolder = new File("./temp/testAddDependenciesFromBuildFolderDoesNotDuplicate/"); + newCleanFolder(projectFolder); + File depsRoot = new File(new File(projectFolder, "_build"), "dependencies"); + File a = new File(depsRoot, "a"); + a.mkdirs(); - assertEquals(gui.getErrorList().size(), 1); - CompileError err = gui.getErrorList().get(0); - assertEquals(err.getSource().getLine(), 2); - assertEquals(err.getSource().getStartColumn(), 1); - assertEquals(err.getSource().getEndLine(), 2); - assertEquals(err.getSource().getEndColumn(), b.getAbsolutePath().length() + 1); + List dependencies = new ArrayList<>(); + dependencies.add(a); - PublishDiagnosticsParams diag = Convert.createDiagnostics("", WFile.create(dep), gui.getErrorList()); - assertEquals(diag.getDiagnostics().size(), 1); - Diagnostic d = diag.getDiagnostics().get(0); - assertEquals(d.getRange().getStart().getLine(), 1); - assertEquals(d.getRange().getStart().getCharacter(), 0); - assertEquals(d.getRange().getEnd().getLine(), 1); - assertEquals(d.getRange().getEnd().getCharacter(), b.getAbsolutePath().length()); + WurstCompilerJassImpl.addDependenciesFromFolder(projectFolder, dependencies); + assertEquals(dependencies.size(), 1); + } + private boolean containsSameFile(List files, File expected) { + for (File f : files) { + if (FileUtils.sameFile(f, expected)) { + return true; + } + } + return false; } private void newCleanFolder(File f) throws IOException {