diff --git a/metals/src/main/java/scala/meta/internal/watcher/DirectoryChangeEvent.java b/metals/src/main/java/scala/meta/internal/watcher/DirectoryChangeEvent.java deleted file mode 100644 index 4f99f726686..00000000000 --- a/metals/src/main/java/scala/meta/internal/watcher/DirectoryChangeEvent.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * NOTE: That this class is taken verbatim from io.methvin:directory-watcher. - * Seeing that we have replaced the directory-watcher functionality with swoval - * we are instead just re-utilizing the few DirectoryChangeEvent related - * classes. - */ -package scala.meta.internal.watcher; - -import java.nio.file.Path; -import java.nio.file.StandardWatchEventKinds; -import java.nio.file.WatchEvent; -import java.util.Objects; - -public final class DirectoryChangeEvent { - public enum EventType { - - /* A new file was created */ - CREATE(StandardWatchEventKinds.ENTRY_CREATE), - - /* An existing file was modified */ - MODIFY(StandardWatchEventKinds.ENTRY_MODIFY), - - /* A file was deleted */ - DELETE(StandardWatchEventKinds.ENTRY_DELETE), - - /* An overflow occurred; some events were lost */ - OVERFLOW(StandardWatchEventKinds.OVERFLOW); - - private WatchEvent.Kind kind; - - EventType(WatchEvent.Kind kind) { - this.kind = kind; - } - - public WatchEvent.Kind getWatchEventKind() { - return kind; - } - } - - private final EventType eventType; - private final Path path; - private final int count; - - public DirectoryChangeEvent(EventType eventType, Path path, int count) { - this.eventType = eventType; - this.path = path; - this.count = count; - } - - public EventType eventType() { - return eventType; - } - - public Path path() { - return path; - } - - public int count() { - return count; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - DirectoryChangeEvent that = (DirectoryChangeEvent) o; - return count == that.count && eventType == that.eventType && Objects.equals(path, that.path); - } - - @Override - public int hashCode() { - return Objects.hash(eventType, path, count); - } - - @Override - public String toString() { - return "DirectoryChangeEvent{" - + "eventType=" - + eventType - + ", path=" - + path - + ", count=" - + count - + '}'; - } -} diff --git a/metals/src/main/java/scala/meta/internal/watcher/hashing/FileHasher.java b/metals/src/main/java/scala/meta/internal/watcher/hashing/FileHasher.java deleted file mode 100644 index 303c6529125..00000000000 --- a/metals/src/main/java/scala/meta/internal/watcher/hashing/FileHasher.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * NOTE: That this class is taken verbatim from io.methvin:directory-watcher. - * Seeing that we have replaced the directory-watcher functionality with swoval - * we are instead just re-utilizing the few DirectoryChangeEvent related - * classes. - */ -package scala.meta.internal.watcher.hashing; - -import java.io.BufferedInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; - -/** - * A function to convert a Path to a hash code used to check if the file content is changed. This is - * called by DirectoryWatcher after checking that the path exists and is not a directory. Therefore - * this hasher can generally assume that those two things are true. - * - *

By default, this hasher may throw an IOException, which will be treated as a `null` hash by - * the watcher, meaning the associated event will be ignored. If you want to handle that exception - * you can catch/rethrow it. - */ -@FunctionalInterface -public interface FileHasher { - /** The default file hasher instance, which uses Murmur3. */ - FileHasher DEFAULT_FILE_HASHER = - path -> { - Murmur3F murmur = new Murmur3F(); - try (InputStream is = new BufferedInputStream(Files.newInputStream(path))) { - int b; - while ((b = is.read()) != -1) { - murmur.update(b); - } - } - return HashCode.fromBytes(murmur.getValueBytesBigEndian()); - }; - - /** - * A file hasher that returns the last modified time provided by the OS. - * - *

This only works reliably on certain file systems and JDKs that support at least - * millisecond-level precision. - */ - FileHasher LAST_MODIFIED_TIME = - path -> { - Instant modifyTime = Files.getLastModifiedTime(path).toInstant(); - ByteBuffer buffer = ByteBuffer.allocate(2 * Long.BYTES); - buffer.putLong(modifyTime.getEpochSecond()); - buffer.putLong(modifyTime.getNano()); - return new HashCode(buffer.array()); - }; - - HashCode hash(Path path) throws IOException; -} diff --git a/metals/src/main/java/scala/meta/internal/watcher/hashing/HashCode.java b/metals/src/main/java/scala/meta/internal/watcher/hashing/HashCode.java deleted file mode 100644 index d0bcbb724c0..00000000000 --- a/metals/src/main/java/scala/meta/internal/watcher/hashing/HashCode.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * NOTE: That this class is taken verbatim from io.methvin:directory-watcher. - * Seeing that we have replaced the directory-watcher functionality with swoval - * we are instead just re-utilizing the few DirectoryChangeEvent related - * classes. - */ -package scala.meta.internal.watcher.hashing; - -import java.nio.ByteBuffer; -import java.util.Arrays; -import java.util.Formatter; - -/** A class representing the hash code of a file. */ -public class HashCode { - private final byte[] value; - - public static HashCode fromBytes(byte[] value) { - return new HashCode(Arrays.copyOf(value, value.length)); - } - - public static HashCode fromLong(long value) { - ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); - buffer.putLong(value); - return new HashCode(buffer.array()); - } - - public static HashCode empty() { - return new HashCode(new byte[0]); - } - - HashCode(byte[] value) { - this.value = value; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - HashCode hashCode = (HashCode) o; - return Arrays.equals(value, hashCode.value); - } - - @Override - public int hashCode() { - return Arrays.hashCode(value); - } - - @Override - public String toString() { - Formatter formatter = new Formatter(); - for (byte b : value) { - formatter.format("%02x", b); - } - return formatter.toString(); - } -} diff --git a/metals/src/main/java/scala/meta/internal/watcher/hashing/Murmur3F.java b/metals/src/main/java/scala/meta/internal/watcher/hashing/Murmur3F.java deleted file mode 100644 index 1c6f4a35185..00000000000 --- a/metals/src/main/java/scala/meta/internal/watcher/hashing/Murmur3F.java +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Code adapted from Greenrobot Essentials Murmur3F.java (https://git.io/fAG0Z) - * - * Copyright (C) 2014-2016 Markus Junginger, greenrobot (http://greenrobot.org) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * NOTE: That this class is taken verbatim from io.methvin:directory-watcher. - * Seeing that we have replaced the directory-watcher functionality with swoval - * we are instead just re-utilizing the few DirectoryChangeEvent related - * classes. - */ -package scala.meta.internal.watcher.hashing; - -import java.math.BigInteger; -import java.util.zip.Checksum; - -/** Murmur3F (MurmurHash3_x64_128) */ -public class Murmur3F implements Checksum { - - private static final long C1 = 0x87c37b91114253d5L; - private static final long C2 = 0x4cf5ad432745937fL; - - private final long seed; - - private long h1; - private long h2; - private int length; - - private int partialPos; - private long partialK1; - private long partialK2; - - private boolean finished; - private long finishedH1; - private long finishedH2; - - public Murmur3F() { - seed = 0; - } - - public Murmur3F(int seed) { - this.seed = seed & 0xffffffffL; // unsigned 32 bit -> long - h1 = h2 = this.seed; - } - - @Override - public void update(int b) { - finished = false; - switch (partialPos) { - case 0: - partialK1 = 0xff & b; - break; - case 1: - partialK1 |= (0xff & b) << 8; - break; - case 2: - partialK1 |= (0xff & b) << 16; - break; - case 3: - partialK1 |= (0xffL & b) << 24; - break; - case 4: - partialK1 |= (0xffL & b) << 32; - break; - case 5: - partialK1 |= (0xffL & b) << 40; - break; - case 6: - partialK1 |= (0xffL & b) << 48; - break; - case 7: - partialK1 |= (0xffL & b) << 56; - break; - case 8: - partialK2 = 0xff & b; - break; - case 9: - partialK2 |= (0xff & b) << 8; - break; - case 10: - partialK2 |= (0xff & b) << 16; - break; - case 11: - partialK2 |= (0xffL & b) << 24; - break; - case 12: - partialK2 |= (0xffL & b) << 32; - break; - case 13: - partialK2 |= (0xffL & b) << 40; - break; - case 14: - partialK2 |= (0xffL & b) << 48; - break; - case 15: - partialK2 |= (0xffL & b) << 56; - break; - } - - partialPos++; - if (partialPos == 16) { - applyKs(partialK1, partialK2); - partialPos = 0; - } - length++; - } - - public void update(byte[] b) { - update(b, 0, b.length); - } - - @Override - public void update(byte[] b, int off, int len) { - finished = false; - while (partialPos != 0 && len > 0) { - update(b[off]); - off++; - len--; - } - - int remainder = len & 0xF; - int stop = off + len - remainder; - for (int i = off; i < stop; i += 16) { - long k1 = getLongLE(b, i); - long k2 = getLongLE(b, i + 8); - applyKs(k1, k2); - } - length += stop - off; - - for (int i = 0; i < remainder; i++) { - update(b[stop + i]); - } - } - - private void applyKs(long k1, long k2) { - k1 *= C1; - k1 = Long.rotateLeft(k1, 31); - k1 *= C2; - h1 ^= k1; - - h1 = Long.rotateLeft(h1, 27); - h1 += h2; - h1 = h1 * 5 + 0x52dce729; - - k2 *= C2; - k2 = Long.rotateLeft(k2, 33); - k2 *= C1; - h2 ^= k2; - - h2 = Long.rotateLeft(h2, 31); - h2 += h1; - h2 = h2 * 5 + 0x38495ab5; - } - - private void checkFinished() { - if (!finished) { - finished = true; - finishedH1 = h1; - finishedH2 = h2; - if (partialPos > 0) { - if (partialPos > 8) { - long k2 = partialK2 * C2; - k2 = Long.rotateLeft(k2, 33); - k2 *= C1; - finishedH2 ^= k2; - } - long k1 = partialK1 * C1; - k1 = Long.rotateLeft(k1, 31); - k1 *= C2; - finishedH1 ^= k1; - } - - finishedH1 ^= length; - finishedH2 ^= length; - - finishedH1 += finishedH2; - finishedH2 += finishedH1; - - finishedH1 = fmix64(finishedH1); - finishedH2 = fmix64(finishedH2); - - finishedH1 += finishedH2; - finishedH2 += finishedH1; - } - } - - private long fmix64(long k) { - k ^= k >>> 33; - k *= 0xff51afd7ed558ccdL; - k ^= k >>> 33; - k *= 0xc4ceb9fe1a85ec53L; - k ^= k >>> 33; - return k; - } - - @Override - /** - * Returns the lower 64 bits of the 128 bit hash (you can use just this value this as a 64 bit - * hash). - */ - public long getValue() { - checkFinished(); - return finishedH1; - } - - /** Returns the higher 64 bits of the 128 bit hash. */ - public long getValueHigh() { - checkFinished(); - return finishedH2; - } - - /** Positive value. */ - public BigInteger getValueBigInteger() { - byte[] bytes = getValueBytesBigEndian(); - return new BigInteger(1, bytes); - } - - /** Padded with leading 0s to ensure length of 32. */ - public String getValueHexString() { - checkFinished(); - return getPaddedHexString(finishedH2) + getPaddedHexString(finishedH1); - } - - private String getPaddedHexString(long value) { - String string = Long.toHexString(value); - while (string.length() < 16) { - string = '0' + string; - } - return string; - } - - public byte[] getValueBytesBigEndian() { - checkFinished(); - byte[] bytes = new byte[16]; - for (int i = 0; i < 8; i++) { - bytes[i] = (byte) ((finishedH2 >>> (56 - i * 8)) & 0xff); - } - for (int i = 0; i < 8; i++) { - bytes[8 + i] = (byte) ((finishedH1 >>> (56 - i * 8)) & 0xff); - } - return bytes; - } - - public byte[] getValueBytesLittleEndian() { - checkFinished(); - byte[] bytes = new byte[16]; - for (int i = 0; i < 8; i++) { - bytes[i] = (byte) ((finishedH1 >>> (i * 8)) & 0xff); - } - for (int i = 0; i < 8; i++) { - bytes[8 + i] = (byte) ((finishedH2 >>> (i * 8)) & 0xff); - } - return bytes; - } - - @Override - public void reset() { - h1 = h2 = seed; - length = 0; - partialPos = 0; - finished = false; - - // The remainder is not really necessary, but looks nicer when debugging - partialK1 = partialK2 = 0; - finishedH1 = finishedH2 = 0; - } - - private long getLongLE(byte[] bytes, int index) { - return (bytes[index] & 0xff) - | ((bytes[index + 1] & 0xff) << 8) - | ((bytes[index + 2] & 0xff) << 16) - | ((bytes[index + 3] & 0xffL) << 24) - | ((bytes[index + 4] & 0xffL) << 32) - | ((bytes[index + 5] & 0xffL) << 40) - | ((bytes[index + 6] & 0xffL) << 48) - | (((long) bytes[index + 7]) << 56); - } -} diff --git a/metals/src/main/scala/scala/meta/internal/metals/FileWatcher.scala b/metals/src/main/scala/scala/meta/internal/metals/FileWatcher.scala deleted file mode 100644 index 35b7cdf4f5a..00000000000 --- a/metals/src/main/scala/scala/meta/internal/metals/FileWatcher.scala +++ /dev/null @@ -1,150 +0,0 @@ -package scala.meta.internal.metals - -import java.io.IOException -import java.nio.file.Path -import java.util.concurrent.atomic.AtomicReference - -import scala.collection.mutable - -import scala.meta.internal.metals.MetalsEnrichments._ -import scala.meta.internal.watcher.DirectoryChangeEvent -import scala.meta.internal.watcher.DirectoryChangeEvent.EventType -import scala.meta.internal.watcher.hashing.FileHasher -import scala.meta.internal.watcher.hashing.HashCode -import scala.meta.io.AbsolutePath - -import com.swoval.files.FileTreeDataViews.CacheObserver -import com.swoval.files.FileTreeDataViews.Converter -import com.swoval.files.FileTreeDataViews.Entry -import com.swoval.files.FileTreeRepositories -import com.swoval.files.FileTreeRepository -import com.swoval.files.TypedPath - -/** - * Handles file watching of interesting files in this build. - * - * Tries to minimize file events by dynamically watching only relevant directories for - * the structure of the build. We don't use the LSP dynamic file watcher capability because - * - * 1. the glob syntax is not defined in the LSP spec making it difficult to deliver a - * consistent file watching experience with all editor clients on all operating systems. - * 2. we may have a lot of file watching events and it's presumably less overhead to - * get the notifications directly from the OS instead of through the editor via LSP. - * - * Given we rely on file watching for critical functionality like Goto Definition and it's - * really difficult to reproduce/investigate file watching issues, I think it's best to - * have a single file watching solution that we have control over. - * - * This class does not watch for changes in `*.sbt` files in the workspace directory and - * in the `project/`. Those notifications are nice-to-have, but not critical. The library we are - * using https://github.com/gmethvin/directory-watcher only supports recursive directory meaning - * we would have to watch the workspace directory, resulting in a LOT of redundant file events. - * Editors are free to send `workspace/didChangedWatchedFiles` notifications for these directories. - */ -final class FileWatcher( - buildTargets: BuildTargets, - didChangeWatchedFiles: DirectoryChangeEvent => Unit -) extends Cancelable { - private def newRepository: FileTreeRepository[HashCode] = { - val hasher = FileHasher.DEFAULT_FILE_HASHER - val converter: Converter[HashCode] = (path: TypedPath) => - try hasher.hash(path.getPath) - catch { case _: IOException => HashCode.empty() } - val repo = FileTreeRepositories.get(converter, /*follow symlinks*/ true) - def entryToEvent(kind: EventType, entry: Entry[_]): DirectoryChangeEvent = - new DirectoryChangeEvent(kind, entry.getTypedPath.getPath, 1) - repo.addCacheObserver(new CacheObserver[HashCode] { - override def onCreate(entry: Entry[HashCode]): Unit = { - didChangeWatchedFiles(entryToEvent(EventType.CREATE, entry)) - } - override def onDelete(entry: Entry[HashCode]): Unit = { - didChangeWatchedFiles(entryToEvent(EventType.DELETE, entry)) - } - override def onUpdate( - previous: Entry[HashCode], - current: Entry[HashCode] - ): Unit = { - if (previous.getValue != current.getValue) { - didChangeWatchedFiles(entryToEvent(EventType.MODIFY, current)) - } - } - override def onError(ex: IOException) = {} - }) - repo - } - private val repository = new AtomicReference[FileTreeRepository[HashCode]] - - override def cancel(): Unit = { - repository.getAndSet(null) match { - case null => - case r => r.close() - } - } - - def restart(): Unit = { - val sourceDirectoriesToWatch = mutable.Set.empty[Path] - val sourceFilesToWatch = mutable.Set.empty[Path] - val createdSourceDirectories = new java.util.ArrayList[AbsolutePath]() - def watch(path: AbsolutePath, isSource: Boolean): Unit = { - if (!path.isDirectory && !path.isFile) { - val pathToCreate = if (path.isScalaOrJava) { - AbsolutePath(path.toNIO.getParent()) - } else { - path - } - val createdPaths = pathToCreate.createAndGetDirectories() - // this is a workaround for MacOS, it will continue watching - // directories even if they are removed, however it doesn't - // work on some other systems like Linux - if (isSource) { - createdPaths.foreach(createdSourceDirectories.add) - } - } - if (buildTargets.isInsideSourceRoot(path)) { - () // Do nothing, already covered by a source root - } else if (path.isScalaOrJava) { - sourceFilesToWatch.add(path.toNIO) - } else { - sourceDirectoriesToWatch.add(path.toNIO) - } - } - // Watch the source directories for "goto definition" index. - buildTargets.sourceRoots.foreach(watch(_, isSource = true)) - buildTargets.sourceItems.foreach(watch(_, isSource = true)) - buildTargets.scalacOptions.foreach { item => - for { - scalaInfo <- buildTargets.scalaInfo(item.getTarget) - } { - val targetroot = item.targetroot(scalaInfo.getScalaVersion) - if (!targetroot.isJar) { - // Watch META-INF/semanticdb directories for "find references" index. - watch( - targetroot.resolve(Directories.semanticdb), - isSource = false - ) - } - } - - } - val repo = repository.get match { - case null => - val r = newRepository - repository.set(r) - r - case r => r - } - // The second parameter of repo.register is the recursive depth of the watch. - // A value of -1 means only watch this exact path. A value of 0 means only - // watch the immediate children of the path. A value of 1 means watch the - // children of the path and each child's children and so on. - sourceDirectoriesToWatch.foreach(repo.register(_, Int.MaxValue)) - sourceFilesToWatch.foreach(repo.register(_, -1)) - - // reverse sorting here is necessary to delete parent paths at the end - createdSourceDirectories.asScala.sortBy(_.toNIO).reverse.foreach { dir => - if (dir.isEmptyDirectory) { - dir.delete() - } - } - } -} diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala index bc3012f1687..31916101710 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala @@ -59,6 +59,9 @@ import scala.meta.internal.metals.debug.DebugProvider import scala.meta.internal.metals.formatting.OnTypeFormattingProvider import scala.meta.internal.metals.formatting.RangeFormattingProvider import scala.meta.internal.metals.newScalaFile.NewFileProvider +import scala.meta.internal.metals.watcher.FileWatcher +import scala.meta.internal.metals.watcher.FileWatcherEvent +import scala.meta.internal.metals.watcher.FileWatcherEvent.EventType import scala.meta.internal.mtags._ import scala.meta.internal.parsing.ClassFinder import scala.meta.internal.parsing.DocumentSymbolProvider @@ -70,8 +73,6 @@ import scala.meta.internal.rename.RenameProvider import scala.meta.internal.semanticdb.Scala._ import scala.meta.internal.semver.SemVer import scala.meta.internal.tvp._ -import scala.meta.internal.watcher.DirectoryChangeEvent -import scala.meta.internal.watcher.DirectoryChangeEvent.EventType import scala.meta.internal.worksheets.DecorationWorksheetPublisher import scala.meta.internal.worksheets.WorksheetProvider import scala.meta.internal.worksheets.WorkspaceEditWorksheetPublisher @@ -181,7 +182,9 @@ class MetalsLanguageServer( ) private val fileWatcher = register( new FileWatcher( + () => workspace, buildTargets, + fileWatchFilter, params => didChangeWatchedFiles(params) ) ) @@ -1146,8 +1149,7 @@ class MetalsLanguageServer( .resolve(Directories.semanticdb) generatedFile <- semanticdb.listRecursive } { - val event = - new DirectoryChangeEvent(EventType.MODIFY, generatedFile.toNIO, 1) + val event = FileWatcherEvent.modify(generatedFile.toNIO) didChangeWatchedFiles(event).get() } } @@ -1235,47 +1237,55 @@ class MetalsLanguageServer( onChange(paths).asJava } - // This method is run the FileWatcher, so it should not do anything expensive on the main thread + /** + * This filter is an optimization and it is closely related to which files are processed + * in [[didChangeWatchedFiles]] + */ + private def fileWatchFilter(path: Path): Boolean = { + val abs = AbsolutePath(path) + abs.isScalaOrJava || abs.isSemanticdb || abs.isBuild + } + + /** + * Callback that is executed on a file change event by the file watcher. + * + * Note that if you are adding processing of another kind of a file, + * be sure to include it in the [[fileWatchFilter]] + * + * This method is run synchronously in the FileWatcher, so it should not do anything expensive on the main thread + */ private def didChangeWatchedFiles( - event: DirectoryChangeEvent + event: FileWatcherEvent ): CompletableFuture[Unit] = { - if (event.eventType() == EventType.OVERFLOW && event.path() == null) { + val path = AbsolutePath(event.path) + val isScalaOrJava = path.isScalaOrJava + if (isScalaOrJava && event.eventType == EventType.Delete) { Future { - semanticDBIndexer.onOverflow() + diagnostics.didDelete(path) }.asJava - } else { - val path = AbsolutePath(event.path()) - val isScalaOrJava = path.isScalaOrJava - if (isScalaOrJava && event.eventType() == EventType.DELETE) { - Future { - diagnostics.didDelete(path) - }.asJava - } else if ( - isScalaOrJava && !savedFiles.isRecentlyActive(path) && !buffers - .contains(path) - ) { - event.eventType() match { - case EventType.CREATE => - buildTargets.onCreate(path) - case _ => - } - onChange(List(path)).asJava - } else if (path.isSemanticdb) { - Future { - event.eventType() match { - case EventType.DELETE => - semanticDBIndexer.onDelete(event.path()) - case EventType.CREATE | EventType.MODIFY => - semanticDBIndexer.onChange(event.path()) - case EventType.OVERFLOW => - semanticDBIndexer.onOverflow(event.path()) - } - }.asJava - } else if (path.isBuild) { - onBuildChanged(List(path)).ignoreValue.asJava - } else { - CompletableFuture.completedFuture(()) + } else if ( + isScalaOrJava && !savedFiles.isRecentlyActive(path) && !buffers + .contains(path) + ) { + event.eventType match { + case EventType.Create => + buildTargets.onCreate(path) + case _ => } + onChange(List(path)).asJava + } else if (path.isSemanticdb) { + Future { + event.eventType match { + case EventType.Delete => + semanticDBIndexer.onDelete(event.path) + case EventType.Create | EventType.Modify => + semanticDBIndexer.onChange(event.path) + } + }.asJava + } else if (path.isBuild) { + onBuildChanged(List(path)).ignoreValue.asJava + } else { + CompletableFuture.completedFuture(()) } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/SemanticdbIndexer.scala b/metals/src/main/scala/scala/meta/internal/metals/SemanticdbIndexer.scala index 3e2e911cad5..a217435758b 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/SemanticdbIndexer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/SemanticdbIndexer.scala @@ -45,33 +45,6 @@ class SemanticdbIndexer( implementationProvider.onDelete(file) } - /** - * Handle EventType.OVERFLOW, meaning we lost file events for a given path. - * - * We walk up the file tree to the parent `META-INF/semanticdb` parent directory - * and re-index all of its `*.semanticdb` children. - */ - def onOverflow(path: Path): Unit = { - path.semanticdbRoot.foreach(onChangeDirectory(_)) - } - - /** - * Handle EventType.OVERFLOW, meaning we lost file events, when we don't know the path. - * We walk up the file tree for all targets `META-INF/semanticdb` directory - * and re-index all of its `*.semanticdb` children. - */ - def onOverflow(): Unit = { - for { - item <- buildTargets.scalacOptions - scalaInfo <- buildTargets.scalaInfo(item.getTarget) - } { - val targetroot = item.targetroot(scalaInfo.getScalaVersion) - if (!targetroot.isJar) { - onChangeDirectory(targetroot.resolve(Directories.semanticdb).toNIO) - } - } - } - private def onChangeDirectory(dir: Path): Unit = { if (Files.isDirectory(dir)) { val stream = Files.walk(dir) diff --git a/metals/src/main/scala/scala/meta/internal/metals/watcher/FileWatcher.scala b/metals/src/main/scala/scala/meta/internal/metals/watcher/FileWatcher.scala new file mode 100644 index 00000000000..dc6ccc8022e --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/watcher/FileWatcher.scala @@ -0,0 +1,222 @@ +package scala.meta.internal.metals.watcher + +import java.io.BufferedInputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +import scala.collection.mutable +import scala.util.hashing.MurmurHash3 + +import scala.meta.internal.metals.BuildTargets +import scala.meta.internal.metals.Cancelable +import scala.meta.internal.metals.Directories +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.io.AbsolutePath + +import com.swoval.files.FileTreeDataViews.CacheObserver +import com.swoval.files.FileTreeDataViews.Converter +import com.swoval.files.FileTreeDataViews.Entry +import com.swoval.files.FileTreeRepositories +import com.swoval.files.FileTreeRepository + +/** + * Watch selected files and execute a callback on file events. + * + * This class recursively watches selected directories and selected files. + * File events can be further filtered by the `watchFiler` parameter, which can speed by watching for changes + * by limiting the number of files that need to be hashed. + * + * We don't use the LSP dynamic file watcher capability because + * + * 1. the glob syntax is not defined in the LSP spec making it difficult to deliver a + * consistent file watching experience with all editor clients on all operating systems. + * 2. we may have a lot of file watching events and it's presumably less overhead to + * get the notifications directly from the OS instead of through the editor via LSP. + */ +final class FileWatcher( + workspaceDeferred: () => AbsolutePath, + buildTargets: BuildTargets, + watchFilter: Path => Boolean, + onFileWatchEvent: FileWatcherEvent => Unit +) extends Cancelable { + import FileWatcher._ + + private var disposeAction: Option[() => Unit] = None + + override def cancel(): Unit = { + disposeAction.map(_.apply()) + disposeAction = None + } + + def restart(): Unit = { + disposeAction.map(_.apply()) + + val newDispose = startWatch( + workspaceDeferred().toNIO, + collectFilesToWatch(buildTargets), + onFileWatchEvent, + watchFilter + ) + disposeAction = Some(newDispose) + } +} + +object FileWatcher { + type Hash = Int + + private case class FilesToWatch( + sourceFiles: Set[Path], + sourceDirectories: Set[Path], + semanticdDirectories: Set[Path] + ) + + private def collectFilesToWatch(buildTargets: BuildTargets): FilesToWatch = { + val sourceDirectoriesToWatch = mutable.Set.empty[Path] + val sourceFilesToWatch = mutable.Set.empty[Path] + + def collect(path: AbsolutePath): Unit = { + if (buildTargets.isInsideSourceRoot(path)) { + () // Do nothing, already covered by a source root + } else if (path.isScalaOrJava) { + sourceFilesToWatch.add(path.toNIO) + } else { + sourceDirectoriesToWatch.add(path.toNIO) + } + } + // Watch the source directories for "goto definition" index. + buildTargets.sourceRoots.foreach(collect) + buildTargets.sourceItems.foreach(collect) + val semanticdbs = buildTargets.scalacOptions.flatMap { item => + for { + scalaInfo <- buildTargets.scalaInfo(item.getTarget) + targetroot = item.targetroot(scalaInfo.getScalaVersion) + path = targetroot.resolve(Directories.semanticdb) if !targetroot.isJar + } yield path.toNIO + } + + FilesToWatch( + sourceFilesToWatch.toSet, + sourceDirectoriesToWatch.toSet, + semanticdbs.toSet + ) + } + + /** + * Start file watching + * + * Contains platform specific file watch initialization logic + * + * @param workspace current project workspace directory + * @param filesToWatch source files and directories to watch + * @param callback to execute on FileWatchEvent + * @param watchFilter predicate that filters which files + * generate a FileWatchEvent on create/delete/change + * @return a dispose action resources used by file watching + */ + private def startWatch( + workspace: Path, + filesToWatch: FilesToWatch, + callback: FileWatcherEvent => Unit, + watchFilter: Path => Boolean + ): () => Unit = { + if (scala.util.Properties.isMac) { + // Due to a hard limit on the number of FSEvents streams that can be opened on macOS, + // only the root workspace directory is registered for a recursive watch. + // However, the events are then filtered to receive only relevant events + // and also to hash only revelevant files when watching for changes + + val trie = PathTrie( + filesToWatch.sourceFiles ++ filesToWatch.sourceDirectories ++ filesToWatch.semanticdDirectories + ) + val isWatched = trie.containsPrefixOf _ + + val repo = initFileTreeRepository( + path => watchFilter(path) && isWatched(path), + callback + ) + repo.register(workspace, Int.MaxValue) + () => repo.close() + } else { + // Other OSes register all the files and directories individually + val repo = initFileTreeRepository(watchFilter, callback) + + // TODO(@pvid) swoval's FileTreeRepository should be able to create watch + // for files/directories that do not exist yet. However, there is an issue + // with watching **semanticdb** files when not creating source directories. + // I was not able to diagnose the issue. + // If you'd like to dive deeper into, try to remove the file creation and deletion + // and run some tests with `-Dswoval.log.level=debug` to see which files are registered + // and which file events are received. + val directoriesToCreate = + filesToWatch.sourceDirectories ++ filesToWatch.sourceFiles.map( + _.getParent() + ) + + val createdDirectories = directoriesToCreate.flatMap(path => + AbsolutePath(path).createAndGetDirectories() + ) + + filesToWatch.sourceDirectories.foreach(repo.register(_, Int.MaxValue)) + filesToWatch.semanticdDirectories.foreach(repo.register(_, Int.MaxValue)) + filesToWatch.sourceFiles.foreach(repo.register(_, -1)) + + createdDirectories.toSeq.sortBy(_.toNIO).reverse.foreach { dir => + if (dir.isEmptyDirectory) dir.delete() + } + + () => repo.close() + } + } + + private def initFileTreeRepository( + watchFilter: Path => Boolean, + callback: FileWatcherEvent => Unit + ): FileTreeRepository[Hash] = { + val converter: Converter[Hash] = typedPath => + hashFile( + typedPath.getPath(), + watchFilter + ) + val repo = FileTreeRepositories.get(converter, /*follow symlinks*/ true) + + repo.addCacheObserver(new CacheObserver[Hash] { + override def onCreate(entry: Entry[Hash]): Unit = { + val path = entry.getTypedPath().getPath() + if (watchFilter(path)) callback(FileWatcherEvent.create(path)) + } + override def onDelete(entry: Entry[Hash]): Unit = { + val path = entry.getTypedPath().getPath() + if (watchFilter(path)) callback(FileWatcherEvent.delete(path)) + } + override def onUpdate( + previous: Entry[Hash], + current: Entry[Hash] + ): Unit = { + val path = current.getTypedPath().getPath() + if (previous.getValue != current.getValue && watchFilter(path)) { + callback(FileWatcherEvent.modify(path)) + } + } + override def onError(ex: IOException) = {} + }) + repo + } + + private def hashFile(path: Path, hashFilter: Path => Boolean): Hash = { + if (hashFilter(path)) { + val inputStream = new BufferedInputStream(Files.newInputStream(path)) + try { + MurmurHash3.orderedHash( + Stream.continually(inputStream.read()).takeWhile(_ != -1) + ) + } catch { + case _: IOException => 0 + } finally { + inputStream.close() + } + } else { + 0 + } + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/watcher/FileWatcherEvent.scala b/metals/src/main/scala/scala/meta/internal/metals/watcher/FileWatcherEvent.scala new file mode 100644 index 00000000000..396dd468c35 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/watcher/FileWatcherEvent.scala @@ -0,0 +1,25 @@ +package scala.meta.internal.metals.watcher + +import java.nio.file.Path + +final case class FileWatcherEvent( + eventType: FileWatcherEvent.EventType, + path: Path +) + +object FileWatcherEvent { + sealed trait EventType + + object EventType { + case object Create extends EventType + case object Modify extends EventType + case object Delete extends EventType + } + + def create(path: Path): FileWatcherEvent = + FileWatcherEvent(EventType.Create, path) + def modify(path: Path): FileWatcherEvent = + FileWatcherEvent(EventType.Modify, path) + def delete(path: Path): FileWatcherEvent = + FileWatcherEvent(EventType.Delete, path) +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/watcher/PathTrie.scala b/metals/src/main/scala/scala/meta/internal/metals/watcher/PathTrie.scala new file mode 100644 index 00000000000..7d6aea24178 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/watcher/PathTrie.scala @@ -0,0 +1,73 @@ +package scala.meta.internal.metals.watcher + +import java.nio.file.Path + +import scala.collection.JavaConverters._ + +import scala.meta.internal.metals.watcher.PathTrie._ + +/** + * Trie representation of a set of paths + * + * Each path segment is represented by a node in a tree. + * Can be used to efficiently check if the trie contains + * a prefix of a given path + */ +class PathTrie private (root: Node) { + + def containsPrefixOf(path: Path): Boolean = { + val segments: List[String] = toSegments(path) + + def go(segments: List[String], node: Node): Boolean = { + (segments, node) match { + case (_, Leaf) => true + case (Nil, _) => false + case (head :: tail, Single(segment, child)) => + if (head == segment) go(tail, child) else false + case (head :: tail, Multi(children)) => + children.get(head).fold(false)(go(tail, _)) + } + } + go(segments, root) + } +} + +object PathTrie { + private sealed trait Node + + private case object Leaf extends Node + private case class Single(segment: String, child: Node) extends Node + private case class Multi(children: Map[String, Node]) extends Node + + def apply(paths: Set[Path]): PathTrie = { + def construct(paths: Set[List[String]]): Node = { + val groupedNonEmptyPaths = + paths + .filter(_.nonEmpty) + .groupBy(_.head) + .mapValues(_.map(_.tail)) + .toList + + groupedNonEmptyPaths match { + case Nil => Leaf + case singleGroup :: Nil => + Single(singleGroup._1, construct(singleGroup._2)) + case _ => + val children = groupedNonEmptyPaths.map { + case (topSegment, tailSegments) => + topSegment -> construct(tailSegments) + }.toMap + Multi(children) + } + } + + new PathTrie( + construct( + paths.map(toSegments) + ) + ) + } + + private def toSegments(path: Path): List[String] = + path.iterator().asScala.map(_.toString()).toList +}