diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java similarity index 50% rename from src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java rename to src/main/java/software/amazon/smithy/lsp/FilePatterns.java index f4ba000c..23bab11f 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java +++ b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java @@ -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 getSmithyFileWatchPatterns(Project project) { + static List getSmithyFileWatchPatterns(Project project) { return Stream.concat(project.sources().stream(), project.imports().stream()) .map(path -> getSmithyFilePattern(path, true)) .toList(); @@ -38,45 +37,63 @@ public static List 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 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")) { diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java similarity index 68% rename from src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java rename to src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java index 57602501..6299e84f 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java @@ -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; @@ -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 @@ -32,7 +32,7 @@ * 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"; @@ -40,17 +40,23 @@ public final class FileWatcherRegistrationHandler { private static final List SMITHY_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration( WATCH_SMITHY_FILES_ID, WATCH_FILES_METHOD)); + private static final List 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 getSmithyFileWatcherRegistrations(Collection projects) { + static List getSmithyFileWatcherRegistrations(Collection projects) { List 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(); @@ -63,17 +69,20 @@ public static List getSmithyFileWatcherRegistrations(Collection getSmithyFileWatcherUnregistrations() { + static List 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 getBuildFileWatcherRegistrations(Collection projects) { - List watchers = projects.stream() - .map(ProjectFilePatterns::getBuildFilesWatchPattern) + static List getBuildFileWatcherRegistrations(Collection workspaceRoots) { + List watchers = workspaceRoots.stream() + .map(FilePatterns::getWorkspaceBuildFilesWatchPattern) .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern))) .toList(); @@ -82,4 +91,8 @@ public static List getBuildFileWatcherRegistrations(Collection getBuildFileWatcherUnregistrations() { + return BUILD_FILE_WATCHER_UNREGISTRATIONS; + } } diff --git a/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java new file mode 100644 index 00000000..a1016b9d --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java @@ -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 { + 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 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 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; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 436c3f44..f2efd72b 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -33,9 +33,7 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; -import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -84,7 +82,6 @@ import org.eclipse.lsp4j.Unregistration; import org.eclipse.lsp4j.UnregistrationParams; import org.eclipse.lsp4j.WorkDoneProgressBegin; -import org.eclipse.lsp4j.WorkDoneProgressCreateParams; import org.eclipse.lsp4j.WorkDoneProgressEnd; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; @@ -107,10 +104,8 @@ import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; import software.amazon.smithy.lsp.handler.CompletionHandler; import software.amazon.smithy.lsp.handler.DefinitionHandler; -import software.amazon.smithy.lsp.handler.FileWatcherRegistrationHandler; import software.amazon.smithy.lsp.handler.HoverHandler; import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectChanges; import software.amazon.smithy.lsp.project.ProjectLoader; import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.lsp.project.SmithyFile; @@ -154,6 +149,7 @@ public class SmithyLanguageServer implements private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); private Severity minimumSeverity = Severity.WARNING; private boolean onlyReloadOnSave = false; + private final Set workspacePaths = new HashSet<>(); SmithyLanguageServer() { } @@ -174,6 +170,10 @@ DocumentLifecycleManager getLifecycleManager() { return this.lifecycleManager; } + Set getWorkspacePaths() { + return workspacePaths; + } + @Override public void connect(LanguageClient client) { LOGGER.finest("Connect"); @@ -227,8 +227,7 @@ public CompletableFuture initialize(InitializeParams params) { } for (WorkspaceFolder workspaceFolder : params.getWorkspaceFolders()) { - Path root = Paths.get(URI.create(workspaceFolder.getUri())); - tryInitProject(workspaceFolder.getName(), root); + loadWorkspace(workspaceFolder); } if (workDoneProgressToken != null) { @@ -241,15 +240,17 @@ public CompletableFuture initialize(InitializeParams params) { return completedFuture(new InitializeResult(CAPABILITIES)); } - private void tryInitProject(String name, Path root) { + private void tryInitProject(Path root) { LOGGER.finest("Initializing project at " + root); lifecycleManager.cancelAllTasks(); + Result> loadResult = ProjectLoader.load( root, projects, lifecycleManager.managedDocuments()); + + String projectName = root.toString(); if (loadResult.isOk()) { Project updatedProject = loadResult.unwrap(); - resolveDetachedProjects(this.projects.getProjectByName(name), updatedProject); - this.projects.updateProjectByName(name, updatedProject); + updateProject(projectName, updatedProject); LOGGER.finest("Initialized project at " + root); } else { LOGGER.severe("Init project failed"); @@ -257,11 +258,11 @@ private void tryInitProject(String name, Path root) { // if we find a smithy-build.json, etc. // If we overwrite an existing project with an empty one, we lose track of the state of tracked // files. Instead, we will just keep the original project before the reload failure. - if (projects.getProjectByName(name) == null) { - projects.updateProjectByName(name, Project.empty(root)); + if (projects.getProjectByName(projectName) == null) { + projects.updateProjectByName(projectName, Project.empty(root)); } - String baseMessage = "Failed to load Smithy project " + name + " at " + root; + String baseMessage = "Failed to load Smithy project at " + root; StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); for (Exception error : loadResult.unwrapErr()) { errorMessage.append(System.lineSeparator()); @@ -275,6 +276,16 @@ private void tryInitProject(String name, Path root) { } } + private void updateProject(String projectName, Project updatedProject) { + // If the project didn't load any config files, it is now empty and should be removed + if (updatedProject.config().loadedConfigPaths().isEmpty()) { + removeProjectAndResolveDetached(projectName); + } else { + resolveDetachedProjects(this.projects.getProjectByName(projectName), updatedProject); + this.projects.updateProjectByName(projectName, updatedProject); + } + } + private void resolveDetachedProjects(Project oldProject, Project updatedProject) { // This is a project reload, so we need to resolve any added/removed files // that need to be moved to or from detached projects. @@ -306,21 +317,29 @@ private void resolveDetachedProjects(Project oldProject, Project updatedProject) } private CompletableFuture registerSmithyFileWatchers() { - List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations( + List registrations = FileWatcherRegistrations.getSmithyFileWatcherRegistrations( projects.attachedProjects().values()); return client.registerCapability(new RegistrationParams(registrations)); } private CompletableFuture unregisterSmithyFileWatchers() { - List unregistrations = FileWatcherRegistrationHandler.getSmithyFileWatcherUnregistrations(); + List unregistrations = FileWatcherRegistrations.getSmithyFileWatcherUnregistrations(); + return client.unregisterCapability(new UnregistrationParams(unregistrations)); + } + + private CompletableFuture registerWorkspaceBuildFileWatchers() { + var registrations = FileWatcherRegistrations.getBuildFileWatcherRegistrations(workspacePaths); + return client.registerCapability(new RegistrationParams(registrations)); + } + + private CompletableFuture unregisterWorkspaceBuildFileWatchers() { + var unregistrations = FileWatcherRegistrations.getBuildFileWatcherUnregistrations(); return client.unregisterCapability(new UnregistrationParams(unregistrations)); } @Override public void initialized(InitializedParams params) { - List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations( - projects.attachedProjects().values()); - client.registerCapability(new RegistrationParams(registrations)); + registerWorkspaceBuildFileWatchers(); registerSmithyFileWatchers(); } @@ -411,19 +430,22 @@ public CompletableFuture serverStatus() { public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { LOGGER.finest("DidChangeWatchedFiles"); // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), - // or the smithy-build.json itself was changed + // the smithy-build.json itself was changed, added, or deleted. - Map changesByProject = projects.computeProjectChanges(params.getChanges()); + WorkspaceChanges changes = WorkspaceChanges.computeWorkspaceChanges( + params.getChanges(), projects, workspacePaths); - changesByProject.forEach((projectName, projectChanges) -> { + changes.byProject().forEach((projectName, projectChange) -> { Project project = projects.getProjectByName(projectName); - if (projectChanges.hasChangedBuildFiles()) { + + if (!projectChange.changedBuildFileUris().isEmpty()) { client.info("Build files changed, reloading project"); // TODO: Handle more granular updates to build files. - tryInitProject(projectName, project.root()); - } else if (projectChanges.hasChangedSmithyFiles()) { - Set createdUris = projectChanges.createdSmithyFileUris(); - Set deletedUris = projectChanges.deletedSmithyFileUris(); + // Note: This will take care of removing projects when build files are deleted + tryInitProject(project.root()); + } else { + Set createdUris = projectChange.createdSmithyFileUris(); + Set deletedUris = projectChange.deletedSmithyFileUris(); client.info("Project files changed, adding files " + createdUris + " and removing files " + deletedUris); @@ -433,7 +455,10 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { } }); + changes.newProjectRoots().forEach(this::tryInitProject); + // TODO: Update watchers based on specific changes + // Note: We don't update build file watchers here - only on workspace changes unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); sendFileDiagnosticsForManagedDocuments(); @@ -443,40 +468,54 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { LOGGER.finest("DidChangeWorkspaceFolders"); - Either progressToken = Either.forLeft(UUID.randomUUID().toString()); - try { - client.createProgress(new WorkDoneProgressCreateParams(progressToken)).get(); - } catch (ExecutionException | InterruptedException e) { - client.error(String.format("Unable to create work done progress token: %s", e.getMessage())); - progressToken = null; + for (WorkspaceFolder folder : params.getEvent().getAdded()) { + loadWorkspace(folder); } - if (progressToken != null) { - WorkDoneProgressBegin begin = new WorkDoneProgressBegin(); - begin.setTitle("Updating workspace"); - client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(begin))); + for (WorkspaceFolder folder : params.getEvent().getRemoved()) { + removeWorkspace(folder); } - for (WorkspaceFolder folder : params.getEvent().getAdded()) { - Path root = Paths.get(URI.create(folder.getUri())); - tryInitProject(folder.getName(), root); - } + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + unregisterWorkspaceBuildFileWatchers().thenRun(this::registerWorkspaceBuildFileWatchers); + sendFileDiagnosticsForManagedDocuments(); + } - for (WorkspaceFolder folder : params.getEvent().getRemoved()) { - Project removedProject = projects.removeProjectByName(folder.getName()); - if (removedProject == null) { - continue; + private void loadWorkspace(WorkspaceFolder workspaceFolder) { + Path workspaceRoot = Paths.get(URI.create(workspaceFolder.getUri())); + workspacePaths.add(workspaceRoot); + try { + List projectRoots = ProjectRootVisitor.findProjectRoots(workspaceRoot); + for (Path root : projectRoots) { + tryInitProject(root); } + } catch (IOException e) { + LOGGER.severe(e.getMessage()); + } + } - resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); + private void removeWorkspace(WorkspaceFolder folder) { + Path workspaceRoot = Paths.get(URI.create(folder.getUri())); + workspacePaths.remove(workspaceRoot); + + // Have to do the removal separately, so we don't modify project.attachedProjects() + // while iterating through it + List projectsToRemove = new ArrayList<>(); + for (var entry : projects.attachedProjects().entrySet()) { + if (entry.getValue().root().startsWith(workspaceRoot)) { + projectsToRemove.add(entry.getKey()); + } } - unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); - sendFileDiagnosticsForManagedDocuments(); + for (String projectName : projectsToRemove) { + removeProjectAndResolveDetached(projectName); + } + } - if (progressToken != null) { - WorkDoneProgressEnd end = new WorkDoneProgressEnd(); - client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(end))); + private void removeProjectAndResolveDetached(String projectName) { + Project removedProject = this.projects.removeProjectByName(projectName); + if (removedProject != null) { + resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); } } diff --git a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java new file mode 100644 index 00000000..8fa94dc2 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectChange; +import software.amazon.smithy.lsp.project.ProjectManager; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +/** + * Aggregates changes to the workspace, including existing project changes and + * new project additions. + */ +final class WorkspaceChanges { + // smithy-build.json + .smithy-project.json + exts + private final Map byProject = new HashMap<>(); + private final List newProjectRoots = new ArrayList<>(); + + private WorkspaceChanges() { + } + + static WorkspaceChanges computeWorkspaceChanges( + List events, + ProjectManager projects, + Set workspacePaths + ) { + WorkspaceChanges changes = new WorkspaceChanges(); + + List projectFileMatchers = new ArrayList<>(projects.attachedProjects().size()); + projects.attachedProjects().forEach((projectName, project) -> + projectFileMatchers.add(createProjectFileMatcher(projectName, project))); + + List workspaceBuildFileMatchers = new ArrayList<>(workspacePaths.size()); + workspacePaths.forEach(workspacePath -> + workspaceBuildFileMatchers.add(FilePatterns.getWorkspaceBuildFilesPathMatcher(workspacePath))); + + for (FileEvent event : events) { + changes.addEvent(event, projectFileMatchers, workspaceBuildFileMatchers); + } + + return changes; + } + + Map byProject() { + return byProject; + } + + List newProjectRoots() { + return newProjectRoots; + } + + private void addEvent( + FileEvent event, + List projectFileMatchers, + List workspaceBuildFileMatchers + ) { + String changedUri = event.getUri(); + Path changedPath = Path.of(LspAdapter.toPath(changedUri)); + if (changedUri.endsWith(".smithy")) { + for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { + if (projectFileMatcher.smithyFileMatcher().matches(changedPath)) { + ProjectChange projectChange = byProject.computeIfAbsent( + projectFileMatcher.projectName(), ignored -> ProjectChange.empty()); + + switch (event.getType()) { + case Created -> projectChange.createdSmithyFileUris().add(changedUri); + case Deleted -> projectChange.deletedSmithyFileUris().add(changedUri); + default -> { + } + } + return; + } + } + } else { + for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { + if (projectFileMatcher.buildFileMatcher().matches(changedPath)) { + byProject.computeIfAbsent(projectFileMatcher.projectName(), ignored -> ProjectChange.empty()) + .changedBuildFileUris() + .add(changedUri); + return; + } + } + + // Only check if there's an added project. If there was a project we didn't match before, there's + // not much we could do at this point anyway. + if (event.getType() == FileChangeType.Created) { + for (PathMatcher workspaceBuildFileMatcher : workspaceBuildFileMatchers) { + if (workspaceBuildFileMatcher.matches(changedPath)) { + Path newProjectRoot = changedPath.getParent(); + this.newProjectRoots.add(newProjectRoot); + return; + } + } + } + } + } + + private record ProjectFileMatcher(String projectName, PathMatcher smithyFileMatcher, PathMatcher buildFileMatcher) { + } + + private static ProjectFileMatcher createProjectFileMatcher(String projectName, Project project) { + PathMatcher smithyFileMatcher = FilePatterns.getSmithyFilesPathMatcher(project); + + PathMatcher buildFileMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); + return new ProjectFileMatcher(projectName, smithyFileMatcher, buildFileMatcher); + } +} + diff --git a/src/main/java/software/amazon/smithy/lsp/project/Project.java b/src/main/java/software/amazon/smithy/lsp/project/Project.java index 42c9135e..850f1082 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -76,6 +76,10 @@ public Path root() { return root; } + public ProjectConfig config() { + return config; + } + /** * @return The paths of all Smithy sources specified * in this project's smithy build configuration files, diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java similarity index 59% rename from src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java rename to src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java index 0da219e9..2e85742a 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java @@ -5,6 +5,7 @@ package software.amazon.smithy.lsp.project; +import java.util.HashSet; import java.util.Set; /** @@ -14,22 +15,18 @@ * @param createdSmithyFileUris The uris of created Smithy files * @param deletedSmithyFileUris The uris of deleted Smithy files */ -public record ProjectChanges( +public record ProjectChange( Set changedBuildFileUris, Set createdSmithyFileUris, Set deletedSmithyFileUris ) { /** - * @return Whether there are any changed build files + * @return An empty and mutable set of project changes */ - public boolean hasChangedBuildFiles() { - return !changedBuildFileUris.isEmpty(); - } - - /** - * @return Whether there are any changed Smithy files - */ - public boolean hasChangedSmithyFiles() { - return !createdSmithyFileUris.isEmpty() || !deletedSmithyFileUris.isEmpty(); + public static ProjectChange empty() { + return new ProjectChange( + new HashSet<>(), + new HashSet<>(), + new HashSet<>()); } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java index 33e5ec21..17893273 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -20,12 +20,13 @@ * A complete view of all a project's configuration that is needed to load it, * merged from all configuration sources. */ -final class ProjectConfig { +public final class ProjectConfig { private final List sources; private final List imports; private final String outputDirectory; private final List dependencies; private final MavenConfig mavenConfig; + private final List loadedConfigPaths; private ProjectConfig(Builder builder) { this.sources = builder.sources; @@ -33,6 +34,7 @@ private ProjectConfig(Builder builder) { this.outputDirectory = builder.outputDirectory; this.dependencies = builder.dependencies; this.mavenConfig = builder.mavenConfig; + this.loadedConfigPaths = builder.loadedConfigPaths; } static ProjectConfig empty() { @@ -78,12 +80,17 @@ public Optional maven() { return Optional.ofNullable(mavenConfig); } + public List loadedConfigPaths() { + return loadedConfigPaths; + } + static final class Builder { final List sources = new ArrayList<>(); final List imports = new ArrayList<>(); String outputDirectory; final List dependencies = new ArrayList<>(); MavenConfig mavenConfig; + final List loadedConfigPaths = new ArrayList<>(); private Builder() { } @@ -148,6 +155,11 @@ public Builder mavenConfig(MavenConfig mavenConfig) { return this; } + public Builder loadedConfigPaths(List loadedConfigPaths) { + this.loadedConfigPaths.addAll(loadedConfigPaths); + return this; + } + public ProjectConfig build() { return new ProjectConfig(this); } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java index c299ecea..71c12433 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -5,9 +5,11 @@ package software.amazon.smithy.lsp.project; +import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.logging.Logger; import software.amazon.smithy.build.model.SmithyBuildConfig; @@ -63,28 +65,38 @@ */ public final class ProjectConfigLoader { public static final String SMITHY_BUILD = "smithy-build.json"; - public static final String[] SMITHY_BUILD_EXTS = {"build/smithy-dependencies.json", ".smithy.json"}; + public static final String[] SMITHY_BUILD_EXTS = { + "build" + File.separator + "smithy-dependencies.json", ".smithy.json"}; public static final String SMITHY_PROJECT = ".smithy-project.json"; + public static final List PROJECT_BUILD_FILES = new ArrayList<>(2 + SMITHY_BUILD_EXTS.length); private static final Logger LOGGER = Logger.getLogger(ProjectConfigLoader.class.getName()); private static final SmithyBuildConfig DEFAULT_SMITHY_BUILD = SmithyBuildConfig.builder().version("1").build(); private static final NodeMapper NODE_MAPPER = new NodeMapper(); + static { + PROJECT_BUILD_FILES.add(SMITHY_BUILD); + PROJECT_BUILD_FILES.add(SMITHY_PROJECT); + PROJECT_BUILD_FILES.addAll(Arrays.asList(SMITHY_BUILD_EXTS)); + } + private ProjectConfigLoader() { } static Result> loadFromRoot(Path workspaceRoot) { + List loadedConfigPaths = new ArrayList<>(); SmithyBuildConfig.Builder builder = SmithyBuildConfig.builder(); List exceptions = new ArrayList<>(); - // TODO: We don't handle cases where the smithy-build.json isn't in the top level of the root. - // In order to do so, we probably need to be able to keep track of multiple projects. Path smithyBuildPath = workspaceRoot.resolve(SMITHY_BUILD); if (Files.isRegularFile(smithyBuildPath)) { LOGGER.info("Loading smithy-build.json from " + smithyBuildPath); Result result = Result.ofFallible(() -> SmithyBuildConfig.load(smithyBuildPath)); - result.get().ifPresent(builder::merge); + result.get().ifPresent(config -> { + builder.merge(config); + loadedConfigPaths.add(smithyBuildPath); + }); result.getErr().ifPresent(exceptions::add); } else { LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); @@ -97,7 +109,10 @@ static Result> loadFromRoot(Path workspaceRoot) { if (Files.isRegularFile(extPath)) { Result result = Result.ofFallible(() -> loadSmithyBuildExtensions(extPath)); - result.get().ifPresent(extensionsBuilder::merge); + result.get().ifPresent(config -> { + extensionsBuilder.merge(config); + loadedConfigPaths.add(extPath); + }); result.getErr().ifPresent(exceptions::add); } } @@ -110,6 +125,7 @@ static Result> loadFromRoot(Path workspaceRoot) { ProjectConfig.Builder.load(smithyProjectPath)); if (result.isOk()) { finalConfigBuilder = result.unwrap(); + loadedConfigPaths.add(smithyProjectPath); } else { exceptions.add(result.unwrapErr()); } @@ -126,6 +142,7 @@ static Result> loadFromRoot(Path workspaceRoot) { if (finalConfigBuilder.outputDirectory == null) { config.getOutputDirectory().ifPresent(finalConfigBuilder::outputDirectory); } + finalConfigBuilder.loadedConfigPaths(loadedConfigPaths); return Result.ok(finalConfigBuilder.build()); } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java index de6927e2..3793dad2 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -5,16 +5,9 @@ package software.amazon.smithy.lsp.project; -import java.nio.file.Path; -import java.nio.file.PathMatcher; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.logging.Logger; -import org.eclipse.lsp4j.FileChangeType; -import org.eclipse.lsp4j.FileEvent; -import org.eclipse.lsp4j.WorkspaceFolder; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; @@ -31,7 +24,7 @@ public ProjectManager() { } /** - * @param name Name of the project, usually comes from {@link WorkspaceFolder#getName()} + * @param name Name of the project, should be path of the project directory * @return The project with the given name, if it exists */ public Project getProjectByName(String name) { @@ -160,56 +153,4 @@ public Document getDocument(String uri) { } return project.getDocument(uri); } - - /** - * Computes per-project file changes from the given file events. - * - *

>Note: if you have lots of projects, this will create a bunch of - * garbage because most times you aren't getting multiple sets of large - * updates to a project. Project changes are relatively rare, so this - * shouldn't have a huge impact. - * - * @param events The file events to compute per-project file changes from - * @return A map of project name to the corresponding project's changes - */ - public Map computeProjectChanges(List events) { - // Note: we could eagerly compute these and store them, but project changes are relatively rare, - // and doing it this way means we don't need to manage the state. - Map projectSmithyFileMatchers = new HashMap<>(attachedProjects().size()); - Map projectBuildFileMatchers = new HashMap<>(attachedProjects().size()); - - Map changes = new HashMap<>(attachedProjects().size()); - - attachedProjects().forEach((projectName, project) -> { - projectSmithyFileMatchers.put(projectName, ProjectFilePatterns.getSmithyFilesPathMatcher(project)); - projectBuildFileMatchers.put(projectName, ProjectFilePatterns.getBuildFilesPathMatcher(project)); - - // Need these to be hash sets so they are mutable - changes.put(projectName, new ProjectChanges(new HashSet<>(), new HashSet<>(), new HashSet<>())); - }); - - for (FileEvent event : events) { - String changedUri = event.getUri(); - Path changedPath = Path.of(LspAdapter.toPath(changedUri)); - if (changedUri.endsWith(".smithy")) { - projectSmithyFileMatchers.forEach((projectName, matcher) -> { - if (matcher.matches(changedPath)) { - if (event.getType() == FileChangeType.Created) { - changes.get(projectName).createdSmithyFileUris().add(changedUri); - } else if (event.getType() == FileChangeType.Deleted) { - changes.get(projectName).deletedSmithyFileUris().add(changedUri); - } - } - }); - } else { - projectBuildFileMatchers.forEach((projectName, matcher) -> { - if (matcher.matches(changedPath)) { - changes.get(projectName).changedBuildFileUris().add(changedUri); - } - }); - } - } - - return changes; - } } diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java similarity index 51% rename from src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java rename to src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java index fe9a2c50..06d7973c 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -3,22 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.project; +package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.HashSet; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.UtilMatchers; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; -public class ProjectFilePatternsTest { +public class FilePatternsTest { @Test - public void createsPathMatchers() { + public void createsProjectPathMatchers() { TestWorkspace workspace = TestWorkspace.builder() .withSourceDir(new TestWorkspace.Dir() .withPath("foo") @@ -38,8 +42,8 @@ public void createsPathMatchers() { .build(); Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - PathMatcher smithyMatcher = ProjectFilePatterns.getSmithyFilesPathMatcher(project); - PathMatcher buildMatcher = ProjectFilePatterns.getBuildFilesPathMatcher(project); + PathMatcher smithyMatcher = FilePatterns.getSmithyFilesPathMatcher(project); + PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); Path root = project.root(); assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("abc.smithy"))); @@ -48,4 +52,29 @@ public void createsPathMatchers() { assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve("smithy-build.json"))); assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve(".smithy-project.json"))); } + + @Test + public void createsWorkspacePathMatchers() throws IOException { + Path workspaceRoot = Files.createTempDirectory("test"); + workspaceRoot.toFile().deleteOnExit(); + + TestWorkspace fooWorkspace = TestWorkspace.builder() + .withRoot(workspaceRoot) + .withPath("foo") + .build(); + + // Set up a project outside the 'foo' root. + workspaceRoot.resolve("bar").toFile().mkdir(); + workspaceRoot.resolve("bar/smithy-build.json").toFile().createNewFile(); + + Project fooProject = ProjectLoader.load(fooWorkspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + + PathMatcher fooBuildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(fooProject); + PathMatcher workspaceBuildMatcher = FilePatterns.getWorkspaceBuildFilesPathMatcher(workspaceRoot); + + assertThat(fooBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(fooBuildMatcher, not(UtilMatchers.canMatchPath(workspaceRoot.resolve("bar/smithy-build.json")))); + assertThat(workspaceBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(workspaceBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("bar/smithy-build.json"))); + } } diff --git a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java similarity index 90% rename from src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java rename to src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java index 8b9df90a..2b839e72 100644 --- a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.handler; +package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; @@ -16,14 +16,12 @@ import org.eclipse.lsp4j.Registration; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.UtilMatchers; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; -public class FileWatcherRegistrationHandlerTest { +public class FileWatcherRegistrationsTest { @Test public void createsCorrectRegistrations() { TestWorkspace workspace = TestWorkspace.builder() @@ -45,7 +43,7 @@ public void createsCorrectRegistrations() { .build(); Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - List matchers = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(List.of(project)) + List matchers = FileWatcherRegistrations.getSmithyFileWatcherRegistrations(List.of(project)) .stream() .map(Registration::getRegisterOptions) .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) diff --git a/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java b/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java new file mode 100644 index 00000000..d8f9b241 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static software.amazon.smithy.lsp.project.ProjectTest.toPath; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class ProjectRootVisitorTest { + @Test + public void findsNestedRoot() throws Exception { + Path root = toPath(getClass().getResource("project/nested")); + List found = ProjectRootVisitor.findProjectRoots(root); + assertThat(found, contains(UtilMatchers.endsWith(Path.of("nested/nested")))); + } + + @Test + public void findsMultiNestedRoots() throws Exception { + Path root = toPath(getClass().getResource("project/multi-nested")); + List found = ProjectRootVisitor.findProjectRoots(root); + assertThat(found, containsInAnyOrder( + UtilMatchers.endsWith(Path.of("multi-nested/nested-a")), + UtilMatchers.endsWith(Path.of("multi-nested/nested-b")))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 22ea3400..25f4e3ed 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -2,6 +2,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; @@ -30,6 +31,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; @@ -1766,7 +1768,6 @@ public void useCompletionDoesntAutoImport() throws Exception { @Test public void loadsMultipleRoots() { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", """ $version: "2" @@ -1776,7 +1777,6 @@ public void loadsMultipleRoots() { .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", """ $version: "2" @@ -1787,14 +1787,14 @@ public void loadsMultipleRoots() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -1806,7 +1806,6 @@ public void loadsMultipleRoots() { @Test public void multiRootLifecycleManagement() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", """ $version: "2" @@ -1816,7 +1815,6 @@ public void multiRootLifecycleManagement() throws Exception { .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", """ $version: "2" @@ -1857,8 +1855,8 @@ public void multiRootLifecycleManagement() throws Exception { server.getLifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1870,14 +1868,12 @@ public void multiRootLifecycleManagement() throws Exception { @Test public void multiRootAddingWatchedFile() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceDir(new TestWorkspace.Dir() .withPath("model") .withSourceFile("main.smithy", "")) .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceDir(new TestWorkspace.Dir() .withPath("model") @@ -1930,8 +1926,8 @@ public void multiRootAddingWatchedFile() throws Exception { server.getLifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -1945,14 +1941,12 @@ public void multiRootAddingWatchedFile() throws Exception { @Test public void multiRootChangingBuildFile() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceDir(new TestWorkspace.Dir() .withPath("model") .withSourceFile("main.smithy", "")) .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceDir(new TestWorkspace.Dir() .withPath("model") @@ -2017,8 +2011,8 @@ public void multiRootChangingBuildFile() throws Exception { assertThat(server.getProjects().getProject(workspaceBar.getUri("model/main.smithy")), notNullValue()); assertThat(server.getProjects().getProject(workspaceFoo.getUri("model/main.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -2038,7 +2032,6 @@ public void addingWorkspaceFolder() throws Exception { structure Foo {} """; TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", fooModel) .build(); @@ -2051,7 +2044,6 @@ public void addingWorkspaceFolder() throws Exception { structure Bar {} """; TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", barModel) .build(); @@ -2072,14 +2064,14 @@ public void addingWorkspaceFolder() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -2096,7 +2088,6 @@ public void removingWorkspaceFolder() { structure Foo {} """; TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", fooModel) .build(); @@ -2107,7 +2098,6 @@ public void removingWorkspaceFolder() { structure Bar {} """; TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", barModel) .build(); @@ -2128,15 +2118,15 @@ public void removingWorkspaceFolder() { .removed(workspaceBar.getRoot().toUri().toString(), "bar") .build()); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), not(hasKey("bar"))); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getProjects().attachedProjects(), not(hasKey(workspaceBar.getName()))); assertThat(server.getProjects().detachedProjects(), hasKey(endsWith("bar.smithy"))); assertThat(server.getProjects().isDetached(workspaceBar.getUri("bar.smithy")), is(true)); assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); Project projectBar = server.getProjects().getProject(workspaceBar.getUri("bar.smithy")); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); @@ -2145,6 +2135,247 @@ public void removingWorkspaceFolder() { assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); } + + @Test + public void singleWorkspaceMultiRoot() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getWorkspacePaths(), contains(root)); + } + + @Test + public void addingRootsToWorkspace() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + SmithyLanguageServer server = initFromRoot(root); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Created) + .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Created) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + } + + @Test + public void removingRootsFromWorkspace() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getWorkspacePaths(), contains(root)); + + workspaceFoo.deleteModel("smithy-build.json"); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Deleted) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), contains(workspaceBar.getName())); + } + + @Test + public void addingConfigFile() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("baz.smithy")) + .text(bazModel) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + + workspaceFoo.addModel(".smithy-project.json", """ + { + "sources": ["baz.smithy"] + }"""); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Created) + .build()); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), empty()); + } + + @Test + public void removingConfigFile() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + workspaceFoo.addModel(".smithy-project.json", """ + { + "sources": ["baz.smithy"] + }"""); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("baz.smithy")) + .text(bazModel) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), empty()); + + workspaceFoo.deleteModel(".smithy-project.json"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Deleted) + .build()); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index f95c0fe3..ab6946fb 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -25,10 +25,10 @@ public final class TestWorkspace { private SmithyBuildConfig config; private final String name; - private TestWorkspace(Path root, SmithyBuildConfig config, String name) { + private TestWorkspace(Path root, SmithyBuildConfig config) { this.root = root; this.config = config; - this.name = name; + this.name = root.toString(); } /** @@ -183,7 +183,7 @@ private static void writeModels(Path toDir, Map models) throws E public static final class Builder extends Dir { private SmithyBuildConfig config = null; - private String name = ""; + private Path root = null; private Builder() {} @@ -222,8 +222,8 @@ public Builder withConfig(SmithyBuildConfig config) { return this; } - public Builder withName(String name) { - this.name = name; + public Builder withRoot(Path root) { + this.root = root; return this; } @@ -232,8 +232,13 @@ public TestWorkspace build() { if (path == null) { path = "test"; } - Path root = Files.createTempDirectory(path); - root.toFile().deleteOnExit(); + Path projectRoot; + if (this.root != null) { + projectRoot = Files.createDirectory(this.root.resolve(path)); + } else { + projectRoot = Files.createTempDirectory(path); + projectRoot.toFile().deleteOnExit(); + } List sources = new ArrayList<>(); sources.addAll(sourceModels.keySet()); @@ -250,11 +255,11 @@ public TestWorkspace build() { .imports(imports) .build(); } - writeConfig(root, config); + writeConfig(projectRoot, config); - writeModels(root); + writeModels(projectRoot); - return new TestWorkspace(root, config, name); + return new TestWorkspace(projectRoot, config); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java index f4dc1103..58b3f6d0 100644 --- a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -53,4 +53,18 @@ protected void describeMismatchSafely(PathMatcher item, Description mismatchDesc } }; } + + public static Matcher endsWith(Path path) { + return new CustomTypeSafeMatcher("A path that ends with " + path.toString()) { + @Override + protected boolean matchesSafely(Path item) { + return item.endsWith(path); + } + + @Override + protected void describeMismatchSafely(Path item, Description mismatchDescription) { + mismatchDescription.appendText(item.toString() + " did not end with " + path.toString()); + } + }; + } } diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json new file mode 100644 index 00000000..e69de29b