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()); - } -}