Skip to content

Commit

Permalink
Add support for multiple workspaces (#160)
Browse files Browse the repository at this point in the history
* Add support for multiple workspaces

Previously, the language server only knew about a single workspace root,
so if your editor was using [WorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_workspaceFolders)
the server would just pick the first one, and not load any others. This
commit allows the server to load multiple workspaces. The primary
challenge was handling state changes to individual workspaces
independently. We use client-side file watchers and the
`didChangeWatchedFiles` notification to make sure projects are up to
date with new and deleted Smithy files, and any changes to build files
(i.e. smithy-build.json). `didChangeWatchedFiles` sends a flat list of
file events - not partitioned by workspace - so we have to figure out
which projects each change applies to. This is done by creating a
`PathMatcher` for each project's smithy files and build files, then
matching on each file event. This way, we can apply changes to each
individual project, rather than reloading everything. Selectors were
also updated to select from all available projects.

* Fix file patterns for windows

The file patterns we were using for telling the client which files to
watch, and to match file events in `didChangeWatchedFiles` to projects,
were not working properly on windows because they didn't use the correct
file separator.
  • Loading branch information
milesziemer authored Sep 5, 2024
1 parent 779ef70 commit 5ef6b14
Show file tree
Hide file tree
Showing 14 changed files with 1,041 additions and 289 deletions.
238 changes: 130 additions & 108 deletions src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,17 @@

package software.amazon.smithy.lsp.handler;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions;
import org.eclipse.lsp4j.FileSystemWatcher;
import org.eclipse.lsp4j.Registration;
import org.eclipse.lsp4j.Unregistration;
import org.eclipse.lsp4j.WatchKind;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import software.amazon.smithy.lsp.project.Project;
import software.amazon.smithy.lsp.project.ProjectConfigLoader;
import software.amazon.smithy.lsp.project.ProjectFilePatterns;

/**
* Handles computing the {@link Registration}s and {@link Unregistration}s for
Expand All @@ -40,48 +37,22 @@ public final class FileWatcherRegistrationHandler {
private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles";
private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles";
private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles";
private static final List<Registration> BUILD_FILE_WATCHER_REGISTRATIONS;
private static final List<Unregistration> SMITHY_FILE_WATCHER_UNREGISTRATIONS;

static {
// smithy-build.json + .smithy-project.json + build exts
int buildFileWatcherCount = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length;
List<FileSystemWatcher> buildFileWatchers = new ArrayList<>(buildFileWatcherCount);
buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_BUILD)));
buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_PROJECT)));
for (String ext : ProjectConfigLoader.SMITHY_BUILD_EXTS) {
buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ext)));
}

BUILD_FILE_WATCHER_REGISTRATIONS = Collections.singletonList(new Registration(
WATCH_BUILD_FILES_ID,
WATCH_FILES_METHOD,
new DidChangeWatchedFilesRegistrationOptions(buildFileWatchers)));

SMITHY_FILE_WATCHER_UNREGISTRATIONS = Collections.singletonList(new Unregistration(
WATCH_SMITHY_FILES_ID,
WATCH_FILES_METHOD));
}
private static final List<Unregistration> SMITHY_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration(
WATCH_SMITHY_FILES_ID,
WATCH_FILES_METHOD));

private FileWatcherRegistrationHandler() {
}

/**
* @return The registrations to watch for build file changes
* @param projects The projects to get registrations for
* @return The registrations to watch for Smithy file changes across all projects
*/
public static List<Registration> getBuildFileWatcherRegistrations() {
return BUILD_FILE_WATCHER_REGISTRATIONS;
}

/**
* @param project The Project to get registrations for
* @return The registrations to watch for Smithy file changes
*/
public static List<Registration> getSmithyFileWatcherRegistrations(Project project) {
List<FileSystemWatcher> smithyFileWatchers = Stream.concat(project.sources().stream(),
project.imports().stream())
.map(FileWatcherRegistrationHandler::smithyFileWatcher)
.collect(Collectors.toList());
public static List<Registration> getSmithyFileWatcherRegistrations(Collection<Project> projects) {
List<FileSystemWatcher> smithyFileWatchers = projects.stream()
.flatMap(project -> ProjectFilePatterns.getSmithyFileWatchPatterns(project).stream())
.map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), SMITHY_WATCH_FILE_KIND))
.toList();

