diff --git a/rascal-lsp/pom.xml b/rascal-lsp/pom.xml index 34c3c2b4a..c3e8c119d 100644 --- a/rascal-lsp/pom.xml +++ b/rascal-lsp/pom.xml @@ -160,6 +160,12 @@ 3.5.5 + + + always + org.rascalmpl.vscode.lsp.log.LogRedirectConfiguration + + org.rascalmpl diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ILanguageContributions.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ILanguageContributions.java index 6dc401469..7df394fc4 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ILanguageContributions.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ILanguageContributions.java @@ -62,6 +62,7 @@ public interface ILanguageContributions { public InterruptibleFuture selectionRange(IList focus); public InterruptibleFuture prepareCallHierarchy(IList focus); public InterruptibleFuture incomingOutgoingCalls(IConstructor hierarchyItem, IConstructor direction); + public InterruptibleFuture formatting(IList input, IConstructor formattingOptions); public InterruptibleFuture prepareRename(IList focus); public InterruptibleFuture rename(IList focus, String name); @@ -88,6 +89,7 @@ public interface ILanguageContributions { public CompletableFuture providesSelectionRange(); public CompletableFuture providesCallHierarchy(); public CompletableFuture providesCompletion(); + public CompletableFuture providesFormatting(); public CompletableFuture specialCaseHighlighting(); diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/InterpretedLanguageContributions.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/InterpretedLanguageContributions.java index cba9a91f7..75d9cb788 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/InterpretedLanguageContributions.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/InterpretedLanguageContributions.java @@ -99,6 +99,8 @@ public class InterpretedLanguageContributions implements ILanguageContributions private final CompletableFuture<@Nullable IFunction> didRenameFiles; private final CompletableFuture<@Nullable IFunction> selectionRange; private final CompletableFuture<@Nullable IFunction> prepareCallHierarchy; + private final CompletableFuture<@Nullable IFunction> formatting; + private final CompletableFuture<@Nullable IFunction> callHierarchyService; private final CompletableFuture<@Nullable IFunction> completion; private final CompletableFuture completionTriggerCharacters; @@ -119,6 +121,7 @@ public class InterpretedLanguageContributions implements ILanguageContributions private final CompletableFuture providesSelectionRange; private final CompletableFuture providesCallHierarchy; private final CompletableFuture providesCompletion; + private final CompletableFuture providesFormatting; private final CompletableFuture specialCaseHighlighting; @@ -169,6 +172,7 @@ public InterpretedLanguageContributions(LanguageParameter lang, IBaseTextDocumen this.callHierarchyService = getFunctionFor(contributions, LanguageContributions.CALL_HIERARCHY, 1); this.completion = getFunctionFor(contributions, LanguageContributions.COMPLETION); this.completionTriggerCharacters = getContributionParameter(contributions, LanguageContributions.COMPLETION, LanguageContributions.COMPLETION_TRIGGER_CHARACTERS, VF.list(), IList.class); + this.formatting = getFunctionFor(contributions, LanguageContributions.FORMATTING); // assign boolean properties once instead of wasting futures all the time this.providesAnalysis = nonNull(this.analysis); @@ -187,6 +191,7 @@ public InterpretedLanguageContributions(LanguageParameter lang, IBaseTextDocumen this.providesSelectionRange = nonNull(this.selectionRange); this.providesCallHierarchy = nonNull(this.prepareCallHierarchy); this.providesCompletion = nonNull(this.completion); + this.providesFormatting = nonNull(this.formatting); this.specialCaseHighlighting = getContributionParameter(contributions, LanguageContributions.PARSING, @@ -486,6 +491,12 @@ public CompletableFuture completionTriggerCharacters() { return completionTriggerCharacters; } + @Override + public InterruptibleFuture formatting(IList focus, IConstructor formattingOptions) { + debug(LanguageContributions.FORMATTING, focus.size(), formattingOptions); + return execFunction(LanguageContributions.FORMATTING, formatting, VF.list(), focus, formattingOptions); + } + private void debug(String name, Object param) { logger.debug("{}({})", name, param); } @@ -563,6 +574,11 @@ public CompletableFuture providesCompletion() { return providesCompletion; } + @Override + public CompletableFuture providesFormatting() { + return providesFormatting; + } + @Override public CompletableFuture providesAnalysis() { return providesAnalysis; diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/LanguageContributionsMultiplexer.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/LanguageContributionsMultiplexer.java index 62ce162f5..4aefb9652 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/LanguageContributionsMultiplexer.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/LanguageContributionsMultiplexer.java @@ -77,6 +77,7 @@ private static final CompletableFuture failedInitialization() { private volatile CompletableFuture selectionRange = failedInitialization(); private volatile CompletableFuture callHierarchy = failedInitialization(); private volatile CompletableFuture completion = failedInitialization(); + private volatile CompletableFuture formatting = failedInitialization(); private volatile CompletableFuture providesAnalysis = failedInitialization(); private volatile CompletableFuture providesBuild = failedInitialization(); @@ -94,6 +95,7 @@ private static final CompletableFuture failedInitialization() { private volatile CompletableFuture providesSelectionRange = failedInitialization(); private volatile CompletableFuture providesCallHierarchy = failedInitialization(); private volatile CompletableFuture providesCompletion = failedInitialization(); + private volatile CompletableFuture providesFormatting = failedInitialization(); private volatile CompletableFuture specialCaseHighlighting = failedInitialization(); @@ -172,6 +174,7 @@ private synchronized void calculateRouting() { selectionRange = findFirstOrDefault(ILanguageContributions::providesSelectionRange, "selectionRange"); callHierarchy = findFirstOrDefault(ILanguageContributions::providesCallHierarchy, "callHierarchy"); completion = findFirstOrDefault(ILanguageContributions::providesCompletion, "completion"); + formatting = findFirstOrDefault(ILanguageContributions::providesFormatting, "formatting"); providesAnalysis = anyTrue(ILanguageContributions::providesAnalysis); providesBuild = anyTrue(ILanguageContributions::providesBuild); @@ -189,6 +192,7 @@ private synchronized void calculateRouting() { providesSelectionRange = anyTrue(ILanguageContributions::providesSelectionRange); providesCallHierarchy = anyTrue(ILanguageContributions::providesCallHierarchy); providesCompletion = anyTrue(ILanguageContributions::providesCompletion); + providesFormatting = anyTrue(ILanguageContributions::providesFormatting); // Always use the special-case highlighting status of *the first* // contribution (possibly using the default value in the Rascal ADT if @@ -350,6 +354,11 @@ public InterruptibleFuture selectionRange(IList focus) { return flatten(selectionRange, c -> c.selectionRange(focus)); } + @Override + public InterruptibleFuture formatting(IList focus, IConstructor formattingOptions) { + return flatten(formatting, c -> c.formatting(focus, formattingOptions)); + } + @Override public InterruptibleFuture completion(IList focus, IInteger cursorOffset, IConstructor trigger) { return flatten(completion, c -> c.completion(focus, cursorOffset, trigger)); @@ -449,6 +458,11 @@ public CompletableFuture providesCompletion() { return providesCompletion; } + @Override + public CompletableFuture providesFormatting() { + return providesFormatting; + } + @Override public CompletableFuture specialCaseHighlighting() { return specialCaseHighlighting; diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/NoContributions.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/NoContributions.java index b30c31360..26405876a 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/NoContributions.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/NoContributions.java @@ -199,6 +199,11 @@ public CompletableFuture completionTriggerCharacters() { return completable(VF.list()); } + @Override + public InterruptibleFuture formatting(IList input, IConstructor formattingOptions) { + return interruptible(VF.list()); + } + @Override public CompletableFuture providesAnalysis() { return falsy; @@ -279,6 +284,11 @@ public CompletableFuture providesCompletion() { return falsy; } + @Override + public CompletableFuture providesFormatting() { + return falsy; + } + @Override public CompletableFuture specialCaseHighlighting() { return falsy; 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 c024c43ee..48eecb968 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 @@ -78,6 +78,8 @@ import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.DocumentFormattingParams; +import org.eclipse.lsp4j.DocumentRangeFormattingParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; import org.eclipse.lsp4j.ExecuteCommandOptions; @@ -85,6 +87,7 @@ import org.eclipse.lsp4j.FileRename; import org.eclipse.lsp4j.FoldingRange; import org.eclipse.lsp4j.FoldingRangeRequestParams; +import org.eclipse.lsp4j.FormattingOptions; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.ImplementationParams; @@ -117,6 +120,7 @@ import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextDocumentItem; import org.eclipse.lsp4j.TextDocumentSyncKind; +import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; import org.eclipse.lsp4j.WorkspaceEdit; import org.eclipse.lsp4j.WorkspaceFolder; @@ -127,6 +131,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; +import org.eclipse.lsp4j.util.Ranges; import org.rascalmpl.uri.URIResolverRegistry; import org.rascalmpl.uri.URIUtil; import org.rascalmpl.util.locations.ColumnMaps; @@ -279,6 +284,8 @@ public void initializeServerCapabilities(ClientCapabilities clientCapabilities, result.setCodeLensProvider(new CodeLensOptions(false)); result.setRenameProvider(new RenameOptions(true)); result.setExecuteCommandProvider(new ExecuteCommandOptions(Collections.singletonList(getRascalMetaCommandName()))); + result.setDocumentFormattingProvider(true); + result.setDocumentRangeFormattingProvider(true); result.setInlayHintProvider(true); result.setSelectionRangeProvider(true); result.setFoldingRangeProvider(true); @@ -802,6 +809,66 @@ public CompletableFuture>> codeAction(CodeActio return CodeActions.mergeAndConvertCodeActions(this, dedicatedLanguageName, contribs.getName(), quickfixes, codeActions); } + @Override + public CompletableFuture> formatting(DocumentFormattingParams params) { + logger.debug("Formatting: {}", params); + + var uri = Locations.toLoc(params.getTextDocument()); + final ILanguageContributions contribs = contributions(uri); + + // call the `formatting` implementation of the relevant language contribution + return getFile(uri) + .getCurrentTreeAsync(true) + .thenApply(Versioned::get) + .thenCompose(tree -> { + final var opts = getFormattingOptions(params.getOptions()); + return contribs.formatting(VF.list(tree), opts).get(); + }) + .thenApply(l -> DocumentChanges.translateTextEdits(l, columns)); + } + + @Override + public CompletableFuture> rangeFormatting(DocumentRangeFormattingParams params) { + logger.debug("Formatting range: {}", params); + + var uri = Locations.toLoc(params.getTextDocument()); + Range range = params.getRange(); + final ILanguageContributions contribs = contributions(uri); + + // call the `formatting` implementation of the relevant language contribution + var fileState = getFile(uri); + return fileState + .getCurrentTreeAsync(true) + .thenApply(Versioned::get) + .thenCompose(tree -> { + // just a range + var r = Locations.setRange(uri, range, columns); + // compute the focus list at the end of the range + var focus = TreeSearch.computeFocusList(tree, r.getBeginLine(), r.getBeginColumn(), r.getEndLine(), r.getEndColumn()); + + var opts = getFormattingOptions(params.getOptions()); + return contribs.formatting(focus, opts).get(); + }) + // convert the document changes + .thenApply(l -> DocumentChanges.translateTextEdits(l, columns) + .stream() + .filter(e -> Ranges.containsRange(range, e.getRange())) + .collect(Collectors.toList())); + } + + private IConstructor getFormattingOptions(FormattingOptions options) { + var optionsType = tf.abstractDataType(typeStore, "FormattingOptions"); + var consType = tf.constructor(typeStore, optionsType, "formattingOptions"); + var opts = Map.of( + "tabSize", VF.integer(options.getTabSize()), + "insertSpaces", VF.bool(options.isInsertSpaces()), + "trimTrailingWhitespace", VF.bool(options.isTrimTrailingWhitespace()), + "insertFinalNewline", VF.bool(options.isInsertFinalNewline()), + "trimFinalNewlines", VF.bool(options.isTrimFinalNewlines()) + ); + return VF.constructor(consType, new IValue[0], opts); + } + private CompletableFuture computeCodeActions(final ILanguageContributions contribs, final int startLine, final int startColumn, ITree tree) { IList focus = TreeSearch.computeFocusList(tree, startLine, startColumn); diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/model/RascalADTs.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/model/RascalADTs.java index 84c559766..e54ec9af7 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/model/RascalADTs.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/model/RascalADTs.java @@ -51,6 +51,7 @@ private LanguageContributions () {} public static final String CALL_HIERARCHY = "callHierarchy"; public static final String COMPLETION = "completion"; public static final String COMPLETION_TRIGGER_CHARACTERS = "additionalTriggerCharacters"; + public static final String FORMATTING = "formatting"; public static final String RENAME_SERVICE = "renameService"; public static final String PREPARE_RENAME_SERVICE = "prepareRenameService"; diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/locations/impl/TreeSearch.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/locations/impl/TreeSearch.java index 4dc2e594f..f2b882d9e 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/locations/impl/TreeSearch.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/locations/impl/TreeSearch.java @@ -26,6 +26,8 @@ */ package org.rascalmpl.vscode.lsp.util.locations.impl; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.rascalmpl.values.IRascalValueFactory; import org.rascalmpl.values.parsetrees.ITree; import org.rascalmpl.values.parsetrees.TreeAdapter; @@ -40,6 +42,9 @@ */ public class TreeSearch { + private static final Logger logger = LogManager.getLogger(TreeSearch.class); + private static final IRascalValueFactory VF = IRascalValueFactory.getInstance(); + private TreeSearch() {} /** @@ -85,8 +90,30 @@ else if (line == loc.getEndLine()) { return true; } + private static boolean rightOfBegin(ISourceLocation loc, int line, int column) { + if (!loc.hasLineColumn()) { + return false; + } + + if (line > loc.getBeginLine()) { + return true; + } + return line == loc.getBeginLine() && column > loc.getBeginColumn(); + } + + private static boolean rightOfEnd(ISourceLocation loc, int line, int column) { + if (!loc.hasLineColumn()) { + return false; + } + + if (line > loc.getEndLine()) { + return true; + } + return line == loc.getEndLine() && column > loc.getEndColumn(); + } + /** - * Produces a list of trees that are "in focus" at given line and column offset (UTF-24). + * Produces a list of trees that are "in focus" at given line and column offset (UTF-32). * * This log(filesize) algorithm quickly collects the trees along a spine from the * root to the largest lexical or, if that does not exist, the smallest context-free node. @@ -99,7 +126,7 @@ else if (line == loc.getEndLine()) { * @return list of tree that are around the given line/column position, ordered from child to parent. */ public static IList computeFocusList(ITree tree, int line, int column) { - var lw = IRascalValueFactory.getInstance().listWriter(); + var lw = VF.listWriter(); computeFocusList(lw, tree, line, column); return lw.done(); } @@ -157,4 +184,73 @@ private static boolean computeFocusList(IListWriter focus, ITree tree, int line, // cycles and characters do not have locations return false; } + + public static IList computeFocusList(ITree tree, int startLine, int startColumn, int endLine, int endColumn) { + // Compute the focus for both the start end end positions. + // These foci give us information about the structure of the selection. + final var startList = computeFocusList(tree, startLine, startColumn); + final var endList = computeFocusList(tree, endLine, endColumn); + + logger.trace("Focus at range start: {}", startList.length()); + logger.trace("Focus at range end: {}", endList.length()); + + final var commonSuffix = startList.intersect(endList); + + logger.trace("Common focus suffix length: {}", commonSuffix.length()); + // The range spans multiple subtrees. The easy way out is not to focus farther down than + // their smallest common subtree (i.e. `commonSuffix`) - let's see if we can do any better. + if (TreeAdapter.isList((ITree) commonSuffix.get(0))) { + logger.trace("Focus range spans a (partial) list: {}", TreeAdapter.getType((ITree) commonSuffix.get(0))); + return computeListRangeFocus(commonSuffix, startLine, startColumn, endLine, endColumn); + } + + return commonSuffix; + } + + private static IList computeListRangeFocus(final IList commonSuffix, int startLine, int startColumn, int endLine, int endColumn) { + final var parent = (ITree) commonSuffix.get(0); + logger.trace("Computing focus list for {} at range [{}:{}, {}:{}]", TreeAdapter.getType(parent), startLine, startColumn, endLine, endColumn); + final var elements = TreeAdapter.getArgs(parent); + final int nElements = elements.length(); + + logger.trace("Smallest common tree is a {} with {} elements", TreeAdapter.getType(parent), nElements); + if (inside(TreeAdapter.getLocation((ITree) elements.get(0)), startLine, startColumn) && + inside(TreeAdapter.getLocation((ITree) elements.get(nElements - 1)), endLine, endColumn)) { + // The whole list is selected + return commonSuffix; + } + + // Find the elements in the list that are (partially) selected. + final var selected = elements.stream() + .map(ITree.class::cast) + .dropWhile(t -> { + final var l = TreeAdapter.getLocation(t); + // only include layout if the element before it is selected as well + return TreeAdapter.isLayout(t) + ? rightOfBegin(l, startLine, startColumn) + : rightOfEnd(l, startLine, startColumn); + }) + .takeWhile(t -> { + final var l = TreeAdapter.getLocation(t); + // only include layout if the element after it is selected as well + return TreeAdapter.isLayout(t) + ? rightOfEnd(l, endLine, endColumn) + : rightOfBegin(l, endLine, endColumn); + }) + .collect(VF.listWriter()); + + final int nSelected = selected.length(); + + logger.trace("Range covers {} (of {}) elements in the parent list", nSelected, nElements); + final var firstSelected = TreeAdapter.getLocation((ITree) selected.get(0)); + final var lastSelected = TreeAdapter.getLocation((ITree) selected.get(nSelected - 1)); + + final int totalLength = lastSelected.getOffset() - firstSelected.getOffset() + lastSelected.getLength(); + final var selectionLoc = VF.sourceLocation(firstSelected, firstSelected.getOffset(), totalLength, + firstSelected.getBeginLine(), lastSelected.getEndLine(), firstSelected.getBeginColumn(), lastSelected.getEndColumn()); + final var artificialParent = TreeAdapter.setLocation(VF.appl(TreeAdapter.getProduction(parent), selected), selectionLoc); + + // Build new focus list + return commonSuffix.insert(artificialParent); + } } diff --git a/rascal-lsp/src/main/rascal/library/demo/lang/pico/LanguageServer.rsc b/rascal-lsp/src/main/rascal/library/demo/lang/pico/LanguageServer.rsc index 67fd64df6..71e173990 100644 --- a/rascal-lsp/src/main/rascal/library/demo/lang/pico/LanguageServer.rsc +++ b/rascal-lsp/src/main/rascal/library/demo/lang/pico/LanguageServer.rsc @@ -34,16 +34,23 @@ The core functionality of this module is built upon these concepts: module demo::lang::pico::LanguageServer import util::LanguageServer; +import util::Format; import util::IDEServices; import ParseTree; import util::ParseErrorRecovery; import util::Reflective; extend lang::pico::\syntax::Main; +import lang::pico::format::Formatting; import DateTime; import IO; import Location; import String; +import lang::box::\syntax::Box; +extend lang::box::util::Tree2Box; +import lang::box::util::Box2Text; +import analysis::diff::edits::HiFiLayoutDiff; + // We extend the grammar with functions and calls, so we can demo call hierarchy functionality. // For most use-cases, one should not extend the grammar in the language server implementation syntax IdType @@ -76,9 +83,46 @@ set[LanguageService] picoLanguageServer(bool allowRecovery) = { didRenameFiles(picoFileRenameService), selectionRange(picoSelectionRangeService), callHierarchy(picoPrepareCallHierarchy, picoCallsService), - completion(picoCompletionService, additionalTriggerCharacters = ["="]) + completion(picoCompletionService, additionalTriggerCharacters = ["="]), + formatting(picoFormattingService) }; +list[TextEdit] picoFormattingService(Focus input, FormattingOptions opts) { + str original = ""; + box = toBox(input[-1]); + box = visit (box) { case i:I(_) => i[is=opts.tabSize] } + formatted = format(box); + + if (!opts.trimTrailingWhitespace) { + // restore trailing whitespace that was lost during tree->box->text, or find a way to not lose it + println("The Pico formatter does not support maintaining trailing whitespace."); + } + + if (!opts.insertSpaces) { + // replace indentation spaces with tabs + formatted = perLine(formatted, indentSpacesAsTabs(opts.tabSize)); + } + + if (/^.*[^\n]$/s := original) { + // replace original final newlines or remove the one introduced by ((format)) (`Box2Text`) + formatted = replaceLast(formatted, "\n", opts.trimFinalNewlines ? "" : newlines); + } + + if (opts.insertFinalNewline) { + // ensure presence of final newline + formatted = insertFinalNewline(formatted); + } + + // compute layout differences as edits, and restore comments + edits = layoutDiff(input[-1], parse(#start[Program], formatted, input[-1]@\loc.top)); + + // instead of computing all edits and filtering, we can be more efficient by only formatting certain trees. + loc range = input[0]@\loc; + filteredEdits = [e | e <- edits, isContainedIn(e.range, range)]; + + return filteredEdits; +} + set[LanguageService] picoLanguageServer() = picoLanguageServer(false); set[LanguageService] picoLanguageServerWithRecovery() = picoLanguageServer(true); diff --git a/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/fac.pico b/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/fac.pico index 297869c20..60ac1f400 100644 --- a/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/fac.pico +++ b/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/fac.pico @@ -1,14 +1,14 @@ -begin - declare - input : natural, - output : natural, +begin + declare + input : natural, + output : natural, repnr : natural, rep : natural, add(x: natural, y: natural): natural := x + y, multiply(x: natural, y: natural): natural := add(x, multiply(x - 1, y)); - + input := 6; - while input - 1 do + while input - 1 do rep := output; repnr := input; while repnr - 1 do @@ -17,4 +17,4 @@ begin od; input := input - 1 od -end \ No newline at end of file +end diff --git a/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/ite.pico b/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/ite.pico index 8e0699861..0557e7bab 100644 --- a/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/ite.pico +++ b/rascal-lsp/src/main/rascal/library/demo/lang/pico/examples/ite.pico @@ -1,13 +1,12 @@ -begin -declare - input : natural, - output : natural; - - input := 0; - output := 1; - if input then - output := 1 - else - output := 2 - fi -end \ No newline at end of file +begin + declare + input : natural, output : natural + ; + input := 0; + output := 1; + if input then + output := 1 + else + output := 2 + fi +end diff --git a/rascal-lsp/src/main/rascal/library/util/Format.rsc b/rascal-lsp/src/main/rascal/library/util/Format.rsc new file mode 100644 index 000000000..598bc42e8 --- /dev/null +++ b/rascal-lsp/src/main/rascal/library/util/Format.rsc @@ -0,0 +1,183 @@ +@license{ +Copyright (c) 2018-2025, 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. +} +module util::Format + +import List; +import Map; +import Set; +import String; + +list[str] newLineCharacters = [ + "\u000A", // LF + "\u000B", // VT + "\u000C", // FF + "\u000D", // CR + "\u000D\u000A", // CRLF + "\u0085", // NEL + "\u2028", // LS + "\u2029" // PS +]; + +@synopsis{Comparator to sort strings by length (ascending).} +private bool bySize(str a, str b) = size(a) < size(b); + +@synopsis{Comparator to sort strings by relative position in a reference list.} +private bool(str, str) byIndex(list[str] indices) { + return bool(str a, str b) { + return indexOf(indices, a) < indexOf(indices, b); + }; +} + +@synopsis{Determine the most-used newline character in a string.} +str mostUsedNewline(str input, list[str] lineseps = newLineCharacters, str(list[str]) tieBreaker = getFirstFrom) { + linesepCounts = (nl: 0 | nl <- lineseps); + for (nl <- sort(lineseps, bySize)) { + int count = size(findAll(input, nl)); + linesepCounts[nl] = count; + // subtract all occurrences of substrings of newline characters that we counted before + for (str snl <- substrings(nl), linesepCounts[snl]?) { + linesepCounts[snl] = linesepCounts[snl] - count; + } + } + + byCount = invert(linesepCounts); + return tieBreaker(sort(byCount[max(domain(byCount))], byIndex(lineseps))); +} + +@synopsis{Split a string to an indentation prefix and the remainder of the string.} +tuple[str indentation, str rest] splitIndentation(/^/) + = ; + +str(str) indentSpacesAsTabs(int tabSize) { + str spaces = ("" | it + " " | _ <- [0..tabSize]); + return str(str line) { + parts = splitIndentation(line); + return ""; + }; +} + +str(str) indentTabsAsSpaces(int tabSize) { + str spaces = ("" | it + " " | _ <- [0..tabSize]); + return str(str line) { + parts = splitIndentation(line); + return ""; + }; +} + +@synopsis{Compute all possible strict substrings of a string.} +set[str] substrings(str input) + = {input[i..i+l] | int i <- [0..size(input)], int l <- [1..size(input)], i + l <= size(input)}; + +test bool mostUsedNewlineTestMixed() + = mostUsedNewline("\r\n\n\r\n\t\t\t\t") == "\r\n"; + +test bool mostUsedNewlineTestTie() + = mostUsedNewline("\n\n\r\n\r\n") == "\n"; + +test bool mostUsedNewlineTestGreedy() + = mostUsedNewline("\r\n\r\n\n") == "\r\n"; + +@synopsis{If a string does not end with a newline character, append one. } +str insertFinalNewline(str input, list[str] lineseps = newLineCharacters) + = any(nl <- lineseps, endsWith(input, nl)) + ? input + : input + mostUsedNewline(input, lineseps=lineseps) + ; + +test bool insertFinalNewlineTestSimple() + = insertFinalNewline("a\nb") + == "a\nb\n"; + +test bool insertFinalNewlineTestNoop() + = insertFinalNewline("a\nb\n") + == "a\nb\n"; + +test bool insertFinalNewlineTestMixed() + = insertFinalNewline("a\nb\r\n") + == "a\nb\r\n"; + +@synopsis{Remove all newlines from the end of a string.} +str trimFinalNewlines(str input, list[str] lineseps = newLineCharacters) { + orderedSeps = reverse(sort(lineseps, bySize)); + while (nl <- orderedSeps, endsWith(input, nl)) { + input = input[0..-size(nl)]; + } + return input; +} + +test bool trimFinalNewlineTestSimple() + = trimFinalNewlines("a\n\n\n") == "a"; + +test bool trimFinalNewlineTestEndOnly() + = trimFinalNewlines("a\n\n\nb\n\n") == "a\n\n\nb"; + +test bool trimFinalNewlineTestWhiteSpace() + = trimFinalNewlines("a\n\n\nb\n\n ") == "a\n\n\nb\n\n "; + +@synopsis{Split a string in pairs for each line.} +list[tuple[str, str]] separateLines(str input, list[str] lineseps = newLineCharacters) { + orderedSeps = reverse(sort(lineseps, bySize)); + + list[tuple[str, str]] lines = []; + int next = 0; + for (int i <- [0..size(input)]) { + // greedily match line separators (longest first) + if (i >= next, str nl <- orderedSeps, nl == input[i..i+size(nl)]) { + lines += ; + next = i + size(nl); // skip to the start of the next line + } + } + + // last line + if (str nl <- orderedSeps, nl == input[-size(nl)..]) { + lines += ; + } + + return lines; +} + +@synopsis{Concatenate a list of pairs to form a single string.} +str mergeLines(list[tuple[str, str]] lines) + = ("" | it + line + sep | <- lines); + +@synopsis{Process the text of a string per line, maintaining the original newline characters.} +str perLine(str input, str(str) lineFunc, list[str] lineseps = newLineCharacters) + = mergeLines([ | <- separateLines(input, lineseps=lineseps)]); + +test bool perLineTest() + = perLine("a\nb\r\nc\n\r\n", str(str line) { return line + "x"; }) == "ax\nbx\r\ncx\nx\r\nx"; + +@synopsis{Trim trailing non-newline whitespace from each line in a multi-line string.} +str trimTrailingWhitespace(str input) { + str trimLineTrailingWs(/^\s*$/) = nonWhiteSpace; + default str trimLineTrailingWs(/^\s*$/) = ""; + + return perLine(input, trimLineTrailingWs); +} + +test bool trimTrailingWhitespaceTest() + = trimTrailingWhitespace("a \nb\t\n c \n") == "a\nb\n c\n"; diff --git a/rascal-lsp/src/main/rascal/library/util/LanguageServer.rsc b/rascal-lsp/src/main/rascal/library/util/LanguageServer.rsc index 2bd6233da..3575f3f62 100644 --- a/rascal-lsp/src/main/rascal/library/util/LanguageServer.rsc +++ b/rascal-lsp/src/main/rascal/library/util/LanguageServer.rsc @@ -41,10 +41,10 @@ module util::LanguageServer import util::Reflective; extend analysis::diff::edits::AnnotatedTextEdits; +import Exception; import IO; -import ParseTree; import Message; -import Exception; +import ParseTree; @synopsis{Definition of a language server by its meta-data.} @description{ @@ -232,7 +232,8 @@ hover documentation, definition with uses, references to declarations, implement * To keep this API simple, we have left out support for incomplete (partial) completions, so "CompletionList.isIncomplete" will always be set to false. * Again to keep the API simple we have not implemented support for defaults, so CompletionItem, edit range (and commitCharacters if you want them) must be set explicitly on each CompletionItem. -Note: Depending on the capabilities of the client, we will generate "InsertReplaceEdit" items or "TextEdit" items. + Note: Depending on the capabilities of the client, we will generate "InsertReplaceEdit" items or "TextEdit" items. +* The ((formatting)) service determines what edits to do to format (part of) a file. The `range` parameter determines what part of the file to format. For whole-file formatting, `_tree.top == range`. ((FormattingOptions)) influence how formatting treats whitespace. Many services receive a ((Focus)) parameter. The focus lists the syntactical constructs under the current cursor, from the current leaf all the way up to the root of the tree. This list helps to create functionality that is syntax-directed, and always relevant to the @@ -302,6 +303,7 @@ data LanguageService list[CallHierarchyItem] (Focus _focus) prepareService, lrel[CallHierarchyItem item, loc call] (CallHierarchyItem _ci, CallDirection _dir) callsService) | completion(list[CompletionItem] (Focus _focus, int cursorOffset, CompletionTrigger trigger) completionService, list[str] additionalTriggerCharacters = []) + | formatting (list[TextEdit](Focus _focus, FormattingOptions _opts) formattingService) ; @description{ @@ -405,6 +407,24 @@ data CompletionTrigger = invoked() | character(str trigger); loc defaultPrepareRenameService(Focus _:[Tree tr, *_]) = tr.src when tr.src?; default loc defaultPrepareRenameService(Focus focus) { throw IllegalArgument(focus, "Element under cursor does not have source location"); } +@synopsis{Options for formatting of programs.} +@description{ +Options that specify how to format contents of a file. +* `insertSpaces`; if `true`, use spaces for indentation; if `false`, use tabs. +* `tabSize`; if `insertSpaces == true`, use this amount of spaces for a single level of indentation. +* `trimTrailingWhiteSpace`; if `true`, remove any whitespace (except newlines) from ends of formatted lines. +* `insertFinalNewline`; if `true`, and the file does not end with a new line, add one. +* `trimFinalNewlines`; if `true`, and the file ends in one or more new lines, remove them. + Note: formatting with `insertFinalNewline && trimFinalNewlines` is expected to return a file that ends in a single newline. +} +data FormattingOptions( + int tabSize = 4 + , bool insertSpaces = true + , bool trimTrailingWhitespace = false + , bool insertFinalNewline = false + , bool trimFinalNewlines = false +) = formattingOptions(); + @synopsis{A node in a call hierarchy, either a caller or a callee.} @description{ A ((CallHierarchyItem)) represents a single function, method, or procedure in the call hierarchy. diff --git a/rascal-lsp/src/test/java/engineering/swat/rascal/lsp/util/TreeSearchTests.java b/rascal-lsp/src/test/java/engineering/swat/rascal/lsp/util/TreeSearchTests.java new file mode 100644 index 000000000..762d59e35 --- /dev/null +++ b/rascal-lsp/src/test/java/engineering/swat/rascal/lsp/util/TreeSearchTests.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2018-2025, 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 engineering.swat.rascal.lsp.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.rascalmpl.vscode.lsp.util.locations.impl.TreeSearch.computeFocusList; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.rascalmpl.values.IRascalValueFactory; +import org.rascalmpl.values.parsetrees.ITree; +import org.rascalmpl.values.parsetrees.TreeAdapter; +import org.rascalmpl.vscode.lsp.util.RascalServices; + +public class TreeSearchTests { + private static final IRascalValueFactory VF = IRascalValueFactory.getInstance(); + private static final String URI = "unknown:///"; + private static final String CONTENTS = fromLines( + "module TreeTest" // 1 + , "" // 2 + , "int f() {" // 3 + , " int x = 8;" // 4 + , " int y = 54;" // 5 + , " int z = -1;" // 6 + , "" // 7 + , " return x + y + z;" // 8 + , "}" // 9 + ); + + private static ITree tree; + + private static String fromLines(String... lines) { + final var builder = new StringBuilder(); + for (var line : lines) { + builder.append(line); + builder.append("\n"); + } + return builder.toString(); + } + + @BeforeClass + public static void setUpSuite() { + tree = RascalServices.parseRascalModule(VF.sourceLocation(URI), CONTENTS.toCharArray()); + } + + @Test + public void focusStartsWithLexical() { + final var focus = computeFocusList(tree, 8, 13); + final var first = (ITree) focus.get(0); + assertTrue(TreeAdapter.isLexical(first)); + } + + @Test + public void focusEndsWithModule() { + final var focus = computeFocusList(tree, 6, 4); + final var last = (ITree) focus.get(focus.length() - 1); + assertEquals(tree, last); + } + + @Test + public void listRangePartial() { + final var focus = computeFocusList(tree, 5, 8, 6, 8); + final var selection = (ITree) focus.get(0); + final var originalList = (ITree) focus.get(1); + + assertValidListWithLength(selection, 2); + assertValidListWithLength(originalList, 4); + } + + @Test + public void listRangeStartsWithWhitespace() { + final var focus = computeFocusList(tree, 7, 0, 8, 15); + final var selection = (ITree) focus.get(0); + final var originalList = (ITree) focus.get(1); + + assertValidListWithLength(selection, 1); + assertValidListWithLength(originalList, 4); + } + + @Test + public void listRangeEndsWithWhitespace() { + final var focus = computeFocusList(tree, 4, 3, 7, 0); + final var selection = (ITree) focus.get(0); + final var originalList = (ITree) focus.get(1); + + assertValidListWithLength(selection, 3); + assertValidListWithLength(originalList, 4); + } + + private static void assertValidListWithLength(final ITree list, int length) { + assertTrue(String.format("Not a list: %s", TreeAdapter.getType(list)), TreeAdapter.isList(list)); + assertEquals(TreeAdapter.yield(list), length, TreeAdapter.getListASTArgs(list).size()); + + // assert no layout padding + final var args = TreeAdapter.getArgs(list); + assertFalse("List tree malformed: starts with layout", TreeAdapter.isLayout((ITree) args.get(0))); + assertFalse("List tree malformed: ends with layout", TreeAdapter.isLayout((ITree) args.get(args.length() - 1))); + } +}