Skip to content

Commit

Permalink
Support projects in subdirectories (#167)
Browse files Browse the repository at this point in the history
* Support projects in subdirectories

This commit makes the language server work with Smithy projects in
subdirectories of the folder (or folders, in the case of e.g. vscode
workspace folders) open in the editor. I previously added support for
multiple _workspace folders_ (an LSP concept), but I assumed only one
_project_ (Smithy LS concept) per workspace folder. So this commit fixes
that mixing, allowing many projects per workspace folder.

Now, the language server will search through subdirectories of all workspace
folders (by default, one workspace folder is open in the client) to find
projects. Changes to build files, i.e. smithy-build.json,
.smithy-project.json, are now tracked at the workspace level, so you can
add a new project to an existing workspace.

I also did _some_ cleanup of the project/workspace synchronization code,
and moved some things around. A note on some tests: I'm using a
`Files.createTempDirectory`, and adding the `TestWorkspace` as a subdir.
In a follow-up commit, I will be changing `TestWorkspace` to be
something like `TestProject`, which is more accurate. I didn't include
it here to avoid a bunch of noise.

* Fix deadlock in didChangeWorkspaceFolders

Blocking on the future creating the progress token with the client means the
server can't actually receive the response from the client for that request.
Tests don't catch this because the mock client is called directly,
rather than through the server proxy.

I decided to just remove the progress token code for now so
didChangeWorkspaceFolders can work at all, rather than trying to make
the method work asynchronously, which is a larger lift considering it
mutates the server state. That change is coming though.
  • Loading branch information
milesziemer authored Oct 28, 2024
1 parent b425716 commit 87154b0
Show file tree
Hide file tree
Showing 19 changed files with 745 additions and 221 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,31 @@
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp.project;
package software.amazon.smithy.lsp;

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;
import software.amazon.smithy.lsp.project.Project;
import software.amazon.smithy.lsp.project.ProjectConfigLoader;

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

private ProjectFilePatterns() {
final class FilePatterns {
private FilePatterns() {
}

/**
* @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) {
static List<String> getSmithyFileWatchPatterns(Project project) {
return Stream.concat(project.sources().stream(), project.imports().stream())
.map(path -> getSmithyFilePattern(path, true))
.toList();
Expand All @@ -38,45 +37,63 @@ public static List<String> getSmithyFileWatchPatterns(Project project) {
* @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) {
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 + "}");
return toPathMatcher("{" + pattern + "}");
}

/**
* @param project The project to get the watch pattern for
* @return A glob pattern used to watch build files in the given project
* @param root The root to get the watch pattern for
* @return A glob pattern used to watch build files in the given workspace
*/
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()));
}
static String getWorkspaceBuildFilesWatchPattern(Path root) {
return getBuildFilesPattern(root, true);
}

return "{" + String.join(",", patterns) + "}";
/**
* @param root The root to get a path matcher for
* @return A path matcher that can check if a file is a build file within the given workspace
*/
static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) {
String pattern = getWorkspaceBuildFilesWatchPattern(root);
return toPathMatcher(pattern);
}

/**
* @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);
static PathMatcher getProjectBuildFilesPathMatcher(Project project) {
String pattern = getBuildFilesPattern(project.root(), false);
return toPathMatcher(pattern);
}

private static PathMatcher toPathMatcher(String globPattern) {
return FileSystems.getDefault().getPathMatcher("glob:" + globPattern);
}

// Patterns for the workspace need to match on all build files in all subdirectories,
// whereas patterns for projects only look at the top level (because project locations
// are defined by the presence of these build files).
private static String getBuildFilesPattern(Path root, boolean isWorkspacePattern) {
String rootString = root.toString();
if (!rootString.endsWith(File.separator)) {
rootString += File.separator;
}

if (isWorkspacePattern) {
rootString += "**" + File.separator;
}

return escapeBackslashes(rootString + "{" + String.join(",", ProjectConfigLoader.PROJECT_BUILD_FILES) + "}");
}

// 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).
// to only watch .smithy/.json files. We don't need it in the PathMatcher pattern because
// we only need to match files, not listen for specific changes (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")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp.handler;
package software.amazon.smithy.lsp;

import java.nio.file.Path;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
Expand All @@ -15,7 +16,6 @@
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.ProjectFilePatterns;

/**
* Handles computing the {@link Registration}s and {@link Unregistration}s for
Expand All @@ -32,25 +32,31 @@
* everything, since these events should be rarer. But we can optimize it in the
* future.
*/
public final class FileWatcherRegistrationHandler {
final class FileWatcherRegistrations {
private static final Integer SMITHY_WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create;
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<Unregistration> SMITHY_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration(
WATCH_SMITHY_FILES_ID,
WATCH_FILES_METHOD));
private static final List<Unregistration> BUILD_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration(
WATCH_BUILD_FILES_ID,
WATCH_FILES_METHOD));

