From 9f1e92b628310c698168ad551323f826d92d51a8 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 3 Oct 2024 13:55:55 -0400 Subject: [PATCH 1/2] Synchronize on build file changes This change makes the server register for document lifecycle events like didChange for build files. Previous behavior was to register for file system change events for build files, but eventually we want to give diagnostics, completions, etc. for build files as you type, so we need to track all changes. This change keeps existing behavior from the user's perspective, as we still only reload projects on save, so it serves mostly as a refactor. I also extracted the state managed by SmithyLanguageServer to its own class, ServerState, and flattened ProjectManager into that class, so we can manage the state of the server in a more centralized location. --- .../smithy/lsp/DocumentLifecycleManager.java | 17 +- .../smithy/lsp/FileWatcherRegistrations.java | 11 +- .../amazon/smithy/lsp/ServerState.java | 236 +++++++++ .../smithy/lsp/SmithyLanguageServer.java | 380 +++++++------- .../amazon/smithy/lsp/WorkspaceChanges.java | 78 ++- .../amazon/smithy/lsp/project/BuildFile.java | 32 ++ .../amazon/smithy/lsp/project/Project.java | 20 +- .../smithy/lsp/project/ProjectAndFile.java | 16 + .../smithy/lsp/project/ProjectConfig.java | 26 +- .../lsp/project/ProjectConfigLoader.java | 59 ++- .../smithy/lsp/project/ProjectFile.java | 24 + .../smithy/lsp/project/ProjectLoader.java | 39 +- .../smithy/lsp/project/ProjectManager.java | 156 ------ .../amazon/smithy/lsp/project/SmithyFile.java | 2 +- .../amazon/smithy/lsp/FilePatternsTest.java | 6 +- .../lsp/FileWatcherRegistrationsTest.java | 4 +- .../amazon/smithy/lsp/ServerStateTest.java | 42 ++ .../smithy/lsp/SmithyLanguageServerTest.java | 469 ++++++++++++------ .../amazon/smithy/lsp/TestWorkspace.java | 8 + .../lsp/project/ProjectManagerTest.java | 43 -- 20 files changed, 995 insertions(+), 673 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/ServerState.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/BuildFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java create mode 100644 src/test/java/software/amazon/smithy/lsp/ServerStateTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java index ba9c33f7..7d2c98fb 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -6,29 +6,16 @@ package software.amazon.smithy.lsp; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.logging.Logger; /** - * Tracks asynchronous lifecycle tasks and client-managed documents. - * Allows cancelling of an ongoing task if a new task needs to be started. + * Tracks asynchronous lifecycle tasks, allowing for cancellation of an ongoing + * task if a new task needs to be started. */ final class DocumentLifecycleManager { - private static final Logger LOGGER = Logger.getLogger(DocumentLifecycleManager.class.getName()); private final Map> tasks = new HashMap<>(); - private final Set managedDocumentUris = new HashSet<>(); - - Set managedDocuments() { - return managedDocumentUris; - } - - boolean isManaged(String uri) { - return managedDocuments().contains(uri); - } CompletableFuture getTask(String uri) { return tasks.get(uri); diff --git a/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java index 6299e84f..d45ae276 100644 --- a/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java +++ b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java @@ -33,7 +33,7 @@ * future. */ final class FileWatcherRegistrations { - private static final Integer SMITHY_WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; + private static final Integer WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles"; private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles"; private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles"; @@ -57,7 +57,7 @@ private FileWatcherRegistrations() { static List getSmithyFileWatcherRegistrations(Collection projects) { List smithyFileWatchers = projects.stream() .flatMap(project -> FilePatterns.getSmithyFileWatchPatterns(project).stream()) - .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), SMITHY_WATCH_FILE_KIND)) + .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); return Collections.singletonList(new Registration( @@ -75,7 +75,7 @@ static List getSmithyFileWatcherUnregistrations() { /** * Creates registrations to tell the client to watch for any build file - * changes, creations, or deletions, across all workspaces. + * 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 @@ -83,7 +83,7 @@ static List getSmithyFileWatcherUnregistrations() { static List getBuildFileWatcherRegistrations(Collection workspaceRoots) { List watchers = workspaceRoots.stream() .map(FilePatterns::getWorkspaceBuildFilesWatchPattern) - .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern))) + .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); return Collections.singletonList(new Registration( @@ -92,6 +92,9 @@ static List getBuildFileWatcherRegistrations(Collection work new DidChangeWatchedFilesRegistrationOptions(watchers))); } + /** + * @return The unregistrations to stop watching for build file changes + */ static List getBuildFileWatcherUnregistrations() { return BUILD_FILE_WATCHER_UNREGISTRATIONS; } diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java new file mode 100644 index 00000000..44a109e7 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -0,0 +1,236 @@ +/* + * 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.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import org.eclipse.lsp4j.WorkspaceFolder; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; +import software.amazon.smithy.lsp.project.ProjectFile; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.util.Result; + +/** + * Keeps track of the state of the server. + * + * @param detachedProjects Map of smithy file **uri** to detached project + * for that file + * @param attachedProjects Map of directory **path** to attached project roots + * @param workspacePaths Paths to roots of each workspace open in the client + * @param managedUris Uris of each file managed by the server/client, i.e. + * files which may be updated by didChange + * @param lifecycleManager Container for ongoing tasks + */ +public record ServerState( + Map detachedProjects, + Map attachedProjects, + Set workspacePaths, + Set managedUris, + DocumentLifecycleManager lifecycleManager +) { + private static final Logger LOGGER = Logger.getLogger(ServerState.class.getName()); + + /** + * Create a new, empty server state. + */ + public ServerState() { + this( + new HashMap<>(), + new HashMap<>(), + new HashSet<>(), + new HashSet<>(), + new DocumentLifecycleManager()); + } + + /** + * @param uri Uri of the document to get + * @return The document if found and it is managed, otherwise {@code null} + */ + public Document getManagedDocument(String uri) { + if (managedUris.contains(uri)) { + ProjectAndFile projectAndFile = findProjectAndFile(uri); + if (projectAndFile != null) { + return projectAndFile.file().document(); + } + } + + return null; + } + + /** + * @param path The path of the document to get + * @return The document if found and it is managed, otherwise {@code null} + */ + public Document getManagedDocument(Path path) { + if (managedUris.isEmpty()) { + return null; + } + + String uri = LspAdapter.toUri(path.toString()); + return getManagedDocument(uri); + } + + ProjectAndFile findProjectAndFile(String uri) { + String path = LspAdapter.toPath(uri); + // TODO: From isDetached + for (Project project : attachedProjects.values()) { + ProjectFile projectFile = project.getProjectFile(path); + if (projectFile != null) { + detachedProjects.remove(uri); + + return new ProjectAndFile(project, projectFile); + } + } + + Project detachedProject = detachedProjects.get(uri); + if (detachedProject != null) { + ProjectFile projectFile = detachedProject.getProjectFile(path); + if (projectFile != null) { + return new ProjectAndFile(detachedProject, projectFile); + } + } + + LOGGER.warning(() -> "Tried to unknown file: " + uri); + + return null; + } + + boolean isDetached(String uri) { + // 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 detachedProjects project. Removing it here is basically + // like removing it lazily, although it does feel a little hacky. + String path = LspAdapter.toPath(uri); + if (detachedProjects.containsKey(uri)) { + for (Project project : attachedProjects.values()) { + if (project.smithyFiles().containsKey(path)) { + detachedProjects.remove(uri); + return false; + } + } + return true; + } + return false; + } + + Project createDetachedProject(String uri, String text) { + Project project = ProjectLoader.loadDetached(uri, text); + detachedProjects.put(uri, project); + return project; + } + + List tryInitProject(Path root) { + LOGGER.finest("Initializing project at " + root); + lifecycleManager.cancelAllTasks(); + + Result> loadResult = ProjectLoader.load(root, this); + String projectName = root.toString(); + if (loadResult.isOk()) { + Project updatedProject = loadResult.unwrap(); + + // If the project didn't load any config files, it is now empty and should be removed + if (updatedProject.config().buildFiles().isEmpty()) { + removeProjectAndResolveDetached(projectName); + } else { + resolveDetachedProjects(attachedProjects.get(projectName), updatedProject); + attachedProjects.put(projectName, updatedProject); + } + + LOGGER.finest("Initialized project at " + root); + return List.of(); + } + + 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. + attachedProjects.computeIfAbsent(projectName, ignored -> Project.empty(root)); + + return loadResult.unwrapErr(); + } + + 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()); + } + } + + 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 : attachedProjects.entrySet()) { + if (entry.getValue().root().startsWith(workspaceRoot)) { + projectsToRemove.add(entry.getKey()); + } + } + + for (String projectName : projectsToRemove) { + removeProjectAndResolveDetached(projectName); + } + } + + private void removeProjectAndResolveDetached(String projectName) { + Project removedProject = attachedProjects.remove(projectName); + if (removedProject != null) { + resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); + } + } + + 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 detachedProjects projects. + if (oldProject != null) { + Set currentProjectSmithyPaths = oldProject.smithyFiles().keySet(); + Set updatedProjectSmithyPaths = updatedProject.smithyFiles().keySet(); + + Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); + addedPaths.removeAll(currentProjectSmithyPaths); + for (String addedPath : addedPaths) { + String addedUri = LspAdapter.toUri(addedPath); + if (isDetached(addedUri)) { + detachedProjects.remove(addedUri); + } + } + + Set removedPaths = new HashSet<>(currentProjectSmithyPaths); + removedPaths.removeAll(updatedProjectSmithyPaths); + for (String removedPath : removedPaths) { + String removedUri = LspAdapter.toUri(removedPath); + // Only move to a detachedProjects project if the file is managed + if (managedUris.contains(removedUri)) { + Document removedDocument = oldProject.getDocument(removedUri); + // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings + createDetachedProject(removedUri, removedDocument.copyText()); + } + } + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index f2efd72b..6aa714ba 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -19,14 +19,11 @@ import com.google.gson.JsonObject; import java.io.IOException; -import java.net.URI; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,6 +34,7 @@ import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.eclipse.lsp4j.ClientCapabilities; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionOptions; import org.eclipse.lsp4j.CodeActionParams; @@ -55,6 +53,7 @@ import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.DocumentFilter; import org.eclipse.lsp4j.DocumentFormattingParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; @@ -75,8 +74,11 @@ import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.SymbolKind; +import org.eclipse.lsp4j.TextDocumentChangeRegistrationOptions; import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentRegistrationOptions; +import org.eclipse.lsp4j.TextDocumentSaveRegistrationOptions; import org.eclipse.lsp4j.TextDocumentSyncKind; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.Unregistration; @@ -105,12 +107,11 @@ import software.amazon.smithy.lsp.handler.CompletionHandler; import software.amazon.smithy.lsp.handler.DefinitionHandler; import software.amazon.smithy.lsp.handler.HoverHandler; +import software.amazon.smithy.lsp.project.BuildFile; 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.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.IdlTokenizer; import software.amazon.smithy.model.selector.Selector; @@ -128,7 +129,6 @@ public class SmithyLanguageServer implements static { ServerCapabilities capabilities = new ServerCapabilities(); - capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental); capabilities.setCodeActionProvider(new CodeActionOptions(SmithyCodeActions.all())); capabilities.setDefinitionProvider(true); capabilities.setDeclarationProvider(true); @@ -145,33 +145,20 @@ public class SmithyLanguageServer implements } private SmithyLanguageClient client; - private final ProjectManager projects = new ProjectManager(); - private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); + private final ServerState state = new ServerState(); private Severity minimumSeverity = Severity.WARNING; private boolean onlyReloadOnSave = false; - private final Set workspacePaths = new HashSet<>(); + private ClientCapabilities clientCapabilities; SmithyLanguageServer() { } - SmithyLanguageClient getClient() { - return this.client; - } - Project getFirstProject() { - return projects.attachedProjects().values().stream().findFirst().orElse(null); - } - - ProjectManager getProjects() { - return projects; - } - - DocumentLifecycleManager getLifecycleManager() { - return this.lifecycleManager; + return state.attachedProjects().values().stream().findFirst().orElse(null); } - Set getWorkspacePaths() { - return workspacePaths; + ServerState getState() { + return state; } @Override @@ -217,7 +204,6 @@ public CompletableFuture initialize(InitializeParams params) { } } - if (params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { Either workDoneProgressToken = params.getWorkDoneToken(); if (workDoneProgressToken != null) { @@ -227,7 +213,7 @@ public CompletableFuture initialize(InitializeParams params) { } for (WorkspaceFolder workspaceFolder : params.getWorkspaceFolders()) { - loadWorkspace(workspaceFolder); + state.loadWorkspace(workspaceFolder); } if (workDoneProgressToken != null) { @@ -236,35 +222,23 @@ public CompletableFuture initialize(InitializeParams params) { } } + this.clientCapabilities = params.getCapabilities(); + + // We register for this capability dynamically otherwise + if (!isDynamicSyncRegistrationSupported()) { + CAPABILITIES.setTextDocumentSync(TextDocumentSyncKind.Incremental); + } + LOGGER.finest("Done initialize"); return completedFuture(new InitializeResult(CAPABILITIES)); } 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(); - updateProject(projectName, 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.getProjectByName(projectName) == null) { - projects.updateProjectByName(projectName, Project.empty(root)); - } - + List loadErrors = state.tryInitProject(root); + if (!loadErrors.isEmpty()) { String baseMessage = "Failed to load Smithy project at " + root; StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); - for (Exception error : loadResult.unwrapErr()) { + for (Exception error : loadErrors) { errorMessage.append(System.lineSeparator()); errorMessage.append('\t'); errorMessage.append(error.getMessage()); @@ -276,49 +250,9 @@ private void tryInitProject(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. - if (oldProject != null) { - Set currentProjectSmithyPaths = oldProject.smithyFiles().keySet(); - Set updatedProjectSmithyPaths = updatedProject.smithyFiles().keySet(); - - Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); - addedPaths.removeAll(currentProjectSmithyPaths); - for (String addedPath : addedPaths) { - String addedUri = LspAdapter.toUri(addedPath); - if (projects.isDetached(addedUri)) { - projects.removeDetachedProject(addedUri); - } - } - - Set removedPaths = new HashSet<>(currentProjectSmithyPaths); - removedPaths.removeAll(updatedProjectSmithyPaths); - for (String removedPath : removedPaths) { - String removedUri = LspAdapter.toUri(removedPath); - // Only move to a detached project if the file is managed - if (lifecycleManager.managedDocuments().contains(removedUri)) { - Document removedDocument = oldProject.getDocument(removedUri); - // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings - projects.createDetachedProject(removedUri, removedDocument.copyText()); - } - } - } - } - private CompletableFuture registerSmithyFileWatchers() { List registrations = FileWatcherRegistrations.getSmithyFileWatcherRegistrations( - projects.attachedProjects().values()); + state.attachedProjects().values()); return client.registerCapability(new RegistrationParams(registrations)); } @@ -328,7 +262,7 @@ private CompletableFuture unregisterSmithyFileWatchers() { } private CompletableFuture registerWorkspaceBuildFileWatchers() { - var registrations = FileWatcherRegistrations.getBuildFileWatcherRegistrations(workspacePaths); + var registrations = FileWatcherRegistrations.getBuildFileWatcherRegistrations(state.workspacePaths()); return client.registerCapability(new RegistrationParams(registrations)); } @@ -339,10 +273,61 @@ private CompletableFuture unregisterWorkspaceBuildFileWatchers() { @Override public void initialized(InitializedParams params) { + // We have to do this in `initialized` because we can't send dynamic registrations in `initialize`. + if (isDynamicSyncRegistrationSupported()) { + registerDocumentSynchronization(); + } + registerWorkspaceBuildFileWatchers(); registerSmithyFileWatchers(); } + private boolean isDynamicSyncRegistrationSupported() { + return clientCapabilities != null + && clientCapabilities.getTextDocument() != null + && clientCapabilities.getTextDocument().getSynchronization() != null + && clientCapabilities.getTextDocument().getSynchronization().getDynamicRegistration(); + } + + private void registerDocumentSynchronization() { + List buildDocumentSelector = List.of( + new DocumentFilter("json", "file", "**/{smithy-build,.smithy-project}.json")); + + var openCloseBuildOpts = new TextDocumentRegistrationOptions(buildDocumentSelector); + var changeBuildOpts = new TextDocumentChangeRegistrationOptions(TextDocumentSyncKind.Incremental); + changeBuildOpts.setDocumentSelector(buildDocumentSelector); + var saveBuildOpts = new TextDocumentSaveRegistrationOptions(); + saveBuildOpts.setDocumentSelector(buildDocumentSelector); + + client.registerCapability(new RegistrationParams(List.of( + new Registration("SyncSmithyBuildFiles/Open", "textDocument/didOpen", openCloseBuildOpts), + new Registration("SyncSmithyBuildFiles/Close", "textDocument/didClose", openCloseBuildOpts), + new Registration("SyncSmithyBuildFiles/Change", "textDocument/didChange", changeBuildOpts), + new Registration("SyncSmithyBuildFiles/Save", "textDocument/didSave", saveBuildOpts)))); + + DocumentFilter smithyFilter = new DocumentFilter(); + smithyFilter.setLanguage("smithy"); + smithyFilter.setScheme("file"); + + DocumentFilter smithyJarFilter = new DocumentFilter(); + smithyJarFilter.setLanguage("smithy"); + smithyJarFilter.setScheme("smithyjar"); + + List smithyDocumentSelector = List.of(smithyFilter); + + var openCloseSmithyOpts = new TextDocumentRegistrationOptions(List.of(smithyFilter, smithyJarFilter)); + var changeSmithyOpts = new TextDocumentChangeRegistrationOptions(TextDocumentSyncKind.Incremental); + changeSmithyOpts.setDocumentSelector(smithyDocumentSelector); + var saveSmithyOpts = new TextDocumentSaveRegistrationOptions(); + saveSmithyOpts.setDocumentSelector(smithyDocumentSelector); + + client.registerCapability(new RegistrationParams(List.of( + new Registration("SyncSmithyFiles/Open", "textDocument/didOpen", openCloseSmithyOpts), + new Registration("SyncSmithyFiles/Close", "textDocument/didClose", openCloseSmithyOpts), + new Registration("SyncSmithyFiles/Change", "textDocument/didChange", changeSmithyOpts), + new Registration("SyncSmithyFiles/Save", "textDocument/didSave", saveBuildOpts)))); + } + @Override public WorkspaceService getWorkspaceService() { return this; @@ -367,11 +352,11 @@ public void exit() { @Override public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { LOGGER.finest("JarFileContents"); + String uri = textDocumentIdentifier.getUri(); - Project project = projects.getProject(uri); - Document document = project.getDocument(uri); - if (document != null) { - return completedFuture(document.copyText()); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile != null) { + return completedFuture(projectAndFile.file().document().copyText()); } else { // Technically this can throw if the uri is invalid return completedFuture(IoUtils.readUtf8Url(LspAdapter.jarUrl(uri))); @@ -391,8 +376,8 @@ public CompletableFuture> selectorCommand(SelectorParam } // Select from all available projects - Collection detached = projects.detachedProjects().values(); - Collection nonDetached = projects.attachedProjects().values(); + Collection detached = state.detachedProjects().values(); + Collection nonDetached = state.attachedProjects().values(); return completedFuture(Stream.concat(detached.stream(), nonDetached.stream()) .flatMap(project -> project.modelResult().getResult().stream()) @@ -407,7 +392,7 @@ public CompletableFuture> selectorCommand(SelectorParam @Override public CompletableFuture serverStatus() { List openProjects = new ArrayList<>(); - for (Project project : projects.attachedProjects().values()) { + for (Project project : state.attachedProjects().values()) { openProjects.add(new OpenProject( LspAdapter.toUri(project.root().toString()), project.smithyFiles().keySet().stream() @@ -416,7 +401,7 @@ public CompletableFuture serverStatus() { false)); } - for (Map.Entry entry : projects.detachedProjects().entrySet()) { + for (Map.Entry entry : state.detachedProjects().entrySet()) { openProjects.add(new OpenProject( entry.getKey(), Collections.singletonList(entry.getKey()), @@ -432,11 +417,10 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), // the smithy-build.json itself was changed, added, or deleted. - WorkspaceChanges changes = WorkspaceChanges.computeWorkspaceChanges( - params.getChanges(), projects, workspacePaths); + WorkspaceChanges changes = WorkspaceChanges.computeWorkspaceChanges(params.getChanges(), state); changes.byProject().forEach((projectName, projectChange) -> { - Project project = projects.getProjectByName(projectName); + Project project = state.attachedProjects().get(projectName); if (!projectChange.changedBuildFileUris().isEmpty()) { client.info("Build files changed, reloading project"); @@ -450,7 +434,7 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { + 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. + // so we don't need to resolve detachedProjects projects. project.updateFiles(createdUris, deletedUris); } }); @@ -469,11 +453,11 @@ public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { LOGGER.finest("DidChangeWorkspaceFolders"); for (WorkspaceFolder folder : params.getEvent().getAdded()) { - loadWorkspace(folder); + state.loadWorkspace(folder); } for (WorkspaceFolder folder : params.getEvent().getRemoved()) { - removeWorkspace(folder); + state.removeWorkspace(folder); } unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); @@ -481,44 +465,6 @@ public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { sendFileDiagnosticsForManagedDocuments(); } - 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()); - } - } - - 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()); - } - } - - for (String projectName : projectsToRemove) { - removeProjectAndResolveDetached(projectName); - } - } - - private void removeProjectAndResolveDetached(String projectName) { - Project removedProject = this.projects.removeProjectByName(projectName); - if (removedProject != null) { - resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); - } - } - @Override public void didChangeConfiguration(DidChangeConfigurationParams params) { } @@ -534,14 +480,15 @@ public void didChange(DidChangeTextDocumentParams params) { String uri = params.getTextDocument().getUri(); - lifecycleManager.cancelTask(uri); + state.lifecycleManager().cancelTask(uri); - Document document = projects.getDocument(uri); - if (document == null) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "change"); return; } + Document document = projectAndFile.file().document(); for (TextDocumentContentChangeEvent contentChangeEvent : params.getContentChanges()) { if (contentChangeEvent.getRange() != null) { document.applyEdit(contentChangeEvent.getRange(), contentChangeEvent.getText()); @@ -550,12 +497,13 @@ public void didChange(DidChangeTextDocumentParams params) { } } + // Don't reload or update the project on build file changes, only on save + if (projectAndFile.file() instanceof BuildFile) { + return; + } + if (!onlyReloadOnSave) { - Project project = projects.getProject(uri); - if (project == null) { - client.unknownFileError(uri, "change"); - return; - } + Project project = projectAndFile.project(); // TODO: A consequence of this is that any existing validation events are cleared, which // is kinda annoying. @@ -563,7 +511,7 @@ public void didChange(DidChangeTextDocumentParams params) { CompletableFuture future = CompletableFuture .runAsync(() -> project.updateModelWithoutValidating(uri)) .thenComposeAsync(unused -> sendFileDiagnostics(uri)); - lifecycleManager.putTask(uri, future); + state.lifecycleManager().putTask(uri, future); } } @@ -573,18 +521,18 @@ public void didOpen(DidOpenTextDocumentParams params) { String uri = params.getTextDocument().getUri(); - lifecycleManager.cancelTask(uri); - lifecycleManager.managedDocuments().add(uri); + state.lifecycleManager().cancelTask(uri); + state.managedUris().add(uri); String text = params.getTextDocument().getText(); - Document document = projects.getDocument(uri); - if (document != null) { - document.applyEdit(null, text); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile != null) { + projectAndFile.file().document().applyEdit(null, text); } else { - projects.createDetachedProject(uri, text); + state.createDetachedProject(uri, text); } - lifecycleManager.putTask(uri, sendFileDiagnostics(uri)); + state.lifecycleManager().putTask(uri, sendFileDiagnostics(uri)); } @Override @@ -592,12 +540,12 @@ public void didClose(DidCloseTextDocumentParams params) { LOGGER.finest("DidClose"); String uri = params.getTextDocument().getUri(); - lifecycleManager.managedDocuments().remove(uri); + state.managedUris().remove(uri); - if (projects.isDetached(uri)) { - // Only cancel tasks for detached projects, since we're dropping the project - lifecycleManager.cancelTask(uri); - projects.removeDetachedProject(uri); + if (state.isDetached(uri)) { + // Only cancel tasks for detachedProjects projects, since we're dropping the project + state.lifecycleManager().cancelTask(uri); + state.detachedProjects().remove(uri); } // TODO: Clear diagnostics? Can do this by sending an empty list @@ -608,24 +556,31 @@ public void didSave(DidSaveTextDocumentParams params) { LOGGER.finest("DidSave"); String uri = params.getTextDocument().getUri(); - lifecycleManager.cancelTask(uri); - if (!projects.isTracked(uri)) { - // TODO: Could also load a detached project here, but I don't know how this would + state.lifecycleManager().cancelTask(uri); + + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + // TODO: Could also load a detachedProjects project here, but I don't know how this would // actually happen in practice client.unknownFileError(uri, "save"); return; } - Project project = projects.getProject(uri); if (params.getText() != null) { - Document document = project.getDocument(uri); - document.applyEdit(null, params.getText()); + projectAndFile.file().document().applyEdit(null, params.getText()); } - CompletableFuture future = CompletableFuture - .runAsync(() -> project.updateAndValidateModel(uri)) - .thenCompose(unused -> sendFileDiagnostics(uri)); - lifecycleManager.putTask(uri, future); + Project project = projectAndFile.project(); + if (projectAndFile.file() instanceof BuildFile) { + tryInitProject(project.root()); + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + sendFileDiagnosticsForManagedDocuments(); + } else { + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateAndValidateModel(uri)) + .thenCompose(unused -> sendFileDiagnostics(uri)); + state.lifecycleManager().putTask(uri, future); + } } @Override @@ -633,13 +588,17 @@ public CompletableFuture, CompletionList>> completio LOGGER.finest("Completion"); String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "completion"); return completedFuture(Either.forLeft(Collections.emptyList())); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return completedFuture(Either.forLeft(List.of())); + } + + Project project = projectAndFile.project(); return CompletableFutures.computeAsync((cc) -> { CompletionHandler handler = new CompletionHandler(project, smithyFile); return Either.forLeft(handler.handle(params, cc)); @@ -657,14 +616,17 @@ public CompletableFuture resolveCompletionItem(CompletionItem un public CompletableFuture>> documentSymbol(DocumentSymbolParams params) { LOGGER.finest("DocumentSymbol"); + String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "document symbol"); return completedFuture(Collections.emptyList()); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return completedFuture(List.of()); + } return CompletableFutures.computeAsync((cc) -> { Collection documentShapes = smithyFile.documentShapes(); @@ -718,13 +680,17 @@ public CompletableFuture resolveCompletionItem(CompletionItem un LOGGER.finest("Definition"); String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "definition"); - return completedFuture(Either.forLeft(Collections.emptyList())); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return completedFuture(null); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + Project project = projectAndFile.project(); List locations = new DefinitionHandler(project, smithyFile).handle(params); return completedFuture(Either.forLeft(locations)); } @@ -734,13 +700,17 @@ public CompletableFuture hover(HoverParams params) { LOGGER.finest("Hover"); String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "hover"); - return completedFuture(HoverHandler.emptyContents()); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return completedFuture(null); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + Project project = projectAndFile.project(); // TODO: Abstract away passing minimum severity Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); @@ -759,13 +729,20 @@ public CompletableFuture>> codeAction(CodeActio @Override public CompletableFuture> formatting(DocumentFormattingParams params) { LOGGER.finest("Formatting"); + String uri = params.getTextDocument().getUri(); - Project project = projects.getProject(uri); - Document document = project.getDocument(uri); - if (document == null) { - return completedFuture(Collections.emptyList()); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "format"); + return completedFuture(null); } + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return completedFuture(null); + } + + Document document = smithyFile.document(); + IdlTokenizer tokenizer = IdlTokenizer.create(uri, document.borrowText()); TokenTree tokenTree = TokenTree.of(tokenizer); String formatted = Formatter.format(tokenTree); @@ -775,8 +752,8 @@ public CompletableFuture> formatting(DocumentFormatting } private void sendFileDiagnosticsForManagedDocuments() { - for (String managedDocumentUri : lifecycleManager.managedDocuments()) { - lifecycleManager.putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); + for (String managedDocumentUri : state.managedUris()) { + state.lifecycleManager().putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); } } @@ -795,12 +772,17 @@ List getFileDiagnostics(String uri) { return Collections.emptyList(); } - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "diagnostics"); + return List.of(); + } + + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return List.of(); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + Project project = projectAndFile.project(); String path = LspAdapter.toPath(uri); List diagnostics = project.modelResult().getValidationEvents().stream() @@ -814,7 +796,7 @@ List getFileDiagnostics(String uri) { diagnostics.add(versionDiagnostic); } - if (projects.isDetached(uri)) { + if (state.isDetached(uri)) { diagnostics.add(SmithyDiagnostics.detachedDiagnostic(smithyFile)); } @@ -827,7 +809,7 @@ private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFi // TODO: Improve location of diagnostics Range range = LspAdapter.lineOffset(LspAdapter.toPosition(sourceLocation)); - if (validationEvent.getShapeId().isPresent() && smithyFile != null) { + if (validationEvent.getShapeId().isPresent()) { // Event is (probably) on a member target if (validationEvent.containsId("Target")) { DocumentShape documentShape = smithyFile.documentShapesByStartPosition() diff --git a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java index 8fa94dc2..5f1601dc 100644 --- a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java +++ b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java @@ -11,12 +11,10 @@ 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; /** @@ -31,19 +29,15 @@ final class WorkspaceChanges { private WorkspaceChanges() { } - static WorkspaceChanges computeWorkspaceChanges( - List events, - ProjectManager projects, - Set workspacePaths - ) { + static WorkspaceChanges computeWorkspaceChanges(List events, ServerState state) { WorkspaceChanges changes = new WorkspaceChanges(); - List projectFileMatchers = new ArrayList<>(projects.attachedProjects().size()); - projects.attachedProjects().forEach((projectName, project) -> + List projectFileMatchers = new ArrayList<>(state.attachedProjects().size()); + state.attachedProjects().forEach((projectName, project) -> projectFileMatchers.add(createProjectFileMatcher(projectName, project))); - List workspaceBuildFileMatchers = new ArrayList<>(workspacePaths.size()); - workspacePaths.forEach(workspacePath -> + List workspaceBuildFileMatchers = new ArrayList<>(state.workspacePaths().size()); + state.workspacePaths().forEach(workspacePath -> workspaceBuildFileMatchers.add(FilePatterns.getWorkspaceBuildFilesPathMatcher(workspacePath))); for (FileEvent event : events) { @@ -68,45 +62,47 @@ private void addEvent( ) { 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; - } + for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { + if (projectFileMatcher.smithyFileMatcher().matches(changedPath)) { + addSmithyFileChange(event.getType(), changedUri, projectFileMatcher.projectName()); + return; + } else if (projectFileMatcher.buildFileMatcher().matches(changedPath)) { + addBuildFileChange(changedUri, projectFileMatcher.projectName()); + return; } - } else { - for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { - if (projectFileMatcher.buildFileMatcher().matches(changedPath)) { - byProject.computeIfAbsent(projectFileMatcher.projectName(), ignored -> ProjectChange.empty()) - .changedBuildFileUris() - .add(changedUri); + } + + // If no project matched, maybe there's an added project. + if (event.getType() == FileChangeType.Created) { + for (PathMatcher workspaceBuildFileMatcher : workspaceBuildFileMatchers) { + if (workspaceBuildFileMatcher.matches(changedPath)) { + Path newProjectRoot = changedPath.getParent(); + this.newProjectRoots.add(newProjectRoot); 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 void addSmithyFileChange(FileChangeType changeType, String changedUri, String projectName) { + ProjectChange projectChange = byProject.computeIfAbsent( + projectName, ignored -> ProjectChange.empty()); + + switch (changeType) { + case Created -> projectChange.createdSmithyFileUris().add(changedUri); + case Deleted -> projectChange.deletedSmithyFileUris().add(changedUri); + default -> { } } } + private void addBuildFileChange(String changedUri, String projectName) { + ProjectChange projectChange = byProject.computeIfAbsent( + projectName, ignored -> ProjectChange.empty()); + + projectChange.changedBuildFileUris().add(changedUri); + } + private record ProjectFileMatcher(String projectName, PathMatcher smithyFileMatcher, PathMatcher buildFileMatcher) { } diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java new file mode 100644 index 00000000..d2374302 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFile.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.project; + +import software.amazon.smithy.lsp.document.Document; + +/** + * The language server's representation of a smithy-build.json + * .smithy-project.json file. + */ +public final class BuildFile implements ProjectFile { + private final String path; + private final Document document; + + BuildFile(String path, Document document) { + this.path = path; + this.document = document; + } + + @Override + public String path() { + return path; + } + + @Override + public Document document() { + return document; + } +} 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 850f1082..1a793200 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -133,11 +133,25 @@ public ValidatedResult modelResult() { */ public Document getDocument(String uri) { String path = LspAdapter.toPath(uri); - SmithyFile smithyFile = smithyFiles.get(path); - if (smithyFile == null) { + ProjectFile projectFile = getProjectFile(path); + if (projectFile == null) { return null; } - return smithyFile.document(); + return projectFile.document(); + } + + /** + * @param path The path of the {@link ProjectFile} to get + * @return The {@link ProjectFile} corresponding to {@code path} if + * it exists in this project, otherwise {@code null}. + */ + public ProjectFile getProjectFile(String path) { + SmithyFile smithyFile = smithyFiles.get(path); + if (smithyFile != null) { + return smithyFile; + } + + return config.buildFiles().get(path); } /** diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java new file mode 100644 index 00000000..0d8b7494 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java @@ -0,0 +1,16 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +/** + * Simple wrapper for a project and a file in that project, which many + * server functions act upon. + * + * @param project The project, non-nullable + * @param file The file within {@code project}, non-nullable + */ +public record ProjectAndFile(Project project, ProjectFile file) { +} 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 17893273..bdad2b49 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -5,16 +5,16 @@ package software.amazon.smithy.lsp.project; -import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.ObjectNode; import software.amazon.smithy.model.node.StringNode; -import software.amazon.smithy.utils.IoUtils; /** * A complete view of all a project's configuration that is needed to load it, @@ -26,7 +26,7 @@ public final class ProjectConfig { private final String outputDirectory; private final List dependencies; private final MavenConfig mavenConfig; - private final List loadedConfigPaths; + private final Map buildFiles; private ProjectConfig(Builder builder) { this.sources = builder.sources; @@ -34,7 +34,7 @@ private ProjectConfig(Builder builder) { this.outputDirectory = builder.outputDirectory; this.dependencies = builder.dependencies; this.mavenConfig = builder.mavenConfig; - this.loadedConfigPaths = builder.loadedConfigPaths; + this.buildFiles = builder.buildFiles; } static ProjectConfig empty() { @@ -80,8 +80,11 @@ public Optional maven() { return Optional.ofNullable(mavenConfig); } - public List loadedConfigPaths() { - return loadedConfigPaths; + /** + * @return Map of path to each {@link BuildFile} loaded in the project + */ + public Map buildFiles() { + return buildFiles; } static final class Builder { @@ -90,14 +93,13 @@ static final class Builder { String outputDirectory; final List dependencies = new ArrayList<>(); MavenConfig mavenConfig; - final List loadedConfigPaths = new ArrayList<>(); + final Map buildFiles = new HashMap<>(); private Builder() { } - static Builder load(Path path) { - String json = IoUtils.readUtf8File(path); - Node node = Node.parseJsonWithComments(json, path.toString()); + static Builder load(BuildFile buildFile) { + Node node = Node.parseJsonWithComments(buildFile.document().copyText(), buildFile.path()); ObjectNode objectNode = node.expectObjectNode(); ProjectConfig.Builder projectConfigBuilder = ProjectConfig.builder(); objectNode.getArrayMember("sources").ifPresent(arrayNode -> @@ -155,8 +157,8 @@ public Builder mavenConfig(MavenConfig mavenConfig) { return this; } - public Builder loadedConfigPaths(List loadedConfigPaths) { - this.loadedConfigPaths.addAll(loadedConfigPaths); + public Builder buildFiles(Map buildFiles) { + this.buildFiles.putAll(buildFiles); return 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 71c12433..1061ce77 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -10,9 +10,13 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.logging.Logger; import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.NodeMapper; @@ -84,19 +88,23 @@ private ProjectConfigLoader() { } static Result> loadFromRoot(Path workspaceRoot) { - List loadedConfigPaths = new ArrayList<>(); + return loadFromRoot(workspaceRoot, new ServerState()); + } + + static Result> loadFromRoot(Path workspaceRoot, ServerState state) { SmithyBuildConfig.Builder builder = SmithyBuildConfig.builder(); List exceptions = new ArrayList<>(); + Map buildFiles = new HashMap<>(); 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(config -> { - builder.merge(config); - loadedConfigPaths.add(smithyBuildPath); + Result result = Result.ofFallible(() -> { + BuildFile buildFile = addBuildFile(buildFiles, smithyBuildPath, state); + return SmithyBuildConfig.fromNode( + Node.parseJsonWithComments(buildFile.document().copyText(), buildFile.path())); }); + result.get().ifPresent(builder::merge); result.getErr().ifPresent(exceptions::add); } else { LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); @@ -107,12 +115,11 @@ static Result> loadFromRoot(Path workspaceRoot) { for (String ext : SMITHY_BUILD_EXTS) { Path extPath = workspaceRoot.resolve(ext); if (Files.isRegularFile(extPath)) { - Result result = Result.ofFallible(() -> - loadSmithyBuildExtensions(extPath)); - result.get().ifPresent(config -> { - extensionsBuilder.merge(config); - loadedConfigPaths.add(extPath); + Result result = Result.ofFallible(() -> { + BuildFile buildFile = addBuildFile(buildFiles, extPath, state); + return loadSmithyBuildExtensions(buildFile); }); + result.get().ifPresent(extensionsBuilder::merge); result.getErr().ifPresent(exceptions::add); } } @@ -121,11 +128,12 @@ static Result> loadFromRoot(Path workspaceRoot) { Path smithyProjectPath = workspaceRoot.resolve(SMITHY_PROJECT); if (Files.isRegularFile(smithyProjectPath)) { LOGGER.info("Loading .smithy-project.json from " + smithyProjectPath); - Result result = Result.ofFallible(() -> - ProjectConfig.Builder.load(smithyProjectPath)); + Result result = Result.ofFallible(() -> { + BuildFile buildFile = addBuildFile(buildFiles, smithyProjectPath, state); + return ProjectConfig.Builder.load(buildFile); + }); if (result.isOk()) { finalConfigBuilder = result.unwrap(); - loadedConfigPaths.add(smithyProjectPath); } else { exceptions.add(result.unwrapErr()); } @@ -142,16 +150,29 @@ static Result> loadFromRoot(Path workspaceRoot) { if (finalConfigBuilder.outputDirectory == null) { config.getOutputDirectory().ifPresent(finalConfigBuilder::outputDirectory); } - finalConfigBuilder.loadedConfigPaths(loadedConfigPaths); + finalConfigBuilder.buildFiles(buildFiles); return Result.ok(finalConfigBuilder.build()); } - private static SmithyBuildExtensions loadSmithyBuildExtensions(Path path) { + private static BuildFile addBuildFile(Map buildFiles, Path path, ServerState state) { + Document managed = state.getManagedDocument(path); + BuildFile buildFile; + if (managed != null) { + buildFile = new BuildFile(path.toString(), managed); + } else { + Document document = Document.of(IoUtils.readUtf8File(path)); + buildFile = new BuildFile(path.toString(), document); + } + buildFiles.put(buildFile.path(), buildFile); + return buildFile; + } + + private static SmithyBuildExtensions loadSmithyBuildExtensions(BuildFile buildFile) { // NOTE: This is the legacy way we loaded build extensions. It used to throw a checked exception. - String content = IoUtils.readUtf8File(path); - ObjectNode node = Node.parseJsonWithComments(content, path.toString()).expectObjectNode(); + ObjectNode node = Node.parseJsonWithComments( + buildFile.document().copyText(), buildFile.path()).expectObjectNode(); SmithyBuildExtensions config = NODE_MAPPER.deserialize(node, SmithyBuildExtensions.class); - config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.load(path)); + config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.fromNode(node)); return config; } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java new file mode 100644 index 00000000..55f88ea5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import software.amazon.smithy.lsp.document.Document; + +/** + * A file belonging to a Smithy project that the language server understands + * and tracks. + */ +public sealed interface ProjectFile permits SmithyFile, BuildFile { + /** + * @return The absolute path of the file + */ + String path(); + + /** + * @return The underlying document of the file + */ + Document document(); +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java index cc4f8c6d..86b8b550 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -16,7 +16,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -26,6 +25,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.document.DocumentImports; import software.amazon.smithy.lsp.document.DocumentNamespace; @@ -55,9 +55,9 @@ private ProjectLoader() { } /** - * Loads a detached (single-file) {@link Project} with the given file. + * Loads a detachedProjects (single-file) {@link Project} with the given file. * - *

Unlike {@link #load(Path, ProjectManager, Set)}, this method isn't + *

Unlike {@link #load(Path, ServerState)}, this method isn't * fallible since it doesn't do any IO that we would want to recover an * error from. * @@ -66,7 +66,7 @@ private ProjectLoader() { * @return The loaded project */ public static Project loadDetached(String uri, String text) { - LOGGER.info("Loading detached project at " + uri); + LOGGER.info("Loading detachedProjects project at " + uri); String asPath = LspAdapter.toPath(uri); ValidatedResult modelResult = Model.assembler() .addUnparsedModel(asPath, text) @@ -94,7 +94,7 @@ public static Project loadDetached(String uri, String text) { // TODO: Make generic 'please file a bug report' exception throw new IllegalStateException( "Attempted to load an unknown source file (" - + filePath + ") in detached project at " + + filePath + ") in detachedProjects project at " + asPath + ". This is a bug in the language server."); } }); @@ -117,16 +117,11 @@ public static Project loadDetached(String uri, String text) { * reason about how the project was structured. * * @param root Path of the project root - * @param projects Currently loaded projects, for getting content of managed documents - * @param managedDocuments URIs of documents managed by the client + * @param state Server's current state * @return Result of loading the project */ - public static Result> load( - Path root, - ProjectManager projects, - Set managedDocuments - ) { - Result> configResult = ProjectConfigLoader.loadFromRoot(root); + public static Result> load(Path root, ServerState state) { + Result> configResult = ProjectConfigLoader.loadFromRoot(root, state); if (configResult.isErr()) { return Result.err(configResult.unwrapErr()); } @@ -155,14 +150,9 @@ public static Result> load( Result, Exception> loadModelResult = Result.ofFallible(() -> { for (Path path : allSmithyFilePaths) { - if (!managedDocuments.isEmpty()) { - String pathString = path.toString(); - String uri = LspAdapter.toUri(pathString); - if (managedDocuments.contains(uri)) { - assembler.addUnparsedModel(pathString, projects.getDocument(uri).copyText()); - } else { - assembler.addImport(path); - } + Document managed = state.getManagedDocument(path); + if (managed != null) { + assembler.addUnparsedModel(path.toString(), managed.copyText()); } else { assembler.addImport(path); } @@ -198,8 +188,9 @@ public static Result> load( // TODO: We recompute uri from path and vice-versa very frequently, // maybe we can cache it. String uri = LspAdapter.toUri(filePath); - if (managedDocuments.contains(uri)) { - return projects.getDocument(uri); + Document managed = state.getManagedDocument(uri); + if (managed != null) { + return managed; } // There may be a more efficient way of reading this return Document.of(IoUtils.readUtf8File(filePath)); @@ -212,7 +203,7 @@ public static Result> load( } static Result> load(Path root) { - return load(root, new ProjectManager(), new HashSet<>(0)); + return load(root, new ServerState()); } private static Map computeSmithyFiles( diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java deleted file mode 100644 index 3793dad2..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * 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.HashMap; -import java.util.Map; -import java.util.logging.Logger; -import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.LspAdapter; - -/** - * 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<>(); - private final Map attached = new HashMap<>(); - - public ProjectManager() { - } - - /** - * @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) { - return this.attached.get(name); - } - - /** - * @param name Name of the project to update - * @param updated Project to update - */ - public void updateProjectByName(String name, Project updated) { - this.attached.put(name, updated); - } - - /** - * @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. - */ - 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 - */ - public Project getProject(String uri) { - String path = LspAdapter.toPath(uri); - if (isDetached(uri)) { - return detached.get(uri); - } else { - for (Project project : attached.values()) { - if (project.smithyFiles().containsKey(path)) { - return project; - } - } - - LOGGER.warning(() -> "Tried getting project for unknown file: " + uri); - - return null; - } - } - - /** - * Note: This is equivalent to {@code getProject(uri) == null}. If this is true, - * there is also a corresponding {@link SmithyFile} in {@link Project#getSmithyFile(String)}. - * - * @param uri The URI of the file to check - * @return True if the given URI corresponds to a file tracked by the server - */ - public boolean isTracked(String uri) { - return getProject(uri) != null; - } - - /** - * @param uri The URI of the file to check - * @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 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); - 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 - * @return A new detached project of the given {@code uri} and {@code text} - */ - public Project createDetachedProject(String uri, String text) { - Project project = ProjectLoader.loadDetached(uri, text); - detached.put(uri, project); - return project; - } - - /** - * @param uri The URI of the file to remove a detached project for - * @return The removed project, or null if none existed - */ - public Project removeDetachedProject(String uri) { - return detached.remove(uri); - } - - /** - * @param uri The URI of the file to get the document of - * @return The {@link Document} corresponding to the given {@code uri}, if - * it exists in any projects, otherwise {@code null}. - */ - public Document getDocument(String uri) { - Project project = getProject(uri); - if (project == null) { - return null; - } - return project.getDocument(uri); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java index ba4374c0..a30cec1e 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -24,7 +24,7 @@ *

Note: This currently is only ever a .smithy file, but could represent * a .json file in the future. */ -public final class SmithyFile { +public final class SmithyFile implements ProjectFile { private final String path; private final Document document; // TODO: If we have more complex use-cases for partially updating SmithyFile, we diff --git a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java index 06d7973c..9ac13497 100644 --- a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -12,12 +12,10 @@ 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.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; -import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; public class FilePatternsTest { @@ -41,7 +39,7 @@ public void createsProjectPathMatchers() { .build()) .build(); - Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); PathMatcher smithyMatcher = FilePatterns.getSmithyFilesPathMatcher(project); PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); @@ -67,7 +65,7 @@ public void createsWorkspacePathMatchers() throws IOException { workspaceRoot.resolve("bar").toFile().mkdir(); workspaceRoot.resolve("bar/smithy-build.json").toFile().createNewFile(); - Project fooProject = ProjectLoader.load(fooWorkspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + Project fooProject = ProjectLoader.load(fooWorkspace.getRoot(), new ServerState()).unwrap(); PathMatcher fooBuildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(fooProject); PathMatcher workspaceBuildMatcher = FilePatterns.getWorkspaceBuildFilesPathMatcher(workspaceRoot); diff --git a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java index 2b839e72..88c710b8 100644 --- a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java @@ -10,7 +10,6 @@ import java.nio.file.FileSystems; import java.nio.file.PathMatcher; -import java.util.HashSet; import java.util.List; import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; import org.eclipse.lsp4j.Registration; @@ -18,7 +17,6 @@ import software.amazon.smithy.build.model.SmithyBuildConfig; 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 FileWatcherRegistrationsTest { @@ -42,7 +40,7 @@ public void createsCorrectRegistrations() { .build()) .build(); - Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); List matchers = FileWatcherRegistrations.getSmithyFileWatcherRegistrations(List.of(project)) .stream() .map(Registration::getRegisterOptions) diff --git a/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java b/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java new file mode 100644 index 00000000..c079935d --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java @@ -0,0 +1,42 @@ +/* + * 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.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class ServerStateTest { + @Test + public void canCheckIfAFileIsTracked() { + Path attachedRoot = ProjectTest.toPath(getClass().getResource("project/flat")); + ServerState manager = new ServerState(); + Project mainProject = ProjectLoader.load(attachedRoot, manager).unwrap(); + + manager.attachedProjects().put("main", mainProject); + + String detachedUri = LspAdapter.toUri("/foo/bar"); + manager.createDetachedProject(detachedUri, ""); + + String mainUri = LspAdapter.toUri(attachedRoot.resolve("main.smithy").toString()); + + assertThat(manager.findProjectAndFile(mainUri), notNullValue()); + assertThat(manager.findProjectAndFile(mainUri).project().getSmithyFile(mainUri), notNullValue()); + + assertThat(manager.findProjectAndFile(detachedUri), notNullValue()); + assertThat(manager.findProjectAndFile(detachedUri).project().getSmithyFile(detachedUri), notNullValue()); + + String untrackedUri = LspAdapter.toUri("/bar/baz.smithy"); + assertThat(manager.findProjectAndFile(untrackedUri), nullValue()); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 25f4e3ed..cbbb4228 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -37,6 +37,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -68,6 +69,7 @@ import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.protocol.RangeBuilder; import software.amazon.smithy.model.node.ArrayNode; @@ -369,7 +371,7 @@ public void documentSymbol() throws Exception { .uri(uri) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); DocumentSymbolParams params = new DocumentSymbolParams(new TextDocumentIdentifier(uri)); List> response = server.documentSymbol(params).get(); @@ -465,7 +467,7 @@ public void didChange() throws Exception { server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(" ").build()); server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("G").build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); // mostly so you can see what it looks like assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" @@ -518,7 +520,7 @@ public void didChangeReloadsModel() throws Exception { .build(); server.didChange(didChangeParams); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); @@ -577,7 +579,7 @@ public void didChangeThenDefinition() throws Exception { server.didChange(change.range(range.shiftRight().build()).text("a").build()); server.didChange(change.range(range.shiftRight().build()).text("z").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" $version: "2" @@ -691,7 +693,7 @@ public void newShapeMixinCompletion() throws Exception { server.didChange(change.range(range.shiftRight().build()).text("[]").build()); server.didChange(change.range(range.shiftRight().build()).text("F").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" $version: "2" @@ -750,7 +752,7 @@ public void existingShapeMixinCompletion() throws Exception { server.didChange(change.range(range.shiftRight().build()).text("[]").build()); server.didChange(change.range(range.shiftRight().build()).text("F").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" $version: "2" @@ -868,7 +870,7 @@ public void diagnosticsOnShape() throws Exception { assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), containsString("Missing required member")); - // TODO: In this case, the event is attached to the shape, but the shape isn't in the model + // TODO: In this case, the event is attachedProjects to the shape, but the shape isn't in the model // because it could not be successfully created. So we can't know the actual position of // the shape, because determining it depends on where its defined in the model. // assertThat(diagnostic.getRange().getStart(), equalTo(new Position(3, 5))); @@ -945,15 +947,15 @@ public void addingWatchedFile() throws Exception { .build()); // Make sure the task is running, then wait for it - CompletableFuture future = server.getLifecycleManager().getTask(uri); + CompletableFuture future = server.getState().lifecycleManager().getTask(uri); assertThat(future, notNullValue()); future.get(); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(false)); assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri).copyText(), equalTo("$")); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); + assertThat(server.getState().findProjectAndFile(uri).file().document().copyText(), equalTo("$")); } @Test @@ -982,8 +984,8 @@ public void removingWatchedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().getDocument(uri), nullValue()); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); } @Test @@ -1005,9 +1007,9 @@ public void addingDetachedFile() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(true)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); String movedFilename = "model/main.smithy"; workspace.moveModel(filename, movedFilename); @@ -1023,12 +1025,12 @@ public void addingDetachedFile() { .event(movedUri, FileChangeType.Created) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().isDetached(movedUri), is(false)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().managedUris().contains(movedUri), is(true)); + assertThat(server.getState().isDetached(movedUri), is(false)); + assertThat(server.getState().findProjectAndFile(movedUri), notNullValue()); } @Test @@ -1050,9 +1052,9 @@ public void removingAttachedFile() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); String movedFilename = "main.smithy"; workspace.moveModel(filename, movedFilename); @@ -1069,12 +1071,12 @@ public void removingAttachedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().isDetached(movedUri), is(true)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().managedUris().contains(movedUri), is(true)); + assertThat(server.getState().isDetached(movedUri), is(true)); + assertThat(server.getState().findProjectAndFile(movedUri), notNullValue()); } @Test @@ -1105,9 +1107,9 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); } @Test @@ -1155,7 +1157,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .uri(uri) .build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); @@ -1169,7 +1171,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .text("") .build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); Map metadataAfter2 = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter2, hasKey("foo")); @@ -1216,7 +1218,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { .event(uri, FileChangeType.Deleted) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); @@ -1242,18 +1244,18 @@ public void addingOpenedDetachedFile() throws Exception { String uri = workspace.getUri("main.smithy"); - assertThat(server.getLifecycleManager().managedDocuments(), not(hasItem(uri))); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); + assertThat(server.getState().managedUris(), not(hasItem(uri))); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); server.didOpen(RequestBuilders.didOpen() .uri(uri) .text(modelText) .build()); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris(), hasItem(uri)); + assertThat(server.getState().isDetached(uri), is(true)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1273,10 +1275,10 @@ public void addingOpenedDetachedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getState().managedUris(), hasItem(uri)); + assertThat(server.getState().isDetached(uri), is(false)); assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); @@ -1313,14 +1315,14 @@ public void detachingOpenedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getState().managedUris(), hasItem(uri)); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); } @Test @@ -1344,7 +1346,7 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - // Moving to an also detached file - the server doesn't send DidChangeWatchedFiles + // Moving to an also detachedProjects file - the server doesn't send DidChangeWatchedFiles String movedFilename = "main-2.smithy"; workspace.moveModel(filename, movedFilename); String movedUri = workspace.getUri(movedFilename); @@ -1357,14 +1359,14 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); - assertThat(server.getProjects().isDetached(movedUri), is(true)); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().managedUris().contains(movedUri), is(true)); + assertThat(server.getState().findProjectAndFile(movedUri), notNullValue()); + assertThat(server.getState().isDetached(movedUri), is(true)); } @Test @@ -1393,7 +1395,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .text(modelText1) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); List publishedDiagnostics1 = client.diagnostics; assertThat(publishedDiagnostics1, hasSize(1)); @@ -1419,7 +1421,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .event(uri2, FileChangeType.Created) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); List publishedDiagnostics2 = client.diagnostics; assertThat(publishedDiagnostics2, hasSize(2)); // sent more diagnostics @@ -1461,13 +1463,14 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { .text(modelText) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(true)); - assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult().isBroken(), is(true)); + assertThat(projectAndFile.project().modelResult().getResult().isPresent(), is(true)); + assertThat(projectAndFile.project().smithyFiles().keySet(), hasItem(endsWith(filename))); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1478,14 +1481,15 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { """)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(false)); - assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); - assertThat(server.getProjects().getProject(uri).modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile1 = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile1, notNullValue()); + assertThat(projectAndFile1.project().modelResult().isBroken(), is(false)); + assertThat(projectAndFile1.project().modelResult().getResult().isPresent(), is(true)); + assertThat(projectAndFile1.project().smithyFiles().keySet(), hasItem(endsWith(filename))); + assertThat(projectAndFile1.project().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } // TODO: apparently flaky @@ -1504,11 +1508,12 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .text("") .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().getSmithyFile(uri), notNullValue()); List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); updatedSources.add(filename); @@ -1537,10 +1542,10 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .range(LspAdapter.point(2, 0)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().detachedProjects().keySet(), empty()); assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @@ -1573,7 +1578,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text("2") .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); Shape foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); @@ -1592,7 +1597,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text(safeString("string Another\n")) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); @@ -1616,14 +1621,25 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { .event(uri, FileChangeType.Created) .build()); + String buildJson = workspace.readFile("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + String invalidDependency = "software.amazon.smithy:smithy-smoke-test-traits:[1.0, 2.0["; workspace.updateConfig(workspace.getConfig().toBuilder() .maven(MavenConfig.builder() .dependencies(Collections.singletonList(invalidDependency)) .build()) .build()); - server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + buildJson = workspace.readFile("smithy-build.json"); + server.didChange(RequestBuilders.didChange() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(workspace.getUri("smithy-build.json")) .build()); String fixed = "software.amazon.smithy:smithy-smoke-test-traits:1.49.0"; @@ -1632,8 +1648,13 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { .dependencies(Collections.singletonList(fixed)) .build()) .build()); - server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + buildJson = workspace.readFile("smithy-build.json"); + server.didChange(RequestBuilders.didChange() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(workspace.getUri("smithy-build.json")) .build()); server.didChange(RequestBuilders.didChange() @@ -1645,12 +1666,11 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { """)) .range(LspAdapter.origin()) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1787,14 +1807,14 @@ public void loadsMultipleRoots() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceBar.getName())); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -1838,12 +1858,12 @@ public void multiRootLifecycleManagement() throws Exception { server.didChange(RequestBuilders.didChange() .uri(fooUri) .text("\nstructure Bar {}") - .range(LspAdapter.point(server.getProjects().getDocument(fooUri).end())) + .range(LspAdapter.point(server.getState().findProjectAndFile(fooUri).file().document().end())) .build()); server.didChange(RequestBuilders.didChange() .uri(barUri) .text("\nstructure Foo {}") - .range(LspAdapter.point(server.getProjects().getDocument(barUri).end())) + .range(LspAdapter.point(server.getState().findProjectAndFile(barUri).file().document().end())) .build()); server.didSave(RequestBuilders.didSave() @@ -1853,10 +1873,10 @@ public void multiRootLifecycleManagement() throws Exception { .uri(barUri) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1924,10 +1944,10 @@ public void multiRootAddingWatchedFile() throws Exception { .range(LspAdapter.point(3, 0)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -2004,15 +2024,15 @@ public void multiRootChangingBuildFile() throws Exception { .range(LspAdapter.origin()) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().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()); + assertThat(server.getState().detachedProjects(), anEmptyMap()); + assertThat(server.getState().findProjectAndFile(newUri), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("model/main.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("model/main.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -2062,16 +2082,16 @@ public void addingWorkspaceFolder() throws Exception { .text(barModel) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceBar.getName())); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -2118,16 +2138,16 @@ public void removingWorkspaceFolder() { .removed(workspaceBar.getRoot().toUri().toString(), "bar") .build()); - 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.getState().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getState().attachedProjects(), not(hasKey(workspaceBar.getName()))); + assertThat(server.getState().detachedProjects(), hasKey(endsWith("bar.smithy"))); + assertThat(server.getState().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()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProject(workspaceBar.getUri("bar.smithy")); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")).project(); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -2165,10 +2185,10 @@ public void singleWorkspaceMultiRoot() throws Exception { SmithyLanguageServer server = initFromRoot(root); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getState().workspacePaths(), contains(root)); } @Test @@ -2205,8 +2225,8 @@ public void addingRootsToWorkspace() throws Exception { .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Created) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); } @@ -2240,10 +2260,10 @@ public void removingRootsFromWorkspace() throws Exception { SmithyLanguageServer server = initFromRoot(root); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getState().workspacePaths(), contains(root)); workspaceFoo.deleteModel("smithy-build.json"); @@ -2251,8 +2271,8 @@ public void removingRootsFromWorkspace() throws Exception { .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Deleted) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), contains(workspaceBar.getName())); + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), contains(workspaceBar.getName())); } @Test @@ -2295,11 +2315,11 @@ public void addingConfigFile() throws Exception { .text(bazModel) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + assertThat(server.getState().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); workspaceFoo.addModel(".smithy-project.json", """ { @@ -2309,10 +2329,10 @@ public void addingConfigFile() throws Exception { .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Created) .build()); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getState().detachedProjects().keySet(), empty()); } @Test @@ -2359,21 +2379,172 @@ public void removingConfigFile() throws Exception { .text(bazModel) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getState().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( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + assertThat(server.getState().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + } + + @Test + public void tracksJsonFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + workspace.addModel("model/main.json",""" + { + "smithy": "2.0", + "shapes": { + "com.foo#Foo": { + "type": "structure" + } + } + } + """); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + assertServerState(server, new ServerState( + Map.of( + workspace.getName(), + new ProjectState( + Set.of(workspace.getUri("model/main.json")), + Set.of(workspace.getUri("smithy-build.json")))), + Map.of() + )); + } + + @Test + public void tracksBuildFileChanges() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + String smithyBuildJson = workspace.readFile("smithy-build.json"); + String uri = workspace.getUri("smithy-build.json"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(smithyBuildJson) + .build()); + + assertThat(server.getState().managedUris(), contains(uri)); + assertThat(server.getState().getManagedDocument(uri), notNullValue()); + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(smithyBuildJson)); + + String updatedSmithyBuildJson = """ + { + "version": "1.0", + "sources": ["foo.smithy"] + } + """; + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(updatedSmithyBuildJson) + .build()); + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(updatedSmithyBuildJson)); + + server.didSave(RequestBuilders.didSave() + .uri(uri) + .build()); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + + assertThat(server.getState().managedUris(), not(contains(uri))); + assertThat(server.getState().getManagedDocument(uri), nullValue()); + } + + @Test + public void reloadsProjectOnBuildFileSave() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + String buildJson = workspace.readFile("smithy-build.json"); + String buildJsonUri = workspace.getUri("smithy-build.json"); + + server.didOpen(RequestBuilders.didOpen() + .uri(buildJsonUri) + .text(buildJson) + .build()); + + String model = """ + namespace com.foo + string Foo + """; + workspace.addModel("foo.smithy", model); + server.didOpen(RequestBuilders.didOpen() + .uri(workspace.getUri("foo.smithy")) + .text(model) + .build()); + + assertThat(server.getState().detachedProjects().keySet(), contains(workspace.getUri("foo.smithy"))); + + String updatedBuildJson = """ + { + "version": "1.0", + "sources": ["foo.smithy"] + } + """; + server.didChange(RequestBuilders.didChange() + .uri(buildJsonUri) + .text(updatedBuildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(buildJsonUri) + .build()); + + assertThat(server.getState().managedUris(), containsInAnyOrder( + buildJsonUri, + workspace.getUri("foo.smithy"))); + assertServerState(server, new ServerState( + Map.of(workspace.getName(), new ProjectState( + Set.of(workspace.getUri("foo.smithy")), + Set.of(buildJsonUri))), + Map.of())); + } + + private void assertServerState(SmithyLanguageServer server, ServerState expected) { + ServerState actual = ServerState.from(server); + assertThat(actual, equalTo(expected)); + } + + record ServerState( + Map attached, + Map detached + ) { + static ServerState from(SmithyLanguageServer server) { + return new ServerState( + server.getState().attachedProjects().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> ProjectState.from(e.getValue()))), + server.getState().detachedProjects().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> ProjectState.from(e.getValue())))); + } + } + + record ProjectState( + Set smithyFileUris, + Set buildFileUris + ) { + static ProjectState from(Project project) { + Set smithyFileUris = project.smithyFiles().keySet() + .stream() + .map(LspAdapter::toUri) + // Ignore these to make comparisons simpler + .filter(uri -> !LspAdapter.isSmithyJarFile(uri)) + .collect(Collectors.toSet()); + Set buildFileUris = project.config().buildFiles().keySet() + .stream() + .map(LspAdapter::toUri) + .collect(Collectors.toSet()); + return new ProjectState(smithyFileUris, buildFileUris); + } } public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index ab6946fb..8edab024 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -87,6 +87,14 @@ public void updateConfig(SmithyBuildConfig newConfig) { this.config = newConfig; } + public String readFile(String relativePath) { + try { + return Files.readString(root.resolve(relativePath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + /** * @param model String of the model to create in the workspace * @return A workspace with a single model, "main.smithy", with the given contents, and diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java deleted file mode 100644 index 4446ea5b..00000000 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -import java.nio.file.Path; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.protocol.LspAdapter; - -public class ProjectManagerTest { - @Test - public void canCheckIfAFileIsTracked() { - Path attachedRoot = ProjectTest.toPath(getClass().getResource("flat")); - Project mainProject = ProjectLoader.load(attachedRoot).unwrap(); - - ProjectManager manager = new ProjectManager(); - manager.updateProjectByName("main", mainProject); - - String detachedUri = LspAdapter.toUri("/foo/bar"); - manager.createDetachedProject(detachedUri, ""); - - String mainUri = LspAdapter.toUri(attachedRoot.resolve("main.smithy").toString()); - - assertThat(manager.isTracked(mainUri), is(true)); - assertThat(manager.getProject(mainUri), notNullValue()); - assertThat(manager.getProject(mainUri).getSmithyFile(mainUri), notNullValue()); - - assertThat(manager.isTracked(detachedUri), is(true)); - assertThat(manager.getProject(detachedUri), notNullValue()); - assertThat(manager.getProject(detachedUri).getSmithyFile(detachedUri), notNullValue()); - - String untrackedUri = LspAdapter.toUri("/bar/baz.smithy"); - assertThat(manager.isTracked(untrackedUri), is(false)); - assertThat(manager.getProject(untrackedUri), nullValue()); - } -} From b3beccaf3b3d306603fcc4f0f2b7dfd5e2c05781 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Tue, 5 Nov 2024 10:20:59 -0500 Subject: [PATCH 2/2] Refactor isDetached --- .../amazon/smithy/lsp/ServerState.java | 48 ++++++++++++------- .../smithy/lsp/project/ProjectConfig.java | 2 +- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index 44a109e7..20f30733 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -86,19 +86,14 @@ public Document getManagedDocument(Path path) { } ProjectAndFile findProjectAndFile(String uri) { - String path = LspAdapter.toPath(uri); - // TODO: From isDetached - for (Project project : attachedProjects.values()) { - ProjectFile projectFile = project.getProjectFile(path); - if (projectFile != null) { - detachedProjects.remove(uri); - - return new ProjectAndFile(project, projectFile); - } + ProjectAndFile attached = findAttachedAndRemoveDetached(uri); + if (attached != null) { + return attached; } Project detachedProject = detachedProjects.get(uri); if (detachedProject != null) { + String path = LspAdapter.toPath(uri); ProjectFile projectFile = detachedProject.getProjectFile(path); if (projectFile != null) { return new ProjectAndFile(detachedProject, projectFile); @@ -111,21 +106,38 @@ ProjectAndFile findProjectAndFile(String uri) { } boolean isDetached(String uri) { + if (detachedProjects.containsKey(uri)) { + ProjectAndFile attached = findAttachedAndRemoveDetached(uri); + // The file is only truly detached if the above didn't find an attached project + // for the given file + return attached == null; + } + + return false; + } + + /** + * Searches for the given {@code uri} in attached projects, and if found, + * makes sure any old detached projects for that file are removed. + * + * @param uri The uri of the project and file to find + * @return The attached project and file, or null if not found + */ + private ProjectAndFile findAttachedAndRemoveDetached(String uri) { + String path = LspAdapter.toPath(uri); // 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 detachedProjects project. Removing it here is basically // like removing it lazily, although it does feel a little hacky. - String path = LspAdapter.toPath(uri); - if (detachedProjects.containsKey(uri)) { - for (Project project : attachedProjects.values()) { - if (project.smithyFiles().containsKey(path)) { - detachedProjects.remove(uri); - return false; - } + for (Project project : attachedProjects.values()) { + ProjectFile projectFile = project.getProjectFile(path); + if (projectFile != null) { + detachedProjects.remove(uri); + return new ProjectAndFile(project, projectFile); } - return true; } - return false; + + return null; } Project createDetachedProject(String uri, String text) { 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 bdad2b49..7a07f6eb 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -93,7 +93,7 @@ static final class Builder { String outputDirectory; final List dependencies = new ArrayList<>(); MavenConfig mavenConfig; - final Map buildFiles = new HashMap<>(); + private final Map buildFiles = new HashMap<>(); private Builder() { }