diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/TextDocumentState.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/TextDocumentState.java index c4205bcea..b392ba044 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/TextDocumentState.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/TextDocumentState.java @@ -27,27 +27,36 @@ package org.rascalmpl.vscode.lsp; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.AtomicStampedReference; -import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletionException; import java.util.function.BiFunction; import java.util.function.Function; -import java.util.function.Supplier; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; import org.rascalmpl.library.util.ErrorRecovery; +import org.rascalmpl.parser.gtd.exception.ParseError; import org.rascalmpl.values.RascalValueFactory; import org.rascalmpl.values.ValueFactoryFactory; import org.rascalmpl.values.parsetrees.ITree; +import org.rascalmpl.vscode.lsp.util.Diagnostics; import org.rascalmpl.vscode.lsp.util.Versioned; +import org.rascalmpl.vscode.lsp.util.concurrent.Debouncer; +import org.rascalmpl.vscode.lsp.util.locations.ColumnMaps; +import io.usethesource.vallang.IList; import io.usethesource.vallang.ISourceLocation; +import io.usethesource.vallang.IValue; /** * TextDocumentState encapsulates the current contents of every open file editor, @@ -59,30 +68,32 @@ * and ParametricTextDocumentService. */ public class TextDocumentState { - - private static final ErrorRecovery RECOVERY = - new ErrorRecovery((RascalValueFactory) ValueFactoryFactory.getValueFactory()); + private static final Logger logger = LogManager.getLogger(TextDocumentState.class); private final BiFunction> parser; private final ISourceLocation location; + private final ColumnMaps columns; @SuppressWarnings("java:S3077") // Visibility of writes is enough private volatile Update current; - private final Debouncer> currentTreeAsyncDebouncer; + private final Debouncer currentAsyncParseDebouncer; private final AtomicReference<@MonotonicNonNull Versioned> lastWithoutErrors; private final AtomicReference<@MonotonicNonNull Versioned> last; public TextDocumentState( BiFunction> parser, - ISourceLocation location, int initialVersion, String initialContent) { + ISourceLocation location, ColumnMaps columns, + int initialVersion, String initialContent) { this.parser = parser; this.location = location; + this.columns = columns; this.current = new Update(initialVersion, initialContent); - this.currentTreeAsyncDebouncer = new Debouncer<>(50, - this::getCurrentTreeAsyncIfParsing, this::getCurrentTreeAsync); + this.currentAsyncParseDebouncer = new Debouncer<>(50, + this::getCurrentAsyncIfParsing, + this::parseAndGetCurrentAsync); this.lastWithoutErrors = new AtomicReference<>(); this.last = new AtomicReference<>(); @@ -95,7 +106,7 @@ public ISourceLocation getLocation() { public void update(int version, String content) { current = new Update(version, content); // The creation of the `Update` object doesn't trigger the parser yet. - // This happens only when the tree is requested. + // This happens only when the tree or diagnostics are requested. } public Versioned getCurrentContent() { @@ -107,17 +118,21 @@ public CompletableFuture> getCurrentTreeAsync() { } public CompletableFuture> getCurrentTreeAsync(Duration delay) { - return currentTreeAsyncDebouncer.get(delay); + return currentAsyncParseDebouncer + .get(delay) + .thenApply(Update::getTreeAsync) + .thenCompose(Function.identity()); } - public @Nullable CompletableFuture> getCurrentTreeAsyncIfParsing() { - var update = current; - return update.isParsing() ? update.getTreeAsync() : null; + public CompletableFuture>> getCurrentDiagnosticsAsync() { + return current.getDiagnosticsAsync(); // Triggers the parser } - public CompletableFuture>> getCurrentDiagnostics() { - throw new UnsupportedOperationException(); - // TODO: In a separate PR + public CompletableFuture>> getCurrentDiagnosticsAsync(Duration delay) { + return currentAsyncParseDebouncer + .get(delay) + .thenApply(Update::getDiagnosticsAsync) + .thenCompose(Function.identity()); } public @MonotonicNonNull Versioned getLastTree() { @@ -128,16 +143,29 @@ public CompletableFuture>> getCurrentDiagnostics() { return lastWithoutErrors.get(); } + private @Nullable CompletableFuture getCurrentAsyncIfParsing() { + var update = current; + return update.isParsing() ? CompletableFuture.completedFuture(update) : null; + } + + private CompletableFuture parseAndGetCurrentAsync() { + var update = current; + update.parseIfNotParsing(); + return CompletableFuture.completedFuture(update); + } + private class Update { private final int version; private final String content; private final CompletableFuture> treeAsync; + private final CompletableFuture>> diagnosticsAsync; private final AtomicBoolean parsing; public Update(int version, String content) { this.version = version; this.content = content; this.treeAsync = new CompletableFuture<>(); + this.diagnosticsAsync = new CompletableFuture<>(); this.parsing = new AtomicBoolean(false); } @@ -150,6 +178,11 @@ public CompletableFuture> getTreeAsync() { return treeAsync; } + public CompletableFuture>> getDiagnosticsAsync() { + parseIfNotParsing(); + return diagnosticsAsync; + } + public boolean isParsing() { return parsing.get(); } @@ -158,154 +191,57 @@ private void parseIfNotParsing() { if (parsing.compareAndSet(false, true)) { parser .apply(location, content) - .thenApply(t -> new Versioned<>(version, t)) - .whenComplete((t, error) -> { - if (t != null) { - var errors = RECOVERY.findAllErrors(t.get()); - if (errors.isEmpty()) { - Versioned.replaceIfNewer(lastWithoutErrors, t); + .whenComplete((t, e) -> { + + // Prepare result values for futures + var tree = new Versioned<>(version, t); + var diagnostics = new Versioned<>(version, toDiagnostics(t, e)); + + // Complete future to get the tree + if (t == null) { + treeAsync.completeExceptionally(e); + } else { + treeAsync.complete(tree); + Versioned.replaceIfNewer(last, tree); + if (diagnostics.get().isEmpty()) { + Versioned.replaceIfNewer(lastWithoutErrors, tree); } - Versioned.replaceIfNewer(last, t); - treeAsync.complete(t); - } - if (error != null) { - treeAsync.completeExceptionally(error); } + + // Complete future to get diagnostics + diagnosticsAsync.complete(diagnostics); }); } } - } -} - -/** - * A *debouncer* is an object to get a *resource* from an *underlying resource - * provider* with a certain delay. From the perspective of the debouncer, the - * underlying resource provider has two states: initialized and not-initialized. - * - * 1. While the underlying resource provider is not-initialized (e.g., the - * computation of a parse tree has not yet started), the debouncer waits - * until the delay is over. - * - * 2. When the underlying resource provider becomes initialized (e.g., the - * computation of a parse tree has started, but possibly not yet finished), - * the debouncer returns a future for the resource. - * - * 3. When the underlying resource provider is not-initialized, but the delay - * is over, the debouncer forcibly initializes the resource (e.g., it starts - * the asynchronous computation of a parse tree) and returns a future for - * the resource. - */ -class Debouncer { - - // A debouncer is implemented using a *delayed executor* as `scheduler`. The - // idea is to *periodically* check the state of the underlying resource - // provider. More precisely, each time when the resource is requested, - // immediately check if case 2 or case 3 (above) are applicable. If so, - // return. If not, schedule a *delayed future* to retry the request, to be - // completed after a small `period` (e.g., 50 milliseconds). - // - // The reason why multiple futures are scheduled in small periods, instead - // of a single future for the entire large delay, is that futures (of type - // `CompletableFuture`) cannot be interrupted. - - private final int period; // Milliseconds - private final Executor scheduler; - - // At any point in time, only one delayed future to retry the request for - // the resource should be `scheduled`, tied with the total remaining delay. - // For bookkeeping, a *stamped reference* is used. The reference is the - // delayed future, while the stamp is the remaining delay *upon completion - // of the delayed future*. - - private final AtomicStampedReference<@Nullable CompletableFuture> scheduled; - - // The underlying resource provider is represented abstractly in terms of - // two suppliers, each of which corresponds with a state of the underlying - // resource provider. `getIfInitialized` should return `null` iff the - // underlying resource provider is not-initialized. - - private final Supplier<@Nullable CompletableFuture> getIfInitialized; - private final Supplier> initializeAndGet; - - public Debouncer(Duration period, - Supplier<@Nullable CompletableFuture> getIfInitialized, - Supplier> initializeAndGet) { - - this(Math.toIntExact(period.toMillis()), getIfInitialized, initializeAndGet); - } - - public Debouncer(int period, - Supplier<@Nullable CompletableFuture> getIfInitialized, - Supplier> initializeAndGet) { - - this.period = period; - this.scheduler = CompletableFuture.delayedExecutor(period, TimeUnit.MILLISECONDS); - this.scheduled = new AtomicStampedReference<>(null, 0); - this.getIfInitialized = getIfInitialized; - this.initializeAndGet = initializeAndGet; - } - public CompletableFuture get(Duration delay) { - return get(Math.toIntExact(delay.toMillis())); - } - - public CompletableFuture get(int delay) { - return schedule(delay, false); - } + private List toDiagnostics(ITree tree, Throwable excp) { + List parseErrors = new ArrayList<>(); - private CompletableFuture schedule(int delay, boolean reschedule) { - - // Get a consistent old stamp and old reference - var oldRef = scheduled.getReference(); - var oldStamp = scheduled.getStamp(); - while (!scheduled.compareAndSet(oldRef, oldRef, oldStamp, oldStamp)); - - // Compute a new reference (= delayed future to retry this method) - var delayArg = new CompletableFuture(); - var newRef = delayArg - .thenApplyAsync(this::reschedule, scheduler) - .thenCompose(Function.identity()); - - // Compute a new stamp - var delayRemaining = Math.max(oldStamp, delay); - var newStamp = delayRemaining - period; - - // If the underlying resource provider is initialized, then return the - // future to get the resource - var future = getIfInitialized.get(); - if (future != null && scheduled.compareAndSet(oldRef, null, oldStamp, 0)) { - return future; - } + if (excp instanceof CompletionException) { + excp = excp.getCause(); + } - // Otherwise, if the delay is over already, then initialize the - // underlying resource provider and return the future to get the - // resource - if (delayRemaining <= 0 && scheduled.compareAndSet(oldRef, null, oldStamp, 0)) { - return initializeAndGet.get(); - } + if (excp instanceof ParseError) { + parseErrors.add(Diagnostics.translateDiagnostic((ParseError)excp, columns)); + } else if (excp != null) { + logger.error("Parsing crashed", excp); + parseErrors.add(new Diagnostic( + new Range(new Position(0,0), new Position(0,1)), + "Parsing failed: " + excp.getMessage(), + DiagnosticSeverity.Error, + "Rascal Parser")); + } - // Otherwise (i.e., the delay isn't over yet), if a delayed future to - // retry this method hasn't been scheduled yet, or if it must be - // rescheduled regardless, then schedule it - if ((oldRef == null || reschedule) && scheduled.compareAndSet(oldRef, newRef, oldStamp, newStamp)) { - delayArg.complete(newStamp); - return newRef; - } + if (tree != null) { + RascalValueFactory valueFactory = (RascalValueFactory) ValueFactoryFactory.getValueFactory(); + IList errors = new ErrorRecovery(valueFactory).findAllErrors(tree); + for (IValue error : errors) { + ITree errorTree = (ITree) error; + parseErrors.add(Diagnostics.translateErrorRecoveryDiagnostic(errorTree, columns)); + } + } - // Otherwise (i.e, the delay is not yet over, but a delayed future has - // been scheduled already), then update the remaining delay; it will be - // used by the already-scheduled delayed future. - if (scheduled.attemptStamp(oldRef, newStamp)) { - return oldRef; + return parseErrors; } - - // When this point is reached, concurrent modifications to the stamp or - // the reference in `scheduled` have happened. In that case, retry - // immediately. - return schedule(delay, reschedule); - } - - private CompletableFuture reschedule(int delay) { - return schedule(delay, true); } } 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 161322514..79eee19c8 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 @@ -521,7 +521,7 @@ private ParametricFileFacts facts(String doc) { private TextDocumentState open(TextDocumentItem doc) { return files.computeIfAbsent(Locations.toLoc(doc), - l -> new TextDocumentState(contributions(doc)::parseSourceFile, l, doc.getVersion(), doc.getText()) + l -> new TextDocumentState(contributions(doc)::parseSourceFile, l, columns, doc.getVersion(), doc.getText()) ); } @@ -638,7 +638,7 @@ public CompletableFuture>> codeAction(CodeActio private CompletableFuture computeCodeActions(final ILanguageContributions contribs, final int startLine, final int startColumn, ITree tree) { IList focus = TreeSearch.computeFocusList(tree, startLine, startColumn); - + if (!focus.isEmpty()) { return contribs.codeActions(focus).get(); } 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 46aa39bb9..d1a1d69d3 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 @@ -35,7 +35,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; @@ -49,7 +48,6 @@ import org.eclipse.lsp4j.Command; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; @@ -63,8 +61,6 @@ import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.RenameParams; import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.SemanticTokensDelta; @@ -85,12 +81,7 @@ import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; import org.rascalmpl.library.Prelude; -import org.rascalmpl.library.util.ErrorRecovery; -import org.rascalmpl.parser.gtd.exception.ParseError; import org.rascalmpl.uri.URIResolverRegistry; -import org.rascalmpl.values.RascalValueFactory; -import org.rascalmpl.values.ValueFactoryFactory; -import org.rascalmpl.values.parsetrees.ITree; import org.rascalmpl.vscode.lsp.BaseWorkspaceService; import org.rascalmpl.vscode.lsp.IBaseLanguageClient; import org.rascalmpl.vscode.lsp.IBaseTextDocumentService; @@ -99,7 +90,6 @@ 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.Diagnostics; import org.rascalmpl.vscode.lsp.util.DocumentChanges; import org.rascalmpl.vscode.lsp.util.FoldingRanges; import org.rascalmpl.vscode.lsp.util.Outline; @@ -109,7 +99,6 @@ import org.rascalmpl.vscode.lsp.util.locations.LineColumnOffsetMap; import org.rascalmpl.vscode.lsp.util.locations.Locations; -import io.usethesource.vallang.IList; import io.usethesource.vallang.ISourceLocation; import io.usethesource.vallang.IValue; @@ -183,7 +172,7 @@ public void connect(LanguageClient client) { public void didOpen(DidOpenTextDocumentParams params) { logger.debug("Open file: {}", params.getTextDocument()); TextDocumentState file = open(params.getTextDocument()); - handleParsingErrors(file); + handleParsingErrors(file, file.getCurrentDiagnosticsAsync()); // No debounce } @Override @@ -215,51 +204,20 @@ private TextDocumentState updateContents(VersionedTextDocumentIdentifier doc, St TextDocumentState file = getFile(doc); logger.trace("New contents for {}", doc); file.update(doc.getVersion(), newContents); - handleParsingErrors(file, file.getCurrentTreeAsync(Duration.ofMillis(800))); + handleParsingErrors(file, file.getCurrentDiagnosticsAsync(Duration.ofMillis(800))); return file; } - private void handleParsingErrors(TextDocumentState file, CompletableFuture> futureTree) { - futureTree.handle((tree, excp) -> { - List parseErrors = new ArrayList<>(); - - if (excp instanceof CompletionException) { - excp = excp.getCause(); - } - - if (excp instanceof ParseError) { - parseErrors.add(Diagnostics.translateDiagnostic((ParseError)excp, columns)); - } else if (excp != null) { - logger.error("Parsing crashed", excp); - parseErrors.add(new Diagnostic( - new Range(new Position(0,0), new Position(0,1)), - "Parsing failed: " + excp.getMessage(), - DiagnosticSeverity.Error, - "Rascal Parser")); - } - - if (tree != null) { - RascalValueFactory valueFactory = (RascalValueFactory) ValueFactoryFactory.getValueFactory(); - IList errors = new ErrorRecovery(valueFactory).findAllErrors(tree.get()); - for (IValue error : errors) { - ITree errorTree = (ITree) error; - parseErrors.add(Diagnostics.translateErrorRecoveryDiagnostic(errorTree, columns)); - } - } - + private void handleParsingErrors(TextDocumentState file, CompletableFuture>> diagnosticsAsync) { + diagnosticsAsync.thenAccept(diagnostics -> { + List parseErrors = diagnostics.get(); logger.trace("Finished parsing tree, reporting new parse errors: {} for: {}", parseErrors, file.getLocation()); if (facts != null) { facts.reportParseErrors(file.getLocation(), parseErrors); } - return null; }); } - private void handleParsingErrors(TextDocumentState file) { - handleParsingErrors(file,file.getCurrentTreeAsync()); - } - - @Override public CompletableFuture, List>> definition(DefinitionParams params) { logger.debug("Definition: {} at {}", params.getTextDocument(), params.getPosition()); @@ -342,7 +300,7 @@ private static T last(List l) { private TextDocumentState open(TextDocumentItem doc) { return documents.computeIfAbsent(Locations.toLoc(doc), - l -> new TextDocumentState((loc, input) -> rascalServices.parseSourceFile(loc, input), l, doc.getVersion(), doc.getText())); + l -> new TextDocumentState((loc, input) -> rascalServices.parseSourceFile(loc, input), l, columns, doc.getVersion(), doc.getText())); } private TextDocumentState getFile(TextDocumentIdentifier doc) { diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/Diagnostics.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/Diagnostics.java index 2e404baee..60c1c9db4 100644 --- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/Diagnostics.java +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/Diagnostics.java @@ -73,13 +73,14 @@ public static Map> groupByKey(Stream> diagnostics) } public static Diagnostic translateDiagnostic(ParseError e, ColumnMaps cm) { - return new Diagnostic(toRange(e, cm), e.getMessage(), DiagnosticSeverity.Error, "parser"); + var message = e.getMessage() + " (irrecoverable)"; + return new Diagnostic(toRange(e, cm), message, DiagnosticSeverity.Error, "parser"); } public static Diagnostic translateErrorRecoveryDiagnostic(ITree errorTree, ColumnMaps cm) { IList args = TreeAdapter.getArgs(errorTree); ITree skipped = (ITree) args.get(args.size()-1); - return new Diagnostic(toRange(skipped, cm), "Parse error", DiagnosticSeverity.Error, "parser"); + return new Diagnostic(toRange(skipped, cm), "Parse error (recoverable)", DiagnosticSeverity.Error, "parser"); } public static Diagnostic translateRascalParseError(IValue e, ColumnMaps cm) { diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/Debouncer.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/Debouncer.java new file mode 100644 index 000000000..84bb3e0a0 --- /dev/null +++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/Debouncer.java @@ -0,0 +1,170 @@ +/* + * 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.concurrent; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicStampedReference; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A *debouncer* is an object to get a *resource* from an *underlying resource + * provider* with a certain delay. From the perspective of the debouncer, the + * underlying resource provider has two states: initialized and not-initialized. + * + * 1. While the underlying resource provider is not-initialized (e.g., the + * computation of a parse tree has not yet started), the debouncer waits + * until the delay is over. + * + * 2. When the underlying resource provider becomes initialized (e.g., the + * computation of a parse tree has started, but possibly not yet finished), + * the debouncer returns a future for the resource. + * + * 3. When the underlying resource provider is not-initialized, but the delay + * is over, the debouncer forcibly initializes the resource (e.g., it starts + * the asynchronous computation of a parse tree) and returns a future for + * the resource. + */ +public class Debouncer { + + // A debouncer is implemented using a *delayed executor* as `scheduler`. The + // idea is to *periodically* check the state of the underlying resource + // provider. More precisely, each time when the resource is requested, + // immediately check if case 2 or case 3 (above) are applicable. If so, + // return. If not, schedule a *delayed future* to retry the request, to be + // completed after a small `period` (e.g., 50 milliseconds). + // + // The reason why multiple futures are scheduled in small periods, instead + // of a single future for the entire large delay, is that futures (of type + // `CompletableFuture`) cannot be interrupted. + + private final int period; // Milliseconds + private final Executor scheduler; + + // At any point in time, only one delayed future to retry the request for + // the resource should be `scheduled`, tied with the total remaining delay. + // For bookkeeping, a *stamped reference* is used. The reference is the + // delayed future, while the stamp is the remaining delay *upon completion + // of the delayed future*. + + private final AtomicStampedReference<@Nullable CompletableFuture> scheduled; + + // The underlying resource provider is represented abstractly in terms of + // two suppliers, each of which corresponds with a state of the underlying + // resource provider. `getIfInitialized` should return `null` iff the + // underlying resource provider is not-initialized. + + private final Supplier<@Nullable CompletableFuture> getIfInitialized; + private final Supplier> initializeAndGet; + + public Debouncer(Duration period, + Supplier<@Nullable CompletableFuture> getIfInitialized, + Supplier> initializeAndGet) { + + this(Math.toIntExact(period.toMillis()), getIfInitialized, initializeAndGet); + } + + public Debouncer(int period, + Supplier<@Nullable CompletableFuture> getIfInitialized, + Supplier> initializeAndGet) { + + this.period = period; + this.scheduler = CompletableFuture.delayedExecutor(period, TimeUnit.MILLISECONDS); + this.scheduled = new AtomicStampedReference<>(null, 0); + this.getIfInitialized = getIfInitialized; + this.initializeAndGet = initializeAndGet; + } + + public CompletableFuture get(Duration delay) { + return get(Math.toIntExact(delay.toMillis())); + } + + public CompletableFuture get(int delay) { + return schedule(delay, false); + } + + private CompletableFuture schedule(int delay, boolean reschedule) { + + // Get a consistent old stamp and old reference + var oldRef = scheduled.getReference(); + var oldStamp = scheduled.getStamp(); + while (!scheduled.compareAndSet(oldRef, oldRef, oldStamp, oldStamp)); + + // Compute a new reference (= delayed future to retry this method) + var delayArg = new CompletableFuture(); + var newRef = delayArg + .thenApplyAsync(this::reschedule, scheduler) + .thenCompose(Function.identity()); + + // Compute a new stamp + var delayRemaining = Math.max(oldStamp, delay); + var newStamp = delayRemaining - period; + + // If the underlying resource provider is initialized, then return the + // future to get the resource + var future = getIfInitialized.get(); + if (future != null && scheduled.compareAndSet(oldRef, null, oldStamp, 0)) { + return future; + } + + // Otherwise, if the delay is over already, then initialize the + // underlying resource provider and return the future to get the + // resource + if (delayRemaining <= 0 && scheduled.compareAndSet(oldRef, null, oldStamp, 0)) { + return initializeAndGet.get(); + } + + // Otherwise (i.e., the delay isn't over yet), if a delayed future to + // retry this method hasn't been scheduled yet, or if it must be + // rescheduled regardless, then schedule it + if ((oldRef == null || reschedule) && scheduled.compareAndSet(oldRef, newRef, oldStamp, newStamp)) { + delayArg.complete(newStamp); + return newRef; + } + + // Otherwise (i.e, the delay is not yet over, but a delayed future has + // been scheduled already), then update the remaining delay; it will be + // used by the already-scheduled delayed future. + if (scheduled.attemptStamp(oldRef, newStamp)) { + return oldRef; + } + + // When this point is reached, concurrent modifications to the stamp or + // the reference in `scheduled` have happened. In that case, retry + // immediately. + return schedule(delay, reschedule); + } + + private CompletableFuture reschedule(int delay) { + return schedule(delay, true); + } +}