diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index fbf65c64..be92de98 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -33,9 +33,12 @@ 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; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionOptions; import org.eclipse.lsp4j.CodeActionParams; @@ -50,14 +53,13 @@ import org.eclipse.lsp4j.DidChangeConfigurationParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams; import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; import org.eclipse.lsp4j.DocumentFormattingParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; -import org.eclipse.lsp4j.FileChangeType; -import org.eclipse.lsp4j.FileEvent; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; @@ -82,7 +84,11 @@ 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; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; import org.eclipse.lsp4j.jsonrpc.CompletableFutures; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; @@ -102,7 +108,7 @@ 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.ProjectConfigLoader; +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; @@ -133,6 +139,11 @@ public class SmithyLanguageServer implements capabilities.setHoverProvider(true); capabilities.setDocumentFormattingProvider(true); capabilities.setDocumentSymbolProvider(true); + + WorkspaceFoldersOptions workspaceFoldersOptions = new WorkspaceFoldersOptions(); + workspaceFoldersOptions.setSupported(true); + capabilities.setWorkspace(new WorkspaceServerCapabilities(workspaceFoldersOptions)); + CAPABILITIES = capabilities; } @@ -145,17 +156,12 @@ public class SmithyLanguageServer implements SmithyLanguageServer() { } - SmithyLanguageServer(LanguageClient client, Project project) { - this.client = new SmithyLanguageClient(client); - this.projects.updateMainProject(project); - } - SmithyLanguageClient getClient() { return this.client; } - Project getProject() { - return projects.mainProject(); + Project getFirstProject() { + return projects.attachedProjects().values().stream().findFirst().orElse(null); } ProjectManager getProjects() { @@ -168,7 +174,7 @@ DocumentLifecycleManager getLifecycleManager() { @Override public void connect(LanguageClient client) { - LOGGER.info("Connect"); + LOGGER.finest("Connect"); this.client = new SmithyLanguageClient(client); String message = "smithy-language-server"; try { @@ -183,7 +189,7 @@ public void connect(LanguageClient client) { @Override public CompletableFuture initialize(InitializeParams params) { - LOGGER.info("Initialize"); + LOGGER.finest("Initialize"); // TODO: Use this to manage shutdown if the parent process exits, after upgrading jdk // Optional.ofNullable(params.getProcessId()) @@ -212,22 +218,8 @@ public CompletableFuture initialize(InitializeParams params) { } } - Path root = null; - // TODO: Handle multiple workspaces + if (params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { - String uri = params.getWorkspaceFolders().get(0).getUri(); - root = Paths.get(URI.create(uri)); - } else if (params.getRootUri() != null) { - String uri = params.getRootUri(); - root = Paths.get(URI.create(uri)); - } else if (params.getRootPath() != null) { - String uri = params.getRootPath(); - root = Paths.get(URI.create(uri)); - } - - if (root != null) { - // TODO: Support this for other tasks. Need to create a progress token with the client - // through createProgress. Either workDoneProgressToken = params.getWorkDoneToken(); if (workDoneProgressToken != null) { WorkDoneProgressBegin notification = new WorkDoneProgressBegin(); @@ -235,7 +227,10 @@ public CompletableFuture initialize(InitializeParams params) { client.notifyProgress(new ProgressParams(workDoneProgressToken, Either.forLeft(notification))); } - tryInitProject(root); + for (WorkspaceFolder workspaceFolder : params.getWorkspaceFolders()) { + Path root = Paths.get(URI.create(workspaceFolder.getUri())); + tryInitProject(workspaceFolder.getName(), root); + } if (workDoneProgressToken != null) { WorkDoneProgressEnd notification = new WorkDoneProgressEnd(); @@ -243,31 +238,31 @@ public CompletableFuture initialize(InitializeParams params) { } } - LOGGER.info("Done initialize"); + LOGGER.finest("Done initialize"); return completedFuture(new InitializeResult(CAPABILITIES)); } - private void tryInitProject(Path root) { - LOGGER.info("Initializing project at " + root); + private void tryInitProject(String name, Path root) { + LOGGER.finest("Initializing project at " + root); lifecycleManager.cancelAllTasks(); Result> loadResult = ProjectLoader.load( root, projects, lifecycleManager.managedDocuments()); if (loadResult.isOk()) { Project updatedProject = loadResult.unwrap(); - resolveDetachedProjects(updatedProject); - projects.updateMainProject(loadResult.unwrap()); - LOGGER.info("Initialized project at " + root); + resolveDetachedProjects(this.projects.getProjectByName(name), updatedProject); + this.projects.updateProjectByName(name, updatedProject); + LOGGER.finest("Initialized project at " + root); } else { LOGGER.severe("Init project failed"); // TODO: Maybe we just start with this anyways by default, and then add to it // 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.mainProject() == null) { - projects.updateMainProject(Project.empty(root)); + if (projects.getProjectByName(name) == null) { + projects.updateProjectByName(name, Project.empty(root)); } - String baseMessage = "Failed to load Smithy project at " + root; + String baseMessage = "Failed to load Smithy project " + name + " at " + root; StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); for (Exception error : loadResult.unwrapErr()) { errorMessage.append(System.lineSeparator()); @@ -281,11 +276,11 @@ private void tryInitProject(Path root) { } } - private void resolveDetachedProjects(Project 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. - if (getProject() != null) { - Set currentProjectSmithyPaths = getProject().smithyFiles().keySet(); + if (oldProject != null) { + Set currentProjectSmithyPaths = oldProject.smithyFiles().keySet(); Set updatedProjectSmithyPaths = updatedProject.smithyFiles().keySet(); Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); @@ -303,8 +298,7 @@ private void resolveDetachedProjects(Project updatedProject) { String removedUri = LspAdapter.toUri(removedPath); // Only move to a detached project if the file is managed if (lifecycleManager.managedDocuments().contains(removedUri)) { - // Note: This should always be non-null, since we essentially got this from the current project - Document removedDocument = projects.getDocument(removedUri); + Document removedDocument = oldProject.getDocument(removedUri); // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings projects.createDetachedProject(removedUri, removedDocument.copyText()); } @@ -313,8 +307,8 @@ private void resolveDetachedProjects(Project updatedProject) { } private CompletableFuture registerSmithyFileWatchers() { - Project project = projects.mainProject(); - List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project); + List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations( + projects.attachedProjects().values()); return client.registerCapability(new RegistrationParams(registrations)); } @@ -325,7 +319,8 @@ private CompletableFuture unregisterSmithyFileWatchers() { @Override public void initialized(InitializedParams params) { - List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations(); + List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations( + projects.attachedProjects().values()); client.registerCapability(new RegistrationParams(registrations)); registerSmithyFileWatchers(); } @@ -353,7 +348,7 @@ public void exit() { @Override public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { - LOGGER.info("JarFileContents"); + LOGGER.finest("JarFileContents"); String uri = textDocumentIdentifier.getUri(); Project project = projects.getProject(uri); Document document = project.getDocument(uri); @@ -365,10 +360,9 @@ public CompletableFuture jarFileContents(TextDocumentIdentifier textDocu } } - // TODO: This doesn't really work for multiple projects @Override public CompletableFuture> selectorCommand(SelectorParams selectorParams) { - LOGGER.info("SelectorCommand"); + LOGGER.finest("SelectorCommand"); Selector selector; try { selector = Selector.parse(selectorParams.getExpression()); @@ -378,29 +372,31 @@ public CompletableFuture> selectorCommand(SelectorParam return completedFuture(Collections.emptyList()); } - Project project = projects.mainProject(); - // TODO: Might also want to tell user if the model isn't loaded - // TODO: Use proper location (source is just a point) - return completedFuture(project.modelResult().getResult() + // Select from all available projects + Collection detached = projects.detachedProjects().values(); + Collection nonDetached = projects.attachedProjects().values(); + + return completedFuture(Stream.concat(detached.stream(), nonDetached.stream()) + .flatMap(project -> project.modelResult().getResult().stream()) .map(selector::select) - .map(shapes -> shapes.stream() + .flatMap(shapes -> shapes.stream() + // TODO: Use proper location (source is just a point) .map(Shape::getSourceLocation) - .map(LspAdapter::toLocation) - .collect(Collectors.toList())) - .orElse(Collections.emptyList())); + .map(LspAdapter::toLocation)) + .toList()); } @Override public CompletableFuture serverStatus() { - OpenProject openProject = new OpenProject( - LspAdapter.toUri(projects.mainProject().root().toString()), - projects.mainProject().smithyFiles().keySet().stream() - .map(LspAdapter::toUri) - .collect(Collectors.toList()), - false); - List openProjects = new ArrayList<>(); - openProjects.add(openProject); + for (Project project : projects.attachedProjects().values()) { + openProjects.add(new OpenProject( + LspAdapter.toUri(project.root().toString()), + project.smithyFiles().keySet().stream() + .map(LspAdapter::toUri) + .toList(), + false)); + } for (Map.Entry entry : projects.detachedProjects().entrySet()) { openProjects.add(new OpenProject( @@ -414,49 +410,75 @@ public CompletableFuture serverStatus() { @Override public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { - LOGGER.info("DidChangeWatchedFiles"); + 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 - Set createdSmithyFiles = new HashSet<>(params.getChanges().size()); - Set deletedSmithyFiles = new HashSet<>(params.getChanges().size()); - boolean changedBuildFiles = false; - for (FileEvent event : params.getChanges()) { - String changedUri = event.getUri(); - if (changedUri.endsWith(".smithy")) { - if (event.getType().equals(FileChangeType.Created)) { - createdSmithyFiles.add(changedUri); - } else if (event.getType().equals(FileChangeType.Deleted)) { - deletedSmithyFiles.add(changedUri); - } - } else if (changedUri.endsWith(ProjectConfigLoader.SMITHY_BUILD) - || changedUri.endsWith(ProjectConfigLoader.SMITHY_PROJECT)) { - changedBuildFiles = true; - } else { - for (String extFile : ProjectConfigLoader.SMITHY_BUILD_EXTS) { - if (changedUri.endsWith(extFile)) { - changedBuildFiles = true; - break; - } - } + + Map changesByProject = projects.computeProjectChanges(params.getChanges()); + + changesByProject.forEach((projectName, projectChanges) -> { + Project project = projects.getProjectByName(projectName); + if (projectChanges.hasChangedBuildFiles()) { + 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(); + client.info("Project files changed, adding files " + + createdUris + " and removing files " + deletedUris); + + // We get this notification for watched files, which only includes project files, + // so we don't need to resolve detached projects. + project.updateFiles(createdUris, deletedUris); } + }); + + // TODO: Update watchers based on specific changes + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + + sendFileDiagnosticsForManagedDocuments(); + } + + @Override + 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; } - if (changedBuildFiles) { - client.info("Build files changed, reloading project"); - // TODO: Handle more granular updates to build files. - tryInitProject(projects.mainProject().root()); - } else { - client.info("Project files changed, adding files " - + createdSmithyFiles + " and removing files " + deletedSmithyFiles); - // We get this notification for watched files, which only includes project files, - // so we don't need to resolve detached projects. - projects.mainProject().updateFiles(createdSmithyFiles, deletedSmithyFiles); + if (progressToken != null) { + WorkDoneProgressBegin begin = new WorkDoneProgressBegin(); + begin.setTitle("Updating workspace"); + client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(begin))); } - // TODO: Update watchers based on specific changes - unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + for (WorkspaceFolder folder : params.getEvent().getAdded()) { + Path root = Paths.get(URI.create(folder.getUri())); + tryInitProject(folder.getName(), root); + } + for (WorkspaceFolder folder : params.getEvent().getRemoved()) { + Project removedProject = projects.removeProjectByName(folder.getName()); + if (removedProject == null) { + continue; + } + + resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); + } + + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); sendFileDiagnosticsForManagedDocuments(); + + if (progressToken != null) { + WorkDoneProgressEnd end = new WorkDoneProgressEnd(); + client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(end))); + } } @Override @@ -465,7 +487,7 @@ public void didChangeConfiguration(DidChangeConfigurationParams params) { @Override public void didChange(DidChangeTextDocumentParams params) { - LOGGER.info("DidChange"); + LOGGER.finest("DidChange"); if (params.getContentChanges().isEmpty()) { LOGGER.info("Received empty DidChange"); @@ -508,7 +530,7 @@ public void didChange(DidChangeTextDocumentParams params) { @Override public void didOpen(DidOpenTextDocumentParams params) { - LOGGER.info("DidOpen"); + LOGGER.finest("DidOpen"); String uri = params.getTextDocument().getUri(); @@ -528,7 +550,7 @@ public void didOpen(DidOpenTextDocumentParams params) { @Override public void didClose(DidCloseTextDocumentParams params) { - LOGGER.info("DidClose"); + LOGGER.finest("DidClose"); String uri = params.getTextDocument().getUri(); lifecycleManager.managedDocuments().remove(uri); @@ -544,7 +566,7 @@ public void didClose(DidCloseTextDocumentParams params) { @Override public void didSave(DidSaveTextDocumentParams params) { - LOGGER.info("DidSave"); + LOGGER.finest("DidSave"); String uri = params.getTextDocument().getUri(); lifecycleManager.cancelTask(uri); @@ -569,7 +591,7 @@ public void didSave(DidSaveTextDocumentParams params) { @Override public CompletableFuture, CompletionList>> completion(CompletionParams params) { - LOGGER.info("Completion"); + LOGGER.finest("Completion"); String uri = params.getTextDocument().getUri(); if (!projects.isTracked(uri)) { @@ -587,7 +609,7 @@ public CompletableFuture, CompletionList>> completio @Override public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { - LOGGER.info("ResolveCompletion"); + LOGGER.finest("ResolveCompletion"); // TODO: Use this to add the import when a completion item is selected, if its expensive return completedFuture(unresolved); } @@ -595,7 +617,7 @@ public CompletableFuture resolveCompletionItem(CompletionItem un @Override public CompletableFuture>> documentSymbol(DocumentSymbolParams params) { - LOGGER.info("DocumentSymbol"); + LOGGER.finest("DocumentSymbol"); String uri = params.getTextDocument().getUri(); if (!projects.isTracked(uri)) { client.unknownFileError(uri, "document symbol"); @@ -657,7 +679,7 @@ public CompletableFuture resolveCompletionItem(CompletionItem un @Override public CompletableFuture, List>> definition(DefinitionParams params) { - LOGGER.info("Definition"); + LOGGER.finest("Definition"); String uri = params.getTextDocument().getUri(); if (!projects.isTracked(uri)) { @@ -673,7 +695,7 @@ public CompletableFuture resolveCompletionItem(CompletionItem un @Override public CompletableFuture hover(HoverParams params) { - LOGGER.info("Hover"); + LOGGER.finest("Hover"); String uri = params.getTextDocument().getUri(); if (!projects.isTracked(uri)) { @@ -700,7 +722,7 @@ public CompletableFuture>> codeAction(CodeActio @Override public CompletableFuture> formatting(DocumentFormattingParams params) { - LOGGER.info("Formatting"); + LOGGER.finest("Formatting"); String uri = params.getTextDocument().getUri(); Project project = projects.getProject(uri); Document document = project.getDocument(uri); diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java index 08c61ff0..57602501 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java @@ -5,12 +5,9 @@ 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; @@ -18,7 +15,7 @@ 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 @@ -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 BUILD_FILE_WATCHER_REGISTRATIONS; - private static final List SMITHY_FILE_WATCHER_UNREGISTRATIONS; - - static { - // smithy-build.json + .smithy-project.json + build exts - int buildFileWatcherCount = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length; - List 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 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 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 getSmithyFileWatcherRegistrations(Project project) { - List smithyFileWatchers = Stream.concat(project.sources().stream(), - project.imports().stream()) - .map(FileWatcherRegistrationHandler::smithyFileWatcher) - .collect(Collectors.toList()); + public static List getSmithyFileWatcherRegistrations(Collection projects) { + List 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, @@ -96,17 +67,19 @@ public static List 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 getBuildFileWatcherRegistrations(Collection projects) { + List 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))); } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java new file mode 100644 index 00000000..0da219e9 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java @@ -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 changedBuildFileUris, + Set createdSmithyFileUris, + Set 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(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java new file mode 100644 index 00000000..f4ba000c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java @@ -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 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 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("\\", "\\\\"); + } +} 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 07cfb337..de6927e2 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -5,9 +5,16 @@ 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 org.eclipse.lsp4j.InitializeParams; +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; @@ -15,33 +22,40 @@ * Manages open projects tracked by the server. */ public final class ProjectManager { + private static final Logger LOGGER = Logger.getLogger(ProjectManager.class.getName()); + private final Map detached = new HashMap<>(); - // TODO: Handle multiple main projects - private Project mainProject; + private final Map attached = new HashMap<>(); public ProjectManager() { } /** - * @return The main project (the one with a smithy-build.json). Note that - * this will always be present after - * {@link org.eclipse.lsp4j.services.LanguageServer#initialize(InitializeParams)} - * is called. If there's no smithy-build.json, this is just an empty project. + * @param name Name of the project, usually comes from {@link WorkspaceFolder#getName()} + * @return The project with the given name, if it exists */ - public Project mainProject() { - return mainProject; + public Project getProjectByName(String name) { + return this.attached.get(name); } /** - * @param updated The updated main project. Overwrites existing main project - * without doing a partial update + * @param name Name of the project to update + * @param updated Project to update */ - public void updateMainProject(Project updated) { - this.mainProject = updated; + public void updateProjectByName(String name, Project updated) { + this.attached.put(name, updated); } /** - * @return A map of URIs of open files that aren't attached to the main project + * @param name Name of the project to remove + * @return The removed project, if it exists + */ + public Project removeProjectByName(String name) { + return this.attached.remove(name); + } + + /** + * @return A map of URIs of open files that aren't attached to a tracked project * to their own detached projects. These projects contain only the file that * corresponds to the key in the map. */ @@ -49,6 +63,13 @@ public Map detachedProjects() { return detached; } + /** + * @return A map of project names to projects tracked by the server + */ + public Map attachedProjects() { + return attached; + } + /** * @param uri The URI of the file belonging to the project to get * @return The project the given {@code uri} belongs to @@ -57,12 +78,15 @@ public Project getProject(String uri) { String path = LspAdapter.toPath(uri); if (isDetached(uri)) { return detached.get(uri); - } else if (mainProject.smithyFiles().containsKey(path)) { - return mainProject; - } else { - // Note: In practice, this shouldn't really happen because the server shouldn't - // be tracking any files that aren't attached to a project. But for testing, this - // is useful to ensure that fact. + } else { + for (Project project : attached.values()) { + if (project.smithyFiles().containsKey(path)) { + return project; + } + } + + LOGGER.warning(() -> "Tried getting project for unknown file: " + uri); + return null; } } @@ -83,18 +107,28 @@ public boolean isTracked(String uri) { * @return Whether the given {@code uri} is of a file in a detached project */ public boolean isDetached(String uri) { - // We might be in a state where a file was added to the main project, + // We might be in a state where a file was added to a tracked project, // but was opened before the project loaded. This would result in it // being placed in a detached project. Removing it here is basically // like removing it lazily, although it does feel a little hacky. String path = LspAdapter.toPath(uri); - if (mainProject.smithyFiles().containsKey(path) && detached.containsKey(uri)) { + Project nonDetached = getNonDetached(path); + if (nonDetached != null && detached.containsKey(uri)) { removeDetachedProject(uri); } return detached.containsKey(uri); } + private Project getNonDetached(String path) { + for (Project project : attached.values()) { + if (project.smithyFiles().containsKey(path)) { + return project; + } + } + return null; + } + /** * @param uri The URI of the file to create a detached project for * @param text The text of the file to create a detached project for @@ -126,4 +160,56 @@ 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/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java index 2a033e5f..4181faa0 100644 --- a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -17,6 +17,7 @@ import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams; import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; @@ -31,6 +32,7 @@ import org.eclipse.lsp4j.TextDocumentItem; import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent; import software.amazon.smithy.utils.IoUtils; /** @@ -67,11 +69,15 @@ public static DidChangeWatchedFiles didChangeWatchedFiles() { return new DidChangeWatchedFiles(); } + public static DidChangeWorkspaceFolders didChangeWorkspaceFolders() { + return new DidChangeWorkspaceFolders(); + } + public static final class DidChange { - public String uri; - public Integer version; - public Range range; - public String text; + private String uri; + private Integer version; + private Range range; + private String text; public DidChange next() { this.version += 1; @@ -112,8 +118,8 @@ public DidChangeTextDocumentParams build() { } public static final class Initialize { - public List workspaceFolders = new ArrayList<>(); - public Object initializationOptions; + private final List workspaceFolders = new ArrayList<>(); + private Object initializationOptions; public Initialize workspaceFolder(String uri, String name) { this.workspaceFolders.add(new WorkspaceFolder(uri, name)); @@ -137,7 +143,7 @@ public InitializeParams build() { } public static final class DidClose { - public String uri; + private String uri; public DidClose uri(String uri) { this.uri = uri; @@ -150,10 +156,10 @@ public DidCloseTextDocumentParams build() { } public static final class DidOpen { - public String uri; - public String languageId = "smithy"; - public int version = 1; - public String text; + private String uri; + private String languageId = "smithy"; + private int version = 1; + private String text; public DidOpen uri(String uri) { this.uri = uri; @@ -184,7 +190,7 @@ public DidOpenTextDocumentParams build() { } public static final class DidSave { - String uri; + private String uri; public DidSave uri(String uri) { this.uri = uri; @@ -197,9 +203,9 @@ public DidSaveTextDocumentParams build() { } public static final class PositionRequest { - String uri; - int line; - int character; + private String uri; + private int line; + private int character; public PositionRequest uri(String uri) { this.uri = uri; @@ -239,7 +245,7 @@ public CompletionParams buildCompletion() { } public static final class DidChangeWatchedFiles { - public final List changes = new ArrayList<>(); + private final List changes = new ArrayList<>(); public DidChangeWatchedFiles event(String uri, FileChangeType type) { this.changes.add(new FileEvent(uri, type)); @@ -250,4 +256,24 @@ public DidChangeWatchedFilesParams build() { return new DidChangeWatchedFilesParams(changes); } } + + public static final class DidChangeWorkspaceFolders { + private final List added = new ArrayList<>(); + private final List removed = new ArrayList<>(); + + public DidChangeWorkspaceFolders added(String uri, String name) { + this.added.add(new WorkspaceFolder(uri, name)); + return this; + } + + public DidChangeWorkspaceFolders removed(String uri, String name) { + this.removed.add(new WorkspaceFolder(uri, name)); + return this; + } + + public DidChangeWorkspaceFoldersParams build() { + return new DidChangeWorkspaceFoldersParams( + new WorkspaceFoldersChangeEvent(added, removed)); + } + } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 4ec04ab1..9c845170 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1,6 +1,7 @@ package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anEmptyMap; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; @@ -59,11 +60,11 @@ import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; -import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.protocol.RangeBuilder; import software.amazon.smithy.model.node.ArrayNode; @@ -176,7 +177,7 @@ public void completionImports() throws Exception { assertThat(completions, containsInAnyOrder(hasLabel("Bar"))); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); // TODO: The server puts the 'use' on the wrong line assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + "namespace com.foo\n" + @@ -228,7 +229,7 @@ public void definition() throws Exception { List traitLocations = server.definition(traitParams).get().getLeft(); List wsLocations = server.definition(wsParams).get().getLeft(); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); assertNotNull(document); assertThat(memberTargetLocations, hasSize(1)); @@ -376,9 +377,9 @@ public void formatting() throws Exception { TextDocumentIdentifier id = new TextDocumentIdentifier(uri); DocumentFormattingParams params = new DocumentFormattingParams(id, new FormattingOptions()); List edits = server.formatting(params).get(); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); - assertThat(edits, (Matcher) containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + + assertThat(edits, containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" + @@ -434,16 +435,16 @@ public void didChange() throws Exception { server.getLifecycleManager().waitForAllTasks(); // mostly so you can see what it looks like - assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + - "\n" + - "namespace com.foo\n" + - "\n" + - "structure GetFooInput {\n" + - "}\n" + - "\n" + - "operation GetFoo {\n" + - " input: G\n" + - "}\n"))); + assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure GetFooInput {\n" + + "}\n" + + "\n" + + "operation GetFoo {\n" + + " input: G\n" + + "}\n"))); // input: G CompletionParams completionParams = new RequestBuilders.PositionRequest() @@ -471,7 +472,7 @@ public void didChangeReloadsModel() throws Exception { .text(model) .build(); server.didOpen(openParams); - assertThat(server.getProject().modelResult().getValidationEvents(), empty()); + assertThat(server.getFirstProject().modelResult().getValidationEvents(), empty()); DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() .uri(uri) @@ -482,13 +483,13 @@ public void didChangeReloadsModel() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().modelResult().getValidationEvents(), + assertThat(server.getFirstProject().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); server.didSave(didSaveParams); - assertThat(server.getProject().modelResult().getValidationEvents(), + assertThat(server.getFirstProject().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); } @@ -539,16 +540,16 @@ public void didChangeThenDefinition() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + - "namespace com.foo\n" + - "\n" + - "structure Foo {\n" + - " bar: Bar\n" + - "}\n" + - "\n" + - "string Baz\n" + - "\n" + - "string Bar\n"))); + assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "string Baz\n" + + "\n" + + "string Bar\n"))); Location afterChanges = server.definition(definitionParams).get().getLeft().get(0); assertThat(afterChanges.getUri(), equalTo(uri)); @@ -649,13 +650,13 @@ public void newShapeMixinCompletion() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + - "namespace com.foo\n" + - "\n" + - "@mixin\n" + - "structure Foo {}\n" + - "\n" + - "structure Bar with [F]"))); + assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar with [F]"))); Position currentPosition = range.build().getStart(); CompletionParams completionParams = new RequestBuilders.PositionRequest() @@ -663,7 +664,7 @@ public void newShapeMixinCompletion() throws Exception { .position(range.shiftRight().build().getStart()) .buildCompletion(); - assertThat(server.getProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + assertThat(server.getFirstProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); List completions = server.completion(completionParams).get().getLeft(); @@ -705,13 +706,13 @@ public void existingShapeMixinCompletion() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + - "namespace com.foo\n" + - "\n" + - "@mixin\n" + - "structure Foo {}\n" + - "\n" + - "structure Bar with [F] {}\n"))); + assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar with [F] {}\n"))); Position currentPosition = range.build().getStart(); CompletionParams completionParams = new RequestBuilders.PositionRequest() @@ -719,7 +720,7 @@ public void existingShapeMixinCompletion() throws Exception { .position(range.shiftRight().build().getStart()) .buildCompletion(); - assertThat(server.getProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + assertThat(server.getFirstProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); List completions = server.completion(completionParams).get().getLeft(); @@ -744,7 +745,7 @@ public void diagnosticsOnMemberTarget() { Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), startsWith("Target.UnresolvedShape")); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); } @@ -772,7 +773,7 @@ public void diagnosticOnTrait() { Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), startsWith("Model.UnresolvedTrait")); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); assertThat(diagnostic.getRange(), hasText(document, equalTo("@bar"))); } @@ -849,7 +850,7 @@ public void insideJar() throws Exception { String preludeUri = preludeLocation.getUri(); assertThat(preludeUri, startsWith("smithyjar")); - Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getProject().getDocument(preludeUri).fullRange()); + Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getFirstProject().getDocument(preludeUri).fullRange()); Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() .uri(preludeUri) @@ -894,9 +895,9 @@ public void addingWatchedFile() throws Exception { assertThat(server.getLifecycleManager().isManaged(uri), is(true)); assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().mainProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().mainProject().getDocument(uri), notNullValue()); - assertThat(server.getProjects().mainProject().getDocument(uri).copyText(), equalTo("$")); + assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProjects().getDocument(uri), notNullValue()); + assertThat(server.getProjects().getDocument(uri).copyText(), equalTo("$")); } @Test @@ -1027,7 +1028,7 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { + "string Foo\n"); TestWorkspace workspace = TestWorkspace.builder() .withSourceDir(TestWorkspace.dir() - .path("./smithy") + .withPath("./smithy") .withSourceFile("main.smithy", modelText)) .withConfig(config) .build(); @@ -1066,7 +1067,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getProject().modelResult().unwrap().getMetadata(); + Map metadataBefore = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -1088,7 +1089,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { server.getLifecycleManager().getTask(uri).get(); - Map metadataAfter = server.getProject().modelResult().unwrap().getMetadata(); + Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); @@ -1102,7 +1103,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { server.getLifecycleManager().getTask(uri).get(); - Map metadataAfter2 = server.getProject().modelResult().unwrap().getMetadata(); + Map metadataAfter2 = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter2, hasKey("foo")); assertThat(metadataAfter2, hasKey("bar")); assertThat(metadataAfter2.get("foo"), instanceOf(ArrayNode.class)); @@ -1130,7 +1131,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getProject().modelResult().unwrap().getMetadata(); + Map metadataBefore = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -1145,7 +1146,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { server.getLifecycleManager().waitForAllTasks(); - Map metadataAfter = server.getProject().modelResult().unwrap().getMetadata(); + Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); @@ -1202,9 +1203,9 @@ public void addingOpenedDetachedFile() throws Exception { assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - assertThat(server.getProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); } @Test @@ -1355,10 +1356,10 @@ public void invalidSyntaxModelPartiallyLoads() { String uri = workspace.getUri("model-0.smithy"); - assertThat(server.getProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProject().modelResult().isBroken(), is(true)); - assertThat(server.getProject().modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProject().modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getFirstProject().modelResult().isBroken(), is(true)); + assertThat(server.getFirstProject().modelResult().getResult().isPresent(), is(true)); + assertThat(server.getFirstProject().modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); } @Test @@ -1453,8 +1454,8 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { assertThat(server.getProjects().isDetached(uri), is(false)); assertThat(server.getProjects().detachedProjects().keySet(), empty()); - assertThat(server.getProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1483,8 +1484,8 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - Shape foo = server.getProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + Shape foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); @@ -1502,9 +1503,9 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - foo = server.getProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); + foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); } @@ -1661,6 +1662,388 @@ public void useCompletionDoesntAutoImport() throws Exception { assertThat(completions.get(0).getAdditionalTextEdits(), nullValue()); } + @Test + public void loadsMultipleRoots() { + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withName("foo") + .withPath("foo") + .withSourceFile("foo.smithy", """ + $version: "2" + namespace com.foo + structure Foo {} + """) + .build(); + + TestWorkspace workspaceBar = TestWorkspace.builder() + .withName("bar") + .withPath("bar") + .withSourceFile("bar.smithy", """ + $version: "2" + namespace com.bar + structure Bar {} + """) + .build(); + + SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + + assertThat(server.getProjects().attachedProjects(), hasKey("foo")); + assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + + 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"); + + assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + } + + @Test + public void multiRootLifecycleManagement() throws Exception { + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withName("foo") + .withPath("foo") + .withSourceFile("foo.smithy", """ + $version: "2" + namespace com.foo + structure Foo {} + """) + .build(); + + TestWorkspace workspaceBar = TestWorkspace.builder() + .withName("bar") + .withPath("bar") + .withSourceFile("bar.smithy", """ + $version: "2" + namespace com.bar + structure Bar {} + """) + .build(); + + SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + String barUri = workspaceBar.getUri("bar.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(fooUri) + .text("\nstructure Bar {}") + .range(LspAdapter.point(server.getProjects().getDocument(fooUri).end())) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(barUri) + .text("\nstructure Foo {}") + .range(LspAdapter.point(server.getProjects().getDocument(barUri).end())) + .build()); + + server.didSave(RequestBuilders.didSave() + .uri(fooUri) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(barUri) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + Project projectFoo = server.getProjects().getProjectByName("foo"); + Project projectBar = server.getProjects().getProjectByName("bar"); + + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); + + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Foo"))); + } + + @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") + .withSourceFile("main.smithy", "")) + .build(); + + SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + + String fooUri = workspaceFoo.getUri("model/main.smithy"); + String barUri = workspaceBar.getUri("model/main.smithy"); + + String newFilename = "model/other.smithy"; + String newText = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + workspaceBar.addModel(newFilename, newText); + + String newUri = workspaceBar.getUri(newFilename); + + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(fooUri) + .text(""" + $version: "2" + namespace com.foo + structure Foo {} + """) + .range(LspAdapter.origin()) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(newUri, FileChangeType.Created) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(fooUri) + .text(""" + + structure Bar {}""") + .range(LspAdapter.point(3, 0)) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + Project projectFoo = server.getProjects().getProjectByName("foo"); + Project projectBar = server.getProjects().getProjectByName("bar"); + + assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("other.smithy"))); + + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + } + + @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") + .withSourceFile("main.smithy", "")) + .build(); + + SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + + String newFilename = "other.smithy"; + String newText = """ + $version: "2" + namespace com.other + structure Other {} + """; + workspaceBar.addModel(newFilename, newText); + String newUri = workspaceBar.getUri(newFilename); + + server.didOpen(RequestBuilders.didOpen() + .uri(newUri) + .text(newText) + .build()); + + List updatedSources = new ArrayList<>(workspaceBar.getConfig().getSources()); + updatedSources.add(newFilename); + workspaceBar.updateConfig(workspaceBar.getConfig().toBuilder() + .sources(updatedSources) + .build()); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("model/main.smithy")) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(workspaceFoo.getUri("model/main.smithy")) + .text(""" + $version: "2" + namespace com.foo + structure Foo {} + """) + .range(LspAdapter.origin()) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(workspaceBar.getUri("model/main.smithy")) + .text(""" + $version: "2" + namespace com.bar + structure Bar { + other: com.other#Other + } + """) + .range(LspAdapter.origin()) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().detachedProjects(), anEmptyMap()); + assertThat(server.getProjects().getProject(newUri), notNullValue()); + 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"); + + assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("other.smithy"))); + + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar$other"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.other#Other"))); + } + + @Test + public void addingWorkspaceFolder() throws Exception { + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withName("foo") + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + SmithyLanguageServer server = initFromWorkspace(workspaceFoo); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withName("bar") + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("foo.smithy")) + .text(fooModel) + .build()); + + server.didChangeWorkspaceFolders(RequestBuilders.didChangeWorkspaceFolders() + .added(workspaceBar.getRoot().toUri().toString(), "bar") + .build()); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceBar.getUri("bar.smithy")) + .text(barModel) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().attachedProjects(), hasKey("foo")); + assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + + 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"); + + assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + } + + @Test + public void removingWorkspaceFolder() { + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withName("foo") + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withName("bar") + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("foo.smithy")) + .text(fooModel) + .build()); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceBar.getUri("bar.smithy")) + .text(barModel) + .build()); + + server.didChangeWorkspaceFolders(RequestBuilders.didChangeWorkspaceFolders() + .removed(workspaceBar.getRoot().toUri().toString(), "bar") + .build()); + + assertThat(server.getProjects().attachedProjects(), hasKey("foo")); + assertThat(server.getProjects().attachedProjects(), not(hasKey("bar"))); + 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 projectBar = server.getProjects().getProject(workspaceBar.getUri("bar.smithy")); + + assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); + assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + + assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + } public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } @@ -1671,7 +2054,7 @@ public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace, La server.connect(client); server.initialize(RequestBuilders.initialize() - .workspaceFolder(workspace.getRoot().toUri().toString(), "test") + .workspaceFolder(workspace.getRoot().toUri().toString(), workspace.getName()) .build()) .get(); @@ -1681,6 +2064,25 @@ public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace, La } } + public static SmithyLanguageServer initFromWorkspaces(TestWorkspace... workspaces) { + LanguageClient client = new StubClient(); + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + RequestBuilders.Initialize initialize = RequestBuilders.initialize(); + for (TestWorkspace workspace : workspaces) { + initialize.workspaceFolder(workspace.getRoot().toUri().toString(), workspace.getName()); + } + + try { + server.initialize(initialize.build()).get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return server; + } + public static SmithyLanguageServer initFromRoot(Path root) { try { LanguageClient client = new StubClient(); diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index 5349b874..166b82e8 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -86,7 +86,7 @@ public void noVersionDiagnostic() throws Exception { List edits = action.getEdit().getChanges().get(uri); assertThat(edits, hasSize(1)); TextEdit edit = edits.get(0); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); document.applyEdit(edit.getRange(), edit.getNewText()); assertThat(document.copyText(), equalTo("$version: \"1\"\n" + "\n" + @@ -135,7 +135,7 @@ public void oldVersionDiagnostic() throws Exception { List edits = action.getEdit().getChanges().get(uri); assertThat(edits, hasSize(1)); TextEdit edit = edits.get(0); - Document document = server.getProject().getDocument(uri); + Document document = server.getFirstProject().getDocument(uri); document.applyEdit(edit.getRange(), edit.getNewText()); assertThat(document.copyText(), equalTo("$version: \"2\"\n" + "namespace com.foo\n" + diff --git a/src/test/java/software/amazon/smithy/lsp/StubClient.java b/src/test/java/software/amazon/smithy/lsp/StubClient.java index f8b1d130..153f95cc 100644 --- a/src/test/java/software/amazon/smithy/lsp/StubClient.java +++ b/src/test/java/software/amazon/smithy/lsp/StubClient.java @@ -5,10 +5,12 @@ import java.util.concurrent.CompletableFuture; import org.eclipse.lsp4j.MessageActionItem; import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.ShowMessageRequestParams; import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.WorkDoneProgressCreateParams; import org.eclipse.lsp4j.services.LanguageClient; public final class StubClient implements LanguageClient { @@ -63,4 +65,12 @@ public CompletableFuture registerCapability(RegistrationParams params) { public CompletableFuture unregisterCapability(UnregistrationParams params) { return CompletableFuture.completedFuture(null); } + + @Override + public CompletableFuture createProgress(WorkDoneProgressCreateParams params) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void notifyProgress(ProgressParams params) {} } diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index 3dc37675..21d3c40b 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -25,10 +25,12 @@ public final class TestWorkspace { private static final NodeMapper MAPPER = new NodeMapper(); private final Path root; private SmithyBuildConfig config; + private final String name; - private TestWorkspace(Path root, SmithyBuildConfig config) { + private TestWorkspace(Path root, SmithyBuildConfig config, String name) { this.root = root; this.config = config; + this.name = name; } /** @@ -42,6 +44,10 @@ public SmithyBuildConfig getConfig() { return config; } + public String getName() { + return name; + } + /** * @param filename The name of the file to get the URI for, relative to the root * @return The LSP URI for the given filename @@ -99,7 +105,7 @@ public static TestWorkspace singleModel(String model) { */ public static TestWorkspace emptyWithDirSource() { return builder() - .withSourceDir(new Dir().path("model")) + .withSourceDir(new Dir().withPath("model")) .build(); } @@ -131,7 +137,7 @@ public static class Dir { List

sourceDirs = new ArrayList<>(); List importDirs = new ArrayList<>(); - public Dir path(String path) { + public Dir withPath(String path) { this.path = path; return this; } @@ -179,8 +185,16 @@ 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 Builder() {} + @Override + public Builder withPath(String path) { + this.path = path; + return this; + } + @Override public Builder withSourceFile(String filename, String model) { super.withSourceFile(filename, model); @@ -210,6 +224,11 @@ public Builder withConfig(SmithyBuildConfig config) { return this; } + public Builder withName(String name) { + this.name = name; + return this; + } + public TestWorkspace build() { try { if (path == null) { @@ -237,7 +256,7 @@ public TestWorkspace build() { writeModels(root); - return new TestWorkspace(root, config); + return new TestWorkspace(root, config, name); } 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 8c643d5d..6c73b426 100644 --- a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -5,6 +5,8 @@ package software.amazon.smithy.lsp; +import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.util.Optional; import org.hamcrest.CustomTypeSafeMatcher; import org.hamcrest.Description; @@ -33,4 +35,22 @@ public void describeMismatchSafely(Optional item, Description description) { } }; } + + public static Matcher canMatchPath(Path path) { + // PathMatcher implementations don't seem to have a nice toString, so this Matcher + // doesn't print out the PathMatcher that couldn't match, but we could wrap the + // system default PathMatcher in one that stores the original pattern, if this + // Matcher becomes too hard to diagnose failures for. + return new CustomTypeSafeMatcher("A matcher that matches " + path) { + @Override + protected boolean matchesSafely(PathMatcher item) { + return item.matches(path); + } + + @Override + protected void describeMismatchSafely(PathMatcher item, Description mismatchDescription) { + mismatchDescription.appendText("did not match"); + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java index 2cdf44ee..8b9df90a 100644 --- a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java @@ -6,17 +6,18 @@ package software.amazon.smithy.lsp.handler; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasItem; +import java.nio.file.FileSystems; +import java.nio.file.PathMatcher; import java.util.HashSet; import java.util.List; -import java.util.stream.Collectors; import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; 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; @@ -27,14 +28,14 @@ public class FileWatcherRegistrationHandlerTest { public void createsCorrectRegistrations() { TestWorkspace workspace = TestWorkspace.builder() .withSourceDir(new TestWorkspace.Dir() - .path("foo") + .withPath("foo") .withSourceDir(new TestWorkspace.Dir() - .path("bar") + .withPath("bar") .withSourceFile("bar.smithy", "") .withSourceFile("baz.smithy", "")) .withSourceFile("baz.smithy", "")) .withSourceDir(new TestWorkspace.Dir() - .path("other") + .withPath("other") .withSourceFile("other.smithy", "")) .withSourceFile("abc.smithy", "") .withConfig(SmithyBuildConfig.builder() @@ -44,17 +45,21 @@ public void createsCorrectRegistrations() { .build(); Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - List watcherPatterns = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project) + List matchers = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(List.of(project)) .stream() .map(Registration::getRegisterOptions) .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) .flatMap(options -> options.getWatchers().stream()) .map(watcher -> watcher.getGlobPattern().getLeft()) - .collect(Collectors.toList()); + // The watcher glob patterns will look different between windows/unix, so turning + // them into path matchers lets us do platform-agnostic assertions. + .map(pattern -> FileSystems.getDefault().getPathMatcher("glob:" + pattern)) + .toList(); - assertThat(watcherPatterns, containsInAnyOrder( - endsWith("foo/**/*.{smithy,json}"), - endsWith("other/**/*.{smithy,json}"), - endsWith("abc.smithy"))); + assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("foo/abc.smithy")))); + assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("foo/foo/abc/def.smithy")))); + assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("other/abc.smithy")))); + assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("other/foo/abc.smithy")))); + assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("abc.smithy")))); } } diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java new file mode 100644 index 00000000..fe9a2c50 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.MatcherAssert.assertThat; + +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.utils.ListUtils; + +public class ProjectFilePatternsTest { + @Test + public void createsPathMatchers() { + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(new TestWorkspace.Dir() + .withPath("foo") + .withSourceDir(new TestWorkspace.Dir() + .withPath("bar") + .withSourceFile("bar.smithy", "") + .withSourceFile("baz.smithy", "")) + .withSourceFile("baz.smithy", "")) + .withSourceDir(new TestWorkspace.Dir() + .withPath("other") + .withSourceFile("other.smithy", "")) + .withSourceFile("abc.smithy", "") + .withConfig(SmithyBuildConfig.builder() + .version("1") + .sources(ListUtils.of("foo", "other/", "abc.smithy")) + .build()) + .build(); + + Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + PathMatcher smithyMatcher = ProjectFilePatterns.getSmithyFilesPathMatcher(project); + PathMatcher buildMatcher = ProjectFilePatterns.getBuildFilesPathMatcher(project); + + Path root = project.root(); + assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("abc.smithy"))); + assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("foo/bar/baz.smithy"))); + assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("other/bar.smithy"))); + assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve("smithy-build.json"))); + assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve(".smithy-project.json"))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java index 1b8bea7f..4446ea5b 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java @@ -21,7 +21,7 @@ public void canCheckIfAFileIsTracked() { Project mainProject = ProjectLoader.load(attachedRoot).unwrap(); ProjectManager manager = new ProjectManager(); - manager.updateMainProject(mainProject); + manager.updateProjectByName("main", mainProject); String detachedUri = LspAdapter.toUri("/foo/bar"); manager.createDetachedProject(detachedUri, "");