diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/BaseWorkspaceService.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/BaseWorkspaceService.java index b83c27430..f9b9e3fa9 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/BaseWorkspaceService.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/BaseWorkspaceService.java @@ -46,7 +46,9 @@ import org.eclipse.lsp4j.services.WorkspaceService; public class BaseWorkspaceService implements WorkspaceService, LanguageClientAware { + public static final String RASCAL_LANGUAGE = "Rascal"; public static final String RASCAL_META_COMMAND = "rascal-meta-command"; + public static final String RASCAL_COMMAND = "rascal-command"; private final IBaseTextDocumentService documentService; private final CopyOnWriteArrayList workspaceFolders = new CopyOnWriteArrayList<>(); @@ -108,11 +110,12 @@ public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { @Override public CompletableFuture executeCommand(ExecuteCommandParams params) { - if (params.getCommand().startsWith(RASCAL_META_COMMAND)) { + if (params.getCommand().startsWith(RASCAL_META_COMMAND) || params.getCommand().startsWith(RASCAL_COMMAND)) { String languageName = ((JsonPrimitive) params.getArguments().get(0)).getAsString(); String command = ((JsonPrimitive) params.getArguments().get(1)).getAsString(); return documentService.executeCommand(languageName, command).thenApply(v -> v); } + return CompletableFuture.supplyAsync(() -> params.getCommand() + " was ignored."); } diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/IBaseTextDocumentService.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/IBaseTextDocumentService.java index 288cf66d5..2615b4a0a 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/IBaseTextDocumentService.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/IBaseTextDocumentService.java @@ -44,4 +44,5 @@ public interface IBaseTextDocumentService extends TextDocumentService { void unregisterLanguage(LanguageParameter lang); CompletableFuture executeCommand(String languageName, String command); LineColumnOffsetMap getColumnMap(ISourceLocation file); + } diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParametricTextDocumentService.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParametricTextDocumentService.java index 83da701f2..a2ac230f9 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParametricTextDocumentService.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParametricTextDocumentService.java @@ -29,11 +29,9 @@ import java.io.IOException; import java.io.Reader; import java.time.Duration; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; @@ -50,7 +48,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.eclipse.lsp4j.CodeAction; -import org.eclipse.lsp4j.CodeActionKind; import org.eclipse.lsp4j.CodeActionParams; import org.eclipse.lsp4j.CodeLens; import org.eclipse.lsp4j.CodeLensOptions; @@ -90,7 +87,6 @@ import org.eclipse.lsp4j.TextDocumentItem; import org.eclipse.lsp4j.TextDocumentSyncKind; import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; -import org.eclipse.lsp4j.WorkspaceEdit; import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; @@ -108,11 +104,10 @@ import org.rascalmpl.vscode.lsp.TextDocumentState; import org.rascalmpl.vscode.lsp.parametric.model.ParametricFileFacts; import org.rascalmpl.vscode.lsp.parametric.model.ParametricSummary; -import org.rascalmpl.vscode.lsp.parametric.model.RascalADTs; import org.rascalmpl.vscode.lsp.parametric.model.ParametricSummary.SummaryLookup; import org.rascalmpl.vscode.lsp.terminal.ITerminalIDEServer.LanguageParameter; +import org.rascalmpl.vscode.lsp.util.CodeActions; import org.rascalmpl.vscode.lsp.util.Diagnostics; -import org.rascalmpl.vscode.lsp.util.DocumentChanges; import org.rascalmpl.vscode.lsp.util.FoldingRanges; import org.rascalmpl.vscode.lsp.util.DocumentSymbols; import org.rascalmpl.vscode.lsp.util.SemanticTokenizer; @@ -123,8 +118,6 @@ import org.rascalmpl.vscode.lsp.util.locations.Locations; import org.rascalmpl.vscode.lsp.util.locations.impl.TreeSearch; -import com.google.gson.JsonPrimitive; - import io.usethesource.vallang.IBool; import io.usethesource.vallang.IConstructor; import io.usethesource.vallang.IList; @@ -132,7 +125,6 @@ import io.usethesource.vallang.IString; import io.usethesource.vallang.ITuple; import io.usethesource.vallang.IValue; -import io.usethesource.vallang.IWithKeywordParameters; import io.usethesource.vallang.exceptions.FactParseError; public class ParametricTextDocumentService implements IBaseTextDocumentService, LanguageClientAware { @@ -386,80 +378,10 @@ private CodeLens locCommandTupleToCodeLense(String languageName, IValue v) { ISourceLocation loc = (ISourceLocation) t.get(0); IConstructor command = (IConstructor) t.get(1); - return new CodeLens(Locations.toRange(loc, columns), constructorToCommand(languageName, command), null); - } - - private CodeAction constructorToCodeAction(String languageName, IConstructor codeAction) { - IWithKeywordParameters kw = codeAction.asWithKeywordParameters(); - IConstructor command = (IConstructor) kw.getParameter(RascalADTs.CodeActionFields.COMMAND); - IString title = (IString) kw.getParameter(RascalADTs.CodeActionFields.TITLE); - IList edits = (IList) kw.getParameter(RascalADTs.CodeActionFields.EDITS); - IConstructor kind = (IConstructor) kw.getParameter(RascalADTs.CodeActionFields.KIND); - - // first deal with the defaults. Must mimick what's in util::LanguageServer with the `data CodeAction` declaration - if (title == null) { - if (command != null) { - title = (IString) command.asWithKeywordParameters().getParameter(RascalADTs.CommandFields.TITLE); - } - - if (title == null) { - title = IRascalValueFactory.getInstance().string(""); - } - } - - CodeAction result = new CodeAction(title.getValue()); - - if (command != null) { - result.setCommand(constructorToCommand(languageName, command)); - } - - if (edits != null) { - result.setEdit(new WorkspaceEdit(DocumentChanges.translateDocumentChanges(this, edits))); - } - - result.setKind(constructorToCodeActionKind(kind)); - - return result; + return new CodeLens(Locations.toRange(loc, columns), CodeActions.constructorToCommand(dedicatedLanguageName, languageName, command), null); } - /** - * Translates `refactor(inline())` to `"refactor.inline"` and `empty()` to `""`, etc. - * `kind == null` signals absence of the optional parameter. This is factorede into - * this private function because otherwise every call has to check it. - */ - private String constructorToCodeActionKind(@Nullable IConstructor kind) { - if (kind == null) { - return CodeActionKind.QuickFix; - } - String name = kind.getName(); - - if (name.isEmpty()) { - return ""; - } - else if (name.length() == 1) { - return name.toUpperCase(); - } - else if ("empty".equals(name)) { - return ""; - } - else { - var kw = kind.asWithKeywordParameters(); - for (String kwn : kw.getParameterNames()) { - String nestedName = constructorToCodeActionKind((IConstructor) kw.getParameter(kwn)); - name = name + (nestedName.isEmpty() ? "" : ("." + nestedName)); - } - } - - return name; - } - - private Command constructorToCommand(String languageName, IConstructor command) { - IWithKeywordParameters kw = command.asWithKeywordParameters(); - IString possibleTitle = (IString) kw.getParameter(RascalADTs.CommandFields.TITLE); - - return new Command(possibleTitle != null ? possibleTitle.getValue() : command.toString(), getRascalMetaCommandName(), Arrays.asList(languageName, command.toString())); - } private void handleParsingErrors(TextDocumentState file) { handleParsingErrors(file, file.getCurrentTreeAsync()); @@ -584,34 +506,21 @@ public CompletableFuture semanticTokensRange(SemanticTokensRange @Override public CompletableFuture>> codeAction(CodeActionParams params) { - logger.debug("codeActions: {}", params); + logger.debug("codeAction: {}", params); final ILanguageContributions contribs = contributions(params.getTextDocument()); + final var loc = Locations.toLoc(params.getTextDocument()); final var start = params.getRange().getStart(); // convert to Rascal 1-based line final var startLine = start.getLine() + 1; // convert to Rascal UTF-32 column width final var startColumn = columns.get(loc).translateInverseColumn(start.getLine(), start.getCharacter(), false); - final var emptyListFuture = CompletableFuture.completedFuture(IRascalValueFactory.getInstance().list()); // first we make a future stream for filtering out the "fixes" that were optionally sent along with earlier diagnostics // and which came back with the codeAction's list of relevant (in scope) diagnostics: // CompletableFuture> - CompletableFuture> quickfixes - = params.getContext().getDiagnostics() - .stream() - .map(Diagnostic::getData) - .filter(Objects::nonNull) - .filter(JsonPrimitive.class::isInstance) - .map(JsonPrimitive.class::cast) - .map(JsonPrimitive::getAsString) - // this is the "magic" resurrection of command terms from the JSON data field - .map(contribs::parseCodeActions) - // this serializes the stream of futures and accumulates their results as a flat list again - .reduce(emptyListFuture, (acc, next) -> acc.thenCombine(next, IList::concat)) - .thenApply(IList::stream) - ; + var quickfixes = CodeActions.extractActionsFromDiagnostics(params, contribs::parseCodeActions); // here we dynamically ask the contributions for more actions, // based on the cursor position in the file and the current parse tree @@ -625,13 +534,7 @@ public CompletableFuture>> codeAction(CodeActio ; // final merging the two streams of commmands, and their conversion to LSP Command data-type - return codeActions.thenCombine(quickfixes, (actions, quicks) -> - Stream.concat(quicks, actions) - .map(IConstructor.class::cast) - .map(cons -> constructorToCodeAction(contribs.getName(), cons)) - .map(cmd -> Either.forRight(cmd)) - .collect(Collectors.toList()) - ); + return CodeActions.mergeAndConvertCodeActions(this, dedicatedLanguageName, contribs.getName(), quickfixes, codeActions); } private CompletableFuture computeCodeActions(final ILanguageContributions contribs, final int startLine, final int startColumn, ITree tree) { diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalLanguageServices.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalLanguageServices.java index eed62f975..2d44bdf0f 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalLanguageServices.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalLanguageServices.java @@ -30,6 +30,7 @@ import static org.rascalmpl.vscode.lsp.util.EvaluatorUtil.runEvaluator; import java.io.IOException; +import java.io.StringReader; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -51,6 +52,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; import org.rascalmpl.exceptions.Throw; import org.rascalmpl.interpreter.Evaluator; +import org.rascalmpl.interpreter.env.ModuleEnvironment; import org.rascalmpl.library.util.PathConfig; import org.rascalmpl.values.IRascalValueFactory; import org.rascalmpl.values.functions.IFunction; @@ -59,10 +61,10 @@ import org.rascalmpl.vscode.lsp.BaseWorkspaceService; import org.rascalmpl.vscode.lsp.IBaseLanguageClient; import org.rascalmpl.vscode.lsp.RascalLSPMonitor; +import org.rascalmpl.vscode.lsp.util.EvaluatorUtil; import org.rascalmpl.vscode.lsp.util.RascalServices; import org.rascalmpl.vscode.lsp.util.concurrent.InterruptibleFuture; import org.rascalmpl.vscode.lsp.util.locations.ColumnMaps; - import io.usethesource.vallang.IConstructor; import io.usethesource.vallang.IList; import io.usethesource.vallang.ISet; @@ -70,6 +72,8 @@ import io.usethesource.vallang.IString; import io.usethesource.vallang.IValue; import io.usethesource.vallang.IValueFactory; +import io.usethesource.vallang.exceptions.FactTypeUseException; +import io.usethesource.vallang.io.StandardTextReader; import io.usethesource.vallang.type.Type; import io.usethesource.vallang.type.TypeFactory; import io.usethesource.vallang.type.TypeStore; @@ -83,6 +87,8 @@ public class RascalLanguageServices { private final CompletableFuture semanticEvaluator; private final CompletableFuture compilerEvaluator; + private final CompletableFuture actionStore; + private final TypeFactory tf = TypeFactory.getInstance(); private final TypeStore store = new TypeStore(); private final Type getPathConfigType = tf.functionType(tf.abstractDataType(store, "PathConfig"), tf.tupleType(tf.sourceLocationType()), tf.tupleEmpty()); @@ -98,8 +104,9 @@ public RascalLanguageServices(RascalTextDocumentService docService, BaseWorkspac var monitor = new RascalLSPMonitor(client, logger); documentSymbolEvaluator = makeFutureEvaluator(exec, docService, workspaceService, client, "Rascal document symbols", monitor, null, false, "lang::rascal::lsp::DocumentSymbols"); - semanticEvaluator = makeFutureEvaluator(exec, docService, workspaceService, client, "Rascal semantics", monitor, null, true, "lang::rascalcore::check::Summary", "lang::rascal::lsp::refactor::Rename"); + semanticEvaluator = makeFutureEvaluator(exec, docService, workspaceService, client, "Rascal semantics", monitor, null, true, "lang::rascalcore::check::Summary", "lang::rascal::lsp::refactor::Rename", "lang::rascal::lsp::Actions"); compilerEvaluator = makeFutureEvaluator(exec, docService, workspaceService, client, "Rascal compiler", monitor, null, true, "lang::rascalcore::check::Checker"); + actionStore = semanticEvaluator.thenApply(e -> ((ModuleEnvironment) e.getModule("lang::rascal::lsp::Actions")).getStore()); } public InterruptibleFuture<@Nullable IConstructor> getSummary(ISourceLocation occ, PathConfig pcfg) { @@ -116,6 +123,8 @@ public RascalLanguageServices(RascalTextDocumentService docService, BaseWorkspac } } + + private static IConstructor addResources(PathConfig pcfg) { var result = pcfg.asConstructor(); return result.asWithKeywordParameters() @@ -250,6 +259,51 @@ public List locateCodeLenses(ITree tree) { return result; } + public CompletableFuture parseCodeActions(String command) { + return actionStore.thenApply(commandStore -> { + try { + var TF = TypeFactory.getInstance(); + return (IList) new StandardTextReader().read(VF, commandStore, TF.listType(commandStore.lookupAbstractDataType("CodeAction")), new StringReader(command)); + } catch (FactTypeUseException | IOException e) { + // this should never happen as long as the Rascal code + // for creating errors is type-correct. So it _might_ happen + // when running the interpreter on broken code. + throw new IllegalArgumentException("The command could not be parsed", e); + } + }); + } + + public InterruptibleFuture executeCommand(String command) { + logger.debug("executeCommand({}...) (full command value in TRACE level)", () -> command.substring(0, Math.min(10, command.length()))); + logger.trace("Full command: {}", command); + var defaultMap = VF.mapWriter(); + defaultMap.put(VF.string("result"), VF.bool(false)); + + return InterruptibleFuture.flatten(parseCommand(command).thenApply(cons -> + EvaluatorUtil.runEvaluator( + "executeCommand", + semanticEvaluator, + ev -> ev.call("evaluateRascalCommand", cons), + defaultMap.done(), + exec, + true, + client + ) + ), exec); + } + + private CompletableFuture parseCommand(String command) { + return actionStore.thenApply(commandStore -> { + try { + return (IConstructor) new StandardTextReader().read(VF, commandStore, commandStore.lookupAbstractDataType("Command"), new StringReader(command)); + } + catch (FactTypeUseException | IOException e) { + logger.catching(e); + throw new IllegalArgumentException("The command could not be parsed", e); + } + }); + } + public static final class CodeLensSuggestion { private final ISourceLocation line; private final String commandName; @@ -268,7 +322,6 @@ public List getArguments() { return arguments; } - public ISourceLocation getLine() { return line; } @@ -281,6 +334,13 @@ public String getCommandName() { public String getShortName() { return shortName; } + } + public InterruptibleFuture codeActions(IList focus, PathConfig pcfg) { + return runEvaluator("Rascal codeActions", semanticEvaluator, eval -> { + Map kws = Map.of("pcfg", pcfg.asConstructor()); + return (IList) eval.call("rascalCodeActions", "lang::rascal::lsp::Actions", kws, focus); + }, + VF.list(), exec, false, client); } } diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalTextDocumentService.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalTextDocumentService.java index 6de9d8b44..8a33294fa 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalTextDocumentService.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalTextDocumentService.java @@ -37,11 +37,16 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.CodeActionParams; import org.eclipse.lsp4j.CodeLens; import org.eclipse.lsp4j.CodeLensOptions; import org.eclipse.lsp4j.CodeLensParams; @@ -55,6 +60,7 @@ import org.eclipse.lsp4j.DidSaveTextDocumentParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.ExecuteCommandOptions; import org.eclipse.lsp4j.FoldingRange; import org.eclipse.lsp4j.FoldingRangeRequestParams; import org.eclipse.lsp4j.Hover; @@ -84,8 +90,10 @@ import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; import org.rascalmpl.library.Prelude; +import org.rascalmpl.library.util.PathConfig; import org.rascalmpl.parser.gtd.exception.ParseError; import org.rascalmpl.uri.URIResolverRegistry; +import org.rascalmpl.values.IRascalValueFactory; import org.rascalmpl.values.parsetrees.ITree; import org.rascalmpl.vscode.lsp.BaseWorkspaceService; import org.rascalmpl.vscode.lsp.IBaseLanguageClient; @@ -95,6 +103,7 @@ import org.rascalmpl.vscode.lsp.rascal.model.FileFacts; import org.rascalmpl.vscode.lsp.rascal.model.SummaryBridge; import org.rascalmpl.vscode.lsp.terminal.ITerminalIDEServer.LanguageParameter; +import org.rascalmpl.vscode.lsp.util.CodeActions; import org.rascalmpl.vscode.lsp.util.Diagnostics; import org.rascalmpl.vscode.lsp.util.DocumentChanges; import org.rascalmpl.vscode.lsp.util.FoldingRanges; @@ -104,7 +113,9 @@ import org.rascalmpl.vscode.lsp.util.locations.ColumnMaps; import org.rascalmpl.vscode.lsp.util.locations.LineColumnOffsetMap; import org.rascalmpl.vscode.lsp.util.locations.Locations; +import org.rascalmpl.vscode.lsp.util.locations.impl.TreeSearch; +import io.usethesource.vallang.IList; import io.usethesource.vallang.ISourceLocation; import io.usethesource.vallang.IValue; @@ -157,6 +168,8 @@ public void initializeServerCapabilities(ServerCapabilities result) { result.setCodeLensProvider(new CodeLensOptions(false)); result.setFoldingRangeProvider(true); result.setRenameProvider(true); + result.setCodeActionProvider(true); + result.setExecuteCommandProvider(new ExecuteCommandOptions(Collections.singletonList(BaseWorkspaceService.RASCAL_COMMAND))); } @Override @@ -415,6 +428,50 @@ public CompletableFuture> codeLens(CodeLensParams param ; } + @Override + public CompletableFuture>> codeAction(CodeActionParams params) { + logger.debug("codeAction: {}", params); + + final var loc = Locations.toLoc(params.getTextDocument()); + final var start = params.getRange().getStart(); + // convert to Rascal 1-based line + final var startLine = start.getLine() + 1; + // convert to Rascal UTF-32 column width + final var startColumn = columns.get(loc).translateInverseColumn(start.getLine(), start.getCharacter(), false); + + // first we make a future stream for filtering out the "fixes" that were optionally sent along with earlier diagnostics + // and which came back with the codeAction's list of relevant (in scope) diagnostics: + // CompletableFuture> + CompletableFuture> quickfixes + = CodeActions.extractActionsFromDiagnostics(params, rascalServices::parseCodeActions); + + // here we dynamically ask the contributions for more actions, + // based on the cursor position in the file and the current parse tree + CompletableFuture> codeActions = recoverExceptions( + getFile(params.getTextDocument()) + .getCurrentTreeAsync() + .thenApply(Versioned::get) + .thenCompose((ITree tree) -> computeCodeActions(startLine, startColumn, tree, facts.getPathConfig(loc))) + .thenApply(IList::stream) + , () -> Stream.empty()) + ; + + // final merging the two streams of commmands, and their conversion to LSP Command data-type + return CodeActions.mergeAndConvertCodeActions(this, "", BaseWorkspaceService.RASCAL_LANGUAGE, quickfixes, codeActions); + } + + private CompletableFuture computeCodeActions(final int startLine, final int startColumn, ITree tree, PathConfig pcfg) { + IList focus = TreeSearch.computeFocusList(tree, startLine, startColumn); + + if (!focus.isEmpty()) { + return rascalServices.codeActions(focus, pcfg).get(); + } + else { + logger.log(Level.DEBUG, "no tree focus found at {}:{}", startLine, startColumn); + return CompletableFuture.completedFuture(IRascalValueFactory.getInstance().list()); + } + } + private CodeLens makeRunCodeLens(CodeLensSuggestion detected) { return new CodeLens( Locations.toRange(detected.getLine(), columns), @@ -425,8 +482,14 @@ private CodeLens makeRunCodeLens(CodeLensSuggestion detected) { @Override public CompletableFuture executeCommand(String extension, String command) { - // there is currently no way the Rascal LSP can receive this, but the Rascal DSL LSP does. - logger.warn("ignoring execute command in Rascal LSP: {}, {}", extension, command); - return CompletableFuture.completedFuture(null); + return rascalServices.executeCommand(command).get(); + } + + private static CompletableFuture recoverExceptions(CompletableFuture future, Supplier defaultValue) { + return future + .exceptionally(e -> { + logger.error("Operation failed with", e); + return defaultValue.get(); + }); } } diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/CodeActions.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/CodeActions.java new file mode 100644 index 000000000..b0dd9a9a5 --- /dev/null +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/CodeActions.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2018-2023, NWO-I CWI and Swat.engineering + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package org.rascalmpl.vscode.lsp.util; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.checkerframework.checker.nullness.qual.Nullable; +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.WorkspaceEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.rascalmpl.values.IRascalValueFactory; +import org.rascalmpl.vscode.lsp.BaseWorkspaceService; +import org.rascalmpl.vscode.lsp.IBaseTextDocumentService; +import org.rascalmpl.vscode.lsp.parametric.model.RascalADTs; + +import com.google.gson.JsonPrimitive; + +import io.usethesource.vallang.IConstructor; +import io.usethesource.vallang.IList; +import io.usethesource.vallang.IString; +import io.usethesource.vallang.IValue; +import io.usethesource.vallang.IWithKeywordParameters; + +/** + * Reusable utilities for code actions and commands (maps between Rascal and LSP world) + */ +public class CodeActions { + /** + * Makes a future stream for filtering out the "fixes" that were optionally sent along with earlier diagnostics + * and which came back with the codeAction's list of relevant (in scope) diagnostics. + * + * @param params diagnostics directly from the client (holding embedded action terms) + * @param actionParser provides the parser with a scope that imports the right definitions of Command terms. + * @return a future stream of parsed and type-checked Rascal CodeAction terms. + */ + public static CompletableFuture> extractActionsFromDiagnostics(CodeActionParams params, Function> actionParser) { + final var emptyListFuture = CompletableFuture.completedFuture(IRascalValueFactory.getInstance().list()); + + return params.getContext().getDiagnostics() + .stream() + .map(Diagnostic::getData) + .filter(Objects::nonNull) + .filter(JsonPrimitive.class::isInstance) + .map(JsonPrimitive.class::cast) + .map(JsonPrimitive::getAsString) + // this is the "magic" resurrection of command terms from the JSON data field + .map(actionParser) + // this serializes the stream of futures and accumulates their results as a flat list again + .reduce(emptyListFuture, (acc, next) -> acc.thenCombine(next, IList::concat)) + .thenApply(IList::stream); + } + + /* merges two streams of CodeAction terms and then converts them to LSP objects */ + public static CompletableFuture>> mergeAndConvertCodeActions(IBaseTextDocumentService doc, String dedicatedLanguageName, String languageName, CompletableFuture> quickfixes, CompletableFuture> codeActions) { + return codeActions.thenCombine(quickfixes, (actions, quicks) -> + Stream.concat(quicks, actions) + .map(IConstructor.class::cast) + .map(cons -> constructorToCodeAction(doc, dedicatedLanguageName, languageName, cons)) + .map(cmd -> Either.forRight(cmd)) + .collect(Collectors.toList()) + ); + } + + private static CodeAction constructorToCodeAction(IBaseTextDocumentService doc, String dedicatedLanguageName, String languageName, IConstructor codeAction) { + IWithKeywordParameters kw = codeAction.asWithKeywordParameters(); + IConstructor command = (IConstructor) kw.getParameter(RascalADTs.CodeActionFields.COMMAND); + IString title = (IString) kw.getParameter(RascalADTs.CodeActionFields.TITLE); + IList edits = (IList) kw.getParameter(RascalADTs.CodeActionFields.EDITS); + IConstructor kind = (IConstructor) kw.getParameter(RascalADTs.CodeActionFields.KIND); + + // first deal with the defaults. Must mimick what's in util::LanguageServer with the `data CodeAction` declaration + if (title == null) { + if (command != null) { + title = (IString) command.asWithKeywordParameters().getParameter(RascalADTs.CommandFields.TITLE); + } + + if (title == null) { + title = IRascalValueFactory.getInstance().string(""); + } + } + + CodeAction result = new CodeAction(title.getValue()); + + if (command != null) { + result.setCommand(constructorToCommand(dedicatedLanguageName, languageName, command)); + } + + if (edits != null) { + result.setEdit(new WorkspaceEdit(DocumentChanges.translateDocumentChanges(doc, edits))); + } + + result.setKind(constructorToCodeActionKind(kind)); + + return result; + } + + /** + * Translates `refactor(inline())` to `"refactor.inline"` and `empty()` to `""`, etc. + * `kind == null` signals absence of the optional parameter. This is factorede into + * this private function because otherwise every call has to check it. + */ + private static String constructorToCodeActionKind(@Nullable IConstructor kind) { + if (kind == null) { + return CodeActionKind.QuickFix; + } + + String name = kind.getName(); + + if (name.isEmpty()) { + return ""; + } + else if (name.length() == 1) { + return name.toUpperCase(); + } + else if ("empty".equals(name)) { + return ""; + } + else { + var kw = kind.asWithKeywordParameters(); + for (String kwn : kw.getParameterNames()) { + String nestedName = constructorToCodeActionKind((IConstructor) kw.getParameter(kwn)); + name = name + (nestedName.isEmpty() ? "" : ("." + nestedName)); + } + } + + return name; + } + + public static Command constructorToCommand(String dedicatedLanguageName, String languageName, IConstructor command) { + IWithKeywordParameters kw = command.asWithKeywordParameters(); + IString possibleTitle = (IString) kw.getParameter(RascalADTs.CommandFields.TITLE); + + return new Command(possibleTitle != null ? possibleTitle.getValue() : command.toString(), getRascalMetaCommandName(languageName, dedicatedLanguageName), Arrays.asList(languageName, command.toString())); + } + + public static String getRascalMetaCommandName(String language, String dedicatedLanguageName) { + // if we run in dedicated mode, we prefix the commands with our language name + // to avoid ambiguity with other dedicated languages and the generic rascal plugin + if (!dedicatedLanguageName.isEmpty()) { + return BaseWorkspaceService.RASCAL_META_COMMAND + "-" + dedicatedLanguageName; + } + else if (BaseWorkspaceService.RASCAL_LANGUAGE.equals(language)) { + return BaseWorkspaceService.RASCAL_COMMAND; + } + else { + return BaseWorkspaceService.RASCAL_META_COMMAND; + } + } +} diff --git a/rascal-lsp/src/main/rascal/lang/rascal/lsp/Actions.rsc b/rascal-lsp/src/main/rascal/lang/rascal/lsp/Actions.rsc new file mode 100644 index 000000000..0c07d6d8f --- /dev/null +++ b/rascal-lsp/src/main/rascal/lang/rascal/lsp/Actions.rsc @@ -0,0 +1,156 @@ +@license{ +Copyright (c) 2018-2023, NWO-I CWI and Swat.engineering +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +} +@bootstrapParser +@synopsis{Defines both the command evaluator and the codeAction retriever for Rascal} +module lang::rascal::lsp::Actions + +import lang::rascal::\syntax::Rascal; +import util::LanguageServer; +import analysis::diff::edits::TextEdits; +import ParseTree; +import String; +import lang::rascal::vis::ImportGraph; +import util::Reflective; +import util::IDEServices; +import List; +import IO; + +@synopsis{Here we list Rascal-specific code commands} +@description{ +The commands must be evaluated by ((evaluateRascalCommand)) +} +data Command + = visualImportGraphCommand(PathConfig pcfg) + | sortImportsAndExtends(Header h) + ; + +@synopsis{Detects (on-demand) source actions to register with specific places near the current cursor} +list[CodeAction] rascalCodeActions(Focus focus, PathConfig pcfg=pathConfig()) { + result = []; + + if ([*_, QualifiedName _, *_, Header _, *_, start[Module] top] := focus) { + result += addLicenseAction(top, pcfg); + } + + if ([*_, Toplevel t, *_] := focus) { + result += toplevelCodeActions(t); + } + + if ([*_, Header h, *_] := focus) { + result += [action(command=visualImportGraphCommand(pcfg), title="Visualize project import graph")] + + [action(command=sortImportsAndExtends(h), title="Sort imports and extends")] + ; + } + + return result; +} + +@synopsis{Add a license header if there isn't one.} +list[CodeAction] addLicenseAction(start[Module] \module, PathConfig pcfg) { + Tags tags = \module.top.header.tags; + + if ((Tags) ` @license ` !:= tags) { + license = findLicense(pcfg); + if (license != "") { + license = "@license{ + ' + '}\n"; + return [action(edits=[makeLicenseEdit(\module@\loc, license)], title="Add missing license header")]; + } + } + + return []; +} + +private str findLicense(PathConfig pcfg) { + for (loc src <- pcfg.srcs) { + while (!exists(src + "pom.xml") && src.path != "" && src.path != "/") { + src = src.parent; + } + + if (exists(src + "LICENSE")) { + return trim(readFile(src + "LICENSE")); + } + else if (exists(src + "LICENSE.md")) { + return trim(readFile(src + "LICENSE.md")); + } + else if (exists(src + "LICENSE.txt")) { + return trim(readFile(src + "LICENSE.txt")); + } + } + + return ""; +} + +private DocumentEdit makeLicenseEdit(loc \module, str license) + = changed(\module.top, [replace(\module.top(0, 0), license)]); + +@synopsis{Rewrite immediate return to expression.} +list[CodeAction] toplevelCodeActions(Toplevel t: + (Toplevel) ` + ' { + ' return ; + '}`) { + + result = (Toplevel) ` + ' = ;`; + + edits=[changed(t@\loc.top, [replace(t@\loc, trim(""))])]; + + return [action(edits=edits, title="Rewrite block return to simpler rewrite rule.", kind=refactor())]; +} + +default list[CodeAction] toplevelCodeActions(Toplevel _) = []; + +@synopsis{Evaluates all commands and quickfixes produced by ((rascalCodeActions)) and the type-checker} +default value evaluateRascalCommand(Command _) = ("result" : false); + +value evaluateRascalCommand(visualImportGraphCommand(PathConfig pcfg)) { + importGraph(pcfg); + return ("result" : true); +} + +value evaluateRascalCommand(sortImportsAndExtends(Header h)) { + extends = [trim("") | i <- h.imports, i is \extend]; + imports = [trim("") | i <- h.imports, i is \default]; + grammar = [trim("") | i <- h.imports, i is \syntax]; + + newHeader = " + '<}>"[..-1] + + " + ' + '<}> + '<}>"[..-1] + + " + ' + '<}> + ' + '<}>"[..-2]; + + applyDocumentsEdits([changed(h@\loc.top, [replace(h.imports@\loc, newHeader)])]); + return ("result":true); +} diff --git a/rascal-vscode-extension/src/test/vscode-suite/dsl.test.ts b/rascal-vscode-extension/src/test/vscode-suite/dsl.test.ts index 32abc9c21..bed1a5725 100644 --- a/rascal-vscode-extension/src/test/vscode-suite/dsl.test.ts +++ b/rascal-vscode-extension/src/test/vscode-suite/dsl.test.ts @@ -153,18 +153,11 @@ describe('DSL', function () { await driver.wait(async ()=> (await editor.getCoordinates())[0] === 3, Delays.slow, "Cursor should have moved to line 3"); }); - function assertLineBecomes(editor: TextEditor, lineNumber: number, lineContents: string, msg: string, wait = Delays.verySlow) : Promise { - return driver.wait(async () => { - const currentContent = (await editor.getTextAtLine(lineNumber)).trim(); - return currentContent === lineContents; - }, wait, msg, 100); - } - it("code lens works", async () => { const editor = await ide.openModule(TestWorkspace.picoFile); const lens = await driver.wait(() => editor.getCodeLens("Rename variables a to b."), Delays.verySlow, "Rename lens should be available"); await lens!.click(); - await assertLineBecomes(editor, 9, "b := 2;", "a variable should be changed to b"); + await ide.assertLineBecomes(editor, 9, "b := 2;", "a variable should be changed to b"); }); it("quick fix works", async() => { @@ -173,17 +166,22 @@ describe('DSL', function () { await editor.moveCursor(9,3); // it's where the undeclared variable `az` is await ide.hasErrorSquiggly(editor, Delays.verySlow); // just make sure there is indeed something to fix - const inputarea = await editor.findElement(By.className('inputarea')); - await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, ".")); + try { + const inputarea = await editor.findElement(By.className('inputarea')); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, ".")); - // finds an open menu with the right item in it (Change to a) and then select - // the parent that handles UI events like click() and sendKeys() - const menuContainer = await ide.hasElement(editor, By.xpath("//div[contains(@class, 'focused') and contains(@class, 'action')]/span[contains(text(), 'Change to a')]//ancestor::*[contains(@class, 'monaco-list')]"), Delays.normal, "The Change to a option should be available and focussed by default"); + // finds an open menu with the right item in it (Change to a) and then select + // the parent that handles UI events like click() and sendKeys() + const menuContainer = await ide.hasElement(editor, By.xpath("//div[contains(@class, 'focused') and contains(@class, 'action')]/span[contains(text(), 'Change to a')]//ancestor::*[contains(@class, 'monaco-list')]"), Delays.normal, "The Change to a option should be available and focussed by default"); - // menu container works a bit strangely, it ask the focus to keep track of it, - // and manages clicks and menus on the highest level (not per item). - await menuContainer.sendKeys(Key.RETURN); - await assertLineBecomes(editor, 9, "a := 2;", "a variable should be changed back to a", Delays.extremelySlow); + // menu container works a bit strangely, it ask the focus to keep track of it, + // and manages clicks and menus on the highest level (not per item). + await menuContainer.sendKeys(Key.RETURN); + await ide.assertLineBecomes(editor, 9, "a := 2;", "a variable should be changed back to a", Delays.extremelySlow); + } + finally { + await ide.revertOpenChanges(); + } }); }); diff --git a/rascal-vscode-extension/src/test/vscode-suite/ide.test.ts b/rascal-vscode-extension/src/test/vscode-suite/ide.test.ts index 08ceef2e9..936ca45c6 100644 --- a/rascal-vscode-extension/src/test/vscode-suite/ide.test.ts +++ b/rascal-vscode-extension/src/test/vscode-suite/ide.test.ts @@ -187,5 +187,28 @@ describe('IDE', function () { expect(editorText).to.contain("i - 1"); expect(editorText).to.contain("i -2"); }); + + it("code actions work", async() => { + const editor = await ide.openModule(TestWorkspace.libCallFile); + await editor.moveCursor(1,8); // in the module name + + try { + // trigger the code actions + const inputarea = await editor.findElement(By.className('inputarea')); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, ".")); + + // finds an open menu with the right item in it (Change to a) and then select + // the parent that handles UI events like click() and sendKeys() + const menuContainer = await ide.hasElement(editor, By.xpath("//div[contains(@class, 'focused') and contains(@class, 'action')]/span[contains(text(), 'Add missing license header')]//ancestor::*[contains(@class, 'monaco-list')]"), Delays.normal, "Add-license action should be available and focused"); + + // menu container works a bit strangely, it ask the focus to keep track of it, + // and manages clicks and menus on the highest level (not per item). + await menuContainer.sendKeys(Key.RETURN); + await ide.assertLineBecomes(editor, 1, "@license{", "license header should have been added", Delays.extremelySlow); + } + finally { + await ide.revertOpenChanges(); + } + }) }); diff --git a/rascal-vscode-extension/src/test/vscode-suite/utils.ts b/rascal-vscode-extension/src/test/vscode-suite/utils.ts index 9ec8a51bd..2422739a7 100644 --- a/rascal-vscode-extension/src/test/vscode-suite/utils.ts +++ b/rascal-vscode-extension/src/test/vscode-suite/utils.ts @@ -56,6 +56,7 @@ export class TestWorkspace { public static readonly mainFile = path.join(src(this.testProject), 'Main.rsc'); public static readonly mainFileTpl = path.join(target(this.testProject),'$Main.tpl'); public static readonly libCallFile = path.join(src(this.testProject), 'LibCall.rsc'); + public static readonly licenseFile = path.join(this.testProject, 'LICENSE'); public static readonly libCallFileTpl = path.join(target(this.testProject),'$LibCall.tpl'); public static readonly libFile = path.join(src(this.libProject), 'Lib.rsc'); public static readonly libFileTpl = path.join(target(this.libProject),'$Lib.tpl'); @@ -214,7 +215,12 @@ export class IDEOperations { await ignoreFails(center?.close()); } - + assertLineBecomes(editor: TextEditor, lineNumber: number, lineContents: string, msg: string, wait = Delays.verySlow) : Promise { + return this.driver.wait(async () => { + const currentContent = (await editor.getTextAtLine(lineNumber)).trim(); + return currentContent === lineContents; + }, wait, msg, 100); + } hasElement(editor: TextEditor, selector: Locator, timeout: number, message: string): Promise { return this.driver.wait(scopedElementLocated(editor, selector), timeout, message, 50); diff --git a/rascal-vscode-extension/test-workspace/test-project/LICENSE b/rascal-vscode-extension/test-workspace/test-project/LICENSE new file mode 100644 index 000000000..eb1ffea66 --- /dev/null +++ b/rascal-vscode-extension/test-workspace/test-project/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2018-2021, NWO-I CWI and Swat.engineering +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/rascal-vscode-extension/test-workspace/test-project/src/main/rascal/LibCall.rsc b/rascal-vscode-extension/test-workspace/test-project/src/main/rascal/LibCall.rsc index 03b61c70f..3384f82a4 100644 --- a/rascal-vscode-extension/test-workspace/test-project/src/main/rascal/LibCall.rsc +++ b/rascal-vscode-extension/test-workspace/test-project/src/main/rascal/LibCall.rsc @@ -1,7 +1,7 @@ module LibCall -import IO; import Lib; +import IO; int main() { println(fib(4));