private FileWatcherRegistrationHandler() {
private FileWatcherRegistrations() {
}

/**
* Creates registrations to tell the client to watch for new or deleted
* Smithy files, specifically for files that are part of {@link Project}s.
*
* @param projects The projects to get registrations for
* @return The registrations to watch for Smithy file changes across all projects
*/
public static List<Registration> getSmithyFileWatcherRegistrations(Collection<Project> projects) {
static List<Registration> getSmithyFileWatcherRegistrations(Collection<Project> projects) {
List<FileSystemWatcher> smithyFileWatchers = projects.stream()
.flatMap(project -> ProjectFilePatterns.getSmithyFileWatchPatterns(project).stream())
.flatMap(project -> FilePatterns.getSmithyFileWatchPatterns(project).stream())
.map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), SMITHY_WATCH_FILE_KIND))
.toList();

Expand All @@ -63,17 +69,20 @@ public static List<Registration> getSmithyFileWatcherRegistrations(Collection<Pr
/**
* @return The unregistrations to stop watching for Smithy file changes
*/
public static List<Unregistration> getSmithyFileWatcherUnregistrations() {
static List<Unregistration> getSmithyFileWatcherUnregistrations() {
return SMITHY_FILE_WATCHER_UNREGISTRATIONS;
}

/**
* @param projects The projects to get registrations for
* @return The registrations to watch for build file changes across all projects
* Creates registrations to tell the client to watch for any build file
* changes, creations, or deletions, across all workspaces.
*
* @param workspaceRoots The roots of the workspaces to get registrations for
* @return The registrations to watch for build file changes across all workspaces
*/
public static List<Registration> getBuildFileWatcherRegistrations(Collection<Project> projects) {
List<FileSystemWatcher> watchers = projects.stream()
.map(ProjectFilePatterns::getBuildFilesWatchPattern)
static List<Registration> getBuildFileWatcherRegistrations(Collection<Path> workspaceRoots) {
List<FileSystemWatcher> watchers = workspaceRoots.stream()
.map(FilePatterns::getWorkspaceBuildFilesWatchPattern)
.map(pattern -> new FileSystemWatcher(Either.forLeft(pattern)))
.toList();

Expand All @@ -82,4 +91,8 @@ public static List<Registration> getBuildFileWatcherRegistrations(Collection<Pro
WATCH_FILES_METHOD,
new DidChangeWatchedFilesRegistrationOptions(watchers)));
}

static List<Unregistration> getBuildFileWatcherUnregistrations() {
return BUILD_FILE_WATCHER_UNREGISTRATIONS;
}
}
55 changes: 55 additions & 0 deletions src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp;

import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import software.amazon.smithy.lsp.project.ProjectConfigLoader;

/**
* Finds Project roots based on the location of smithy-build.json and .smithy-project.json.
*/
final class ProjectRootVisitor extends SimpleFileVisitor<Path> {
private static final PathMatcher PROJECT_ROOT_MATCHER = FileSystems.getDefault().getPathMatcher(
"glob:{" + ProjectConfigLoader.SMITHY_BUILD + "," + ProjectConfigLoader.SMITHY_PROJECT + "}");
private static final int MAX_VISIT_DEPTH = 10;

private final List<Path> roots = new ArrayList<>();

/**
* Walks through the file tree starting at {@code workspaceRoot}, collecting
* paths of Project roots.
*
* @param workspaceRoot Root of the workspace to find projects in
* @return A list of project roots
* @throws IOException If an I/O error is thrown while walking files
*/
static List<Path> findProjectRoots(Path workspaceRoot) throws IOException {
ProjectRootVisitor visitor = new ProjectRootVisitor();
Files.walkFileTree(workspaceRoot, EnumSet.noneOf(FileVisitOption.class), MAX_VISIT_DEPTH, visitor);
return visitor.roots;
}

@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
Path name = file.getFileName();
if (name != null && PROJECT_ROOT_MATCHER.matches(name)) {
roots.add(file.getParent());
return FileVisitResult.SKIP_SIBLINGS;
}
return FileVisitResult.CONTINUE;
}
}
Loading

0 comments on commit 87154b0

Please sign in to comment.