return Collections.singletonList(new Registration(
WATCH_SMITHY_FILES_ID,
Expand All @@ -96,17 +67,19 @@ public static List<Unregistration> getSmithyFileWatcherUnregistrations() {
return SMITHY_FILE_WATCHER_UNREGISTRATIONS;
}

private static FileSystemWatcher smithyFileWatcher(Path path) {
String glob = path.toString();
if (!glob.endsWith(".smithy") && !glob.endsWith(".json")) {
// we have a directory
if (glob.endsWith("/")) {
glob = glob + "**/*.{smithy,json}";
} else {
glob = glob + "/**/*.{smithy,json}";
}
}
// Watch the absolute path, either a directory or file
return new FileSystemWatcher(Either.forLeft(glob), SMITHY_WATCH_FILE_KIND);
/**
* @param projects The projects to get registrations for
* @return The registrations to watch for build file changes across all projects
*/
public static List<Registration> getBuildFileWatcherRegistrations(Collection<Project> projects) {
List<FileSystemWatcher> watchers = projects.stream()
.map(ProjectFilePatterns::getBuildFilesWatchPattern)
.map(pattern -> new FileSystemWatcher(Either.forLeft(pattern)))
.toList();

return Collections.singletonList(new Registration(
WATCH_BUILD_FILES_ID,
WATCH_FILES_METHOD,
new DidChangeWatchedFilesRegistrationOptions(watchers)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp.project;

import java.util.Set;

/**
* File changes to a {@link Project}.
*
* @param changedBuildFileUris The uris of changed build files
* @param createdSmithyFileUris The uris of created Smithy files
* @param deletedSmithyFileUris The uris of deleted Smithy files
*/
public record ProjectChanges(
Set<String> changedBuildFileUris,
Set<String> createdSmithyFileUris,
Set<String> deletedSmithyFileUris
) {
/**
* @return Whether there are any changed build files
*/
public boolean hasChangedBuildFiles() {
return !changedBuildFileUris.isEmpty();
}

/**
* @return Whether there are any changed Smithy files
*/
public boolean hasChangedSmithyFiles() {
return !createdSmithyFileUris.isEmpty() || !deletedSmithyFileUris.isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp.project;

import java.io.File;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* Utility methods for creating file patterns corresponding to meaningful
* paths of a {@link Project}, such as sources and build files.
*/
public final class ProjectFilePatterns {
private static final int BUILD_FILE_COUNT = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length;

private ProjectFilePatterns() {
}

/**
* @param project The project to get watch patterns for
* @return A list of glob patterns used to watch Smithy files in the given project
*/
public static List<String> getSmithyFileWatchPatterns(Project project) {
return Stream.concat(project.sources().stream(), project.imports().stream())
.map(path -> getSmithyFilePattern(path, true))
.toList();
}

/**
* @param project The project to get a path matcher for
* @return A path matcher that can check if Smithy files belong to the given project
*/
public static PathMatcher getSmithyFilesPathMatcher(Project project) {
String pattern = Stream.concat(project.sources().stream(), project.imports().stream())
.map(path -> getSmithyFilePattern(path, false))
.collect(Collectors.joining(","));
return FileSystems.getDefault().getPathMatcher("glob:{" + pattern + "}");
}

/**
* @param project The project to get the watch pattern for
* @return A glob pattern used to watch build files in the given project
*/
public static String getBuildFilesWatchPattern(Project project) {
Path root = project.root();
String buildJsonPattern = escapeBackslashes(root.resolve(ProjectConfigLoader.SMITHY_BUILD).toString());
String projectJsonPattern = escapeBackslashes(root.resolve(ProjectConfigLoader.SMITHY_PROJECT).toString());

List<String> patterns = new ArrayList<>(BUILD_FILE_COUNT);
patterns.add(buildJsonPattern);
patterns.add(projectJsonPattern);
for (String buildExt : ProjectConfigLoader.SMITHY_BUILD_EXTS) {
patterns.add(escapeBackslashes(root.resolve(buildExt).toString()));
}

return "{" + String.join(",", patterns) + "}";
}

/**
* @param project The project to get a path matcher for
* @return A path matcher that can check if a file is a build file belonging to the given project
*/
public static PathMatcher getBuildFilesPathMatcher(Project project) {
// Watch pattern is the same as the pattern used for matching
String pattern = getBuildFilesWatchPattern(project);
return FileSystems.getDefault().getPathMatcher("glob:" + pattern);
}

// When computing the pattern used for telling the client which files to watch, we want
// to only watch .smithy/.json files. We don't need in the PathMatcher pattern (and it
// is impossible anyway because we can't have a nested pattern).
private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) {
String glob = path.toString();
if (glob.endsWith(".smithy") || glob.endsWith(".json")) {
return escapeBackslashes(glob);
}

if (!glob.endsWith(File.separator)) {
glob += File.separator;
}
glob += "**";

if (isWatcherPattern) {
glob += ".{smithy,json}";
}

return escapeBackslashes(glob);
}

// In glob patterns, '\' is an escape character, so it needs to escaped
// itself to work as a separator (i.e. for windows)
private static String escapeBackslashes(String pattern) {
return pattern.replace("\\", "\\\\");
}
}
Loading

0 comments on commit 5ef6b14

Please sign in to comment.