diff --git a/rascal-lsp/pom.xml b/rascal-lsp/pom.xml
index aad8ffb08..f2a6395f8 100644
--- a/rascal-lsp/pom.xml
+++ b/rascal-lsp/pom.xml
@@ -153,6 +153,12 @@
3.5.3
+
+
+ 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 e26fb3fc2..2601c327c 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
@@ -34,6 +34,7 @@
import org.rascalmpl.values.IRascalValueFactory;
import org.rascalmpl.values.parsetrees.ITree;
import org.rascalmpl.vscode.lsp.util.concurrent.InterruptibleFuture;
+
import io.usethesource.vallang.IConstructor;
import io.usethesource.vallang.IList;
import io.usethesource.vallang.ISet;
@@ -60,6 +61,7 @@ public interface ILanguageContributions {
public InterruptibleFuture implementation(IList focus);
public InterruptibleFuture codeAction(IList focus);
public InterruptibleFuture selectionRange(IList focus);
+ public InterruptibleFuture formatting(IList input, IConstructor formattingOptions);
public InterruptibleFuture prepareRename(IList focus);
public InterruptibleFuture rename(IList focus, String name);
@@ -81,6 +83,7 @@ public interface ILanguageContributions {
public CompletableFuture hasCodeAction();
public CompletableFuture hasDidRenameFiles();
public CompletableFuture hasSelectionRange();
+ public CompletableFuture hasFormatting();
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 6b9968cc3..6c4f6c3d3 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
@@ -31,6 +31,7 @@
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
+
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -52,6 +53,7 @@
import org.rascalmpl.vscode.lsp.util.EvaluatorUtil;
import org.rascalmpl.vscode.lsp.util.EvaluatorUtil.LSPContext;
import org.rascalmpl.vscode.lsp.util.concurrent.InterruptibleFuture;
+
import io.usethesource.vallang.IBool;
import io.usethesource.vallang.IConstructor;
import io.usethesource.vallang.IList;
@@ -93,6 +95,7 @@ public class InterpretedLanguageContributions implements ILanguageContributions
private final CompletableFuture<@Nullable IFunction> rename;
private final CompletableFuture<@Nullable IFunction> didRenameFiles;
private final CompletableFuture<@Nullable IFunction> selectionRange;
+ private final CompletableFuture<@Nullable IFunction> formatting;
private final CompletableFuture hasAnalysis;
private final CompletableFuture hasBuild;
@@ -108,6 +111,7 @@ public class InterpretedLanguageContributions implements ILanguageContributions
private final CompletableFuture hasRename;
private final CompletableFuture hasDidRenameFiles;
private final CompletableFuture hasSelectionRange;
+ private final CompletableFuture hasFormatting;
private final CompletableFuture specialCaseHighlighting;
@@ -154,6 +158,7 @@ public InterpretedLanguageContributions(LanguageParameter lang, IBaseTextDocumen
this.rename = getFunctionFor(contributions, LanguageContributions.RENAME);
this.didRenameFiles = getFunctionFor(contributions, LanguageContributions.DID_RENAME_FILES);
this.selectionRange = getFunctionFor(contributions, LanguageContributions.SELECTION_RANGE);
+ this.formatting = getFunctionFor(contributions, LanguageContributions.FORMATTING);
// assign boolean properties once instead of wasting futures all the time
this.hasAnalysis = nonNull(this.analysis);
@@ -170,6 +175,7 @@ public InterpretedLanguageContributions(LanguageParameter lang, IBaseTextDocumen
this.hasRename = nonNull(this.rename);
this.hasDidRenameFiles = nonNull(this.didRenameFiles);
this.hasSelectionRange = nonNull(this.selectionRange);
+ this.hasFormatting = nonNull(this.formatting);
this.specialCaseHighlighting = getContributionParameter(contributions,
LanguageContributions.PARSING,
@@ -389,6 +395,12 @@ public InterruptibleFuture selectionRange(IList focus) {
return execFunction(LanguageContributions.SELECTION_RANGE, selectionRange, VF.list(), focus);
}
+ @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);
}
@@ -457,6 +469,11 @@ public CompletableFuture hasSelectionRange() {
return hasSelectionRange;
}
+ @Override
+ public CompletableFuture hasFormatting() {
+ return hasFormatting;
+ }
+
@Override
public CompletableFuture hasAnalysis() {
return hasAnalysis;
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 4917cf8ac..c3f36aabd 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
@@ -70,6 +70,7 @@ private static final CompletableFuture failedInitialization() {
private volatile CompletableFuture rename = failedInitialization();
private volatile CompletableFuture didRenameFiles = failedInitialization();
private volatile CompletableFuture selectionRange = failedInitialization();
+ private volatile CompletableFuture formatting = failedInitialization();
private volatile CompletableFuture hasAnalysis = failedInitialization();
private volatile CompletableFuture hasBuild = failedInitialization();
@@ -85,6 +86,7 @@ private static final CompletableFuture failedInitialization() {
private volatile CompletableFuture hasRename = failedInitialization();
private volatile CompletableFuture hasDidRenameFiles = failedInitialization();
private volatile CompletableFuture hasSelectionRange = failedInitialization();
+ private volatile CompletableFuture hasFormatting = failedInitialization();
private volatile CompletableFuture specialCaseHighlighting = failedInitialization();
@@ -162,6 +164,7 @@ private synchronized void calculateRouting() {
prepareRename = findFirstOrDefault(ILanguageContributions::hasRename);
didRenameFiles = findFirstOrDefault(ILanguageContributions::hasDidRenameFiles);
selectionRange = findFirstOrDefault(ILanguageContributions::hasSelectionRange);
+ formatting = findFirstOrDefault(ILanguageContributions::hasFormatting);
hasAnalysis = anyTrue(ILanguageContributions::hasAnalysis);
hasBuild = anyTrue(ILanguageContributions::hasBuild);
@@ -177,6 +180,7 @@ private synchronized void calculateRouting() {
hasDidRenameFiles = anyTrue(ILanguageContributions::hasDidRenameFiles);
hasCodeAction = anyTrue(ILanguageContributions::hasCodeAction);
hasSelectionRange = anyTrue(ILanguageContributions::hasSelectionRange);
+ hasFormatting = anyTrue(ILanguageContributions::hasFormatting);
// Always use the special-case highlighting status of *the first*
// contribution (possibly using the default value in the Rascal ADT if
@@ -337,6 +341,16 @@ 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 CompletableFuture hasFormatting() {
+ return hasFormatting;
+ }
+
@Override
public CompletableFuture hasCodeAction() {
return hasCodeAction;
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 81af3c3d9..0ad36f27b 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
@@ -62,12 +62,15 @@
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;
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;
@@ -100,6 +103,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;
@@ -110,6 +114,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.values.IRascalValueFactory;
import org.rascalmpl.values.parsetrees.ITree;
@@ -236,6 +241,8 @@ public void initializeServerCapabilities(ServerCapabilities result) {
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);
@@ -407,7 +414,7 @@ private CompletableFuture computeRenameRange(final ILanguageCon
public CompletableFuture rename(RenameParams params) {
logger.trace("rename for: {}, new name: {}", params.getTextDocument().getUri(), params.getNewName());
final ILanguageContributions contribs = contributions(params.getTextDocument());
- final Position rascalPos = Locations.toRascalPosition(params.getTextDocument(), params.getPosition(), columns);;
+ final Position rascalPos = Locations.toRascalPosition(params.getTextDocument(), params.getPosition(), columns);
return getFile(params.getTextDocument())
.getCurrentTreeAsync()
.thenApply(Versioned::get)
@@ -727,6 +734,67 @@ public CompletableFuture>> codeAction(CodeActio
return CodeActions.mergeAndConvertCodeActions(this, dedicatedLanguageName, contribs.getName(), quickfixes, codeActions);
}
+ @Override
+ public CompletableFuture> formatting(DocumentFormattingParams params) {
+ logger.debug("Formatting: {}", params);
+
+ TextDocumentIdentifier uri = params.getTextDocument();
+ final ILanguageContributions contribs = contributions(uri);
+
+ // call the `formatting` implementation of the relevant language contribution
+ return getFile(uri)
+ .getCurrentTreeAsync()
+ .thenApply(Versioned::get)
+ .thenCompose(tree -> {
+ final var opts = getFormattingOptions(params.getOptions());
+ return contribs.formatting(VF.list(tree), opts).get();
+ })
+ .thenApply(l -> DocumentChanges.translateTextEdits(this, l, Map.of()));
+ }
+
+ @Override
+ public CompletableFuture> rangeFormatting(DocumentRangeFormattingParams params) {
+ logger.debug("Formatting range: {}", params);
+
+ TextDocumentIdentifier uri = 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()
+ .thenApply(Versioned::get)
+ .thenCompose(tree -> {
+ // just a range
+ var start = Locations.toRascalPosition(uri, range.getStart(), columns);
+ var end = Locations.toRascalPosition(uri, range.getEnd(), columns);
+ // compute the focus list at the end of the range
+ var focus = TreeSearch.computeFocusList(tree, start.getLine(), start.getCharacter(), end.getLine(), end.getCharacter());
+
+ var opts = getFormattingOptions(params.getOptions());
+ return contribs.formatting(focus, opts).get();
+ })
+ // convert the document changes
+ .thenApply(l -> DocumentChanges.translateTextEdits(this, l, Map.of())
+ .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/ParserOnlyContribution.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParserOnlyContribution.java
index 3b0f6cdcb..75a806289 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParserOnlyContribution.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/ParserOnlyContribution.java
@@ -189,6 +189,16 @@ public InterruptibleFuture codeAction(IList focus) {
return InterruptibleFuture.completedFuture(VF.list());
}
+ @Override
+ public InterruptibleFuture formatting(IList focus, IConstructor formattingOptions) {
+ return InterruptibleFuture.completedFuture(VF.list());
+ }
+
+ @Override
+ public CompletableFuture hasFormatting() {
+ return CompletableFuture.completedFuture(false);
+ }
+
@Override
public InterruptibleFuture implementation(IList focus) {
return InterruptibleFuture.completedFuture(VF.set());
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 09c90f8a3..931f1549a 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
@@ -48,6 +48,7 @@ private LanguageContributions () {}
public static final String IMPLEMENTATION = "implementation";
public static final String CODE_ACTION = "codeAction";
public static final String SELECTION_RANGE = "selectionRange";
+ 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/rascal/RascalTextDocumentService.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/rascal/RascalTextDocumentService.java
index 32510704a..43d8dad56 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
@@ -366,7 +366,8 @@ public CompletableFuture DocumentChanges.locationToRange(this, TreeAdapter.getLocation(cur)))
+ .thenApply(TreeAdapter::getLocation)
+ .thenApply(loc -> Locations.toRange(loc, columns))
.thenApply(Either3::forFirst);
}
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/DocumentChanges.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/DocumentChanges.java
index 3622d5725..9f7a9b0c4 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/DocumentChanges.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/DocumentChanges.java
@@ -97,11 +97,12 @@ public static WorkspaceEdit translateDocumentChanges(final IBaseTextDocumentServ
return wsEdit;
}
- private static List translateTextEdits(final IBaseTextDocumentService docService, IList edits, Map changeAnnotations) {
+ public static List translateTextEdits(final IBaseTextDocumentService docService, IList edits, Map changeAnnotations) {
return edits.stream()
.map(IConstructor.class::cast)
.map(c -> {
- var range = locationToRange(docService, (ISourceLocation) c.get("range"));
+ var loc = (ISourceLocation) c.get("range");
+ var range = Locations.toRange(loc, docService.getColumnMap(loc));
var replacement = ((IString) c.get("replacement")).getValue();
// Check annotation
var kw = c.asWithKeywordParameters();
@@ -125,11 +126,6 @@ private static List translateTextEdits(final IBaseTextDocumentService
.collect(Collectors.toList());
}
- public static Range locationToRange(final IBaseTextDocumentService docService, ISourceLocation loc) {
- LineColumnOffsetMap columnMap = docService.getColumnMap(loc);
- return Locations.toRange(loc, columnMap);
- }
-
private static String getFileURI(IConstructor edit, String label) {
return ((ISourceLocation) edit.get(label)).getURI().toString();
}
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 1ec7af63b..6ebea3f4b 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,12 +34,22 @@ 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;
import 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;
private Tree (str _input, loc _origin) picoParser(bool allowRecovery) {
return ParseTree::parser(#start[Program], allowRecovery=allowRecovery, filters=allowRecovery ? {createParseErrorFilter(false)} : {});
@@ -61,9 +71,46 @@ set[LanguageService] picoLanguageServer(bool allowRecovery) = {
codeAction(picoCodeActionService),
rename(picoRenamingService, prepareRenameService = picoRenamePreparingService),
didRenameFiles(picoFileRenameService),
- selectionRange(picoSelectionRangeService)
+ selectionRange(picoSelectionRangeService),
+ 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 ad231294e..0a26dee3d 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,12 +1,9 @@
-begin
- declare
- input : natural,
- output : natural,
- repnr : natural,
- rep : natural;
-
+begin
+ declare
+ input : natural, output : natural, repnr : natural, rep : natural
+ ;
input := 6;
- while input - 1 do
+ while input - 1 do
rep := output;
repnr := input;
while repnr - 1 do
@@ -15,4 +12,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 2716255f5..8e0c05107 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;
import analysis::diff::edits::TextEdits;
+import Exception;
import IO;
-import ParseTree;
import Message;
-import Exception;
+import ParseTree;
@synopsis{Definition of a language server by its meta-data.}
@description{
@@ -212,6 +212,7 @@ hover documentation, definition with uses, references to declarations, implement
* The optional `prepareRename` service argument discovers places in the editor where a ((util::LanguageServer::rename)) is possible. If renameing the location is not supported, it should throw an exception.
* The ((didRenameFiles)) service collects ((DocumentEdit))s corresponding to renamed files (e.g. to rename a class when the class file was renamed). The IDE applies the edits after moving the files. It might fail and report why in diagnostics.
* The ((selectionRange)) service discovers selections around a cursor, that a user might want to select. It expects the list of source locations to be in ascending order of size (each location should be contained by the next) - similar to ((Focus)) trees.
+* 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
@@ -277,11 +278,30 @@ data LanguageService
, loc (Focus _focus) prepareRenameService = defaultPrepareRenameService)
| didRenameFiles(tuple[list[DocumentEdit], set[Message]] (list[DocumentEdit] fileRenames) didRenameFilesService)
| selectionRange(list[loc](Focus _focus) selectionRangeService)
+ | formatting (list[TextEdit](Focus _focus, FormattingOptions _opts) formattingService)
;
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();
+
@deprecated{Backward compatible with ((parsing)).}
@synopsis{Construct a `parsing` ((LanguageService))}
LanguageService parser(Parser parser) = parsing(parser);
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)));
+ }
+}