From 87154b00ed0d8963cc08cf9df5b9572c911734d0 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:51:20 -0400 Subject: [PATCH 01/43] Support projects in subdirectories (#167) * Support projects in subdirectories This commit makes the language server work with Smithy projects in subdirectories of the folder (or folders, in the case of e.g. vscode workspace folders) open in the editor. I previously added support for multiple _workspace folders_ (an LSP concept), but I assumed only one _project_ (Smithy LS concept) per workspace folder. So this commit fixes that mixing, allowing many projects per workspace folder. Now, the language server will search through subdirectories of all workspace folders (by default, one workspace folder is open in the client) to find projects. Changes to build files, i.e. smithy-build.json, .smithy-project.json, are now tracked at the workspace level, so you can add a new project to an existing workspace. I also did _some_ cleanup of the project/workspace synchronization code, and moved some things around. A note on some tests: I'm using a `Files.createTempDirectory`, and adding the `TestWorkspace` as a subdir. In a follow-up commit, I will be changing `TestWorkspace` to be something like `TestProject`, which is more accurate. I didn't include it here to avoid a bunch of noise. * Fix deadlock in didChangeWorkspaceFolders Blocking on the future creating the progress token with the client means the server can't actually receive the response from the client for that request. Tests don't catch this because the mock client is called directly, rather than through the server proxy. I decided to just remove the progress token code for now so didChangeWorkspaceFolders can work at all, rather than trying to make the method work asynchronously, which is a larger lift considering it mutates the server state. That change is coming though. --- ...ectFilePatterns.java => FilePatterns.java} | 79 +++-- ...ler.java => FileWatcherRegistrations.java} | 37 ++- .../amazon/smithy/lsp/ProjectRootVisitor.java | 55 ++++ .../smithy/lsp/SmithyLanguageServer.java | 139 ++++++--- .../amazon/smithy/lsp/WorkspaceChanges.java | 120 ++++++++ .../amazon/smithy/lsp/project/Project.java | 4 + ...ProjectChanges.java => ProjectChange.java} | 19 +- .../smithy/lsp/project/ProjectConfig.java | 14 +- .../lsp/project/ProjectConfigLoader.java | 27 +- .../smithy/lsp/project/ProjectManager.java | 61 +--- ...atternsTest.java => FilePatternsTest.java} | 43 ++- ...java => FileWatcherRegistrationsTest.java} | 8 +- .../smithy/lsp/ProjectRootVisitorTest.java | 32 ++ .../smithy/lsp/SmithyLanguageServerTest.java | 289 ++++++++++++++++-- .../amazon/smithy/lsp/TestWorkspace.java | 25 +- .../amazon/smithy/lsp/UtilMatchers.java | 14 + .../multi-nested/nested-a/smithy-build.json | 0 .../nested-b/.smithy-project.json | 0 .../project/nested/nested/smithy-build.json | 0 19 files changed, 745 insertions(+), 221 deletions(-) rename src/main/java/software/amazon/smithy/lsp/{project/ProjectFilePatterns.java => FilePatterns.java} (50%) rename src/main/java/software/amazon/smithy/lsp/{handler/FileWatcherRegistrationHandler.java => FileWatcherRegistrations.java} (68%) create mode 100644 src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java create mode 100644 src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java rename src/main/java/software/amazon/smithy/lsp/project/{ProjectChanges.java => ProjectChange.java} (59%) rename src/test/java/software/amazon/smithy/lsp/{project/ProjectFilePatternsTest.java => FilePatternsTest.java} (51%) rename src/test/java/software/amazon/smithy/lsp/{handler/FileWatcherRegistrationHandlerTest.java => FileWatcherRegistrationsTest.java} (90%) create mode 100644 src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java similarity index 50% rename from src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java rename to src/main/java/software/amazon/smithy/lsp/FilePatterns.java index f4ba000c..23bab11f 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java +++ b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java @@ -3,32 +3,31 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.project; +package software.amazon.smithy.lsp; import java.io.File; import java.nio.file.FileSystems; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectConfigLoader; /** - * Utility methods for creating file patterns corresponding to meaningful - * paths of a {@link Project}, such as sources and build files. + * Utility methods for computing glob patterns that match against Smithy files + * or build files in Projects and workspaces. */ -public final class ProjectFilePatterns { - private static final int BUILD_FILE_COUNT = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length; - - private ProjectFilePatterns() { +final class FilePatterns { + private FilePatterns() { } /** * @param project The project to get watch patterns for * @return A list of glob patterns used to watch Smithy files in the given project */ - public static List getSmithyFileWatchPatterns(Project project) { + static List getSmithyFileWatchPatterns(Project project) { return Stream.concat(project.sources().stream(), project.imports().stream()) .map(path -> getSmithyFilePattern(path, true)) .toList(); @@ -38,45 +37,63 @@ public static List getSmithyFileWatchPatterns(Project project) { * @param project The project to get a path matcher for * @return A path matcher that can check if Smithy files belong to the given project */ - public static PathMatcher getSmithyFilesPathMatcher(Project project) { + static PathMatcher getSmithyFilesPathMatcher(Project project) { String pattern = Stream.concat(project.sources().stream(), project.imports().stream()) .map(path -> getSmithyFilePattern(path, false)) .collect(Collectors.joining(",")); - return FileSystems.getDefault().getPathMatcher("glob:{" + pattern + "}"); + return toPathMatcher("{" + pattern + "}"); } /** - * @param project The project to get the watch pattern for - * @return A glob pattern used to watch build files in the given project + * @param root The root to get the watch pattern for + * @return A glob pattern used to watch build files in the given workspace */ - public static String getBuildFilesWatchPattern(Project project) { - Path root = project.root(); - String buildJsonPattern = escapeBackslashes(root.resolve(ProjectConfigLoader.SMITHY_BUILD).toString()); - String projectJsonPattern = escapeBackslashes(root.resolve(ProjectConfigLoader.SMITHY_PROJECT).toString()); - - List patterns = new ArrayList<>(BUILD_FILE_COUNT); - patterns.add(buildJsonPattern); - patterns.add(projectJsonPattern); - for (String buildExt : ProjectConfigLoader.SMITHY_BUILD_EXTS) { - patterns.add(escapeBackslashes(root.resolve(buildExt).toString())); - } + static String getWorkspaceBuildFilesWatchPattern(Path root) { + return getBuildFilesPattern(root, true); + } - return "{" + String.join(",", patterns) + "}"; + /** + * @param root The root to get a path matcher for + * @return A path matcher that can check if a file is a build file within the given workspace + */ + static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) { + String pattern = getWorkspaceBuildFilesWatchPattern(root); + return toPathMatcher(pattern); } /** * @param project The project to get a path matcher for * @return A path matcher that can check if a file is a build file belonging to the given project */ - public static PathMatcher getBuildFilesPathMatcher(Project project) { - // Watch pattern is the same as the pattern used for matching - String pattern = getBuildFilesWatchPattern(project); - return FileSystems.getDefault().getPathMatcher("glob:" + pattern); + static PathMatcher getProjectBuildFilesPathMatcher(Project project) { + String pattern = getBuildFilesPattern(project.root(), false); + return toPathMatcher(pattern); + } + + private static PathMatcher toPathMatcher(String globPattern) { + return FileSystems.getDefault().getPathMatcher("glob:" + globPattern); + } + + // Patterns for the workspace need to match on all build files in all subdirectories, + // whereas patterns for projects only look at the top level (because project locations + // are defined by the presence of these build files). + private static String getBuildFilesPattern(Path root, boolean isWorkspacePattern) { + String rootString = root.toString(); + if (!rootString.endsWith(File.separator)) { + rootString += File.separator; + } + + if (isWorkspacePattern) { + rootString += "**" + File.separator; + } + + return escapeBackslashes(rootString + "{" + String.join(",", ProjectConfigLoader.PROJECT_BUILD_FILES) + "}"); } // When computing the pattern used for telling the client which files to watch, we want - // to only watch .smithy/.json files. We don't need in the PathMatcher pattern (and it - // is impossible anyway because we can't have a nested pattern). + // to only watch .smithy/.json files. We don't need it in the PathMatcher pattern because + // we only need to match files, not listen for specific changes (and it is impossible anyway + // because we can't have a nested pattern). private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) { String glob = path.toString(); if (glob.endsWith(".smithy") || glob.endsWith(".json")) { diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java similarity index 68% rename from src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java rename to src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java index 57602501..6299e84f 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java @@ -3,8 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.handler; +package software.amazon.smithy.lsp; +import java.nio.file.Path; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -15,7 +16,6 @@ import org.eclipse.lsp4j.WatchKind; import org.eclipse.lsp4j.jsonrpc.messages.Either; import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectFilePatterns; /** * Handles computing the {@link Registration}s and {@link Unregistration}s for @@ -32,7 +32,7 @@ * everything, since these events should be rarer. But we can optimize it in the * future. */ -public final class FileWatcherRegistrationHandler { +final class FileWatcherRegistrations { private static final Integer SMITHY_WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles"; private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles"; @@ -40,17 +40,23 @@ public final class FileWatcherRegistrationHandler { private static final List SMITHY_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration( WATCH_SMITHY_FILES_ID, WATCH_FILES_METHOD)); + private static final List BUILD_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration( + WATCH_BUILD_FILES_ID, + WATCH_FILES_METHOD)); - private FileWatcherRegistrationHandler() { + private FileWatcherRegistrations() { } /** + * Creates registrations to tell the client to watch for new or deleted + * Smithy files, specifically for files that are part of {@link Project}s. + * * @param projects The projects to get registrations for * @return The registrations to watch for Smithy file changes across all projects */ - public static List getSmithyFileWatcherRegistrations(Collection projects) { + static List getSmithyFileWatcherRegistrations(Collection projects) { List smithyFileWatchers = projects.stream() - .flatMap(project -> ProjectFilePatterns.getSmithyFileWatchPatterns(project).stream()) + .flatMap(project -> FilePatterns.getSmithyFileWatchPatterns(project).stream()) .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), SMITHY_WATCH_FILE_KIND)) .toList(); @@ -63,17 +69,20 @@ public static List getSmithyFileWatcherRegistrations(Collection getSmithyFileWatcherUnregistrations() { + static List getSmithyFileWatcherUnregistrations() { return SMITHY_FILE_WATCHER_UNREGISTRATIONS; } /** - * @param projects The projects to get registrations for - * @return The registrations to watch for build file changes across all projects + * Creates registrations to tell the client to watch for any build file + * changes, creations, or deletions, across all workspaces. + * + * @param workspaceRoots The roots of the workspaces to get registrations for + * @return The registrations to watch for build file changes across all workspaces */ - public static List getBuildFileWatcherRegistrations(Collection projects) { - List watchers = projects.stream() - .map(ProjectFilePatterns::getBuildFilesWatchPattern) + static List getBuildFileWatcherRegistrations(Collection workspaceRoots) { + List watchers = workspaceRoots.stream() + .map(FilePatterns::getWorkspaceBuildFilesWatchPattern) .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern))) .toList(); @@ -82,4 +91,8 @@ public static List getBuildFileWatcherRegistrations(Collection getBuildFileWatcherUnregistrations() { + return BUILD_FILE_WATCHER_UNREGISTRATIONS; + } } diff --git a/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java new file mode 100644 index 00000000..a1016b9d --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import software.amazon.smithy.lsp.project.ProjectConfigLoader; + +/** + * Finds Project roots based on the location of smithy-build.json and .smithy-project.json. + */ +final class ProjectRootVisitor extends SimpleFileVisitor { + private static final PathMatcher PROJECT_ROOT_MATCHER = FileSystems.getDefault().getPathMatcher( + "glob:{" + ProjectConfigLoader.SMITHY_BUILD + "," + ProjectConfigLoader.SMITHY_PROJECT + "}"); + private static final int MAX_VISIT_DEPTH = 10; + + private final List roots = new ArrayList<>(); + + /** + * Walks through the file tree starting at {@code workspaceRoot}, collecting + * paths of Project roots. + * + * @param workspaceRoot Root of the workspace to find projects in + * @return A list of project roots + * @throws IOException If an I/O error is thrown while walking files + */ + static List findProjectRoots(Path workspaceRoot) throws IOException { + ProjectRootVisitor visitor = new ProjectRootVisitor(); + Files.walkFileTree(workspaceRoot, EnumSet.noneOf(FileVisitOption.class), MAX_VISIT_DEPTH, visitor); + return visitor.roots; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + Path name = file.getFileName(); + if (name != null && PROJECT_ROOT_MATCHER.matches(name)) { + roots.add(file.getParent()); + return FileVisitResult.SKIP_SIBLINGS; + } + return FileVisitResult.CONTINUE; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 436c3f44..f2efd72b 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -33,9 +33,7 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; -import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -84,7 +82,6 @@ import org.eclipse.lsp4j.Unregistration; import org.eclipse.lsp4j.UnregistrationParams; import org.eclipse.lsp4j.WorkDoneProgressBegin; -import org.eclipse.lsp4j.WorkDoneProgressCreateParams; import org.eclipse.lsp4j.WorkDoneProgressEnd; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; @@ -107,10 +104,8 @@ import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; import software.amazon.smithy.lsp.handler.CompletionHandler; import software.amazon.smithy.lsp.handler.DefinitionHandler; -import software.amazon.smithy.lsp.handler.FileWatcherRegistrationHandler; import software.amazon.smithy.lsp.handler.HoverHandler; import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectChanges; import software.amazon.smithy.lsp.project.ProjectLoader; import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.lsp.project.SmithyFile; @@ -154,6 +149,7 @@ public class SmithyLanguageServer implements private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); private Severity minimumSeverity = Severity.WARNING; private boolean onlyReloadOnSave = false; + private final Set workspacePaths = new HashSet<>(); SmithyLanguageServer() { } @@ -174,6 +170,10 @@ DocumentLifecycleManager getLifecycleManager() { return this.lifecycleManager; } + Set getWorkspacePaths() { + return workspacePaths; + } + @Override public void connect(LanguageClient client) { LOGGER.finest("Connect"); @@ -227,8 +227,7 @@ public CompletableFuture initialize(InitializeParams params) { } for (WorkspaceFolder workspaceFolder : params.getWorkspaceFolders()) { - Path root = Paths.get(URI.create(workspaceFolder.getUri())); - tryInitProject(workspaceFolder.getName(), root); + loadWorkspace(workspaceFolder); } if (workDoneProgressToken != null) { @@ -241,15 +240,17 @@ public CompletableFuture initialize(InitializeParams params) { return completedFuture(new InitializeResult(CAPABILITIES)); } - private void tryInitProject(String name, Path root) { + private void tryInitProject(Path root) { LOGGER.finest("Initializing project at " + root); lifecycleManager.cancelAllTasks(); + Result> loadResult = ProjectLoader.load( root, projects, lifecycleManager.managedDocuments()); + + String projectName = root.toString(); if (loadResult.isOk()) { Project updatedProject = loadResult.unwrap(); - resolveDetachedProjects(this.projects.getProjectByName(name), updatedProject); - this.projects.updateProjectByName(name, updatedProject); + updateProject(projectName, updatedProject); LOGGER.finest("Initialized project at " + root); } else { LOGGER.severe("Init project failed"); @@ -257,11 +258,11 @@ private void tryInitProject(String name, Path root) { // if we find a smithy-build.json, etc. // If we overwrite an existing project with an empty one, we lose track of the state of tracked // files. Instead, we will just keep the original project before the reload failure. - if (projects.getProjectByName(name) == null) { - projects.updateProjectByName(name, Project.empty(root)); + if (projects.getProjectByName(projectName) == null) { + projects.updateProjectByName(projectName, Project.empty(root)); } - String baseMessage = "Failed to load Smithy project " + name + " at " + root; + String baseMessage = "Failed to load Smithy project at " + root; StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); for (Exception error : loadResult.unwrapErr()) { errorMessage.append(System.lineSeparator()); @@ -275,6 +276,16 @@ private void tryInitProject(String name, Path root) { } } + private void updateProject(String projectName, Project updatedProject) { + // If the project didn't load any config files, it is now empty and should be removed + if (updatedProject.config().loadedConfigPaths().isEmpty()) { + removeProjectAndResolveDetached(projectName); + } else { + resolveDetachedProjects(this.projects.getProjectByName(projectName), updatedProject); + this.projects.updateProjectByName(projectName, updatedProject); + } + } + private void resolveDetachedProjects(Project oldProject, Project updatedProject) { // This is a project reload, so we need to resolve any added/removed files // that need to be moved to or from detached projects. @@ -306,21 +317,29 @@ private void resolveDetachedProjects(Project oldProject, Project updatedProject) } private CompletableFuture registerSmithyFileWatchers() { - List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations( + List registrations = FileWatcherRegistrations.getSmithyFileWatcherRegistrations( projects.attachedProjects().values()); return client.registerCapability(new RegistrationParams(registrations)); } private CompletableFuture unregisterSmithyFileWatchers() { - List unregistrations = FileWatcherRegistrationHandler.getSmithyFileWatcherUnregistrations(); + List unregistrations = FileWatcherRegistrations.getSmithyFileWatcherUnregistrations(); + return client.unregisterCapability(new UnregistrationParams(unregistrations)); + } + + private CompletableFuture registerWorkspaceBuildFileWatchers() { + var registrations = FileWatcherRegistrations.getBuildFileWatcherRegistrations(workspacePaths); + return client.registerCapability(new RegistrationParams(registrations)); + } + + private CompletableFuture unregisterWorkspaceBuildFileWatchers() { + var unregistrations = FileWatcherRegistrations.getBuildFileWatcherUnregistrations(); return client.unregisterCapability(new UnregistrationParams(unregistrations)); } @Override public void initialized(InitializedParams params) { - List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations( - projects.attachedProjects().values()); - client.registerCapability(new RegistrationParams(registrations)); + registerWorkspaceBuildFileWatchers(); registerSmithyFileWatchers(); } @@ -411,19 +430,22 @@ public CompletableFuture serverStatus() { public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { LOGGER.finest("DidChangeWatchedFiles"); // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), - // or the smithy-build.json itself was changed + // the smithy-build.json itself was changed, added, or deleted. - Map changesByProject = projects.computeProjectChanges(params.getChanges()); + WorkspaceChanges changes = WorkspaceChanges.computeWorkspaceChanges( + params.getChanges(), projects, workspacePaths); - changesByProject.forEach((projectName, projectChanges) -> { + changes.byProject().forEach((projectName, projectChange) -> { Project project = projects.getProjectByName(projectName); - if (projectChanges.hasChangedBuildFiles()) { + + if (!projectChange.changedBuildFileUris().isEmpty()) { client.info("Build files changed, reloading project"); // TODO: Handle more granular updates to build files. - tryInitProject(projectName, project.root()); - } else if (projectChanges.hasChangedSmithyFiles()) { - Set createdUris = projectChanges.createdSmithyFileUris(); - Set deletedUris = projectChanges.deletedSmithyFileUris(); + // Note: This will take care of removing projects when build files are deleted + tryInitProject(project.root()); + } else { + Set createdUris = projectChange.createdSmithyFileUris(); + Set deletedUris = projectChange.deletedSmithyFileUris(); client.info("Project files changed, adding files " + createdUris + " and removing files " + deletedUris); @@ -433,7 +455,10 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { } }); + changes.newProjectRoots().forEach(this::tryInitProject); + // TODO: Update watchers based on specific changes + // Note: We don't update build file watchers here - only on workspace changes unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); sendFileDiagnosticsForManagedDocuments(); @@ -443,40 +468,54 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { LOGGER.finest("DidChangeWorkspaceFolders"); - Either progressToken = Either.forLeft(UUID.randomUUID().toString()); - try { - client.createProgress(new WorkDoneProgressCreateParams(progressToken)).get(); - } catch (ExecutionException | InterruptedException e) { - client.error(String.format("Unable to create work done progress token: %s", e.getMessage())); - progressToken = null; + for (WorkspaceFolder folder : params.getEvent().getAdded()) { + loadWorkspace(folder); } - if (progressToken != null) { - WorkDoneProgressBegin begin = new WorkDoneProgressBegin(); - begin.setTitle("Updating workspace"); - client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(begin))); + for (WorkspaceFolder folder : params.getEvent().getRemoved()) { + removeWorkspace(folder); } - for (WorkspaceFolder folder : params.getEvent().getAdded()) { - Path root = Paths.get(URI.create(folder.getUri())); - tryInitProject(folder.getName(), root); - } + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + unregisterWorkspaceBuildFileWatchers().thenRun(this::registerWorkspaceBuildFileWatchers); + sendFileDiagnosticsForManagedDocuments(); + } - for (WorkspaceFolder folder : params.getEvent().getRemoved()) { - Project removedProject = projects.removeProjectByName(folder.getName()); - if (removedProject == null) { - continue; + private void loadWorkspace(WorkspaceFolder workspaceFolder) { + Path workspaceRoot = Paths.get(URI.create(workspaceFolder.getUri())); + workspacePaths.add(workspaceRoot); + try { + List projectRoots = ProjectRootVisitor.findProjectRoots(workspaceRoot); + for (Path root : projectRoots) { + tryInitProject(root); } + } catch (IOException e) { + LOGGER.severe(e.getMessage()); + } + } - resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); + private void removeWorkspace(WorkspaceFolder folder) { + Path workspaceRoot = Paths.get(URI.create(folder.getUri())); + workspacePaths.remove(workspaceRoot); + + // Have to do the removal separately, so we don't modify project.attachedProjects() + // while iterating through it + List projectsToRemove = new ArrayList<>(); + for (var entry : projects.attachedProjects().entrySet()) { + if (entry.getValue().root().startsWith(workspaceRoot)) { + projectsToRemove.add(entry.getKey()); + } } - unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); - sendFileDiagnosticsForManagedDocuments(); + for (String projectName : projectsToRemove) { + removeProjectAndResolveDetached(projectName); + } + } - if (progressToken != null) { - WorkDoneProgressEnd end = new WorkDoneProgressEnd(); - client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(end))); + private void removeProjectAndResolveDetached(String projectName) { + Project removedProject = this.projects.removeProjectByName(projectName); + if (removedProject != null) { + resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); } } diff --git a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java new file mode 100644 index 00000000..8fa94dc2 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectChange; +import software.amazon.smithy.lsp.project.ProjectManager; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +/** + * Aggregates changes to the workspace, including existing project changes and + * new project additions. + */ +final class WorkspaceChanges { + // smithy-build.json + .smithy-project.json + exts + private final Map byProject = new HashMap<>(); + private final List newProjectRoots = new ArrayList<>(); + + private WorkspaceChanges() { + } + + static WorkspaceChanges computeWorkspaceChanges( + List events, + ProjectManager projects, + Set workspacePaths + ) { + WorkspaceChanges changes = new WorkspaceChanges(); + + List projectFileMatchers = new ArrayList<>(projects.attachedProjects().size()); + projects.attachedProjects().forEach((projectName, project) -> + projectFileMatchers.add(createProjectFileMatcher(projectName, project))); + + List workspaceBuildFileMatchers = new ArrayList<>(workspacePaths.size()); + workspacePaths.forEach(workspacePath -> + workspaceBuildFileMatchers.add(FilePatterns.getWorkspaceBuildFilesPathMatcher(workspacePath))); + + for (FileEvent event : events) { + changes.addEvent(event, projectFileMatchers, workspaceBuildFileMatchers); + } + + return changes; + } + + Map byProject() { + return byProject; + } + + List newProjectRoots() { + return newProjectRoots; + } + + private void addEvent( + FileEvent event, + List projectFileMatchers, + List workspaceBuildFileMatchers + ) { + String changedUri = event.getUri(); + Path changedPath = Path.of(LspAdapter.toPath(changedUri)); + if (changedUri.endsWith(".smithy")) { + for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { + if (projectFileMatcher.smithyFileMatcher().matches(changedPath)) { + ProjectChange projectChange = byProject.computeIfAbsent( + projectFileMatcher.projectName(), ignored -> ProjectChange.empty()); + + switch (event.getType()) { + case Created -> projectChange.createdSmithyFileUris().add(changedUri); + case Deleted -> projectChange.deletedSmithyFileUris().add(changedUri); + default -> { + } + } + return; + } + } + } else { + for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { + if (projectFileMatcher.buildFileMatcher().matches(changedPath)) { + byProject.computeIfAbsent(projectFileMatcher.projectName(), ignored -> ProjectChange.empty()) + .changedBuildFileUris() + .add(changedUri); + return; + } + } + + // Only check if there's an added project. If there was a project we didn't match before, there's + // not much we could do at this point anyway. + if (event.getType() == FileChangeType.Created) { + for (PathMatcher workspaceBuildFileMatcher : workspaceBuildFileMatchers) { + if (workspaceBuildFileMatcher.matches(changedPath)) { + Path newProjectRoot = changedPath.getParent(); + this.newProjectRoots.add(newProjectRoot); + return; + } + } + } + } + } + + private record ProjectFileMatcher(String projectName, PathMatcher smithyFileMatcher, PathMatcher buildFileMatcher) { + } + + private static ProjectFileMatcher createProjectFileMatcher(String projectName, Project project) { + PathMatcher smithyFileMatcher = FilePatterns.getSmithyFilesPathMatcher(project); + + PathMatcher buildFileMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); + return new ProjectFileMatcher(projectName, smithyFileMatcher, buildFileMatcher); + } +} + diff --git a/src/main/java/software/amazon/smithy/lsp/project/Project.java b/src/main/java/software/amazon/smithy/lsp/project/Project.java index 42c9135e..850f1082 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -76,6 +76,10 @@ public Path root() { return root; } + public ProjectConfig config() { + return config; + } + /** * @return The paths of all Smithy sources specified * in this project's smithy build configuration files, diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java similarity index 59% rename from src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java rename to src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java index 0da219e9..2e85742a 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java @@ -5,6 +5,7 @@ package software.amazon.smithy.lsp.project; +import java.util.HashSet; import java.util.Set; /** @@ -14,22 +15,18 @@ * @param createdSmithyFileUris The uris of created Smithy files * @param deletedSmithyFileUris The uris of deleted Smithy files */ -public record ProjectChanges( +public record ProjectChange( Set changedBuildFileUris, Set createdSmithyFileUris, Set deletedSmithyFileUris ) { /** - * @return Whether there are any changed build files + * @return An empty and mutable set of project changes */ - public boolean hasChangedBuildFiles() { - return !changedBuildFileUris.isEmpty(); - } - - /** - * @return Whether there are any changed Smithy files - */ - public boolean hasChangedSmithyFiles() { - return !createdSmithyFileUris.isEmpty() || !deletedSmithyFileUris.isEmpty(); + public static ProjectChange empty() { + return new ProjectChange( + new HashSet<>(), + new HashSet<>(), + new HashSet<>()); } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java index 33e5ec21..17893273 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -20,12 +20,13 @@ * A complete view of all a project's configuration that is needed to load it, * merged from all configuration sources. */ -final class ProjectConfig { +public final class ProjectConfig { private final List sources; private final List imports; private final String outputDirectory; private final List dependencies; private final MavenConfig mavenConfig; + private final List loadedConfigPaths; private ProjectConfig(Builder builder) { this.sources = builder.sources; @@ -33,6 +34,7 @@ private ProjectConfig(Builder builder) { this.outputDirectory = builder.outputDirectory; this.dependencies = builder.dependencies; this.mavenConfig = builder.mavenConfig; + this.loadedConfigPaths = builder.loadedConfigPaths; } static ProjectConfig empty() { @@ -78,12 +80,17 @@ public Optional maven() { return Optional.ofNullable(mavenConfig); } + public List loadedConfigPaths() { + return loadedConfigPaths; + } + static final class Builder { final List sources = new ArrayList<>(); final List imports = new ArrayList<>(); String outputDirectory; final List dependencies = new ArrayList<>(); MavenConfig mavenConfig; + final List loadedConfigPaths = new ArrayList<>(); private Builder() { } @@ -148,6 +155,11 @@ public Builder mavenConfig(MavenConfig mavenConfig) { return this; } + public Builder loadedConfigPaths(List loadedConfigPaths) { + this.loadedConfigPaths.addAll(loadedConfigPaths); + return this; + } + public ProjectConfig build() { return new ProjectConfig(this); } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java index c299ecea..71c12433 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -5,9 +5,11 @@ package software.amazon.smithy.lsp.project; +import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.logging.Logger; import software.amazon.smithy.build.model.SmithyBuildConfig; @@ -63,28 +65,38 @@ */ public final class ProjectConfigLoader { public static final String SMITHY_BUILD = "smithy-build.json"; - public static final String[] SMITHY_BUILD_EXTS = {"build/smithy-dependencies.json", ".smithy.json"}; + public static final String[] SMITHY_BUILD_EXTS = { + "build" + File.separator + "smithy-dependencies.json", ".smithy.json"}; public static final String SMITHY_PROJECT = ".smithy-project.json"; + public static final List PROJECT_BUILD_FILES = new ArrayList<>(2 + SMITHY_BUILD_EXTS.length); private static final Logger LOGGER = Logger.getLogger(ProjectConfigLoader.class.getName()); private static final SmithyBuildConfig DEFAULT_SMITHY_BUILD = SmithyBuildConfig.builder().version("1").build(); private static final NodeMapper NODE_MAPPER = new NodeMapper(); + static { + PROJECT_BUILD_FILES.add(SMITHY_BUILD); + PROJECT_BUILD_FILES.add(SMITHY_PROJECT); + PROJECT_BUILD_FILES.addAll(Arrays.asList(SMITHY_BUILD_EXTS)); + } + private ProjectConfigLoader() { } static Result> loadFromRoot(Path workspaceRoot) { + List loadedConfigPaths = new ArrayList<>(); SmithyBuildConfig.Builder builder = SmithyBuildConfig.builder(); List exceptions = new ArrayList<>(); - // TODO: We don't handle cases where the smithy-build.json isn't in the top level of the root. - // In order to do so, we probably need to be able to keep track of multiple projects. Path smithyBuildPath = workspaceRoot.resolve(SMITHY_BUILD); if (Files.isRegularFile(smithyBuildPath)) { LOGGER.info("Loading smithy-build.json from " + smithyBuildPath); Result result = Result.ofFallible(() -> SmithyBuildConfig.load(smithyBuildPath)); - result.get().ifPresent(builder::merge); + result.get().ifPresent(config -> { + builder.merge(config); + loadedConfigPaths.add(smithyBuildPath); + }); result.getErr().ifPresent(exceptions::add); } else { LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); @@ -97,7 +109,10 @@ static Result> loadFromRoot(Path workspaceRoot) { if (Files.isRegularFile(extPath)) { Result result = Result.ofFallible(() -> loadSmithyBuildExtensions(extPath)); - result.get().ifPresent(extensionsBuilder::merge); + result.get().ifPresent(config -> { + extensionsBuilder.merge(config); + loadedConfigPaths.add(extPath); + }); result.getErr().ifPresent(exceptions::add); } } @@ -110,6 +125,7 @@ static Result> loadFromRoot(Path workspaceRoot) { ProjectConfig.Builder.load(smithyProjectPath)); if (result.isOk()) { finalConfigBuilder = result.unwrap(); + loadedConfigPaths.add(smithyProjectPath); } else { exceptions.add(result.unwrapErr()); } @@ -126,6 +142,7 @@ static Result> loadFromRoot(Path workspaceRoot) { if (finalConfigBuilder.outputDirectory == null) { config.getOutputDirectory().ifPresent(finalConfigBuilder::outputDirectory); } + finalConfigBuilder.loadedConfigPaths(loadedConfigPaths); return Result.ok(finalConfigBuilder.build()); } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java index de6927e2..3793dad2 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -5,16 +5,9 @@ package software.amazon.smithy.lsp.project; -import java.nio.file.Path; -import java.nio.file.PathMatcher; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.logging.Logger; -import org.eclipse.lsp4j.FileChangeType; -import org.eclipse.lsp4j.FileEvent; -import org.eclipse.lsp4j.WorkspaceFolder; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; @@ -31,7 +24,7 @@ public ProjectManager() { } /** - * @param name Name of the project, usually comes from {@link WorkspaceFolder#getName()} + * @param name Name of the project, should be path of the project directory * @return The project with the given name, if it exists */ public Project getProjectByName(String name) { @@ -160,56 +153,4 @@ public Document getDocument(String uri) { } return project.getDocument(uri); } - - /** - * Computes per-project file changes from the given file events. - * - *

>Note: if you have lots of projects, this will create a bunch of - * garbage because most times you aren't getting multiple sets of large - * updates to a project. Project changes are relatively rare, so this - * shouldn't have a huge impact. - * - * @param events The file events to compute per-project file changes from - * @return A map of project name to the corresponding project's changes - */ - public Map computeProjectChanges(List events) { - // Note: we could eagerly compute these and store them, but project changes are relatively rare, - // and doing it this way means we don't need to manage the state. - Map projectSmithyFileMatchers = new HashMap<>(attachedProjects().size()); - Map projectBuildFileMatchers = new HashMap<>(attachedProjects().size()); - - Map changes = new HashMap<>(attachedProjects().size()); - - attachedProjects().forEach((projectName, project) -> { - projectSmithyFileMatchers.put(projectName, ProjectFilePatterns.getSmithyFilesPathMatcher(project)); - projectBuildFileMatchers.put(projectName, ProjectFilePatterns.getBuildFilesPathMatcher(project)); - - // Need these to be hash sets so they are mutable - changes.put(projectName, new ProjectChanges(new HashSet<>(), new HashSet<>(), new HashSet<>())); - }); - - for (FileEvent event : events) { - String changedUri = event.getUri(); - Path changedPath = Path.of(LspAdapter.toPath(changedUri)); - if (changedUri.endsWith(".smithy")) { - projectSmithyFileMatchers.forEach((projectName, matcher) -> { - if (matcher.matches(changedPath)) { - if (event.getType() == FileChangeType.Created) { - changes.get(projectName).createdSmithyFileUris().add(changedUri); - } else if (event.getType() == FileChangeType.Deleted) { - changes.get(projectName).deletedSmithyFileUris().add(changedUri); - } - } - }); - } else { - projectBuildFileMatchers.forEach((projectName, matcher) -> { - if (matcher.matches(changedPath)) { - changes.get(projectName).changedBuildFileUris().add(changedUri); - } - }); - } - } - - return changes; - } } diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java similarity index 51% rename from src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java rename to src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java index fe9a2c50..06d7973c 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -3,22 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.project; +package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.HashSet; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.UtilMatchers; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; -public class ProjectFilePatternsTest { +public class FilePatternsTest { @Test - public void createsPathMatchers() { + public void createsProjectPathMatchers() { TestWorkspace workspace = TestWorkspace.builder() .withSourceDir(new TestWorkspace.Dir() .withPath("foo") @@ -38,8 +42,8 @@ public void createsPathMatchers() { .build(); Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - PathMatcher smithyMatcher = ProjectFilePatterns.getSmithyFilesPathMatcher(project); - PathMatcher buildMatcher = ProjectFilePatterns.getBuildFilesPathMatcher(project); + PathMatcher smithyMatcher = FilePatterns.getSmithyFilesPathMatcher(project); + PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); Path root = project.root(); assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("abc.smithy"))); @@ -48,4 +52,29 @@ public void createsPathMatchers() { assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve("smithy-build.json"))); assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve(".smithy-project.json"))); } + + @Test + public void createsWorkspacePathMatchers() throws IOException { + Path workspaceRoot = Files.createTempDirectory("test"); + workspaceRoot.toFile().deleteOnExit(); + + TestWorkspace fooWorkspace = TestWorkspace.builder() + .withRoot(workspaceRoot) + .withPath("foo") + .build(); + + // Set up a project outside the 'foo' root. + workspaceRoot.resolve("bar").toFile().mkdir(); + workspaceRoot.resolve("bar/smithy-build.json").toFile().createNewFile(); + + Project fooProject = ProjectLoader.load(fooWorkspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + + PathMatcher fooBuildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(fooProject); + PathMatcher workspaceBuildMatcher = FilePatterns.getWorkspaceBuildFilesPathMatcher(workspaceRoot); + + assertThat(fooBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(fooBuildMatcher, not(UtilMatchers.canMatchPath(workspaceRoot.resolve("bar/smithy-build.json")))); + assertThat(workspaceBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(workspaceBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("bar/smithy-build.json"))); + } } diff --git a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java similarity index 90% rename from src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java rename to src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java index 8b9df90a..2b839e72 100644 --- a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.handler; +package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; @@ -16,14 +16,12 @@ import org.eclipse.lsp4j.Registration; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.UtilMatchers; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; -public class FileWatcherRegistrationHandlerTest { +public class FileWatcherRegistrationsTest { @Test public void createsCorrectRegistrations() { TestWorkspace workspace = TestWorkspace.builder() @@ -45,7 +43,7 @@ public void createsCorrectRegistrations() { .build(); Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - List matchers = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(List.of(project)) + List matchers = FileWatcherRegistrations.getSmithyFileWatcherRegistrations(List.of(project)) .stream() .map(Registration::getRegisterOptions) .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) diff --git a/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java b/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java new file mode 100644 index 00000000..d8f9b241 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static software.amazon.smithy.lsp.project.ProjectTest.toPath; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class ProjectRootVisitorTest { + @Test + public void findsNestedRoot() throws Exception { + Path root = toPath(getClass().getResource("project/nested")); + List found = ProjectRootVisitor.findProjectRoots(root); + assertThat(found, contains(UtilMatchers.endsWith(Path.of("nested/nested")))); + } + + @Test + public void findsMultiNestedRoots() throws Exception { + Path root = toPath(getClass().getResource("project/multi-nested")); + List found = ProjectRootVisitor.findProjectRoots(root); + assertThat(found, containsInAnyOrder( + UtilMatchers.endsWith(Path.of("multi-nested/nested-a")), + UtilMatchers.endsWith(Path.of("multi-nested/nested-b")))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 22ea3400..25f4e3ed 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -2,6 +2,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; @@ -30,6 +31,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; @@ -1766,7 +1768,6 @@ public void useCompletionDoesntAutoImport() throws Exception { @Test public void loadsMultipleRoots() { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", """ $version: "2" @@ -1776,7 +1777,6 @@ public void loadsMultipleRoots() { .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", """ $version: "2" @@ -1787,14 +1787,14 @@ public void loadsMultipleRoots() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -1806,7 +1806,6 @@ public void loadsMultipleRoots() { @Test public void multiRootLifecycleManagement() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", """ $version: "2" @@ -1816,7 +1815,6 @@ public void multiRootLifecycleManagement() throws Exception { .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", """ $version: "2" @@ -1857,8 +1855,8 @@ public void multiRootLifecycleManagement() throws Exception { server.getLifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1870,14 +1868,12 @@ public void multiRootLifecycleManagement() throws Exception { @Test public void multiRootAddingWatchedFile() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceDir(new TestWorkspace.Dir() .withPath("model") .withSourceFile("main.smithy", "")) .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceDir(new TestWorkspace.Dir() .withPath("model") @@ -1930,8 +1926,8 @@ public void multiRootAddingWatchedFile() throws Exception { server.getLifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -1945,14 +1941,12 @@ public void multiRootAddingWatchedFile() throws Exception { @Test public void multiRootChangingBuildFile() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceDir(new TestWorkspace.Dir() .withPath("model") .withSourceFile("main.smithy", "")) .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceDir(new TestWorkspace.Dir() .withPath("model") @@ -2017,8 +2011,8 @@ public void multiRootChangingBuildFile() throws Exception { assertThat(server.getProjects().getProject(workspaceBar.getUri("model/main.smithy")), notNullValue()); assertThat(server.getProjects().getProject(workspaceFoo.getUri("model/main.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -2038,7 +2032,6 @@ public void addingWorkspaceFolder() throws Exception { structure Foo {} """; TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", fooModel) .build(); @@ -2051,7 +2044,6 @@ public void addingWorkspaceFolder() throws Exception { structure Bar {} """; TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", barModel) .build(); @@ -2072,14 +2064,14 @@ public void addingWorkspaceFolder() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); + Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -2096,7 +2088,6 @@ public void removingWorkspaceFolder() { structure Foo {} """; TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", fooModel) .build(); @@ -2107,7 +2098,6 @@ public void removingWorkspaceFolder() { structure Bar {} """; TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", barModel) .build(); @@ -2128,15 +2118,15 @@ public void removingWorkspaceFolder() { .removed(workspaceBar.getRoot().toUri().toString(), "bar") .build()); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), not(hasKey("bar"))); + assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getProjects().attachedProjects(), not(hasKey(workspaceBar.getName()))); assertThat(server.getProjects().detachedProjects(), hasKey(endsWith("bar.smithy"))); assertThat(server.getProjects().isDetached(workspaceBar.getUri("bar.smithy")), is(true)); assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); + Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); Project projectBar = server.getProjects().getProject(workspaceBar.getUri("bar.smithy")); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); @@ -2145,6 +2135,247 @@ public void removingWorkspaceFolder() { assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); } + + @Test + public void singleWorkspaceMultiRoot() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getWorkspacePaths(), contains(root)); + } + + @Test + public void addingRootsToWorkspace() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + SmithyLanguageServer server = initFromRoot(root); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Created) + .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Created) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + } + + @Test + public void removingRootsFromWorkspace() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getWorkspacePaths(), contains(root)); + + workspaceFoo.deleteModel("smithy-build.json"); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Deleted) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), contains(workspaceBar.getName())); + } + + @Test + public void addingConfigFile() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("baz.smithy")) + .text(bazModel) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + + workspaceFoo.addModel(".smithy-project.json", """ + { + "sources": ["baz.smithy"] + }"""); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Created) + .build()); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), empty()); + } + + @Test + public void removingConfigFile() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + workspaceFoo.addModel(".smithy-project.json", """ + { + "sources": ["baz.smithy"] + }"""); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceFoo.getUri("baz.smithy")) + .text(bazModel) + .build()); + + assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), empty()); + + workspaceFoo.deleteModel(".smithy-project.json"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Deleted) + .build()); + + assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + workspaceFoo.getName(), + workspaceBar.getName())); + assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index f95c0fe3..ab6946fb 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -25,10 +25,10 @@ public final class TestWorkspace { private SmithyBuildConfig config; private final String name; - private TestWorkspace(Path root, SmithyBuildConfig config, String name) { + private TestWorkspace(Path root, SmithyBuildConfig config) { this.root = root; this.config = config; - this.name = name; + this.name = root.toString(); } /** @@ -183,7 +183,7 @@ private static void writeModels(Path toDir, Map models) throws E public static final class Builder extends Dir { private SmithyBuildConfig config = null; - private String name = ""; + private Path root = null; private Builder() {} @@ -222,8 +222,8 @@ public Builder withConfig(SmithyBuildConfig config) { return this; } - public Builder withName(String name) { - this.name = name; + public Builder withRoot(Path root) { + this.root = root; return this; } @@ -232,8 +232,13 @@ public TestWorkspace build() { if (path == null) { path = "test"; } - Path root = Files.createTempDirectory(path); - root.toFile().deleteOnExit(); + Path projectRoot; + if (this.root != null) { + projectRoot = Files.createDirectory(this.root.resolve(path)); + } else { + projectRoot = Files.createTempDirectory(path); + projectRoot.toFile().deleteOnExit(); + } List sources = new ArrayList<>(); sources.addAll(sourceModels.keySet()); @@ -250,11 +255,11 @@ public TestWorkspace build() { .imports(imports) .build(); } - writeConfig(root, config); + writeConfig(projectRoot, config); - writeModels(root); + writeModels(projectRoot); - return new TestWorkspace(root, config, name); + return new TestWorkspace(projectRoot, config); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java index f4dc1103..58b3f6d0 100644 --- a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -53,4 +53,18 @@ protected void describeMismatchSafely(PathMatcher item, Description mismatchDesc } }; } + + public static Matcher endsWith(Path path) { + return new CustomTypeSafeMatcher("A path that ends with " + path.toString()) { + @Override + protected boolean matchesSafely(Path item) { + return item.endsWith(path); + } + + @Override + protected void describeMismatchSafely(Path item, Description mismatchDescription) { + mismatchDescription.appendText(item.toString() + " did not end with " + path.toString()); + } + }; + } } diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json new file mode 100644 index 00000000..e69de29b From 85f275362ffb045d6182349c18bae49e36bf2c9d Mon Sep 17 00:00:00 2001 From: smithy-automation <127955164+smithy-automation@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:42:48 -0700 Subject: [PATCH 02/43] Update Smithy Version (#177) Co-authored-by: Smithy Automation --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 23bea553..c0100b3d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -smithyVersion=1.50.0 +smithyVersion=1.52.1 From 3b1ea235fee178526bf4ef30b781f7880e2b4e2a Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:27:03 -0500 Subject: [PATCH 03/43] Track build file changes (#168) This change makes the server register for document lifecycle events like didChange for build files. Previous behavior was to register for file system change events for build files, but eventually we want to give diagnostics, completions, etc. for build files as you type, so we need to track all changes. This change keeps existing behavior from the user's perspective, as we still only reload projects on save, so it serves mostly as a refactor. I also extracted the state managed by SmithyLanguageServer to its own class, ServerState, and flattened ProjectManager into that class, so we can manage the state of the server in a more centralized location. --- .../smithy/lsp/DocumentLifecycleManager.java | 17 +- .../smithy/lsp/FileWatcherRegistrations.java | 11 +- .../amazon/smithy/lsp/ServerState.java | 248 +++++++++ .../smithy/lsp/SmithyLanguageServer.java | 380 +++++++------- .../amazon/smithy/lsp/WorkspaceChanges.java | 78 ++- .../amazon/smithy/lsp/project/BuildFile.java | 32 ++ .../amazon/smithy/lsp/project/Project.java | 20 +- .../smithy/lsp/project/ProjectAndFile.java | 16 + .../smithy/lsp/project/ProjectConfig.java | 26 +- .../lsp/project/ProjectConfigLoader.java | 59 ++- .../smithy/lsp/project/ProjectFile.java | 24 + .../smithy/lsp/project/ProjectLoader.java | 39 +- .../smithy/lsp/project/ProjectManager.java | 156 ------ .../amazon/smithy/lsp/project/SmithyFile.java | 2 +- .../amazon/smithy/lsp/FilePatternsTest.java | 6 +- .../lsp/FileWatcherRegistrationsTest.java | 4 +- .../amazon/smithy/lsp/ServerStateTest.java | 42 ++ .../smithy/lsp/SmithyLanguageServerTest.java | 469 ++++++++++++------ .../amazon/smithy/lsp/TestWorkspace.java | 8 + .../lsp/project/ProjectManagerTest.java | 43 -- 20 files changed, 1007 insertions(+), 673 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/ServerState.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/BuildFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java create mode 100644 src/test/java/software/amazon/smithy/lsp/ServerStateTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java index ba9c33f7..7d2c98fb 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -6,29 +6,16 @@ package software.amazon.smithy.lsp; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.logging.Logger; /** - * Tracks asynchronous lifecycle tasks and client-managed documents. - * Allows cancelling of an ongoing task if a new task needs to be started. + * Tracks asynchronous lifecycle tasks, allowing for cancellation of an ongoing + * task if a new task needs to be started. */ final class DocumentLifecycleManager { - private static final Logger LOGGER = Logger.getLogger(DocumentLifecycleManager.class.getName()); private final Map> tasks = new HashMap<>(); - private final Set managedDocumentUris = new HashSet<>(); - - Set managedDocuments() { - return managedDocumentUris; - } - - boolean isManaged(String uri) { - return managedDocuments().contains(uri); - } CompletableFuture getTask(String uri) { return tasks.get(uri); diff --git a/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java index 6299e84f..d45ae276 100644 --- a/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java +++ b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java @@ -33,7 +33,7 @@ * future. */ final class FileWatcherRegistrations { - private static final Integer SMITHY_WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; + private static final Integer WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles"; private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles"; private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles"; @@ -57,7 +57,7 @@ private FileWatcherRegistrations() { static List getSmithyFileWatcherRegistrations(Collection projects) { List smithyFileWatchers = projects.stream() .flatMap(project -> FilePatterns.getSmithyFileWatchPatterns(project).stream()) - .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), SMITHY_WATCH_FILE_KIND)) + .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); return Collections.singletonList(new Registration( @@ -75,7 +75,7 @@ static List getSmithyFileWatcherUnregistrations() { /** * Creates registrations to tell the client to watch for any build file - * changes, creations, or deletions, across all workspaces. + * creations or deletions, across all workspaces. * * @param workspaceRoots The roots of the workspaces to get registrations for * @return The registrations to watch for build file changes across all workspaces @@ -83,7 +83,7 @@ static List getSmithyFileWatcherUnregistrations() { static List getBuildFileWatcherRegistrations(Collection workspaceRoots) { List watchers = workspaceRoots.stream() .map(FilePatterns::getWorkspaceBuildFilesWatchPattern) - .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern))) + .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); return Collections.singletonList(new Registration( @@ -92,6 +92,9 @@ static List getBuildFileWatcherRegistrations(Collection work new DidChangeWatchedFilesRegistrationOptions(watchers))); } + /** + * @return The unregistrations to stop watching for build file changes + */ static List getBuildFileWatcherUnregistrations() { return BUILD_FILE_WATCHER_UNREGISTRATIONS; } diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java new file mode 100644 index 00000000..20f30733 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -0,0 +1,248 @@ +/* + * 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) { + ProjectAndFile attached = findAttachedAndRemoveDetached(uri); + if (attached != null) { + return attached; + } + + Project detachedProject = detachedProjects.get(uri); + if (detachedProject != null) { + String path = LspAdapter.toPath(uri); + ProjectFile projectFile = detachedProject.getProjectFile(path); + if (projectFile != null) { + return new ProjectAndFile(detachedProject, projectFile); + } + } + + LOGGER.warning(() -> "Tried to unknown file: " + uri); + + return null; + } + + boolean isDetached(String uri) { + if (detachedProjects.containsKey(uri)) { + ProjectAndFile attached = findAttachedAndRemoveDetached(uri); + // The file is only truly detached if the above didn't find an attached project + // for the given file + return attached == null; + } + + return false; + } + + /** + * Searches for the given {@code uri} in attached projects, and if found, + * makes sure any old detached projects for that file are removed. + * + * @param uri The uri of the project and file to find + * @return The attached project and file, or null if not found + */ + private ProjectAndFile findAttachedAndRemoveDetached(String uri) { + String path = LspAdapter.toPath(uri); + // We might be in a state where a file was added to a tracked project, + // but was opened before the project loaded. This would result in it + // being placed in a detachedProjects project. Removing it here is basically + // like removing it lazily, although it does feel a little hacky. + for (Project project : attachedProjects.values()) { + ProjectFile projectFile = project.getProjectFile(path); + if (projectFile != null) { + detachedProjects.remove(uri); + return new ProjectAndFile(project, projectFile); + } + } + + return null; + } + + 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..7a07f6eb 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<>(); + private final Map buildFiles = new HashMap<>(); private Builder() { } - static Builder load(Path path) { - String json = IoUtils.readUtf8File(path); - Node node = Node.parseJsonWithComments(json, path.toString()); + static Builder load(BuildFile buildFile) { + Node node = Node.parseJsonWithComments(buildFile.document().copyText(), buildFile.path()); ObjectNode objectNode = node.expectObjectNode(); ProjectConfig.Builder projectConfigBuilder = ProjectConfig.builder(); objectNode.getArrayMember("sources").ifPresent(arrayNode -> @@ -155,8 +157,8 @@ public Builder mavenConfig(MavenConfig mavenConfig) { return this; } - public Builder loadedConfigPaths(List loadedConfigPaths) { - this.loadedConfigPaths.addAll(loadedConfigPaths); + public Builder buildFiles(Map buildFiles) { + this.buildFiles.putAll(buildFiles); return this; } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java index 71c12433..1061ce77 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -10,9 +10,13 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.logging.Logger; import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.NodeMapper; @@ -84,19 +88,23 @@ private ProjectConfigLoader() { } static Result> loadFromRoot(Path workspaceRoot) { - List loadedConfigPaths = new ArrayList<>(); + return loadFromRoot(workspaceRoot, new ServerState()); + } + + static Result> loadFromRoot(Path workspaceRoot, ServerState state) { SmithyBuildConfig.Builder builder = SmithyBuildConfig.builder(); List exceptions = new ArrayList<>(); + Map buildFiles = new HashMap<>(); Path smithyBuildPath = workspaceRoot.resolve(SMITHY_BUILD); if (Files.isRegularFile(smithyBuildPath)) { LOGGER.info("Loading smithy-build.json from " + smithyBuildPath); - Result result = Result.ofFallible(() -> - SmithyBuildConfig.load(smithyBuildPath)); - result.get().ifPresent(config -> { - builder.merge(config); - loadedConfigPaths.add(smithyBuildPath); + Result result = Result.ofFallible(() -> { + BuildFile buildFile = addBuildFile(buildFiles, smithyBuildPath, state); + return SmithyBuildConfig.fromNode( + Node.parseJsonWithComments(buildFile.document().copyText(), buildFile.path())); }); + result.get().ifPresent(builder::merge); result.getErr().ifPresent(exceptions::add); } else { LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); @@ -107,12 +115,11 @@ static Result> loadFromRoot(Path workspaceRoot) { for (String ext : SMITHY_BUILD_EXTS) { Path extPath = workspaceRoot.resolve(ext); if (Files.isRegularFile(extPath)) { - Result result = Result.ofFallible(() -> - loadSmithyBuildExtensions(extPath)); - result.get().ifPresent(config -> { - extensionsBuilder.merge(config); - loadedConfigPaths.add(extPath); + Result result = Result.ofFallible(() -> { + BuildFile buildFile = addBuildFile(buildFiles, extPath, state); + return loadSmithyBuildExtensions(buildFile); }); + result.get().ifPresent(extensionsBuilder::merge); result.getErr().ifPresent(exceptions::add); } } @@ -121,11 +128,12 @@ static Result> loadFromRoot(Path workspaceRoot) { Path smithyProjectPath = workspaceRoot.resolve(SMITHY_PROJECT); if (Files.isRegularFile(smithyProjectPath)) { LOGGER.info("Loading .smithy-project.json from " + smithyProjectPath); - Result result = Result.ofFallible(() -> - ProjectConfig.Builder.load(smithyProjectPath)); + Result result = Result.ofFallible(() -> { + BuildFile buildFile = addBuildFile(buildFiles, smithyProjectPath, state); + return ProjectConfig.Builder.load(buildFile); + }); if (result.isOk()) { finalConfigBuilder = result.unwrap(); - loadedConfigPaths.add(smithyProjectPath); } else { exceptions.add(result.unwrapErr()); } @@ -142,16 +150,29 @@ static Result> loadFromRoot(Path workspaceRoot) { if (finalConfigBuilder.outputDirectory == null) { config.getOutputDirectory().ifPresent(finalConfigBuilder::outputDirectory); } - finalConfigBuilder.loadedConfigPaths(loadedConfigPaths); + finalConfigBuilder.buildFiles(buildFiles); return Result.ok(finalConfigBuilder.build()); } - private static SmithyBuildExtensions loadSmithyBuildExtensions(Path path) { + private static BuildFile addBuildFile(Map buildFiles, Path path, ServerState state) { + Document managed = state.getManagedDocument(path); + BuildFile buildFile; + if (managed != null) { + buildFile = new BuildFile(path.toString(), managed); + } else { + Document document = Document.of(IoUtils.readUtf8File(path)); + buildFile = new BuildFile(path.toString(), document); + } + buildFiles.put(buildFile.path(), buildFile); + return buildFile; + } + + private static SmithyBuildExtensions loadSmithyBuildExtensions(BuildFile buildFile) { // NOTE: This is the legacy way we loaded build extensions. It used to throw a checked exception. - String content = IoUtils.readUtf8File(path); - ObjectNode node = Node.parseJsonWithComments(content, path.toString()).expectObjectNode(); + ObjectNode node = Node.parseJsonWithComments( + buildFile.document().copyText(), buildFile.path()).expectObjectNode(); SmithyBuildExtensions config = NODE_MAPPER.deserialize(node, SmithyBuildExtensions.class); - config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.load(path)); + config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.fromNode(node)); return config; } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java new file mode 100644 index 00000000..55f88ea5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import software.amazon.smithy.lsp.document.Document; + +/** + * A file belonging to a Smithy project that the language server understands + * and tracks. + */ +public sealed interface ProjectFile permits SmithyFile, BuildFile { + /** + * @return The absolute path of the file + */ + String path(); + + /** + * @return The underlying document of the file + */ + Document document(); +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java index cc4f8c6d..86b8b550 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -16,7 +16,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -26,6 +25,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.document.DocumentImports; import software.amazon.smithy.lsp.document.DocumentNamespace; @@ -55,9 +55,9 @@ private ProjectLoader() { } /** - * Loads a detached (single-file) {@link Project} with the given file. + * Loads a detachedProjects (single-file) {@link Project} with the given file. * - *

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

Unlike {@link #load(Path, ServerState)}, this method isn't * fallible since it doesn't do any IO that we would want to recover an * error from. * @@ -66,7 +66,7 @@ private ProjectLoader() { * @return The loaded project */ public static Project loadDetached(String uri, String text) { - LOGGER.info("Loading detached project at " + uri); + LOGGER.info("Loading detachedProjects project at " + uri); String asPath = LspAdapter.toPath(uri); ValidatedResult modelResult = Model.assembler() .addUnparsedModel(asPath, text) @@ -94,7 +94,7 @@ public static Project loadDetached(String uri, String text) { // TODO: Make generic 'please file a bug report' exception throw new IllegalStateException( "Attempted to load an unknown source file (" - + filePath + ") in detached project at " + + filePath + ") in detachedProjects project at " + asPath + ". This is a bug in the language server."); } }); @@ -117,16 +117,11 @@ public static Project loadDetached(String uri, String text) { * reason about how the project was structured. * * @param root Path of the project root - * @param projects Currently loaded projects, for getting content of managed documents - * @param managedDocuments URIs of documents managed by the client + * @param state Server's current state * @return Result of loading the project */ - public static Result> load( - Path root, - ProjectManager projects, - Set managedDocuments - ) { - Result> configResult = ProjectConfigLoader.loadFromRoot(root); + public static Result> load(Path root, ServerState state) { + Result> configResult = ProjectConfigLoader.loadFromRoot(root, state); if (configResult.isErr()) { return Result.err(configResult.unwrapErr()); } @@ -155,14 +150,9 @@ public static Result> load( Result, Exception> loadModelResult = Result.ofFallible(() -> { for (Path path : allSmithyFilePaths) { - if (!managedDocuments.isEmpty()) { - String pathString = path.toString(); - String uri = LspAdapter.toUri(pathString); - if (managedDocuments.contains(uri)) { - assembler.addUnparsedModel(pathString, projects.getDocument(uri).copyText()); - } else { - assembler.addImport(path); - } + Document managed = state.getManagedDocument(path); + if (managed != null) { + assembler.addUnparsedModel(path.toString(), managed.copyText()); } else { assembler.addImport(path); } @@ -198,8 +188,9 @@ public static Result> load( // TODO: We recompute uri from path and vice-versa very frequently, // maybe we can cache it. String uri = LspAdapter.toUri(filePath); - if (managedDocuments.contains(uri)) { - return projects.getDocument(uri); + Document managed = state.getManagedDocument(uri); + if (managed != null) { + return managed; } // There may be a more efficient way of reading this return Document.of(IoUtils.readUtf8File(filePath)); @@ -212,7 +203,7 @@ public static Result> load( } static Result> load(Path root) { - return load(root, new ProjectManager(), new HashSet<>(0)); + return load(root, new ServerState()); } private static Map computeSmithyFiles( diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java deleted file mode 100644 index 3793dad2..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import java.util.HashMap; -import java.util.Map; -import java.util.logging.Logger; -import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.LspAdapter; - -/** - * Manages open projects tracked by the server. - */ -public final class ProjectManager { - private static final Logger LOGGER = Logger.getLogger(ProjectManager.class.getName()); - - private final Map detached = new HashMap<>(); - private final Map attached = new HashMap<>(); - - public ProjectManager() { - } - - /** - * @param name Name of the project, should be path of the project directory - * @return The project with the given name, if it exists - */ - public Project getProjectByName(String name) { - return this.attached.get(name); - } - - /** - * @param name Name of the project to update - * @param updated Project to update - */ - public void updateProjectByName(String name, Project updated) { - this.attached.put(name, updated); - } - - /** - * @param name Name of the project to remove - * @return The removed project, if it exists - */ - public Project removeProjectByName(String name) { - return this.attached.remove(name); - } - - /** - * @return A map of URIs of open files that aren't attached to a tracked project - * to their own detached projects. These projects contain only the file that - * corresponds to the key in the map. - */ - public Map detachedProjects() { - return detached; - } - - /** - * @return A map of project names to projects tracked by the server - */ - public Map attachedProjects() { - return attached; - } - - /** - * @param uri The URI of the file belonging to the project to get - * @return The project the given {@code uri} belongs to - */ - public Project getProject(String uri) { - String path = LspAdapter.toPath(uri); - if (isDetached(uri)) { - return detached.get(uri); - } else { - for (Project project : attached.values()) { - if (project.smithyFiles().containsKey(path)) { - return project; - } - } - - LOGGER.warning(() -> "Tried getting project for unknown file: " + uri); - - return null; - } - } - - /** - * Note: This is equivalent to {@code getProject(uri) == null}. If this is true, - * there is also a corresponding {@link SmithyFile} in {@link Project#getSmithyFile(String)}. - * - * @param uri The URI of the file to check - * @return True if the given URI corresponds to a file tracked by the server - */ - public boolean isTracked(String uri) { - return getProject(uri) != null; - } - - /** - * @param uri The URI of the file to check - * @return Whether the given {@code uri} is of a file in a detached project - */ - public boolean isDetached(String uri) { - // We might be in a state where a file was added to a tracked project, - // but was opened before the project loaded. This would result in it - // being placed in a detached project. Removing it here is basically - // like removing it lazily, although it does feel a little hacky. - String path = LspAdapter.toPath(uri); - Project nonDetached = getNonDetached(path); - if (nonDetached != null && detached.containsKey(uri)) { - removeDetachedProject(uri); - } - - return detached.containsKey(uri); - } - - private Project getNonDetached(String path) { - for (Project project : attached.values()) { - if (project.smithyFiles().containsKey(path)) { - return project; - } - } - return null; - } - - /** - * @param uri The URI of the file to create a detached project for - * @param text The text of the file to create a detached project for - * @return A new detached project of the given {@code uri} and {@code text} - */ - public Project createDetachedProject(String uri, String text) { - Project project = ProjectLoader.loadDetached(uri, text); - detached.put(uri, project); - return project; - } - - /** - * @param uri The URI of the file to remove a detached project for - * @return The removed project, or null if none existed - */ - public Project removeDetachedProject(String uri) { - return detached.remove(uri); - } - - /** - * @param uri The URI of the file to get the document of - * @return The {@link Document} corresponding to the given {@code uri}, if - * it exists in any projects, otherwise {@code null}. - */ - public Document getDocument(String uri) { - Project project = getProject(uri); - if (project == null) { - return null; - } - return project.getDocument(uri); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java index ba4374c0..a30cec1e 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -24,7 +24,7 @@ *

Note: This currently is only ever a .smithy file, but could represent * a .json file in the future. */ -public final class SmithyFile { +public final class SmithyFile implements ProjectFile { private final String path; private final Document document; // TODO: If we have more complex use-cases for partially updating SmithyFile, we diff --git a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java index 06d7973c..9ac13497 100644 --- a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -12,12 +12,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.util.HashSet; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; -import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; public class FilePatternsTest { @@ -41,7 +39,7 @@ public void createsProjectPathMatchers() { .build()) .build(); - Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); PathMatcher smithyMatcher = FilePatterns.getSmithyFilesPathMatcher(project); PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); @@ -67,7 +65,7 @@ public void createsWorkspacePathMatchers() throws IOException { workspaceRoot.resolve("bar").toFile().mkdir(); workspaceRoot.resolve("bar/smithy-build.json").toFile().createNewFile(); - Project fooProject = ProjectLoader.load(fooWorkspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + Project fooProject = ProjectLoader.load(fooWorkspace.getRoot(), new ServerState()).unwrap(); PathMatcher fooBuildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(fooProject); PathMatcher workspaceBuildMatcher = FilePatterns.getWorkspaceBuildFilesPathMatcher(workspaceRoot); diff --git a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java index 2b839e72..88c710b8 100644 --- a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java @@ -10,7 +10,6 @@ import java.nio.file.FileSystems; import java.nio.file.PathMatcher; -import java.util.HashSet; import java.util.List; import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; import org.eclipse.lsp4j.Registration; @@ -18,7 +17,6 @@ import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; -import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.utils.ListUtils; public class FileWatcherRegistrationsTest { @@ -42,7 +40,7 @@ public void createsCorrectRegistrations() { .build()) .build(); - Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); List matchers = FileWatcherRegistrations.getSmithyFileWatcherRegistrations(List.of(project)) .stream() .map(Registration::getRegisterOptions) diff --git a/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java b/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java new file mode 100644 index 00000000..c079935d --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java @@ -0,0 +1,42 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class ServerStateTest { + @Test + public void canCheckIfAFileIsTracked() { + Path attachedRoot = ProjectTest.toPath(getClass().getResource("project/flat")); + ServerState manager = new ServerState(); + Project mainProject = ProjectLoader.load(attachedRoot, manager).unwrap(); + + manager.attachedProjects().put("main", mainProject); + + String detachedUri = LspAdapter.toUri("/foo/bar"); + manager.createDetachedProject(detachedUri, ""); + + String mainUri = LspAdapter.toUri(attachedRoot.resolve("main.smithy").toString()); + + assertThat(manager.findProjectAndFile(mainUri), notNullValue()); + assertThat(manager.findProjectAndFile(mainUri).project().getSmithyFile(mainUri), notNullValue()); + + assertThat(manager.findProjectAndFile(detachedUri), notNullValue()); + assertThat(manager.findProjectAndFile(detachedUri).project().getSmithyFile(detachedUri), notNullValue()); + + String untrackedUri = LspAdapter.toUri("/bar/baz.smithy"); + assertThat(manager.findProjectAndFile(untrackedUri), nullValue()); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 25f4e3ed..cbbb4228 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -37,6 +37,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -68,6 +69,7 @@ import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.protocol.RangeBuilder; import software.amazon.smithy.model.node.ArrayNode; @@ -369,7 +371,7 @@ public void documentSymbol() throws Exception { .uri(uri) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); DocumentSymbolParams params = new DocumentSymbolParams(new TextDocumentIdentifier(uri)); List> response = server.documentSymbol(params).get(); @@ -465,7 +467,7 @@ public void didChange() throws Exception { server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(" ").build()); server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("G").build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); // mostly so you can see what it looks like assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" @@ -518,7 +520,7 @@ public void didChangeReloadsModel() throws Exception { .build(); server.didChange(didChangeParams); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); @@ -577,7 +579,7 @@ public void didChangeThenDefinition() throws Exception { server.didChange(change.range(range.shiftRight().build()).text("a").build()); server.didChange(change.range(range.shiftRight().build()).text("z").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" $version: "2" @@ -691,7 +693,7 @@ public void newShapeMixinCompletion() throws Exception { server.didChange(change.range(range.shiftRight().build()).text("[]").build()); server.didChange(change.range(range.shiftRight().build()).text("F").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" $version: "2" @@ -750,7 +752,7 @@ public void existingShapeMixinCompletion() throws Exception { server.didChange(change.range(range.shiftRight().build()).text("[]").build()); server.didChange(change.range(range.shiftRight().build()).text("F").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" $version: "2" @@ -868,7 +870,7 @@ public void diagnosticsOnShape() throws Exception { assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), containsString("Missing required member")); - // TODO: In this case, the event is attached to the shape, but the shape isn't in the model + // TODO: In this case, the event is attachedProjects to the shape, but the shape isn't in the model // because it could not be successfully created. So we can't know the actual position of // the shape, because determining it depends on where its defined in the model. // assertThat(diagnostic.getRange().getStart(), equalTo(new Position(3, 5))); @@ -945,15 +947,15 @@ public void addingWatchedFile() throws Exception { .build()); // Make sure the task is running, then wait for it - CompletableFuture future = server.getLifecycleManager().getTask(uri); + CompletableFuture future = server.getState().lifecycleManager().getTask(uri); assertThat(future, notNullValue()); future.get(); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(false)); assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri).copyText(), equalTo("$")); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); + assertThat(server.getState().findProjectAndFile(uri).file().document().copyText(), equalTo("$")); } @Test @@ -982,8 +984,8 @@ public void removingWatchedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().getDocument(uri), nullValue()); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); } @Test @@ -1005,9 +1007,9 @@ public void addingDetachedFile() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(true)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); String movedFilename = "model/main.smithy"; workspace.moveModel(filename, movedFilename); @@ -1023,12 +1025,12 @@ public void addingDetachedFile() { .event(movedUri, FileChangeType.Created) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().isDetached(movedUri), is(false)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().managedUris().contains(movedUri), is(true)); + assertThat(server.getState().isDetached(movedUri), is(false)); + assertThat(server.getState().findProjectAndFile(movedUri), notNullValue()); } @Test @@ -1050,9 +1052,9 @@ public void removingAttachedFile() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); String movedFilename = "main.smithy"; workspace.moveModel(filename, movedFilename); @@ -1069,12 +1071,12 @@ public void removingAttachedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().isDetached(movedUri), is(true)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().managedUris().contains(movedUri), is(true)); + assertThat(server.getState().isDetached(movedUri), is(true)); + assertThat(server.getState().findProjectAndFile(movedUri), notNullValue()); } @Test @@ -1105,9 +1107,9 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris().contains(uri), is(true)); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); } @Test @@ -1155,7 +1157,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .uri(uri) .build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); @@ -1169,7 +1171,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .text("") .build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleManager().getTask(uri).get(); Map metadataAfter2 = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter2, hasKey("foo")); @@ -1216,7 +1218,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { .event(uri, FileChangeType.Deleted) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); @@ -1242,18 +1244,18 @@ public void addingOpenedDetachedFile() throws Exception { String uri = workspace.getUri("main.smithy"); - assertThat(server.getLifecycleManager().managedDocuments(), not(hasItem(uri))); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); + assertThat(server.getState().managedUris(), not(hasItem(uri))); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); server.didOpen(RequestBuilders.didOpen() .uri(uri) .text(modelText) .build()); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getState().managedUris(), hasItem(uri)); + assertThat(server.getState().isDetached(uri), is(true)); + assertThat(server.getState().findProjectAndFile(uri), notNullValue()); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1273,10 +1275,10 @@ public void addingOpenedDetachedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getState().managedUris(), hasItem(uri)); + assertThat(server.getState().isDetached(uri), is(false)); assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); @@ -1313,14 +1315,14 @@ public void detachingOpenedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getState().managedUris(), hasItem(uri)); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); } @Test @@ -1344,7 +1346,7 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - // Moving to an also detached file - the server doesn't send DidChangeWatchedFiles + // Moving to an also detachedProjects file - the server doesn't send DidChangeWatchedFiles String movedFilename = "main-2.smithy"; workspace.moveModel(filename, movedFilename); String movedUri = workspace.getUri(movedFilename); @@ -1357,14 +1359,14 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); - assertThat(server.getProjects().isDetached(movedUri), is(true)); + assertThat(server.getState().managedUris().contains(uri), is(false)); + assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().managedUris().contains(movedUri), is(true)); + assertThat(server.getState().findProjectAndFile(movedUri), notNullValue()); + assertThat(server.getState().isDetached(movedUri), is(true)); } @Test @@ -1393,7 +1395,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .text(modelText1) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); List publishedDiagnostics1 = client.diagnostics; assertThat(publishedDiagnostics1, hasSize(1)); @@ -1419,7 +1421,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .event(uri2, FileChangeType.Created) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); List publishedDiagnostics2 = client.diagnostics; assertThat(publishedDiagnostics2, hasSize(2)); // sent more diagnostics @@ -1461,13 +1463,14 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { .text(modelText) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(true)); - assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult().isBroken(), is(true)); + assertThat(projectAndFile.project().modelResult().getResult().isPresent(), is(true)); + assertThat(projectAndFile.project().smithyFiles().keySet(), hasItem(endsWith(filename))); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1478,14 +1481,15 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { """)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(false)); - assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); - assertThat(server.getProjects().getProject(uri).modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile1 = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile1, notNullValue()); + assertThat(projectAndFile1.project().modelResult().isBroken(), is(false)); + assertThat(projectAndFile1.project().modelResult().getResult().isPresent(), is(true)); + assertThat(projectAndFile1.project().smithyFiles().keySet(), hasItem(endsWith(filename))); + assertThat(projectAndFile1.project().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } // TODO: apparently flaky @@ -1504,11 +1508,12 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .text("") .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); + assertThat(server.getState().isDetached(uri), is(true)); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().getSmithyFile(uri), notNullValue()); List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); updatedSources.add(filename); @@ -1537,10 +1542,10 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .range(LspAdapter.point(2, 0)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getState().isDetached(uri), is(false)); + assertThat(server.getState().detachedProjects().keySet(), empty()); assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @@ -1573,7 +1578,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text("2") .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); Shape foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); @@ -1592,7 +1597,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text(safeString("string Another\n")) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); @@ -1616,14 +1621,25 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { .event(uri, FileChangeType.Created) .build()); + String buildJson = workspace.readFile("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + String invalidDependency = "software.amazon.smithy:smithy-smoke-test-traits:[1.0, 2.0["; workspace.updateConfig(workspace.getConfig().toBuilder() .maven(MavenConfig.builder() .dependencies(Collections.singletonList(invalidDependency)) .build()) .build()); - server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + buildJson = workspace.readFile("smithy-build.json"); + server.didChange(RequestBuilders.didChange() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(workspace.getUri("smithy-build.json")) .build()); String fixed = "software.amazon.smithy:smithy-smoke-test-traits:1.49.0"; @@ -1632,8 +1648,13 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { .dependencies(Collections.singletonList(fixed)) .build()) .build()); - server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + buildJson = workspace.readFile("smithy-build.json"); + server.didChange(RequestBuilders.didChange() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(workspace.getUri("smithy-build.json")) .build()); server.didChange(RequestBuilders.didChange() @@ -1645,12 +1666,11 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { """)) .range(LspAdapter.origin()) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1787,14 +1807,14 @@ public void loadsMultipleRoots() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceBar.getName())); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -1838,12 +1858,12 @@ public void multiRootLifecycleManagement() throws Exception { server.didChange(RequestBuilders.didChange() .uri(fooUri) .text("\nstructure Bar {}") - .range(LspAdapter.point(server.getProjects().getDocument(fooUri).end())) + .range(LspAdapter.point(server.getState().findProjectAndFile(fooUri).file().document().end())) .build()); server.didChange(RequestBuilders.didChange() .uri(barUri) .text("\nstructure Foo {}") - .range(LspAdapter.point(server.getProjects().getDocument(barUri).end())) + .range(LspAdapter.point(server.getState().findProjectAndFile(barUri).file().document().end())) .build()); server.didSave(RequestBuilders.didSave() @@ -1853,10 +1873,10 @@ public void multiRootLifecycleManagement() throws Exception { .uri(barUri) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1924,10 +1944,10 @@ public void multiRootAddingWatchedFile() throws Exception { .range(LspAdapter.point(3, 0)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -2004,15 +2024,15 @@ public void multiRootChangingBuildFile() throws Exception { .range(LspAdapter.origin()) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().detachedProjects(), anEmptyMap()); - assertThat(server.getProjects().getProject(newUri), notNullValue()); - assertThat(server.getProjects().getProject(workspaceBar.getUri("model/main.smithy")), notNullValue()); - assertThat(server.getProjects().getProject(workspaceFoo.getUri("model/main.smithy")), notNullValue()); + assertThat(server.getState().detachedProjects(), anEmptyMap()); + assertThat(server.getState().findProjectAndFile(newUri), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("model/main.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("model/main.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); @@ -2062,16 +2082,16 @@ public void addingWorkspaceFolder() throws Exception { .text(barModel) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleManager().waitForAllTasks(); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceBar.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getState().attachedProjects(), hasKey(workspaceBar.getName())); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProjectByName(workspaceBar.getName()); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -2118,16 +2138,16 @@ public void removingWorkspaceFolder() { .removed(workspaceBar.getRoot().toUri().toString(), "bar") .build()); - assertThat(server.getProjects().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getProjects().attachedProjects(), not(hasKey(workspaceBar.getName()))); - assertThat(server.getProjects().detachedProjects(), hasKey(endsWith("bar.smithy"))); - assertThat(server.getProjects().isDetached(workspaceBar.getUri("bar.smithy")), is(true)); + assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); + assertThat(server.getState().attachedProjects(), not(hasKey(workspaceBar.getName()))); + assertThat(server.getState().detachedProjects(), hasKey(endsWith("bar.smithy"))); + assertThat(server.getState().isDetached(workspaceBar.getUri("bar.smithy")), is(true)); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName(workspaceFoo.getName()); - Project projectBar = server.getProjects().getProject(workspaceBar.getUri("bar.smithy")); + Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")).project(); assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); @@ -2165,10 +2185,10 @@ public void singleWorkspaceMultiRoot() throws Exception { SmithyLanguageServer server = initFromRoot(root); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getState().workspacePaths(), contains(root)); } @Test @@ -2205,8 +2225,8 @@ public void addingRootsToWorkspace() throws Exception { .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Created) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); } @@ -2240,10 +2260,10 @@ public void removingRootsFromWorkspace() throws Exception { SmithyLanguageServer server = initFromRoot(root); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getWorkspacePaths(), contains(root)); + assertThat(server.getState().workspacePaths(), contains(root)); workspaceFoo.deleteModel("smithy-build.json"); @@ -2251,8 +2271,8 @@ public void removingRootsFromWorkspace() throws Exception { .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Deleted) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), contains(workspaceBar.getName())); + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), contains(workspaceBar.getName())); } @Test @@ -2295,11 +2315,11 @@ public void addingConfigFile() throws Exception { .text(bazModel) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + assertThat(server.getState().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); workspaceFoo.addModel(".smithy-project.json", """ { @@ -2309,10 +2329,10 @@ public void addingConfigFile() throws Exception { .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Created) .build()); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getState().detachedProjects().keySet(), empty()); } @Test @@ -2359,21 +2379,172 @@ public void removingConfigFile() throws Exception { .text(bazModel) .build()); - assertThat(server.getWorkspacePaths(), contains(root)); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getState().detachedProjects().keySet(), empty()); workspaceFoo.deleteModel(".smithy-project.json"); server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Deleted) .build()); - assertThat(server.getProjects().attachedProjects().keySet(), containsInAnyOrder( + assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( workspaceFoo.getName(), workspaceBar.getName())); - assertThat(server.getProjects().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + assertThat(server.getState().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + } + + @Test + public void tracksJsonFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + workspace.addModel("model/main.json",""" + { + "smithy": "2.0", + "shapes": { + "com.foo#Foo": { + "type": "structure" + } + } + } + """); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + assertServerState(server, new ServerState( + Map.of( + workspace.getName(), + new ProjectState( + Set.of(workspace.getUri("model/main.json")), + Set.of(workspace.getUri("smithy-build.json")))), + Map.of() + )); + } + + @Test + public void tracksBuildFileChanges() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + String smithyBuildJson = workspace.readFile("smithy-build.json"); + String uri = workspace.getUri("smithy-build.json"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(smithyBuildJson) + .build()); + + assertThat(server.getState().managedUris(), contains(uri)); + assertThat(server.getState().getManagedDocument(uri), notNullValue()); + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(smithyBuildJson)); + + String updatedSmithyBuildJson = """ + { + "version": "1.0", + "sources": ["foo.smithy"] + } + """; + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(updatedSmithyBuildJson) + .build()); + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(updatedSmithyBuildJson)); + + server.didSave(RequestBuilders.didSave() + .uri(uri) + .build()); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + + assertThat(server.getState().managedUris(), not(contains(uri))); + assertThat(server.getState().getManagedDocument(uri), nullValue()); + } + + @Test + public void reloadsProjectOnBuildFileSave() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + String buildJson = workspace.readFile("smithy-build.json"); + String buildJsonUri = workspace.getUri("smithy-build.json"); + + server.didOpen(RequestBuilders.didOpen() + .uri(buildJsonUri) + .text(buildJson) + .build()); + + String model = """ + namespace com.foo + string Foo + """; + workspace.addModel("foo.smithy", model); + server.didOpen(RequestBuilders.didOpen() + .uri(workspace.getUri("foo.smithy")) + .text(model) + .build()); + + assertThat(server.getState().detachedProjects().keySet(), contains(workspace.getUri("foo.smithy"))); + + String updatedBuildJson = """ + { + "version": "1.0", + "sources": ["foo.smithy"] + } + """; + server.didChange(RequestBuilders.didChange() + .uri(buildJsonUri) + .text(updatedBuildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(buildJsonUri) + .build()); + + assertThat(server.getState().managedUris(), containsInAnyOrder( + buildJsonUri, + workspace.getUri("foo.smithy"))); + assertServerState(server, new ServerState( + Map.of(workspace.getName(), new ProjectState( + Set.of(workspace.getUri("foo.smithy")), + Set.of(buildJsonUri))), + Map.of())); + } + + private void assertServerState(SmithyLanguageServer server, ServerState expected) { + ServerState actual = ServerState.from(server); + assertThat(actual, equalTo(expected)); + } + + record ServerState( + Map attached, + Map detached + ) { + static ServerState from(SmithyLanguageServer server) { + return new ServerState( + server.getState().attachedProjects().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> ProjectState.from(e.getValue()))), + server.getState().detachedProjects().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> ProjectState.from(e.getValue())))); + } + } + + record ProjectState( + Set smithyFileUris, + Set buildFileUris + ) { + static ProjectState from(Project project) { + Set smithyFileUris = project.smithyFiles().keySet() + .stream() + .map(LspAdapter::toUri) + // Ignore these to make comparisons simpler + .filter(uri -> !LspAdapter.isSmithyJarFile(uri)) + .collect(Collectors.toSet()); + Set buildFileUris = project.config().buildFiles().keySet() + .stream() + .map(LspAdapter::toUri) + .collect(Collectors.toSet()); + return new ProjectState(smithyFileUris, buildFileUris); + } } public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index ab6946fb..8edab024 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -87,6 +87,14 @@ public void updateConfig(SmithyBuildConfig newConfig) { this.config = newConfig; } + public String readFile(String relativePath) { + try { + return Files.readString(root.resolve(relativePath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + /** * @param model String of the model to create in the workspace * @return A workspace with a single model, "main.smithy", with the given contents, and diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java deleted file mode 100644 index 4446ea5b..00000000 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -import java.nio.file.Path; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.protocol.LspAdapter; - -public class ProjectManagerTest { - @Test - public void canCheckIfAFileIsTracked() { - Path attachedRoot = ProjectTest.toPath(getClass().getResource("flat")); - Project mainProject = ProjectLoader.load(attachedRoot).unwrap(); - - ProjectManager manager = new ProjectManager(); - manager.updateProjectByName("main", mainProject); - - String detachedUri = LspAdapter.toUri("/foo/bar"); - manager.createDetachedProject(detachedUri, ""); - - String mainUri = LspAdapter.toUri(attachedRoot.resolve("main.smithy").toString()); - - assertThat(manager.isTracked(mainUri), is(true)); - assertThat(manager.getProject(mainUri), notNullValue()); - assertThat(manager.getProject(mainUri).getSmithyFile(mainUri), notNullValue()); - - assertThat(manager.isTracked(detachedUri), is(true)); - assertThat(manager.getProject(detachedUri), notNullValue()); - assertThat(manager.getProject(detachedUri).getSmithyFile(detachedUri), notNullValue()); - - String untrackedUri = LspAdapter.toUri("/bar/baz.smithy"); - assertThat(manager.isTracked(untrackedUri), is(false)); - assertThat(manager.getProject(untrackedUri), nullValue()); - } -} From 818acfbb0f3a0c0d2c30cfac70f5c69953cd4dc0 Mon Sep 17 00:00:00 2001 From: Yash Mewada <128434488+yasmewad@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:16:22 -0800 Subject: [PATCH 04/43] Improve Location for Diagnostics (#179) What is changed? * Updated SmithyLanguageServer.java to improve location for diagnostics. * Added a new method to find contiguousRange for non-whitespace characters given a source location. Why is it necessary? * To improve location for diagnostics. * See [Issue #76](https://github.com/smithy-lang/smithy-language-server/issues/76) How was the change tested? * Updated unit tests in SmithyLanguageServerTest.java and DocumentParserTest.java * Ran the full test suite to ensure no regressions * Manually tested the language server with various Smithy documents to verify improvements --- .../smithy/lsp/SmithyLanguageServer.java | 30 ++++--- .../smithy/lsp/document/DocumentParser.java | 54 +++++++++++++ .../smithy/lsp/SmithyLanguageServerTest.java | 51 ++++++++++++ .../lsp/document/DocumentParserTest.java | 80 +++++++++++++++++++ 4 files changed, 205 insertions(+), 10 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 6aa714ba..f8917ce2 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -806,28 +806,38 @@ List getFileDiagnostics(String uri) { private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); SourceLocation sourceLocation = validationEvent.getSourceLocation(); + Range range = determineRange(validationEvent, sourceLocation, smithyFile); + String message = validationEvent.getId() + ": " + validationEvent.getMessage(); + return new Diagnostic(range, message, severity, "Smithy"); + } + + private static Range determineRange(ValidationEvent validationEvent, + SourceLocation sourceLocation, + SmithyFile smithyFile) { + final Range defaultRange = LspAdapter.lineOffset(LspAdapter.toPosition(sourceLocation)); + + if (smithyFile == null) { + return defaultRange; + } - // TODO: Improve location of diagnostics - Range range = LspAdapter.lineOffset(LspAdapter.toPosition(sourceLocation)); + DocumentParser parser = DocumentParser.forDocument(smithyFile.document()); + + // Case where we have shapes present if (validationEvent.getShapeId().isPresent()) { // Event is (probably) on a member target if (validationEvent.containsId("Target")) { DocumentShape documentShape = smithyFile.documentShapesByStartPosition() .get(LspAdapter.toPosition(sourceLocation)); if (documentShape != null && documentShape.hasMemberTarget()) { - range = documentShape.targetReference().range(); + return documentShape.targetReference().range(); } - } else { + } else { // Check if the event location is on a trait application - Range traitRange = DocumentParser.forDocument(smithyFile.document()).traitIdRange(sourceLocation); - if (traitRange != null) { - range = traitRange; - } + return Objects.requireNonNullElse(parser.traitIdRange(sourceLocation), defaultRange); } } - String message = validationEvent.getId() + ": " + validationEvent.getMessage(); - return new Diagnostic(range, message, severity, "Smithy"); + return Objects.requireNonNullElse(parser.findContiguousRange(sourceLocation), defaultRange); } private static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java index 3982447b..d311e03e 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -704,4 +704,58 @@ private void nextNonWsNonComment() { private void reset() { rewind(0, 1, 1); } + + /** + * Finds a contiguous range of non-whitespace characters starting from the given SourceLocation. + * If the sourceLocation happens to be a whitespace character, it returns a Range representing that column. + * + * Here is how it works: + * 1. We first jump to sourceLocation. If we can't, we return null. + * 2. We then check if the sourceLocation is a whitespace character. If it is, we return that column. + * 3. We then find the start of the contiguous range by traversing backwards until a whitespace character is found. + * 4. We then find the end of the contiguous range by traversing forwards until a whitespace character is found. + * + * @param sourceLocation The starting location to search from. + * @return A Range object representing the contiguous non-whitespace characters, + * or null if not found. + */ + public Range findContiguousRange(SourceLocation sourceLocation) { + if (!jumpToSource(sourceLocation)) { + return null; + } + + Position startPosition = LspAdapter.toPosition(sourceLocation); + int startLine = startPosition.getLine(); + int startColumn = startPosition.getCharacter(); + + if (isWs()) { + return new Range( + new Position(startLine, startColumn), + // As per LSP docs the end postion is exclusive, + // so adding `+1` makes it highlight the startColumn. + new Position(startLine, startColumn + 1) + ); + } + + // The column offset is NOT the position, but an offset from the sourceLocation column. + // This is required as the `isWs` uses offset, and not position to determine whether the token at the offset + // is whitespace or not. + int startColumnOffset = 0; + // Find the start of the contiguous range by traversing backwards until a whitespace. + while (startColumn + startColumnOffset > 0 && !isWs(startColumnOffset - 1)) { + startColumnOffset--; + } + + int endColumn = startColumn; + // Find the end of the contiguous range + while (!isEof() && !isWs()) { + endColumn++; + skip(); + } + + // We add one to the column as it helps us shift it to correct character. + return new Range( + new Position(startLine, startColumn + startColumnOffset), + new Position(startLine, endColumn)); + } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index cbbb4228..984bfcea 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -58,6 +58,7 @@ import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextEdit; @@ -801,6 +802,56 @@ public void diagnosticsOnMemberTarget() { assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); } + @Test + public void diagnosticsOnInvalidStructureMember() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure Foo { + abc + } + """); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + List diagnostics = server.getFileDiagnostics(uri); + assertThat(diagnostics, hasSize(1)); + + Diagnostic diagnostic = diagnostics.getFirst(); + Document document = server.getFirstProject().getDocument(uri); + + assertThat(diagnostic.getRange(), equalTo( + new Range( + new Position(4, 7), + new Position(4, 8) + ) + ) + ); + } + + @Test + public void diagnosticsOnUse() { + String model = safeString(""" + $version: "2" + namespace com.foo + + use mything#SomeUnknownThing + """); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + List diagnostics = server.getFileDiagnostics(uri); + + Diagnostic diagnostic = diagnostics.getFirst(); + Document document = server.getFirstProject().getDocument(uri); + + assertThat(diagnostic.getRange(), hasText(document, equalTo("mything#SomeUnknownThing"))); + + } + @Test public void diagnosticOnTrait() { String model = safeString(""" diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java index db29102a..27e31ed6 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -11,6 +11,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static software.amazon.smithy.lsp.document.DocumentTest.safeIndex; import static software.amazon.smithy.lsp.document.DocumentTest.safeString; import static software.amazon.smithy.lsp.document.DocumentTest.string; @@ -18,9 +19,14 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; + import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; @@ -319,4 +325,78 @@ enum Baz { assertThat(getInputA.kind(), equalTo(DocumentShape.Kind.DefinedMember)); assertThat(getInputA.shapeName(), string("a")); } + + @ParameterizedTest + @MethodSource("contiguousRangeTestCases") + public void findsContiguousRange(SourceLocation input, Range expected) { + String text = """ + abc def + ghi jkl + mno pqr + """; + DocumentParser parser = DocumentParser.of(safeString(text)); + + Range actual = parser.findContiguousRange(input); + + if (expected == null) { + assertNull(actual); + } else { + assertEquals(expected, actual); + } + } + + private static Stream contiguousRangeTestCases() { + return Stream.of( + // Middle of a word + Arguments.of( + new SourceLocation("test.smithy", 1, 2), + new Range(new Position(0, 0), new Position(0, 3)) + ), + // Start of a word + Arguments.of( + new SourceLocation("test.smithy", 1, 5), + new Range(new Position(0, 4), new Position(0, 7)) + ), + // End of a word + Arguments.of( + new SourceLocation("test.smithy", 1, 7), + new Range(new Position(0, 4), new Position(0, 7)) + ), + // Start of line + Arguments.of( + new SourceLocation("test.smithy", 3, 1), + new Range(new Position(2, 0), new Position(2, 3)) + ), + // End of line + Arguments.of( + new SourceLocation("test.smithy", 3, 6), + new Range(new Position(2, 5), new Position(2, 8)) + ), + // Invalid location + Arguments.of( + new SourceLocation("test.smithy", 10, 1), + null + ), + // At whitespace between words + Arguments.of( + new SourceLocation("test.smithy", 1, 4), + new Range(new Position(0, 3), new Position(0, 4)) + ), + // At start of file + Arguments.of( + new SourceLocation("test.smithy", 1, 1), + new Range(new Position(0, 0), new Position(0, 3)) + ), + // At end of file - last character + Arguments.of( + new SourceLocation("test.smithy", 3, 8), + new Range(new Position(2, 5), new Position(2, 8)) + ), + // At end of file - after last character + Arguments.of( + new SourceLocation("test.smithy", 3, 9), + new Range(new Position(2, 8), new Position(2, 9)) + ) + ); + } } From 6178d586c1e0bc06bcf91062673fad8ee7ba194f Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:39:04 -0500 Subject: [PATCH 05/43] Bump version to 0.5.0 (#180) --- CHANGELOG.md | 9 +++++++++ VERSION | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30314dea..634731f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Smithy Language Server Changelog +## 0.5.0 (2024-11-06) + +### Features +* Added support for projects nested in subdirectories of a workspace. The server can now load multiple projects within the same workspace. ([#167](https://github.com/smithy-lang/smithy-language-server/pull/167)) +* Improved location of diagnostics. Diagnostics now appear only on the token, rather than including a bunch of whitespace. ([#179](https://github.com/smithy-lang/smithy-language-server/pull/179)) + +### Bug fixes +* Fixed potential deadlock in `didChangeWorkspaceFolders`. ([#167](https://github.com/smithy-lang/smithy-language-server/pull/167)) + ## 0.4.1 (2024-09-09) ### Features diff --git a/VERSION b/VERSION index 267577d4..8f0916f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.5.0 From ab3a651da752f302866036eddd0dc1c8a7eea435 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:12:51 -0500 Subject: [PATCH 06/43] Fix document sync registrations on init (#181) Fixes a bug in #168 where the server would send the wrong registration for didSave on Smithy files. --- .../java/software/amazon/smithy/lsp/SmithyLanguageServer.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index f8917ce2..3e7ce8b8 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -298,6 +298,7 @@ private void registerDocumentSynchronization() { changeBuildOpts.setDocumentSelector(buildDocumentSelector); var saveBuildOpts = new TextDocumentSaveRegistrationOptions(); saveBuildOpts.setDocumentSelector(buildDocumentSelector); + saveBuildOpts.setIncludeText(true); client.registerCapability(new RegistrationParams(List.of( new Registration("SyncSmithyBuildFiles/Open", "textDocument/didOpen", openCloseBuildOpts), @@ -320,12 +321,13 @@ private void registerDocumentSynchronization() { changeSmithyOpts.setDocumentSelector(smithyDocumentSelector); var saveSmithyOpts = new TextDocumentSaveRegistrationOptions(); saveSmithyOpts.setDocumentSelector(smithyDocumentSelector); + saveSmithyOpts.setIncludeText(true); 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)))); + new Registration("SyncSmithyFiles/Save", "textDocument/didSave", saveSmithyOpts)))); } @Override From 30c386a43924e048939ed56b19c5e68552fb8d4f Mon Sep 17 00:00:00 2001 From: smithy-automation <127955164+smithy-automation@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:03:37 -0800 Subject: [PATCH 07/43] Update Smithy Version (#182) Co-authored-by: Smithy Automation --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index c0100b3d..6090d27b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -smithyVersion=1.52.1 +smithyVersion=1.53.0 From 5c9c72d8abdc7a73d81f9bdbe24bc3cdd58b5926 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:16:37 -0500 Subject: [PATCH 08/43] Add stubs for setTrace and cancelProgress (#183) Lsp4j provides default implementations of these methods in the LanguageServer interface, which throw exceptions. Even though we don't support this server side, better to give a warning than shutdown the server. --- .../amazon/smithy/lsp/SmithyLanguageServer.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 3e7ce8b8..5e5fb320 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -72,6 +72,7 @@ import org.eclipse.lsp4j.Registration; import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SetTraceParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.SymbolKind; import org.eclipse.lsp4j.TextDocumentChangeRegistrationOptions; @@ -84,6 +85,7 @@ import org.eclipse.lsp4j.Unregistration; import org.eclipse.lsp4j.UnregistrationParams; import org.eclipse.lsp4j.WorkDoneProgressBegin; +import org.eclipse.lsp4j.WorkDoneProgressCancelParams; import org.eclipse.lsp4j.WorkDoneProgressEnd; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; @@ -351,6 +353,21 @@ public void exit() { System.exit(0); } + @Override + public void cancelProgress(WorkDoneProgressCancelParams params) { + // TODO: Right now this stub just avoids a possible runtime error from the default + // impl in lsp4j. If we start using work done tokens, we will want to support canceling + // them here. + LOGGER.warning("window/workDoneProgress/cancel not implemented"); + } + + @Override + public void setTrace(SetTraceParams params) { + // TODO: Eventually when we set up better logging, maybe there's something to do here. + // For now, this stub just avoids a runtime error from the default impl in lsp4j. + LOGGER.warning("$/setTrace not implemented"); + } + @Override public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { LOGGER.finest("JarFileContents"); From 1dd15b222f45f3ebd20a89ebd6110141f8f6f5d5 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Fri, 17 Jan 2025 11:42:45 -0500 Subject: [PATCH 09/43] Upgrade completions, definition, hover (#166) * Upgrade completions, definition, hover This commit is a rewrite of how language features (i.e. completions, definition, hover) are implemented. It improves the accuracy and expands the functionality of each feature significantly. Improvements include: - Completions - Trait values - Builtin control keys and metadata - Namespaces, based on other namespaces in the project - Keywords - Member names (like inside resources, maps) - Member values (like inside the list of operation errors, resource property targets, etc.) - Elided members - Some trait values have special completions, like `examples` has completions for the target operation's input/output parameters - Definition - Trait values - Elided members - Shape ids referenced within trait values - Hover - Trait values - Elided members - Builtin metadata There's a lot going on here, but there's a few key pieces of this commit that all work together to make this work: At the core of these improvements is the addition of a custom parser for the IDL that provides the needed syntactic information to implement these features. See the javadoc on the Syntax class for more details on how the parser works, and why it was written that way. At a high level though, the parser produces a flat list of `Syntax.Statement`, and that list is searched through to find things, such as the statement the cursor is currently in. It is also used to search 'around' a statement, like to find the shape a trait is being applied to. Another key piece of these changes is `NodeCursor` and `NodeSearch`. There are a few places in the syntax of a smithy file where you may have a node value whose structure is (or can be) described by a Smithy model. For example, trait values. `NodeCursor` is basically two things: 1. A path from the start of a `Node` to a position within that `Node`, 2. An index into that path. `NodeSearch` is used to search a model along the path of a `NodeCursor`, from a starting shape. For example, when the cursor is within a trait value, the `NodeCursor` is that path from the root of the trait value, to the cursor position, and `NodeSearch` is used to search in the model, starting at the trait's definition, along the path of the `NodeCursor`, to find what shape corresponds to the cursor's location. That shape can then be used e.g. to provide completions. Finally, there's the `Builtins` class, and the corresponding Smithy model it uses. I originally had a completely different abstraction for describing the structure of metadata, different shape types' members, and even `smithy-build.json`. But it was basically just a 'structured graph', like a Smithy model. So I decided to just _use_ a Smithy model itself, since I already had the abstractions for traversing it (like I had to for trait values). The `Builtins` model contains shapes that define the structure of certain Smithy constructs. For example, I use it to model the shape of builtin metadata, like suppressions. I also use it to model the shape of shapes, that is, what members shapes have, and what their targets are. Some shapes in this model are considered 'builtins' (in the builtins.smithy files). Builtins are shapes that require some custom processing, or have some special meaning, like `AnyNamespace`, which is used for describing a namespace that can be used in https://smithy.io/2.0/spec/model-validation.html#suppression-metadata. The builtin model pretty 'meta', and I don't _love_ it, but it reduces a significant amount of duplicated logic. For example, if we want to give documentation for some metadata, it is as easy as adding it to the builtins model. We can also use it to add support for smithy-build.json completions, hover, and even validation, later. It would be nice if these definitions lived elsewhere, so other tooling could consume them, like the Smithy docs for example, and I have some other ideas on how we can use it, but they're out of scope here. Testing for this commit comes mostly from the completions, definitions, and hover tests, which indirectly test lower-level components like the parser (there are still some parser tests, though). * Address feedback * Refactoring This commit keeps the functionality added to the language features in the previous commits, but does some broad refactoring of those changes to clean up the APIs, get rid of some footguns, and reduce the chance of some concurrency/parallelism issues. The main changes are: - Syntax.Ident/Syntax.Node.Str produced by the new parser now copy the actual string value. Previously, they only stored the start/end positions, and required you to copy the value out of the Document on-demand. This reduced the memory footprint of parsing, but I was concerned about the Document being changed at the same time another thread is trying to copy a value out of it. Copying eagerly avoids this. Plus, we can avoid most of the memory issues by doing partial reparsing (more on that later). - Project now stores an index of files -> shapes defined in that file, instead of storing the shapes on the SmithyFile. This index is only needed to help determine which shapes need to be removed when rebuilding the model, so it doesn't make sense for SmithyFile to know about it. This also ties into the next change... - Multiple changes to SmithyFile. SmithyFile now has a subclass, IdlFile, which stores its parse result. With the addition of the parser, and the changes to make DocumentVersion/DocumentImports/DocumentNamespace be computed from the parse result, SmithyFile can't represent both IDL and AST files. Arguably, it never really did because AST files don't have namespaces/imports. Either way, IdlFile now provides access to the parse result, which contains DocumentNamespace/Version/Imports, as well as the parsed statements. I also added synchronization to handle access to the parse result, since it will be mutated on every change. I don't really like how this works, but I'm going to address that in a future update (which I will describe below). - Added StatementView, which wraps a list of parsed statements and a specific index in that list, providing methods to look "around" that index. This replaces the error-prone and unreadable SyntaxSearch, which required you to pass around int indicies everywhere. Some more minor changes to note: - Moved diagnostics computation into SmithyDiagnostics. It already belonged there probably, but especially with the addition of IdlFile I just had to do it. - Moved document symbols into a 'handler' like definition, etc. - Added `uri` and `isDetached` properties to ProjectAndFile, for convenience. There are still some rough edges with this code, but I plan on making a follow up PR to address them, so I this one doesn't become even larger. Specifically, I want to only parse opened/managed files. This could let us get rid of the whole ProjectFile thing, or at least not require going through a project to find a file (it would be stored directly on ServerState). This also makes the synchronization story much simpler, improves initialization time, and should make it easier to eventually load projects async. * Fix hover on member defs * Fix replace text range In some cases, when a completion is meant to replace existing text, the range it was supposed to replace would leave an extra character at the end. This was because the range's end position was not exclusive. * Fix test class name --- .gitignore | 2 - build.gradle | 5 + config/checkstyle/checkstyle.xml | 1 - .../amazon/smithy/lsp/ServerState.java | 7 +- .../smithy/lsp/SmithyLanguageServer.java | 191 +-- .../lsp/diagnostics/SmithyDiagnostics.java | 161 ++- .../amazon/smithy/lsp/document/Document.java | 158 +-- .../smithy/lsp/document/DocumentId.java | 11 + .../smithy/lsp/document/DocumentImports.java | 5 +- .../lsp/document/DocumentNamespace.java | 5 +- .../smithy/lsp/document/DocumentParser.java | 665 +--------- .../lsp/document/DocumentPositionContext.java | 44 - .../smithy/lsp/document/DocumentShape.java | 42 - .../smithy/lsp/document/DocumentVersion.java | 5 +- .../smithy/lsp/handler/CompletionHandler.java | 315 ----- .../smithy/lsp/handler/DefinitionHandler.java | 90 -- .../smithy/lsp/handler/HoverHandler.java | 169 --- .../amazon/smithy/lsp/language/Builtins.java | 110 ++ .../smithy/lsp/language/CompleterContext.java | 92 ++ .../lsp/language/CompletionCandidates.java | 263 ++++ .../lsp/language/CompletionHandler.java | 235 ++++ .../lsp/language/DefinitionHandler.java | 60 + .../lsp/language/DocumentSymbolHandler.java | 63 + .../lsp/language/DynamicMemberTarget.java | 177 +++ .../smithy/lsp/language/HoverHandler.java | 250 ++++ .../smithy/lsp/language/IdlPosition.java | 128 ++ .../smithy/lsp/language/NodeSearch.java | 239 ++++ .../smithy/lsp/language/ShapeCompleter.java | 261 ++++ .../smithy/lsp/language/ShapeSearch.java | 313 +++++ .../smithy/lsp/language/SimpleCompleter.java | 199 +++ .../amazon/smithy/lsp/project/IdlFile.java | 46 + .../amazon/smithy/lsp/project/Project.java | 160 +-- .../smithy/lsp/project/ProjectAndFile.java | 4 +- .../smithy/lsp/project/ProjectLoader.java | 210 ++-- .../amazon/smithy/lsp/project/SmithyFile.java | 193 +-- .../smithy/lsp/protocol/LspAdapter.java | 37 +- .../amazon/smithy/lsp/syntax/NodeCursor.java | 222 ++++ .../amazon/smithy/lsp/syntax/Parser.java | 1037 ++++++++++++++++ .../smithy/lsp/syntax/StatementView.java | 228 ++++ .../amazon/smithy/lsp/syntax/Syntax.java | 787 ++++++++++++ .../amazon/smithy/lsp/util/StreamUtils.java | 24 + src/main/resources/.gitkeep | 1 - .../smithy/lsp/language/builtins.smithy | 37 + .../amazon/smithy/lsp/language/control.smithy | 17 + .../amazon/smithy/lsp/language/members.smithy | 75 ++ .../smithy/lsp/language/metadata.smithy | 95 ++ .../amazon/smithy/lsp/LspMatchers.java | 29 + .../smithy/lsp/SmithyLanguageServerTest.java | 670 +--------- .../lsp/SmithyVersionRefactoringTest.java | 12 +- .../amazon/smithy/lsp/TextWithPositions.java | 53 + .../lsp/document/DocumentParserTest.java | 244 +--- .../lsp/language/CompletionHandlerTest.java | 1104 +++++++++++++++++ .../lsp/language/DefinitionHandlerTest.java | 384 ++++++ .../lsp/language/DocumentSymbolTest.java | 62 + .../smithy/lsp/language/HoverHandlerTest.java | 173 +++ .../smithy/lsp/project/ProjectTest.java | 52 +- .../smithy/lsp/syntax/IdlParserTest.java | 469 +++++++ .../smithy/lsp/syntax/NodeCursorTest.java | 55 + .../smithy/lsp/syntax/NodeParserTest.java | 406 ++++++ 59 files changed, 8284 insertions(+), 2868 deletions(-) delete mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/Builtins.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/IdlFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/Parser.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java create mode 100644 src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java delete mode 100644 src/main/resources/.gitkeep create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/control.smithy create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/members.smithy create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy create mode 100644 src/test/java/software/amazon/smithy/lsp/TextWithPositions.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java diff --git a/.gitignore b/.gitignore index d47eb117..5ae1ecbd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,4 @@ bin .settings .java-version -*.smithy -!/src/test/resources/**/*.smithy .ammonite \ No newline at end of file diff --git a/build.gradle b/build.gradle index 339f1131..e050c8ba 100644 --- a/build.gradle +++ b/build.gradle @@ -141,6 +141,9 @@ publishing { } } +checkstyle { + toolVersion = "10.12.4" +} dependencies { implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1" @@ -153,6 +156,8 @@ dependencies { testImplementation "org.hamcrest:hamcrest:2.2" testRuntimeOnly "org.junit.platform:junit-platform-launcher" + + checkstyle "com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}" } tasks.withType(Javadoc).all { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index fa284ede..c6658c32 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -182,7 +182,6 @@ - diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index 20f30733..9481d38d 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -96,7 +96,7 @@ ProjectAndFile findProjectAndFile(String uri) { String path = LspAdapter.toPath(uri); ProjectFile projectFile = detachedProject.getProjectFile(path); if (projectFile != null) { - return new ProjectAndFile(detachedProject, projectFile); + return new ProjectAndFile(uri, detachedProject, projectFile, true); } } @@ -133,17 +133,16 @@ private ProjectAndFile findAttachedAndRemoveDetached(String uri) { ProjectFile projectFile = project.getProjectFile(path); if (projectFile != null) { detachedProjects.remove(uri); - return new ProjectAndFile(project, projectFile); + return new ProjectAndFile(uri, project, projectFile, false); } } return null; } - Project createDetachedProject(String uri, String text) { + void createDetachedProject(String uri, String text) { Project project = ProjectLoader.loadDetached(uri, text); detachedProjects.put(uri, project); - return project; } List tryInitProject(Path root) { diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 5e5fb320..e78467b9 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -45,7 +45,6 @@ import org.eclipse.lsp4j.CompletionParams; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; import org.eclipse.lsp4j.DidChangeConfigurationParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidChangeWatchedFilesParams; @@ -74,7 +73,6 @@ import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.SetTraceParams; 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; @@ -100,26 +98,25 @@ import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentShape; import software.amazon.smithy.lsp.ext.OpenProject; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.ext.ServerStatus; import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; -import software.amazon.smithy.lsp.handler.CompletionHandler; -import software.amazon.smithy.lsp.handler.DefinitionHandler; -import software.amazon.smithy.lsp.handler.HoverHandler; +import software.amazon.smithy.lsp.language.CompletionHandler; +import software.amazon.smithy.lsp.language.DefinitionHandler; +import software.amazon.smithy.lsp.language.DocumentSymbolHandler; +import software.amazon.smithy.lsp.language.HoverHandler; import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; 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.model.SourceLocation; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.loader.IdlTokenizer; import software.amazon.smithy.model.selector.Selector; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.syntax.Formatter; import software.amazon.smithy.syntax.TokenTree; import software.amazon.smithy.utils.IoUtils; @@ -163,6 +160,10 @@ ServerState getState() { return state; } + Severity getMinimumSeverity() { + return minimumSeverity; + } + @Override public void connect(LanguageClient client) { LOGGER.finest("Connect"); @@ -517,10 +518,11 @@ public void didChange(DidChangeTextDocumentParams params) { } // Don't reload or update the project on build file changes, only on save - if (projectAndFile.file() instanceof BuildFile) { + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { return; } + smithyFile.reparse(); if (!onlyReloadOnSave) { Project project = projectAndFile.project(); @@ -529,7 +531,7 @@ public void didChange(DidChangeTextDocumentParams params) { // Report any parse/shape/trait loading errors CompletableFuture future = CompletableFuture .runAsync(() -> project.updateModelWithoutValidating(uri)) - .thenComposeAsync(unused -> sendFileDiagnostics(uri)); + .thenComposeAsync(unused -> sendFileDiagnostics(projectAndFile)); state.lifecycleManager().putTask(uri, future); } } @@ -549,9 +551,10 @@ public void didOpen(DidOpenTextDocumentParams params) { projectAndFile.file().document().applyEdit(null, text); } else { state.createDetachedProject(uri, text); + projectAndFile = state.findProjectAndFile(uri); // Note: This will always be present } - state.lifecycleManager().putTask(uri, sendFileDiagnostics(uri)); + state.lifecycleManager().putTask(uri, sendFileDiagnostics(projectAndFile)); } @Override @@ -597,7 +600,7 @@ public void didSave(DidSaveTextDocumentParams params) { } else { CompletableFuture future = CompletableFuture .runAsync(() -> project.updateAndValidateModel(uri)) - .thenCompose(unused -> sendFileDiagnostics(uri)); + .thenCompose(unused -> sendFileDiagnostics(projectAndFile)); state.lifecycleManager().putTask(uri, future); } } @@ -613,15 +616,13 @@ public CompletableFuture, CompletionList>> completio return completedFuture(Either.forLeft(Collections.emptyList())); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile 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)); - }); + var handler = new CompletionHandler(project, smithyFile); + return CompletableFutures.computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); } @Override @@ -643,54 +644,13 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return completedFuture(Collections.emptyList()); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { return completedFuture(List.of()); } - return CompletableFutures.computeAsync((cc) -> { - Collection documentShapes = smithyFile.documentShapes(); - if (documentShapes.isEmpty()) { - return Collections.emptyList(); - } - - if (cc.isCanceled()) { - return Collections.emptyList(); - } - - List> documentSymbols = new ArrayList<>(documentShapes.size()); - for (DocumentShape documentShape : documentShapes) { - SymbolKind symbolKind; - switch (documentShape.kind()) { - case Inline: - // No shape name in the document text, so no symbol - continue; - case DefinedMember: - case Elided: - symbolKind = SymbolKind.Property; - break; - case DefinedShape: - case Targeted: - default: - symbolKind = SymbolKind.Class; - break; - } - - // Check before copying shapeName, which is actually a reference to the underlying document, and may - // be changed. - cc.checkCanceled(); - - String symbolName = documentShape.shapeName().toString(); - if (symbolName.isEmpty()) { - LOGGER.warning("[DocumentSymbols] Empty shape name for " + documentShape); - continue; - } - Range symbolRange = documentShape.range(); - DocumentSymbol symbol = new DocumentSymbol(symbolName, symbolKind, symbolRange, symbolRange); - documentSymbols.add(Either.forRight(symbol)); - } - - return documentSymbols; - }); + List statements = idlFile.getParse().statements(); + var handler = new DocumentSymbolHandler(idlFile.document(), statements); + return CompletableFuture.supplyAsync(handler::handle); } @Override @@ -705,13 +665,13 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return completedFuture(null); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { return completedFuture(null); } Project project = projectAndFile.project(); - List locations = new DefinitionHandler(project, smithyFile).handle(params); - return completedFuture(Either.forLeft(locations)); + var handler = new DefinitionHandler(project, smithyFile); + return CompletableFuture.supplyAsync(() -> Either.forLeft(handler.handle(params))); } @Override @@ -725,15 +685,15 @@ public CompletableFuture hover(HoverParams params) { return completedFuture(null); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { return completedFuture(null); } Project project = projectAndFile.project(); // TODO: Abstract away passing minimum severity - Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); - return completedFuture(hover); + var handler = new HoverHandler(project, smithyFile, minimumSeverity); + return CompletableFuture.supplyAsync(() -> handler.handle(params)); } @Override @@ -772,99 +732,16 @@ public CompletableFuture> formatting(DocumentFormatting private void sendFileDiagnosticsForManagedDocuments() { for (String managedDocumentUri : state.managedUris()) { - state.lifecycleManager().putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); + ProjectAndFile projectAndFile = state.findProjectAndFile(managedDocumentUri); + state.lifecycleManager().putOrComposeTask(managedDocumentUri, sendFileDiagnostics(projectAndFile)); } } - private CompletableFuture sendFileDiagnostics(String uri) { + private CompletableFuture sendFileDiagnostics(ProjectAndFile projectAndFile) { return CompletableFuture.runAsync(() -> { - List diagnostics = getFileDiagnostics(uri); - PublishDiagnosticsParams publishDiagnosticsParams = new PublishDiagnosticsParams(uri, diagnostics); + List diagnostics = SmithyDiagnostics.getFileDiagnostics(projectAndFile, minimumSeverity); + var publishDiagnosticsParams = new PublishDiagnosticsParams(projectAndFile.uri(), diagnostics); client.publishDiagnostics(publishDiagnosticsParams); }); } - - List getFileDiagnostics(String uri) { - if (LspAdapter.isJarFile(uri) || LspAdapter.isSmithyJarFile(uri)) { - // Don't send diagnostics to jar files since they can't be edited - // and diagnostics could be misleading. - return Collections.emptyList(); - } - - 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 = projectAndFile.project(); - String path = LspAdapter.toPath(uri); - - List diagnostics = project.modelResult().getValidationEvents().stream() - .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) - .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) - .map(validationEvent -> toDiagnostic(validationEvent, smithyFile)) - .collect(Collectors.toCollection(ArrayList::new)); - - Diagnostic versionDiagnostic = SmithyDiagnostics.versionDiagnostic(smithyFile); - if (versionDiagnostic != null) { - diagnostics.add(versionDiagnostic); - } - - if (state.isDetached(uri)) { - diagnostics.add(SmithyDiagnostics.detachedDiagnostic(smithyFile)); - } - - return diagnostics; - } - - private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { - DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); - SourceLocation sourceLocation = validationEvent.getSourceLocation(); - Range range = determineRange(validationEvent, sourceLocation, smithyFile); - String message = validationEvent.getId() + ": " + validationEvent.getMessage(); - return new Diagnostic(range, message, severity, "Smithy"); - } - - private static Range determineRange(ValidationEvent validationEvent, - SourceLocation sourceLocation, - SmithyFile smithyFile) { - final Range defaultRange = LspAdapter.lineOffset(LspAdapter.toPosition(sourceLocation)); - - if (smithyFile == null) { - return defaultRange; - } - - DocumentParser parser = DocumentParser.forDocument(smithyFile.document()); - - // Case where we have shapes present - if (validationEvent.getShapeId().isPresent()) { - // Event is (probably) on a member target - if (validationEvent.containsId("Target")) { - DocumentShape documentShape = smithyFile.documentShapesByStartPosition() - .get(LspAdapter.toPosition(sourceLocation)); - if (documentShape != null && documentShape.hasMemberTarget()) { - return documentShape.targetReference().range(); - } - } else { - // Check if the event location is on a trait application - return Objects.requireNonNullElse(parser.traitIdRange(sourceLocation), defaultRange); - } - } - - return Objects.requireNonNullElse(parser.findContiguousRange(sourceLocation), defaultRange); - } - - private static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { - return switch (severity) { - case ERROR, DANGER -> DiagnosticSeverity.Error; - case WARNING -> DiagnosticSeverity.Warning; - case NOTE -> DiagnosticSeverity.Information; - default -> DiagnosticSeverity.Hint; - }; - } } diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java index 2f4452d8..54459b17 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java @@ -5,17 +5,28 @@ package software.amazon.smithy.lsp.diagnostics; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DiagnosticCodeDescription; import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.lsp.document.DocumentParser; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +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.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; /** - * Utility class for creating different kinds of file diagnostics, that aren't - * necessarily connected to model validation events. + * Creates diagnostics for Smithy files. */ public final class SmithyDiagnostics { public static final String UPDATE_VERSION = "migrating-idl-1-to-2"; @@ -29,38 +40,65 @@ private SmithyDiagnostics() { } /** - * Creates a diagnostic for when a $version control statement hasn't been defined, - * or when it has been defined for IDL 1.0. - * - * @param smithyFile The Smithy file to get a version diagnostic for - * @return The version diagnostic associated with the Smithy file, or null - * if one doesn't exist + * @param projectAndFile Project and file to get diagnostics for + * @param minimumSeverity Minimum severity of validation events to diagnose + * @return A list of diagnostics for the given project and file */ - public static Diagnostic versionDiagnostic(SmithyFile smithyFile) { - if (smithyFile.documentVersion().isPresent()) { - DocumentVersion documentVersion = smithyFile.documentVersion().get(); - if (!documentVersion.version().startsWith("2")) { - Diagnostic diagnostic = createDiagnostic( - documentVersion.range(), "You can upgrade to idl version 2.", UPDATE_VERSION); - diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); - return diagnostic; - } - } else if (smithyFile.document() != null) { + public static List getFileDiagnostics(ProjectAndFile projectAndFile, Severity minimumSeverity) { + if (LspAdapter.isJarFile(projectAndFile.uri()) || LspAdapter.isSmithyJarFile(projectAndFile.uri())) { + // Don't send diagnostics to jar files since they can't be edited + // and diagnostics could be misleading. + return List.of(); + } + + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return List.of(); + } + + Project project = projectAndFile.project(); + String path = projectAndFile.file().path(); + + EventToDiagnostic eventToDiagnostic = eventToDiagnostic(smithyFile); + + List diagnostics = project.modelResult().getValidationEvents().stream() + .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0 + && event.getSourceLocation().getFilename().equals(path)) + .map(eventToDiagnostic::toDiagnostic) + .collect(Collectors.toCollection(ArrayList::new)); + + Diagnostic versionDiagnostic = versionDiagnostic(smithyFile); + if (versionDiagnostic != null) { + diagnostics.add(versionDiagnostic); + } + + if (projectAndFile.isDetached()) { + diagnostics.add(detachedDiagnostic(smithyFile)); + } + + return diagnostics; + } + + private static Diagnostic versionDiagnostic(SmithyFile smithyFile) { + if (!(smithyFile instanceof IdlFile idlFile)) { + return null; + } + + Syntax.IdlParseResult syntaxInfo = idlFile.getParse(); + if (syntaxInfo.version().version().startsWith("2")) { + return null; + } else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) { + var diagnostic = createDiagnostic( + syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION); + diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); + return diagnostic; + } else { int end = smithyFile.document().lineEnd(0); Range range = LspAdapter.lineSpan(0, 0, end); return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION); } - return null; } - /** - * Creates a diagnostic for when a Smithy file is not connected to a - * Smithy project via smithy-build.json or other build file. - * - * @param smithyFile The Smithy file to get a detached diagnostic for - * @return The detached diagnostic associated with the Smithy file - */ - public static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { + private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { Range range; if (smithyFile.document() == null) { range = LspAdapter.origin(); @@ -75,4 +113,71 @@ public static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { private static Diagnostic createDiagnostic(Range range, String title, String code) { return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code); } + + private static EventToDiagnostic eventToDiagnostic(SmithyFile smithyFile) { + if (!(smithyFile instanceof IdlFile idlFile)) { + return new Simple(); + } + + var idlParse = idlFile.getParse(); + var view = StatementView.createAtStart(idlParse).orElse(null); + if (view == null) { + return new Simple(); + } else { + var documentParser = DocumentParser.forStatements(smithyFile.document(), view.parseResult().statements()); + return new Idl(view, documentParser); + } + } + + private sealed interface EventToDiagnostic { + default Range getDiagnosticRange(ValidationEvent event) { + var start = LspAdapter.toPosition(event.getSourceLocation()); + var end = LspAdapter.toPosition(event.getSourceLocation()); + end.setCharacter(end.getCharacter() + 1); // Range is exclusive + + return new Range(start, end); + } + + default Diagnostic toDiagnostic(ValidationEvent event) { + var diagnosticSeverity = switch (event.getSeverity()) { + case ERROR, DANGER -> DiagnosticSeverity.Error; + case WARNING -> DiagnosticSeverity.Warning; + case NOTE -> DiagnosticSeverity.Information; + default -> DiagnosticSeverity.Hint; + }; + var diagnosticRange = getDiagnosticRange(event); + var message = event.getId() + ": " + event.getMessage(); + return new Diagnostic(diagnosticRange, message, diagnosticSeverity, "Smithy"); + } + } + + private record Simple() implements EventToDiagnostic {} + + private record Idl(StatementView view, DocumentParser parser) implements EventToDiagnostic { + @Override + public Range getDiagnosticRange(ValidationEvent event) { + Position eventStart = LspAdapter.toPosition(event.getSourceLocation()); + final Range defaultRange = EventToDiagnostic.super.getDiagnosticRange(event); + + if (event.getShapeId().isPresent()) { + int eventStartIndex = parser.getDocument().indexOfPosition(eventStart); + Syntax.Statement statement = view.getStatementAt(eventStartIndex).orElse(null); + + if (statement instanceof Syntax.Statement.MemberDef def + && event.containsId("Target") + && def.target() != null) { + Range targetRange = LspAdapter.identRange(def.target(), parser.getDocument()); + return Objects.requireNonNullElse(targetRange, defaultRange); + } else if (statement instanceof Syntax.Statement.TraitApplication app) { + Range traitIdRange = LspAdapter.identRange(app.id(), parser.getDocument()); + if (traitIdRange != null) { + traitIdRange.getStart().setCharacter(traitIdRange.getStart().getCharacter() - 1); // include @ + } + return Objects.requireNonNullElse(traitIdRange, defaultRange); + } + } + + return Objects.requireNonNullElse(parser.findContiguousRange(event.getSourceLocation()), defaultRange); + } + } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java index 75ee0e15..74c3f0c7 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -97,20 +97,31 @@ public int indexOfLine(int line) { * doesn't exist */ public int lineOfIndex(int idx) { - // TODO: Use binary search or similar - if (idx >= length() || idx < 0) { - return -1; - } - - for (int line = 0; line <= lastLine() - 1; line++) { - int currentLineIdx = indexOfLine(line); - int nextLineIdx = indexOfLine(line + 1); - if (idx >= currentLineIdx && idx < nextLineIdx) { - return line; + int low = 0; + int up = lastLine(); + + while (low <= up) { + int mid = (low + up) / 2; + int midLineIdx = lineIndices[mid]; + int midLineEndIdx = lineEndUnchecked(mid); + if (idx >= midLineIdx && idx <= midLineEndIdx) { + return mid; + } else if (idx < midLineIdx) { + up = mid - 1; + } else { + low = mid + 1; } } - return lastLine(); + return -1; + } + + private int lineEndUnchecked(int line) { + if (line == lastLine()) { + return length() - 1; + } else { + return lineIndices[line + 1] - 1; + } } /** @@ -167,6 +178,34 @@ public Position positionAtIndex(int index) { return new Position(line, character); } + /** + * @param start The start character offset + * @param end The end character offset + * @return The range between the two given offsets + */ + public Range rangeBetween(int start, int end) { + if (end < start || start < 0) { + return null; + } + + // The start is inclusive, so it should be within the bounds of the document + Position startPos = positionAtIndex(start); + if (startPos == null) { + return null; + } + + Position endPos; + if (end == length()) { + int lastLine = lastLine(); + int lastCol = length() - lineIndices[lastLine]; + endPos = new Position(lastLine, lastCol); + } else { + endPos = positionAtIndex(end); + } + + return new Range(startPos, endPos); + } + /** * @param line The line to find the end of * @return The index of the end of the given line, or {@code -1} if the @@ -220,23 +259,6 @@ public int lastIndexOf(String s, int before) { return buffer.lastIndexOf(s, before); } - /** - * @param c The character to find the last index of - * @param before The index to stop the search at - * @param line The line to search within - * @return The index of the last occurrence of {@code c} before {@code before} - * on the line {@code line} or {@code -1} if one doesn't exist - */ - int lastIndexOfOnLine(char c, int before, int line) { - int lineIdx = indexOfLine(line); - for (int i = before; i >= lineIdx; i--) { - if (buffer.charAt(i) == c) { - return i; - } - } - return -1; - } - /** * @return A reference to the text in this document */ @@ -312,19 +334,6 @@ public CharBuffer borrowToken(Position position) { return CharBuffer.wrap(buffer, startIdx + 1, endIdx); } - /** - * @param position The position within the id to borrow - * @return A reference to the id that the given {@code position} is - * within, or {@code null} if the position is not within an id - */ - public CharBuffer borrowId(Position position) { - DocumentId id = copyDocumentId(position); - if (id == null) { - return null; - } - return id.idSlice(); - } - /** * @param line The line to borrow * @return A reference to the text in the given line, or {@code null} if @@ -383,32 +392,6 @@ public String copyRange(Range range) { return borrowed.toString(); } - /** - * @param position The position within the token to copy - * @return A copy of the token that the given {@code position} is within, - * or {@code null} if the position is not within a token - */ - public String copyToken(Position position) { - CharSequence token = borrowToken(position); - if (token == null) { - return null; - } - return token.toString(); - } - - /** - * @param position The position within the id to copy - * @return A copy of the id that the given {@code position} is - * within, or {@code null} if the position is not within an id - */ - public String copyId(Position position) { - CharBuffer id = borrowId(position); - if (id == null) { - return null; - } - return id.toString(); - } - /** * @param position The position within the id to get * @return A new id that the given {@code position} is @@ -495,10 +478,18 @@ public DocumentId copyDocumentId(Position position) { type = DocumentId.Type.ID; } - int actualStartIdx = startIdx + 1; // because we go past the actual start in the loop - CharBuffer wrapped = CharBuffer.wrap(buffer, actualStartIdx, endIdx); // endIdx here is non-inclusive - Position start = positionAtIndex(actualStartIdx); - Position end = positionAtIndex(endIdx - 1); // because we go pas the actual end in the loop + // We go past the start and end in each loop, so startIdx is before the start character, and endIdx + // is after the end character. + int startCharIdx = startIdx + 1; + int endCharIdx = endIdx - 1; + + // For creating the buffer and the range, the start is inclusive, and the end is exclusive. + CharBuffer wrapped = CharBuffer.wrap(buffer, startCharIdx, endCharIdx + 1); + Position start = positionAtIndex(startCharIdx); + // However, we can't get the position for an index that may be out of bounds, so we need to make + // the end position exclusive manually. + Position end = positionAtIndex(endCharIdx); + end.setCharacter(end.getCharacter() + 1); Range range = new Range(start, end); return new DocumentId(type, wrapped, range); } @@ -507,19 +498,6 @@ private static boolean isIdChar(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; } - /** - * @param line The line to copy - * @return A copy of the text in the given line, or {@code null} if the line - * doesn't exist - */ - public String copyLine(int line) { - CharBuffer borrowed = borrowLine(line); - if (borrowed == null) { - return null; - } - return borrowed.toString(); - } - /** * @param start The index of the start of the span to copy * @param end The index of the end of the span to copy @@ -541,18 +519,6 @@ public int length() { return buffer.length(); } - /** - * @param index The index to get the character at - * @return The character at the given index, or {@code \u0000} if one - * doesn't exist - */ - char charAt(int index) { - if (index < 0 || index >= length()) { - return '\u0000'; - } - return buffer.charAt(index); - } - // Adapted from String::split private static int[] computeLineIndicies(StringBuilder buffer) { int matchCount = 0; diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java index ec7c5f39..f20dd67b 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -51,4 +51,15 @@ public enum Type { public String copyIdValue() { return idSlice.toString(); } + + /** + * @return The value of the id without a leading '$' + */ + public String copyIdValueForElidedMember() { + String idValue = copyIdValue(); + if (idValue.startsWith("$")) { + return idValue.substring(1); + } + return idValue; + } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java index 0c5d9c60..47eefd7e 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java @@ -7,6 +7,7 @@ import java.util.Set; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The imports of a document, including the range they occupy. @@ -14,4 +15,6 @@ * @param importsRange The range of the imports * @param imports The set of imported shape ids. They are not guaranteed to be valid shape ids */ -public record DocumentImports(Range importsRange, Set imports) {} +public record DocumentImports(Range importsRange, Set imports) { + static final DocumentImports EMPTY = new DocumentImports(LspAdapter.origin(), Set.of()); +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java index 94c8b79b..d6e6ce39 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp.document; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The namespace of the document, including the range it occupies. @@ -13,4 +14,6 @@ * @param statementRange The range of the statement, including {@code namespace} * @param namespace The namespace of the document. Not guaranteed to be a valid namespace */ -public record DocumentNamespace(Range statementRange, CharSequence namespace) {} +public record DocumentNamespace(Range statementRange, String namespace) { + static final DocumentNamespace NONE = new DocumentNamespace(LspAdapter.origin(), ""); +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java index d311e03e..6b322ee6 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -5,303 +5,112 @@ package software.amazon.smithy.lsp.document; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; +import java.util.List; import java.util.Set; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.loader.ParserUtils; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.node.StringNode; -import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.utils.SimpleParser; /** - * 'Parser' that uses the line-indexed property of the underlying {@link Document} - * to jump around the document, parsing small pieces without needing to start at - * the beginning. - * - *

This isn't really a parser as much as it is a way to get very specific - * information about a document, such as whether a given position lies within - * a trait application, a member target, etc. It won't tell you whether syntax - * is valid. - * - *

Methods on this class often return {@code -1} or {@code null} for failure - * cases to reduce allocations, since these methods may be called frequently. + * Essentially a wrapper around a list of {@link Syntax.Statement}, to map + * them into the current "Document*" objects used by the rest of the server, + * until we replace those too. */ public final class DocumentParser extends SimpleParser { private final Document document; + private final List statements; - private DocumentParser(Document document) { + private DocumentParser(Document document, List statements) { super(document.borrowText()); this.document = document; + this.statements = statements; } static DocumentParser of(String text) { - return DocumentParser.forDocument(Document.of(text)); + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + return DocumentParser.forStatements(document, parse.statements()); } /** * @param document Document to create a parser for - * @return A parser for the given document + * @param statements The statements the parser should use + * @return The parser for the given document and statements */ - public static DocumentParser forDocument(Document document) { - return new DocumentParser(document); + public static DocumentParser forStatements(Document document, List statements) { + return new DocumentParser(document, statements); } /** - * @return The {@link DocumentNamespace} for the underlying document, or - * {@code null} if it couldn't be found + * @return The {@link DocumentNamespace} for the underlying document. */ public DocumentNamespace documentNamespace() { - int namespaceStartIdx = firstIndexOfWithOnlyLeadingWs("namespace"); - if (namespaceStartIdx < 0) { - return null; - } - - Position namespaceStatementStartPosition = document.positionAtIndex(namespaceStartIdx); - if (namespaceStatementStartPosition == null) { - // Shouldn't happen on account of the previous check - return null; - } - jumpToPosition(namespaceStatementStartPosition); - skip(); // n - skip(); // a - skip(); // m - skip(); // e - skip(); // s - skip(); // p - skip(); // a - skip(); // c - skip(); // e - - if (!isSp()) { - return null; - } - - sp(); - - if (!isNamespaceChar()) { - return null; - } - - int start = position(); - while (isNamespaceChar()) { - skip(); - } - int end = position(); - CharSequence namespace = document.borrowSpan(start, end); - - consumeRemainingCharactersOnLine(); - Position namespaceStatementEnd = currentPosition(); - - return new DocumentNamespace(new Range(namespaceStatementStartPosition, namespaceStatementEnd), namespace); - } - - /** - * @return The {@link DocumentImports} for the underlying document, or - * {@code null} if they couldn't be found - */ - public DocumentImports documentImports() { - // TODO: What if its 'uses', not just 'use'? - // Should we look for another? - int firstUseStartIdx = firstIndexOfWithOnlyLeadingWs("use"); - if (firstUseStartIdx < 0) { - // No use - return null; - } - - Position firstUsePosition = document.positionAtIndex(firstUseStartIdx); - if (firstUsePosition == null) { - // Shouldn't happen on account of the previous check - return null; - } - rewind(firstUseStartIdx, firstUsePosition.getLine() + 1, firstUsePosition.getCharacter() + 1); - - Set imports = new HashSet<>(); - Position lastUseEnd; // At this point we know there's at least one - do { - skip(); // u - skip(); // s - skip(); // e - - String id = getImport(); // handles skipping the ws - if (id != null) { - imports.add(id); + for (Syntax.Statement statement : statements) { + if (statement instanceof Syntax.Statement.Namespace namespace) { + Range range = document.rangeBetween(namespace.start(), namespace.end()); + String namespaceValue = namespace.namespace().stringValue(); + return new DocumentNamespace(range, namespaceValue); + } else if (statement instanceof Syntax.Statement.ShapeDef) { + break; } - consumeRemainingCharactersOnLine(); - lastUseEnd = currentPosition(); - nextNonWsNonComment(); - } while (isUse()); - - if (imports.isEmpty()) { - return null; } - - return new DocumentImports(new Range(firstUsePosition, lastUseEnd), imports); + return DocumentNamespace.NONE; } /** - * @param shapes The shapes defined in the underlying document - * @return A map of the starting positions of shapes defined or referenced - * in the underlying document to their corresponding {@link DocumentShape} + * @return The {@link DocumentImports} for the underlying document. */ - public Map documentShapes(Set shapes) { - Map documentShapes = new HashMap<>(shapes.size()); - for (Shape shape : shapes) { - if (!jumpToSource(shape.getSourceLocation())) { - continue; - } - - DocumentShape documentShape; - if (shape.isMemberShape()) { - DocumentShape.Kind kind = DocumentShape.Kind.DefinedMember; - if (is('$')) { - kind = DocumentShape.Kind.Elided; + public DocumentImports documentImports() { + Set imports; + for (int i = 0; i < statements.size(); i++) { + Syntax.Statement statement = statements.get(i); + if (statement instanceof Syntax.Statement.Use firstUse) { + imports = new HashSet<>(); + imports.add(firstUse.use().stringValue()); + Range useRange = document.rangeBetween(firstUse.start(), firstUse.end()); + Position start = useRange.getStart(); + Position end = useRange.getEnd(); + i++; + while (i < statements.size()) { + statement = statements.get(i); + if (statement instanceof Syntax.Statement.Use use) { + imports.add(use.use().stringValue()); + end = document.rangeBetween(use.start(), use.end()).getEnd(); + i++; + } else { + break; + } } - documentShape = documentShape(kind); - } else { - skipAlpha(); // shape type - sp(); - documentShape = documentShape(DocumentShape.Kind.DefinedShape); - } - - documentShapes.put(documentShape.range().getStart(), documentShape); - if (documentShape.hasMemberTarget()) { - DocumentShape memberTarget = documentShape.targetReference(); - documentShapes.put(memberTarget.range().getStart(), memberTarget); - } - } - return documentShapes; - } - - private DocumentShape documentShape(DocumentShape.Kind kind) { - Position start = currentPosition(); - int startIdx = position(); - if (kind == DocumentShape.Kind.Elided) { - skip(); // '$' - startIdx = position(); // so the name doesn't contain '$' - we need to match it later - } - skipIdentifier(); // shape name - Position end = currentPosition(); - int endIdx = position(); - Range range = new Range(start, end); - CharSequence shapeName = document.borrowSpan(startIdx, endIdx); - - // This is a bit ugly, but it avoids intermediate allocations (like a builder would require) - DocumentShape targetReference = null; - if (kind == DocumentShape.Kind.DefinedMember) { - sp(); - if (is(':')) { - skip(); - sp(); - targetReference = documentShape(DocumentShape.Kind.Targeted); + return new DocumentImports(new Range(start, end), imports); + } else if (statement instanceof Syntax.Statement.ShapeDef) { + break; } - } else if (kind == DocumentShape.Kind.DefinedShape && (shapeName == null || shapeName.isEmpty())) { - kind = DocumentShape.Kind.Inline; } - - return new DocumentShape(range, shapeName, kind, targetReference); + return DocumentImports.EMPTY; } /** - * @return The {@link DocumentVersion} for the underlying document, or - * {@code null} if it couldn't be found + * @return The {@link DocumentVersion} for the underlying document. */ public DocumentVersion documentVersion() { - firstIndexOfNonWsNonComment(); - if (!is('$')) { - return null; - } - while (is('$') && !isVersion()) { - // Skip this line - if (!jumpToLine(line())) { - return null; + for (Syntax.Statement statement : statements) { + if (statement instanceof Syntax.Statement.Control control + && control.value() instanceof Syntax.Node.Str str) { + String key = control.key().stringValue(); + if (key.equals("version")) { + String version = str.stringValue(); + Range range = document.rangeBetween(control.start(), control.end()); + return new DocumentVersion(range, version); + } + } else if (statement instanceof Syntax.Statement.Namespace) { + break; } - // Skip any ws and docs - nextNonWsNonComment(); - } - - // Found a non-control statement before version. - if (!is('$')) { - return null; - } - - Position start = currentPosition(); - skip(); // $ - skipAlpha(); // version - sp(); - if (!is(':')) { - return null; - } - skip(); // ':' - sp(); - int nodeStartCharacter = column() - 1; - CharSequence span = document.borrowSpan(position(), document.lineEnd(line() - 1) + 1); - if (span == null) { - return null; - } - - // TODO: Ew - Node node; - try { - node = StringNode.parseJsonWithComments(span.toString()); - } catch (Exception e) { - return null; - } - - if (node.isStringNode()) { - String version = node.expectStringNode().getValue(); - int end = nodeStartCharacter + version.length() + 2; // ? - Range range = LspAdapter.of(start.getLine(), start.getCharacter(), start.getLine(), end); - return new DocumentVersion(range, version); } - return null; - } - - /** - * @param sourceLocation The source location of the start of the trait - * application. The filename must be the same as - * the underlying document's (this is not checked), - * and the position must be on the {@code @} - * @return The range of the trait id from the {@code @} up to the trait's - * body or end, or null if the {@code sourceLocation} isn't on an {@code @} - * or there's no id next to the {@code @} - */ - public Range traitIdRange(SourceLocation sourceLocation) { - if (!jumpToSource(sourceLocation)) { - return null; - } - - if (!is('@')) { - return null; - } - - skip(); - - while (isShapeIdChar()) { - skip(); - } - - return new Range(LspAdapter.toPosition(sourceLocation), currentPosition()); - } - - /** - * Jumps the parser location to the start of the given {@code line}. - * - * @param line The line in the underlying document to jump to - * @return Whether the parser successfully jumped - */ - public boolean jumpToLine(int line) { - int idx = this.document.indexOfLine(line); - if (idx >= 0) { - this.rewind(idx, line + 1, 1); - return true; - } - return false; + return DocumentVersion.EMPTY; } /** @@ -320,13 +129,6 @@ public boolean jumpToSource(SourceLocation source) { return true; } - /** - * @return The current position of the parser - */ - public Position currentPosition() { - return new Position(line() - 1, column() - 1); - } - /** * @return The underlying document */ @@ -334,264 +136,6 @@ public Document getDocument() { return this.document; } - /** - * @param position The position in the document to check - * @return The context at that position - */ - public DocumentPositionContext determineContext(Position position) { - // TODO: Support additional contexts - // Also can compute these in one pass probably. - if (isTrait(position)) { - return DocumentPositionContext.TRAIT; - } else if (isMemberTarget(position)) { - return DocumentPositionContext.MEMBER_TARGET; - } else if (isShapeDef(position)) { - return DocumentPositionContext.SHAPE_DEF; - } else if (isMixin(position)) { - return DocumentPositionContext.MIXIN; - } else if (isUseTarget(position)) { - return DocumentPositionContext.USE_TARGET; - } else { - return DocumentPositionContext.OTHER; - } - } - - private boolean isTrait(Position position) { - if (!jumpToPosition(position)) { - return false; - } - CharSequence line = document.borrowLine(position.getLine()); - if (line == null) { - return false; - } - - for (int i = position.getCharacter() - 1; i >= 0; i--) { - char c = line.charAt(i); - if (c == '@') { - return true; - } - if (!isShapeIdChar()) { - return false; - } - } - return false; - } - - private boolean isMixin(Position position) { - int idx = document.indexOfPosition(position); - if (idx < 0) { - return false; - } - - int lastWithIndex = document.lastIndexOf("with", idx); - if (lastWithIndex < 0) { - return false; - } - - jumpToPosition(document.positionAtIndex(lastWithIndex)); - if (!isWs(-1)) { - return false; - } - skip(); - skip(); - skip(); - skip(); - - if (position() >= idx) { - return false; - } - - ws(); - - if (position() >= idx) { - return false; - } - - if (!is('[')) { - return false; - } - - skip(); - - while (position() < idx) { - if (!isWs() && !isShapeIdChar() && !is(',')) { - return false; - } - ws(); - skipShapeId(); - ws(); - if (is(',')) { - skip(); - ws(); - } - } - - return true; - } - - private boolean isShapeDef(Position position) { - int idx = document.indexOfPosition(position); - if (idx < 0) { - return false; - } - - if (!jumpToLine(position.getLine())) { - return false; - } - - if (position() >= idx) { - return false; - } - - if (!isShapeType()) { - return false; - } - - skipAlpha(); - - if (position() >= idx) { - return false; - } - - if (!isSp()) { - return false; - } - - sp(); - skipIdentifier(); - - return position() >= idx; - } - - private boolean isMemberTarget(Position position) { - int idx = document.indexOfPosition(position); - if (idx < 0) { - return false; - } - - int lastColonIndex = document.lastIndexOfOnLine(':', idx, position.getLine()); - if (lastColonIndex < 0) { - return false; - } - - if (!jumpToPosition(document.positionAtIndex(lastColonIndex))) { - return false; - } - - skip(); // ':' - sp(); - - if (position() >= idx) { - return true; - } - - skipShapeId(); - - return position() >= idx; - } - - private boolean isUseTarget(Position position) { - int idx = document.indexOfPosition(position); - if (idx < 0) { - return false; - } - int lineStartIdx = document.indexOfLine(document.lineOfIndex(idx)); - - int useIdx = nextIndexOfWithOnlyLeadingWs("use", lineStartIdx, idx); - if (useIdx < 0) { - return false; - } - - jumpToPosition(document.positionAtIndex(useIdx)); - - skip(); // u - skip(); // s - skip(); // e - - if (!isSp()) { - return false; - } - - sp(); - - skipShapeId(); - - return position() >= idx; - } - - private boolean jumpToPosition(Position position) { - int idx = this.document.indexOfPosition(position); - if (idx < 0) { - return false; - } - this.rewind(idx, position.getLine() + 1, position.getCharacter() + 1); - return true; - } - - private void skipAlpha() { - while (isAlpha()) { - skip(); - } - } - - private void skipIdentifier() { - if (isAlpha() || isUnder()) { - skip(); - } - while (isAlpha() || isDigit() || isUnder()) { - skip(); - } - } - - private boolean isIdentifierStart() { - return isAlpha() || isUnder(); - } - - private boolean isIdentifierChar() { - return isAlpha() || isUnder() || isDigit(); - } - - private boolean isAlpha() { - return Character.isAlphabetic(peek()); - } - - private boolean isUnder() { - return peek() == '_'; - } - - private boolean isDigit() { - return Character.isDigit(peek()); - } - - private boolean isUse() { - return is('u', 0) && is('s', 1) && is('e', 2); - } - - private boolean isVersion() { - return is('$', 0) && is('v', 1) && is('e', 2) && is('r', 3) && is('s', 4) && is('i', 5) && is('o', 6) - && is('n', 7) && (is(':', 8) || is(' ', 8) || is('\t', 8)); - - } - - private String getImport() { - if (!is(' ', 0) && !is('\t', 0)) { - // should be a space after use - return null; - } - - sp(); // skip space after use - - try { - return ParserUtils.parseRootShapeId(this); - } catch (Exception e) { - return null; - } - } - - private boolean is(char c, int offset) { - return peek(offset) == c; - } - private boolean is(char c) { return peek() == c; } @@ -620,91 +164,6 @@ private boolean isEof() { return is(EOF); } - private boolean isShapeIdChar() { - return isIdentifierChar() || is('#') || is('.') || is('$'); - } - - private void skipShapeId() { - while (isShapeIdChar()) { - skip(); - } - } - - private boolean isShapeIdChar(char c) { - return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; - } - - private boolean isNamespaceChar() { - return isIdentifierChar() || is('.'); - } - - private boolean isShapeType() { - CharSequence token = document.borrowToken(currentPosition()); - if (token == null) { - return false; - } - - return switch (token.toString()) { - case "structure", "operation", "string", "integer", "list", "map", "boolean", "enum", "union", "blob", - "byte", "short", "long", "float", "double", "timestamp", "intEnum", "document", "service", - "resource", "bigDecimal", "bigInteger" -> true; - default -> false; - }; - } - - private int firstIndexOfWithOnlyLeadingWs(String s) { - return nextIndexOfWithOnlyLeadingWs(s, 0, document.length()); - } - - private int nextIndexOfWithOnlyLeadingWs(String s, int start, int end) { - int searchFrom = start; - int previousSearchFrom; - do { - int idx = document.nextIndexOf(s, searchFrom); - if (idx < 0) { - return -1; - } - int lineStart = document.lastIndexOf(System.lineSeparator(), idx) + 1; - if (idx == lineStart) { - return idx; - } - CharSequence before = document.borrowSpan(lineStart, idx); - if (before == null) { - return -1; - } - if (before.chars().allMatch(Character::isWhitespace)) { - return idx; - } - previousSearchFrom = searchFrom; - searchFrom = idx + 1; - } while (previousSearchFrom != searchFrom && searchFrom < end); - return -1; - } - - private int firstIndexOfNonWsNonComment() { - reset(); - do { - ws(); - if (is('/')) { - consumeRemainingCharactersOnLine(); - } - } while (isWs()); - return position(); - } - - private void nextNonWsNonComment() { - do { - ws(); - if (is('/')) { - consumeRemainingCharactersOnLine(); - } - } while (isWs()); - } - - private void reset() { - rewind(0, 1, 1); - } - /** * Finds a contiguous range of non-whitespace characters starting from the given SourceLocation. * If the sourceLocation happens to be a whitespace character, it returns a Range representing that column. diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java deleted file mode 100644 index e3007332..00000000 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.document; - -/** - * Represents what kind of construct might exist at a certain position in a document. - */ -public enum DocumentPositionContext { - /** - * Within a trait id, that is anywhere from the {@code @} to the start of the - * trait's body, or its end (if there is no trait body). - */ - TRAIT, - - /** - * Within the target of a member. - */ - MEMBER_TARGET, - - /** - * Within a shape definition, specifically anywhere from the beginning of - * the shape type token, and the end of the shape name token. Does not - * include members. - */ - SHAPE_DEF, - - /** - * Within a mixed in shape, specifically in the {@code []} next to {@code with}. - */ - MIXIN, - - /** - * Within the target (shape id) of a {@code use} statement. - */ - USE_TARGET, - - /** - * An unknown or indeterminate position. - */ - OTHER -} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java deleted file mode 100644 index 1fe748e1..00000000 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.document; - -import org.eclipse.lsp4j.Range; - -/** - * A Shape definition OR reference within a document, including the range it occupies. - * - *

Shapes can be defined/referenced in various ways within a Smithy file, each - * corresponding to a specific {@link Kind}. For each kind, the range spans the - * shape name/id only. - */ -public record DocumentShape( - Range range, - CharSequence shapeName, - Kind kind, - DocumentShape targetReference -) { - public boolean isKind(Kind other) { - return this.kind.equals(other); - } - - public boolean hasMemberTarget() { - return isKind(Kind.DefinedMember) && targetReference() != null; - } - - /** - * The different kinds of {@link DocumentShape}s that can exist, corresponding to places - * that a shape definition or reference may appear. This is non-exhaustive (for now). - */ - public enum Kind { - DefinedShape, - DefinedMember, - Elided, - Targeted, - Inline - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java index da710cc3..a64512bb 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp.document; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The Smithy version of the document, including the range it occupies. @@ -13,4 +14,6 @@ * @param range The range of the version statement * @param version The literal text of the version value */ -public record DocumentVersion(Range range, String version) {} +public record DocumentVersion(Range range, String version) { + static final DocumentVersion EMPTY = new DocumentVersion(LspAdapter.origin(), ""); +} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java deleted file mode 100644 index 874cb048..00000000 --- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.handler; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.stream.Stream; -import org.eclipse.lsp4j.CompletionContext; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionItemKind; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.CompletionTriggerKind; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.TextEdit; -import org.eclipse.lsp4j.jsonrpc.CancelChecker; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.lsp.document.DocumentId; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentPositionContext; -import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.loader.Prelude; -import software.amazon.smithy.model.shapes.BlobShape; -import software.amazon.smithy.model.shapes.BooleanShape; -import software.amazon.smithy.model.shapes.ListShape; -import software.amazon.smithy.model.shapes.MapShape; -import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.SetShape; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.ShapeVisitor; -import software.amazon.smithy.model.shapes.StringShape; -import software.amazon.smithy.model.shapes.StructureShape; -import software.amazon.smithy.model.shapes.TimestampShape; -import software.amazon.smithy.model.shapes.UnionShape; -import software.amazon.smithy.model.traits.MixinTrait; -import software.amazon.smithy.model.traits.RequiredTrait; -import software.amazon.smithy.model.traits.TraitDefinition; - -/** - * Handles completion requests. - */ -public final class CompletionHandler { - // TODO: Handle keyword completions - private static final List KEYWORDS = Arrays.asList("bigDecimal", "bigInteger", "blob", "boolean", "byte", - "create", "collectionOperations", "delete", "document", "double", "errors", "float", "identifiers", "input", - "integer", "integer", "key", "list", "long", "map", "member", "metadata", "namespace", "operation", - "operations", - "output", "put", "read", "rename", "resource", "resources", "service", "set", "short", "string", - "structure", - "timestamp", "union", "update", "use", "value", "version"); - - private final Project project; - private final SmithyFile smithyFile; - - public CompletionHandler(Project project, SmithyFile smithyFile) { - this.project = project; - this.smithyFile = smithyFile; - } - - /** - * @param params The request params - * @return A list of possible completions - */ - public List handle(CompletionParams params, CancelChecker cc) { - // TODO: This method has to check for cancellation before using shared resources, - // and before performing expensive operations. If we have to change this, or do - // the same type of thing elsewhere, it would be nice to have some type of state - // machine abstraction or similar to make sure cancellation is properly checked. - if (cc.isCanceled()) { - return Collections.emptyList(); - } - - Position position = params.getPosition(); - CompletionContext completionContext = params.getContext(); - if (completionContext != null - && completionContext.getTriggerKind().equals(CompletionTriggerKind.Invoked) - && position.getCharacter() > 0) { - // When the trigger is 'Invoked', the position is the next character - position.setCharacter(position.getCharacter() - 1); - } - - if (cc.isCanceled()) { - return Collections.emptyList(); - } - - // TODO: Maybe we should only copy the token up to the current character - DocumentId id = smithyFile.document().copyDocumentId(position); - if (id == null || id.idSlice().isEmpty()) { - return Collections.emptyList(); - } - - if (cc.isCanceled()) { - return Collections.emptyList(); - } - - Optional modelResult = project.modelResult().getResult(); - if (modelResult.isEmpty()) { - return Collections.emptyList(); - } - Model model = modelResult.get(); - DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) - .determineContext(position); - - if (cc.isCanceled()) { - return Collections.emptyList(); - } - - return contextualShapes(model, context, smithyFile) - .filter(contextualMatcher(id, context)) - .mapMulti(completionsFactory(context, model, smithyFile, id)) - .toList(); - } - - private static BiConsumer> completionsFactory( - DocumentPositionContext context, - Model model, - SmithyFile smithyFile, - DocumentId id - ) { - TraitBodyVisitor visitor = new TraitBodyVisitor(model); - boolean useFullId = shouldMatchOnAbsoluteId(id, context); - return (shape, consumer) -> { - String shapeLabel = useFullId - ? shape.getId().toString() - : shape.getId().getName(); - - switch (context) { - case TRAIT -> { - String traitBody = shape.accept(visitor); - // Strip outside pair of brackets from any structure traits. - if (!traitBody.isEmpty() && traitBody.charAt(0) == '{') { - traitBody = traitBody.substring(1, traitBody.length() - 1); - } - - if (!traitBody.isEmpty()) { - CompletionItem traitWithMembersItem = createCompletion( - shapeLabel + "(" + traitBody + ")", shape.getId(), smithyFile, useFullId, id); - consumer.accept(traitWithMembersItem); - } - - if (shape.isStructureShape() && !shape.members().isEmpty()) { - shapeLabel += "()"; - } - CompletionItem defaultItem = createCompletion(shapeLabel, shape.getId(), smithyFile, useFullId, id); - consumer.accept(defaultItem); - } - case MEMBER_TARGET, MIXIN, USE_TARGET -> { - CompletionItem item = createCompletion(shapeLabel, shape.getId(), smithyFile, useFullId, id); - consumer.accept(item); - } - default -> { - } - } - }; - } - - private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, SmithyFile smithyFile) { - String importId = shapeId.toString(); - String importNamespace = shapeId.getNamespace(); - CharSequence currentNamespace = smithyFile.namespace(); - - if (importNamespace.contentEquals(currentNamespace) - || Prelude.isPreludeShape(shapeId) - || smithyFile.hasImport(importId)) { - return; - } - - TextEdit textEdit = getImportTextEdit(smithyFile, importId); - if (textEdit != null) { - completionItem.setAdditionalTextEdits(Collections.singletonList(textEdit)); - } - } - - private static TextEdit getImportTextEdit(SmithyFile smithyFile, String importId) { - String insertText = System.lineSeparator() + "use " + importId; - // We can only know where to put the import if there's already use statements, or a namespace - if (smithyFile.documentImports().isPresent()) { - Range importsRange = smithyFile.documentImports().get().importsRange(); - Range editRange = LspAdapter.point(importsRange.getEnd()); - return new TextEdit(editRange, insertText); - } else if (smithyFile.documentNamespace().isPresent()) { - Range namespaceStatementRange = smithyFile.documentNamespace().get().statementRange(); - Range editRange = LspAdapter.point(namespaceStatementRange.getEnd()); - return new TextEdit(editRange, insertText); - } - - return null; - } - - private static Stream contextualShapes(Model model, DocumentPositionContext context, SmithyFile smithyFile) { - return switch (context) { - case TRAIT -> model.getShapesWithTrait(TraitDefinition.class).stream(); - case MEMBER_TARGET -> model.shapes() - .filter(shape -> !shape.isMemberShape()) - .filter(shape -> !shape.hasTrait(TraitDefinition.class)); - case MIXIN -> model.getShapesWithTrait(MixinTrait.class).stream(); - case USE_TARGET -> model.shapes() - .filter(shape -> !shape.isMemberShape()) - .filter(shape -> !shape.getId().getNamespace().contentEquals(smithyFile.namespace())) - .filter(shape -> !smithyFile.hasImport(shape.getId().toString())); - default -> Stream.empty(); - }; - } - - private static Predicate contextualMatcher(DocumentId id, DocumentPositionContext context) { - String matchToken = id.copyIdValue().toLowerCase(); - if (shouldMatchOnAbsoluteId(id, context)) { - return (shape) -> shape.getId().toString().toLowerCase().startsWith(matchToken); - } else { - return (shape) -> shape.getId().getName().toLowerCase().startsWith(matchToken); - } - } - - private static boolean shouldMatchOnAbsoluteId(DocumentId id, DocumentPositionContext context) { - return context == DocumentPositionContext.USE_TARGET - || id.type() == DocumentId.Type.NAMESPACE - || id.type() == DocumentId.Type.ABSOLUTE_ID; - } - - private static CompletionItem createCompletion( - String label, - ShapeId shapeId, - SmithyFile smithyFile, - boolean useFullId, - DocumentId id - ) { - CompletionItem completionItem = new CompletionItem(label); - completionItem.setKind(CompletionItemKind.Class); - TextEdit textEdit = new TextEdit(id.range(), label); - completionItem.setTextEdit(Either.forLeft(textEdit)); - if (!useFullId) { - addTextEdits(completionItem, shapeId, smithyFile); - } - return completionItem; - } - - private static final class TraitBodyVisitor extends ShapeVisitor.Default { - private final Model model; - - TraitBodyVisitor(Model model) { - this.model = model; - } - - @Override - protected String getDefault(Shape shape) { - return ""; - } - - @Override - public String blobShape(BlobShape shape) { - return "\"\""; - } - - @Override - public String booleanShape(BooleanShape shape) { - return "true|false"; - } - - @Override - public String listShape(ListShape shape) { - return "[]"; - } - - @Override - public String mapShape(MapShape shape) { - return "{}"; - } - - @Override - public String setShape(SetShape shape) { - return "[]"; - } - - @Override - public String stringShape(StringShape shape) { - return "\"\""; - } - - @Override - public String structureShape(StructureShape shape) { - List entries = new ArrayList<>(); - for (MemberShape memberShape : shape.members()) { - if (memberShape.hasTrait(RequiredTrait.class)) { - Shape targetShape = model.expectShape(memberShape.getTarget()); - entries.add(memberShape.getMemberName() + ": " + targetShape.accept(this)); - } - } - return "{" + String.join(", ", entries) + "}"; - } - - @Override - public String timestampShape(TimestampShape shape) { - // TODO: Handle timestampFormat (which could indicate a numeric default) - return "\"\""; - } - - @Override - public String unionShape(UnionShape shape) { - return "{}"; - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java deleted file mode 100644 index 264960c4..00000000 --- a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.handler; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.function.Predicate; -import java.util.stream.Stream; -import org.eclipse.lsp4j.DefinitionParams; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.Position; -import software.amazon.smithy.lsp.document.DocumentId; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentPositionContext; -import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.loader.Prelude; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.traits.MixinTrait; -import software.amazon.smithy.model.traits.TraitDefinition; - -/** - * Handles go-to-definition requests. - */ -public final class DefinitionHandler { - private final Project project; - private final SmithyFile smithyFile; - - public DefinitionHandler(Project project, SmithyFile smithyFile) { - this.project = project; - this.smithyFile = smithyFile; - } - - /** - * @param params The request params - * @return A list of possible definition locations - */ - public List handle(DefinitionParams params) { - Position position = params.getPosition(); - DocumentId id = smithyFile.document().copyDocumentId(position); - if (id == null || id.idSlice().isEmpty()) { - return Collections.emptyList(); - } - - Optional modelResult = project.modelResult().getResult(); - if (modelResult.isEmpty()) { - return Collections.emptyList(); - } - - Model model = modelResult.get(); - DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) - .determineContext(position); - return contextualShapes(model, context) - .filter(contextualMatcher(smithyFile, id)) - .findFirst() - .map(Shape::getSourceLocation) - .map(LspAdapter::toLocation) - .map(Collections::singletonList) - .orElse(Collections.emptyList()); - } - - private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) { - String token = id.copyIdValue(); - if (id.type() == DocumentId.Type.ABSOLUTE_ID) { - return (shape) -> shape.getId().toString().equals(token); - } else { - return (shape) -> (Prelude.isPublicPreludeShape(shape) - || shape.getId().getNamespace().contentEquals(smithyFile.namespace()) - || smithyFile.hasImport(shape.getId().toString())) - && shape.getId().getName().equals(token); - } - } - - private static Stream contextualShapes(Model model, DocumentPositionContext context) { - return switch (context) { - case TRAIT -> model.getShapesWithTrait(TraitDefinition.class).stream(); - case MEMBER_TARGET -> model.shapes() - .filter(shape -> !shape.isMemberShape()) - .filter(shape -> !shape.hasTrait(TraitDefinition.class)); - case MIXIN -> model.getShapesWithTrait(MixinTrait.class).stream(); - default -> model.shapes().filter(shape -> !shape.isMemberShape()); - }; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java deleted file mode 100644 index d0cf640a..00000000 --- a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.handler; - -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Predicate; -import java.util.regex.Matcher; -import java.util.stream.Stream; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.Position; -import software.amazon.smithy.lsp.document.DocumentId; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentPositionContext; -import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.loader.Prelude; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; -import software.amazon.smithy.model.traits.MixinTrait; -import software.amazon.smithy.model.traits.TraitDefinition; -import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidationEvent; - -/** - * Handles hover requests. - */ -public final class HoverHandler { - private final Project project; - private final SmithyFile smithyFile; - - public HoverHandler(Project project, SmithyFile smithyFile) { - this.project = project; - this.smithyFile = smithyFile; - } - - /** - * @return A {@link Hover} instance with empty markdown content. - */ - public static Hover emptyContents() { - Hover hover = new Hover(); - hover.setContents(new MarkupContent("markdown", "")); - return hover; - } - - /** - * @param params The request params - * @param minimumSeverity The minimum severity of events to show - * @return The hover content - */ - public Hover handle(HoverParams params, Severity minimumSeverity) { - Hover hover = emptyContents(); - Position position = params.getPosition(); - DocumentId id = smithyFile.document().copyDocumentId(position); - if (id == null || id.idSlice().isEmpty()) { - return hover; - } - - ValidatedResult modelResult = project.modelResult(); - if (modelResult.getResult().isEmpty()) { - return hover; - } - - Model model = modelResult.getResult().get(); - DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) - .determineContext(position); - Optional matchingShape = contextualShapes(model, context) - .filter(contextualMatcher(smithyFile, id)) - .findFirst(); - - if (matchingShape.isEmpty()) { - return hover; - } - - Shape shapeToSerialize = matchingShape.get(); - - SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder() - .metadataFilter(key -> false) - .shapeFilter(s -> s.getId().equals(shapeToSerialize.getId())) - // TODO: If we remove the documentation trait in the serializer, - // it also gets removed from members. This causes weird behavior if - // there are applied traits (such as through mixins), where you get - // an empty apply because the documentation trait was removed - // .traitFilter(trait -> !trait.toShapeId().equals(DocumentationTrait.ID)) - .serializePrelude() - .build(); - Map serialized = serializer.serialize(model); - Path path = Paths.get(shapeToSerialize.getId().getNamespace() + ".smithy"); - if (!serialized.containsKey(path)) { - return hover; - } - - StringBuilder hoverContent = new StringBuilder(); - List validationEvents = modelResult.getValidationEvents().stream() - .filter(event -> event.getShapeId().isPresent()) - .filter(event -> event.getShapeId().get().equals(shapeToSerialize.getId())) - .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0) - .toList(); - if (!validationEvents.isEmpty()) { - for (ValidationEvent event : validationEvents) { - hoverContent.append("**") - .append(event.getSeverity()) - .append("**") - .append(": ") - .append(event.getMessage()); - } - hoverContent.append(System.lineSeparator()) - .append(System.lineSeparator()) - .append("---") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - } - - String serializedShape = serialized.get(path) - .substring(15) // remove '$version: "2.0"' - .trim() - .replaceAll(Matcher.quoteReplacement( - // Replace newline literals with actual newlines - System.lineSeparator() + System.lineSeparator()), System.lineSeparator()); - hoverContent.append(String.format(""" - ```smithy - %s - ``` - """, serializedShape)); - - // TODO: Add docs to a separate section of the hover content - // if (shapeToSerialize.hasTrait(DocumentationTrait.class)) { - // String docs = shapeToSerialize.expectTrait(DocumentationTrait.class).getValue(); - // hoverContent.append("\n---\n").append(docs); - // } - - MarkupContent content = new MarkupContent("markdown", hoverContent.toString()); - hover.setContents(content); - return hover; - } - - private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) { - String token = id.copyIdValue(); - if (id.type() == DocumentId.Type.ABSOLUTE_ID) { - return (shape) -> shape.getId().toString().equals(token); - } else { - return (shape) -> (Prelude.isPublicPreludeShape(shape) - || shape.getId().getNamespace().contentEquals(smithyFile.namespace()) - || smithyFile.hasImport(shape.getId().toString())) - && shape.getId().getName().equals(token); - } - } - - private Stream contextualShapes(Model model, DocumentPositionContext context) { - return switch (context) { - case TRAIT -> model.getShapesWithTrait(TraitDefinition.class).stream(); - case MEMBER_TARGET -> model.shapes() - .filter(shape -> !shape.isMemberShape()) - .filter(shape -> !shape.hasTrait(TraitDefinition.class)); - case MIXIN -> model.getShapesWithTrait(MixinTrait.class).stream(); - default -> model.shapes().filter(shape -> !shape.isMemberShape()); - }; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java new file mode 100644 index 00000000..cad276e3 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java @@ -0,0 +1,110 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; + +/** + * Provides access to a Smithy model used to model various builtin constructs + * of the Smithy language, such as metadata validators. + * + *

As a modeling language, Smithy is, unsurprisingly, good at modeling stuff. + * Instead of building a whole separate abstraction to provide completions and + * hover information for stuff like metadata validators, the language server uses + * a Smithy model for the structure and documentation. This means we can re-use the + * same mechanisms of model/node-traversal we do for regular models.

+ * + *

See the Smithy model for docs on the specific shapes.

+ */ +final class Builtins { + static final String NAMESPACE = "smithy.lang.server"; + + static final Model MODEL = Model.assembler() + .disableValidation() + .addImport(Builtins.class.getResource("builtins.smithy")) + .addImport(Builtins.class.getResource("control.smithy")) + .addImport(Builtins.class.getResource("metadata.smithy")) + .addImport(Builtins.class.getResource("members.smithy")) + .assemble() + .unwrap(); + + static final Map BUILTIN_SHAPES = Arrays.stream(BuiltinShape.values()) + .collect(Collectors.toMap( + builtinShape -> id(builtinShape.name()), + builtinShape -> builtinShape)); + + static final Shape CONTROL = MODEL.expectShape(id("BuiltinControl")); + + static final Shape METADATA = MODEL.expectShape(id("BuiltinMetadata")); + + static final Shape VALIDATORS = MODEL.expectShape(id("BuiltinValidators")); + + static final Shape SHAPE_MEMBER_TARGETS = MODEL.expectShape(id("ShapeMemberTargets")); + + static final Map VALIDATOR_CONFIG_MAPPING = VALIDATORS.members().stream() + .collect(Collectors.toMap( + MemberShape::getMemberName, + memberShape -> memberShape.getTarget())); + + private Builtins() { + } + + /** + * Shapes in the builtin model that require some custom processing by consumers. + * + *

Some values are special - they don't correspond to a specific shape type, + * can't be represented by a Smithy model, or have some known constraints that + * aren't as efficient to model. These values get their own dedicated shape in + * the builtin model, corresponding to the names of this enum.

+ */ + enum BuiltinShape { + SmithyIdlVersion, + AnyNamespace, + ValidatorName, + AnyShape, + AnyTrait, + AnyMixin, + AnyString, + AnyError, + AnyOperation, + AnyResource, + AnyMemberTarget + } + + static Shape getMetadataValue(String metadataKey) { + return METADATA.getMember(metadataKey) + .map(memberShape -> MODEL.expectShape(memberShape.getTarget())) + .orElse(null); + } + + static StructureShape getMembersForShapeType(String shapeType) { + return SHAPE_MEMBER_TARGETS.getMember(shapeType) + .map(memberShape -> MODEL.expectShape(memberShape.getTarget(), StructureShape.class)) + .orElse(null); + } + + static Shape getMemberTargetForShapeType(String shapeType, String memberName) { + StructureShape memberTargets = getMembersForShapeType(shapeType); + if (memberTargets == null) { + return null; + } + + return memberTargets.getMember(memberName) + .map(memberShape -> MODEL.expectShape(memberShape.getTarget())) + .orElse(null); + } + + private static ShapeId id(String name) { + return ShapeId.fromParts(NAMESPACE, name); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java b/src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java new file mode 100644 index 00000000..125356ea --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java @@ -0,0 +1,92 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Set; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.Project; + +/** + * Simple POJO capturing common properties that completers need. + */ +final class CompleterContext { + private final String matchToken; + private final Range insertRange; + private final Project project; + private Set exclude = Set.of(); + private CompletionItemKind literalKind = CompletionItemKind.Field; + + private CompleterContext(String matchToken, Range insertRange, Project project) { + this.matchToken = matchToken; + this.insertRange = insertRange; + this.project = project; + } + + /** + * @param id The id at the cursor position. + * @param insertRange The range to insert completion text in. + * @param project The project the completion was triggered in. + * @return A new completer context. + */ + static CompleterContext create(DocumentId id, Range insertRange, Project project) { + String matchToken = getMatchToken(id); + return new CompleterContext(matchToken, insertRange, project); + } + + private static String getMatchToken(DocumentId id) { + return id != null + ? id.copyIdValue().toLowerCase() + : ""; + } + + /** + * @return The token to match candidates against. + */ + String matchToken() { + return matchToken; + } + + /** + * @return The range to insert completion text. + */ + Range insertRange() { + return insertRange; + } + + /** + * @return The project the completion was triggered in. + */ + Project project() { + return project; + } + + /** + * @return The set of tokens to exclude. + */ + Set exclude() { + return exclude; + } + + CompleterContext withExclude(Set exclude) { + this.exclude = exclude; + return this; + } + + /** + * @return The kind of completion to use for {@link CompletionCandidates.Literals}, + * which will be displayed in the client. + */ + CompletionItemKind literalKind() { + return literalKind; + } + + CompleterContext withLiteralKind(CompletionItemKind literalKind) { + this.literalKind = literalKind; + return this; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java new file mode 100644 index 00000000..44b2fa8b --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java @@ -0,0 +1,263 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import software.amazon.smithy.lsp.util.StreamUtils; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.EnumShape; +import software.amazon.smithy.model.shapes.IntEnumShape; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.DefaultTrait; +import software.amazon.smithy.model.traits.IdRefTrait; + +/** + * Candidates for code completions. + * + *

There are different kinds of completion candidates, each of which may + * need to be represented differently, filtered, and/or mapped to IDE-specific + * data structures in their own way.

+ */ +sealed interface CompletionCandidates { + Constant NONE = new Constant(""); + Constant EMPTY_STRING = new Constant("\"\""); + Constant EMPTY_OBJ = new Constant("{}"); + Constant EMPTY_ARR = new Constant("[]"); + Literals BOOL = new Literals(List.of("true", "false")); + Literals KEYWORD = new Literals(List.of( + "metadata", "namespace", "use", + "blob", "boolean", "string", "byte", "short", "integer", "long", "float", "double", + "bigInteger", "bigDecimal", "timestamp", "document", "enum", "intEnum", + "list", "map", "structure", "union", + "service", "resource", "operation", + "apply")); + Literals BUILTIN_CONTROLS = new Literals(Builtins.CONTROL.members().stream() + .map(member -> "$" + member.getMemberName() + ": " + defaultCandidates(member).value()) + .toList()); + Literals BUILTIN_METADATA = new Literals(Builtins.METADATA.members().stream() + .map(member -> member.getMemberName() + " = []") + .toList()); + Labeled SMITHY_IDL_VERSION = new Labeled(Stream.of("1.0", "2.0") + .collect(StreamUtils.toWrappedMap())); + Labeled VALIDATOR_NAMES = new Labeled(Builtins.VALIDATOR_CONFIG_MAPPING.keySet().stream() + .collect(StreamUtils.toWrappedMap())); + + /** + * @apiNote This purposefully does not handle {@link software.amazon.smithy.lsp.language.Builtins.BuiltinShape} + * as it is meant to be used for member target default values. + * + * @param shape The shape to get candidates for. + * @return A constant value corresponding to the 'default' or 'empty' value + * of a shape. + */ + static Constant defaultCandidates(Shape shape) { + if (shape.hasTrait(DefaultTrait.class)) { + DefaultTrait defaultTrait = shape.expectTrait(DefaultTrait.class); + return new Constant(Node.printJson(defaultTrait.toNode())); + } + + if (shape.isBlobShape() || (shape.isStringShape() && !shape.hasTrait(IdRefTrait.class))) { + return EMPTY_STRING; + } else if (ShapeSearch.isObjectShape(shape)) { + return EMPTY_OBJ; + } else if (shape.isListShape()) { + return EMPTY_ARR; + } else { + return NONE; + } + } + + /** + * @param result The search result to get candidates from. + * @return The completion candidates for {@code result}. + */ + static CompletionCandidates fromSearchResult(NodeSearch.Result result) { + return switch (result) { + case NodeSearch.Result.TerminalShape(Shape shape, var ignored) -> + terminalCandidates(shape); + + case NodeSearch.Result.ObjectKey(var ignored, Shape shape, Model model) -> + membersCandidates(model, shape); + + case NodeSearch.Result.ObjectShape(var ignored, Shape shape, Model model) -> + membersCandidates(model, shape); + + case NodeSearch.Result.ArrayShape(var ignored, ListShape shape, Model model) -> + model.getShape(shape.getMember().getTarget()) + .map(CompletionCandidates::terminalCandidates) + .orElse(NONE); + + default -> NONE; + }; + } + + /** + * @param idlPosition The position in the idl to get candidates for. + * @return The candidates for shape completions. + */ + static CompletionCandidates shapeCandidates(IdlPosition idlPosition) { + return switch (idlPosition) { + case IdlPosition.UseTarget ignored -> Shapes.USE_TARGET; + case IdlPosition.TraitId ignored -> Shapes.TRAITS; + case IdlPosition.Mixin ignored -> Shapes.MIXINS; + case IdlPosition.ForResource ignored -> Shapes.RESOURCE_SHAPES; + case IdlPosition.MemberTarget ignored -> Shapes.MEMBER_TARGETABLE; + case IdlPosition.ApplyTarget ignored -> Shapes.ANY_SHAPE; + case IdlPosition.NodeMemberTarget nodeMemberTarget -> fromSearchResult( + ShapeSearch.searchNodeMemberTarget(nodeMemberTarget)); + default -> CompletionCandidates.NONE; + }; + } + + /** + * @param model The model that {@code shape} is a part of. + * @param shape The shape to get member candidates for. + * @return If a struct or union shape, returns {@link Members} candidates. + * Otherwise, {@link #NONE}. + */ + static CompletionCandidates membersCandidates(Model model, Shape shape) { + if (shape.isStructureShape() || shape.isUnionShape()) { + return new Members(shape.getAllMembers().entrySet().stream() + .collect(StreamUtils.mappingValue(member -> model.getShape(member.getTarget()) + .map(CompletionCandidates::defaultCandidates) + .orElse(NONE)))); + } else if (shape instanceof MapShape mapShape) { + return model.getShape(mapShape.getKey().getTarget()) + .flatMap(Shape::asEnumShape) + .map(CompletionCandidates::terminalCandidates) + .orElse(NONE); + } + return NONE; + } + + private static CompletionCandidates terminalCandidates(Shape shape) { + Builtins.BuiltinShape builtinShape = Builtins.BUILTIN_SHAPES.get(shape.getId()); + if (builtinShape != null) { + return forBuiltin(builtinShape); + } + + return switch (shape) { + case EnumShape enumShape -> new Labeled(enumShape.getEnumValues() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> "\"" + entry.getValue() + "\""))); + + case IntEnumShape intEnumShape -> new Labeled(intEnumShape.getEnumValues() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().toString()))); + + case Shape s when s.hasTrait(IdRefTrait.class) -> Shapes.ANY_SHAPE; + + case Shape s when s.isBooleanShape() -> BOOL; + + default -> defaultCandidates(shape); + }; + } + + private static CompletionCandidates forBuiltin(Builtins.BuiltinShape builtinShape) { + return switch (builtinShape) { + case SmithyIdlVersion -> SMITHY_IDL_VERSION; + case AnyNamespace -> Custom.NAMESPACE_FILTER; + case ValidatorName -> Custom.VALIDATOR_NAME; + case AnyShape -> Shapes.ANY_SHAPE; + case AnyTrait -> Shapes.TRAITS; + case AnyMixin -> Shapes.MIXINS; + case AnyString -> Shapes.STRING_SHAPES; + case AnyError -> Shapes.ERROR_SHAPES; + case AnyOperation -> Shapes.OPERATION_SHAPES; + case AnyResource -> Shapes.RESOURCE_SHAPES; + case AnyMemberTarget -> Shapes.MEMBER_TARGETABLE; + }; + } + + /** + * A single, constant-value completion, like an empty string, for example. + * + * @param value The completion value. + */ + record Constant(String value) implements CompletionCandidates {} + + /** + * Multiple values to be completed as literals, like keywords. + * + * @param literals The completion values. + */ + record Literals(List literals) implements CompletionCandidates {} + + /** + * Multiple label -> value pairs, where the label is displayed to the user, + * and may be used for matching, and the value is the literal text to complete. + * + *

For example, completing enum value in a trait may display and match on the + * name, like FOO, but complete the actual value, like "foo". + * + * @param labeled The labeled completion values. + */ + record Labeled(Map labeled) implements CompletionCandidates {} + + /** + * Multiple name -> constant pairs, where the name corresponds to a member + * name, and the constant is a default/empty value for that member. + * + *

For example, shape members can be completed as {@code name: constant}. + * + * @param members The members completion values. + */ + record Members(Map members) implements CompletionCandidates {} + + /** + * Multiple member names to complete as elided members. + * + * @apiNote These are distinct from {@link Literals} because they may have + * custom filtering/mapping, and may appear _with_ {@link Literals} in an + * {@link And}. + * + * @param memberNames The member names completion values. + */ + record ElidedMembers(Collection memberNames) implements CompletionCandidates {} + + /** + * A combination of two sets of completion candidates, of possibly different + * types. + * + * @param one The first set of completion candidates. + * @param two The second set of completion candidates. + */ + record And(CompletionCandidates one, CompletionCandidates two) implements CompletionCandidates {} + + /** + * Shape completion candidates, each corresponding to a different set of + * shapes that will be selected from the model. + */ + enum Shapes implements CompletionCandidates { + ANY_SHAPE, + USE_TARGET, + TRAITS, + MIXINS, + STRING_SHAPES, + ERROR_SHAPES, + RESOURCE_SHAPES, + OPERATION_SHAPES, + MEMBER_TARGETABLE + } + + /** + * Candidates that require a custom computation to generate, lazily. + */ + enum Custom implements CompletionCandidates { + NAMESPACE_FILTER, + VALIDATOR_NAME, + PROJECT_NAMESPACES, + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java new file mode 100644 index 00000000..48fc881e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -0,0 +1,235 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.eclipse.lsp4j.CompletionContext; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.CompletionTriggerKind; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.jsonrpc.CancelChecker; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.StructureShape; + +/** + * Handles completion requests for the Smithy IDL. + */ +public final class CompletionHandler { + private final Project project; + private final IdlFile smithyFile; + + public CompletionHandler(Project project, IdlFile smithyFile) { + this.project = project; + this.smithyFile = smithyFile; + } + + /** + * @param params The request params + * @return A list of possible completions + */ + public List handle(CompletionParams params, CancelChecker cc) { + // TODO: This method has to check for cancellation before using shared resources, + // and before performing expensive operations. If we have to change this, or do + // the same type of thing elsewhere, it would be nice to have some type of state + // machine abstraction or similar to make sure cancellation is properly checked. + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + Position position = getTokenPosition(params); + DocumentId id = smithyFile.document().copyDocumentId(position); + Range insertRange = getInsertRange(id, position); + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + Syntax.IdlParseResult parseResult = smithyFile.getParse(); + int documentIndex = smithyFile.document().indexOfPosition(position); + IdlPosition idlPosition = StatementView.createAt(parseResult, documentIndex) + .map(IdlPosition::of) + .orElse(null); + + if (cc.isCanceled() || idlPosition == null) { + return Collections.emptyList(); + } + + CompleterContext context = CompleterContext.create(id, insertRange, project); + + return switch (idlPosition) { + case IdlPosition.ControlKey ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Constant)) + .getCompletionItems(CompletionCandidates.BUILTIN_CONTROLS); + + case IdlPosition.MetadataKey ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Field)) + .getCompletionItems(CompletionCandidates.BUILTIN_METADATA); + + case IdlPosition.StatementKeyword ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Keyword)) + .getCompletionItems(CompletionCandidates.KEYWORD); + + case IdlPosition.Namespace ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Module)) + .getCompletionItems(CompletionCandidates.Custom.PROJECT_NAMESPACES); + + case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, context); + + case IdlPosition.MemberName memberName -> memberNameCompletions(memberName, context); + + default -> modelBasedCompletions(idlPosition, context); + }; + } + + private static Position getTokenPosition(CompletionParams params) { + Position position = params.getPosition(); + CompletionContext context = params.getContext(); + if (context != null + && context.getTriggerKind() == CompletionTriggerKind.Invoked + && position.getCharacter() > 0) { + position.setCharacter(position.getCharacter() - 1); + } + return position; + } + + private static Range getInsertRange(DocumentId id, Position position) { + if (id == null || id.idSlice().isEmpty()) { + // When we receive the completion request, we're always on the + // character either after what has just been typed, or we're in + // empty space and have manually triggered a completion. To account + // for this when extracting the DocumentId the cursor is on, we move + // the cursor back one. But when we're not on a DocumentId (as is the case here), + // we want to insert any completion text at the current cursor position. + Position point = new Position(position.getLine(), position.getCharacter() + 1); + return LspAdapter.point(point); + } + return id.range(); + } + + private List metadataValueCompletions( + IdlPosition.MetadataValue metadataValue, + CompleterContext context + ) { + var result = ShapeSearch.searchMetadataValue(metadataValue); + Set excludeKeys = result.getOtherPresentKeys(); + CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); + return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates); + } + + private List modelBasedCompletions(IdlPosition idlPosition, CompleterContext context) { + if (project.modelResult().getResult().isEmpty()) { + return List.of(); + } + + Model model = project.modelResult().getResult().get(); + + if (idlPosition instanceof IdlPosition.ElidedMember elidedMember) { + return elidedMemberCompletions(elidedMember, context, model); + } else if (idlPosition instanceof IdlPosition.TraitValue traitValue) { + return traitValueCompletions(traitValue, context, model); + } + + CompletionCandidates candidates = CompletionCandidates.shapeCandidates(idlPosition); + if (candidates instanceof CompletionCandidates.Shapes shapes) { + return new ShapeCompleter(idlPosition, model, context).getCompletionItems(shapes); + } else if (candidates != CompletionCandidates.NONE) { + return new SimpleCompleter(context).getCompletionItems(candidates); + } + + return List.of(); + } + + private List elidedMemberCompletions( + IdlPosition.ElidedMember elidedMember, + CompleterContext context, + Model model + ) { + CompletionCandidates candidates = getElidableMemberCandidates(elidedMember, model); + if (candidates == null) { + return List.of(); + } + + Set otherMembers = elidedMember.view().otherMemberNames(); + return new SimpleCompleter(context.withExclude(otherMembers)).getCompletionItems(candidates); + } + + private List traitValueCompletions( + IdlPosition.TraitValue traitValue, + CompleterContext context, + Model model + ) { + var result = ShapeSearch.searchTraitValue(traitValue, model); + Set excludeKeys = result.getOtherPresentKeys(); + CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); + return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates); + } + + private List memberNameCompletions(IdlPosition.MemberName memberName, CompleterContext context) { + Syntax.Statement.ShapeDef shapeDef = memberName.view().nearestShapeDefBefore(); + + if (shapeDef == null) { + return List.of(); + } + + String shapeType = shapeDef.shapeType().stringValue(); + StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType); + + CompletionCandidates candidates = null; + if (shapeMembersDef != null) { + candidates = CompletionCandidates.membersCandidates(Builtins.MODEL, shapeMembersDef); + } + + if (project.modelResult().getResult().isPresent()) { + CompletionCandidates elidedCandidates = getElidableMemberCandidates( + memberName, + project.modelResult().getResult().get()); + + if (elidedCandidates != null) { + candidates = candidates == null + ? elidedCandidates + : new CompletionCandidates.And(candidates, elidedCandidates); + } + } + + if (candidates == null) { + return List.of(); + } + + Set otherMembers = memberName.view().otherMemberNames(); + return new SimpleCompleter(context.withExclude(otherMembers)).getCompletionItems(candidates); + } + + private CompletionCandidates getElidableMemberCandidates(IdlPosition idlPosition, Model model) { + Set memberNames = new HashSet<>(); + + var forResourceAndMixins = idlPosition.view().nearestForResourceAndMixinsBefore(); + ShapeSearch.findResource(forResourceAndMixins.forResource(), idlPosition.view(), model) + .ifPresent(resourceShape -> { + memberNames.addAll(resourceShape.getIdentifiers().keySet()); + memberNames.addAll(resourceShape.getProperties().keySet()); + }); + ShapeSearch.findMixins(forResourceAndMixins.mixins(), idlPosition.view(), model) + .forEach(mixinShape -> memberNames.addAll(mixinShape.getMemberNames())); + + if (memberNames.isEmpty()) { + return null; + } + + return new CompletionCandidates.ElidedMembers(memberNames); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java new file mode 100644 index 00000000..30e066fd --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; + +/** + * Handles go-to-definition requests for the Smithy IDL. + */ +public final class DefinitionHandler { + final Project project; + final IdlFile smithyFile; + + public DefinitionHandler(Project project, IdlFile smithyFile) { + this.project = project; + this.smithyFile = smithyFile; + } + + /** + * @param params The request params + * @return A list of possible definition locations + */ + public List handle(DefinitionParams params) { + Position position = params.getPosition(); + DocumentId id = smithyFile.document().copyDocumentId(position); + if (id == null || id.idSlice().isEmpty()) { + return Collections.emptyList(); + } + + Optional modelResult = project.modelResult().getResult(); + if (modelResult.isEmpty()) { + return Collections.emptyList(); + } + + Model model = modelResult.get(); + Syntax.IdlParseResult parseResult = smithyFile.getParse(); + int documentIndex = smithyFile.document().indexOfPosition(position); + return StatementView.createAt(parseResult, documentIndex) + .map(IdlPosition::of) + .flatMap(idlPosition -> ShapeSearch.findShapeDefinition(idlPosition, id, model)) + .map(LspAdapter::toLocation) + .map(List::of) + .orElse(List.of()); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java new file mode 100644 index 00000000..7aa47fa0 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java @@ -0,0 +1,63 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.List; +import java.util.function.Consumer; +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.SymbolInformation; +import org.eclipse.lsp4j.SymbolKind; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; + +public record DocumentSymbolHandler(Document document, List statements) { + /** + * @return A list of DocumentSymbol + */ + public List> handle() { + return statements.stream() + .mapMulti(this::addSymbols) + .toList(); + } + + private void addSymbols(Syntax.Statement statement, Consumer> consumer) { + switch (statement) { + case Syntax.Statement.TraitApplication app -> addSymbol(consumer, app.id(), SymbolKind.Class); + + case Syntax.Statement.ShapeDef def -> addSymbol(consumer, def.shapeName(), SymbolKind.Class); + + case Syntax.Statement.EnumMemberDef def -> addSymbol(consumer, def.name(), SymbolKind.Enum); + + case Syntax.Statement.ElidedMemberDef def -> addSymbol(consumer, def.name(), SymbolKind.Property); + + case Syntax.Statement.MemberDef def -> { + addSymbol(consumer, def.name(), SymbolKind.Property); + if (def.target() != null) { + addSymbol(consumer, def.target(), SymbolKind.Class); + } + } + default -> { + } + } + } + + private void addSymbol( + Consumer> consumer, + Syntax.Ident ident, + SymbolKind symbolKind + ) { + Range range = LspAdapter.identRange(ident, document); + if (range == null) { + return; + } + + DocumentSymbol symbol = new DocumentSymbol(ident.stringValue(), symbolKind, range, range); + consumer.accept(Either.forRight(symbol)); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java new file mode 100644 index 00000000..70082804 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java @@ -0,0 +1,177 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Map; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * An abstraction to allow computing the target of a member dynamically, instead + * of just using what's in the model, when traversing a model using a + * {@link NodeCursor}. + * + *

For example, the examples trait has two members, input and output, whose + * values are represented by the target operation's input and output shapes, + * respectively. In the model however, these members just target Document shapes, + * because we don't have a way to directly model the relationship. It would be + * really useful for customers to get e.g. completions despite that, which is the + * purpose of this interface.

+ * + * @implNote One of the ideas behind this is that you should not have to pay for + * computing the member target unless necessary. + */ +sealed interface DynamicMemberTarget { + /** + * @param cursor The cursor being used to traverse the model. + * @param model The model being traversed. + * @return The target of the member shape at the cursor's current position. + */ + Shape getTarget(NodeCursor cursor, Model model); + + static Map forTrait(Shape traitShape, IdlPosition.TraitValue traitValue) { + Syntax.IdlParseResult syntaxInfo = traitValue.view().parseResult(); + return switch (traitShape.getId().toString()) { + case "smithy.test#smokeTests" -> Map.of( + ShapeId.from("smithy.test#SmokeTestCase$params"), + new OperationInput(traitValue), + ShapeId.from("smithy.test#SmokeTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); + + case "smithy.api#examples" -> Map.of( + ShapeId.from("smithy.api#Example$input"), + new OperationInput(traitValue), + ShapeId.from("smithy.api#Example$output"), + new OperationOutput(traitValue)); + + case "smithy.test#httpRequestTests" -> Map.of( + ShapeId.from("smithy.test#HttpRequestTestCase$params"), + new OperationInput(traitValue), + ShapeId.from("smithy.test#HttpRequestTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); + + case "smithy.test#httpResponseTests" -> Map.of( + ShapeId.from("smithy.test#HttpResponseTestCase$params"), + new OperationOutput(traitValue), + ShapeId.from("smithy.test#HttpResponseTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); + + default -> null; + }; + } + + static Map forMetadata(String metadataKey) { + return switch (metadataKey) { + case "validators" -> Map.of( + ShapeId.from("smithy.lang.server#Validator$configuration"), new MappedDependent( + "name", + Builtins.VALIDATOR_CONFIG_MAPPING)); + default -> null; + }; + } + + /** + * Computes the input shape of the operation targeted by {@code traitValue}, + * to use as the member target. + * + * @param traitValue The position, in the applied trait value. + */ + record OperationInput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget { + @Override + public Shape getTarget(NodeCursor cursor, Model model) { + return ShapeSearch.findTraitTarget(traitValue, model) + .flatMap(Shape::asOperationShape) + .flatMap(operationShape -> model.getShape(operationShape.getInputShape())) + .orElse(null); + } + } + + /** + * Computes the output shape of the operation targeted by {@code traitValue}, + * to use as the member target. + * + * @param traitValue The position, in the applied trait value. + */ + record OperationOutput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget { + @Override + public Shape getTarget(NodeCursor cursor, Model model) { + return ShapeSearch.findTraitTarget(traitValue, model) + .flatMap(Shape::asOperationShape) + .flatMap(operationShape -> model.getShape(operationShape.getOutputShape())) + .orElse(null); + } + } + + /** + * Computes the value of another member in the node, {@code memberName}, + * using that as the id of the target shape. + * + * @param memberName The name of the other member to compute the value of. + * @param parseResult The parse result of the file the node is within. + */ + record ShapeIdDependent(String memberName, Syntax.IdlParseResult parseResult) implements DynamicMemberTarget { + @Override + public Shape getTarget(NodeCursor cursor, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor); + if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) { + String id = str.stringValue(); + return ShapeSearch.findShape(parseResult, id, model).orElse(null); + } + return null; + } + } + + /** + * Computes the value of another member in the node, {@code memberName}, + * and looks up the id of the target shape from {@code mapping} using that + * value. + * + * @param memberName The name of the member to compute the value of. + * @param mapping A mapping of {@code memberName} values to corresponding + * member target ids. + */ + record MappedDependent(String memberName, Map mapping) implements DynamicMemberTarget { + @Override + public Shape getTarget(NodeCursor cursor, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor); + if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) { + String value = str.stringValue(); + ShapeId targetId = mapping.get(value); + if (targetId != null) { + return model.getShape(targetId).orElse(null); + } + } + return null; + } + } + + // Note: This is suboptimal in isolation, but it should be called rarely in + // comparison to parsing or NodeCursor construction, which are optimized for + // speed and memory usage (instead of key lookup), and the number of keys + // is assumed to be low in most cases. + private static Syntax.Node.Kvp findMatchingKvp(String keyName, NodeCursor cursor) { + // This will be called after skipping a ValueForKey, so that will be previous + if (!cursor.hasPrevious()) { + // TODO: Log + return null; + } + NodeCursor.Edge edge = cursor.previous(); + if (edge instanceof NodeCursor.ValueForKey(var ignored, Syntax.Node.Kvps parent)) { + for (Syntax.Node.Kvp kvp : parent.kvps()) { + String key = kvp.key().stringValue(); + if (!keyName.equals(key)) { + continue; + } + + return kvp; + } + } + return null; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java new file mode 100644 index 00000000..79ba7073 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -0,0 +1,250 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; +import software.amazon.smithy.model.traits.DocumentationTrait; +import software.amazon.smithy.model.traits.IdRefTrait; +import software.amazon.smithy.model.traits.StringTrait; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; + +/** + * Handles hover requests for the Smithy IDL. + */ +public final class HoverHandler { + /** + * Empty markdown hover content. + */ + public static final Hover EMPTY = new Hover(new MarkupContent("markdown", "")); + + private final Project project; + private final IdlFile smithyFile; + private final Severity minimumSeverity; + + /** + * @param project Project the hover is in + * @param smithyFile Smithy file the hover is in + * @param minimumSeverity Minimum severity of validation events to show + */ + public HoverHandler(Project project, IdlFile smithyFile, Severity minimumSeverity) { + this.project = project; + this.smithyFile = smithyFile; + this.minimumSeverity = minimumSeverity; + } + + /** + * @param params The request params + * @return The hover content + */ + public Hover handle(HoverParams params) { + Position position = params.getPosition(); + DocumentId id = smithyFile.document().copyDocumentId(position); + if (id == null || id.idSlice().isEmpty()) { + return EMPTY; + } + + Syntax.IdlParseResult parseResult = smithyFile.getParse(); + int documentIndex = smithyFile.document().indexOfPosition(position); + IdlPosition idlPosition = StatementView.createAt(parseResult, documentIndex) + .map(IdlPosition::of) + .orElse(null); + + return switch (idlPosition) { + case IdlPosition.ControlKey ignored -> Builtins.CONTROL.getMember(id.copyIdValueForElidedMember()) + .map(HoverHandler::withShapeDocs) + .orElse(EMPTY); + + case IdlPosition.MetadataKey ignored -> Builtins.METADATA.getMember(id.copyIdValue()) + .map(HoverHandler::withShapeDocs) + .orElse(EMPTY); + + case IdlPosition.MetadataValue metadataValue -> takeShapeReference( + ShapeSearch.searchMetadataValue(metadataValue)) + .map(HoverHandler::withShapeDocs) + .orElse(EMPTY); + + case null -> EMPTY; + + default -> modelSensitiveHover(id, idlPosition); + }; + } + + private static Optional takeShapeReference(NodeSearch.Result result) { + return switch (result) { + case NodeSearch.Result.TerminalShape(Shape shape, var ignored) + when shape.hasTrait(IdRefTrait.class) -> Optional.of(shape); + + case NodeSearch.Result.ObjectKey(NodeCursor.Key key, Shape containerShape, var ignored) + when !containerShape.isMapShape() -> containerShape.getMember(key.name()); + + default -> Optional.empty(); + }; + } + + private Hover modelSensitiveHover(DocumentId id, IdlPosition idlPosition) { + ValidatedResult validatedModel = project.modelResult(); + if (validatedModel.getResult().isEmpty()) { + return EMPTY; + } + + Model model = validatedModel.getResult().get(); + Optional matchingShape = switch (idlPosition) { + // TODO: Handle resource ids and properties. This only works for mixins right now. + case IdlPosition.ElidedMember elidedMember -> + ShapeSearch.findElidedMemberParent(elidedMember, id, model) + .flatMap(shape -> shape.getMember(id.copyIdValueForElidedMember())); + + default -> ShapeSearch.findShapeDefinition(idlPosition, id, model); + }; + + if (matchingShape.isEmpty()) { + return EMPTY; + } + + return withShapeAndValidationEvents(matchingShape.get(), model, validatedModel.getValidationEvents()); + } + + private Hover withShapeAndValidationEvents(Shape shape, Model model, List events) { + String serializedShape = switch (shape) { + case MemberShape memberShape -> serializeMember(memberShape); + default -> serializeShape(model, shape); + }; + + if (serializedShape == null) { + return EMPTY; + } + + String serializedValidationEvents = serializeValidationEvents(events, shape); + + String hoverContent = String.format(""" + %s + ```smithy + %s + ``` + """, serializedValidationEvents, serializedShape); + + // TODO: Add docs to a separate section of the hover content + // if (shapeToSerialize.hasTrait(DocumentationTrait.class)) { + // String docs = shapeToSerialize.expectTrait(DocumentationTrait.class).getValue(); + // hoverContent.append("\n---\n").append(docs); + // } + + return withMarkupContents(hoverContent); + } + + private String serializeValidationEvents(List events, Shape shape) { + StringBuilder serialized = new StringBuilder(); + List applicableEvents = events.stream() + .filter(event -> event.getShapeId().isPresent()) + .filter(event -> event.getShapeId().get().equals(shape.getId())) + .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0) + .toList(); + + if (!applicableEvents.isEmpty()) { + for (ValidationEvent event : applicableEvents) { + serialized.append("**") + .append(event.getSeverity()) + .append("**") + .append(": ") + .append(event.getMessage()); + } + serialized.append(System.lineSeparator()) + .append(System.lineSeparator()) + .append("---") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + } + + return serialized.toString(); + } + + private static Hover withShapeDocs(Shape shape) { + return shape.getTrait(DocumentationTrait.class) + .map(StringTrait::getValue) + .map(HoverHandler::withMarkupContents) + .orElse(EMPTY); + } + + private static Hover withMarkupContents(String text) { + return new Hover(new MarkupContent("markdown", text)); + } + + private static String serializeMember(MemberShape memberShape) { + StringBuilder contents = new StringBuilder(); + contents.append("namespace") + .append(" ") + .append(memberShape.getId().getNamespace()) + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + for (var trait : memberShape.getAllTraits().values()) { + if (trait.toShapeId().equals(DocumentationTrait.ID)) { + continue; + } + + contents.append("@") + .append(trait.toShapeId().getName()) + .append("(") + .append(Node.printJson(trait.toNode())) + .append(")") + .append(System.lineSeparator()); + } + + contents.append(memberShape.getMemberName()) + .append(": ") + .append(memberShape.getTarget().getName()) + .append(System.lineSeparator()); + return contents.toString(); + } + + private static String serializeShape(Model model, Shape shape) { + SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder() + .metadataFilter(key -> false) + .shapeFilter(s -> s.getId().equals(shape.getId())) + // TODO: If we remove the documentation trait in the serializer, + // it also gets removed from members. This causes weird behavior if + // there are applied traits (such as through mixins), where you get + // an empty apply because the documentation trait was removed + // .traitFilter(trait -> !trait.toShapeId().equals(DocumentationTrait.ID)) + .serializePrelude() + .build(); + Map serialized = serializer.serialize(model); + Path path = Paths.get(shape.getId().getNamespace() + ".smithy"); + if (!serialized.containsKey(path)) { + return null; + } + + String serializedShape = serialized.get(path) + .substring(15) // remove '$version: "2.0"' + .trim() + .replaceAll(Matcher.quoteReplacement( + // Replace newline literals with actual newlines + System.lineSeparator() + System.lineSeparator()), System.lineSeparator()); + return serializedShape; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java new file mode 100644 index 00000000..a13a4697 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java @@ -0,0 +1,128 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; + +/** + * Represents different kinds of positions within an IDL file. + */ +sealed interface IdlPosition { + /** + * @return Whether the token at this position is definitely a reference + * to a root/top-level shape. + */ + default boolean isRootShapeReference() { + return switch (this) { + case TraitId ignored -> true; + case MemberTarget ignored -> true; + case ShapeDef ignored -> true; + case ForResource ignored -> true; + case Mixin ignored -> true; + case UseTarget ignored -> true; + case ApplyTarget ignored -> true; + default -> false; + }; + } + + /** + * @return The view this position is within. + */ + StatementView view(); + + record TraitId(StatementView view) implements IdlPosition {} + + record MemberTarget(StatementView view) implements IdlPosition {} + + record ShapeDef(StatementView view) implements IdlPosition {} + + record Mixin(StatementView view) implements IdlPosition {} + + record ApplyTarget(StatementView view) implements IdlPosition {} + + record UseTarget(StatementView view) implements IdlPosition {} + + record Namespace(StatementView view) implements IdlPosition {} + + record TraitValue(StatementView view, Syntax.Statement.TraitApplication application) implements IdlPosition {} + + record NodeMemberTarget(StatementView view, Syntax.Statement.NodeMemberDef nodeMember) implements IdlPosition {} + + record ControlKey(StatementView view) implements IdlPosition {} + + record MetadataKey(StatementView view) implements IdlPosition {} + + record MetadataValue(StatementView view, Syntax.Statement.Metadata metadata) implements IdlPosition {} + + record StatementKeyword(StatementView view) implements IdlPosition {} + + record MemberName(StatementView view, String name) implements IdlPosition {} + + record ElidedMember(StatementView view) implements IdlPosition {} + + record ForResource(StatementView view) implements IdlPosition {} + + record Unknown(StatementView view) implements IdlPosition {} + + static IdlPosition of(StatementView view) { + int documentIndex = view.documentIndex(); + return switch (view.getStatement()) { + case Syntax.Statement.Incomplete incomplete + when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); + + case Syntax.Statement.ShapeDef shapeDef + when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); + + case Syntax.Statement.Apply apply + when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(view); + + case Syntax.Statement.Metadata m + when m.key().isIn(documentIndex) -> new IdlPosition.MetadataKey(view); + + case Syntax.Statement.Metadata m + when m.value() != null && m.value().isIn(documentIndex) -> new IdlPosition.MetadataValue(view, m); + + case Syntax.Statement.Control c + when c.key().isIn(documentIndex) -> new IdlPosition.ControlKey(view); + + case Syntax.Statement.TraitApplication t + when t.id().isEmpty() || t.id().isIn(documentIndex) -> new IdlPosition.TraitId(view); + + case Syntax.Statement.Use u + when u.use().isIn(documentIndex) -> new IdlPosition.UseTarget(view); + + case Syntax.Statement.MemberDef m + when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(view); + + case Syntax.Statement.MemberDef m + when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view, m.name().stringValue()); + + case Syntax.Statement.NodeMemberDef m + when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget(view, m); + + case Syntax.Statement.Namespace n + when n.namespace().isIn(documentIndex) -> new IdlPosition.Namespace(view); + + case Syntax.Statement.TraitApplication t + when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue(view, t); + + case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember(view); + + case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(view); + + case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(view); + + case Syntax.Statement.NodeMemberDef m -> new IdlPosition.MemberName(view, m.name().stringValue()); + + case Syntax.Statement.Block ignored -> new IdlPosition.MemberName(view, ""); + + case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(view); + + default -> new IdlPosition.Unknown(view); + }; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java new file mode 100644 index 00000000..a81de9f4 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java @@ -0,0 +1,239 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Searches models along the path of {@link NodeCursor}s, with support for + * dynamically computing member targets via {@link DynamicMemberTarget}. + */ +final class NodeSearch { + private NodeSearch() { + } + + /** + * @param cursor The cursor to search along. + * @param model The model to search within. + * @param startingShape The shape to start the search at. + * @return The search result. + */ + static Result search(NodeCursor cursor, Model model, Shape startingShape) { + return new DefaultSearch(model).search(cursor, startingShape); + } + + /** + * @param cursor The cursor to search along. + * @param model The model to search within. + * @param startingShape The shape to start the search at. + * @param dynamicMemberTargets A map of member shape id to dynamic member + * targets to use for the search. + * @return The search result. + */ + static Result search( + NodeCursor cursor, + Model model, + Shape startingShape, + Map dynamicMemberTargets + ) { + if (dynamicMemberTargets == null || dynamicMemberTargets.isEmpty()) { + return search(cursor, model, startingShape); + } + + return new SearchWithDynamicMemberTargets(model, dynamicMemberTargets).search(cursor, startingShape); + } + + /** + * The different types of results of a search. The result will be {@link None} + * if at any point the cursor doesn't line up with the model (i.e. if the + * cursor was an array edge, but in the model we were at a structure shape). + * + * @apiNote Each result type, besides {@link None}, also includes the model, + * because it may be necessary to interpret the results (i.e. if you need + * member targets). This is done so that other APIs can wrap {@link NodeSearch} + * and callers don't have to know about which model was used in the search + * under the hood, or to allow switching the model if necessary during a search. + */ + sealed interface Result { + None NONE = new None(); + + /** + * @return The string values of other keys in {@link ObjectKey} and {@link ObjectShape}, + * or an empty set. + */ + default Set getOtherPresentKeys() { + Syntax.Node.Kvps terminalContainer; + NodeCursor.Key terminalKey; + switch (this) { + case NodeSearch.Result.ObjectShape obj -> { + terminalContainer = obj.node(); + terminalKey = null; + } + case NodeSearch.Result.ObjectKey key -> { + terminalContainer = key.key().parent(); + terminalKey = key.key(); + } + default -> { + return Set.of(); + } + } + + Set otherPresentKeys = new HashSet<>(); + for (var kvp : terminalContainer.kvps()) { + otherPresentKeys.add(kvp.key().stringValue()); + } + + if (terminalKey != null) { + otherPresentKeys.remove(terminalKey.name()); + } + + return otherPresentKeys; + } + + /** + * No result - the path is invalid in the model. + */ + record None() implements Result {} + + /** + * The path ended on a shape. + * + * @param shape The shape at the end of the path. + * @param model The model {@code shape} is within. + */ + record TerminalShape(Shape shape, Model model) implements Result {} + + /** + * The path ended on a key or member name of an object-like shape. + * + * @param key The key node the path ended at. + * @param containerShape The shape containing the key. + * @param model The model {@code containerShape} is within. + */ + record ObjectKey(NodeCursor.Key key, Shape containerShape, Model model) implements Result {} + + /** + * The path ended on an object-like shape. + * + * @param node The node the path ended at. + * @param shape The shape at the end of the path. + * @param model The model {@code shape} is within. + */ + record ObjectShape(Syntax.Node.Kvps node, Shape shape, Model model) implements Result {} + + /** + * The path ended on an array-like shape. + * + * @param node The node the path ended at. + * @param shape The shape at the end of the path. + * @param model The model {@code shape} is within. + */ + record ArrayShape(Syntax.Node.Arr node, ListShape shape, Model model) implements Result {} + } + + private static sealed class DefaultSearch { + protected final Model model; + + private DefaultSearch(Model model) { + this.model = model; + } + + Result search(NodeCursor cursor, Shape shape) { + if (!cursor.hasNext() || shape == null) { + return Result.NONE; + } + + NodeCursor.Edge edge = cursor.next(); + return switch (edge) { + case NodeCursor.Obj obj + when ShapeSearch.isObjectShape(shape) -> searchObj(cursor, obj, shape); + + case NodeCursor.Arr arr + when shape instanceof ListShape list -> searchArr(cursor, arr, list); + + case NodeCursor.Terminal ignored -> new Result.TerminalShape(shape, model); + + default -> Result.NONE; + }; + } + + private Result searchObj(NodeCursor cursor, NodeCursor.Obj obj, Shape shape) { + if (!cursor.hasNext()) { + return new Result.ObjectShape(obj.node(), shape, model); + } + + return switch (cursor.next()) { + case NodeCursor.Terminal ignored -> new Result.ObjectShape(obj.node(), shape, model); + + case NodeCursor.Key key -> new Result.ObjectKey(key, shape, model); + + case NodeCursor.ValueForKey ignored + when shape instanceof MapShape map -> searchTarget(cursor, map.getValue()); + + case NodeCursor.ValueForKey value -> shape.getMember(value.keyName()) + .map(member -> searchTarget(cursor, member)) + .orElse(Result.NONE); + + default -> Result.NONE; + }; + } + + private Result searchArr(NodeCursor cursor, NodeCursor.Arr arr, ListShape shape) { + if (!cursor.hasNext()) { + return new Result.ArrayShape(arr.node(), shape, model); + } + + return switch (cursor.next()) { + case NodeCursor.Terminal ignored -> new Result.ArrayShape(arr.node(), shape, model); + + case NodeCursor.Elem ignored -> searchTarget(cursor, shape.getMember()); + + default -> Result.NONE; + }; + } + + protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) { + return search(cursor, model.getShape(memberShape.getTarget()).orElse(null)); + } + } + + private static final class SearchWithDynamicMemberTargets extends DefaultSearch { + private final Map dynamicMemberTargets; + + private SearchWithDynamicMemberTargets( + Model model, + Map dynamicMemberTargets + ) { + super(model); + this.dynamicMemberTargets = dynamicMemberTargets; + } + + @Override + protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) { + DynamicMemberTarget dynamicMemberTarget = dynamicMemberTargets.get(memberShape.getId()); + if (dynamicMemberTarget != null) { + cursor.setCheckpoint(); + Shape target = dynamicMemberTarget.getTarget(cursor, model); + cursor.returnToCheckpoint(); + if (target != null) { + return search(cursor, target); + } + } + + return super.searchTarget(cursor, memberShape); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java new file mode 100644 index 00000000..7c5cc339 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java @@ -0,0 +1,261 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionItemLabelDetails; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.document.DocumentNamespace; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ShapeVisitor; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.ErrorTrait; +import software.amazon.smithy.model.traits.MixinTrait; +import software.amazon.smithy.model.traits.PrivateTrait; +import software.amazon.smithy.model.traits.RequiredTrait; +import software.amazon.smithy.model.traits.TraitDefinition; + +/** + * Maps {@link CompletionCandidates.Shapes} to {@link CompletionItem}s. + * + * @param idlPosition The position of the cursor in the IDL file. + * @param model The model to get shape completions from. + * @param context The context for creating completions. + */ +record ShapeCompleter(IdlPosition idlPosition, Model model, CompleterContext context) { + List getCompletionItems(CompletionCandidates.Shapes candidates) { + AddItems addItems; + if (idlPosition instanceof IdlPosition.TraitId) { + addItems = new AddDeepTraitBodyItem(model); + } else { + addItems = AddItems.NOOP; + } + + ToLabel toLabel; + ModifyItems modifyItems; + boolean shouldMatchFullId = idlPosition instanceof IdlPosition.UseTarget + || context.matchToken().contains("#") + || context.matchToken().contains("."); + if (shouldMatchFullId) { + toLabel = (shape) -> shape.getId().toString(); + modifyItems = ModifyItems.NOOP; + } else { + toLabel = (shape) -> shape.getId().getName(); + modifyItems = new AddImportTextEdits(idlPosition.view().parseResult()); + } + + Matcher matcher = new Matcher(context.matchToken(), toLabel, idlPosition.view().parseResult().namespace()); + Mapper mapper = new Mapper(context.insertRange(), toLabel, addItems, modifyItems); + return streamCandidates(candidates) + .filter(matcher::test) + .mapMulti(mapper::accept) + .toList(); + } + + private Stream streamCandidates(CompletionCandidates.Shapes candidates) { + return switch (candidates) { + case ANY_SHAPE -> model.shapes(); + case STRING_SHAPES -> model.getStringShapes().stream(); + case RESOURCE_SHAPES -> model.getResourceShapes().stream(); + case OPERATION_SHAPES -> model.getOperationShapes().stream(); + case ERROR_SHAPES -> model.getShapesWithTrait(ErrorTrait.class).stream(); + case TRAITS -> model.getShapesWithTrait(TraitDefinition.class).stream(); + case MIXINS -> model.getShapesWithTrait(MixinTrait.class).stream(); + case MEMBER_TARGETABLE -> model.shapes() + .filter(shape -> !shape.isMemberShape() + && !shape.hasTrait(TraitDefinition.ID) + && !shape.hasTrait(MixinTrait.ID)); + case USE_TARGET -> model.shapes().filter(this::shouldImport); + }; + } + + private boolean shouldImport(Shape shape) { + return !shape.isMemberShape() + && !shape.getId().getNamespace().equals(idlPosition.view().parseResult().namespace().namespace()) + && !idlPosition.view().parseResult().imports().imports().contains(shape.getId().toString()) + && !shape.hasTrait(PrivateTrait.ID); + } + + /** + * Filters shape candidates based on whether they are accessible and match + * the match token. + * + * @param matchToken The token to match shapes against, i.e. the token + * being typed. + * @param toLabel The way to get the label to match against from a shape. + * @param namespace The namespace of the current Smithy file. + */ + private record Matcher(String matchToken, ToLabel toLabel, DocumentNamespace namespace) { + boolean test(Shape shape) { + return toLabel.toLabel(shape).toLowerCase().startsWith(matchToken) + && (shape.getId().getNamespace().equals(namespace.namespace()) || !shape.hasTrait(PrivateTrait.ID)); + } + } + + /** + * Maps matching shape candidates to {@link CompletionItem}. + * + * @param insertRange Range the completion text will be inserted into. + * @param toLabel The way to get the label to show in the completion item. + * @param addItems Adds extra completion items for a shape. + * @param modifyItems Modifies created completion items for a shape. + */ + private record Mapper(Range insertRange, ToLabel toLabel, AddItems addItems, ModifyItems modifyItems) { + void accept(Shape shape, Consumer completionItemConsumer) { + String shapeLabel = toLabel.toLabel(shape); + CompletionItem defaultItem = shapeCompletion(shapeLabel, shape); + completionItemConsumer.accept(defaultItem); + addItems.add(this, shapeLabel, shape, completionItemConsumer); + } + + private CompletionItem shapeCompletion(String shapeLabel, Shape shape) { + var completionItem = new CompletionItem(shapeLabel); + completionItem.setKind(CompletionItemKind.Class); + completionItem.setDetail(shape.getType().toString()); + + var labelDetails = new CompletionItemLabelDetails(); + labelDetails.setDetail(shape.getId().getNamespace()); + completionItem.setLabelDetails(labelDetails); + + TextEdit edit = new TextEdit(insertRange, shapeLabel); + completionItem.setTextEdit(Either.forLeft(edit)); + + modifyItems.modify(this, shapeLabel, shape, completionItem); + return completionItem; + } + } + + /** + * Strategy to get the completion label from {@link Shape}s used for + * matching and constructing the completion item. + */ + private interface ToLabel { + String toLabel(Shape shape); + } + + /** + * A customization point for adding extra completions items for a given + * shape. + */ + private interface AddItems { + AddItems NOOP = new AddItems() { + }; + + default void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) { + } + } + + /** + * Adds a completion item that fills out required member names. + * + * TODO: Need to check what happens for recursive traits. The model won't + * be valid, but it may still be loaded and could blow this up. + */ + private static final class AddDeepTraitBodyItem extends ShapeVisitor.Default implements AddItems { + private final Model model; + + AddDeepTraitBodyItem(Model model) { + this.model = model; + } + + @Override + public void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) { + String traitBody = shape.accept(this); + // Strip outside pair of brackets from any structure traits. + if (!traitBody.isEmpty() && traitBody.charAt(0) == '{') { + traitBody = traitBody.substring(1, traitBody.length() - 1); + } + + if (!traitBody.isEmpty()) { + String label = String.format("%s(%s)", shapeLabel, traitBody); + var traitWithMembersItem = mapper.shapeCompletion(label, shape); + consumer.accept(traitWithMembersItem); + } + } + + @Override + protected String getDefault(Shape shape) { + return CompletionCandidates.defaultCandidates(shape).value(); + } + + @Override + public String structureShape(StructureShape shape) { + List entries = new ArrayList<>(); + for (MemberShape memberShape : shape.members()) { + if (memberShape.hasTrait(RequiredTrait.class)) { + entries.add(memberShape.getMemberName() + ": " + memberShape.accept(this)); + } + } + return "{" + String.join(", ", entries) + "}"; + } + + @Override + public String memberShape(MemberShape shape) { + return model.getShape(shape.getTarget()) + .map(target -> target.accept(this)) + .orElse(""); + } + } + + /** + * A customization point for modifying created completion items, adding + * context, additional text edits, etc. + */ + private interface ModifyItems { + ModifyItems NOOP = new ModifyItems() { + }; + + default void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { + } + } + + /** + * Adds text edits for use statements for shapes that need to be imported. + * + * @param syntaxInfo Syntax info of the current Smithy file. + */ + private record AddImportTextEdits(Syntax.IdlParseResult syntaxInfo) implements ModifyItems { + @Override + public void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { + if (inScope(shape.getId())) { + return; + } + + // We can only know where to put the import if there's already use statements, or a namespace + if (!syntaxInfo.imports().imports().isEmpty()) { + addEdit(completionItem, syntaxInfo.imports().importsRange(), shape); + } else if (!syntaxInfo.namespace().namespace().isEmpty()) { + addEdit(completionItem, syntaxInfo.namespace().statementRange(), shape); + } + } + + private boolean inScope(ShapeId shapeId) { + return Prelude.isPublicPreludeShape(shapeId) + || shapeId.getNamespace().equals(syntaxInfo.namespace().namespace()) + || syntaxInfo.imports().imports().contains(shapeId.toString()); + } + + private void addEdit(CompletionItem completionItem, Range range, Shape shape) { + Range editRange = LspAdapter.point(range.getEnd()); + String insertText = System.lineSeparator() + "use " + shape.getId().toString(); + TextEdit importEdit = new TextEdit(editRange, insertText); + completionItem.setAdditionalTextEdits(List.of(importEdit)); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java new file mode 100644 index 00000000..d86e9be9 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -0,0 +1,313 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.shapes.ResourceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ShapeIdSyntaxException; +import software.amazon.smithy.model.traits.IdRefTrait; + +/** + * Provides methods to search for shapes, using context and syntax specific + * information, like the current {@link SmithyFile} or {@link IdlPosition}. + */ +final class ShapeSearch { + private ShapeSearch() { + } + + /** + * Attempts to find a shape using a token, {@code nameOrId}. + * + *

When {@code nameOrId} does not contain a '#', this searches for shapes + * either in {@code idlParse}'s namespace, in {@code idlParse}'s + * imports, or the prelude, in that order. When {@code nameOrId} does contain + * a '#', it is assumed to be a full shape id and is searched for directly. + * + * @param parseResult The parse result of the file {@code nameOrId} is within. + * @param nameOrId The name or shape id of the shape to find. + * @param model The model to search. + * @return The shape, if found. + */ + static Optional findShape(Syntax.IdlParseResult parseResult, String nameOrId, Model model) { + return switch (nameOrId) { + case String s when s.isEmpty() -> Optional.empty(); + case String s when s.contains("#") -> tryFrom(s).flatMap(model::getShape); + case String s -> { + Optional fromCurrent = tryFromParts(parseResult.namespace().namespace(), s) + .flatMap(model::getShape); + if (fromCurrent.isPresent()) { + yield fromCurrent; + } + + for (String fileImport : parseResult.imports().imports()) { + Optional imported = tryFrom(fileImport) + .filter(importId -> importId.getName().equals(s)) + .flatMap(model::getShape); + if (imported.isPresent()) { + yield imported; + } + } + + yield tryFromParts(Prelude.NAMESPACE, s).flatMap(model::getShape); + } + case null -> Optional.empty(); + }; + } + + private static Optional tryFrom(String id) { + try { + return Optional.of(ShapeId.from(id)); + } catch (ShapeIdSyntaxException ignored) { + return Optional.empty(); + } + } + + private static Optional tryFromParts(String namespace, String name) { + try { + return Optional.of(ShapeId.fromRelative(namespace, name)); + } catch (ShapeIdSyntaxException ignored) { + return Optional.empty(); + } + } + + /** + * Attempts to find the shape referenced by {@code id} at {@code idlPosition} in {@code model}. + * + * @param idlPosition The position of the potential shape reference. + * @param id The identifier at {@code idlPosition}. + * @param model The model to search for shapes in. + * @return The shape, if found. + */ + static Optional findShapeDefinition(IdlPosition idlPosition, DocumentId id, Model model) { + return switch (idlPosition) { + case IdlPosition.TraitValue traitValue -> { + var result = searchTraitValue(traitValue, model); + if (result instanceof NodeSearch.Result.TerminalShape(var s, var m) && s.hasTrait(IdRefTrait.class)) { + yield findShape(idlPosition.view().parseResult(), id.copyIdValue(), m); + } else if (result instanceof NodeSearch.Result.ObjectKey(var key, var container, var m) + && !container.isMapShape()) { + yield container.getMember(key.name()); + } + yield Optional.empty(); + } + + case IdlPosition.NodeMemberTarget nodeMemberTarget -> { + var result = searchNodeMemberTarget(nodeMemberTarget); + if (result instanceof NodeSearch.Result.TerminalShape(Shape shape, var ignored) + && shape.hasTrait(IdRefTrait.class)) { + yield findShape(nodeMemberTarget.view().parseResult(), id.copyIdValue(), model); + } + yield Optional.empty(); + } + + // Note: This could be made more specific, at least for mixins + case IdlPosition.ElidedMember elidedMember -> + findElidedMemberParent(elidedMember, id, model); + + case IdlPosition.MemberName memberName -> { + var parentDef = memberName.view().nearestShapeDefBefore(); + if (parentDef == null) { + yield Optional.empty(); + } + var relativeId = parentDef.shapeName().stringValue() + "$" + memberName.name(); + yield findShape(memberName.view().parseResult(), relativeId, model); + } + + case IdlPosition pos when pos.isRootShapeReference() -> + findShape(pos.view().parseResult(), id.copyIdValue(), model); + + default -> Optional.empty(); + }; + } + + /** + * @param forResource The nullable for-resource statement. + * @param view A statement view containing the for-resource statement. + * @param model The model to search in. + * @return A resource shape matching the given for-resource statement, if found. + */ + static Optional findResource( + Syntax.Statement.ForResource forResource, + StatementView view, + Model model + ) { + if (forResource != null) { + String resourceNameOrId = forResource.resource().stringValue(); + return findShape(view.parseResult(), resourceNameOrId, model) + .flatMap(Shape::asResourceShape); + } + return Optional.empty(); + } + + /** + * @param mixins The nullable mixins statement. + * @param view The statement view containing the mixins statement. + * @param model The model to search in. + * @return A list of the mixin shapes matching those in the mixin statement. + */ + static List findMixins(Syntax.Statement.Mixins mixins, StatementView view, Model model) { + if (mixins != null) { + List mixinShapes = new ArrayList<>(mixins.mixins().size()); + for (Syntax.Ident ident : mixins.mixins()) { + String mixinNameOrId = ident.stringValue(); + findShape(view.parseResult(), mixinNameOrId, model).ifPresent(mixinShapes::add); + } + return mixinShapes; + } + return List.of(); + } + + /** + * @param elidedMember The elided member position + * @param id The identifier of the elided member + * @param model The model to search in + * @return The shape the elided member comes from, if found. + */ + static Optional findElidedMemberParent( + IdlPosition.ElidedMember elidedMember, + DocumentId id, + Model model + ) { + var view = elidedMember.view(); + var forResourceAndMixins = view.nearestForResourceAndMixinsBefore(); + + String searchToken = id.copyIdValueForElidedMember(); + + // TODO: Handle ambiguity + Optional foundResource = findResource(forResourceAndMixins.forResource(), view, model) + .filter(shape -> shape.getIdentifiers().containsKey(searchToken) + || shape.getProperties().containsKey(searchToken)); + if (foundResource.isPresent()) { + return foundResource; + } + + return findMixins(forResourceAndMixins.mixins(), view, model) + .stream() + .filter(shape -> shape.getAllMembers().containsKey(searchToken)) + .findFirst(); + } + + /** + * @param traitValue The trait value position + * @param model The model to search in + * @return The shape that {@code traitValue} is being applied to, if found. + */ + static Optional findTraitTarget(IdlPosition.TraitValue traitValue, Model model) { + Syntax.Statement.ShapeDef shapeDef = traitValue.view().nearestShapeDefAfter(); + + if (shapeDef == null) { + return Optional.empty(); + } + + String shapeName = shapeDef.shapeName().stringValue(); + return findShape(traitValue.view().parseResult(), shapeName, model); + } + + /** + * @param shape The shape to check + * @return Whether {@code shape} is represented as an object in a + * {@link software.amazon.smithy.lsp.syntax.Syntax.Node}. + */ + static boolean isObjectShape(Shape shape) { + return switch (shape.getType()) { + case STRUCTURE, UNION, MAP -> true; + default -> false; + }; + } + + /** + * @param metadataValue The metadata value position + * @return The result of searching from the given metadata value within the + * {@link Builtins} model. + */ + static NodeSearch.Result searchMetadataValue(IdlPosition.MetadataValue metadataValue) { + String metadataKey = metadataValue.metadata().key().stringValue(); + Shape metadataValueShapeDef = Builtins.getMetadataValue(metadataKey); + if (metadataValueShapeDef == null) { + return NodeSearch.Result.NONE; + } + + NodeCursor cursor = NodeCursor.create( + metadataValue.metadata().value(), + metadataValue.view().documentIndex()); + var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey); + return NodeSearch.search(cursor, Builtins.MODEL, metadataValueShapeDef, dynamicTargets); + } + + /** + * @param nodeMemberTarget The node member target position + * @return The result of searching from the given node member target value + * within the {@link Builtins} model. + */ + static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nodeMemberTarget) { + Syntax.Statement.ShapeDef shapeDef = nodeMemberTarget.view().nearestShapeDefBefore(); + + if (shapeDef == null) { + return NodeSearch.Result.NONE; + } + + String shapeType = shapeDef.shapeType().stringValue(); + String memberName = nodeMemberTarget.nodeMember().name().stringValue(); + Shape memberShapeDef = Builtins.getMemberTargetForShapeType(shapeType, memberName); + + if (memberShapeDef == null) { + return NodeSearch.Result.NONE; + } + + // This is a workaround for the case when you just have 'operations: '. + // Alternatively, we could add an 'empty' Node value, if this situation comes up + // elsewhere. + // + // TODO: Note that searchTraitValue has to do a similar thing, but parsing + // trait values always yields at least an empty Kvps, so it is kind of the same. + if (nodeMemberTarget.nodeMember().value() == null) { + return new NodeSearch.Result.TerminalShape(memberShapeDef, Builtins.MODEL); + } + + NodeCursor cursor = NodeCursor.create( + nodeMemberTarget.nodeMember().value(), + nodeMemberTarget.view().documentIndex()); + return NodeSearch.search(cursor, Builtins.MODEL, memberShapeDef); + } + + /** + * @param traitValue The trait value position + * @param model The model to search + * @return The result of searching from {@code traitValue} within {@code model}. + */ + static NodeSearch.Result searchTraitValue(IdlPosition.TraitValue traitValue, Model model) { + String traitName = traitValue.application().id().stringValue(); + Optional maybeTraitShape = findShape(traitValue.view().parseResult(), traitName, model); + if (maybeTraitShape.isEmpty()) { + return NodeSearch.Result.NONE; + } + + Shape traitShape = maybeTraitShape.get(); + NodeCursor cursor = NodeCursor.create( + traitValue.application().value(), + traitValue.view().documentIndex()); + if (cursor.isTerminal() && isObjectShape(traitShape)) { + // In this case, we've just started to type '@myTrait(foo)', which to the parser looks like 'foo' is just + // an identifier. But this would mean you don't get member completions when typing the first trait value + // member, so we can modify the node path to make it _look_ like it's actually a key + cursor.edges().addFirst(new NodeCursor.Obj(new Syntax.Node.Kvps())); + } + + var dynamicTargets = DynamicMemberTarget.forTrait(traitShape, traitValue); + return NodeSearch.search(cursor, model, traitShape, dynamicTargets); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java new file mode 100644 index 00000000..04150084 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java @@ -0,0 +1,199 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.util.StreamUtils; + +/** + * Maps simple {@link CompletionCandidates} to {@link CompletionItem}s. + * + * @param context The context for creating completions. + * + * @see ShapeCompleter for what maps {@link CompletionCandidates.Shapes}. + */ +record SimpleCompleter(CompleterContext context) { + List getCompletionItems(CompletionCandidates candidates) { + Matcher matcher; + if (context.exclude().isEmpty()) { + matcher = new DefaultMatcher(context.matchToken()); + } else { + matcher = new ExcludingMatcher(context.matchToken(), context.exclude()); + } + + Mapper mapper = new Mapper(context().insertRange(), context().literalKind()); + + return getCompletionItems(candidates, matcher, mapper); + } + + private List getCompletionItems(CompletionCandidates candidates, Matcher matcher, Mapper mapper) { + return switch (candidates) { + case CompletionCandidates.Constant(var value) + when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value)); + + case CompletionCandidates.Literals(var literals) -> literals.stream() + .filter(matcher::testLiteral) + .map(mapper::literal) + .toList(); + + case CompletionCandidates.Labeled(var labeled) -> labeled.entrySet().stream() + .filter(matcher::testLabeled) + .map(mapper::labeled) + .toList(); + + case CompletionCandidates.Members(var members) -> members.entrySet().stream() + .filter(matcher::testMember) + .map(mapper::member) + .toList(); + + case CompletionCandidates.ElidedMembers(var memberNames) -> memberNames.stream() + .filter(matcher::testElided) + .map(mapper::elided) + .toList(); + + case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher, mapper); + + case CompletionCandidates.And(var one, var two) -> { + List oneItems = getCompletionItems(one); + List twoItems = getCompletionItems(two); + List completionItems = new ArrayList<>(oneItems.size() + twoItems.size()); + completionItems.addAll(oneItems); + completionItems.addAll(twoItems); + yield completionItems; + } + + default -> List.of(); + }; + } + + private CompletionCandidates customCandidates(CompletionCandidates.Custom custom) { + return switch (custom) { + case NAMESPACE_FILTER -> new CompletionCandidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces()) + .collect(StreamUtils.toWrappedMap())); + + case VALIDATOR_NAME -> CompletionCandidates.VALIDATOR_NAMES; + + case PROJECT_NAMESPACES -> new CompletionCandidates.Literals(streamNamespaces().toList()); + }; + } + + private Stream streamNamespaces() { + return context().project().smithyFiles().values().stream() + .map(smithyFile -> switch (smithyFile) { + case IdlFile idlFile -> idlFile.getParse().namespace().namespace(); + default -> ""; + }) + .filter(namespace -> !namespace.isEmpty()); + } + + /** + * Matches different kinds of completion candidates against the text of + * whatever triggered the completion, used to filter out candidates. + * + * @apiNote LSP has support for client-side matching/filtering, but only when + * the completion items don't have text edits. We use text edits to have more + * control over the range the completion text will occupy, so we need to do + * matching/filtering server-side. + * + * @see LSP Completion Docs + */ + private sealed interface Matcher { + String matchToken(); + + default boolean testConstant(String constant) { + return test(constant); + } + + default boolean testLiteral(String literal) { + return test(literal); + } + + default boolean testLabeled(Map.Entry labeled) { + return test(labeled.getKey()) || test(labeled.getValue()); + } + + default boolean testMember(Map.Entry member) { + return test(member.getKey()); + } + + default boolean testElided(String memberName) { + return test(memberName) || test("$" + memberName); + } + + default boolean test(String s) { + return s.toLowerCase().startsWith(matchToken()); + } + } + + private record DefaultMatcher(String matchToken) implements Matcher {} + + private record ExcludingMatcher(String matchToken, Set exclude) implements Matcher { + @Override + public boolean testElided(String memberName) { + // Exclusion set doesn't contain member names with leading '$', so we don't + // want to delegate to the regular `test` method + return !exclude.contains(memberName) + && (Matcher.super.test(memberName) || Matcher.super.test("$" + memberName)); + } + + @Override + public boolean test(String s) { + return !exclude.contains(s) && Matcher.super.test(s); + } + } + + /** + * Maps different kinds of completion candidates to {@link CompletionItem}s. + * + * @param insertRange The range the completion text will occupy. + * @param literalKind The completion item kind that will be shown in the + * client for {@link CompletionCandidates.Literals}. + */ + private record Mapper(Range insertRange, CompletionItemKind literalKind) { + CompletionItem constant(String value) { + return textEditCompletion(value, CompletionItemKind.Constant); + } + + CompletionItem literal(String value) { + return textEditCompletion(value, literalKind); + } + + CompletionItem labeled(Map.Entry entry) { + return textEditCompletion(entry.getKey(), CompletionItemKind.EnumMember, entry.getValue()); + } + + CompletionItem member(Map.Entry entry) { + String value = entry.getKey() + ": " + entry.getValue().value(); + return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value); + } + + CompletionItem elided(String memberName) { + return textEditCompletion("$" + memberName, CompletionItemKind.Field); + } + + private CompletionItem textEditCompletion(String label, CompletionItemKind kind) { + return textEditCompletion(label, kind, label); + } + + private CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) { + CompletionItem item = new CompletionItem(label); + item.setKind(kind); + TextEdit textEdit = new TextEdit(insertRange, insertText); + item.setTextEdit(Either.forLeft(textEdit)); + return item; + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java b/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java new file mode 100644 index 00000000..f171fbc6 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java @@ -0,0 +1,46 @@ +/* + * 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.concurrent.locks.ReentrantLock; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; + +public final class IdlFile extends SmithyFile { + private final ReentrantLock idlParseLock = new ReentrantLock(); + private Syntax.IdlParseResult parseResult; + + IdlFile(String path, Document document, Syntax.IdlParseResult parseResult) { + super(path, document); + this.parseResult = parseResult; + } + + @Override + public void reparse() { + Syntax.IdlParseResult parse = Syntax.parseIdl(document()); + + idlParseLock.lock(); + try { + this.parseResult = parse; + } finally { + idlParseLock.unlock(); + } + } + + /** + * @return The latest computed {@link Syntax.IdlParseResult} of this Smithy file + * @apiNote Don't call this method over and over. {@link Syntax.IdlParseResult} is + * immutable so just call this once and use the returned value. + */ + public Syntax.IdlParseResult getParse() { + idlParseLock.lock(); + try { + return parseResult; + } finally { + idlParseLock.unlock(); + } + } +} 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 1a793200..baae3773 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -6,13 +6,11 @@ package software.amazon.smithy.lsp.project; import java.nio.file.Path; -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.Objects; import java.util.Set; import java.util.function.Supplier; import java.util.logging.Logger; @@ -25,6 +23,7 @@ import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.shapes.AbstractShapeBuilder; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; @@ -40,20 +39,30 @@ public final class Project { private final List dependencies; private final Map smithyFiles; private final Supplier assemblerFactory; + private final Map> definedShapesByFile; private ValidatedResult modelResult; // TODO: Move this into SmithyFileDependenciesIndex private Map> perFileMetadata; private SmithyFileDependenciesIndex smithyFileDependenciesIndex; - private Project(Builder builder) { - this.root = Objects.requireNonNull(builder.root); - this.config = builder.config; - this.dependencies = builder.dependencies; - this.smithyFiles = builder.smithyFiles; - this.modelResult = builder.modelResult; - this.assemblerFactory = builder.assemblerFactory; - this.perFileMetadata = builder.perFileMetadata; - this.smithyFileDependenciesIndex = builder.smithyFileDependenciesIndex; + Project(Path root, + ProjectConfig config, + List dependencies, + Map smithyFiles, + Supplier assemblerFactory, + Map> definedShapesByFile, + ValidatedResult modelResult, + Map> perFileMetadata, + SmithyFileDependenciesIndex smithyFileDependenciesIndex) { + this.root = root; + this.config = config; + this.dependencies = dependencies; + this.smithyFiles = smithyFiles; + this.assemblerFactory = assemblerFactory; + this.definedShapesByFile = definedShapesByFile; + this.modelResult = modelResult; + this.perFileMetadata = perFileMetadata; + this.smithyFileDependenciesIndex = smithyFileDependenciesIndex; } /** @@ -63,10 +72,15 @@ private Project(Builder builder) { * @return The empty project */ public static Project empty(Path root) { - return builder() - .root(root) - .modelResult(ValidatedResult.empty()) - .build(); + return new Project(root, + ProjectConfig.empty(), + List.of(), + new HashMap<>(), + Model::assembler, + new HashMap<>(), + ValidatedResult.empty(), + new HashMap<>(), + new SmithyFileDependenciesIndex()); } /** @@ -119,6 +133,13 @@ public Map smithyFiles() { return this.smithyFiles; } + /** + * @return A map of paths to the set of shape ids defined in the file at that path. + */ + public Map> definedShapesByFile() { + return this.definedShapesByFile; + } + /** * @return The latest result of loading this project */ @@ -224,7 +245,6 @@ public void updateFiles(Set addUris, Set removeUris, Set // So we don't have to recompute the paths later Set removedPaths = new HashSet<>(removeUris.size()); - Set changedPaths = new HashSet<>(changeUris.size()); Set visited = new HashSet<>(); @@ -245,7 +265,6 @@ public void updateFiles(Set addUris, Set removeUris, Set for (String uri : changeUris) { String path = LspAdapter.toPath(uri); - changedPaths.add(path); removeFileForReload(assembler, builder, path, visited); removeDependentsForReload(assembler, builder, path, visited); @@ -281,25 +300,19 @@ public void updateFiles(Set addUris, Set removeUris, Set for (String visitedPath : visited) { if (!removedPaths.contains(visitedPath)) { - SmithyFile current = smithyFiles.get(visitedPath); - Set updatedShapes = getFileShapes(visitedPath, smithyFiles.get(visitedPath).shapes()); - // Only recompute the rest of the smithy file if it changed - if (changedPaths.contains(visitedPath)) { - // TODO: Could cache validation events - this.smithyFiles.put(visitedPath, - ProjectLoader.buildSmithyFile(visitedPath, current.document(), updatedShapes).build()); - } else { - current.setShapes(updatedShapes); - } + Set currentShapes = definedShapesByFile.getOrDefault(visitedPath, Set.of()); + this.definedShapesByFile.put(visitedPath, getFileShapes(visitedPath, currentShapes)); + } else { + this.definedShapesByFile.remove(visitedPath); } } for (String uri : addUris) { String path = LspAdapter.toPath(uri); - Set fileShapes = getFileShapes(path, Collections.emptySet()); Document document = Document.of(IoUtils.readUtf8File(path)); - SmithyFile smithyFile = ProjectLoader.buildSmithyFile(path, document, fileShapes).build(); - smithyFiles.put(path, smithyFile); + SmithyFile smithyFile = SmithyFile.create(path, document); + this.smithyFiles.put(path, smithyFile); + this.definedShapesByFile.put(path, getFileShapes(path, Set.of())); } } @@ -324,21 +337,21 @@ private void removeFileForReload( visited.add(path); - for (Shape shape : smithyFiles.get(path).shapes()) { - builder.removeShape(shape.getId()); + for (ToShapeId toShapeId : definedShapesByFile.getOrDefault(path, Set.of())) { + builder.removeShape(toShapeId.toShapeId()); // This shape may have traits applied to it in other files, // so simply removing the shape loses the information about // those traits. // This shape's dependencies files will be removed and re-loaded - smithyFileDependenciesIndex.getDependenciesFiles(shape).forEach((depPath) -> + smithyFileDependenciesIndex.getDependenciesFiles(toShapeId).forEach((depPath) -> removeFileForReload(assembler, builder, depPath, visited)); // Traits applied in other files are re-added to the assembler so if/when the shape // is reloaded, it will have those traits - smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(shape).forEach((trait) -> - assembler.addTrait(shape.getId(), trait)); + smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(toShapeId).forEach((trait) -> + assembler.addTrait(toShapeId.toShapeId(), trait)); } } @@ -350,8 +363,8 @@ private void removeDependentsForReload( ) { // This file may apply traits to shapes in other files. Normally, letting the assembler simply reparse // the file would be fine because it would ignore the duplicated trait application coming from the same - // source location. But if the apply statement is changed/removed, the old application isn't removed, so we - // could get a duplicate trait, or a merged array trait. + // source location. But if the apply statement is changed/removed, the old trait isn't removed, so we + // could get a duplicate application, or a merged array application. smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) -> removeFileForReload(assembler, builder, depPath, visited)); smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { @@ -375,80 +388,11 @@ private void addRemainingMetadataForReload(Model.Builder builder, Set fi } } - private Set getFileShapes(String path, Set orDefault) { + private Set getFileShapes(String path, Set orDefault) { return this.modelResult.getResult() .map(model -> model.shapes() .filter(shape -> shape.getSourceLocation().getFilename().equals(path)) - .collect(Collectors.toSet())) + .collect(Collectors.toSet())) .orElse(orDefault); } - - static Builder builder() { - return new Builder(); - } - - static final class Builder { - private Path root; - private ProjectConfig config = ProjectConfig.empty(); - private final List dependencies = new ArrayList<>(); - private final Map smithyFiles = new HashMap<>(); - private ValidatedResult modelResult; - private Supplier assemblerFactory = Model::assembler; - private Map> perFileMetadata = new HashMap<>(); - private SmithyFileDependenciesIndex smithyFileDependenciesIndex = new SmithyFileDependenciesIndex(); - - private Builder() { - } - - public Builder root(Path root) { - this.root = root; - return this; - } - - public Builder config(ProjectConfig config) { - this.config = config; - return this; - } - - public Builder dependencies(List paths) { - this.dependencies.clear(); - this.dependencies.addAll(paths); - return this; - } - - public Builder addDependency(Path path) { - this.dependencies.add(path); - return this; - } - - public Builder smithyFiles(Map smithyFiles) { - this.smithyFiles.clear(); - this.smithyFiles.putAll(smithyFiles); - return this; - } - - public Builder modelResult(ValidatedResult modelResult) { - this.modelResult = modelResult; - return this; - } - - public Builder assemblerFactory(Supplier assemblerFactory) { - this.assemblerFactory = assemblerFactory; - return this; - } - - public Builder perFileMetadata(Map> perFileMetadata) { - this.perFileMetadata = perFileMetadata; - return this; - } - - public Builder smithyFileDependenciesIndex(SmithyFileDependenciesIndex smithyFileDependenciesIndex) { - this.smithyFileDependenciesIndex = smithyFileDependenciesIndex; - return this; - } - - public Project build() { - return new Project(this); - } - } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java index 0d8b7494..0a79da85 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java @@ -9,8 +9,10 @@ * Simple wrapper for a project and a file in that project, which many * server functions act upon. * + * @param uri The uri of the file * @param project The project, non-nullable * @param file The file within {@code project}, non-nullable + * @param isDetached Whether the project and file represent a detached project */ -public record ProjectAndFile(Project project, ProjectFile file) { +public record ProjectAndFile(String uri, Project project, ProjectFile file, boolean isDetached) { } 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 86b8b550..538b5cca 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -24,14 +24,8 @@ import java.util.logging.Logger; 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; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentShape; -import software.amazon.smithy.lsp.document.DocumentVersion; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; @@ -39,7 +33,7 @@ import software.amazon.smithy.model.loader.ModelDiscovery; import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; @@ -68,28 +62,29 @@ private ProjectLoader() { public static Project loadDetached(String uri, String text) { LOGGER.info("Loading detachedProjects project at " + uri); String asPath = LspAdapter.toPath(uri); - ValidatedResult modelResult = Model.assembler() + Supplier assemblerFactory; + try { + assemblerFactory = createModelAssemblerFactory(List.of()); + } catch (MalformedURLException e) { + // Note: This can't happen because we have no dependencies to turn into URLs + throw new RuntimeException(e); + } + + ValidatedResult modelResult = assemblerFactory.get() .addUnparsedModel(asPath, text) .assemble(); Path path = Paths.get(asPath); List sources = Collections.singletonList(path); - Project.Builder builder = Project.builder() - .root(path.getParent()) - .config(ProjectConfig.builder() - .sources(Collections.singletonList(asPath)) - .build()) - .modelResult(modelResult); - - Map smithyFiles = computeSmithyFiles(sources, modelResult, (filePath) -> { + var definedShapesByFile = computeDefinedShapesByFile(sources, modelResult); + var smithyFiles = createSmithyFiles(definedShapesByFile, (filePath) -> { // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but // the model stores jar paths as URIs if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { return Document.of(IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath))); } else if (filePath.equals(asPath)) { - Document document = Document.of(text); - return document; + return Document.of(text); } else { // TODO: Make generic 'please file a bug report' exception throw new IllegalStateException( @@ -99,9 +94,15 @@ public static Project loadDetached(String uri, String text) { } }); - return builder.smithyFiles(smithyFiles) - .perFileMetadata(computePerFileMetadata(modelResult)) - .build(); + return new Project(path.getParent(), + ProjectConfig.builder().sources(List.of(asPath)).build(), + List.of(), + smithyFiles, + assemblerFactory, + definedShapesByFile, + modelResult, + computePerFileMetadata(modelResult), + new SmithyFileDependenciesIndex()); } /** @@ -136,30 +137,20 @@ public static Result> load(Path root, ServerState state // The model assembler factory is used to get assemblers that already have the correct // dependencies resolved for future loads - Result, Exception> assemblerFactoryResult = createModelAssemblerFactory(dependencies); - if (assemblerFactoryResult.isErr()) { - return Result.err(Collections.singletonList(assemblerFactoryResult.unwrapErr())); + Supplier assemblerFactory; + try { + assemblerFactory = createModelAssemblerFactory(dependencies); + } catch (MalformedURLException e) { + return Result.err(List.of(e)); } - Supplier assemblerFactory = assemblerFactoryResult.unwrap(); ModelAssembler assembler = assemblerFactory.get(); // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential // here for inconsistent behavior. List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); - Result, Exception> loadModelResult = Result.ofFallible(() -> { - for (Path path : allSmithyFilePaths) { - Document managed = state.getManagedDocument(path); - if (managed != null) { - assembler.addUnparsedModel(path.toString(), managed.copyText()); - } else { - assembler.addImport(path); - } - } - - return assembler.assemble(); - }); + Result, Exception> loadModelResult = loadModel(state, allSmithyFilePaths, assembler); // TODO: Assembler can fail if a file is not found. We can be more intelligent about // handling this case to allow partially loading the project, but we will need to // collect and report the errors somehow. For now, using collectAllSmithyPaths skips @@ -170,15 +161,8 @@ public static Result> load(Path root, ServerState state } ValidatedResult modelResult = loadModelResult.unwrap(); - - Project.Builder projectBuilder = Project.builder() - .root(root) - .config(config) - .dependencies(dependencies) - .modelResult(modelResult) - .assemblerFactory(assemblerFactory); - - Map smithyFiles = computeSmithyFiles(allSmithyFilePaths, modelResult, (filePath) -> { + var definedShapesByFile = computeDefinedShapesByFile(allSmithyFilePaths, modelResult); + var smithyFiles = createSmithyFiles(definedShapesByFile, (filePath) -> { // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but // the model stores jar paths as URIs if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { @@ -196,75 +180,75 @@ public static Result> load(Path root, ServerState state return Document.of(IoUtils.readUtf8File(filePath)); }); - return Result.ok(projectBuilder.smithyFiles(smithyFiles) - .perFileMetadata(computePerFileMetadata(modelResult)) - .smithyFileDependenciesIndex(SmithyFileDependenciesIndex.compute(modelResult)) - .build()); + return Result.ok(new Project(root, + config, + dependencies, + smithyFiles, + assemblerFactory, + definedShapesByFile, + modelResult, + computePerFileMetadata(modelResult), + SmithyFileDependenciesIndex.compute(modelResult))); + } + + private static Result, Exception> loadModel( + ServerState state, + List models, + ModelAssembler assembler + ) { + try { + for (Path path : models) { + Document managed = state.getManagedDocument(path); + if (managed != null) { + assembler.addUnparsedModel(path.toString(), managed.copyText()); + } else { + assembler.addImport(path); + } + } + + return Result.ok(assembler.assemble()); + } catch (Exception e) { + return Result.err(e); + } } static Result> load(Path root) { return load(root, new ServerState()); } - private static Map computeSmithyFiles( + private static Map> computeDefinedShapesByFile( List allSmithyFilePaths, - ValidatedResult modelResult, - Function documentProvider + ValidatedResult modelResult ) { - Map> shapesByFile; - if (modelResult.getResult().isPresent()) { - Model model = modelResult.getResult().get(); - shapesByFile = model.shapes().collect(Collectors.groupingByConcurrent( - shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); - } else { - shapesByFile = new HashMap<>(allSmithyFilePaths.size()); - } + Map> definedShapesByFile = modelResult.getResult().map(Model::shapes) + .orElseGet(Stream::empty) + .collect(Collectors.groupingByConcurrent( + shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); - // There may be smithy files part of the project that aren't part of the model + // There may be smithy files part of the project that aren't part of the model, e.g. empty files for (Path smithyFilePath : allSmithyFilePaths) { String pathString = smithyFilePath.toString(); - if (!shapesByFile.containsKey(pathString)) { - shapesByFile.put(pathString, Collections.emptySet()); - } + definedShapesByFile.putIfAbsent(pathString, Set.of()); } - Map smithyFiles = new HashMap<>(allSmithyFilePaths.size()); - for (Map.Entry> shapesByFileEntry : shapesByFile.entrySet()) { - String path = shapesByFileEntry.getKey(); + return definedShapesByFile; + } + + private static Map createSmithyFiles( + Map> definedShapesByFile, + Function documentProvider + ) { + Map smithyFiles = new HashMap<>(definedShapesByFile.size()); + + for (String path : definedShapesByFile.keySet()) { Document document = documentProvider.apply(path); - Set fileShapes = shapesByFileEntry.getValue(); - SmithyFile smithyFile = buildSmithyFile(path, document, fileShapes).build(); + SmithyFile smithyFile = SmithyFile.create(path, document); smithyFiles.put(path, smithyFile); } return smithyFiles; } - /** - * Computes extra information about what is in the Smithy file and where, - * such as the namespace, imports, version number, and shapes. - * - * @param path Path of the Smithy file - * @param document The document backing the Smithy file - * @param shapes The shapes defined in the Smithy file - * @return A builder for the Smithy file - */ - public static SmithyFile.Builder buildSmithyFile(String path, Document document, Set shapes) { - DocumentParser documentParser = DocumentParser.forDocument(document); - DocumentNamespace namespace = documentParser.documentNamespace(); - DocumentImports imports = documentParser.documentImports(); - Map documentShapes = documentParser.documentShapes(shapes); - DocumentVersion documentVersion = documentParser.documentVersion(); - return SmithyFile.builder() - .path(path) - .document(document) - .shapes(shapes) - .namespace(namespace) - .imports(imports) - .documentShapes(documentShapes) - .documentVersion(documentVersion); - } - // This is gross, but necessary to deal with the way that array metadata gets merged. // When we try to reload a single file, we need to make sure we remove the metadata for // that file. But if there's array metadata, a single key contains merged elements from @@ -296,40 +280,30 @@ static Map> computePerFileMetadata(ValidatedResult, Exception> createModelAssemblerFactory(List dependencies) { + private static Supplier createModelAssemblerFactory(List dependencies) + throws MalformedURLException { // We don't want the model to be broken when there are unknown traits, // because that will essentially disable language server features, so // we need to allow unknown traits for each factory. - // TODO: There's almost certainly a better way to to this if (dependencies.isEmpty()) { - return Result.ok(() -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true)); + return () -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); } - Result result = createDependenciesClassLoader(dependencies); - if (result.isErr()) { - return Result.err(result.unwrapErr()); - } - return Result.ok(() -> { - URLClassLoader classLoader = result.unwrap(); - return Model.assembler(classLoader) - .discoverModels(classLoader) - .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); - }); + URLClassLoader classLoader = createDependenciesClassLoader(dependencies); + return () -> Model.assembler(classLoader) + .discoverModels(classLoader) + .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); } - private static Result createDependenciesClassLoader(List dependencies) { + private static URLClassLoader createDependenciesClassLoader(List dependencies) throws MalformedURLException { // Taken (roughly) from smithy-ci IsolatedRunnable - try { - URL[] urls = new URL[dependencies.size()]; - int i = 0; - for (Path dependency : dependencies) { - urls[i++] = dependency.toUri().toURL(); - } - return Result.ok(new URLClassLoader(urls)); - } catch (MalformedURLException e) { - return Result.err(e); + URL[] urls = new URL[dependencies.size()]; + int i = 0; + for (Path dependency : dependencies) { + urls[i++] = dependency.toUri().toURL(); } + return new URLClassLoader(urls); } // sources and imports can contain directories or files, relative or absolute 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 a30cec1e..a3251e11 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -5,198 +5,45 @@ package software.amazon.smithy.lsp.project; -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import org.eclipse.lsp4j.Position; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.document.DocumentImports; -import software.amazon.smithy.lsp.document.DocumentNamespace; -import software.amazon.smithy.lsp.document.DocumentShape; -import software.amazon.smithy.lsp.document.DocumentVersion; -import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.lsp.syntax.Syntax; /** * The language server's representation of a Smithy file. - * - *

Note: This currently is only ever a .smithy file, but could represent - * a .json file in the future. */ -public final class SmithyFile implements ProjectFile { +public sealed class SmithyFile implements ProjectFile permits IdlFile { private final String path; private final Document document; - // TODO: If we have more complex use-cases for partially updating SmithyFile, we - // could use a toBuilder() - private Set shapes; - private final DocumentNamespace namespace; - private final DocumentImports imports; - private final Map documentShapes; - private final DocumentVersion documentVersion; - private SmithyFile(Builder builder) { - this.path = builder.path; - this.document = builder.document; - this.shapes = builder.shapes; - this.namespace = builder.namespace; - this.imports = builder.imports; - this.documentShapes = builder.documentShapes; - this.documentVersion = builder.documentVersion; + SmithyFile(String path, Document document) { + this.path = path; + this.document = document; } - /** - * @return The path of this Smithy file - */ + static SmithyFile create(String path, Document document) { + // TODO: Make a better abstraction for loading an arbitrary project file + if (path.endsWith(".smithy")) { + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + return new IdlFile(path, document, parse); + } else { + return new SmithyFile(path, document); + } + } + + @Override public String path() { return path; } - /** - * @return The {@link Document} backing this Smithy file - */ + @Override public Document document() { return document; } /** - * @return The Shapes defined in this Smithy file - */ - public Set shapes() { - return shapes; - } - - void setShapes(Set shapes) { - this.shapes = shapes; - } - - /** - * @return This Smithy file's imports, if they exist - */ - public Optional documentImports() { - return Optional.ofNullable(this.imports); - } - - /** - * @return The ids of shapes imported into this Smithy file - */ - public Set imports() { - return documentImports() - .map(DocumentImports::imports) - .orElse(Collections.emptySet()); - } - - /** - * @return This Smithy file's namespace, if one exists - */ - public Optional documentNamespace() { - return Optional.ofNullable(namespace); - } - - /** - * @return The shapes in this Smithy file, including referenced shapes - */ - public Collection documentShapes() { - if (documentShapes == null) { - return Collections.emptyList(); - } - return documentShapes.values(); - } - - /** - * @return A map of {@link Position} to the {@link DocumentShape} they are - * the starting position of - */ - public Map documentShapesByStartPosition() { - if (documentShapes == null) { - return Collections.emptyMap(); - } - return documentShapes; - } - - /** - * @return The string literal namespace of this Smithy file, or an empty string - */ - public CharSequence namespace() { - return documentNamespace() - .map(DocumentNamespace::namespace) - .orElse(""); - } - - /** - * @return This Smithy file's version, if it exists - */ - public Optional documentVersion() { - return Optional.ofNullable(documentVersion); - } - - /** - * @param shapeId The shape id to check - * @return Whether {@code shapeId} is in this SmithyFile's imports + * Reparse the underlying {@link #document()}. */ - public boolean hasImport(String shapeId) { - if (imports == null || imports.imports().isEmpty()) { - return false; - } - return imports.imports().contains(shapeId); - } - - /** - * @return A {@link SmithyFile} builder - */ - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - private String path; - private Document document; - private Set shapes; - private DocumentNamespace namespace; - private DocumentImports imports; - private Map documentShapes; - private DocumentVersion documentVersion; - - private Builder() { - } - - public Builder path(String path) { - this.path = path; - return this; - } - - public Builder document(Document document) { - this.document = document; - return this; - } - - public Builder shapes(Set shapes) { - this.shapes = shapes; - return this; - } - - public Builder namespace(DocumentNamespace namespace) { - this.namespace = namespace; - return this; - } - - public Builder imports(DocumentImports imports) { - this.imports = imports; - return this; - } - - public Builder documentShapes(Map documentShapes) { - this.documentShapes = documentShapes; - return this; - } - - public Builder documentVersion(DocumentVersion documentVersion) { - this.documentVersion = documentVersion; - return this; - } - - public SmithyFile build() { - return new SmithyFile(this); - } + public void reparse() { + // Don't parse JSON files, at least for now } } diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java index 59e62ead..1bd9e540 100644 --- a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java @@ -15,6 +15,9 @@ import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.FromSourceLocation; import software.amazon.smithy.model.SourceLocation; /** @@ -111,6 +114,35 @@ public static Range of(int startLine, int startCharacter, int endLine, int endCh .build(); } + /** + * @param ident Identifier to get the range of + * @param document Document the identifier is in + * @return The range of the identifier in the given document + */ + public static Range identRange(Syntax.Ident ident, Document document) { + int line = document.lineOfIndex(ident.start()); + if (line < 0) { + return null; + } + + int lineStart = document.indexOfLine(line); + if (lineStart < 0) { + return null; + } + + int startCharacter = ident.start() - lineStart; + int endCharacter = ident.end() - lineStart; + return LspAdapter.lineSpan(line, startCharacter, endCharacter); + } + + /** + * @param range The range to check + * @return Whether the range's start is equal to it's end + */ + public static boolean isEmpty(Range range) { + return range.getStart().equals(range.getEnd()); + } + /** * Get a {@link Position} from a {@link SourceLocation}, making the line/columns * 0-indexed. @@ -126,10 +158,11 @@ public static Position toPosition(SourceLocation sourceLocation) { * Get a {@link Location} from a {@link SourceLocation}, with the filename * transformed to a URI, and the line/column made 0-indexed. * - * @param sourceLocation The source location to get a Location from + * @param fromSourceLocation The source location to get a Location from * @return The equivalent Location */ - public static Location toLocation(SourceLocation sourceLocation) { + public static Location toLocation(FromSourceLocation fromSourceLocation) { + SourceLocation sourceLocation = fromSourceLocation.getSourceLocation(); return new Location(toUri(sourceLocation.getFilename()), point( new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); } diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java new file mode 100644 index 00000000..ac1cc3a5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java @@ -0,0 +1,222 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import java.util.ArrayList; +import java.util.List; + +/** + * A moveable index into a path from the root of a {@link Syntax.Node} to a + * position somewhere within that node. The path supports iteration both + * forward and backward, as well as storing a 'checkpoint' along the path + * that can be returned to at a later point. + */ +public final class NodeCursor { + private final List edges; + private int pos = 0; + private int checkpoint = 0; + + NodeCursor(List edges) { + this.edges = edges; + } + + /** + * @param value The node value to create the cursor for + * @param documentIndex The index within the document to create the cursor for + * @return A node cursor from the start of {@code value} to {@code documentIndex} + * within {@code document}. + */ + public static NodeCursor create(Syntax.Node value, int documentIndex) { + List edges = new ArrayList<>(); + NodeCursor cursor = new NodeCursor(edges); + + if (value == null || documentIndex < 0) { + return cursor; + } + + Syntax.Node next = value; + while (true) { + iteration: switch (next) { + case Syntax.Node.Kvps kvps -> { + edges.add(new NodeCursor.Obj(kvps)); + Syntax.Node.Kvp lastKvp = null; + for (Syntax.Node.Kvp kvp : kvps.kvps()) { + if (kvp.key.isIn(documentIndex)) { + String key = kvp.key.stringValue(); + edges.add(new NodeCursor.Key(key, kvps)); + edges.add(new NodeCursor.Terminal(kvp)); + return cursor; + } else if (kvp.inValue(documentIndex)) { + if (kvp.value == null) { + lastKvp = kvp; + break; + } + String key = kvp.key.stringValue(); + edges.add(new NodeCursor.ValueForKey(key, kvps)); + next = kvp.value; + break iteration; + } else { + lastKvp = kvp; + } + } + if (lastKvp != null && lastKvp.value == null) { + edges.add(new NodeCursor.ValueForKey(lastKvp.key.stringValue(), kvps)); + edges.add(new NodeCursor.Terminal(lastKvp)); + return cursor; + } + return cursor; + } + case Syntax.Node.Obj obj -> { + next = obj.kvps; + } + case Syntax.Node.Arr arr -> { + edges.add(new NodeCursor.Arr(arr)); + for (int i = 0; i < arr.elements.size(); i++) { + Syntax.Node elem = arr.elements.get(i); + if (elem.isIn(documentIndex)) { + edges.add(new NodeCursor.Elem(i, arr)); + next = elem; + break iteration; + } + } + return cursor; + } + case null -> { + edges.add(new NodeCursor.Terminal(null)); + return cursor; + } + default -> { + edges.add(new NodeCursor.Terminal(next)); + return cursor; + } + } + } + } + + public List edges() { + return edges; + } + + /** + * @return Whether the cursor is not at the end of the path. A return value + * of {@code true} means {@link #next()} may be called safely. + */ + public boolean hasNext() { + return pos < edges.size(); + } + + /** + * @return The next edge along the path. Also moves the cursor forward. + */ + public Edge next() { + Edge edge = edges.get(pos); + pos++; + return edge; + } + + /** + * @return Whether the cursor is not at the start of the path. A return value + * of {@code true} means {@link #previous()} may be called safely. + */ + public boolean hasPrevious() { + return edges.size() - pos >= 0; + } + + /** + * @return The previous edge along the path. Also moves the cursor backward. + */ + public Edge previous() { + pos--; + return edges.get(pos); + } + + /** + * @return Whether the path consists of a single, terminal, node. + */ + public boolean isTerminal() { + return edges.size() == 1 && edges.getFirst() instanceof Terminal; + } + + /** + * Store the current cursor position to be returned to later. Subsequent + * calls overwrite the checkpoint. + */ + public void setCheckpoint() { + this.checkpoint = pos; + } + + /** + * Return to a previously set checkpoint. Subsequent calls continue to + * the same checkpoint, unless overwritten. + */ + public void returnToCheckpoint() { + this.pos = checkpoint; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (Edge edge : edges) { + switch (edge) { + case Obj ignored -> builder.append("Obj,"); + case Arr ignored -> builder.append("Arr,"); + case Terminal ignored -> builder.append("Terminal,"); + case Elem elem -> builder.append("Elem(").append(elem.index).append("),"); + case Key key -> builder.append("Key(").append(key.name).append("),"); + case ValueForKey valueForKey -> builder.append("ValueForKey(").append(valueForKey.keyName).append("),"); + } + } + return builder.toString(); + } + + /** + * An edge along a path within a {@link Syntax.Node}. Edges are fine-grained + * structurally, so there is a distinction between e.g. a path into an object, + * an object key, and a value for an object key, but there is no distinction + * between e.g. a path into a string value vs a numeric value. Each edge stores + * a reference to the underlying node, or a reference to the parent node. + */ + public sealed interface Edge {} + + /** + * Within an object, i.e. within the braces: '{}'. + * @param node The value of the underlying node at this edge. + */ + public record Obj(Syntax.Node.Kvps node) implements Edge {} + + /** + * Within an array/list, i.e. within the brackets: '[]'. + * @param node The value of the underlying node at this edge. + */ + public record Arr(Syntax.Node.Arr node) implements Edge {} + + /** + * The end of a path. Will always be present at the end of any non-empty path. + * @param node The value of the underlying node at this edge. + */ + public record Terminal(Syntax.Node node) implements Edge {} + + /** + * Within a key of an object, i.e. '{"here": null}' + * @param name The name of the key. + * @param parent The object node the key is within. + */ + public record Key(String name, Syntax.Node.Kvps parent) implements Edge {} + + /** + * Within a value corresponding to a key of an object, i.e. '{"key": "here"}' + * @param keyName The name of the key. + * @param parent The object node the value is within. + */ + public record ValueForKey(String keyName, Syntax.Node.Kvps parent) implements Edge {} + + /** + * Within an element of an array/list, i.e. '["here"]'. + * @param index The index of the element. + * @param parent The array node the element is within. + */ + public record Elem(int index, Syntax.Node.Arr parent) implements Edge {} +} diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java new file mode 100644 index 00000000..9f2684be --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -0,0 +1,1037 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.utils.SimpleParser; + +/** + * Parser for {@link Syntax.Node} and {@link Syntax.Statement}. See + * {@link Syntax} for more details on the design of the parser. + * + *

This parser can be used to parse a single {@link Syntax.Node} by itself, + * or to parse a list of {@link Syntax.Statement} in a Smithy file. + */ +final class Parser extends SimpleParser { + final List errors = new ArrayList<>(); + final List statements = new ArrayList<>(); + private final Document document; + + Parser(Document document) { + super(document.borrowText()); + this.document = document; + } + + Syntax.Node parseNode() { + ws(); + return switch (peek()) { + case '{' -> obj(); + case '"' -> str(); + case '[' -> arr(); + case '-' -> num(); + default -> { + if (isDigit()) { + yield num(); + } else if (isIdentStart()) { + yield ident(); + } + + int start = position(); + do { + skip(); + } while (!isWs() && !isNodeStructuralBreakpoint() && !eof()); + int end = position(); + Syntax.Node.Err err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end)); + err.start = start; + err.end = end; + yield err; + } + }; + } + + void parseIdl() { + try { + ws(); + while (!eof()) { + statement(); + ws(); + } + } catch (Parser.Eof e) { + // This is used to stop parsing when eof is encountered even if we're + // within many layers of method calls. + Syntax.Statement.Err err = new Syntax.Statement.Err(e.message); + err.start = position(); + err.end = position(); + addError(err); + } + } + + void parseIdlBetween(int start, int end) { + try { + rewindTo(start); + ws(); + while (!eof() && position() < end) { + statement(); + ws(); + } + } catch (Parser.Eof e) { + Syntax.Statement.Err err = new Syntax.Statement.Err(e.message); + err.start = position(); + err.end = position(); + addError(err); + } + } + + private void addStatement(Syntax.Statement statement) { + statements.add(statement); + } + + private void addError(Syntax.Err err) { + errors.add(err); + } + + private void setStart(Syntax.Item item) { + if (eof()) { + item.start = position() - 1; + } else { + item.start = position(); + } + } + + private int positionForStart() { + if (eof()) { + return position() - 1; + } else { + return position(); + } + } + + private void setEnd(Syntax.Item item) { + item.end = position(); + } + + private void rewindTo(int pos) { + int line = document.lineOfIndex(pos); + int lineIndex = document.indexOfLine(line); + this.rewind(pos, line + 1, pos - lineIndex + 1); + } + + private Syntax.Node traitNode() { + skip(); // '(' + ws(); + return switch (peek()) { + case '{' -> obj(); + case '"' -> { + int pos = position(); + Syntax.Node str = str(); + ws(); + if (is(':')) { + yield traitValueKvps(pos); + } else { + yield str; + } + } + case '[' -> arr(); + case '-' -> num(); + default -> { + if (isDigit()) { + yield num(); + } else if (isIdentStart()) { + int pos = position(); + Syntax.Node ident = nodeIdent(); + ws(); + if (is(':')) { + yield traitValueKvps(pos); + } else { + yield ident; + } + } else if (is(')')) { + Syntax.Node.Kvps kvps = new Syntax.Node.Kvps(); + setStart(kvps); + setEnd(kvps); + skip(); + yield kvps; + } + + int start = position(); + do { + skip(); + } while (!isWs() && !isStructuralBreakpoint() && !eof()); + int end = position(); + Syntax.Node.Err err; + if (eof()) { + err = new Syntax.Node.Err("unexpected eof"); + } else { + err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end)); + } + err.start = start; + err.end = end; + yield err; + } + }; + } + + private Syntax.Node traitValueKvps(int from) { + rewindTo(from); + Syntax.Node.Kvps kvps = new Syntax.Node.Kvps(); + setStart(kvps); + while (!eof()) { + if (is(')')) { + setEnd(kvps); + skip(); + return kvps; + } + + Syntax.Node.Err kvpErr = kvp(kvps, ')'); + if (kvpErr != null) { + addError(kvpErr); + } + + ws(); + } + kvps.end = position() - 1; + return kvps; + } + + private Syntax.Node nodeIdent() { + int start = position(); + // assume there's _something_ here + do { + skip(); + } while (!isWs() && !isStructuralBreakpoint() && !eof()); + int end = position(); + return new Syntax.Ident(start, end, document.copySpan(start, end)); + } + + private Syntax.Node.Obj obj() { + Syntax.Node.Obj obj = new Syntax.Node.Obj(); + setStart(obj); + skip(); + ws(); + while (!eof()) { + if (is('}')) { + skip(); + setEnd(obj); + return obj; + } + + Syntax.Err kvpErr = kvp(obj.kvps, '}'); + if (kvpErr != null) { + addError(kvpErr); + } + + ws(); + } + + Syntax.Node.Err err = new Syntax.Node.Err("missing }"); + setStart(err); + setEnd(err); + addError(err); + + setEnd(obj); + return obj; + } + + private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { + int start = positionForStart(); + Syntax.Node keyValue = parseNode(); + Syntax.Node.Err err = null; + Syntax.Node.Str key = null; + switch (keyValue) { + case Syntax.Node.Str s -> { + key = s; + } + case Syntax.Node.Err e -> { + err = e; + } + default -> { + err = nodeErr(keyValue, "unexpected " + keyValue.type()); + } + } + + ws(); + + Syntax.Node.Kvp kvp = null; + if (key != null) { + kvp = new Syntax.Node.Kvp(key); + kvp.start = start; + kvps.add(kvp); + } + + if (is(':')) { + if (kvp != null) { + kvp.colonPos = position(); + } + skip(); + ws(); + } else if (eof()) { + return nodeErr("unexpected eof"); + } else { + if (err != null) { + addError(err); + } + + err = nodeErr("expected :"); + } + + if (is(close)) { + if (err != null) { + addError(err); + } + + return nodeErr("expected value"); + } + + if (is(',')) { + skip(); + if (kvp != null) { + setEnd(kvp); + } + if (err != null) { + addError(err); + } + + return nodeErr("expected value"); + } + + Syntax.Node value = parseNode(); + if (value instanceof Syntax.Node.Err e) { + if (err != null) { + addError(err); + } + err = e; + } else if (err == null) { + kvp.value = value; + if (is(',')) { + skip(); + } + return null; + } + + return err; + } + + private Syntax.Node.Arr arr() { + Syntax.Node.Arr arr = new Syntax.Node.Arr(); + setStart(arr); + skip(); + ws(); + while (!eof()) { + if (is(']')) { + skip(); + setEnd(arr); + return arr; + } + + Syntax.Node elem = parseNode(); + if (elem instanceof Syntax.Node.Err e) { + addError(e); + } else { + arr.elements.add(elem); + } + ws(); + } + + Syntax.Node.Err err = nodeErr("missing ]"); + addError(err); + + setEnd(arr); + return arr; + } + + private Syntax.Node str() { + int start = position(); + skip(); // '"' + if (is('"')) { + skip(); + + if (is('"')) { + skip(); + + // text block + int end = document.nextIndexOf("\"\"\"", position()); + if (end == -1) { + rewindTo(document.length() - 1); + Syntax.Node.Err err = new Syntax.Node.Err("unclosed text block"); + err.start = start; + err.end = document.length(); + return err; + } + + rewindTo(end + 3); + int strEnd = position(); + return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 3, strEnd - 3)); + } + + // Empty string + skip(); + int strEnd = position(); + return new Syntax.Node.Str(start, strEnd, ""); + } + + int last = '"'; + + // Potential micro-optimization - only loop while position < line end + while (!isNl() && !eof()) { + if (is('"') && last != '\\') { + skip(); // '"' + int strEnd = position(); + return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 1, strEnd - 1)); + } + last = peek(); + skip(); + } + + Syntax.Node.Err err = new Syntax.Node.Err("unclosed string literal"); + err.start = start; + setEnd(err); + return err; + } + + private Syntax.Node num() { + int start = position(); + while (!isWs() && !isNodeStructuralBreakpoint() && !eof()) { + skip(); + } + + String token = document.copySpan(start, position()); + if (token == null) { + throw new RuntimeException("unhandled eof in node num"); + } + + Syntax.Node value; + try { + BigDecimal numValue = new BigDecimal(token); + value = new Syntax.Node.Num(numValue); + } catch (NumberFormatException e) { + value = new Syntax.Node.Err(String.format("%s is not a valid number", token)); + } + value.start = start; + setEnd(value); + return value; + } + + private boolean isNodeStructuralBreakpoint() { + return switch (peek()) { + case '{', '[', '}', ']', ',', ':', ')' -> true; + default -> false; + }; + } + + private Syntax.Node.Err nodeErr(Syntax.Node from, String message) { + Syntax.Node.Err err = new Syntax.Node.Err(message); + err.start = from.start; + err.end = from.end; + return err; + } + + private Syntax.Node.Err nodeErr(String message) { + Syntax.Node.Err err = new Syntax.Node.Err(message); + setStart(err); + setEnd(err); + return err; + } + + private void skipUntilStatementStart() { + while (!is('@') && !is('$') && !isIdentStart() && !eof()) { + skip(); + } + } + + private void statement() { + if (is('@')) { + traitApplication(null); + } else if (is('$')) { + control(); + } else { + // Shape, apply + int start = position(); + Syntax.Ident ident = ident(); + if (ident.isEmpty()) { + if (!isWs()) { + // TODO: Capture all this in an error + skipUntilStatementStart(); + } + return; + } + + sp(); + Syntax.Ident name = ident(); + if (name.isEmpty()) { + Syntax.Statement.Incomplete incomplete = new Syntax.Statement.Incomplete(ident); + incomplete.start = start; + incomplete.end = position(); + addStatement(incomplete); + + if (!isWs()) { + skip(); + } + return; + } + + String identCopy = ident.stringValue(); + + switch (identCopy) { + case "apply" -> { + apply(start, name); + return; + } + case "metadata" -> { + metadata(start, name); + return; + } + case "use" -> { + use(start, name); + return; + } + case "namespace" -> { + namespace(start, name); + return; + } + default -> { + } + } + + Syntax.Statement.ShapeDef shapeDef = new Syntax.Statement.ShapeDef(ident, name); + shapeDef.start = start; + setEnd(shapeDef); + addStatement(shapeDef); + + sp(); + optionalForResourceAndMixins(); + ws(); + + switch (identCopy) { + case "enum", "intEnum" -> { + var block = startBlock(null); + + ws(); + while (!is('}') && !eof()) { + enumMember(block); + ws(); + } + + endBlock(block); + } + case "structure", "list", "map", "union" -> { + var block = startBlock(null); + + ws(); + while (!is('}') && !eof()) { + member(block); + ws(); + } + + endBlock(block); + } + case "resource", "service" -> { + var block = startBlock(null); + + ws(); + while (!is('}') && !eof()) { + nodeMember(block); + ws(); + } + + endBlock(block); + } + case "operation" -> { + var block = startBlock(null); + // This is different from the other member parsing because it needs more fine-grained loop/branch + // control to deal with inline structures + operationMembers(block); + endBlock(block); + } + default -> { + } + } + } + } + + private Syntax.Statement.Block startBlock(Syntax.Statement.Block parent) { + Syntax.Statement.Block block = new Syntax.Statement.Block(parent, statements.size()); + setStart(block); + addStatement(block); + if (is('{')) { + skip(); + } else { + addErr(position(), position(), "expected {"); + recoverToMemberStart(); + } + return block; + } + + private void endBlock(Syntax.Statement.Block block) { + block.lastStatementIndex = statements.size() - 1; + throwIfEofAndFinish("expected }", block); // This will stop execution + skip(); // '}' + setEnd(block); + } + + private void operationMembers(Syntax.Statement.Block parent) { + ws(); + while (!is('}') && !eof()) { + int opMemberStart = position(); + Syntax.Ident memberName = ident(); + + int colonPos = -1; + sp(); + if (is(':')) { + colonPos = position(); + skip(); // ':' + } else { + addErr(position(), position(), "expected :"); + if (isWs()) { + var memberDef = new Syntax.Statement.MemberDef(parent, memberName); + memberDef.start = opMemberStart; + setEnd(memberDef); + addStatement(memberDef); + ws(); + continue; + } + } + + if (is('=')) { + skip(); // '=' + inlineMember(parent, opMemberStart, memberName); + ws(); + continue; + } + + ws(); + + if (isIdentStart()) { + var opMemberDef = new Syntax.Statement.MemberDef(parent, memberName); + opMemberDef.start = opMemberStart; + opMemberDef.colonPos = colonPos; + opMemberDef.target = ident(); + setEnd(opMemberDef); + addStatement(opMemberDef); + } else { + var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName); + nodeMemberDef.start = opMemberStart; + nodeMemberDef.colonPos = colonPos; + nodeMemberDef.value = parseNode(); + setEnd(nodeMemberDef); + addStatement(nodeMemberDef); + } + + ws(); + } + } + + private void control() { + int start = position(); + skip(); // '$' + Syntax.Ident ident = ident(); + Syntax.Statement.Control control = new Syntax.Statement.Control(ident); + control.start = start; + addStatement(control); + sp(); + + if (!is(':')) { + addErr(position(), position(), "expected :"); + if (isWs()) { + setEnd(control); + return; + } + } else { + skip(); + } + + control.value = parseNode(); + setEnd(control); + } + + private void apply(int start, Syntax.Ident name) { + Syntax.Statement.Apply apply = new Syntax.Statement.Apply(name); + apply.start = start; + setEnd(apply); + addStatement(apply); + + sp(); + if (is('@')) { + traitApplication(null); + } else if (is('{')) { + var block = startBlock(null); + + ws(); + while (!is('}') && !eof()) { + if (!is('@')) { + addErr(position(), position(), "expected trait"); + return; + } + traitApplication(block); + ws(); + } + + endBlock(block); + } else { + addErr(position(), position(), "expected trait or block"); + } + } + + private void metadata(int start, Syntax.Ident name) { + Syntax.Statement.Metadata metadata = new Syntax.Statement.Metadata(name); + metadata.start = start; + addStatement(metadata); + + sp(); + if (!is('=')) { + addErr(position(), position(), "expected ="); + if (isWs()) { + setEnd(metadata); + return; + } + } else { + skip(); + } + metadata.value = parseNode(); + setEnd(metadata); + } + + private void use(int start, Syntax.Ident name) { + Syntax.Statement.Use use = new Syntax.Statement.Use(name); + use.start = start; + setEnd(use); + addStatement(use); + } + + private void namespace(int start, Syntax.Ident name) { + Syntax.Statement.Namespace namespace = new Syntax.Statement.Namespace(name); + namespace.start = start; + setEnd(namespace); + addStatement(namespace); + } + + private void optionalForResourceAndMixins() { + int maybeStart = position(); + Syntax.Ident maybe = optIdent(); + + if (maybe.stringValue().equals("for")) { + sp(); + Syntax.Ident resource = ident(); + Syntax.Statement.ForResource forResource = new Syntax.Statement.ForResource(resource); + forResource.start = maybeStart; + addStatement(forResource); + ws(); + setEnd(forResource); + maybeStart = position(); + maybe = optIdent(); + } + + if (maybe.stringValue().equals("with")) { + sp(); + Syntax.Statement.Mixins mixins = new Syntax.Statement.Mixins(); + mixins.start = maybeStart; + + if (!is('[')) { + addErr(position(), position(), "expected ["); + + // If we're on an identifier, just assume the [ was meant to be there + if (!isIdentStart()) { + setEnd(mixins); + addStatement(mixins); + return; + } + } else { + skip(); + } + + ws(); + while (!isStructuralBreakpoint() && !eof()) { + mixins.mixins.add(ident()); + ws(); + } + + if (is(']')) { + skip(); // ']' + } else { + // We either have another structural breakpoint, or eof + addErr(position(), position(), "expected ]"); + } + + setEnd(mixins); + addStatement(mixins); + } + } + + private void member(Syntax.Statement.Block parent) { + if (is('@')) { + traitApplication(parent); + } else if (is('$')) { + elidedMember(parent); + } else if (isIdentStart()) { + int start = positionForStart(); + Syntax.Ident name = ident(); + Syntax.Statement.MemberDef memberDef = new Syntax.Statement.MemberDef(parent, name); + memberDef.start = start; + addStatement(memberDef); + + sp(); + if (is(':')) { + memberDef.colonPos = position(); + skip(); + } else { + addErr(position(), position(), "expected :"); + if (isWs() || is('}')) { + setEnd(memberDef); + addStatement(memberDef); + return; + } + } + ws(); + + memberDef.target = ident(); + setEnd(memberDef); + ws(); + + if (is('=')) { + skip(); + parseNode(); + ws(); + } + + } else { + addErr(position(), position(), + "unexpected token " + peekSingleCharForMessage() + " expected trait or member"); + recoverToMemberStart(); + } + } + + private void enumMember(Syntax.Statement.Block parent) { + if (is('@')) { + traitApplication(parent); + } else if (isIdentStart()) { + int start = positionForStart(); + Syntax.Ident name = ident(); + var enumMemberDef = new Syntax.Statement.EnumMemberDef(parent, name); + enumMemberDef.start = start; + addStatement(enumMemberDef); + + ws(); + if (is('=')) { + skip(); // '=' + ws(); + enumMemberDef.value = parseNode(); + } + setEnd(enumMemberDef); + } else { + addErr(position(), position(), + "unexpected token " + peekSingleCharForMessage() + " expected trait or member"); + recoverToMemberStart(); + } + } + + private void elidedMember(Syntax.Statement.Block parent) { + int start = positionForStart(); + skip(); // '$' + Syntax.Ident name = ident(); + var elidedMemberDef = new Syntax.Statement.ElidedMemberDef(parent, name); + elidedMemberDef.start = start; + setEnd(elidedMemberDef); + addStatement(elidedMemberDef); + } + + private void inlineMember(Syntax.Statement.Block parent, int start, Syntax.Ident name) { + var inlineMemberDef = new Syntax.Statement.InlineMemberDef(parent, name); + inlineMemberDef.start = start; + setEnd(inlineMemberDef); + addStatement(inlineMemberDef); + + ws(); + while (is('@')) { + traitApplication(parent); + ws(); + } + throwIfEof("expected {"); + + optionalForResourceAndMixins(); + ws(); + + var block = startBlock(parent); + ws(); + while (!is('}') && !eof()) { + member(block); + ws(); + } + endBlock(block); + } + + private void nodeMember(Syntax.Statement.Block parent) { + int start = positionForStart(); + Syntax.Ident name = ident(); + var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, name); + nodeMemberDef.start = start; + + sp(); + if (is(':')) { + nodeMemberDef.colonPos = position(); + skip(); // ':' + } else { + addErr(position(), position(), "expected :"); + if (isWs() || is('}')) { + setEnd(nodeMemberDef); + addStatement(nodeMemberDef); + return; + } + } + + ws(); + if (is('}')) { + addErr(nodeMemberDef.colonPos, nodeMemberDef.colonPos, "expected node"); + } else { + nodeMemberDef.value = parseNode(); + } + setEnd(nodeMemberDef); + addStatement(nodeMemberDef); + } + + private void traitApplication(Syntax.Statement.Block parent) { + int startPos = position(); + skip(); // '@' + Syntax.Ident id = ident(); + var application = new Syntax.Statement.TraitApplication(parent, id); + application.start = startPos; + addStatement(application); + + if (is('(')) { + int start = position(); + application.value = traitNode(); + application.value.start = start; + ws(); + if (is(')')) { + setEnd(application.value); + skip(); // ')' + } + // Otherwise, traitNode() probably ate it. + } + setEnd(application); + } + + private Syntax.Ident optIdent() { + if (!isIdentStart()) { + return Syntax.Ident.EMPTY; + } + return ident(); + } + + private Syntax.Ident ident() { + int start = position(); + if (!isIdentStart()) { + addErr(start, start, "expected identifier"); + return Syntax.Ident.EMPTY; + } + + do { + skip(); + } while (isIdentChar()); + + int end = position(); + if (start == end) { + addErr(start, end, "expected identifier"); + return Syntax.Ident.EMPTY; + } + return new Syntax.Ident(start, end, document.copySpan(start, end)); + } + + private void addErr(int start, int end, String message) { + Syntax.Statement.Err err = new Syntax.Statement.Err(message); + err.start = start; + err.end = end; + addError(err); + } + + private void recoverToMemberStart() { + ws(); + while (!isIdentStart() && !is('@') && !is('$') && !eof()) { + skip(); + ws(); + } + + throwIfEof("expected member or trait"); + } + + private boolean isStructuralBreakpoint() { + return switch (peek()) { + case '{', '[', '(', '}', ']', ')', ':', '=', '@' -> true; + default -> false; + }; + } + + private boolean isIdentStart() { + char peeked = peek(); + return Character.isLetter(peeked) || peeked == '_'; + } + + private boolean isIdentChar() { + char peeked = peek(); + return Character.isLetterOrDigit(peeked) || peeked == '_' || peeked == '$' || peeked == '.' || peeked == '#'; + } + + private boolean isDigit() { + return Character.isDigit(peek()); + } + + private boolean isNl() { + return switch (peek()) { + case '\n', '\r' -> true; + default -> false; + }; + } + + private boolean isWs() { + return switch (peek()) { + case '\n', '\r', ' ', ',', '\t' -> true; + default -> false; + }; + } + + private boolean is(char c) { + return peek() == c; + } + + private void throwIfEof(String message) { + if (eof()) { + throw new Eof(message); + } + } + + private void throwIfEofAndFinish(String message, Syntax.Item item) { + if (eof()) { + setEnd(item); + throw new Eof(message); + } + } + + /** + * Used to halt parsing when we reach the end of the file, + * without having to bubble up multiple layers. + */ + private static final class Eof extends RuntimeException { + final String message; + + Eof(String message) { + this.message = message; + } + } + + @Override + public void ws() { + while (this.isWs() || is('/')) { + if (is('/')) { + while (!isNl() && !eof()) { + this.skip(); + } + } else { + this.skip(); + } + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java b/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java new file mode 100644 index 00000000..b9884e38 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java @@ -0,0 +1,228 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * An IDL parse result at a specific position within the underlying document. + * + * @param parseResult The IDL parse result + * @param statementIndex The index of the statement {@code documentIndex} is within + * @param documentIndex The index within the underlying document + */ +public record StatementView(Syntax.IdlParseResult parseResult, int statementIndex, int documentIndex) { + + /** + * @param parseResult The parse result to create a view of + * @return An optional view of the first statement in the given parse result, + * or empty if the parse result has no statements + */ + public static Optional createAtStart(Syntax.IdlParseResult parseResult) { + if (parseResult.statements().isEmpty()) { + return Optional.empty(); + } + + return createAt(parseResult, parseResult.statements().getFirst().start()); + } + + /** + * @param parseResult The parse result to create a view of + * @param documentIndex The index within the underlying document + * @return An optional view of the statement the given documentIndex is within + * in the given parse result, or empty if the index is not within a statement + */ + public static Optional createAt(Syntax.IdlParseResult parseResult, int documentIndex) { + if (documentIndex < 0) { + return Optional.empty(); + } + + int statementIndex = statementIndex(parseResult.statements(), documentIndex); + if (statementIndex < 0) { + return Optional.empty(); + } + + return Optional.of(new StatementView(parseResult, statementIndex, documentIndex)); + } + + private static int statementIndex(List statements, int position) { + int low = 0; + int up = statements.size() - 1; + + while (low <= up) { + int mid = (low + up) / 2; + Syntax.Statement statement = statements.get(mid); + if (statement.isIn(position)) { + if (statement instanceof Syntax.Statement.Block) { + return statementIndexBetween(statements, mid, up, position); + } else { + return mid; + } + } else if (statement.start() > position) { + up = mid - 1; + } else if (statement.end() < position) { + low = mid + 1; + } else { + return -1; + } + } + + Syntax.Statement last = statements.get(up); + if (last instanceof Syntax.Statement.MemberStatement memberStatement) { + // Note: parent() can be null for TraitApplication. + if (memberStatement.parent() != null && memberStatement.parent().isIn(position)) { + return memberStatement.parent().statementIndex(); + } + } + + return -1; + } + + private static int statementIndexBetween(List statements, int lower, int upper, int position) { + int ogLower = lower; + lower += 1; + while (lower <= upper) { + int mid = (lower + upper) / 2; + Syntax.Statement statement = statements.get(mid); + if (statement.isIn(position)) { + // Could have nested blocks, like in an inline structure definition + if (statement instanceof Syntax.Statement.Block) { + return statementIndexBetween(statements, mid, upper, position); + } + return mid; + } else if (statement.start() > position) { + upper = mid - 1; + } else if (statement.end() < position) { + lower = mid + 1; + } else { + return ogLower; + } + } + + return ogLower; + } + + /** + * @return The non-nullable statement that {@link #documentIndex()} is within + */ + public Syntax.Statement getStatement() { + return parseResult.statements().get(statementIndex); + } + + /** + * @param documentIndex The index within the underlying document + * @return The optional statement the given index is within + */ + public Optional getStatementAt(int documentIndex) { + int statementIndex = statementIndex(parseResult.statements(), documentIndex); + if (statementIndex < 0) { + return Optional.empty(); + } + return Optional.of(parseResult.statements().get(statementIndex)); + } + + /** + * @return The nearest shape def before this view + */ + public Syntax.Statement.ShapeDef nearestShapeDefBefore() { + int searchStatementIndex = statementIndex - 1; + while (searchStatementIndex >= 0) { + Syntax.Statement statement = parseResult.statements().get(searchStatementIndex); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + return shapeDef; + } + searchStatementIndex--; + } + return null; + } + + /** + * @return The nearest for resource and mixins before this view + */ + public Syntax.ForResourceAndMixins nearestForResourceAndMixinsBefore() { + int searchStatementIndex = statementIndex; + while (searchStatementIndex >= 0) { + Syntax.Statement searchStatement = parseResult.statements().get(searchStatementIndex); + if (searchStatement instanceof Syntax.Statement.Block) { + Syntax.Statement.ForResource forResource = null; + Syntax.Statement.Mixins mixins = null; + + int lastSearchIndex = searchStatementIndex - 2; + searchStatementIndex--; + while (searchStatementIndex >= 0 && searchStatementIndex >= lastSearchIndex) { + Syntax.Statement candidateStatement = parseResult.statements().get(searchStatementIndex); + if (candidateStatement instanceof Syntax.Statement.Mixins m) { + mixins = m; + } else if (candidateStatement instanceof Syntax.Statement.ForResource f) { + forResource = f; + } + searchStatementIndex--; + } + + return new Syntax.ForResourceAndMixins(forResource, mixins); + } + searchStatementIndex--; + } + + return new Syntax.ForResourceAndMixins(null, null); + } + + /** + * @return The names of all the other members around this view + */ + public Set otherMemberNames() { + Set found = new HashSet<>(); + int searchIndex = statementIndex; + int lastMemberStatementIndex = statementIndex; + while (searchIndex >= 0) { + Syntax.Statement statement = parseResult.statements().get(searchIndex); + if (statement instanceof Syntax.Statement.Block block) { + lastMemberStatementIndex = block.lastStatementIndex(); + break; + } else if (searchIndex != statementIndex) { + addMemberName(found, statement); + } + searchIndex--; + } + searchIndex = statementIndex + 1; + while (searchIndex <= lastMemberStatementIndex) { + Syntax.Statement statement = parseResult.statements().get(searchIndex); + addMemberName(found, statement); + searchIndex++; + } + return found; + } + + private static void addMemberName(Set memberNames, Syntax.Statement statement) { + switch (statement) { + case Syntax.Statement.MemberDef def -> memberNames.add(def.name().stringValue()); + case Syntax.Statement.NodeMemberDef def -> memberNames.add(def.name().stringValue()); + case Syntax.Statement.InlineMemberDef def -> memberNames.add(def.name().stringValue()); + case Syntax.Statement.ElidedMemberDef def -> memberNames.add(def.name().stringValue()); + default -> { + } + } + } + + /** + * @return The nearest shape def after this view + */ + public Syntax.Statement.ShapeDef nearestShapeDefAfter() { + for (int i = statementIndex + 1; i < parseResult.statements().size(); i++) { + Syntax.Statement statement = parseResult.statements().get(i); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + return shapeDef; + } else if (!(statement instanceof Syntax.Statement.TraitApplication)) { + return null; + } + } + + return null; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java new file mode 100644 index 00000000..e6b27667 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -0,0 +1,787 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.document.DocumentImports; +import software.amazon.smithy.lsp.document.DocumentNamespace; +import software.amazon.smithy.lsp.document.DocumentParser; +import software.amazon.smithy.lsp.document.DocumentVersion; + +/** + * Provides classes that represent the syntactic structure of a Smithy file, and + * a means to parse Smithy files into those classes. + *

+ *

IDL Syntax

+ * The result of a parse, {@link IdlParseResult}, is a list of {@link Statement}, + * rather than a syntax tree. For example, the following: + * + * \@someTrait + * structure Foo with [Bar] { + * \@otherTrait + * foo: String + * } + * + * Produces the following list of statements: + * + * TraitApplication, + * ShapeDef, + * Mixins, + * Block, + * TraitApplication, + * MemberDef + * + * While this sacrifices the ability to walk directly from the `foo` member def + * to the `Foo` structure (or vice-versa), it simplifies error handling in the + * parser by allowing more _nearly_ correct syntax, and localizes any errors as + * close to their "cause" as possible. In general, the parser is as lenient as + * possible, always producing a {@link Statement} for any given text, even if + * the statement is incomplete or invalid. This means that consumers of the + * parse result will always have _something_ they can analyze, despite the text + * having invalid syntax, so the server stays responsive as you type. + * + *

At a high-level, the design decisions of the parser and {@link Statement} + * are guided by the following ideas: + * - Minimal lookahead or structural validation to be as fast as possible. + * - Minimal memory allocations, for intermediate objects and the parse result. + * - Minimal sensitivity to context, leaving the door open to easily implement + * incremental/partial re-parsing of changes if it becomes necessary. + * - Provide strongly-typed, concrete syntax productions so consumers don't need + * to create their own wrappers. + * + *

There are a few things to note about the public API of {@link Statement}s + * produced by the parser. + * - Any `final` field is definitely assigned, whereas any non `final` field + * may be null (other than {@link Statement#start} and {@link Statement#end}, + * which are definitely assigned). + *

+ *

Node Syntax

+ * This class also provides classes for the JSON-like Smithy Node, which can + * be used standalone (see {@link Syntax#parseNode(Document)}). {@link Node} + * is a more typical recursive parse tree, so parsing produces a single + * {@link Node}, and any given {@link Node} may be a {@link Node.Err}. Like + * {@link Statement}, the parser tries to be as lenient as possible here too. + */ +public final class Syntax { + private Syntax() { + } + + /** + * Wrapper for {@link Statement.ForResource} and {@link Statement.Mixins}, + * which often are used together. + * + * @param forResource The nullable for-resource statement. + * @param mixins The nullable mixins statement. + */ + public record ForResourceAndMixins(Statement.ForResource forResource, Statement.Mixins mixins) {} + + /** + * The result of parsing an IDL document, containing some extra computed + * info that is used often. + * + * @param statements The parsed statements. + * @param errors The errors that occurred during parsing. + * @param version The IDL version that was parsed. + * @param namespace The namespace that was parsed + * @param imports The imports that were parsed. + */ + public record IdlParseResult( + List statements, + List errors, + DocumentVersion version, + DocumentNamespace namespace, + DocumentImports imports + ) {} + + /** + * @param document The document to parse. + * @return The IDL parse result. + */ + public static IdlParseResult parseIdl(Document document) { + Parser parser = new Parser(document); + parser.parseIdl(); + List statements = parser.statements; + DocumentParser documentParser = DocumentParser.forStatements(document, statements); + return new IdlParseResult( + statements, + parser.errors, + documentParser.documentVersion(), + documentParser.documentNamespace(), + documentParser.documentImports()); + } + + /** + * The result of parsing a Node document. + * + * @param value The parsed node. + * @param errors The errors that occurred during parsing. + */ + public record NodeParseResult(Node value, List errors) {} + + /** + * @param document The document to parse. + * @return The Node parse result. + */ + public static NodeParseResult parseNode(Document document) { + Parser parser = new Parser(document); + Node node = parser.parseNode(); + return new NodeParseResult(node, parser.errors); + } + + /** + * Any syntactic construct has this base type. Mostly used to share + * {@link #start()} and {@link #end()} that all items have. + */ + public abstract static sealed class Item { + int start; + int end; + + public final int start() { + return start; + } + + public final int end() { + return end; + } + + /** + * @param pos The character offset in a file to check + * @return Whether {@code pos} is within this item + */ + public final boolean isIn(int pos) { + return start <= pos && end > pos; + } + } + + /** + * Common type of all JSON-like node syntax productions. + */ + public abstract static sealed class Node extends Item { + /** + * @return The type of the node. + */ + public final Type type() { + return switch (this) { + case Kvps ignored -> Type.Kvps; + case Kvp ignored -> Type.Kvp; + case Obj ignored -> Type.Obj; + case Arr ignored -> Type.Arr; + case Ident ignored -> Type.Ident; + case Str ignored -> Type.Str; + case Num ignored -> Type.Num; + case Err ignored -> Type.Err; + }; + } + + /** + * Applies this node to {@code consumer}, and traverses this node in + * depth-first order. + * + * @param consumer Consumer to do something with each node. + */ + public final void consume(Consumer consumer) { + consumer.accept(this); + switch (this) { + case Kvps kvps -> kvps.kvps().forEach(kvp -> kvp.consume(consumer)); + case Kvp kvp -> { + kvp.key.consume(consumer); + if (kvp.value != null) { + kvp.value.consume(consumer); + } + } + case Obj obj -> obj.kvps.consume(consumer); + case Arr arr -> arr.elements.forEach(elem -> elem.consume(consumer)); + default -> { + } + } + } + + public enum Type { + Kvps, + Kvp, + Obj, + Arr, + Str, + Num, + Ident, + Err + } + + /** + * A list of key-value pairs. May be within an {@link Obj}, or standalone + * (like in a trait body). + */ + public static final class Kvps extends Node { + private final List kvps = new ArrayList<>(); + + void add(Kvp kvp) { + kvps.add(kvp); + } + + public List kvps() { + return kvps; + } + } + + /** + * A single key-value pair. {@link #key} will definitely be present, + * while {@link #value} may be null. + */ + public static final class Kvp extends Node { + final Str key; + int colonPos = -1; + Node value; + + Kvp(Str key) { + this.key = key; + } + + public Str key() { + return key; + } + + public Node value() { + return value; + } + + /** + * @param pos The character offset to check + * @return Whether the given offset is within the value of this pair + */ + public boolean inValue(int pos) { + if (colonPos < 0) { + return false; + } else if (value == null) { + return pos > colonPos && pos < end; + } else { + return value.isIn(pos); + } + } + } + + /** + * Wrapper around {@link Kvps}, for objects enclosed in {}. + */ + public static final class Obj extends Node { + final Kvps kvps = new Kvps(); + + public Kvps kvps() { + return kvps; + } + } + + /** + * An array of {@link Node}. + */ + public static final class Arr extends Node { + final List elements = new ArrayList<>(); + + public List elements() { + return elements; + } + } + + /** + * A string value. The Smithy {@link Node}s can also be regular + * identifiers, so this class a single subclass {@link Ident}. + */ + public static sealed class Str extends Node { + final String value; + + Str(int start, int end, String value) { + this.start = start; + this.end = end; + this.value = value; + } + + public String stringValue() { + return value; + } + } + + /** + * A numeric value. + */ + public static final class Num extends Node { + final BigDecimal value; + + Num(BigDecimal value) { + this.value = value; + } + + public BigDecimal value() { + return value; + } + } + + /** + * An error representing an invalid {@link Node} value. + */ + public static final class Err extends Node implements Syntax.Err { + final String message; + + Err(String message) { + this.message = message; + } + + @Override + public String message() { + return message; + } + } + } + + /** + * Common type of all IDL syntax productions. + */ + public abstract static sealed class Statement extends Item { + /** + * @return The type of the statement. + */ + public final Type type() { + return switch (this) { + case Incomplete ignored -> Type.Incomplete; + case Control ignored -> Type.Control; + case Metadata ignored -> Type.Metadata; + case Namespace ignored -> Type.Namespace; + case Use ignored -> Type.Use; + case Apply ignored -> Type.Apply; + case ShapeDef ignored -> Type.ShapeDef; + case ForResource ignored -> Type.ForResource; + case Mixins ignored -> Type.Mixins; + case TraitApplication ignored -> Type.TraitApplication; + case MemberDef ignored -> Type.MemberDef; + case EnumMemberDef ignored -> Type.EnumMemberDef; + case ElidedMemberDef ignored -> Type.ElidedMemberDef; + case InlineMemberDef ignored -> Type.InlineMemberDef; + case NodeMemberDef ignored -> Type.NodeMemberDef; + case Block ignored -> Type.Block; + case Err ignored -> Type.Err; + }; + } + + public enum Type { + Incomplete, + Control, + Metadata, + Namespace, + Use, + Apply, + ShapeNode, + ShapeDef, + ForResource, + Mixins, + TraitApplication, + MemberDef, + EnumMemberDef, + ElidedMemberDef, + InlineMemberDef, + NodeMemberDef, + Block, + Err; + } + + /** + * A single identifier that can't be associated with an actual statement. + * For example, `stru` by itself is an incomplete statement. + */ + public static final class Incomplete extends Statement { + final Ident ident; + + Incomplete(Ident ident) { + this.ident = ident; + } + + public Ident ident() { + return ident; + } + } + + /** + * A control statement. + */ + public static final class Control extends Statement { + final Ident key; + Node value; + + Control(Ident key) { + this.key = key; + } + + public Ident key() { + return key; + } + + public Node value() { + return value; + } + } + + /** + * A metadata statement. + */ + public static final class Metadata extends Statement { + final Ident key; + Node value; + + Metadata(Ident key) { + this.key = key; + } + + public Ident key() { + return key; + } + + public Node value() { + return value; + } + } + + /** + * A namespace statement, i.e. `namespace` followed by an identifier. + */ + public static final class Namespace extends Statement { + final Ident namespace; + + Namespace(Ident namespace) { + this.namespace = namespace; + } + + public Ident namespace() { + return namespace; + } + } + + /** + * A use statement, i.e. `use` followed by an identifier. + */ + public static final class Use extends Statement { + final Ident use; + + Use(Ident use) { + this.use = use; + } + + public Ident use() { + return use; + } + } + + /** + * An apply statement, i.e. `apply` followed by an identifier. Doesn't + * include, require, or care about subsequent trait applications. + */ + public static final class Apply extends Statement { + final Ident id; + + Apply(Ident id) { + this.id = id; + } + + public Ident id() { + return id; + } + } + + /** + * A shape definition, i.e. a shape type followed by an identifier. + */ + public static final class ShapeDef extends Statement { + final Ident shapeType; + final Ident shapeName; + + ShapeDef(Ident shapeType, Ident shapeName) { + this.shapeType = shapeType; + this.shapeName = shapeName; + } + + public Ident shapeType() { + return shapeType; + } + + public Ident shapeName() { + return shapeName; + } + } + + /** + * `for` followed by an identifier. Only appears after a {@link ShapeDef} + * or after an {@link InlineMemberDef}. + */ + public static final class ForResource extends Statement { + final Ident resource; + + ForResource(Ident resource) { + this.resource = resource; + } + + public Ident resource() { + return resource; + } + } + + /** + * `with` followed by an array. The array may not be present in text, + * but it is in this production. Only appears after a {@link ShapeDef}, + * {@link InlineMemberDef}, or {@link ForResource}. + */ + public static final class Mixins extends Statement { + final List mixins = new ArrayList<>(); + + public List mixins() { + return mixins; + } + } + + /** + * Common type of productions that can appear within shape bodies, i.e. + * within a {@link Block}. + * + *

The sole purpose of this class is to make it cheap to navigate + * from a statement to the {@link Block} it resides within when + * searching for the statement corresponding to a given character offset + * in a document.

+ */ + abstract static sealed class MemberStatement extends Statement { + final Block parent; + + protected MemberStatement(Block parent) { + this.parent = parent; + } + + /** + * @return The possibly null block enclosing this statement. + */ + public Block parent() { + return parent; + } + } + + /** + * A trait application, i.e. `@` followed by an identifier. + */ + public static final class TraitApplication extends MemberStatement { + final Ident id; + Node value; + + TraitApplication(Block parent, Ident id) { + super(parent); + this.id = id; + } + + public Ident id() { + return id; + } + + public Node value() { + return value; + } + } + + /** + * A member definition, i.e. identifier `:` identifier. Only appears + * in {@link Block}s. + */ + public static final class MemberDef extends MemberStatement { + final Ident name; + int colonPos = -1; + Ident target; + + MemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + + public Ident target() { + return target; + } + + /** + * @param pos The character offset to check + * @return Whether the given offset is within this member's target + */ + public boolean inTarget(int pos) { + if (colonPos < 0) { + return false; + } else if (target == null || target.isEmpty()) { + return pos > colonPos; + } else { + return target.isIn(pos); + } + } + } + + /** + * An enum member definition, i.e. an identifier followed by an optional + * value assignment. Only appears in {@link Block}s. + */ + public static final class EnumMemberDef extends MemberStatement { + final Ident name; + Node value; + + EnumMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + } + + /** + * An elided member definition, i.e. `$` followed by an identifier. Only + * appears in {@link Block}s. + */ + public static final class ElidedMemberDef extends MemberStatement { + final Ident name; + + ElidedMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + } + + /** + * An inline member definition, i.e. an identifier followed by `:=`. Only + * appears in {@link Block}s, and doesn't include the actual definition, + * just the member name. + */ + public static final class InlineMemberDef extends MemberStatement { + final Ident name; + + InlineMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + } + + /** + * A member definition with a node value, i.e. identifier `:` node value. + * Only appears in {@link Block}s. + */ + public static final class NodeMemberDef extends MemberStatement { + final Ident name; + int colonPos = -1; + Node value; + + NodeMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + + public Node value() { + return value; + } + + /** + * @param pos The character offset to check + * @return Whether the given {@code pos} is within this member's value + */ + public boolean inValue(int pos) { + return (value != null && value.isIn(pos)) + || (colonPos >= 0 && pos > colonPos); + } + } + + /** + * Used to indicate the start of a block, i.e. {}. + */ + public static final class Block extends MemberStatement { + final int statementIndex; + int lastStatementIndex; + + Block(Block parent, int lastStatementIndex) { + super(parent); + this.statementIndex = lastStatementIndex; + this.lastStatementIndex = lastStatementIndex; + } + + public int statementIndex() { + return statementIndex; + } + + public int lastStatementIndex() { + return lastStatementIndex; + } + } + + /** + * An error that occurred during IDL parsing. This is distinct from + * {@link Node.Err} primarily because {@link Node.Err} is an actual + * value a {@link Node} can have. + */ + public static final class Err extends Statement implements Syntax.Err { + final String message; + + Err(String message) { + this.message = message; + } + + @Override + public String message() { + return message; + } + } + } + + /** + * An identifier in a {@link Node} or {@link Statement}. Starts with any + * alpha or `_` character, followed by any sequence of Shape ID characters + * (i.e. `.`, `#`, `$`, `_` digits, alphas). + */ + public static final class Ident extends Node.Str { + static final Ident EMPTY = new Ident(-1, -1, ""); + + Ident(int start, int end, String value) { + super(start, end, value); + } + + public boolean isEmpty() { + return (start - end) == 0; + } + } + + /** + * Represents any syntax error, either {@link Node} or {@link Statement}. + */ + public sealed interface Err { + /** + * @return The start index of the error. + */ + int start(); + + /** + * @return The end index of the error. + */ + int end(); + + /** + * @return The error message. + */ + String message(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java new file mode 100644 index 00000000..55187018 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.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.util; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +public final class StreamUtils { + private StreamUtils() { + } + + public static Collector> toWrappedMap() { + return Collectors.toMap(s -> s, s -> "\"" + s + "\""); + } + + public static Collector, ?, Map> mappingValue(Function valueMapper) { + return Collectors.toMap(Map.Entry::getKey, entry -> valueMapper.apply(entry.getValue())); + } +} diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep deleted file mode 100644 index 445d5757..00000000 --- a/src/main/resources/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Delete this file as soon as actual an actual resources is added to this directory. \ No newline at end of file diff --git a/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy b/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy new file mode 100644 index 00000000..238cd0f5 --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy @@ -0,0 +1,37 @@ +$version: "2.0" + +namespace smithy.lang.server + +string SmithyIdlVersion + +string AnyNamespace + +string ValidatorName + +structure ValidatorConfig {} + +string Selector + +@idRef +string AnyShape + +@idRef +string AnyTrait + +@idRef +string AnyMixin + +@idRef +string AnyString + +@idRef +string AnyError + +@idRef +string AnyOperation + +@idRef +string AnyResource + +@idRef +string AnyMemberTarget diff --git a/src/main/resources/software/amazon/smithy/lsp/language/control.smithy b/src/main/resources/software/amazon/smithy/lsp/language/control.smithy new file mode 100644 index 00000000..eb0fdd5e --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/control.smithy @@ -0,0 +1,17 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure BuiltinControl { + /// Defines the [version](https://smithy.io/2.0/spec/idl.html#smithy-version) + /// of the smithy idl used in this model file. + version: SmithyIdlVersion = "2.0" + + /// Defines the suffix used when generating names for + /// [inline operation input](https://smithy.io/2.0/spec/idl.html#idl-inline-input-output). + operationInputSuffix: String = "Input" + + /// Defines the suffix used when generating names for + /// [inline operation output](https://smithy.io/2.0/spec/idl.html#idl-inline-input-output). + operationOutputSuffix: String = "Output" +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/members.smithy b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy new file mode 100644 index 00000000..42b50fe8 --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy @@ -0,0 +1,75 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure ShapeMemberTargets { + service: ServiceShape + operation: OperationShape + resource: ResourceShape + list: ListShape + map: MapShape +} + +structure ServiceShape { + version: String + operations: Operations + resources: Resources + errors: Errors + rename: Rename +} + +list Operations { + member: AnyOperation +} + +list Resources { + member: AnyResource +} + +list Errors { + member: AnyError +} + +map Rename { + key: AnyShape + value: String +} + +structure OperationShape { + input: AnyMemberTarget + output: AnyMemberTarget + errors: Errors +} + +structure ResourceShape { + identifiers: Identifiers + properties: Properties + create: AnyOperation + put: AnyOperation + read: AnyOperation + update: AnyOperation + delete: AnyOperation + list: AnyOperation + operations: Operations + collectionOperations: Operations + resources: Resources +} + +map Identifiers { + key: String + value: AnyString +} + +map Properties { + key: String + value: AnyMemberTarget +} + +structure ListShape { + member: AnyMemberTarget +} + +structure MapShape { + key: AnyString + value: AnyMemberTarget +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy new file mode 100644 index 00000000..a3c38cbb --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy @@ -0,0 +1,95 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure BuiltinMetadata { + /// Suppressions are used to suppress specific validation events. + /// See [Suppressions](https://smithy.io/2.0/spec/model-validation.html#suppressions) + suppressions: Suppressions + + /// An array of validator objects used to constrain the model. + /// See [Validators](https://smithy.io/2.0/spec/model-validation.html#validators) + validators: Validators + + /// An array of severity override objects used to raise the severity of non-suppressed validation events. + /// See [Severity overrides](https://smithy.io/2.0/spec/model-validation.html#severity-overrides) + severityOverrides: SeverityOverrides +} + +list Suppressions { + member: Suppression +} + +list Validators { + member: Validator +} + +list SeverityOverrides { + member: SeverityOverride +} + +structure Suppression { + /// The hierarchical validation event ID to suppress. + id: String + + /// The validation event is only suppressed if it matches the supplied namespace. + /// A value of * can be provided to match any namespace. + /// * is useful for suppressing validation events that are not bound to any specific shape. + namespace: AnyNamespace + + /// Provides an optional reason for the suppression. + reason: String +} + +structure Validator { + name: ValidatorName + id: String + message: String + severity: ValidatorSeverity + namespaces: AnyNamespaces + selector: String + configuration: ValidatorConfig +} + +enum ValidatorSeverity { + NOTE = "NOTE" + WARNING = "WARNING" + DANGER = "DANGER" +} + +list AnyNamespaces { + member: AnyNamespace +} + +structure SeverityOverride { + id: String + namespace: AnyNamespace + severity: SeverityOverrideSeverity +} + +enum SeverityOverrideSeverity { + WARNING = "WARNING" + DANGER = "DANGER" +} + +structure BuiltinValidators { + EmitEachSelector: EmitEachSelectorConfig + EmitNoneSelector: EmitNoneSelectorConfig + UnreferencedShapes: UnreferencedShapesConfig +} + +structure EmitEachSelectorConfig { + @required + selector: Selector + bindToTrait: AnyTrait + messageTemplate: String +} + +structure EmitNoneSelectorConfig { + @required + selector: Selector +} + +structure UnreferencedShapesConfig { + selector: Selector = "service" +} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index 4048b749..cab974b4 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -5,6 +5,7 @@ package software.amazon.smithy.lsp; +import java.util.Collection; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.Range; @@ -59,6 +60,34 @@ public void describeMismatchSafely(TextEdit textEdit, Description description) { }; } + public static Matcher> togetherMakeEditedDocument(Document document, String expected) { + return new CustomTypeSafeMatcher<>("make edited document " + expected) { + @Override + protected boolean matchesSafely(Collection item) { + Document copy = document.copy(); + for (TextEdit edit : item) { + copy.applyEdit(edit.getRange(), edit.getNewText()); + } + return copy.copyText().equals(expected); + } + + @Override + public void describeMismatchSafely(Collection item, Description description) { + Document copy = document.copy(); + for (TextEdit edit : item) { + copy.applyEdit(edit.getRange(), edit.getNewText()); + } + String actual = copy.copyText(); + description.appendText(String.format(""" + expected: + '%s' + but was: + '%s' + """, expected, actual)); + } + }; + } + public static Matcher hasText(Document document, Matcher expected) { return new CustomTypeSafeMatcher<>("text in range") { @Override diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 984bfcea..26df2bfa 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -17,7 +17,6 @@ import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static software.amazon.smithy.lsp.LspMatchers.diagnosticWithMessage; import static software.amazon.smithy.lsp.LspMatchers.hasLabel; import static software.amazon.smithy.lsp.LspMatchers.hasText; @@ -27,7 +26,6 @@ import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; import static software.amazon.smithy.lsp.document.DocumentTest.safeString; -import static software.amazon.smithy.lsp.project.ProjectTest.toPath; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -43,30 +41,26 @@ import java.util.stream.Collectors; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; import org.eclipse.lsp4j.DocumentFormattingParams; -import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; import org.eclipse.lsp4j.FileChangeType; import org.eclipse.lsp4j.FormattingOptions; import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextEdit; -import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.project.Project; @@ -97,296 +91,6 @@ public void runsSelector() throws Exception { assertThat(locations, not(empty())); } - @Test - public void completion() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: String - } - - @default(0) - integer Bar - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - // String - CompletionParams memberTargetParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(4) - .character(10) - .buildCompletion(); - // @default - CompletionParams traitParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(7) - .character(2) - .buildCompletion(); - CompletionParams wsParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(2) - .character(1) - .buildCompletion(); - - List memberTargetCompletions = server.completion(memberTargetParams).get().getLeft(); - List traitCompletions = server.completion(traitParams).get().getLeft(); - List wsCompletions = server.completion(wsParams).get().getLeft(); - - assertThat(memberTargetCompletions, containsInAnyOrder(hasLabel("String"))); - assertThat(traitCompletions, containsInAnyOrder(hasLabel("default"))); - assertThat(wsCompletions, empty()); - } - - @Test - public void completionImports() throws Exception { - String model1 = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - } - """); - String model2 = safeString(""" - $version: "2" - namespace com.bar - - string Bar - """); - TestWorkspace workspace = TestWorkspace.multipleModels(model1, model2); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("model-0.smithy"); - - DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() - .uri(uri) - .text(model1) - .build(); - server.didOpen(openParams); - - DidChangeTextDocumentParams changeParams = new RequestBuilders.DidChange() - .uri(uri) - .version(2) - .range(new RangeBuilder() - .startLine(3) - .startCharacter(15) - .endLine(3) - .endCharacter(15) - .build()) - .text(safeString("\n bar: Ba")) - .build(); - server.didChange(changeParams); - - // bar: Ba - CompletionParams completionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(4) - .character(10) - .buildCompletion(); - List completions = server.completion(completionParams).get().getLeft(); - - assertThat(completions, containsInAnyOrder(hasLabel("Bar"))); - - Document document = server.getFirstProject().getDocument(uri); - // TODO: The server puts the 'use' on the wrong line - assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, safeString(""" - $version: "2" - namespace com.foo - use com.bar#Bar - - structure Foo { - bar: Ba - } - """)))); - } - - @Test - public void definition() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @trait - string myTrait - - structure Foo { - bar: Baz - } - - @myTrait("") - string Baz - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - // bar: Baz - DefinitionParams memberTargetParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(7) - .character(9) - .buildDefinition(); - // @myTrait - DefinitionParams traitParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(10) - .character(1) - .buildDefinition(); - DefinitionParams wsParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(2) - .character(0) - .buildDefinition(); - - List memberTargetLocations = server.definition(memberTargetParams).get().getLeft(); - List traitLocations = server.definition(traitParams).get().getLeft(); - List wsLocations = server.definition(wsParams).get().getLeft(); - - Document document = server.getFirstProject().getDocument(uri); - assertNotNull(document); - - assertThat(memberTargetLocations, hasSize(1)); - Location memberTargetLocation = memberTargetLocations.get(0); - assertThat(memberTargetLocation.getUri(), equalTo(uri)); - assertThat(memberTargetLocation.getRange().getStart(), equalTo(new Position(11, 0))); - // TODO - // assertThat(document.borrowRange(memberTargetLocation.getRange()), equalTo("")); - - assertThat(traitLocations, hasSize(1)); - Location traitLocation = traitLocations.get(0); - assertThat(traitLocation.getUri(), equalTo(uri)); - assertThat(traitLocation.getRange().getStart(), equalTo(new Position(4, 0))); - - assertThat(wsLocations, empty()); - } - - @Test - public void hover() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @trait - string myTrait - - structure Foo { - bar: Bar - } - - @myTrait("") - structure Bar { - baz: String - } - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("main.smithy"); - - // bar: Bar - HoverParams memberParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(7) - .character(9) - .buildHover(); - // @myTrait("") - HoverParams traitParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(10) - .character(1) - .buildHover(); - HoverParams wsParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(2) - .character(0) - .buildHover(); - - Hover memberHover = server.hover(memberParams).get(); - Hover traitHover = server.hover(traitParams).get(); - Hover wsHover = server.hover(wsParams).get(); - - assertThat(memberHover.getContents().getRight().getValue(), containsString("structure Bar")); - assertThat(traitHover.getContents().getRight().getValue(), containsString("string myTrait")); - assertThat(wsHover.getContents().getRight().getValue(), equalTo("")); - } - - @Test - public void hoverWithBrokenModel() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: Bar - baz: String - } - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - // baz: String - HoverParams params = new RequestBuilders.PositionRequest() - .uri(uri) - .line(5) - .character(9) - .buildHover(); - Hover hover = server.hover(params).get(); - - assertThat(hover.getContents().getRight().getValue(), containsString("string String")); - } - - @Test - public void documentSymbol() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @trait - string myTrait - - structure Foo { - @required - bar: Bar - } - - structure Bar { - @myTrait("foo") - baz: Baz - } - - @myTrait("abc") - integer Baz - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - server.didOpen(RequestBuilders.didOpen() - .uri(uri) - .build()); - - server.getState().lifecycleManager().waitForAllTasks(); - - DocumentSymbolParams params = new DocumentSymbolParams(new TextDocumentIdentifier(uri)); - List> response = server.documentSymbol(params).get(); - List documentSymbols = response.stream().map(Either::getRight).toList(); - List names = documentSymbols.stream().map(DocumentSymbol::getName).collect(Collectors.toList()); - - assertThat(names, hasItem("myTrait")); - assertThat(names, hasItem("Foo")); - assertThat(names, hasItem("bar")); - assertThat(names, hasItem("Bar")); - assertThat(names, hasItem("baz")); - assertThat(names, hasItem("Baz")); - } - @Test public void formatting() throws Exception { String model = safeString(""" @@ -533,251 +237,6 @@ public void didChangeReloadsModel() throws Exception { containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); } - @Test - public void didChangeThenDefinition() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: Bar - } - - string Bar - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("main.smithy"); - - server.didOpen(new RequestBuilders.DidOpen() - .uri(uri) - .text(model) - .build()); - DefinitionParams definitionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(4) - .character(9) - .buildDefinition(); - Location initialLocation = server.definition(definitionParams).get().getLeft().get(0); - assertThat(initialLocation.getUri(), equalTo(uri)); - assertThat(initialLocation.getRange().getStart(), equalTo(new Position(7, 0))); - - RangeBuilder range = new RangeBuilder() - .startLine(5) - .startCharacter(1) - .endLine(5) - .endCharacter(1); - RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text(safeString("\n\n")).build()); - server.didChange(change.range(range.shiftNewLine().shiftNewLine().build()).text("s").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text("i").build()); - server.didChange(change.range(range.shiftRight().build()).text("n").build()); - server.didChange(change.range(range.shiftRight().build()).text("g").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("B").build()); - server.didChange(change.range(range.shiftRight().build()).text("a").build()); - server.didChange(change.range(range.shiftRight().build()).text("z").build()); - - server.getState().lifecycleManager().getTask(uri).get(); - - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: Bar - } - - string Baz - - string Bar - """))); - - Location afterChanges = server.definition(definitionParams).get().getLeft().get(0); - assertThat(afterChanges.getUri(), equalTo(uri)); - assertThat(afterChanges.getRange().getStart(), equalTo(new Position(9, 0))); - } - - @Test - public void definitionWithApply() throws Exception { - Path root = toPath(getClass().getResource("project/apply")); - SmithyLanguageServer server = initFromRoot(root); - String foo = root.resolve("model/foo.smithy").toUri().toString(); - String bar = root.resolve("model/bar.smithy").toUri().toString(); - - server.didOpen(new RequestBuilders.DidOpen() - .uri(foo) - .build()); - - // on 'apply >MyOpInput' - RequestBuilders.PositionRequest myOpInputRequest = new RequestBuilders.PositionRequest() - .uri(foo) - .line(5) - .character(6); - - Location myOpInputLocation = server.definition(myOpInputRequest.buildDefinition()).get().getLeft().get(0); - assertThat(myOpInputLocation.getUri(), equalTo(foo)); - assertThat(myOpInputLocation.getRange().getStart(), equalTo(new Position(9, 0))); - - Hover myOpInputHover = server.hover(myOpInputRequest.buildHover()).get(); - String myOpInputHoverContent = myOpInputHover.getContents().getRight().getValue(); - assertThat(myOpInputHoverContent, containsString("@tags")); - assertThat(myOpInputHoverContent, containsString("structure MyOpInput with [HasMyBool]")); - assertThat(myOpInputHoverContent, containsString("/// even more docs")); - assertThat(myOpInputHoverContent, containsString("apply MyOpInput$myBool")); - - // on 'with [>HasMyBool]' - RequestBuilders.PositionRequest hasMyBoolRequest = new RequestBuilders.PositionRequest() - .uri(foo) - .line(9) - .character(26); - - Location hasMyBoolLocation = server.definition(hasMyBoolRequest.buildDefinition()).get().getLeft().get(0); - assertThat(hasMyBoolLocation.getUri(), equalTo(bar)); - assertThat(hasMyBoolLocation.getRange().getStart(), equalTo(new Position(6, 0))); - - Hover hasMyBoolHover = server.hover(hasMyBoolRequest.buildHover()).get(); - String hasMyBoolHoverContent = hasMyBoolHover.getContents().getRight().getValue(); - assertThat(hasMyBoolHoverContent, containsString("@mixin")); - assertThat(hasMyBoolHoverContent, containsString("@tags")); - assertThat(hasMyBoolHoverContent, containsString("structure HasMyBool")); - assertThat(hasMyBoolHoverContent, not(containsString("///"))); - assertThat(hasMyBoolHoverContent, not(containsString("@documentation"))); - } - - @Test - public void newShapeMixinCompletion() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @mixin - structure Foo {} - - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("main.smithy"); - - server.didOpen(new RequestBuilders.DidOpen() - .uri(uri) - .text(model) - .build()); - - RangeBuilder range = new RangeBuilder() - .startLine(6) - .startCharacter(0) - .endLine(6) - .endCharacter(0); - RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text("s").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text("u").build()); - server.didChange(change.range(range.shiftRight().build()).text("c").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("u").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text("e").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("B").build()); - server.didChange(change.range(range.shiftRight().build()).text("a").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("w").build()); - server.didChange(change.range(range.shiftRight().build()).text("i").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("h").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("[]").build()); - server.didChange(change.range(range.shiftRight().build()).text("F").build()); - - server.getState().lifecycleManager().getTask(uri).get(); - - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" - $version: "2" - namespace com.foo - - @mixin - structure Foo {} - - structure Bar with [F]"""))); - - Position currentPosition = range.build().getStart(); - CompletionParams completionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .position(range.shiftRight().build().getStart()) - .buildCompletion(); - - assertThat(server.getFirstProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); - - List completions = server.completion(completionParams).get().getLeft(); - - assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); - } - - @Test - public void existingShapeMixinCompletion() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @mixin - structure Foo {} - - structure Bar {} - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("main.smithy"); - - server.didOpen(new RequestBuilders.DidOpen() - .uri(uri) - .text(model) - .build()); - - RangeBuilder range = new RangeBuilder() - .startLine(6) - .startCharacter(13) - .endLine(6) - .endCharacter(13); - RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("w").build()); - server.didChange(change.range(range.shiftRight().build()).text("i").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("h").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("[]").build()); - server.didChange(change.range(range.shiftRight().build()).text("F").build()); - - server.getState().lifecycleManager().getTask(uri).get(); - - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" - $version: "2" - namespace com.foo - - @mixin - structure Foo {} - - structure Bar with [F] {} - """))); - - Position currentPosition = range.build().getStart(); - CompletionParams completionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .position(range.shiftRight().build().getStart()) - .buildCompletion(); - - assertThat(server.getFirstProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); - - List completions = server.completion(completionParams).get().getLeft(); - - assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); - } - @Test public void diagnosticsOnMemberTarget() { String model = safeString(""" @@ -792,7 +251,8 @@ public void diagnosticsOnMemberTarget() { SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); @@ -816,7 +276,8 @@ public void diagnosticsOnInvalidStructureMember() { SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.getFirst(); @@ -843,7 +304,8 @@ public void diagnosticsOnUse() { SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); Diagnostic diagnostic = diagnostics.getFirst(); Document document = server.getFirstProject().getDocument(uri); @@ -872,7 +334,8 @@ public void diagnosticOnTrait() { .text(model) .build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); @@ -916,7 +379,8 @@ public void diagnosticsOnShape() throws Exception { .uri(uri) .build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); @@ -1724,118 +1188,6 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } - @Test - public void completionHoverDefinitionWithAbsoluteIds() throws Exception { - String modelText1 = safeString(""" - $version: "2" - namespace com.foo - use com.bar#Bar - @com.bar#baz - structure Foo { - bar: com.bar#Bar - } - """); - String modelText2 = safeString(""" - $version: "2" - namespace com.bar - string Bar - string Bar2 - @trait - structure baz {} - """); - TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("model-0.smithy"); - - // use com.b - RequestBuilders.PositionRequest useTarget = RequestBuilders.positionRequest() - .uri(uri) - .line(2) - .character(8); - // @com.b - RequestBuilders.PositionRequest trait = RequestBuilders.positionRequest() - .uri(uri) - .line(3) - .character(2); - // bar: com.ba - RequestBuilders.PositionRequest memberTarget = RequestBuilders.positionRequest() - .uri(uri) - .line(5) - .character(14); - - List useTargetCompletions = server.completion(useTarget.buildCompletion()).get().getLeft(); - List traitCompletions = server.completion(trait.buildCompletion()).get().getLeft(); - List memberTargetCompletions = server.completion(memberTarget.buildCompletion()).get().getLeft(); - - assertThat(useTargetCompletions, containsInAnyOrder(hasLabel("com.bar#Bar2"))); // won't match 'Bar' because its already imported - assertThat(traitCompletions, containsInAnyOrder(hasLabel("com.bar#baz"))); - assertThat(memberTargetCompletions, containsInAnyOrder(hasLabel("com.bar#Bar"), hasLabel("com.bar#Bar2"))); - - List useTargetLocations = server.definition(useTarget.buildDefinition()).get().getLeft(); - List traitLocations = server.definition(trait.buildDefinition()).get().getLeft(); - List memberTargetLocations = server.definition(memberTarget.buildDefinition()).get().getLeft(); - - String uri1 = workspace.getUri("model-1.smithy"); - - assertThat(useTargetLocations, hasSize(1)); - assertThat(useTargetLocations.get(0).getUri(), equalTo(uri1)); - assertThat(useTargetLocations.get(0).getRange().getStart(), equalTo(new Position(2, 0))); - - assertThat(traitLocations, hasSize(1)); - assertThat(traitLocations.get(0).getUri(), equalTo(uri1)); - assertThat(traitLocations.get(0).getRange().getStart(), equalTo(new Position(5, 0))); - - assertThat(memberTargetLocations, hasSize(1)); - assertThat(memberTargetLocations.get(0).getUri(), equalTo(uri1)); - assertThat(memberTargetLocations.get(0).getRange().getStart(), equalTo(new Position(2, 0))); - - Hover useTargetHover = server.hover(useTarget.buildHover()).get(); - Hover traitHover = server.hover(trait.buildHover()).get(); - Hover memberTargetHover = server.hover(memberTarget.buildHover()).get(); - - assertThat(useTargetHover.getContents().getRight().getValue(), containsString("string Bar")); - assertThat(traitHover.getContents().getRight().getValue(), containsString("structure baz {}")); - assertThat(memberTargetHover.getContents().getRight().getValue(), containsString("string Bar")); - } - - @Test - public void useCompletionDoesntAutoImport() throws Exception { - String modelText1 = safeString(""" - $version: "2" - namespace com.foo - """); - String modelText2 = safeString(""" - $version: "2" - namespace com.bar - string Bar - """); - TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("model-0.smithy"); - server.didOpen(RequestBuilders.didOpen() - .uri(uri) - .text(modelText1) - .build()); - server.didChange(RequestBuilders.didChange() - .uri(uri) - .range(LspAdapter.point(2, 0)) - .text("use co") - .build()); - - List completions = server.completion(RequestBuilders.positionRequest() - .uri(uri) - .line(2) - .character(5) - .buildCompletion()) - .get() - .getLeft(); - - assertThat(completions, containsInAnyOrder(hasLabel("com.bar#Bar"))); - assertThat(completions.get(0).getAdditionalTextEdits(), nullValue()); - } - @Test public void loadsMultipleRoots() { TestWorkspace workspaceFoo = TestWorkspace.builder() diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index f7337b54..a806b492 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -60,7 +60,8 @@ public void noVersionDiagnostic() throws Exception { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) @@ -110,7 +111,8 @@ public void oldVersionDiagnostic() throws Exception { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) @@ -161,7 +163,8 @@ public void mostRecentVersion() { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) @@ -180,7 +183,8 @@ public void noShapes() { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) diff --git a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java new file mode 100644 index 00000000..6cb2b594 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java @@ -0,0 +1,53 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.Document; + +/** + * Wraps some text and positions within that text for easier testing of features + * that operate on cursor positions within a text document. + * + * @param text The underlying text + * @param positions The positions within {@code text} + */ +public record TextWithPositions(String text, Position... positions) { + private static final String POSITION_MARKER = "%"; + + /** + * A convenience method for constructing {@link TextWithPositions} without + * manually specifying the positions, which are error-prone and hard to + * read. + * + *

The string provided to this method can contain position markers, + * the {@code %} character, denoting where {@link #positions} should + * be. Each marker will be removed from {@link #text}.

+ * + * @param raw The raw string with position markers + * @return {@link TextWithPositions} with positions where the markers were, + * and those markers removed. + */ + public static TextWithPositions from(String raw) { + Document document = Document.of(safeString(raw)); + List positions = new ArrayList<>(); + int i = 0; + while (true) { + int next = document.nextIndexOf(POSITION_MARKER, i); + if (next < 0) { + break; + } + Position position = document.positionAtIndex(next); + positions.add(position); + i = next + 1; + } + String text = document.copyText().replace(POSITION_MARKER, ""); + return new TextWithPositions(text, positions.toArray(new Position[0])); + }} diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java index 27e31ed6..814882e5 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -6,21 +6,13 @@ package software.amazon.smithy.lsp.document; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static software.amazon.smithy.lsp.document.DocumentTest.safeIndex; import static software.amazon.smithy.lsp.document.DocumentTest.safeString; -import static software.amazon.smithy.lsp.document.DocumentTest.string; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import java.util.stream.Stream; - import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.junit.jupiter.api.Test; @@ -28,99 +20,9 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.shapes.Shape; public class DocumentParserTest { - @Test - public void jumpsToLines() { - String text = """ - abc - def - ghi - - - """; - DocumentParser parser = DocumentParser.of(safeString(text)); - assertEquals(0, parser.position()); - assertEquals(1, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(0); - assertEquals(0, parser.position()); - assertEquals(1, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(1); - assertEquals(safeIndex(4, 1), parser.position()); - assertEquals(2, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(2); - assertEquals(safeIndex(8, 2), parser.position()); - assertEquals(3, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(3); - assertEquals(safeIndex(12, 3), parser.position()); - assertEquals(4, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(4); - assertEquals(safeIndex(13, 4), parser.position()); - assertEquals(5, parser.line()); - assertEquals(1, parser.column()); - } - - @Test - public void jumpsToSource() { - String text = "abc\ndef\nghi\n"; - DocumentParser parser = DocumentParser.of(safeString(text)); - assertThat(parser.position(), is(0)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(1)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 0))); - - boolean ok = parser.jumpToSource(new SourceLocation("", 1, 2)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(1)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(2)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 1))); - - ok = parser.jumpToSource(new SourceLocation("", 1, 4)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(3)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - - ok = parser.jumpToSource(new SourceLocation("", 1, 6)); - assertThat(ok, is(false)); - assertThat(parser.position(), is(3)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - - ok = parser.jumpToSource(new SourceLocation("", 2, 1)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(safeIndex(4, 1))); - assertThat(parser.line(), is(2)); - assertThat(parser.column(), is(1)); - assertThat(parser.currentPosition(), equalTo(new Position(1, 0))); - - ok = parser.jumpToSource(new SourceLocation("", 4, 1)); - assertThat(ok, is(false)); - - ok = parser.jumpToSource(new SourceLocation("", 3, 4)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(safeIndex(11, 2))); - assertThat(parser.line(), is(3)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(2, 3))); - } - @Test public void getsDocumentNamespace() { DocumentParser noNamespace = DocumentParser.of(safeString("abc\ndef\n")); @@ -135,20 +37,20 @@ public void getsDocumentNamespace() { DocumentParser notNamespace = DocumentParser.of(safeString("namespace !foo")); DocumentParser trailingComment = DocumentParser.of(safeString("namespace com.foo//foo\n")); - assertThat(noNamespace.documentNamespace(), nullValue()); - assertThat(incompleteNamespace.documentNamespace(), nullValue()); - assertThat(incompleteNamespaceValue.documentNamespace(), nullValue()); - assertThat(likeNamespace.documentNamespace(), nullValue()); - assertThat(otherLikeNamespace.documentNamespace(), nullValue()); - assertThat(namespaceAtEnd.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(noNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteNamespaceValue.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(likeNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(otherLikeNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(namespaceAtEnd.documentNamespace().namespace(), equalTo("com.foo")); assertThat(namespaceAtEnd.documentNamespace().statementRange(), equalTo(LspAdapter.of(2, 0, 2, 17))); - assertThat(brokenNamespace.documentNamespace(), nullValue()); - assertThat(commentedNamespace.documentNamespace(), nullValue()); - assertThat(wsPrefixedNamespace.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(brokenNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(commentedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(wsPrefixedNamespace.documentNamespace().namespace(), equalTo("com.foo")); assertThat(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.of(1, 4, 1, 21))); - assertThat(notNamespace.documentNamespace(), nullValue()); - assertThat(trailingComment.documentNamespace().namespace().toString(), equalTo("com.foo")); - assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 22))); + assertThat(notNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(trailingComment.documentNamespace().namespace(), equalTo("com.foo")); + assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 17))); } @Test @@ -163,15 +65,15 @@ public void getsDocumentImports() { DocumentParser multiImports = DocumentParser.of(safeString("use com.foo#bar\nuse com.foo#baz")); DocumentParser notImport = DocumentParser.of(safeString("usea com.foo#bar")); - assertThat(noImports.documentImports(), nullValue()); - assertThat(incompleteImport.documentImports(), nullValue()); - assertThat(incompleteImportValue.documentImports(), nullValue()); + assertThat(noImports.documentImports().importsRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteImportValue.documentImports().importsRange(), equalTo(LspAdapter.origin())); assertThat(oneImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); assertThat(leadingWsImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); assertThat(trailingCommentImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); - assertThat(commentedImport.documentImports(), nullValue()); + assertThat(commentedImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); assertThat(multiImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz")); - assertThat(notImport.documentImports(), nullValue()); + assertThat(notImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); // Some of these aren't shape ids, but its ok DocumentParser brokenImport = DocumentParser.of(safeString("use com.foo")); @@ -203,20 +105,20 @@ public void getsDocumentVersion() { DocumentParser notSecond = DocumentParser.of(safeString("$foo: \"bar\"\n$bar: 1\n// abc\n$baz: 2\n $version: \"2\"")); DocumentParser notFirstNoVersion = DocumentParser.of(safeString("$foo: \"bar\"\nfoo\n")); - assertThat(noVersion.documentVersion(), nullValue()); - assertThat(notVersion.documentVersion(), nullValue()); - assertThat(noDollar.documentVersion(), nullValue()); - assertThat(noColon.documentVersion(), nullValue()); - assertThat(commented.documentVersion(), nullValue()); + assertThat(noVersion.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(notVersion.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(noDollar.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(noColon.documentVersion().version(), equalTo("2")); + assertThat(commented.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(leadingWs.documentVersion().version(), equalTo("2")); assertThat(leadingLines.documentVersion().version(), equalTo("2")); - assertThat(notStringNode.documentVersion(), nullValue()); + assertThat(notStringNode.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(trailingComment.documentVersion().version(), equalTo("2")); assertThat(trailingLine.documentVersion().version(), equalTo("2")); - assertThat(invalidNode.documentVersion(), nullValue()); + assertThat(invalidNode.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(notFirst.documentVersion().version(), equalTo("2")); assertThat(notSecond.documentVersion().version(), equalTo("2")); - assertThat(notFirstNoVersion.documentVersion(), nullValue()); + assertThat(notFirstNoVersion.documentVersion().range(), equalTo(LspAdapter.origin())); Range leadingWsRange = leadingWs.documentVersion().range(); Range trailingCommentRange = trailingComment.documentVersion().range(); @@ -230,102 +132,6 @@ public void getsDocumentVersion() { assertThat(notSecond.getDocument().copyRange(notSecondRange), equalTo("$version: \"2\"")); } - @Test - public void getsDocumentShapes() { - String text = """ - $version: "2" - namespace com.foo - string Foo - structure Bar { - bar: Foo - } - enum Baz { - ONE - TWO - } - intEnum Biz { - ONE = 1 - } - @mixin - structure Boz { - elided: String - } - structure Mixed with [Boz] { - $elided - } - operation Get { - input := { - a: Integer - } - } - """; - Set shapes = Model.assembler() - .addUnparsedModel("main.smithy", text) - .assemble() - .unwrap() - .shapes() - .filter(shape -> shape.getId().getNamespace().equals("com.foo")) - .collect(Collectors.toSet()); - - DocumentParser parser = DocumentParser.of(safeString(text)); - Map documentShapes = parser.documentShapes(shapes); - - DocumentShape fooDef = documentShapes.get(new Position(2, 7)); - DocumentShape barDef = documentShapes.get(new Position(3, 10)); - DocumentShape barMemberDef = documentShapes.get(new Position(4, 4)); - DocumentShape targetFoo = documentShapes.get(new Position(4, 9)); - DocumentShape bazDef = documentShapes.get(new Position(6, 5)); - DocumentShape bazOneDef = documentShapes.get(new Position(7, 4)); - DocumentShape bazTwoDef = documentShapes.get(new Position(8, 4)); - DocumentShape bizDef = documentShapes.get(new Position(10, 8)); - DocumentShape bizOneDef = documentShapes.get(new Position(11, 4)); - DocumentShape bozDef = documentShapes.get(new Position(14, 10)); - DocumentShape elidedDef = documentShapes.get(new Position(15, 4)); - DocumentShape targetString = documentShapes.get(new Position(15, 12)); - DocumentShape mixedDef = documentShapes.get(new Position(17, 10)); - DocumentShape elided = documentShapes.get(new Position(18, 4)); - DocumentShape get = documentShapes.get(new Position(20, 10)); - DocumentShape getInput = documentShapes.get(new Position(21, 13)); - DocumentShape getInputA = documentShapes.get(new Position(22, 8)); - - assertThat(fooDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(fooDef.shapeName(), string("Foo")); - assertThat(barDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(barDef.shapeName(), string("Bar")); - assertThat(barMemberDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(barMemberDef.shapeName(), string("bar")); - assertThat(barMemberDef.targetReference(), equalTo(targetFoo)); - assertThat(targetFoo.kind(), equalTo(DocumentShape.Kind.Targeted)); - assertThat(targetFoo.shapeName(), string("Foo")); - assertThat(bazDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(bazDef.shapeName(), string("Baz")); - assertThat(bazOneDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(bazOneDef.shapeName(), string("ONE")); - assertThat(bazTwoDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(bazTwoDef.shapeName(), string("TWO")); - assertThat(bizDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(bizDef.shapeName(), string("Biz")); - assertThat(bizOneDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(bizOneDef.shapeName(), string("ONE")); - assertThat(bozDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(bozDef.shapeName(), string("Boz")); - assertThat(elidedDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(elidedDef.shapeName(), string("elided")); - assertThat(elidedDef.targetReference(), equalTo(targetString)); - assertThat(targetString.kind(), equalTo(DocumentShape.Kind.Targeted)); - assertThat(targetString.shapeName(), string("String")); - assertThat(mixedDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(mixedDef.shapeName(), string("Mixed")); - assertThat(elided.kind(), equalTo(DocumentShape.Kind.Elided)); - assertThat(elided.shapeName(), string("elided")); - assertThat(parser.getDocument().borrowRange(elided.range()), string("$elided")); - assertThat(get.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(get.shapeName(), string("Get")); - assertThat(getInput.kind(), equalTo(DocumentShape.Kind.Inline)); - assertThat(getInputA.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(getInputA.shapeName(), string("a")); - } - @ParameterizedTest @MethodSource("contiguousRangeTestCases") public void findsContiguousRange(SourceLocation input, Range expected) { diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java new file mode 100644 index 00000000..b5d3e324 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -0,0 +1,1104 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.LspMatchers; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; + +public class CompletionHandlerTest { + @Test + public void getsCompletions() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar", "baz")); + } + + @Test + public void completesTraitMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(bar: %) + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"")); + } + + @Test + public void completesMetadataMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace:% + }]"""); + List comps = getCompLabels(text); + + assertThat(comps, not(empty())); + } + + @Test + public void doesntDuplicateTraitBodyMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: String + } + + @foo(bar: "", ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void doesntDuplicateMetadataMembers() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace: "foo" + %}] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "reason")); + } + + @Test + public void doesntDuplicateListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + list L { + member: String + %} + map M { + key: String + %} + """); + List comps = getCompLabels(text); + + + assertThat(comps, contains("value")); + } + + @Test + public void doesntDuplicateOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + operation O { + input := {} + %} + """); + List comps = getCompLabels(text); + assertThat(comps, containsInAnyOrder("output", "errors")); + } + + @Test + public void doesntDuplicateServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + service S { + version: "2024-08-31" + operations: [] + %} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("rename", "resources", "errors")); + } + + @Test + public void doesntDuplicateResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + resource R { + identifiers: {} + properties: {} + read: Op + create: Op + %} + + operation Op {} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "list", "put", "delete", "update", "collectionOperations", "operations", "resources")); + } + + @Test + public void completesEnumTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + enum foo { + ONE + TWO + THREE + } + + @foo(T%) + """); + List comps = getCompItems(text.text(), text.positions()); + + List labels = comps.stream().map(CompletionItem::getLabel).toList(); + List editText = comps.stream() + .map(completionItem -> { + if (completionItem.getTextEdit() != null) { + return completionItem.getTextEdit().getLeft().getNewText(); + } else { + return completionItem.getInsertText(); + } + }).toList(); + + assertThat(labels, containsInAnyOrder("TWO", "THREE")); + assertThat(editText, containsInAnyOrder("\"TWO\"", "\"THREE\"")); + // TODO: Fix this issue where the string is inserted within the enclosing "" + } + + @Test + public void completesFromSingleCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @http(m%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method")); + } + + @Test + public void completesBuiltinControlKeys() { + TextWithPositions text = TextWithPositions.from(""" + $ver% + $ope%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + startsWith("$version: \"2.0\""), + startsWith("$operationInputSuffix: \"Input\""), + startsWith("$operationOutputSuffix: \"Output\""))); + } + + @Test + public void completesBuiltinMetadataKeys() { + TextWithPositions text = TextWithPositions.from(""" + metadata su% + metadata va%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("suppressions = []", "validators = []")); + } + + @Test + public void completesStatementKeywords() { + TextWithPositions text = TextWithPositions.from(""" + us% + ma% + met% + nam% + blo% + boo% + str% + byt% + sho% + int% + lon% + flo% + dou% + big% + tim% + doc% + enu% + lis% + uni% + ser% + res% + ope% + app%"""); + List comps = getCompLabels(text); + + String[] keywords = CompletionCandidates.KEYWORD.literals().toArray(new String[0]); + assertThat(comps, containsInAnyOrder(keywords)); + } + + @Test + public void completesServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + service One { + ver% + ope% + res% + err% + ren% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("version", "operations", "resources", "errors", "rename")); + } + + @Test + public void completesResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + resource A { + ide% + pro% + cre% + pu% + rea% + upd% + del% + lis% + ope% + coll% + res% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "identifiers", + "properties", + "create", + "put", + "read", + "update", + "delete", + "list", + "operations", + "collectionOperations", + "resources")); + } + + @Test + public void completesOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + operation Op { + inp% + out% + err% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("input", "output", "errors")); + } + + @Test + public void completesListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + map M { + k% + v% + } + list L { + m% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("key", "value", "member")); + } + + @Test + public void completesMetadataValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata validators = [{ nam% }] + metadata suppressions = [{ rea% }] + metadata severityOverrides = [{ sev% }] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("namespaces", "name", "reason", "severity")); + } + + @Test + public void completesMetadataValueWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "namespace", "reason")); + } + + @Test + public void completesTraitValueWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @http(% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method", "uri", "code")); + } + + @Test + public void completesShapeMemberNameWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + list Foo { + % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("member")); + } + + // TODO: These next two shouldn't need the space after ':' + @Test + public void completesMemberTargetsWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + structure Foo { + bar: % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("String", "Integer", "Float")); + } + + @Test + public void completesOperationMemberTargetsWithoutStartingCharacters() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + structure Foo {} + operation Bar { + input: % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("Foo")); + } + + @Test + public void completesTraitsWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + @%"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("http")); + } + + @Test + public void completesOperationErrors() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + @error("client") + structure MyError {} + + operation Foo { + errors: [% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("MyError")); + } + + @Test + public void completesServiceMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + service Foo { + operations: [%] + resources: [%] + errors: [%] + } + operation MyOp {} + resource MyResource {} + @error("client") + structure MyError {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("MyOp", "MyResource", "MyError")); + } + + @Test + public void completesResourceMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + resource Foo { + create: M% + operations: [O%] + resources: [%] + } + operation MyOp {} + operation OtherOp {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("MyOp", "OtherOp", "Foo")); + } + + @Test + public void insertionTextHasCorrectRange() { + TextWithPositions text = TextWithPositions.from("metadata suppressions = [%]"); + + var comps = getCompItems(text.text(), text.positions()); + var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList(); + + assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "metadata suppressions = [{}]")); + } + + @Test + public void replacementTextHasCorrectRange() { + TextWithPositions text = TextWithPositions.from("strin%"); + + var comps = getCompItems(text.text(), text.positions()); + var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList(); + + assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "string")); + } + + @Test + public void completesNamespace() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo%"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("com.foo")); + } + + // TODO: This shouldn't need the space after the ':' + @Test + public void completesInlineOpMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + operation Op { + input := + @tags([]) + { + foo: % + } + } + """); + List comps = getCompLabels(text); + + + assertThat(comps, hasItem("String")); + } + + @Test + public void completesNamespacesInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata suppressions = [{ + id: "foo" + namespace:% + }] + namespace com.foo + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("*")); + } + + @Test + public void completesSeverityInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata severityOverrides = [{ + id: "foo" + severity:% + }] + namespace com.foo + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("WARNING", "DANGER")); + } + + @Test + public void completesValidatorNamesInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata validators = [{ + id: "foo" + name:% + }] + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("EmitEachSelector", "EmitNoneSelector")); + } + + @Test + public void completesValidatorConfigInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata validators = [{ + id: "foo" + name: "EmitNoneSelector" + configuration: {%} + }] + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("selector")); + } + + @Test + public void doesntCompleteTraitsAfterClosingParen() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @error("client")% + structure Foo {} + """); + List comps = getCompLabels(text); + + assertThat(comps, empty()); + } + + @Test + public void doesntCompleteTraitsAfterClosingParen2() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bool: Boolean + } + + @foo(bool: true)% + structure Foo {} + """); + List comps = getCompLabels(text); + + assertThat(comps, empty()); + } + + @Test + public void recursiveTraitDef() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + bar: Bar + } + + @foo(bar: { bar: { b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar")); + } + + @Test + public void recursiveTraitDef2() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + one: Baz + } + + structure Baz { + two: Bar + } + + @foo(bar: { one: { two: { o% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("one")); + } + + @Test + public void recursiveTraitDef3() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + list Bar { + member: Baz + } + + structure Baz { + bar: Bar + } + + @foo(bar: [{bar: [{b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar")); + } + + @Test + public void recursiveTraitDef4() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + baz: Baz + } + + list Baz { + member: Bar + } + + @foo(bar: {baz:[{baz:[{b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void recursiveTraitDef5() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + baz: Baz + } + + map Baz { + key: String + value: Bar + } + + @foo(bar: {baz: {key: {baz: {key: {b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void completesInlineForResource() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + resource MyResource { + } + + operation Foo { + input := for % + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("MyResource")); + } + + @Test + public void completesElidedMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + resource MyResource { + identifiers: { one: String } + properties: { abc: String } + } + + resource MyResource2 { + identifiers: { two: String } + properties: { def: String } + } + + @mixin + structure MyMixin { + foo: String + } + + @mixin + structure MyMixin2 { + bar: String + } + + structure One for MyResource { + $% + } + + structure Two with [MyMixin] { + $% + } + + operation MyOp { + input := for MyResource2 { + $% + } + output := with [MyMixin2] { + $% + } + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("$one", "$foo", "$two", "$bar", "$abc", "$def")); + } + + @Test + public void traitsWithMaps() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + myMap: MyMap + } + + map MyMap { + key: String + value: String + } + + @foo(myMap: %) + structure A {} + + @foo(myMap: {%}) + structure B {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("{}")); + } + + @Test + public void applyTarget() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string Zzz + + apply Z% + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("Zzz")); + } + + @Test + public void enumMapKeys() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + enum Keys { + FOO = "foo" + BAR = "bar" + } + + @trait + map mapTrait { + key: Keys + value: String + } + + @mapTrait(%) + string Foo + + @mapTrait({%}) + string Bar + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("FOO", "BAR", "FOO", "BAR")); + } + + @Test + public void dynamicTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace smithy.test + + @trait + list smokeTests { + member: SmokeTestCase + } + + structure SmokeTestCase { + params: Document + vendorParams: Document + vendorParamsShape: ShapeId + } + + @idRef + string ShapeId + + @smokeTests([ + { + params: {%} + vendorParamsShape: MyVendorParams + vendorParams: {%} + } + ]) + operation Foo { + input := { + bar: String + } + } + + structure MyVendorParams { + abc: String + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("bar", "abc")); + } + + @Test + public void doesntDuplicateElidedMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + ade: String + } + + structure Bar with [Foo] { + $abc + $% + } + + structure Baz with [Foo] { + abc: String + $% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$ade", "$ade")); + } + + @Test + public void knownMemberNamesWithElided() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + map Foo { + key: String + value: String + } + + map Bar with [Foo] { + key: String + % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("value", "$value")); + } + + @Test + public void unknownMemberNamesWithElided() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + def: String + } + + structure Bar with [Foo] { + $abc + % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$def")); + } + + @Test + public void completesElidedMembersWithoutLeadingDollar() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + } + + structure Bar with [Foo] { + ab% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$abc")); + } + + @Test + public void completesNodeMemberTargetStart() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + service A { + version: % + } + service B { + operations: % + } + resource C { + identifiers: % + } + operation D { + errors: % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"", "[]", "{}", "[]")); + } + + @Test + public void completesAbsoluteShapeIds() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: smithy.% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("smithy.api#String")); + } + + @Test + public void completesUseTarget() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + use smithy.api#Strin% + """); + List comps = getCompItems(text.text(), text.positions()); + + assertThat(comps, hasSize(1)); + CompletionItem item = comps.get(0); + assertThat(item.getTextEdit().getLeft().getNewText(), equalTo("smithy.api#String")); + assertThat(item.getAdditionalTextEdits(), nullValue()); + } + + private static List getCompLabels(TextWithPositions textWithPositions) { + return getCompLabels(textWithPositions.text(), textWithPositions.positions()); + } + + private static List getCompLabels(String text, Position... positions) { + return getCompItems(text, positions).stream().map(CompletionItem::getLabel).toList(); + } + + private static List getCompItems(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + IdlFile smithyFile = (IdlFile) project.getSmithyFile(uri); + + List completionItems = new ArrayList<>(); + CompletionHandler handler = new CompletionHandler(project, smithyFile); + for (Position position : positions) { + CompletionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildCompletion(); + completionItems.addAll(handler.handle(params, () -> {})); + } + + return completionItems; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java new file mode 100644 index 00000000..0dbed9c4 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -0,0 +1,384 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; + +public class DefinitionHandlerTest { + @Test + public void getsPreludeTraitIdLocations() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @tags([]) + string Foo + """); + GetLocationsResult onAt = getLocations(text, new Position(3, 0)); + GetLocationsResult ok = getLocations(text, new Position(3, 1)); + GetLocationsResult atEnd = getLocations(text, new Position(3, 5)); + + assertThat(onAt.locations, empty()); + + assertThat(ok.locations, hasSize(1)); + assertThat(ok.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(ok, ok.locations.getFirst(), "list tags"); + + assertThat(atEnd.locations, empty()); + } + + @Test + public void getsTraitIdsLocationsInCurrentFile() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + string foo + + @foo + string Bar + """); + GetLocationsResult result = getLocations(text, new Position(6, 1)); + + assertThat(result.locations, hasSize(1)); + Location location = result.locations.getFirst(); + assertThat(location.getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, location, "string foo"); + } + + @Test + public void shapeDefs() { + String text = safeString(""" + $version: "2" + namespace com.foo + + structure Foo {} + + structure Bar { + foo: Foo + } + """); + GetLocationsResult onShapeDef = getLocations(text, new Position(3, 10)); + assertThat(onShapeDef.locations, hasSize(1)); + assertThat(onShapeDef.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(onShapeDef, onShapeDef.locations.getFirst(), "structure Foo"); + + GetLocationsResult memberTarget = getLocations(text, new Position(6, 9)); + assertThat(memberTarget.locations, hasSize(1)); + assertThat(memberTarget.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(memberTarget, memberTarget.locations.getFirst(), "structure Foo"); + } + + @Test + public void forResource() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo {} + + structure Bar for Foo {} + """); + GetLocationsResult result = getLocations(text, new Position(5, 18)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "resource Foo"); + } + + @Test + public void mixin() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo {} + + structure Bar with [Foo] {} + """); + GetLocationsResult result = getLocations(text, new Position(6, 20)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void useTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + use smithy.api#tags + """); + GetLocationsResult result = getLocations(text, new Position(2, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "list tags"); + } + + @Test + public void applyTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + structure Foo {} + + apply Foo @tags([]) + """); + GetLocationsResult result = getLocations(text, new Position(5, 6)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void nodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + service Foo { + version: "0" + operations: [Bar] + } + + operation Bar {} + """); + GetLocationsResult result = getLocations(text, new Position(5, 17)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "operation Bar"); + } + + @Test + public void nestedNodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo { + identifiers: { + foo: String + } + } + """); + GetLocationsResult result = getLocations(text, new Position(5, 13)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string String"); + } + + @Test + public void traitValueTopLevelKey() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + } + + @foo(bar: "") + string Baz + """); + GetLocationsResult result = getLocations(text, new Position(8, 7)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "bar: String"); + } + + @Test + public void traitValueNestedKey() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: BarList + } + + list BarList { + member: Bar + } + + structure Bar { + baz: String + } + + @foo(bar: [{ baz: "one" }, { baz: "two" }]) + string S + """); + GetLocationsResult result = getLocations(text, new Position(16, 29)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "baz: String"); + } + + @Test + public void elidedMixinMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + bar: String + } + + structure Bar with [Foo] { + $bar + } + """); + GetLocationsResult result = getLocations(text, new Position(9, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void elidedResourceMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo { + identifiers: { + bar: String + } + } + + structure Bar for Foo { + $bar + } + """); + GetLocationsResult result = getLocations(text, new Position(10, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "resource Foo"); + } + + @Test + public void idRefTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @idRef + string ShapeId + + @trait + structure foo { + id: ShapeId + } + + string Bar + + @foo(id: %Bar) + structure Baz {} + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string Bar"); + } + + @Test + public void absoluteShapeId() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: %smithy.api#String + } + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string String"); + } + + private static void assertIsShapeDef( + GetLocationsResult result, + Location location, + String expected + ) { + SmithyFile smithyFile = result.handler.project.getSmithyFile(location.getUri()); + assertThat(smithyFile, notNullValue()); + + int documentIndex = smithyFile.document().indexOfPosition(location.getRange().getStart()); + assertThat(documentIndex, greaterThanOrEqualTo(0)); + + StatementView view = StatementView.createAt(((IdlFile) smithyFile).getParse(), documentIndex).orElse(null); + assertThat(view, notNullValue()); + assertThat(view.statementIndex(), greaterThanOrEqualTo(0)); + + var statement = view.getStatement(); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + String shapeType = shapeDef.shapeType().stringValue(); + String shapeName = shapeDef.shapeName().stringValue(); + assertThat(shapeType + " " + shapeName, equalTo(expected)); + } else if (statement instanceof Syntax.Statement.MemberDef memberDef) { + String memberName = memberDef.name().stringValue(); + String memberTarget = memberDef.target().stringValue(); + assertThat(memberName + ": " + memberTarget, equalTo(expected)); + } else { + fail("Expected shape or member def, but was " + statement.getClass().getName()); + } + } + + record GetLocationsResult(DefinitionHandler handler, List locations) {} + + private static GetLocationsResult getLocations(TextWithPositions textWithPositions) { + return getLocations(textWithPositions.text(), textWithPositions.positions()); + } + + private static GetLocationsResult getLocations(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + SmithyFile smithyFile = project.getSmithyFile(uri); + + List locations = new ArrayList<>(); + DefinitionHandler handler = new DefinitionHandler(project, (IdlFile) smithyFile); + for (Position position : positions) { + DefinitionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildDefinition(); + locations.addAll(handler.handle(params)); + } + + return new GetLocationsResult(handler, locations); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java new file mode 100644 index 00000000..ab2e521e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItems; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; + +public class DocumentSymbolTest { + @Test + public void documentSymbols() { + String model = safeString(""" + $version: "2" + namespace com.foo + + @trait + string myTrait + + structure Foo { + @required + bar: Bar + } + + structure Bar { + @myTrait("foo") + baz: Baz + } + + @myTrait("abc") + integer Baz + """); + List names = getDocumentSymbolNames(model); + + assertThat(names, hasItems("myTrait", "Foo", "bar", "Bar", "baz", "Baz")); + } + + private static List getDocumentSymbolNames(String text) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getSmithyFile(uri); + + List names = new ArrayList<>(); + var handler = new DocumentSymbolHandler(idlFile.document(), idlFile.getParse().statements()); + for (var sym : handler.handle()) { + names.add(sym.getRight().getName()); + } + return names; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java new file mode 100644 index 00000000..5f37e89e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -0,0 +1,173 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.model.validation.Severity; + +public class HoverHandlerTest { + @Test + public void controlKey() { + String text = safeString(""" + $version: "2" + """); + List hovers = getHovers(text, new Position(0, 1)); + + assertThat(hovers, contains(containsString("version"))); + } + + @Test + public void metadataKey() { + String text = safeString(""" + metadata suppressions = [] + """); + List hovers = getHovers(text, new Position(0, 9)); + + assertThat(hovers, contains(containsString("suppressions"))); + } + + @Test + public void metadataValue() { + String text = safeString(""" + metadata suppressions = [{id: "foo"}] + """); + List hovers = getHovers(text, new Position(0, 26)); + + assertThat(hovers, contains(containsString("id"))); + } + + @Test + public void traitValue() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @http(method: "GET", uri: "/") + operation Foo {} + """); + List hovers = getHovers(text, new Position(3, 7)); + + assertThat(hovers, contains(containsString("method: NonEmptyString"))); + } + + @Test + public void elidedMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + bar: String + } + + structure Bar with [Foo] { + $bar + } + """); + List hovers = getHovers(text, new Position(9, 5)); + + assertThat(hovers, contains(containsString("bar: String"))); + } + + @Test + public void nodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + service Foo { + version: "0" + operations: [Bar] + } + + operation Bar {} + """); + List hovers = getHovers(text, new Position(5, 17)); + + assertThat(hovers, contains(containsString("operation Bar"))); + } + + @Test + public void absoluteShapeId() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: %smithy.api#String + } + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("string String"))); + } + + @Test + public void selfShapeDefinition() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure %Foo {} + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("structure Foo"))); + } + + @Test + public void selfMemberDefinition() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + %bar: String + } + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("bar: String"))); + } + + private static List getHovers(TextWithPositions text) { + return getHovers(text.text(), text.positions()); + } + + private static List getHovers(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + SmithyFile smithyFile = project.getSmithyFile(uri); + + List hover = new ArrayList<>(); + HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile, Severity.WARNING); + for (Position position : positions) { + HoverParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildHover(); + hover.add(handler.handle(params).getContents().getRight().getValue()); + } + + return hover; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java index 21790ba4..a7c983f3 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -19,13 +19,13 @@ import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; -import static software.amazon.smithy.lsp.document.DocumentTest.string; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Set; import java.util.logging.Logger; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; @@ -36,6 +36,7 @@ import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.LengthTrait; import software.amazon.smithy.model.traits.PatternTrait; import software.amazon.smithy.model.traits.TagsTrait; @@ -120,24 +121,19 @@ public void loadsWhenModelHasInvalidSyntax() { assertThat(eventIds, hasItem("Model")); assertThat(project.smithyFiles().keySet(), hasItem(containsString("main.smithy"))); - SmithyFile main = project.getSmithyFile(LspAdapter.toUri(root.resolve("main.smithy").toString())); + IdlFile main = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("main.smithy").toString())); assertThat(main, not(nullValue())); assertThat(main.document(), not(nullValue())); - assertThat(main.namespace(), string("com.foo")); - assertThat(main.imports(), empty()); + assertThat(main.getParse().namespace().namespace(), equalTo("com.foo")); + assertThat(main.getParse().imports().imports(), empty()); - assertThat(main.shapes(), hasSize(2)); - List shapeIds = main.shapes().stream() - .map(Shape::toShapeId) + assertThat(project.definedShapesByFile().keySet(), hasItem(main.path())); + Set mainShapes = project.definedShapesByFile().get(main.path()); + List shapeIds = mainShapes.stream() + .map(ToShapeId::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar")); - - assertThat(main.documentShapes(), hasSize(3)); - List documentShapeNames = main.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(documentShapeNames, hasItems("Foo", "bar", "String")); } @Test @@ -149,31 +145,29 @@ public void loadsProjectWithMultipleNamespaces() { assertThat(project.modelResult().getValidationEvents(), empty()); assertThat(project.smithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); - SmithyFile a = project.getSmithyFile(LspAdapter.toUri(root.resolve("model/a.smithy").toString())); + IdlFile a = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("model/a.smithy").toString())); assertThat(a.document(), not(nullValue())); - assertThat(a.namespace(), string("a")); - List aShapeIds = a.shapes().stream() - .map(Shape::toShapeId) + assertThat(a.getParse().namespace().namespace(), equalTo("a")); + + assertThat(project.definedShapesByFile().keySet(), hasItem(a.path())); + Set aShapes = project.definedShapesByFile().get(a.path()); + List aShapeIds = aShapes.stream() + .map(ToShapeId::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(aShapeIds, hasItems("a#Hello", "a#HelloInput", "a#HelloOutput")); - List aDocumentShapeNames = a.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(aDocumentShapeNames, hasItems("Hello", "name", "String")); - SmithyFile b = project.getSmithyFile(LspAdapter.toUri(root.resolve("model/b.smithy").toString())); + IdlFile b = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("model/b.smithy").toString())); assertThat(b.document(), not(nullValue())); - assertThat(b.namespace(), string("b")); - List bShapeIds = b.shapes().stream() - .map(Shape::toShapeId) + assertThat(b.getParse().namespace().namespace(), equalTo("b")); + + assertThat(project.definedShapesByFile().keySet(), hasItem(b.path())); + Set bShapes = project.definedShapesByFile().get(b.path()); + List bShapeIds = bShapes.stream() + .map(ToShapeId::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(bShapeIds, hasItems("b#Hello", "b#HelloInput", "b#HelloOutput")); - List bDocumentShapeNames = b.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(bDocumentShapeNames, hasItems("Hello", "name", "String")); } @Test diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java new file mode 100644 index 00000000..004cceb0 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -0,0 +1,469 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.document.Document; + +public class IdlParserTest { + @Test + public void parses() { + String text = """ + string Foo + @tags(["foo"]) + structure Bar { + baz: String + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef); + } + + @Test + public void parsesStatements() { + String text = """ + $version: "2" + metadata foo = [{ bar: 2 }] + namespace com.foo + + use com.bar#baz + + @baz + structure Foo { + @baz + bar: String + } + + enum Bar { + BAZ = "BAZ" + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Metadata, + Syntax.Statement.Type.Namespace, + Syntax.Statement.Type.Use, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.EnumMemberDef); + } + + @Test + public void parsesMixinsAndForResource() { + String text = """ + structure Foo with [Mix] {} + structure Bar for Resource {} + structure Baz for Resource with [Mix] {} + structure Bux with [One, Two, Three] {} + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins); + } + + @Test + public void parsesOp() { + String text = """ + operation One {} + operation Two { + input: Input + } + operation Three { + input: Input + output: Output + } + operation Four { + input: Input + errors: [Err] + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void parsesOpInline() { + String text = """ + operation One { + input := { + foo: String + } + output := { + @foo + foo: String + } + } + operation Two { + input := for Foo { + foo: String + } + output := with [Bar] { + bar: String + } + } + operation Three { + input := for Foo with [Bar, Baz] {} + } + operation Four { + input := @foo {} + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication); + } + + @Test + public void parsesOpInlineWithTraits() { + String text = safeString(""" + operation Op { + input := @foo { + foo: Foo + } + output := {} + }"""); + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef); + } + + @Test + public void parsesServiceAndResource() { + String text = """ + service Foo { + version: "2024-08-15 + operations: [ + Op1 + Op2 + ] + errors: [ + Err1 + Err2 + ] + } + resource Bar { + identifiers: { id: String } + properties: { prop: String } + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void ignoresComments() { + String text = """ + // one + $version: "2" // two + + namespace com.foo // three + // four + use com.bar#baz // five + + // six + @baz // seven + structure Foo // eight + { // nine + // ten + bar: String // eleven + } // twelve + + enum Bar // thirteen + { // fourteen + // fifteen + BAR // sixteen + } // seventeen + service Baz // eighteen + { // nineteen + // twenty + version: "" // twenty one + } // twenty two + """; + + assertTypesEqual(text, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Namespace, + Syntax.Statement.Type.Use, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.EnumMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void defaultAssignments() { + String text = """ + structure Foo { + one: One = "" + two: Two = 2 + three: Three = false + four: Four = [] + five: Five = {} + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef); + } + + @Test + public void stringKeysInTraits() { + String text = """ + @foo( + "bar": "baz" + ) + """; + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + assertThat(parse.statements(), hasSize(1)); + assertThat(parse.statements().get(0), instanceOf(Syntax.Statement.TraitApplication.class)); + + Syntax.Statement.TraitApplication traitApplication = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var nodeTypes = NodeParserTest.getNodeTypes(traitApplication.value()); + + assertThat(nodeTypes, contains( + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str)); + } + + @ParameterizedTest + @MethodSource("brokenProvider") + public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { + if (desc.equals("trait missing member value")) { + System.out.println(); + } + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); + List types = parse.statements().stream() + .map(Syntax.Statement::type) + .filter(type -> type != Syntax.Statement.Type.Block) + .toList(); + + assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); + assertThat(desc, types, equalTo(expectedTypes)); + } + + record InvalidSyntaxTestCase( + String description, + String text, + List expectedErrorMessages, + List expectedTypes + ) {} + + private static final List INVALID_SYNTAX_TEST_CASES = List.of( + new InvalidSyntaxTestCase( + "empty", + "", + List.of(), + List.of() + ), + new InvalidSyntaxTestCase( + "just shape type", + "structure", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "missing resource", + "string Foo for", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.ForResource) + ), + new InvalidSyntaxTestCase( + "unexpected line break", + "string \nstring Foo", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete, Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "unexpected token", + "string [", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "unexpected token 2", + "string Foo [", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "enum missing {", + "enum Foo\nBAR}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef) + ), + new InvalidSyntaxTestCase( + "enum missing }", + "enum Foo {BAR", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing {", + "structure Foo\nbar: String}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing }", + "structure Foo {bar: String", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "op with inline missing {", + "operation Foo\ninput := {}}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef) + ), + new InvalidSyntaxTestCase( + "op with inline missing }", + "operation Foo{input:={}", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing {", + "resource Foo\nidentifiers:{}}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing }", + "service Foo{operations:[]", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "apply missing @", + "apply Foo", + List.of("expected trait or block"), + List.of(Syntax.Statement.Type.Apply) + ), + new InvalidSyntaxTestCase( + "apply missing }", + "apply Foo {@bar", + List.of("expected }"), + List.of(Syntax.Statement.Type.Apply, Syntax.Statement.Type.TraitApplication) + ), + new InvalidSyntaxTestCase( + "trait missing member value", + "@foo(bar: )\nstring Foo", + List.of("expected value"), + List.of(Syntax.Statement.Type.TraitApplication, Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "inline with member missing target", + """ + operation Op { + input := + @tags([]) + { + foo:\s + } + }""", + List.of("expected identifier"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef) + ) + ); + + private static Stream brokenProvider() { + return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of( + invalidSyntaxTestCase.description, + invalidSyntaxTestCase.text, + invalidSyntaxTestCase.expectedErrorMessages, + invalidSyntaxTestCase.expectedTypes)); + } + + private static void assertTypesEqual(String text, Syntax.Statement.Type... types) { + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + List actualTypes = parse.statements().stream() + .map(Syntax.Statement::type) + .filter(type -> type != Syntax.Statement.Type.Block) + .toList(); + assertThat(actualTypes, contains(types)); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java new file mode 100644 index 00000000..9fd2bb1f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.document.Document; + +public class NodeCursorTest { + @Test + public void findsNodeCursor() { + String text = safeString(""" + { + "foo": "bar" + }"""); + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); + + assertCursorMatches(cursor, new NodeCursor(List.of( + new NodeCursor.Obj(null), + new NodeCursor.Key("foo", null), + new NodeCursor.Terminal(null) + ))); + } + + @Test + public void findsNodeCursorWhenBroken() { + String text = safeString(""" + { + "foo" + }"""); + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); + + assertCursorMatches(cursor, new NodeCursor(List.of( + new NodeCursor.Obj(null), + new NodeCursor.Key("foo", null), + new NodeCursor.Terminal(null) + ))); + } + + private static void assertCursorMatches(NodeCursor actual, NodeCursor expected) { + if (!actual.toString().equals(expected.toString())) { + fail("Expected cursor to match:\n" + expected + "\nbut was:\n" + actual); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java new file mode 100644 index 00000000..6f45d5f7 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java @@ -0,0 +1,406 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.syntax; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.fail; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.document.Document; + +public class NodeParserTest { + @Test + public void goodEmptyObj() { + String text = "{}"; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps); + } + + @Test + public void goodEmptyObjWithWs() { + String text = "{ }"; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps); + } + + @Test + public void goodObjSingleKey() { + String text = """ + {"abc": "def"}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodObjMultiKey() { + String text = """ + {"abc": "def", "ghi": "jkl"}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodNestedObjs() { + String text = """ + {"abc": {"abc": {"abc": "abc"}, "def": "def"}}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodEmptyArr() { + String text = "[]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr); + } + + @Test + public void goodEmptyArrWithWs() { + String text = "[ ]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr); + } + + @Test + public void goodSingleElemArr() { + String text = "[1]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num); + } + + @Test + public void goodMultiElemArr() { + String text = """ + [1, 2, "3"]"""; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str); + } + + @Test + public void goodNestedArr() { + String text = """ + [[1, [1, 2], []] 3]"""; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num); + } + + @ParameterizedTest + @MethodSource("goodStringsProvider") + public void goodStrings(String text, String expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + if (value instanceof Syntax.Node.Str s) { + String actualValue = s.stringValue(); + if (!expectedValue.equals(actualValue)) { + fail(String.format("expected text of %s to be parsed as a string with value %s, but was %s", + text, expectedValue, actualValue)); + } + } else { + fail(String.format("expected text of %s to be parsed as a string, but was %s", + text, value.type())); + } + } + + private static Stream goodStringsProvider() { + return Stream.of( + Arguments.of("\"foo\"", "foo"), + Arguments.of("\"\"", "") + ); + } + + @ParameterizedTest + @MethodSource("goodIdentsProvider") + public void goodIdents(String text, String expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + if (value instanceof Syntax.Ident ident) { + String actualValue = ident.stringValue(); + if (!expectedValue.equals(actualValue)) { + fail(String.format("expected text of %s to be parsed as an ident with value %s, but was %s", + text, expectedValue, actualValue)); + } + } else { + fail(String.format("expected text of %s to be parsed as an ident, but was %s", + text, value.type())); + } + } + + private static Stream goodIdentsProvider() { + return Stream.of( + Arguments.of("true", "true"), + Arguments.of("false", "false"), + Arguments.of("null", "null") + ); + } + + @ParameterizedTest + @MethodSource("goodNumbersProvider") + public void goodNumbers(String text, BigDecimal expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + + if (value instanceof Syntax.Node.Num num) { + if (!expectedValue.equals(num.value)) { + fail(String.format("Expected text of %s to be parsed as a number with value %s, but was %s", + text, expectedValue, num.value)); + } + } else { + fail(String.format("Expected text of %s to be parsed as a number but was %s", + text, value.type())); + } + } + + private static Stream goodNumbersProvider() { + return Stream.of( + Arguments.of("-10", BigDecimal.valueOf(-10)), + Arguments.of("0", BigDecimal.valueOf(0)), + Arguments.of("123", BigDecimal.valueOf(123)) + ); + } + + @ParameterizedTest + @MethodSource("brokenProvider") + public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { + Syntax.NodeParseResult parse = Syntax.parseNode(Document.of(text)); + List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); + List types = getNodeTypes(parse.value()); + + assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); + assertThat(desc, types, equalTo(expectedTypes)); + } + + record InvalidSyntaxTestCase( + String description, + String text, + List expectedErrorMessages, + List expectedTypes + ) {} + + private static final List INVALID_SYNTAX_TEST_CASES = List.of( + new InvalidSyntaxTestCase( + "invalid element token", + "[1, 2}]", + List.of("unexpected token }"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed empty", + "[", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr) + ), + new InvalidSyntaxTestCase( + "unclosed", + "[1,", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed with sp", + "[1, ", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem", + "[1,a", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem and sp", + "[1,a ", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem no ,", + "[a 2", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Ident, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed in member", + "{foo: [1, 2}", + List.of("unexpected token }", "missing ]", "missing }"), + List.of( + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Ident, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "Non-string key with no value", + "{1}", + List.of("unexpected Num", "expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Non-string key with : but no value", + "{1:}", + List.of("unexpected Num", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "String key with no value", + "{\"1\"}", + List.of("expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no value", + "{\"1\":}", + List.of("expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with no value but a trailing ,", + "{\"1\",}", + List.of("expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no value and a trailing ,", + "{\"1\":,}", + List.of("expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "Invalid key", + "{\"abc}", + List.of("unexpected eof", "missing }"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Missing :", + "{\"abc\" 1}", + List.of("expected :"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ) + ); + + private static Stream brokenProvider() { + return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of( + invalidSyntaxTestCase.description, + invalidSyntaxTestCase.text, + invalidSyntaxTestCase.expectedErrorMessages, + invalidSyntaxTestCase.expectedTypes)); + } + + @Test + public void parsesStringsWithEscapes() { + String text = """ + "a\\"b" + """; + assertTypesEqual(text, + Syntax.Node.Type.Str); + } + + @Test + public void parsesTextBlocks() { + String text = "[\"\"\"foo\"\"\", 2, \"bar\", 3, \"\", 4, \"\"\"\"\"\"]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str); + } + + @Test + public void stringValues() { + Syntax.Node node = Syntax.parseNode(Document.of(""" + [ + "abc", + "", + \""" + foo + \""" + ] + """)).value(); + + assertThat(node, instanceOf(Syntax.Node.Arr.class)); + Syntax.Node.Arr arr = (Syntax.Node.Arr) node; + assertThat(arr.elements(), hasSize(3)); + + Syntax.Node first = arr.elements().get(0); + assertThat(first, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) first).stringValue(), equalTo("abc")); + + Syntax.Node second = arr.elements().get(1); + assertThat(second, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) second).stringValue(), equalTo("")); + + Syntax.Node third = arr.elements().get(2); + assertThat(third, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) third).stringValue().trim(), equalTo("foo")); + } + + private static void assertTypesEqual(String text, Syntax.Node.Type... types) { + assertThat(getNodeTypes(Syntax.parseNode(Document.of(text)).value()), contains(types)); + } + + static List getNodeTypes(Syntax.Node value) { + List types = new ArrayList<>(); + value.consume(v -> types.add(v.type())); + return types; + } +} From 224bc64f513bed0f72cd2f64e6fd58715bd7fce1 Mon Sep 17 00:00:00 2001 From: Divyam Mehta Date: Fri, 17 Jan 2025 12:05:20 -0800 Subject: [PATCH 10/43] moved initializeParams to serverOptions class (#185) --- .../amazon/smithy/lsp/ServerOptions.java | 86 +++++++++++++++++++ .../smithy/lsp/SmithyLanguageServer.java | 33 ++----- .../smithy/lsp/SmithyLanguageServerTest.java | 49 +++++++++++ 3 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/ServerOptions.java diff --git a/src/main/java/software/amazon/smithy/lsp/ServerOptions.java b/src/main/java/software/amazon/smithy/lsp/ServerOptions.java new file mode 100644 index 00000000..7862a65b --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerOptions.java @@ -0,0 +1,86 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import com.google.gson.JsonObject; +import java.util.Arrays; +import java.util.Optional; +import org.eclipse.lsp4j.InitializeParams; +import software.amazon.smithy.model.validation.Severity; + +public final class ServerOptions { + private final Severity minimumSeverity; + private final boolean onlyReloadOnSave; + + private ServerOptions(Builder builder) { + this.minimumSeverity = builder.minimumSeverity; + this.onlyReloadOnSave = builder.onlyReloadOnSave; + } + + public Severity getMinimumSeverity() { + return this.minimumSeverity; + } + + public boolean getOnlyReloadOnSave() { + return this.onlyReloadOnSave; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a ServerOptions instance from the initialization options provided by the client. + * Parses and validates configuration settings from the initialization parameters. + * + * @param params The params passed directly from the client, + * expected to be an InitializeParams object containing server configurations + * @param client The language client used for logging configuration status and errors + * @return A new {@code ServerOptions} instance with parsed configuration values + **/ + public static ServerOptions fromInitializeParams(InitializeParams params, SmithyLanguageClient client) { + // from InitializeParams + Object initializationOptions = params.getInitializationOptions(); + Builder builder = builder(); + if (initializationOptions instanceof JsonObject jsonObject) { + if (jsonObject.has("diagnostics.minimumSeverity")) { + String configuredMinimumSeverity = jsonObject.get("diagnostics.minimumSeverity").getAsString(); + Optional severity = Severity.fromString(configuredMinimumSeverity); + if (severity.isPresent()) { + builder.setMinimumSeverity(severity.get()); + } else { + client.error(String.format(""" + Invalid value for 'diagnostics.minimumSeverity': %s. + Must be one of %s.""", configuredMinimumSeverity, Arrays.toString(Severity.values()))); + } + } + if (jsonObject.has("onlyReloadOnSave")) { + builder.setOnlyReloadOnSave(jsonObject.get("onlyReloadOnSave").getAsBoolean()); + client.info("Configured only reload on save: " + builder.onlyReloadOnSave); + } + } + return builder.build(); + } + + protected static final class Builder { + private Severity minimumSeverity = Severity.WARNING; + private boolean onlyReloadOnSave = false; + + public Builder setMinimumSeverity(Severity minimumSeverity) { + this.minimumSeverity = minimumSeverity; + return this; + } + + public Builder setOnlyReloadOnSave(boolean onlyReloadOnSave) { + this.onlyReloadOnSave = onlyReloadOnSave; + return this; + } + + public ServerOptions build() { + return new ServerOptions(this); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index e78467b9..5257ebab 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -17,11 +17,9 @@ import static java.util.concurrent.CompletableFuture.completedFuture; -import com.google.gson.JsonObject; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -145,9 +143,8 @@ public class SmithyLanguageServer implements private SmithyLanguageClient client; private final ServerState state = new ServerState(); - private Severity minimumSeverity = Severity.WARNING; - private boolean onlyReloadOnSave = false; private ClientCapabilities clientCapabilities; + private ServerOptions serverOptions; SmithyLanguageServer() { } @@ -161,7 +158,7 @@ ServerState getState() { } Severity getMinimumSeverity() { - return minimumSeverity; + return this.serverOptions.getMinimumSeverity(); } @Override @@ -187,25 +184,8 @@ public CompletableFuture initialize(InitializeParams params) { .flatMap(ProcessHandle::of) .ifPresent(processHandle -> processHandle.onExit().thenRun(this::exit)); + this.serverOptions = ServerOptions.fromInitializeParams(params, client); // TODO: Replace with a Gson Type Adapter if more config options are added beyond `logToFile`. - Object initializationOptions = params.getInitializationOptions(); - if (initializationOptions instanceof JsonObject jsonObject) { - if (jsonObject.has("diagnostics.minimumSeverity")) { - String configuredMinimumSeverity = jsonObject.get("diagnostics.minimumSeverity").getAsString(); - Optional severity = Severity.fromString(configuredMinimumSeverity); - if (severity.isPresent()) { - this.minimumSeverity = severity.get(); - } else { - client.error(String.format(""" - Invalid value for 'diagnostics.minimumSeverity': %s. - Must be one of %s.""", configuredMinimumSeverity, Arrays.toString(Severity.values()))); - } - } - if (jsonObject.has("onlyReloadOnSave")) { - this.onlyReloadOnSave = jsonObject.get("onlyReloadOnSave").getAsBoolean(); - client.info("Configured only reload on save: " + this.onlyReloadOnSave); - } - } if (params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { Either workDoneProgressToken = params.getWorkDoneToken(); @@ -523,7 +503,7 @@ public void didChange(DidChangeTextDocumentParams params) { } smithyFile.reparse(); - if (!onlyReloadOnSave) { + if (!this.serverOptions.getOnlyReloadOnSave()) { Project project = projectAndFile.project(); // TODO: A consequence of this is that any existing validation events are cleared, which @@ -692,7 +672,7 @@ public CompletableFuture hover(HoverParams params) { Project project = projectAndFile.project(); // TODO: Abstract away passing minimum severity - var handler = new HoverHandler(project, smithyFile, minimumSeverity); + var handler = new HoverHandler(project, smithyFile, this.serverOptions.getMinimumSeverity()); return CompletableFuture.supplyAsync(() -> handler.handle(params)); } @@ -739,7 +719,8 @@ private void sendFileDiagnosticsForManagedDocuments() { private CompletableFuture sendFileDiagnostics(ProjectAndFile projectAndFile) { return CompletableFuture.runAsync(() -> { - List diagnostics = SmithyDiagnostics.getFileDiagnostics(projectAndFile, minimumSeverity); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + projectAndFile, this.serverOptions.getMinimumSeverity()); var publishDiagnosticsParams = new PublishDiagnosticsParams(projectAndFile.uri(), diagnostics); client.publishDiagnostics(publishDiagnosticsParams); }); diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 26df2bfa..f2b36628 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -50,6 +50,7 @@ import org.eclipse.lsp4j.FileChangeType; import org.eclipse.lsp4j.FormattingOptions; import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.PublishDiagnosticsParams; @@ -72,6 +73,7 @@ import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.traits.LengthTrait; +import software.amazon.smithy.model.validation.Severity; public class SmithyLanguageServerTest { @Test @@ -1913,6 +1915,53 @@ public void reloadsProjectOnBuildFileSave() { Map.of())); } + @Test + public void testCustomServerOptions() { + ServerOptions options = ServerOptions.builder() + .setMinimumSeverity(Severity.NOTE) + .setOnlyReloadOnSave(true) + .build(); + + assertThat(options.getMinimumSeverity(), equalTo(Severity.NOTE)); + assertThat(options.getOnlyReloadOnSave(), equalTo(true)); + } + + @Test + public void testFromInitializeParamsWithValidOptions() { + StubClient client = new StubClient(); + // Create initialization options + JsonObject opts = new JsonObject(); + opts.add("diagnostics.minimumSeverity", new JsonPrimitive("ERROR")); + opts.add("onlyReloadOnSave", new JsonPrimitive(true)); + + // Create InitializeParams with the options + InitializeParams params = new InitializeParams(); + params.setInitializationOptions(opts); + + // Call the method being tested + ServerOptions options = ServerOptions.fromInitializeParams(params, new SmithyLanguageClient(client)); + + assertThat(options.getMinimumSeverity(), equalTo(Severity.ERROR)); + assertThat(options.getOnlyReloadOnSave(), equalTo(true)); + } + + @Test + public void testFromInitializeParamsWithPartialOptions() { + StubClient client = new StubClient(); + JsonObject opts = new JsonObject(); + opts.add("onlyReloadOnSave", new JsonPrimitive(true)); + // Not setting minimumSeverity + + // Create InitializeParams with the options + InitializeParams params = new InitializeParams(); + params.setInitializationOptions(opts); + + ServerOptions options = ServerOptions.fromInitializeParams(params, new SmithyLanguageClient(client)); + + assertThat(options.getMinimumSeverity(), equalTo(Severity.WARNING)); // Default value + assertThat(options.getOnlyReloadOnSave(), equalTo(true)); // Explicitly set value + } + private void assertServerState(SmithyLanguageServer server, ServerState expected) { ServerState actual = ServerState.from(server); assertThat(actual, equalTo(expected)); From 1e48800b2f6667e5d3bbfa8c3b250e657083be54 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Fri, 17 Jan 2025 15:14:58 -0500 Subject: [PATCH 11/43] Cleanup serverstate and project (#187) * Hide ServerState data members * Store only one collection of projects * Collapse Project data members into RebuildIndex * Dont read from disk multiple times When loading a project, SmithyFiles were being read from disk multiple times. This commit refactors ProjectLoader to only read from disk once, and also cleans up some stuff. * Add interface for getting managed files Project loading can now depend on a single interface that provides access to managed files, rather than ServerState itself. * Add documentation to new methods * Rename DocumentLifecycleManager * Cleanup some of Project's api * Get rid of extraneous Document methods --- ...ntLifecycleManager.java => FileTasks.java} | 4 +- .../smithy/lsp/FileWatcherRegistrations.java | 1 + .../amazon/smithy/lsp/ManagedFiles.java | 23 + .../amazon/smithy/lsp/ServerState.java | 212 ++++---- .../smithy/lsp/SmithyLanguageServer.java | 117 +---- .../amazon/smithy/lsp/WorkspaceChanges.java | 9 +- .../lsp/diagnostics/SmithyDiagnostics.java | 2 +- .../amazon/smithy/lsp/document/Document.java | 156 +----- .../smithy/lsp/language/SimpleCompleter.java | 2 +- .../amazon/smithy/lsp/project/Project.java | 290 ++++++++--- .../smithy/lsp/project/ProjectAndFile.java | 3 +- .../lsp/project/ProjectConfigLoader.java | 25 +- .../smithy/lsp/project/ProjectLoader.java | 298 ++++++----- .../project/SmithyFileDependenciesIndex.java | 127 ----- .../amazon/smithy/lsp/LspMatchers.java | 13 +- .../amazon/smithy/lsp/ServerStateTest.java | 42 -- .../smithy/lsp/SmithyLanguageServerTest.java | 461 ++++++++---------- .../amazon/smithy/lsp/SmithyMatchers.java | 13 +- .../lsp/SmithyVersionRefactoringTest.java | 4 +- .../smithy/lsp/document/DocumentTest.java | 102 +--- .../lsp/language/CompletionHandlerTest.java | 3 +- .../lsp/language/DefinitionHandlerTest.java | 5 +- .../lsp/language/DocumentSymbolTest.java | 3 +- .../smithy/lsp/language/HoverHandlerTest.java | 2 +- .../lsp/project/ProjectConfigLoaderTest.java | 13 +- .../smithy/lsp/project/ProjectTest.java | 202 ++++---- 26 files changed, 904 insertions(+), 1228 deletions(-) rename src/main/java/software/amazon/smithy/lsp/{DocumentLifecycleManager.java => FileTasks.java} (92%) create mode 100644 src/main/java/software/amazon/smithy/lsp/ManagedFiles.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ServerStateTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/FileTasks.java similarity index 92% rename from src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java rename to src/main/java/software/amazon/smithy/lsp/FileTasks.java index 7d2c98fb..945df04f 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/FileTasks.java @@ -11,10 +11,10 @@ import java.util.concurrent.ExecutionException; /** - * Tracks asynchronous lifecycle tasks, allowing for cancellation of an ongoing + * Container for tracking asynchronous tasks by file, allowing for cancellation of an ongoing * task if a new task needs to be started. */ -final class DocumentLifecycleManager { +final class FileTasks { private final Map> tasks = new HashMap<>(); CompletableFuture getTask(String uri) { diff --git a/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java index d45ae276..a52acdfd 100644 --- a/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java +++ b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java @@ -56,6 +56,7 @@ private FileWatcherRegistrations() { */ static List getSmithyFileWatcherRegistrations(Collection projects) { List smithyFileWatchers = projects.stream() + .filter(project -> project.type() == Project.Type.NORMAL) .flatMap(project -> FilePatterns.getSmithyFileWatchPatterns(project).stream()) .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); diff --git a/src/main/java/software/amazon/smithy/lsp/ManagedFiles.java b/src/main/java/software/amazon/smithy/lsp/ManagedFiles.java new file mode 100644 index 00000000..d10a17a6 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ManagedFiles.java @@ -0,0 +1,23 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import software.amazon.smithy.lsp.document.Document; + +/** + * Provides access to {@link Document}s managed by the server. + * + *

A document is _managed_ if its state is controlled by the lifecycle methods + * didOpen, didClose, didChange, didSave. In other words, reading from disk _may_ + * not provide the accurate file content. + */ +public interface ManagedFiles { + /** + * @param uri Uri of the document to get + * @return The document if found and it is managed, otherwise {@code null} + */ + Document getManagedDocument(String uri); +} diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index 9481d38d..c0de6d52 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -10,16 +10,19 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Collection; 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.FileEvent; 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.ProjectChange; import software.amazon.smithy.lsp.project.ProjectFile; import software.amazon.smithy.lsp.project.ProjectLoader; import software.amazon.smithy.lsp.protocol.LspAdapter; @@ -27,40 +30,51 @@ /** * 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 -) { +public final class ServerState implements ManagedFiles { private static final Logger LOGGER = Logger.getLogger(ServerState.class.getName()); + private final Map projects; + private final Set workspacePaths; + private final Set managedUris; + private final FileTasks lifecycleTasks; + /** * Create a new, empty server state. */ public ServerState() { - this( - new HashMap<>(), - new HashMap<>(), - new HashSet<>(), - new HashSet<>(), - new DocumentLifecycleManager()); + this.projects = new HashMap<>(); + this.workspacePaths = new HashSet<>(); + this.managedUris = new HashSet<>(); + this.lifecycleTasks = new FileTasks(); } /** - * @param uri Uri of the document to get - * @return The document if found and it is managed, otherwise {@code null} + * @return All projects tracked by the server. */ + public Collection getAllProjects() { + return projects.values(); + } + + /** + * @return All files managed by the server, including their projects. + */ + public Collection getAllManaged() { + List allManaged = new ArrayList<>(managedUris.size()); + for (String uri : managedUris) { + allManaged.add(findManaged(uri)); + } + return allManaged; + } + + /** + * @return All workspace paths tracked by the server. + */ + public Set workspacePaths() { + return workspacePaths; + } + + @Override public Document getManagedDocument(String uri) { if (managedUris.contains(uri)) { ProjectAndFile projectAndFile = findProjectAndFile(uri); @@ -72,31 +86,19 @@ public Document getManagedDocument(String uri) { 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; - } + FileTasks lifecycleTasks() { + return lifecycleTasks; + } - String uri = LspAdapter.toUri(path.toString()); - return getManagedDocument(uri); + Project findProjectByRoot(String root) { + return projects.get(root); } ProjectAndFile findProjectAndFile(String uri) { - ProjectAndFile attached = findAttachedAndRemoveDetached(uri); - if (attached != null) { - return attached; - } - - Project detachedProject = detachedProjects.get(uri); - if (detachedProject != null) { - String path = LspAdapter.toPath(uri); - ProjectFile projectFile = detachedProject.getProjectFile(path); + for (Project project : projects.values()) { + ProjectFile projectFile = project.getProjectFile(uri); if (projectFile != null) { - return new ProjectAndFile(uri, detachedProject, projectFile, true); + return new ProjectAndFile(uri, project, projectFile); } } @@ -105,61 +107,52 @@ ProjectAndFile findProjectAndFile(String uri) { return null; } - boolean isDetached(String uri) { - if (detachedProjects.containsKey(uri)) { - ProjectAndFile attached = findAttachedAndRemoveDetached(uri); - // The file is only truly detached if the above didn't find an attached project - // for the given file - return attached == null; + ProjectAndFile findManaged(String uri) { + if (managedUris.contains(uri)) { + return findProjectAndFile(uri); } - - return false; + return null; } - /** - * Searches for the given {@code uri} in attached projects, and if found, - * makes sure any old detached projects for that file are removed. - * - * @param uri The uri of the project and file to find - * @return The attached project and file, or null if not found - */ - private ProjectAndFile findAttachedAndRemoveDetached(String uri) { - String path = LspAdapter.toPath(uri); - // We might be in a state where a file was added to a tracked project, - // but was opened before the project loaded. This would result in it - // being placed in a detachedProjects project. Removing it here is basically - // like removing it lazily, although it does feel a little hacky. - for (Project project : attachedProjects.values()) { - ProjectFile projectFile = project.getProjectFile(path); - if (projectFile != null) { - detachedProjects.remove(uri); - return new ProjectAndFile(uri, project, projectFile, false); - } + ProjectAndFile open(String uri, String text) { + managedUris.add(uri); + + ProjectAndFile projectAndFile = findProjectAndFile(uri); + if (projectAndFile != null) { + projectAndFile.file().document().applyEdit(null, text); + } else { + createDetachedProject(uri, text); + projectAndFile = findProjectAndFile(uri); // Note: This will always be present } - return null; + return projectAndFile; } - void createDetachedProject(String uri, String text) { - Project project = ProjectLoader.loadDetached(uri, text); - detachedProjects.put(uri, project); + void close(String uri) { + managedUris.remove(uri); + + ProjectAndFile projectAndFile = findProjectAndFile(uri); + if (projectAndFile != null && projectAndFile.project().type() == Project.Type.DETACHED) { + // Only cancel tasks for detached projects, since we're dropping the project + lifecycleTasks.cancelTask(uri); + projects.remove(uri); + } } List tryInitProject(Path root) { LOGGER.finest("Initializing project at " + root); - lifecycleManager.cancelAllTasks(); + lifecycleTasks.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()) { + if (updatedProject.type() == Project.Type.EMPTY) { removeProjectAndResolveDetached(projectName); } else { - resolveDetachedProjects(attachedProjects.get(projectName), updatedProject); - attachedProjects.put(projectName, updatedProject); + resolveDetachedProjects(projects.get(projectName), updatedProject); + projects.put(projectName, updatedProject); } LOGGER.finest("Initialized project at " + root); @@ -172,7 +165,7 @@ List tryInitProject(Path root) { // if we find a smithy-build.json, etc. // If we overwrite an existing project with an empty one, we lose track of the state of tracked // files. Instead, we will just keep the original project before the reload failure. - attachedProjects.computeIfAbsent(projectName, ignored -> Project.empty(root)); + projects.computeIfAbsent(projectName, ignored -> Project.empty(root)); return loadResult.unwrapErr(); } @@ -197,8 +190,8 @@ void removeWorkspace(WorkspaceFolder folder) { // 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)) { + for (var entry : projects.entrySet()) { + if (entry.getValue().type() == Project.Type.NORMAL && entry.getValue().root().startsWith(workspaceRoot)) { projectsToRemove.add(entry.getKey()); } } @@ -208,8 +201,43 @@ void removeWorkspace(WorkspaceFolder folder) { } } + List applyFileEvents(List events) { + List errors = new ArrayList<>(); + + var changes = WorkspaceChanges.computeWorkspaceChanges(events, this); + + for (var entry : changes.byProject().entrySet()) { + String projectRoot = entry.getKey(); + ProjectChange projectChange = entry.getValue(); + + Project project = findProjectByRoot(projectRoot); + + if (!projectChange.changedBuildFileUris().isEmpty()) { + // Note: this will take care of removing projects when build files are + // deleted + errors.addAll(tryInitProject(project.root())); + } else { + Set createdUris = projectChange.createdSmithyFileUris(); + Set deletedUris = projectChange.deletedSmithyFileUris(); + + project.updateFiles(createdUris, deletedUris); + + // If any file was previously opened and created a detached project, remove them + for (String createdUri : createdUris) { + projects.remove(createdUri); + } + } + } + + for (var newProjectRoot : changes.newProjectRoots()) { + errors.addAll(tryInitProject(newProjectRoot)); + } + + return errors; + } + private void removeProjectAndResolveDetached(String projectName) { - Project removedProject = attachedProjects.remove(projectName); + Project removedProject = projects.remove(projectName); if (removedProject != null) { resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); } @@ -219,29 +247,31 @@ 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 currentProjectSmithyPaths = oldProject.getAllSmithyFilePaths(); + Set updatedProjectSmithyPaths = updatedProject.getAllSmithyFilePaths(); Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); addedPaths.removeAll(currentProjectSmithyPaths); for (String addedPath : addedPaths) { String addedUri = LspAdapter.toUri(addedPath); - if (isDetached(addedUri)) { - detachedProjects.remove(addedUri); - } + projects.remove(addedUri); // Remove any detached projects } 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 + // Only move to a detached 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 + Document removedDocument = oldProject.getProjectFile(removedUri).document(); createDetachedProject(removedUri, removedDocument.copyText()); } } } } + + private void createDetachedProject(String uri, String text) { + Project project = ProjectLoader.loadDetached(uri, text); + projects.put(uri, project); + } } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 5257ebab..1cc512b0 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -18,20 +18,15 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import java.io.IOException; -import java.nio.file.Path; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.concurrent.CompletableFuture; 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; @@ -61,8 +56,6 @@ import org.eclipse.lsp4j.InitializedParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.MessageType; import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.Range; @@ -149,10 +142,6 @@ public class SmithyLanguageServer implements SmithyLanguageServer() { } - Project getFirstProject() { - return state.attachedProjects().values().stream().findFirst().orElse(null); - } - ServerState getState() { return state; } @@ -216,26 +205,21 @@ public CompletableFuture initialize(InitializeParams params) { return completedFuture(new InitializeResult(CAPABILITIES)); } - private void tryInitProject(Path 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 : loadErrors) { + private void reportProjectLoadErrors(List errors) { + if (!errors.isEmpty()) { + StringBuilder errorMessage = new StringBuilder("Failed to load Smithy projects").append(":"); + for (Exception error : errors) { errorMessage.append(System.lineSeparator()); errorMessage.append('\t'); errorMessage.append(error.getMessage()); } client.error(errorMessage.toString()); - - String showMessage = baseMessage + ". Check server logs to find out what went wrong."; - client.showMessage(new MessageParams(MessageType.Error, showMessage)); } } private CompletableFuture registerSmithyFileWatchers() { List registrations = FileWatcherRegistrations.getSmithyFileWatcherRegistrations( - state.attachedProjects().values()); + state.getAllProjects()); return client.registerCapability(new RegistrationParams(registrations)); } @@ -375,11 +359,7 @@ public CompletableFuture> selectorCommand(SelectorParam return completedFuture(Collections.emptyList()); } - // Select from all available projects - Collection detached = state.detachedProjects().values(); - Collection nonDetached = state.attachedProjects().values(); - - return completedFuture(Stream.concat(detached.stream(), nonDetached.stream()) + return completedFuture(state.getAllProjects().stream() .flatMap(project -> project.modelResult().getResult().stream()) .map(selector::select) .flatMap(shapes -> shapes.stream() @@ -392,54 +372,24 @@ public CompletableFuture> selectorCommand(SelectorParam @Override public CompletableFuture serverStatus() { List openProjects = new ArrayList<>(); - for (Project project : state.attachedProjects().values()) { + for (Project project : state.getAllProjects()) { openProjects.add(new OpenProject( LspAdapter.toUri(project.root().toString()), - project.smithyFiles().keySet().stream() + project.getAllSmithyFilePaths().stream() .map(LspAdapter::toUri) .toList(), - false)); - } - - for (Map.Entry entry : state.detachedProjects().entrySet()) { - openProjects.add(new OpenProject( - entry.getKey(), - Collections.singletonList(entry.getKey()), - true)); + project.type() == Project.Type.DETACHED)); } - return completedFuture(new ServerStatus(openProjects)); } @Override public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { LOGGER.finest("DidChangeWatchedFiles"); + // 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(), state); - - changes.byProject().forEach((projectName, projectChange) -> { - Project project = state.attachedProjects().get(projectName); - - if (!projectChange.changedBuildFileUris().isEmpty()) { - client.info("Build files changed, reloading project"); - // TODO: Handle more granular updates to build files. - // Note: This will take care of removing projects when build files are deleted - tryInitProject(project.root()); - } else { - Set createdUris = projectChange.createdSmithyFileUris(); - Set deletedUris = projectChange.deletedSmithyFileUris(); - client.info("Project files changed, adding files " - + createdUris + " and removing files " + deletedUris); - - // We get this notification for watched files, which only includes project files, - // so we don't need to resolve detachedProjects projects. - project.updateFiles(createdUris, deletedUris); - } - }); - - changes.newProjectRoots().forEach(this::tryInitProject); + reportProjectLoadErrors(state.applyFileEvents(params.getChanges())); // TODO: Update watchers based on specific changes // Note: We don't update build file watchers here - only on workspace changes @@ -480,9 +430,9 @@ public void didChange(DidChangeTextDocumentParams params) { String uri = params.getTextDocument().getUri(); - state.lifecycleManager().cancelTask(uri); + state.lifecycleTasks().cancelTask(uri); - ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + ProjectAndFile projectAndFile = state.findManaged(uri); if (projectAndFile == null) { client.unknownFileError(uri, "change"); return; @@ -512,7 +462,7 @@ public void didChange(DidChangeTextDocumentParams params) { CompletableFuture future = CompletableFuture .runAsync(() -> project.updateModelWithoutValidating(uri)) .thenComposeAsync(unused -> sendFileDiagnostics(projectAndFile)); - state.lifecycleManager().putTask(uri, future); + state.lifecycleTasks().putTask(uri, future); } } @@ -522,19 +472,11 @@ public void didOpen(DidOpenTextDocumentParams params) { String uri = params.getTextDocument().getUri(); - state.lifecycleManager().cancelTask(uri); - state.managedUris().add(uri); + state.lifecycleTasks().cancelTask(uri); - String text = params.getTextDocument().getText(); - ProjectAndFile projectAndFile = state.findProjectAndFile(uri); - if (projectAndFile != null) { - projectAndFile.file().document().applyEdit(null, text); - } else { - state.createDetachedProject(uri, text); - projectAndFile = state.findProjectAndFile(uri); // Note: This will always be present - } + ProjectAndFile projectAndFile = state.open(uri, params.getTextDocument().getText()); - state.lifecycleManager().putTask(uri, sendFileDiagnostics(projectAndFile)); + state.lifecycleTasks().putTask(uri, sendFileDiagnostics(projectAndFile)); } @Override @@ -542,15 +484,7 @@ public void didClose(DidCloseTextDocumentParams params) { LOGGER.finest("DidClose"); String uri = params.getTextDocument().getUri(); - state.managedUris().remove(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 + state.close(uri); } @Override @@ -558,12 +492,10 @@ public void didSave(DidSaveTextDocumentParams params) { LOGGER.finest("DidSave"); String uri = params.getTextDocument().getUri(); - state.lifecycleManager().cancelTask(uri); + state.lifecycleTasks().cancelTask(uri); - ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + ProjectAndFile projectAndFile = state.findManaged(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; } @@ -574,14 +506,14 @@ public void didSave(DidSaveTextDocumentParams params) { Project project = projectAndFile.project(); if (projectAndFile.file() instanceof BuildFile) { - tryInitProject(project.root()); + reportProjectLoadErrors(state.tryInitProject(project.root())); unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); sendFileDiagnosticsForManagedDocuments(); } else { CompletableFuture future = CompletableFuture .runAsync(() -> project.updateAndValidateModel(uri)) .thenCompose(unused -> sendFileDiagnostics(projectAndFile)); - state.lifecycleManager().putTask(uri, future); + state.lifecycleTasks().putTask(uri, future); } } @@ -711,9 +643,8 @@ public CompletableFuture> formatting(DocumentFormatting } private void sendFileDiagnosticsForManagedDocuments() { - for (String managedDocumentUri : state.managedUris()) { - ProjectAndFile projectAndFile = state.findProjectAndFile(managedDocumentUri); - state.lifecycleManager().putOrComposeTask(managedDocumentUri, sendFileDiagnostics(projectAndFile)); + for (ProjectAndFile managed : state.getAllManaged()) { + state.lifecycleTasks().putOrComposeTask(managed.uri(), sendFileDiagnostics(managed)); } } diff --git a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java index 5f1601dc..8bc72487 100644 --- a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java +++ b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java @@ -32,9 +32,12 @@ private WorkspaceChanges() { static WorkspaceChanges computeWorkspaceChanges(List events, ServerState state) { WorkspaceChanges changes = new WorkspaceChanges(); - List projectFileMatchers = new ArrayList<>(state.attachedProjects().size()); - state.attachedProjects().forEach((projectName, project) -> - projectFileMatchers.add(createProjectFileMatcher(projectName, project))); + List projectFileMatchers = new ArrayList<>(); + state.getAllProjects().forEach(project -> { + if (project.type() == Project.Type.NORMAL) { + projectFileMatchers.add(createProjectFileMatcher(project.root().toString(), project)); + } + }); List workspaceBuildFileMatchers = new ArrayList<>(state.workspacePaths().size()); state.workspacePaths().forEach(workspacePath -> diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java index 54459b17..2edc2034 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java @@ -71,7 +71,7 @@ public static List getFileDiagnostics(ProjectAndFile projectAndFile, diagnostics.add(versionDiagnostic); } - if (projectAndFile.isDetached()) { + if (projectAndFile.project().type() == Project.Type.DETACHED) { diagnostics.add(detachedDiagnostic(smithyFile)); } diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java index 74c3f0c7..d5f7e7ae 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -146,7 +146,6 @@ public int indexOfPosition(int line, int character) { return -1; } - int idx = startLineIdx + character; if (line == lastLine()) { if (idx >= buffer.length()) { @@ -196,9 +195,7 @@ public Range rangeBetween(int start, int end) { Position endPos; if (end == length()) { - int lastLine = lastLine(); - int lastCol = length() - lineIndices[lastLine]; - endPos = new Position(lastLine, lastCol); + endPos = end(); } else { endPos = positionAtIndex(end); } @@ -234,9 +231,7 @@ public int lastLine() { * @return The end position of this document */ public Position end() { - return new Position( - lineIndices.length - 1, - buffer.length() - lineIndices[lineIndices.length - 1]); + return new Position(lastLine(), lastColExclusive()); } /** @@ -266,111 +261,6 @@ public CharSequence borrowText() { return buffer; } - /** - * @param range The range to borrow the text of - * @return A reference to the text in this document within the given {@code range} - * or {@code null} if the range is out of bounds - */ - public CharBuffer borrowRange(Range range) { - int startLine = range.getStart().getLine(); - int startChar = range.getStart().getCharacter(); - int endLine = range.getEnd().getLine(); - int endChar = range.getEnd().getCharacter(); - - // TODO: Maybe make this return the whole thing, thing up to an index, or thing after an - // index if one of the indicies is out of bounds. - int startLineIdx = indexOfLine(startLine); - int endLineIdx = indexOfLine(endLine); - if (startLineIdx < 0 || endLineIdx < 0) { - return null; - } - - int startIdx = startLineIdx + startChar; - int endIdx = endLineIdx + endChar; - if (startIdx > buffer.length() || endIdx > buffer.length()) { - return null; - } - - return CharBuffer.wrap(buffer, startIdx, endIdx); - } - - /** - * @param position The position within the token to borrow - * @return A reference to the token that the given {@code position} is - * within, or {@code null} if the position is not within a token - */ - public CharBuffer borrowToken(Position position) { - int idx = indexOfPosition(position); - if (idx < 0) { - return null; - } - - char atIdx = buffer.charAt(idx); - // Not a token - if (!Character.isLetterOrDigit(atIdx) && atIdx != '_') { - return null; - } - - int startIdx = idx; - while (startIdx >= 0) { - char c = buffer.charAt(startIdx); - if (Character.isLetterOrDigit(c) || c == '_') { - startIdx--; - } else { - break; - } - } - - int endIdx = idx; - while (endIdx < buffer.length()) { - char c = buffer.charAt(endIdx); - if (Character.isLetterOrDigit(c) || c == '_') { - endIdx++; - } else { - break; - } - } - - return CharBuffer.wrap(buffer, startIdx + 1, endIdx); - } - - /** - * @param line The line to borrow - * @return A reference to the text in the given line, or {@code null} if - * the line doesn't exist - */ - public CharBuffer borrowLine(int line) { - if (line >= lineIndices.length || line < 0) { - return null; - } - - int lineStart = indexOfLine(line); - if (line + 1 >= lineIndices.length) { - return CharBuffer.wrap(buffer, lineStart, buffer.length()); - } - - return CharBuffer.wrap(buffer, lineStart, indexOfLine(line + 1)); - } - - /** - * @param start The index of the start of the span to borrow - * @param end The end of the index of the span to borrow (exclusive) - * @return A reference to the text within the indicies {@code start} and - * {@code end}, or {@code null} if the span is out of bounds or start > end - */ - public CharBuffer borrowSpan(int start, int end) { - if (start < 0 || end < 0) { - return null; - } - - // end is exclusive - if (end > buffer.length() || start > end) { - return null; - } - - return CharBuffer.wrap(buffer, start, end); - } - /** * @return A copy of the text of this document */ @@ -384,12 +274,20 @@ public String copyText() { * or {@code null} if the range is out of bounds */ public String copyRange(Range range) { - CharBuffer borrowed = borrowRange(range); - if (borrowed == null) { - return null; + int start = indexOfPosition(range.getStart()); + + int end; + Position endPosition = range.getEnd(); + if (endPosition.getLine() == lastLine() && endPosition.getCharacter() == lastColExclusive()) { + end = length(); + } else { + end = indexOfPosition(range.getEnd()); } + return copySpan(start, end); + } - return borrowed.toString(); + private int lastColExclusive() { + return length() - lineIndices[lastLine()]; } /** @@ -479,18 +377,11 @@ public DocumentId copyDocumentId(Position position) { } // We go past the start and end in each loop, so startIdx is before the start character, and endIdx - // is after the end character. + // is after the end character. Since end is exclusive (both for creating the buffer and getting the + // range) we can leave it. int startCharIdx = startIdx + 1; - int endCharIdx = endIdx - 1; - - // For creating the buffer and the range, the start is inclusive, and the end is exclusive. - CharBuffer wrapped = CharBuffer.wrap(buffer, startCharIdx, endCharIdx + 1); - Position start = positionAtIndex(startCharIdx); - // However, we can't get the position for an index that may be out of bounds, so we need to make - // the end position exclusive manually. - Position end = positionAtIndex(endCharIdx); - end.setCharacter(end.getCharacter() + 1); - Range range = new Range(start, end); + CharBuffer wrapped = CharBuffer.wrap(buffer, startCharIdx, endIdx); + Range range = rangeBetween(startCharIdx, endIdx); return new DocumentId(type, wrapped, range); } @@ -505,11 +396,16 @@ private static boolean isIdChar(char c) { * {@code end}, or {@code null} if the span is out of bounds or start > end */ public String copySpan(int start, int end) { - CharBuffer borrowed = borrowSpan(start, end); - if (borrowed == null) { + if (start < 0 || end < 0) { return null; } - return borrowed.toString(); + + // end is exclusive + if (end > buffer.length() || start > end) { + return null; + } + + return CharBuffer.wrap(buffer, start, end).toString(); } /** diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java index 04150084..75c9c31b 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java @@ -91,7 +91,7 @@ private CompletionCandidates customCandidates(CompletionCandidates.Custom custom } private Stream streamNamespaces() { - return context().project().smithyFiles().values().stream() + return context().project().getAllSmithyFiles().stream() .map(smithyFile -> switch (smithyFile) { case IdlFile idlFile -> idlFile.getParse().namespace().namespace(); default -> ""; 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 baae3773..e70287d9 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -6,6 +6,8 @@ package software.amazon.smithy.lsp.project; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -20,9 +22,11 @@ import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.ModelAssembler; +import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.shapes.AbstractShapeBuilder; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.validation.ValidatedResult; @@ -34,35 +38,54 @@ */ public final class Project { private static final Logger LOGGER = Logger.getLogger(Project.class.getName()); + private final Path root; private final ProjectConfig config; private final List dependencies; private final Map smithyFiles; private final Supplier assemblerFactory; - private final Map> definedShapesByFile; + private final Type type; private ValidatedResult modelResult; - // TODO: Move this into SmithyFileDependenciesIndex - private Map> perFileMetadata; - private SmithyFileDependenciesIndex smithyFileDependenciesIndex; + private RebuildIndex rebuildIndex; - Project(Path root, + Project( + Path root, ProjectConfig config, List dependencies, Map smithyFiles, Supplier assemblerFactory, - Map> definedShapesByFile, + Type type, ValidatedResult modelResult, - Map> perFileMetadata, - SmithyFileDependenciesIndex smithyFileDependenciesIndex) { + RebuildIndex rebuildIndex + ) { this.root = root; this.config = config; this.dependencies = dependencies; this.smithyFiles = smithyFiles; this.assemblerFactory = assemblerFactory; - this.definedShapesByFile = definedShapesByFile; + this.type = type; this.modelResult = modelResult; - this.perFileMetadata = perFileMetadata; - this.smithyFileDependenciesIndex = smithyFileDependenciesIndex; + this.rebuildIndex = rebuildIndex; + } + + /** + * The type of project, which depends on how it was loaded. + */ + public enum Type { + /** + * A project loaded using some build configuration files, i.e. smithy-build.json. + */ + NORMAL, + + /** + * A project loaded from a single source file, without any build configuration files. + */ + DETACHED, + + /** + * A project loaded with no source or build configuration files. + */ + EMPTY; } /** @@ -77,10 +100,9 @@ public static Project empty(Path root) { List.of(), new HashMap<>(), Model::assembler, - new HashMap<>(), + Type.EMPTY, ValidatedResult.empty(), - new HashMap<>(), - new SmithyFileDependenciesIndex()); + new RebuildIndex()); } /** @@ -126,18 +148,21 @@ public List dependencies() { } /** - * @return A map of paths to the {@link SmithyFile} at that path, containing - * all smithy files loaded in the project. + * @return The paths of all Smithy files loaded in the project. */ - public Map smithyFiles() { - return this.smithyFiles; + public Set getAllSmithyFilePaths() { + return this.smithyFiles.keySet(); } /** - * @return A map of paths to the set of shape ids defined in the file at that path. + * @return All the Smithy files loaded in the project. */ - public Map> definedShapesByFile() { - return this.definedShapesByFile; + public Collection getAllSmithyFiles() { + return this.smithyFiles.values(); + } + + public Type type() { + return type; } /** @@ -148,25 +173,12 @@ public ValidatedResult modelResult() { } /** - * @param uri The URI of the {@link Document} to get - * @return The {@link Document} corresponding to the given {@code uri} if - * it exists in this project, otherwise {@code null} - */ - public Document getDocument(String uri) { - String path = LspAdapter.toPath(uri); - ProjectFile projectFile = getProjectFile(path); - if (projectFile == null) { - return null; - } - return projectFile.document(); - } - - /** - * @param path The path of the {@link ProjectFile} to get + * @param uri The uri 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) { + public ProjectFile getProjectFile(String uri) { + String path = LspAdapter.toPath(uri); SmithyFile smithyFile = smithyFiles.get(path); if (smithyFile != null) { return smithyFile; @@ -175,16 +187,6 @@ public ProjectFile getProjectFile(String path) { return config.buildFiles().get(path); } - /** - * @param uri The URI of the {@link SmithyFile} to get - * @return The {@link SmithyFile} corresponding to the given {@code uri} if - * it exists in this project, otherwise {@code null} - */ - public SmithyFile getSmithyFile(String uri) { - String path = LspAdapter.toPath(uri); - return smithyFiles.get(path); - } - /** * Update this project's model without running validation. * @@ -287,7 +289,15 @@ public void updateFiles(Set addUris, Set removeUris, Set } for (String uri : addUris) { - assembler.addImport(LspAdapter.toPath(uri)); + String path = LspAdapter.toPath(uri); + String text = IoUtils.readUtf8File(path); + + // TODO: Inefficient ? + Document document = Document.of(text); + SmithyFile smithyFile = SmithyFile.create(path, document); + this.smithyFiles.put(path, smithyFile); + + assembler.addUnparsedModel(path, text); } if (!validate) { @@ -295,25 +305,7 @@ public void updateFiles(Set addUris, Set removeUris, Set } this.modelResult = assembler.assemble(); - this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); - this.smithyFileDependenciesIndex = SmithyFileDependenciesIndex.compute(this.modelResult); - - for (String visitedPath : visited) { - if (!removedPaths.contains(visitedPath)) { - Set currentShapes = definedShapesByFile.getOrDefault(visitedPath, Set.of()); - this.definedShapesByFile.put(visitedPath, getFileShapes(visitedPath, currentShapes)); - } else { - this.definedShapesByFile.remove(visitedPath); - } - } - - for (String uri : addUris) { - String path = LspAdapter.toPath(uri); - Document document = Document.of(IoUtils.readUtf8File(path)); - SmithyFile smithyFile = SmithyFile.create(path, document); - this.smithyFiles.put(path, smithyFile); - this.definedShapesByFile.put(path, getFileShapes(path, Set.of())); - } + this.rebuildIndex = this.rebuildIndex.recompute(this.modelResult); } // This mainly exists to explain why we remove the metadata @@ -337,7 +329,7 @@ private void removeFileForReload( visited.add(path); - for (ToShapeId toShapeId : definedShapesByFile.getOrDefault(path, Set.of())) { + for (ToShapeId toShapeId : this.rebuildIndex.getDefinedShapes(path)) { builder.removeShape(toShapeId.toShapeId()); // This shape may have traits applied to it in other files, @@ -345,12 +337,12 @@ private void removeFileForReload( // those traits. // This shape's dependencies files will be removed and re-loaded - smithyFileDependenciesIndex.getDependenciesFiles(toShapeId).forEach((depPath) -> + this.rebuildIndex.getDependenciesFiles(toShapeId).forEach((depPath) -> removeFileForReload(assembler, builder, depPath, visited)); // Traits applied in other files are re-added to the assembler so if/when the shape // is reloaded, it will have those traits - smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(toShapeId).forEach((trait) -> + this.rebuildIndex.getTraitsAppliedInOtherFiles(toShapeId).forEach((trait) -> assembler.addTrait(toShapeId.toShapeId(), trait)); } } @@ -365,9 +357,9 @@ private void removeDependentsForReload( // the file would be fine because it would ignore the duplicated trait application coming from the same // source location. But if the apply statement is changed/removed, the old trait isn't removed, so we // could get a duplicate application, or a merged array application. - smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) -> + this.rebuildIndex.getDependentFiles(path).forEach((depPath) -> removeFileForReload(assembler, builder, depPath, visited)); - smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { + this.rebuildIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { Shape shape = builder.getCurrentShapes().get(shapeId); if (shape != null) { builder.removeShape(shapeId); @@ -381,18 +373,158 @@ private void removeDependentsForReload( } private void addRemainingMetadataForReload(Model.Builder builder, Set filesToSkip) { - for (Map.Entry> e : this.perFileMetadata.entrySet()) { + for (Map.Entry> e : this.rebuildIndex.filesToMetadata().entrySet()) { if (!filesToSkip.contains(e.getKey())) { e.getValue().forEach(builder::putMetadataProperty); } } } - private Set getFileShapes(String path, Set orDefault) { - return this.modelResult.getResult() - .map(model -> model.shapes() - .filter(shape -> shape.getSourceLocation().getFilename().equals(path)) - .collect(Collectors.toSet())) - .orElse(orDefault); + /** + * An index that caches rebuild dependency relationships between Smithy files, + * shapes, traits, and metadata. + * + *

This is specifically for the following scenarios: + *

+ *
A file applies traits to shapes in other files
+ *
If that file changes, the applied traits need to be removed before the + * file is reloaded, so there aren't duplicate traits.
+ *
A file has shapes with traits applied in other files
+ *
If that file changes, the traits need to be re-applied when the model is + * re-assembled, so they aren't lost.
+ *
Either 1 or 2, but specifically with list traits
+ *
List traits are merged via + * trait conflict resolution . For these traits, all files that contain + * parts of the list trait must be fully reloaded, since we can only remove + * the whole trait, not parts of it.
+ *
A file has metadata
+ *
Metadata for a specific file has to be removed before reloading that + * file, but since array nodes are merged, we also need to keep track of + * other files' metadata that may also need to be reloaded.
+ *
+ */ + record RebuildIndex( + Map> filesToDependentFiles, + Map> shapeIdsToDependenciesFiles, + Map>> filesToTraitsTheyApply, + Map> shapesToAppliedTraitsInOtherFiles, + Map> filesToMetadata, + Map> filesToDefinedShapes + ) { + private RebuildIndex() { + this( + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0) + ); + } + + static RebuildIndex create(ValidatedResult modelResult) { + return new RebuildIndex().recompute(modelResult); + } + + Set getDependentFiles(String path) { + return filesToDependentFiles.getOrDefault(path, Collections.emptySet()); + } + + Set getDependenciesFiles(ToShapeId toShapeId) { + return shapeIdsToDependenciesFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptySet()); + } + + Map> getAppliedTraitsInFile(String path) { + return filesToTraitsTheyApply.getOrDefault(path, Collections.emptyMap()); + } + + List getTraitsAppliedInOtherFiles(ToShapeId toShapeId) { + return shapesToAppliedTraitsInOtherFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptyList()); + } + + Set getDefinedShapes(String path) { + return filesToDefinedShapes.getOrDefault(path, Collections.emptySet()); + } + + RebuildIndex recompute(ValidatedResult modelResult) { + var newIndex = new RebuildIndex( + new HashMap<>(filesToDependentFiles.size()), + new HashMap<>(shapeIdsToDependenciesFiles.size()), + new HashMap<>(filesToTraitsTheyApply.size()), + new HashMap<>(shapesToAppliedTraitsInOtherFiles.size()), + new HashMap<>(filesToMetadata.size()), + new HashMap<>(filesToDefinedShapes.size()) + ); + + if (modelResult.getResult().isEmpty()) { + return newIndex; + } + + Model model = modelResult.getResult().get(); + + // This is gross, but necessary to deal with the way that array metadata gets merged. + // When we try to reload a single file, we need to make sure we remove the metadata for + // that file. But if there's array metadata, a single key contains merged elements from + // other files. This splits up the metadata by source file, creating an artificial array + // node for elements that are merged. + for (var metadataEntry : model.getMetadata().entrySet()) { + if (metadataEntry.getValue().isArrayNode()) { + Map arrayByFile = new HashMap<>(); + for (Node node : metadataEntry.getValue().expectArrayNode()) { + String filename = node.getSourceLocation().getFilename(); + arrayByFile.computeIfAbsent(filename, (f) -> ArrayNode.builder()).withValue(node); + } + for (var arrayByFileEntry : arrayByFile.entrySet()) { + newIndex.filesToMetadata.computeIfAbsent(arrayByFileEntry.getKey(), (f) -> new HashMap<>()) + .put(metadataEntry.getKey(), arrayByFileEntry.getValue().build()); + } + } else { + String filename = metadataEntry.getValue().getSourceLocation().getFilename(); + newIndex.filesToMetadata.computeIfAbsent(filename, (f) -> new HashMap<>()) + .put(metadataEntry.getKey(), metadataEntry.getValue()); + } + } + + for (Shape shape : model.toSet()) { + String shapeSourceFilename = shape.getSourceLocation().getFilename(); + newIndex.filesToDefinedShapes.computeIfAbsent(shapeSourceFilename, (f) -> new HashSet<>()) + .add(shape); + + for (Trait traitApplication : shape.getAllTraits().values()) { + // We only care about trait applications in the source files + if (traitApplication.isSynthetic()) { + continue; + } + + Node traitNode = traitApplication.toNode(); + if (traitNode.isArrayNode()) { + for (Node element : traitNode.expectArrayNode()) { + String elementSourceFilename = element.getSourceLocation().getFilename(); + if (!elementSourceFilename.equals(shapeSourceFilename)) { + newIndex.filesToDependentFiles + .computeIfAbsent(elementSourceFilename, (f) -> new HashSet<>()) + .add(shapeSourceFilename); + newIndex.shapeIdsToDependenciesFiles + .computeIfAbsent(shape.getId(), (i) -> new HashSet<>()) + .add(elementSourceFilename); + } + } + } else { + String traitSourceFilename = traitApplication.getSourceLocation().getFilename(); + if (!traitSourceFilename.equals(shapeSourceFilename)) { + newIndex.shapesToAppliedTraitsInOtherFiles + .computeIfAbsent(shape.getId(), (i) -> new ArrayList<>()) + .add(traitApplication); + newIndex.filesToTraitsTheyApply + .computeIfAbsent(traitSourceFilename, (f) -> new HashMap<>()) + .computeIfAbsent(shape.getId(), (i) -> new ArrayList<>()) + .add(traitApplication); + } + } + } + } + + return newIndex; + } } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java index 0a79da85..7790acab 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java @@ -12,7 +12,6 @@ * @param uri The uri of the file * @param project The project, non-nullable * @param file The file within {@code project}, non-nullable - * @param isDetached Whether the project and file represent a detached project */ -public record ProjectAndFile(String uri, Project project, ProjectFile file, boolean isDetached) { +public record ProjectAndFile(String uri, Project project, ProjectFile file) { } 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 1061ce77..05e0aeda 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -15,8 +15,9 @@ 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.ManagedFiles; import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.NodeMapper; @@ -87,11 +88,7 @@ public final class ProjectConfigLoader { private ProjectConfigLoader() { } - static Result> loadFromRoot(Path workspaceRoot) { - return loadFromRoot(workspaceRoot, new ServerState()); - } - - static Result> loadFromRoot(Path workspaceRoot, ServerState state) { + static Result> loadFromRoot(Path workspaceRoot, ManagedFiles managedFiles) { SmithyBuildConfig.Builder builder = SmithyBuildConfig.builder(); List exceptions = new ArrayList<>(); Map buildFiles = new HashMap<>(); @@ -100,7 +97,7 @@ static Result> loadFromRoot(Path workspaceRoot, S if (Files.isRegularFile(smithyBuildPath)) { LOGGER.info("Loading smithy-build.json from " + smithyBuildPath); Result result = Result.ofFallible(() -> { - BuildFile buildFile = addBuildFile(buildFiles, smithyBuildPath, state); + BuildFile buildFile = addBuildFile(buildFiles, smithyBuildPath, managedFiles); return SmithyBuildConfig.fromNode( Node.parseJsonWithComments(buildFile.document().copyText(), buildFile.path())); }); @@ -116,7 +113,7 @@ static Result> loadFromRoot(Path workspaceRoot, S Path extPath = workspaceRoot.resolve(ext); if (Files.isRegularFile(extPath)) { Result result = Result.ofFallible(() -> { - BuildFile buildFile = addBuildFile(buildFiles, extPath, state); + BuildFile buildFile = addBuildFile(buildFiles, extPath, managedFiles); return loadSmithyBuildExtensions(buildFile); }); result.get().ifPresent(extensionsBuilder::merge); @@ -129,7 +126,7 @@ static Result> loadFromRoot(Path workspaceRoot, S if (Files.isRegularFile(smithyProjectPath)) { LOGGER.info("Loading .smithy-project.json from " + smithyProjectPath); Result result = Result.ofFallible(() -> { - BuildFile buildFile = addBuildFile(buildFiles, smithyProjectPath, state); + BuildFile buildFile = addBuildFile(buildFiles, smithyProjectPath, managedFiles); return ProjectConfig.Builder.load(buildFile); }); if (result.isOk()) { @@ -154,14 +151,16 @@ static Result> loadFromRoot(Path workspaceRoot, S return Result.ok(finalConfigBuilder.build()); } - private static BuildFile addBuildFile(Map buildFiles, Path path, ServerState state) { - Document managed = state.getManagedDocument(path); + private static BuildFile addBuildFile(Map buildFiles, Path path, ManagedFiles managedFiles) { + String pathString = path.toString(); + String uri = LspAdapter.toUri(pathString); + Document managed = managedFiles.getManagedDocument(uri); BuildFile buildFile; if (managed != null) { - buildFile = new BuildFile(path.toString(), managed); + buildFile = new BuildFile(pathString, managed); } else { Document document = Document.of(IoUtils.readUtf8File(path)); - buildFile = new BuildFile(path.toString(), document); + buildFile = new BuildFile(pathString, document); } buildFiles.put(buildFile.path(), buildFile); return buildFile; 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 538b5cca..77f1d26e 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -19,23 +19,19 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.function.Supplier; import java.util.logging.Logger; -import java.util.stream.Collectors; import java.util.stream.Stream; -import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.ManagedFiles; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.ModelAssembler; import software.amazon.smithy.model.loader.ModelDiscovery; -import software.amazon.smithy.model.node.ArrayNode; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; +import software.amazon.smithy.utils.TriConsumer; /** * Loads {@link Project}s. @@ -51,7 +47,7 @@ private ProjectLoader() { /** * Loads a detachedProjects (single-file) {@link Project} with the given file. * - *

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

Unlike {@link #load(Path, ManagedFiles)}, this method isn't * fallible since it doesn't do any IO that we would want to recover an * error from. * @@ -61,48 +57,40 @@ private ProjectLoader() { */ public static Project loadDetached(String uri, String text) { LOGGER.info("Loading detachedProjects project at " + uri); + String asPath = LspAdapter.toPath(uri); - Supplier assemblerFactory; + Path path = Paths.get(asPath); + List allSmithyFilePaths = List.of(path); + + Document document = Document.of(text); + ManagedFiles managedFiles = (fileUri) -> { + if (uri.equals(fileUri)) { + return document; + } + return null; + }; + + List dependencies = List.of(); + + LoadModelResult result; try { - assemblerFactory = createModelAssemblerFactory(List.of()); - } catch (MalformedURLException e) { - // Note: This can't happen because we have no dependencies to turn into URLs + result = doLoad(managedFiles, dependencies, allSmithyFilePaths); + } catch (IOException e) { + // Note: This can't happen because we aren't doing any fallible IO, + // as only the prelude will be read from disk throw new RuntimeException(e); } - ValidatedResult modelResult = assemblerFactory.get() - .addUnparsedModel(asPath, text) - .assemble(); - - Path path = Paths.get(asPath); - List sources = Collections.singletonList(path); - - var definedShapesByFile = computeDefinedShapesByFile(sources, modelResult); - var smithyFiles = createSmithyFiles(definedShapesByFile, (filePath) -> { - // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but - // the model stores jar paths as URIs - if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { - return Document.of(IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath))); - } else if (filePath.equals(asPath)) { - return Document.of(text); - } else { - // TODO: Make generic 'please file a bug report' exception - throw new IllegalStateException( - "Attempted to load an unknown source file (" - + filePath + ") in detachedProjects project at " - + asPath + ". This is a bug in the language server."); - } - }); - - return new Project(path.getParent(), - ProjectConfig.builder().sources(List.of(asPath)).build(), - List.of(), - smithyFiles, - assemblerFactory, - definedShapesByFile, - modelResult, - computePerFileMetadata(modelResult), - new SmithyFileDependenciesIndex()); + return new Project( + path, + ProjectConfig.empty(), + dependencies, + result.smithyFiles(), + result.assemblerFactory(), + Project.Type.DETACHED, + result.modelResult(), + result.rebuildIndex() + ); } /** @@ -118,16 +106,20 @@ public static Project loadDetached(String uri, String text) { * reason about how the project was structured. * * @param root Path of the project root - * @param state Server's current state + * @param managedFiles Files managed by the server * @return Result of loading the project */ - public static Result> load(Path root, ServerState state) { - Result> configResult = ProjectConfigLoader.loadFromRoot(root, state); + public static Result> load(Path root, ManagedFiles managedFiles) { + Result> configResult = ProjectConfigLoader.loadFromRoot(root, managedFiles); if (configResult.isErr()) { return Result.err(configResult.unwrapErr()); } ProjectConfig config = configResult.unwrap(); + if (config.buildFiles().isEmpty()) { + return Result.ok(Project.empty(root)); + } + Result, Exception> resolveResult = ProjectDependencyResolver.resolveDependencies(root, config); if (resolveResult.isErr()) { return Result.err(Collections.singletonList(resolveResult.unwrapErr())); @@ -135,149 +127,133 @@ public static Result> load(Path root, ServerState state List dependencies = resolveResult.unwrap(); - // The model assembler factory is used to get assemblers that already have the correct - // dependencies resolved for future loads - Supplier assemblerFactory; + // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential + // here for inconsistent behavior. + List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); + + LoadModelResult result; try { - assemblerFactory = createModelAssemblerFactory(dependencies); - } catch (MalformedURLException e) { - return Result.err(List.of(e)); + result = doLoad(managedFiles, dependencies, allSmithyFilePaths); + } catch (Exception e) { + return Result.err(Collections.singletonList(e)); } - ModelAssembler assembler = assemblerFactory.get(); + return Result.ok(new Project( + root, + config, + dependencies, + result.smithyFiles(), + result.assemblerFactory(), + Project.Type.NORMAL, + result.modelResult(), + result.rebuildIndex() + )); + } - // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential - // here for inconsistent behavior. - List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); + private record LoadModelResult( + Supplier assemblerFactory, + ValidatedResult modelResult, + Map smithyFiles, + Project.RebuildIndex rebuildIndex + ) { + } + + private static LoadModelResult doLoad( + ManagedFiles managedFiles, + List dependencies, + List allSmithyFilePaths + ) throws IOException { + // The model assembler factory is used to get assemblers that already have the correct + // dependencies resolved for future loads + Supplier assemblerFactory = createModelAssemblerFactory(dependencies); + + Map smithyFiles = new HashMap<>(allSmithyFilePaths.size()); - Result, Exception> loadModelResult = loadModel(state, allSmithyFilePaths, assembler); // TODO: Assembler can fail if a file is not found. We can be more intelligent about // handling this case to allow partially loading the project, but we will need to // collect and report the errors somehow. For now, using collectAllSmithyPaths skips // any files that don't exist, so we're essentially side-stepping the issue by // coincidence. - if (loadModelResult.isErr()) { - return Result.err(Collections.singletonList(loadModelResult.unwrapErr())); - } + ModelAssembler assembler = assemblerFactory.get(); + ValidatedResult modelResult = loadModel(managedFiles, allSmithyFilePaths, assembler, smithyFiles); - ValidatedResult modelResult = loadModelResult.unwrap(); - var definedShapesByFile = computeDefinedShapesByFile(allSmithyFilePaths, modelResult); - var smithyFiles = createSmithyFiles(definedShapesByFile, (filePath) -> { - // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but - // the model stores jar paths as URIs - if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { - // Technically this can throw - return Document.of(IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath))); - } - // TODO: We recompute uri from path and vice-versa very frequently, - // maybe we can cache it. - String uri = LspAdapter.toUri(filePath); - 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)); - }); + Project.RebuildIndex rebuildIndex = Project.RebuildIndex.create(modelResult); + addDependencySmithyFiles(managedFiles, rebuildIndex.filesToDefinedShapes().keySet(), smithyFiles); - return Result.ok(new Project(root, - config, - dependencies, - smithyFiles, + return new LoadModelResult( assemblerFactory, - definedShapesByFile, modelResult, - computePerFileMetadata(modelResult), - SmithyFileDependenciesIndex.compute(modelResult))); + smithyFiles, + rebuildIndex + ); } - private static Result, Exception> loadModel( - ServerState state, - List models, - ModelAssembler assembler + private static ValidatedResult loadModel( + ManagedFiles managedFiles, + List allSmithyFilePaths, + ModelAssembler assembler, + Map smithyFiles ) { - try { - for (Path path : models) { - Document managed = state.getManagedDocument(path); - if (managed != null) { - assembler.addUnparsedModel(path.toString(), managed.copyText()); - } else { - assembler.addImport(path); - } - } - - return Result.ok(assembler.assemble()); - } catch (Exception e) { - return Result.err(e); + TriConsumer consumer = (filePath, text, document) -> { + assembler.addUnparsedModel(filePath, text.toString()); + smithyFiles.put(filePath, SmithyFile.create(filePath, document)); + }; + + for (Path path : allSmithyFilePaths) { + String pathString = path.toString(); + findOrReadDocument(managedFiles, pathString, consumer); } - } - static Result> load(Path root) { - return load(root, new ServerState()); + return assembler.assemble(); } - private static Map> computeDefinedShapesByFile( - List allSmithyFilePaths, - ValidatedResult modelResult + // Smithy files in jars were loaded by the model assembler via model discovery, so we need to collect those. + private static void addDependencySmithyFiles( + ManagedFiles managedFiles, + Set loadedSmithyFilePaths, + Map smithyFiles ) { - Map> definedShapesByFile = modelResult.getResult().map(Model::shapes) - .orElseGet(Stream::empty) - .collect(Collectors.groupingByConcurrent( - shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); - - // There may be smithy files part of the project that aren't part of the model, e.g. empty files - for (Path smithyFilePath : allSmithyFilePaths) { - String pathString = smithyFilePath.toString(); - definedShapesByFile.putIfAbsent(pathString, Set.of()); + TriConsumer consumer = (filePath, text, document) -> { + SmithyFile smithyFile = SmithyFile.create(filePath, document); + smithyFiles.put(filePath, smithyFile); + }; + + for (String loadedPath : loadedSmithyFilePaths) { + if (!smithyFiles.containsKey(loadedPath)) { + findOrReadDocument(managedFiles, loadedPath, consumer); + } } - - return definedShapesByFile; } - private static Map createSmithyFiles( - Map> definedShapesByFile, - Function documentProvider + private static void findOrReadDocument( + ManagedFiles managedFiles, + String filePath, + TriConsumer consumer ) { - Map smithyFiles = new HashMap<>(definedShapesByFile.size()); - - for (String path : definedShapesByFile.keySet()) { - Document document = documentProvider.apply(path); - SmithyFile smithyFile = SmithyFile.create(path, document); - smithyFiles.put(path, smithyFile); + // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but + // the model stores jar paths as URIs + if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { + // Technically this can throw + String text = IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath)); + Document document = Document.of(text); + consumer.accept(filePath, text, document); + return; } - return smithyFiles; - } - - // This is gross, but necessary to deal with the way that array metadata gets merged. - // When we try to reload a single file, we need to make sure we remove the metadata for - // that file. But if there's array metadata, a single key contains merged elements from - // other files. This splits up the metadata by source file, creating an artificial array - // node for elements that are merged. - // - // This definitely has the potential to cause a performance hit if there's a huge amount - // of metadata, since we are recomputing this on every change. - static Map> computePerFileMetadata(ValidatedResult modelResult) { - Map metadata = modelResult.getResult().map(Model::getMetadata).orElse(new HashMap<>(0)); - Map> perFileMetadata = new HashMap<>(); - for (Map.Entry entry : metadata.entrySet()) { - if (entry.getValue().isArrayNode()) { - Map arrayByFile = new HashMap<>(); - for (Node node : entry.getValue().expectArrayNode()) { - String filename = node.getSourceLocation().getFilename(); - arrayByFile.computeIfAbsent(filename, (f) -> ArrayNode.builder()).withValue(node); - } - for (Map.Entry arrayByFileEntry : arrayByFile.entrySet()) { - perFileMetadata.computeIfAbsent(arrayByFileEntry.getKey(), (f) -> new HashMap<>()) - .put(entry.getKey(), arrayByFileEntry.getValue().build()); - } - } else { - String filename = entry.getValue().getSourceLocation().getFilename(); - perFileMetadata.computeIfAbsent(filename, (f) -> new HashMap<>()) - .put(entry.getKey(), entry.getValue()); - } + // TODO: We recompute uri from path and vice-versa very frequently, + // maybe we can cache it. + String uri = LspAdapter.toUri(filePath); + Document managed = managedFiles.getManagedDocument(uri); + if (managed != null) { + CharSequence text = managed.borrowText(); + consumer.accept(filePath, text, managed); + return; } - return perFileMetadata; + + // There may be a more efficient way of reading this + String text = IoUtils.readUtf8File(filePath); + Document document = Document.of(text); + consumer.accept(filePath, text, document); } private static Supplier createModelAssemblerFactory(List dependencies) diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java deleted file mode 100644 index f6652c16..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java +++ /dev/null @@ -1,127 +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.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; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.ToShapeId; -import software.amazon.smithy.model.traits.Trait; -import software.amazon.smithy.model.validation.ValidatedResult; - -/** - * An index that caches rebuild dependency relationships between Smithy files, - * shapes, and traits. - * - *

This is specifically for the following scenarios: - *

- *
A file applies traits to shapes in other files
- *
If that file changes, the applied traits need to be removed before the - * file is reloaded, so there aren't duplicate traits.
- *
A file has shapes with traits applied in other files
- *
If that file changes, the traits need to be re-applied when the model is - * re-assembled, so they aren't lost.
- *
Either 1 or 2, but specifically with list traits
- *
List traits are merged via - * trait conflict resolution . For these traits, all files that contain - * parts of the list trait must be fully reloaded, since we can only remove - * the whole trait, not parts of it.
- *
- */ -final class SmithyFileDependenciesIndex { - private final Map> filesToDependentFiles; - private final Map> shapeIdsToDependenciesFiles; - private final Map>> filesToTraitsTheyApply; - private final Map> shapesToAppliedTraitsInOtherFiles; - - SmithyFileDependenciesIndex() { - this.filesToDependentFiles = new HashMap<>(0); - this.shapeIdsToDependenciesFiles = new HashMap<>(0); - this.filesToTraitsTheyApply = new HashMap<>(0); - this.shapesToAppliedTraitsInOtherFiles = new HashMap<>(0); - } - - private SmithyFileDependenciesIndex( - Map> filesToDependentFiles, - Map> shapeIdsToDependenciesFiles, - Map>> filesToTraitsTheyApply, - Map> shapesToAppliedTraitsInOtherFiles - ) { - this.filesToDependentFiles = filesToDependentFiles; - this.shapeIdsToDependenciesFiles = shapeIdsToDependenciesFiles; - this.filesToTraitsTheyApply = filesToTraitsTheyApply; - this.shapesToAppliedTraitsInOtherFiles = shapesToAppliedTraitsInOtherFiles; - } - - Set getDependentFiles(String path) { - return filesToDependentFiles.getOrDefault(path, Collections.emptySet()); - } - - Set getDependenciesFiles(ToShapeId toShapeId) { - return shapeIdsToDependenciesFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptySet()); - } - - Map> getAppliedTraitsInFile(String path) { - return filesToTraitsTheyApply.getOrDefault(path, Collections.emptyMap()); - } - - List getTraitsAppliedInOtherFiles(ToShapeId toShapeId) { - return shapesToAppliedTraitsInOtherFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptyList()); - } - - // TODO: Make this take care of metadata too - static SmithyFileDependenciesIndex compute(ValidatedResult modelResult) { - if (modelResult.getResult().isEmpty()) { - return new SmithyFileDependenciesIndex(); - } - - SmithyFileDependenciesIndex index = new SmithyFileDependenciesIndex( - new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>()); - - Model model = modelResult.getResult().get(); - for (Shape shape : model.toSet()) { - String shapeSourceFilename = shape.getSourceLocation().getFilename(); - for (Trait traitApplication : shape.getAllTraits().values()) { - // We only care about trait applications in the source files - if (traitApplication.isSynthetic()) { - continue; - } - - Node traitNode = traitApplication.toNode(); - if (traitNode.isArrayNode()) { - for (Node element : traitNode.expectArrayNode()) { - String elementSourceFilename = element.getSourceLocation().getFilename(); - if (!elementSourceFilename.equals(shapeSourceFilename)) { - index.filesToDependentFiles.computeIfAbsent(elementSourceFilename, (k) -> new HashSet<>()) - .add(shapeSourceFilename); - index.shapeIdsToDependenciesFiles.computeIfAbsent(shape.getId(), (k) -> new HashSet<>()) - .add(elementSourceFilename); - } - } - } else { - String traitSourceFilename = traitApplication.getSourceLocation().getFilename(); - if (!traitSourceFilename.equals(shapeSourceFilename)) { - index.shapesToAppliedTraitsInOtherFiles.computeIfAbsent(shape.getId(), (k) -> new ArrayList<>()) - .add(traitApplication); - index.filesToTraitsTheyApply.computeIfAbsent(traitSourceFilename, (k) -> new HashMap<>()) - .computeIfAbsent(shape.getId(), (k) -> new ArrayList<>()) - .add(traitApplication); - } - } - } - } - - return index; - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index cab974b4..dbe118d1 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -89,23 +89,20 @@ public void describeMismatchSafely(Collection item, Description descri } public static Matcher hasText(Document document, Matcher expected) { - return new CustomTypeSafeMatcher<>("text in range") { + return new CustomTypeSafeMatcher<>("text in range " + expected.toString()) { @Override protected boolean matchesSafely(Range item) { - CharSequence borrowed = document.borrowRange(item); - if (borrowed == null) { - return false; - } - return expected.matches(borrowed.toString()); + String actual = document.copyRange(item); + return expected.matches(actual); } @Override public void describeMismatchSafely(Range range, Description description) { - if (document.borrowRange(range) == null) { + if (document.copyRange(range) == null) { description.appendText("text was null"); } else { description.appendDescriptionOf(expected) - .appendText("was " + document.borrowRange(range).toString()); + .appendText("was " + document.copyRange(range)); } } }; diff --git a/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java b/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java deleted file mode 100644 index c079935d..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ServerStateTest.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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 f2b36628..9395eada 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1,7 +1,7 @@ package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; @@ -35,10 +35,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; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionParams; import org.eclipse.lsp4j.Diagnostic; @@ -112,11 +109,16 @@ public void formatting() throws Exception { String uri = workspace.getUri("main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(model) + .build()); + TextDocumentIdentifier id = new TextDocumentIdentifier(uri); DocumentFormattingParams params = new DocumentFormattingParams(id, new FormattingOptions()); List edits = server.formatting(params).get(); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().getManagedDocument(uri); assertThat(edits, containsInAnyOrder(makesEditedDocument(document, safeString(""" $version: "2" @@ -174,10 +176,10 @@ 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.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); // mostly so you can see what it looks like - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(safeString(""" $version: "2" namespace com.foo @@ -218,7 +220,7 @@ public void didChangeReloadsModel() throws Exception { .text(model) .build(); server.didOpen(openParams); - assertThat(server.getFirstProject().modelResult().getValidationEvents(), empty()); + assertThat(server.getState().findProjectAndFile(uri).project().modelResult().getValidationEvents(), empty()); DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() .uri(uri) @@ -227,15 +229,15 @@ public void didChangeReloadsModel() throws Exception { .build(); server.didChange(didChangeParams); - server.getState().lifecycleManager().getTask(uri).get(); + server.getState().lifecycleTasks().getTask(uri).get(); - assertThat(server.getFirstProject().modelResult().getValidationEvents(), + assertThat(server.getState().findProjectAndFile(uri).project().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); server.didSave(didSaveParams); - assertThat(server.getFirstProject().modelResult().getValidationEvents(), + assertThat(server.getState().findProjectAndFile(uri).project().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); } @@ -260,7 +262,7 @@ public void diagnosticsOnMemberTarget() { Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), startsWith("Target.UnresolvedShape")); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); } @@ -283,7 +285,6 @@ public void diagnosticsOnInvalidStructureMember() { assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.getFirst(); - Document document = server.getFirstProject().getDocument(uri); assertThat(diagnostic.getRange(), equalTo( new Range( @@ -310,7 +311,7 @@ public void diagnosticsOnUse() { server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); Diagnostic diagnostic = diagnostics.getFirst(); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); assertThat(diagnostic.getRange(), hasText(document, equalTo("mything#SomeUnknownThing"))); @@ -343,7 +344,7 @@ public void diagnosticOnTrait() { Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), startsWith("Model.UnresolvedTrait")); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); assertThat(diagnostic.getRange(), hasText(document, equalTo("@bar"))); } @@ -425,7 +426,6 @@ public void insideJar() throws Exception { String preludeUri = preludeLocation.getUri(); assertThat(preludeUri, startsWith("smithyjar")); - Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getFirstProject().getDocument(preludeUri).fullRange()); Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() .uri(preludeUri) @@ -464,15 +464,12 @@ public void addingWatchedFile() throws Exception { .build()); // Make sure the task is running, then wait for it - CompletableFuture future = server.getState().lifecycleManager().getTask(uri); + CompletableFuture future = server.getState().lifecycleTasks().getTask(uri); assertThat(future, notNullValue()); future.get(); - assertThat(server.getState().managedUris().contains(uri), is(true)); - assertThat(server.getState().isDetached(uri), is(false)); - assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getState().findProjectAndFile(uri), notNullValue()); - assertThat(server.getState().findProjectAndFile(uri).file().document().copyText(), equalTo("$")); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().findManaged(uri).file().document().copyText(), equalTo("$")); } @Test @@ -501,8 +498,7 @@ public void removingWatchedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getState().managedUris().contains(uri), is(false)); - assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); } @Test @@ -524,9 +520,7 @@ public void addingDetachedFile() { .text(modelText) .build()); - assertThat(server.getState().managedUris().contains(uri), is(true)); - assertThat(server.getState().isDetached(uri), is(true)); - assertThat(server.getState().findProjectAndFile(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); String movedFilename = "model/main.smithy"; workspace.moveModel(filename, movedFilename); @@ -542,12 +536,8 @@ public void addingDetachedFile() { .event(movedUri, FileChangeType.Created) .build()); - 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()); + assertThat(server.getState().findManaged(uri), nullValue()); + assertManagedMatches(server, movedUri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -569,9 +559,7 @@ public void removingAttachedFile() { .text(modelText) .build()); - assertThat(server.getState().managedUris().contains(uri), is(true)); - assertThat(server.getState().isDetached(uri), is(false)); - assertThat(server.getState().findProjectAndFile(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); String movedFilename = "main.smithy"; workspace.moveModel(filename, movedFilename); @@ -588,12 +576,8 @@ public void removingAttachedFile() { .event(uri, FileChangeType.Deleted) .build()); - 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()); + assertThat(server.getState().findManaged(uri), nullValue()); + assertManagedMatches(server, movedUri, Project.Type.DETACHED, movedUri); } @Test @@ -624,9 +608,7 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { .text(modelText) .build()); - assertThat(server.getState().managedUris().contains(uri), is(true)); - assertThat(server.getState().isDetached(uri), is(false)); - assertThat(server.getState().findProjectAndFile(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -654,7 +636,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataBefore = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -674,9 +656,9 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .uri(uri) .build()); - server.getState().lifecycleManager().getTask(uri).get(); + server.getState().lifecycleTasks().getTask(uri).get(); - Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataAfter = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); @@ -688,9 +670,9 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .text("") .build()); - server.getState().lifecycleManager().getTask(uri).get(); + server.getState().lifecycleTasks().getTask(uri).get(); - Map metadataAfter2 = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataAfter2 = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataAfter2, hasKey("foo")); assertThat(metadataAfter2, hasKey("bar")); assertThat(metadataAfter2.get("foo"), instanceOf(ArrayNode.class)); @@ -722,7 +704,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataBefore = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -735,16 +717,15 @@ public void changingWatchedFilesWithMetadata() throws Exception { .event(uri, FileChangeType.Deleted) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataAfter = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); assertThat(metadataAfter.get("foo").expectArrayNode().size(), equalTo(2)); } - // TODO: Somehow this is flaky @Test public void addingOpenedDetachedFile() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); @@ -761,18 +742,14 @@ public void addingOpenedDetachedFile() throws Exception { String uri = workspace.getUri("main.smithy"); - assertThat(server.getState().managedUris(), not(hasItem(uri))); - assertThat(server.getState().isDetached(uri), is(false)); - assertThat(server.getState().findProjectAndFile(uri), nullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); server.didOpen(RequestBuilders.didOpen() .uri(uri) .text(modelText) .build()); - assertThat(server.getState().managedUris(), hasItem(uri)); - assertThat(server.getState().isDetached(uri), is(true)); - assertThat(server.getState().findProjectAndFile(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -792,13 +769,13 @@ public void addingOpenedDetachedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - 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")); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().findManaged(uri).project().modelResult().unwrap(), allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Bar") + )); } @Test @@ -832,14 +809,13 @@ public void detachingOpenedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - 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"))); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); + assertThat(server.getState().findManaged(uri).project().modelResult(), hasValue(allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Bar") + ))); } @Test @@ -876,14 +852,10 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - 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)); + assertThat(server.getState().findManaged(uri), nullValue()); + assertManagedMatches(server, movedUri, Project.Type.DETACHED, movedUri); } @Test @@ -912,7 +884,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .text(modelText1) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); List publishedDiagnostics1 = client.diagnostics; assertThat(publishedDiagnostics1, hasSize(1)); @@ -938,7 +910,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .event(uri2, FileChangeType.Created) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); List publishedDiagnostics2 = client.diagnostics; assertThat(publishedDiagnostics2, hasSize(2)); // sent more diagnostics @@ -957,12 +929,19 @@ public void invalidSyntaxModelPartiallyLoads() { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("model-0.smithy"); + Project project = server.getState().findProjectByRoot(workspace.getRoot().toString()); + assertThat(project, notNullValue()); + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getResult().isPresent(), is(true)); + assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); - assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getFirstProject().modelResult().isBroken(), is(true)); - assertThat(server.getFirstProject().modelResult().getResult().isPresent(), is(true)); - assertThat(server.getFirstProject().modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + String uri = workspace.getUri("model-1.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText2) + .build()); + + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -980,14 +959,14 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { .text(modelText) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getState().isDetached(uri), is(true)); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); 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))); + assertThat(projectAndFile.project().getAllSmithyFilePaths(), hasItem(endsWith(filename))); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -998,18 +977,17 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { """)) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getState().isDetached(uri), is(true)); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); 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().getAllSmithyFilePaths(), hasItem(endsWith(filename))); assertThat(projectAndFile1.project().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } - // TODO: apparently flaky @Test public void addingDetachedFileWithInvalidSyntax() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); @@ -1025,12 +1003,9 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .text("") .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getState().isDetached(uri), is(true)); - ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); - assertThat(projectAndFile, notNullValue()); - assertThat(projectAndFile.project().getSmithyFile(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); updatedSources.add(filename); @@ -1059,12 +1034,10 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .range(LspAdapter.point(2, 0)) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - 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"))); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().findManaged(uri).project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1095,10 +1068,14 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text("2") .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - Shape foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri2); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + + Shape foo = projectAndFile.project().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); @@ -1114,11 +1091,15 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text(safeString("string Another\n")) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); + + projectAndFile = server.getState().findProjectAndFile(uri1); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + foo = projectAndFile.project().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); } @@ -1183,7 +1164,7 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { """)) .range(LspAdapter.origin()) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); assertThat(projectAndFile, notNullValue()); @@ -1212,17 +1193,14 @@ public void loadsMultipleRoots() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); - assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getState().attachedProjects(), hasKey(workspaceBar.getName())); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertThat(projectFoo, notNullValue()); + assertThat(projectBar, notNullValue()); - 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"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("foo.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("bar.smithy"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); @@ -1278,10 +1256,10 @@ public void multiRootLifecycleManagement() throws Exception { .uri(barUri) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); - Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1349,14 +1327,14 @@ public void multiRootAddingWatchedFile() throws Exception { .range(LspAdapter.point(3, 0)) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); - Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("other.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("other.smithy"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1417,6 +1395,9 @@ public void multiRootChangingBuildFile() throws Exception { .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Changed) .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceBar.getUri("model/main.smithy")) + .build()); server.didChange(RequestBuilders.didChange() .uri(workspaceBar.getUri("model/main.smithy")) .text(""" @@ -1429,24 +1410,23 @@ public void multiRootChangingBuildFile() throws Exception { .range(LspAdapter.origin()) .build()); - server.getState().lifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - 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.getState().attachedProjects().get(workspaceFoo.getName()); - Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("other.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("other.smithy"))); - assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar$other"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.other#Other"))); + assertThat(projectFoo.modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.bar#Bar"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.bar#Bar$other"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.other#Other"))); } @Test @@ -1487,19 +1467,16 @@ public void addingWorkspaceFolder() throws Exception { .text(barModel) .build()); - server.getState().lifecycleManager().waitForAllTasks(); - - assertThat(server.getState().attachedProjects(), hasKey(workspaceFoo.getName())); - assertThat(server.getState().attachedProjects(), hasKey(workspaceBar.getName())); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertManagedMatches(server, workspaceFoo.getUri("foo.smithy"), Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, workspaceBar.getUri("bar.smithy"), Project.Type.NORMAL, workspaceBar.getRoot()); - Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); - Project projectBar = server.getState().attachedProjects().get(workspaceBar.getName()); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("foo.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("bar.smithy"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); @@ -1529,13 +1506,15 @@ public void removingWorkspaceFolder() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + String fooUri = workspaceFoo.getUri("foo.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspaceFoo.getUri("foo.smithy")) + .uri(fooUri) .text(fooModel) .build()); + String barUri = workspaceBar.getUri("bar.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspaceBar.getUri("bar.smithy")) + .uri(barUri) .text(barModel) .build()); @@ -1543,22 +1522,17 @@ public void removingWorkspaceFolder() { .removed(workspaceBar.getRoot().toUri().toString(), "bar") .build()); - 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.getState().findProjectAndFile(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); - Project projectFoo = server.getState().attachedProjects().get(workspaceFoo.getName()); - Project projectBar = server.getState().findProjectAndFile(workspaceBar.getUri("bar.smithy")).project(); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(barUri); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("foo.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("bar.smithy"))); - assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + assertThat(projectFoo.modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.bar#Bar"))); } @Test @@ -1590,9 +1564,8 @@ public void singleWorkspaceMultiRoot() throws Exception { SmithyLanguageServer server = initFromRoot(root); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), notNullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); assertThat(server.getState().workspacePaths(), contains(root)); } @@ -1631,9 +1604,8 @@ public void addingRootsToWorkspace() throws Exception { .build()); assertThat(server.getState().workspacePaths(), contains(root)); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), notNullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); } @Test @@ -1665,10 +1637,9 @@ public void removingRootsFromWorkspace() throws Exception { SmithyLanguageServer server = initFromRoot(root); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), notNullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); workspaceFoo.deleteModel("smithy-build.json"); @@ -1677,7 +1648,8 @@ public void removingRootsFromWorkspace() throws Exception { .build()); assertThat(server.getState().workspacePaths(), contains(root)); - assertThat(server.getState().attachedProjects().keySet(), contains(workspaceBar.getName())); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), nullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); } @Test @@ -1709,22 +1681,34 @@ public void addingConfigFile() throws Exception { SmithyLanguageServer server = initFromRoot(root); + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + String bazModel = """ $version: "2" namespace com.baz structure Baz {} """; workspaceFoo.addModel("baz.smithy", bazModel); + String bazUri = workspaceFoo.getUri("baz.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspaceFoo.getUri("baz.smithy")) + .uri(bazUri) .text(bazModel) .build()); assertThat(server.getState().workspacePaths(), contains(root)); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); - assertThat(server.getState().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); workspaceFoo.addModel(".smithy-project.json", """ { @@ -1734,10 +1718,9 @@ public void addingConfigFile() throws Exception { .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Created) .build()); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); - assertThat(server.getState().detachedProjects().keySet(), empty()); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.NORMAL, workspaceFoo.getRoot()); } @Test @@ -1779,26 +1762,37 @@ public void removingConfigFile() throws Exception { SmithyLanguageServer server = initFromRoot(root); + String fooUri = workspaceFoo.getUri("foo.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspaceFoo.getUri("baz.smithy")) + .uri(fooUri) + .text(fooModel) + .build()); + + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String bazUri = workspaceFoo.getUri("baz.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(bazUri) .text(bazModel) .build()); assertThat(server.getState().workspacePaths(), contains(root)); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); - assertThat(server.getState().detachedProjects().keySet(), empty()); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.NORMAL, workspaceFoo.getRoot()); workspaceFoo.deleteModel(".smithy-project.json"); server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Deleted) .build()); - assertThat(server.getState().attachedProjects().keySet(), containsInAnyOrder( - workspaceFoo.getName(), - workspaceBar.getName())); - assertThat(server.getState().detachedProjects().keySet(), contains(workspaceFoo.getUri("baz.smithy"))); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); } @Test @@ -1816,14 +1810,8 @@ public void tracksJsonFiles() { """); 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() - )); + Project project = server.getState().findProjectByRoot(workspace.getName()); + assertThat(project.modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1839,8 +1827,7 @@ public void tracksBuildFileChanges() { .text(smithyBuildJson) .build()); - assertThat(server.getState().managedUris(), contains(uri)); - assertThat(server.getState().getManagedDocument(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(smithyBuildJson)); String updatedSmithyBuildJson = """ @@ -1862,8 +1849,7 @@ public void tracksBuildFileChanges() { .uri(uri) .build()); - assertThat(server.getState().managedUris(), not(contains(uri))); - assertThat(server.getState().getManagedDocument(uri), nullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); } @Test @@ -1884,12 +1870,13 @@ public void reloadsProjectOnBuildFileSave() { string Foo """; workspace.addModel("foo.smithy", model); + String fooUri = workspace.getUri("foo.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspace.getUri("foo.smithy")) + .uri(fooUri) .text(model) .build()); - assertThat(server.getState().detachedProjects().keySet(), contains(workspace.getUri("foo.smithy"))); + assertManagedMatches(server, fooUri, Project.Type.DETACHED, fooUri); String updatedBuildJson = """ { @@ -1905,14 +1892,7 @@ public void reloadsProjectOnBuildFileSave() { .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())); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -1962,41 +1942,28 @@ public void testFromInitializeParamsWithPartialOptions() { assertThat(options.getOnlyReloadOnSave(), equalTo(true)); // Explicitly set value } - private void assertServerState(SmithyLanguageServer server, ServerState expected) { - ServerState actual = ServerState.from(server); - assertThat(actual, equalTo(expected)); - } - - record ServerState( - Map attached, - Map detached + private void assertManagedMatches( + SmithyLanguageServer server, + String uri, + Project.Type expectedType, + String expectedRootUri ) { - 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())))); - } + ProjectAndFile projectAndFile = server.getState().findManaged(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().type(), equalTo(expectedType)); + assertThat(projectAndFile.project().root().toString(), equalTo(LspAdapter.toPath(expectedRootUri))); } - record ProjectState( - Set smithyFileUris, - Set buildFileUris + private void assertManagedMatches( + SmithyLanguageServer server, + String uri, + Project.Type expectedType, + Path expectedRootPath ) { - 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); - } + ProjectAndFile projectAndFile = server.getState().findManaged(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().type(), equalTo(expectedType)); + assertThat(projectAndFile.project().root(), equalTo(expectedRootPath)); } public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java index f5796236..6dd38cfd 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -42,7 +42,7 @@ public void describeMismatchSafely(ValidatedResult item, Description descript } public static Matcher hasShapeWithId(String id) { - return new CustomTypeSafeMatcher<>("a model with the shape id `" + id + "`") { + return new CustomTypeSafeMatcher<>("has shape id `" + id + "`") { @Override protected boolean matchesSafely(Model item) { return item.getShape(ShapeId.from(id)).isPresent(); @@ -59,7 +59,7 @@ public void describeMismatchSafely(Model model, Description description) { } public static Matcher eventWithMessage(Matcher message) { - return new CustomTypeSafeMatcher<>("has matching message") { + return new CustomTypeSafeMatcher<>("has message matching " + message.toString()) { @Override protected boolean matchesSafely(ValidationEvent item) { return message.matches(item.getMessage()); @@ -71,4 +71,13 @@ public void describeMismatchSafely(ValidationEvent event, Description descriptio } }; } + + public static Matcher eventWithId(Matcher id) { + return new CustomTypeSafeMatcher<>("has id matching " + id.toString()) { + @Override + protected boolean matchesSafely(ValidationEvent item) { + return id.matches(item.getId()); + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index a806b492..9af70e0c 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -87,7 +87,7 @@ public void noVersionDiagnostic() throws Exception { List edits = action.getEdit().getChanges().get(uri); assertThat(edits, hasSize(1)); TextEdit edit = edits.get(0); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); document.applyEdit(edit.getRange(), edit.getNewText()); assertThat(document.copyText(), equalTo(safeString(""" $version: "1" @@ -141,7 +141,7 @@ public void oldVersionDiagnostic() throws Exception { List edits = action.getEdit().getChanges().get(uri); assertThat(edits, hasSize(1)); TextEdit edit = edits.get(0); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); document.applyEdit(edit.getRange(), edit.getNewText()); assertThat(document.copyText(), equalTo(""" $version: "2" diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java index b3acf480..a3424b8c 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -255,88 +255,11 @@ public void getsEnd() { } @Test - public void borrowsToken() { - String s = "abc\n" + - "def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 2)); - - assertThat(token, string("abc")); - } - - @Test - public void borrowsTokenWithNoWs() { - String s = "abc"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 1)); - - assertThat(token, string("abc")); - } - - @Test - public void borrowsTokenAtStart() { - String s = "abc\n" + - "def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 0)); - - assertThat(token, string("abc")); - } - - @Test - public void borrowsTokenAtEnd() { - String s = "abc\n" + - "def"; - Document document = makeDocument(s); + public void foo() { + Document a = makeDocument("abc"); + Document b = makeDocument("def\n"); - CharSequence token = document.borrowToken(new Position(1, 2)); - - assertThat(token, string("def")); - } - - @Test - public void borrowsTokenAtBoundaryStart() { - String s = "a bc d"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 2)); - - assertThat(token, string("bc")); - } - - @Test - public void borrowsTokenAtBoundaryEnd() { - String s = "a bc d"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 3)); - - assertThat(token, string("bc")); - } - - @Test - public void doesntBorrowNonToken() { - String s = "abc def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 3)); - - assertThat(token, nullValue()); - } - - @Test - public void borrowsLine() { - Document document = makeDocument("abc\n\ndef"); - - assertThat(makeDocument("").borrowLine(0), string("")); - assertThat(document.borrowLine(0), string(safeString("abc\n"))); - assertThat(document.borrowLine(1), string(safeString("\n"))); - assertThat(document.borrowLine(2), string("def")); - assertThat(document.borrowLine(-1), nullValue()); - assertThat(document.borrowLine(3), nullValue()); + System.out.println(); } @Test @@ -370,23 +293,6 @@ public void getsLastIndexOf() { assertThat(document.lastIndexOf(" ", safeIndex(8, 1)), is(-1)); // not found } - @Test - public void borrowsSpan() { - Document empty = makeDocument(""); - Document line = makeDocument("abc"); - Document multi = makeDocument("abc\ndef\n\n"); - - assertThat(empty.borrowSpan(0, 1), nullValue()); // empty - assertThat(line.borrowSpan(-1, 1), nullValue()); // negative - assertThat(line.borrowSpan(0, 0), string("")); // empty - assertThat(line.borrowSpan(0, 1), string("a")); // one - assertThat(line.borrowSpan(0, 3), string("abc")); // all - assertThat(line.borrowSpan(0, 4), nullValue()); // oob - assertThat(multi.borrowSpan(0, safeIndex(4, 1)), string(safeString("abc\n"))); // with newline - assertThat(multi.borrowSpan(3, safeIndex(5, 1)), string(safeString("\nd"))); // inner - assertThat(multi.borrowSpan(safeIndex(5, 1), safeIndex(9, 3)), string(safeString("ef\n\n"))); // up to end - } - @Test public void getsLineOfIndex() { Document empty = makeDocument(""); diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java index b5d3e324..1982dec6 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -32,6 +32,7 @@ import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; public class CompletionHandlerTest { @Test @@ -1087,7 +1088,7 @@ private static List getCompItems(String text, Position... positi TestWorkspace workspace = TestWorkspace.singleModel(text); Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); String uri = workspace.getUri("main.smithy"); - IdlFile smithyFile = (IdlFile) project.getSmithyFile(uri); + IdlFile smithyFile = (IdlFile) (SmithyFile) project.getProjectFile(uri); List completionItems = new ArrayList<>(); CompletionHandler handler = new CompletionHandler(project, smithyFile); diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java index 0dbed9c4..8f680cfa 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -333,7 +333,8 @@ private static void assertIsShapeDef( Location location, String expected ) { - SmithyFile smithyFile = result.handler.project.getSmithyFile(location.getUri()); + String uri = location.getUri(); + SmithyFile smithyFile = (SmithyFile) result.handler.project.getProjectFile(uri); assertThat(smithyFile, notNullValue()); int documentIndex = smithyFile.document().indexOfPosition(location.getRange().getStart()); @@ -367,7 +368,7 @@ private static GetLocationsResult getLocations(String text, Position... position TestWorkspace workspace = TestWorkspace.singleModel(text); Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); String uri = workspace.getUri("main.smithy"); - SmithyFile smithyFile = project.getSmithyFile(uri); + SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); List locations = new ArrayList<>(); DefinitionHandler handler = new DefinitionHandler(project, (IdlFile) smithyFile); diff --git a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java index ab2e521e..0242eab8 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java @@ -17,6 +17,7 @@ import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; public class DocumentSymbolTest { @Test @@ -50,7 +51,7 @@ private static List getDocumentSymbolNames(String text) { TestWorkspace workspace = TestWorkspace.singleModel(text); Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); String uri = workspace.getUri("main.smithy"); - IdlFile idlFile = (IdlFile) project.getSmithyFile(uri); + IdlFile idlFile = (IdlFile) (SmithyFile) project.getProjectFile(uri); List names = new ArrayList<>(); var handler = new DocumentSymbolHandler(idlFile.document(), idlFile.getParse().statements()); diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java index 5f37e89e..7a22bc66 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -156,7 +156,7 @@ private static List getHovers(String text, Position... positions) { TestWorkspace workspace = TestWorkspace.singleModel(text); Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); String uri = workspace.getUri("main.smithy"); - SmithyFile smithyFile = project.getSmithyFile(uri); + SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); List hover = new ArrayList<>(); HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile, Severity.WARNING); diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java index 7e0d9f62..6b4c0bb5 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.build.model.MavenRepository; +import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.util.Result; public class ProjectConfigLoaderTest { @@ -25,7 +26,7 @@ public class ProjectConfigLoaderTest { public void loadsConfigWithEnvVariable() { System.setProperty("FOO", "bar"); Path root = toPath(getClass().getResource("env-config")); - Result> result = ProjectConfigLoader.loadFromRoot(root); + Result> result = load(root); assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); @@ -41,7 +42,7 @@ public void loadsConfigWithEnvVariable() { @Test public void loadsLegacyConfig() { Path root = toPath(getClass().getResource("legacy-config")); - Result> result = ProjectConfigLoader.loadFromRoot(root); + Result> result = load(root); assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); @@ -56,7 +57,7 @@ public void loadsLegacyConfig() { @Test public void prefersNonLegacyConfig() { Path root = toPath(getClass().getResource("legacy-config-with-conflicts")); - Result> result = ProjectConfigLoader.loadFromRoot(root); + Result> result = load(root); assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); @@ -71,10 +72,14 @@ public void prefersNonLegacyConfig() { @Test public void mergesBuildExts() { Path root = toPath(getClass().getResource("build-exts")); - Result> result = ProjectConfigLoader.loadFromRoot(root); + Result> result = load(root); assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); assertThat(config.imports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); } + + private static Result> load(Path root) { + return ProjectConfigLoader.loadFromRoot(root, new ServerState()); + } } diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java index a7c983f3..8d030bcb 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -10,25 +10,26 @@ import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; -import java.util.Set; -import java.util.logging.Logger; -import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; @@ -36,22 +37,20 @@ import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.LengthTrait; import software.amazon.smithy.model.traits.PatternTrait; import software.amazon.smithy.model.traits.TagsTrait; import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; public class ProjectTest { @Test public void loadsFlatProject() { Path root = toPath(getClass().getResource("flat")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); - assertThat(project.imports(), empty()); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.config().imports(), empty()); assertThat(project.dependencies(), empty()); assertThat(project.modelResult().isBroken(), is(false)); assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); @@ -60,11 +59,11 @@ public void loadsFlatProject() { @Test public void loadsProjectWithMavenDep() { Path root = toPath(getClass().getResource("maven-dep")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); - assertThat(project.imports(), empty()); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.config().imports(), empty()); assertThat(project.dependencies(), hasSize(3)); assertThat(project.modelResult().isBroken(), is(false)); assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); @@ -73,13 +72,13 @@ public void loadsProjectWithMavenDep() { @Test public void loadsProjectWithSubdir() { Path root = toPath(getClass().getResource("subdirs")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItems( - root.resolve("model"), - root.resolve("model2"))); - assertThat(project.smithyFiles().keySet(), hasItems( + assertThat(project.config().sources(), hasItems( + endsWith("model"), + endsWith("model2"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( equalTo(root.resolve("model/main.smithy").toString()), equalTo(root.resolve("model/subdir/sub.smithy").toString()), equalTo(root.resolve("model2/subdir2/sub2.smithy").toString()), @@ -93,16 +92,12 @@ public void loadsProjectWithSubdir() { @Test public void loadsModelWithUnknownTrait() { Path root = toPath(getClass().getResource("unknown-trait")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); assertThat(project.modelResult().isBroken(), is(false)); // unknown traits don't break it - - List eventIds = project.modelResult().getValidationEvents().stream() - .map(ValidationEvent::getId) - .collect(Collectors.toList()); - assertThat(eventIds, hasItem(containsString("UnresolvedTrait"))); + assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(containsString("UnresolvedTrait")))); assertThat(project.modelResult().getResult().isPresent(), is(true)); assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); } @@ -110,75 +105,47 @@ public void loadsModelWithUnknownTrait() { @Test public void loadsWhenModelHasInvalidSyntax() { Path root = toPath(getClass().getResource("invalid-syntax")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); assertThat(project.modelResult().isBroken(), is(true)); - List eventIds = project.modelResult().getValidationEvents().stream() - .map(ValidationEvent::getId) - .collect(Collectors.toList()); - assertThat(eventIds, hasItem("Model")); - - assertThat(project.smithyFiles().keySet(), hasItem(containsString("main.smithy"))); - IdlFile main = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("main.smithy").toString())); - assertThat(main, not(nullValue())); - assertThat(main.document(), not(nullValue())); - assertThat(main.getParse().namespace().namespace(), equalTo("com.foo")); - assertThat(main.getParse().imports().imports(), empty()); - - assertThat(project.definedShapesByFile().keySet(), hasItem(main.path())); - Set mainShapes = project.definedShapesByFile().get(main.path()); - List shapeIds = mainShapes.stream() - .map(ToShapeId::toShapeId) - .map(ShapeId::toString) - .collect(Collectors.toList()); - assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar")); + assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(equalTo("Model")))); + assertThat(project.modelResult(), hasValue(allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Foo$bar")))); + assertThat(project.getAllSmithyFilePaths(), hasItem(containsString("main.smithy"))); } @Test public void loadsProjectWithMultipleNamespaces() { Path root = toPath(getClass().getResource("multiple-namespaces")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); - assertThat(project.sources(), hasItem(root.resolve("model"))); + assertThat(project.config().sources(), hasItem(endsWith("model"))); assertThat(project.modelResult().getValidationEvents(), empty()); - assertThat(project.smithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); - - IdlFile a = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("model/a.smithy").toString())); - assertThat(a.document(), not(nullValue())); - assertThat(a.getParse().namespace().namespace(), equalTo("a")); - - assertThat(project.definedShapesByFile().keySet(), hasItem(a.path())); - Set aShapes = project.definedShapesByFile().get(a.path()); - List aShapeIds = aShapes.stream() - .map(ToShapeId::toShapeId) - .map(ShapeId::toString) - .collect(Collectors.toList()); - assertThat(aShapeIds, hasItems("a#Hello", "a#HelloInput", "a#HelloOutput")); - - IdlFile b = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("model/b.smithy").toString())); - assertThat(b.document(), not(nullValue())); - assertThat(b.getParse().namespace().namespace(), equalTo("b")); - - assertThat(project.definedShapesByFile().keySet(), hasItem(b.path())); - Set bShapes = project.definedShapesByFile().get(b.path()); - List bShapeIds = bShapes.stream() - .map(ToShapeId::toShapeId) - .map(ShapeId::toString) - .collect(Collectors.toList()); - assertThat(bShapeIds, hasItems("b#Hello", "b#HelloInput", "b#HelloOutput")); + assertThat(project.getAllSmithyFilePaths(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); + + assertThat(project.modelResult(), hasValue(allOf( + hasShapeWithId("a#Hello"), + hasShapeWithId("a#HelloInput"), + hasShapeWithId("a#HelloOutput"), + hasShapeWithId("b#Hello"), + hasShapeWithId("b#HelloInput"), + hasShapeWithId("b#HelloOutput")))); } @Test public void loadsProjectWithExternalJars() { Path root = toPath(getClass().getResource("external-jars")); - Result> result = ProjectLoader.load(root); + Result> result = load(root); assertThat(result.isOk(), is(true)); Project project = result.unwrap(); - assertThat(project.sources(), containsInAnyOrder(root.resolve("test-traits.smithy"), root.resolve("test-validators.smithy"))); - assertThat(project.smithyFiles().keySet(), hasItems( + assertThat(project.config().sources(), containsInAnyOrder( + endsWith("test-traits.smithy"), + endsWith("test-validators.smithy"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( containsString("test-traits.smithy"), containsString("test-validators.smithy"), containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"), @@ -197,7 +164,7 @@ public void loadsProjectWithExternalJars() { @Test public void failsLoadingInvalidSmithyBuildJson() { Path root = toPath(getClass().getResource("broken/missing-version")); - Result> result = ProjectLoader.load(root); + Result> result = load(root); assertThat(result.isErr(), is(true)); } @@ -205,7 +172,7 @@ public void failsLoadingInvalidSmithyBuildJson() { @Test public void failsLoadingUnparseableSmithyBuildJson() { Path root = toPath(getClass().getResource("broken/parse-failure")); - Result> result = ProjectLoader.load(root); + Result> result = load(root); assertThat(result.isErr(), is(true)); } @@ -213,17 +180,17 @@ public void failsLoadingUnparseableSmithyBuildJson() { @Test public void doesntFailLoadingProjectWithNonExistingSource() { Path root = toPath(getClass().getResource("broken/source-doesnt-exist")); - Result> result = ProjectLoader.load(root); + Result> result = load(root); assertThat(result.isErr(), is(false)); - assertThat(result.unwrap().smithyFiles().size(), equalTo(1)); // still have the prelude + assertThat(result.unwrap().getAllSmithyFiles().size(), equalTo(1)); // still have the prelude } @Test public void failsLoadingUnresolvableMavenDependency() { Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); - Result> result = ProjectLoader.load(root); + Result> result = load(root); assertThat(result.isErr(), is(true)); } @@ -231,7 +198,7 @@ public void failsLoadingUnresolvableMavenDependency() { @Test public void failsLoadingUnresolvableProjectDependency() { Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); - Result> result = ProjectLoader.load(root); + Result> result = load(root); assertThat(result.isErr(), is(true)); } @@ -239,14 +206,14 @@ public void failsLoadingUnresolvableProjectDependency() { @Test public void loadsProjectWithUnNormalizedDirs() { Path root = toPath(getClass().getResource("unnormalized-dirs")); - Project project = ProjectLoader.load(root).unwrap(); + Project project = load(root).unwrap(); assertThat(project.root(), equalTo(root)); assertThat(project.sources(), hasItems( root.resolve("model"), root.resolve("model2"))); assertThat(project.imports(), hasItem(root.resolve("model3"))); - assertThat(project.smithyFiles().keySet(), hasItems( + assertThat(project.getAllSmithyFilePaths(), hasItems( equalTo(root.resolve("model/test-traits.smithy").toString()), equalTo(root.resolve("model/one.smithy").toString()), equalTo(root.resolve("model2/two.smithy").toString()), @@ -269,14 +236,14 @@ public void changeFileApplyingSimpleTrait() { string Bar """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("length"), is(true)); assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -300,14 +267,14 @@ public void changeFileApplyingListTrait() { string Bar """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -337,7 +304,7 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { apply Baz @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); Shape baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); @@ -347,7 +314,7 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { assertThat(baz.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -379,7 +346,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { apply Bar @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); @@ -388,7 +355,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -419,14 +386,14 @@ public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { apply Bar @tags(["bar"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "bar")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -450,14 +417,14 @@ public void changingFileWithDependencies() { apply Foo @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("length"), is(true)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -481,14 +448,14 @@ public void changingFileWithArrayDependencies() { apply Foo @tags(["foo"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -513,14 +480,14 @@ public void changingFileWithMixedArrayDependencies() { apply Foo @tags(["foo"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "foo")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -549,7 +516,7 @@ public void changingFileWithArrayDependenciesWithDependencies() { apply Bar @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); @@ -559,18 +526,7 @@ public void changingFileWithArrayDependenciesWithDependencies() { assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); - if (document == null) { - String smithyFilesPaths = String.join(System.lineSeparator(), project.smithyFiles().keySet()); - String smithyFilesUris = project.smithyFiles().keySet().stream() - .map(LspAdapter::toUri) - .collect(Collectors.joining(System.lineSeparator())); - Logger logger = Logger.getLogger(getClass().getName()); - logger.severe("Not found uri: " + uri); - logger.severe("Not found path: " + LspAdapter.toPath(uri)); - logger.severe("PATHS: " + smithyFilesPaths); - logger.severe("URIS: " + smithyFilesUris); - } + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -601,7 +557,7 @@ public void removingSimpleApply() { apply Bar @pattern("a") """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("pattern"), is(true)); @@ -610,7 +566,7 @@ public void removingSimpleApply() { assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); project.updateModelWithoutValidating(uri); @@ -639,14 +595,14 @@ public void removingArrayApply() { apply Bar @tags(["bar"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()).unwrap(); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "bar")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); project.updateModelWithoutValidating(uri); @@ -656,6 +612,18 @@ public void removingArrayApply() { assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("bar")); } + @Test + public void loadsEmptyProjectWhenThereAreNoConfigFiles() throws Exception { + Path root = Files.createTempDirectory("foo"); + Project project = load(root).unwrap(); + + assertThat(project.type(), equalTo(Project.Type.EMPTY)); + } + + private static Result> load(Path root) { + return ProjectLoader.load(root, new ServerState()); + } + public static Path toPath(URL url) { try { return Paths.get(url.toURI()); From f2e3af74ad4c656a410f9230b22c60e9e1079961 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:21:00 -0500 Subject: [PATCH 12/43] Fix Smithy file watch patterns (#192) Addresses https://github.com/smithy-lang/smithy-language-server/issues/191. I had to disable our test for watcher registrations because (as described in the issue) PathMatcher behaves differently that whatever VSCode is using to match glob patterns. For testing, I may need to followup with a more robust way of verifying changes against specific clients. --- src/main/java/software/amazon/smithy/lsp/FilePatterns.java | 2 +- .../amazon/smithy/lsp/FileWatcherRegistrationsTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java index 23bab11f..08469699 100644 --- a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java +++ b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java @@ -106,7 +106,7 @@ private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) glob += "**"; if (isWatcherPattern) { - glob += ".{smithy,json}"; + glob += "/*.{smithy,json}"; } return escapeBackslashes(glob); diff --git a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java index 88c710b8..073317df 100644 --- a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java @@ -13,6 +13,7 @@ import java.util.List; import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; import org.eclipse.lsp4j.Registration; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.project.Project; @@ -21,6 +22,7 @@ public class FileWatcherRegistrationsTest { @Test + @Disabled("https://github.com/smithy-lang/smithy-language-server/issues/191") public void createsCorrectRegistrations() { TestWorkspace workspace = TestWorkspace.builder() .withSourceDir(new TestWorkspace.Dir() From ca6c2d079dd3ec1b7423c986b86c6572f0ac142d Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:52:02 -0500 Subject: [PATCH 13/43] Build file diagnostics (#188) This commit makes the language server send diagnostics back to build files, i.e. smithy-build.json. Previously, any issues in build files would result in the project failing to load, and those errors would be reported to the client's window. With this change, issues are recomputed on change, and sent back as diagnostics so you get squigglies. Much better. To accomplish this, a number of changes were needed: 1. Reparse build files on change. Previously, we just updated the document. 2. Have a way to run some sort of validation on build files that can tolerate errors. This is split into two parts: 1. A regular validation stage that takes the parsed build file and tries to map it to its specific java representation, collecting any errors that occur. For example, smithy-build.json is turned into SmithyBuildConfig. 2. A resolution stage that takes the java representation and tries to resolve maven dependencies, and recursively find all model paths from sources and imports. 3. Keep track of events emitted from this validation so they can be sent back to the client. 2 is the most complicated part. SmithyBuildConfig does some extra work under the hood when it is deserialized from a Node, like environment variable replacement. I wanted to make sure there wasn't any drift between the language server and other Smithy tools, so I kept using SmithyBuildConfig::fromNode, but now any exception thrown from this will be mapped to a validation event. Each of the other build files work the same way. I also kept the same merging logic for aggregating config from multiple build files. Next is the resolution part. Maven resolution can fail in multiple ways. We have to try to map any exceptions back to a source location, because we don't have access to the original source locations. For finding source/import files, I wanted to be able to report when files aren't found (this also helps to make sure assembling the model doesn't fail due to files not being found), so we have to do the same thing (that is, map back to a source location). Resolution in general is expensive, as it could be hitting maven central, but doing this mapping could also be expensive, so we don't perform the resolution step when build files change - only when a project is actually loaded. We will have to see how this validation feels, and make improvements where necessary. Additional changes: - Report Smithy's json parse errors - Added a 'use-smithy-build' diagnostic to 'legacy' build files - Fix json node parsing to properly handle commas in the IDL vs actual json --- .gitignore | 3 +- .../amazon/smithy/lsp/FilePatterns.java | 4 +- .../amazon/smithy/lsp/ProjectRootVisitor.java | 4 +- .../amazon/smithy/lsp/ServerState.java | 22 +- .../smithy/lsp/SmithyLanguageServer.java | 39 +- .../lsp/diagnostics/SmithyDiagnostics.java | 164 ++-- .../amazon/smithy/lsp/project/BuildFile.java | 52 +- .../smithy/lsp/project/BuildFileType.java | 79 ++ .../amazon/smithy/lsp/project/BuildFiles.java | 91 +++ .../amazon/smithy/lsp/project/Project.java | 44 +- .../smithy/lsp/project/ProjectConfig.java | 175 ++--- .../lsp/project/ProjectConfigLoader.java | 710 ++++++++++++++---- .../smithy/lsp/project/ProjectDependency.java | 25 - .../project/ProjectDependencyResolver.java | 113 --- .../smithy/lsp/project/ProjectFile.java | 5 + .../smithy/lsp/project/ProjectLoader.java | 186 +---- .../smithy/lsp/project/SmithyProjectJson.java | 64 ++ .../smithy/lsp/project/ToSmithyNode.java | 109 +++ .../smithy/lsp/protocol/LspAdapter.java | 30 +- .../amazon/smithy/lsp/syntax/Parser.java | 32 +- .../amazon/smithy/lsp/syntax/Syntax.java | 4 +- .../amazon/smithy/lsp/util/Result.java | 163 ---- .../amazon/smithy/lsp/FilePatternsTest.java | 6 +- .../lsp/FileWatcherRegistrationsTest.java | 4 +- .../amazon/smithy/lsp/SmithyMatchers.java | 10 + .../lsp/language/CompletionHandlerTest.java | 5 +- .../lsp/language/DefinitionHandlerTest.java | 5 +- .../lsp/language/DocumentSymbolTest.java | 5 +- .../smithy/lsp/language/HoverHandlerTest.java | 5 +- .../lsp/project/ProjectConfigLoaderTest.java | 85 --- .../smithy/lsp/project/ProjectConfigTest.java | 277 +++++++ .../smithy/lsp/project/ProjectLoaderTest.java | 230 ++++++ .../smithy/lsp/project/ProjectTest.java | 228 +----- .../smithy/lsp/project/ToSmithyNodeTest.java | 105 +++ .../build-exts/build/smithy-dependencies.json | 4 + 35 files changed, 1939 insertions(+), 1148 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/util/Result.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json diff --git a/.gitignore b/.gitignore index 5ae1ecbd..ebfbd49d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,8 @@ project/project .gradle # Ignore Gradle build output directory -build +# Note: Only ignore the top-level build dir, tests use dirs named 'build' which we don't want to ignore +/build bin diff --git a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java index 08469699..f232fffb 100644 --- a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java +++ b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java @@ -12,8 +12,8 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; +import software.amazon.smithy.lsp.project.BuildFileType; import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectConfigLoader; /** * Utility methods for computing glob patterns that match against Smithy files @@ -87,7 +87,7 @@ private static String getBuildFilesPattern(Path root, boolean isWorkspacePattern rootString += "**" + File.separator; } - return escapeBackslashes(rootString + "{" + String.join(",", ProjectConfigLoader.PROJECT_BUILD_FILES) + "}"); + return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}"); } // When computing the pattern used for telling the client which files to watch, we want diff --git a/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java index a1016b9d..448e3da0 100644 --- a/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java +++ b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java @@ -17,14 +17,14 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.List; -import software.amazon.smithy.lsp.project.ProjectConfigLoader; +import software.amazon.smithy.lsp.project.BuildFileType; /** * Finds Project roots based on the location of smithy-build.json and .smithy-project.json. */ final class ProjectRootVisitor extends SimpleFileVisitor { private static final PathMatcher PROJECT_ROOT_MATCHER = FileSystems.getDefault().getPathMatcher( - "glob:{" + ProjectConfigLoader.SMITHY_BUILD + "," + ProjectConfigLoader.SMITHY_PROJECT + "}"); + "glob:{" + BuildFileType.SMITHY_BUILD.filename() + "," + BuildFileType.SMITHY_PROJECT.filename() + "}"); private static final int MAX_VISIT_DEPTH = 10; private final List roots = new ArrayList<>(); diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index c0de6d52..9760c20d 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -26,7 +26,6 @@ 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. @@ -143,10 +142,9 @@ List tryInitProject(Path root) { LOGGER.finest("Initializing project at " + root); lifecycleTasks.cancelAllTasks(); - Result> loadResult = ProjectLoader.load(root, this); String projectName = root.toString(); - if (loadResult.isOk()) { - Project updatedProject = loadResult.unwrap(); + try { + Project updatedProject = ProjectLoader.load(root, this); if (updatedProject.type() == Project.Type.EMPTY) { removeProjectAndResolveDetached(projectName); @@ -157,17 +155,15 @@ List tryInitProject(Path root) { LOGGER.finest("Initialized project at " + root); return List.of(); - } + } catch (Exception e) { + LOGGER.severe("Failed to load project at " + root); - LOGGER.severe("Init project failed"); + // 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. + projects.computeIfAbsent(projectName, ignored -> Project.empty(root)); - // 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. - projects.computeIfAbsent(projectName, ignored -> Project.empty(root)); - - return loadResult.unwrapErr(); + return List.of(e); + } } void loadWorkspace(WorkspaceFolder workspaceFolder) { diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 1cc512b0..78587faa 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -447,22 +447,31 @@ public void didChange(DidChangeTextDocumentParams params) { } } - // Don't reload or update the project on build file changes, only on save - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { - return; - } + projectAndFile.file().reparse(); - smithyFile.reparse(); - if (!this.serverOptions.getOnlyReloadOnSave()) { - Project project = projectAndFile.project(); + Project project = projectAndFile.project(); + switch (projectAndFile.file()) { + case SmithyFile ignored -> { + if (this.serverOptions.getOnlyReloadOnSave()) { + return; + } + + // TODO: A consequence of this is that any existing validation events are cleared, which + // is kinda annoying. + // Report any parse/shape/trait loading errors + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateModelWithoutValidating(uri)) + .thenRunAsync(() -> sendFileDiagnostics(projectAndFile)); + + state.lifecycleTasks().putTask(uri, future); + } + case BuildFile ignored -> { + CompletableFuture future = CompletableFuture + .runAsync(project::validateConfig) + .thenRunAsync(() -> sendFileDiagnostics(projectAndFile)); - // TODO: A consequence of this is that any existing validation events are cleared, which - // is kinda annoying. - // Report any parse/shape/trait loading errors - CompletableFuture future = CompletableFuture - .runAsync(() -> project.updateModelWithoutValidating(uri)) - .thenComposeAsync(unused -> sendFileDiagnostics(projectAndFile)); - state.lifecycleTasks().putTask(uri, future); + state.lifecycleTasks().putTask(uri, future); + } } } @@ -512,7 +521,7 @@ public void didSave(DidSaveTextDocumentParams params) { } else { CompletableFuture future = CompletableFuture .runAsync(() -> project.updateAndValidateModel(uri)) - .thenCompose(unused -> sendFileDiagnostics(projectAndFile)); + .thenRunAsync(() -> sendFileDiagnostics(projectAndFile)); state.lifecycleTasks().putTask(uri, future); } } diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java index 2edc2034..ce9c7652 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java @@ -15,6 +15,7 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.document.DocumentParser; +import software.amazon.smithy.lsp.project.BuildFile; import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectAndFile; @@ -32,9 +33,12 @@ public final class SmithyDiagnostics { public static final String UPDATE_VERSION = "migrating-idl-1-to-2"; public static final String DEFINE_VERSION = "define-idl-version"; public static final String DETACHED_FILE = "detached-file"; + public static final String USE_SMITHY_BUILD = "use-smithy-build"; private static final DiagnosticCodeDescription UPDATE_VERSION_DESCRIPTION = new DiagnosticCodeDescription("https://smithy.io/2.0/guides/migrating-idl-1-to-2.html"); + private static final DiagnosticCodeDescription USE_SMITHY_BUILD_DESCRIPTION = + new DiagnosticCodeDescription("https://smithy.io/2.0/guides/smithy-build-json.html#using-smithy-build-json"); private SmithyDiagnostics() { } @@ -51,82 +55,140 @@ public static List getFileDiagnostics(ProjectAndFile projectAndFile, return List.of(); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { - return List.of(); - } + Diagnose diagnose = switch (projectAndFile.file()) { + case SmithyFile smithyFile -> new DiagnoseSmithy(smithyFile, projectAndFile.project()); + case BuildFile buildFile -> new DiagnoseBuild(buildFile, projectAndFile.project()); + }; - Project project = projectAndFile.project(); String path = projectAndFile.file().path(); + EventToDiagnostic eventToDiagnostic = diagnose.getEventToDiagnostic(); - EventToDiagnostic eventToDiagnostic = eventToDiagnostic(smithyFile); - - List diagnostics = project.modelResult().getValidationEvents().stream() + List diagnostics = diagnose.getValidationEvents().stream() .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0 && event.getSourceLocation().getFilename().equals(path)) .map(eventToDiagnostic::toDiagnostic) .collect(Collectors.toCollection(ArrayList::new)); - Diagnostic versionDiagnostic = versionDiagnostic(smithyFile); - if (versionDiagnostic != null) { - diagnostics.add(versionDiagnostic); - } - - if (projectAndFile.project().type() == Project.Type.DETACHED) { - diagnostics.add(detachedDiagnostic(smithyFile)); - } + diagnose.addExtraDiagnostics(diagnostics); return diagnostics; } - private static Diagnostic versionDiagnostic(SmithyFile smithyFile) { - if (!(smithyFile instanceof IdlFile idlFile)) { - return null; + private sealed interface Diagnose { + List getValidationEvents(); + + EventToDiagnostic getEventToDiagnostic(); + + void addExtraDiagnostics(List diagnostics); + } + + private record DiagnoseSmithy(SmithyFile smithyFile, Project project) implements Diagnose { + @Override + public List getValidationEvents() { + return project.modelResult().getValidationEvents(); } - Syntax.IdlParseResult syntaxInfo = idlFile.getParse(); - if (syntaxInfo.version().version().startsWith("2")) { - return null; - } else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) { - var diagnostic = createDiagnostic( - syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION); - diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); - return diagnostic; - } else { - int end = smithyFile.document().lineEnd(0); - Range range = LspAdapter.lineSpan(0, 0, end); - return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION); + @Override + public EventToDiagnostic getEventToDiagnostic() { + if (!(smithyFile instanceof IdlFile idlFile)) { + return new Simple(); + } + + var idlParse = idlFile.getParse(); + var view = StatementView.createAtStart(idlParse).orElse(null); + if (view == null) { + return new Simple(); + } else { + var documentParser = DocumentParser.forStatements( + smithyFile.document(), view.parseResult().statements()); + return new Idl(view, documentParser); + } } - } - private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { - Range range; - if (smithyFile.document() == null) { - range = LspAdapter.origin(); - } else { - int end = smithyFile.document().lineEnd(0); - range = LspAdapter.lineSpan(0, 0, end); + @Override + public void addExtraDiagnostics(List diagnostics) { + Diagnostic versionDiagnostic = versionDiagnostic(smithyFile); + if (versionDiagnostic != null) { + diagnostics.add(versionDiagnostic); + } + + if (project.type() == Project.Type.DETACHED) { + diagnostics.add(detachedDiagnostic(smithyFile)); + } } - return createDiagnostic(range, "This file isn't attached to a project", DETACHED_FILE); - } - private static Diagnostic createDiagnostic(Range range, String title, String code) { - return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code); + private static Diagnostic versionDiagnostic(SmithyFile smithyFile) { + if (!(smithyFile instanceof IdlFile idlFile)) { + return null; + } + + Syntax.IdlParseResult syntaxInfo = idlFile.getParse(); + if (syntaxInfo.version().version().startsWith("2")) { + return null; + } else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) { + var diagnostic = createDiagnostic( + syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION); + diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); + return diagnostic; + } else { + int end = smithyFile.document().lineEnd(0); + Range range = LspAdapter.lineSpan(0, 0, end); + return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION); + } + } + + private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { + Range range; + if (smithyFile.document() == null) { + range = LspAdapter.origin(); + } else { + int end = smithyFile.document().lineEnd(0); + range = LspAdapter.lineSpan(0, 0, end); + } + + return createDiagnostic(range, "This file isn't attached to a project", DETACHED_FILE); + } } - private static EventToDiagnostic eventToDiagnostic(SmithyFile smithyFile) { - if (!(smithyFile instanceof IdlFile idlFile)) { - return new Simple(); + private record DiagnoseBuild(BuildFile buildFile, Project project) implements Diagnose { + @Override + public List getValidationEvents() { + return project().configEvents(); } - var idlParse = idlFile.getParse(); - var view = StatementView.createAtStart(idlParse).orElse(null); - if (view == null) { + @Override + public EventToDiagnostic getEventToDiagnostic() { return new Simple(); - } else { - var documentParser = DocumentParser.forStatements(smithyFile.document(), view.parseResult().statements()); - return new Idl(view, documentParser); } + + @Override + public void addExtraDiagnostics(List diagnostics) { + switch (buildFile.type()) { + case SMITHY_BUILD_EXT_0, SMITHY_BUILD_EXT_1 -> diagnostics.add(useSmithyBuild()); + default -> { + } + } + } + + private Diagnostic useSmithyBuild() { + Range range = LspAdapter.origin(); + Diagnostic diagnostic = createDiagnostic( + range, + String.format(""" + You should use smithy-build.json as your build configuration file for Smithy. + The %s file is not supported by Smithy, and support from the language server + will be removed in a later version. + """, buildFile.type().filename()), + USE_SMITHY_BUILD + ); + diagnostic.setCodeDescription(USE_SMITHY_BUILD_DESCRIPTION); + return diagnostic; + } + } + + private static Diagnostic createDiagnostic(Range range, String title, String code) { + return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code); } private sealed interface EventToDiagnostic { diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java index d2374302..97d55555 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java @@ -5,7 +5,9 @@ package software.amazon.smithy.lsp.project; +import java.util.concurrent.locks.ReentrantLock; import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; /** * The language server's representation of a smithy-build.json @@ -14,10 +16,25 @@ public final class BuildFile implements ProjectFile { private final String path; private final Document document; + private final BuildFileType type; + private final ReentrantLock parseLock = new ReentrantLock(); + private Syntax.NodeParseResult parseResult; - BuildFile(String path, Document document) { + private BuildFile( + String path, + Document document, + BuildFileType type, + Syntax.NodeParseResult parseResult + ) { this.path = path; this.document = document; + this.type = type; + this.parseResult = parseResult; + } + + static BuildFile create(String path, Document document, BuildFileType type) { + Syntax.NodeParseResult parseResult = Syntax.parseNode(document); + return new BuildFile(path, document, type, parseResult); } @Override @@ -29,4 +46,37 @@ public String path() { public Document document() { return document; } + + @Override + public void reparse() { + Syntax.NodeParseResult updatedParse = Syntax.parseNode(document()); + + parseLock.lock(); + try { + this.parseResult = updatedParse; + } finally { + parseLock.unlock(); + } + } + + /** + * @return The type of this build file + */ + public BuildFileType type() { + return type; + } + + /** + * @return The latest computed {@link Syntax.NodeParseResult} of this build file + * @apiNote Don't call this method over and over. {@link Syntax.NodeParseResult} + * is immutable so just call this once and use the returned value. + */ + public Syntax.NodeParseResult getParse() { + parseLock.lock(); + try { + return parseResult; + } finally { + parseLock.unlock(); + } + } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java new file mode 100644 index 00000000..3e2d5082 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java @@ -0,0 +1,79 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +/** + * The type of build file. + * + *

The language server supports loading project config from multiple kinds + * of files. + */ +public enum BuildFileType { + /** + * Primary smithy-build configuration file used by most projects. + * + * @see software.amazon.smithy.build.model.SmithyBuildConfig + * @see smithy-build.json + */ + SMITHY_BUILD("smithy-build.json"), + + /** + * A config file used specifically for the language server from before + * maven deps from smithy-build.json were supported. + * + * @see SmithyBuildExtensions + */ + SMITHY_BUILD_EXT_0("build" + File.separator + "smithy-dependencies.json"), + + /** + * A config file used specifically for the language server from before + * maven deps from smithy-build.json were supported. + * + * @see SmithyBuildExtensions + */ + SMITHY_BUILD_EXT_1(".smithy.json"), + + /** + * A config file used specifically for the language server to specify + * project config for a project that isn't specifying sources and + * dependencies in smithy-build.json, typically some external build + * system is being used. + * + * @see SmithyProjectJson + */ + SMITHY_PROJECT(".smithy-project.json"),; + + /** + * The filenames of all {@link BuildFileType}s. + */ + public static final List ALL_FILENAMES = Arrays.stream(BuildFileType.values()) + .map(BuildFileType::filename) + .toList(); + + private final String filename; + + BuildFileType(String filename) { + this.filename = filename; + } + + /** + * @return The filename that denotes this {@link BuildFileType}. + */ + public String filename() { + return filename; + } + + boolean supportsMavenConfiguration() { + return switch (this) { + case SMITHY_BUILD, SMITHY_BUILD_EXT_0, SMITHY_BUILD_EXT_1 -> true; + default -> false; + }; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java new file mode 100644 index 00000000..c9123b84 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java @@ -0,0 +1,91 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import software.amazon.smithy.lsp.ManagedFiles; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.utils.IoUtils; + +/** + * Immutable container for multiple {@link BuildFile}s, with accessors by path + * and {@link BuildFileType}. + */ +final class BuildFiles implements Iterable { + private final Map buildFiles; + + private BuildFiles(Map buildFiles) { + this.buildFiles = buildFiles; + } + + @Override + public Iterator iterator() { + return buildFiles.values().iterator(); + } + + BuildFile getByPath(String path) { + return buildFiles.get(path); + } + + BuildFile getByType(BuildFileType type) { + for (BuildFile buildFile : buildFiles.values()) { + if (buildFile.type() == type) { + return buildFile; + } + } + return null; + } + + boolean isEmpty() { + return buildFiles.isEmpty(); + } + + static BuildFiles of(Collection buildFiles) { + Map buildFileMap = new HashMap<>(buildFiles.size()); + for (BuildFile buildFile : buildFiles) { + buildFileMap.put(buildFile.path(), buildFile); + } + return new BuildFiles(buildFileMap); + } + + static BuildFiles load(Path root, ManagedFiles managedFiles) { + Map buildFiles = new HashMap<>(BuildFileType.values().length); + for (BuildFileType type : BuildFileType.values()) { + BuildFile buildFile = readBuildFile(type, root, managedFiles); + if (buildFile != null) { + buildFiles.put(buildFile.path(), buildFile); + } + } + return new BuildFiles(buildFiles); + } + + private static BuildFile readBuildFile( + BuildFileType type, + Path workspaceRoot, + ManagedFiles managedFiles + ) { + Path buildFilePath = workspaceRoot.resolve(type.filename()); + if (!Files.isRegularFile(buildFilePath)) { + return null; + } + + String pathString = buildFilePath.toString(); + String uri = LspAdapter.toUri(pathString); + Document document = managedFiles.getManagedDocument(uri); + if (document == null) { + // Note: This shouldn't fail since we checked for the file's existence + document = Document.of(IoUtils.readUtf8File(pathString)); + } + + return BuildFile.create(pathString, document, type); + } +} 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 e70287d9..b2922b5d 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -30,6 +30,7 @@ import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.utils.IoUtils; /** @@ -41,31 +42,34 @@ public final class Project { private final Path root; private final ProjectConfig config; - private final List dependencies; + private final BuildFiles buildFiles; private final Map smithyFiles; private final Supplier assemblerFactory; private final Type type; - private ValidatedResult modelResult; - private RebuildIndex rebuildIndex; + private volatile ValidatedResult modelResult; + private volatile RebuildIndex rebuildIndex; + private volatile List configEvents; Project( Path root, ProjectConfig config, - List dependencies, + BuildFiles buildFiles, Map smithyFiles, Supplier assemblerFactory, Type type, ValidatedResult modelResult, - RebuildIndex rebuildIndex + RebuildIndex rebuildIndex, + List configEvents ) { this.root = root; this.config = config; - this.dependencies = dependencies; + this.buildFiles = buildFiles; this.smithyFiles = smithyFiles; this.assemblerFactory = assemblerFactory; this.type = type; this.modelResult = modelResult; this.rebuildIndex = rebuildIndex; + this.configEvents = configEvents; } /** @@ -97,12 +101,13 @@ public enum Type { public static Project empty(Path root) { return new Project(root, ProjectConfig.empty(), - List.of(), + BuildFiles.of(List.of()), new HashMap<>(), Model::assembler, Type.EMPTY, ValidatedResult.empty(), - new RebuildIndex()); + new RebuildIndex(), + List.of()); } /** @@ -112,10 +117,14 @@ public Path root() { return root; } - public ProjectConfig config() { + ProjectConfig config() { return config; } + public List configEvents() { + return configEvents; + } + /** * @return The paths of all Smithy sources specified * in this project's smithy build configuration files, @@ -140,13 +149,6 @@ public List imports() { .collect(Collectors.toList()); } - /** - * @return The paths of all resolved dependencies - */ - public List dependencies() { - return dependencies; - } - /** * @return The paths of all Smithy files loaded in the project. */ @@ -184,7 +186,11 @@ public ProjectFile getProjectFile(String uri) { return smithyFile; } - return config.buildFiles().get(path); + return buildFiles.getByPath(path); + } + + public synchronized void validateConfig() { + this.configEvents = ProjectConfigLoader.validateBuildFiles(buildFiles); } /** @@ -215,6 +221,8 @@ public void updateAndValidateModel(String uri) { */ public void updateFiles(Set addUris, Set removeUris) { updateFiles(addUris, removeUris, Collections.emptySet(), true); + // Config has to be re-validated because it may be reporting missing files + validateConfig(); } /** @@ -227,7 +235,7 @@ public void updateFiles(Set addUris, Set removeUris) { * @param changeUris URIs of files that changed * @param validate Whether to run model validation. */ - public void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) { + private void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) { if (modelResult.getResult().isEmpty()) { // TODO: If there's no model, we didn't collect the smithy files (so no document), so I'm thinking // maybe we do nothing here. But we could also still update the document, and 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 7a07f6eb..3f05e0a0 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -5,165 +5,78 @@ package software.amazon.smithy.lsp.project; -import java.util.ArrayList; -import java.util.HashMap; +import java.net.URL; +import java.nio.file.Path; 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; /** * A complete view of all a project's configuration that is needed to load it, * merged from all configuration sources. */ -public final class ProjectConfig { +final class ProjectConfig { + private static final MavenConfig DEFAULT_MAVEN = MavenConfig.builder().build(); + private final List sources; private final List imports; - private final String outputDirectory; - private final List dependencies; - private final MavenConfig mavenConfig; - private final Map buildFiles; + private final List projectDependencies; + private final MavenConfig maven; + private final List modelPaths; + private final List resolvedDependencies; + + ProjectConfig( + List sources, + List imports, + List projectDependencies, + MavenConfig maven, + List modelPaths, + List resolvedDependencies + ) { + this.sources = sources; + this.imports = imports; + this.projectDependencies = projectDependencies; + this.maven = maven == null ? DEFAULT_MAVEN : maven; + this.modelPaths = modelPaths; + this.resolvedDependencies = resolvedDependencies; + } - private ProjectConfig(Builder builder) { - this.sources = builder.sources; - this.imports = builder.imports; - this.outputDirectory = builder.outputDirectory; - this.dependencies = builder.dependencies; - this.mavenConfig = builder.mavenConfig; - this.buildFiles = builder.buildFiles; + private ProjectConfig() { + this(List.of(), List.of(), List.of(), DEFAULT_MAVEN, List.of(), List.of()); + } + + private ProjectConfig(Path modelPath) { + this(List.of(), List.of(), List.of(), DEFAULT_MAVEN, List.of(modelPath), List.of()); } static ProjectConfig empty() { - return builder().build(); + return new ProjectConfig(); } - static Builder builder() { - return new Builder(); + static ProjectConfig detachedConfig(Path modelPath) { + return new ProjectConfig(modelPath); } - /** - * @return All explicitly configured sources - */ - public List sources() { + List sources() { return sources; } - /** - * @return All explicitly configured imports - */ - public List imports() { + List imports() { return imports; } - /** - * @return The configured output directory, if one is present - */ - public Optional outputDirectory() { - return Optional.ofNullable(outputDirectory); + List projectDependencies() { + return projectDependencies; } - /** - * @return All configured external (non-maven) dependencies - */ - public List dependencies() { - return dependencies; + MavenConfig maven() { + return maven; } - /** - * @return The Maven configuration, if present - */ - public Optional maven() { - return Optional.ofNullable(mavenConfig); + List modelPaths() { + return modelPaths; } - /** - * @return Map of path to each {@link BuildFile} loaded in the project - */ - public Map buildFiles() { - return buildFiles; - } - - static final class Builder { - final List sources = new ArrayList<>(); - final List imports = new ArrayList<>(); - String outputDirectory; - final List dependencies = new ArrayList<>(); - MavenConfig mavenConfig; - private final Map buildFiles = new HashMap<>(); - - private Builder() { - } - - 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 -> - projectConfigBuilder.sources(arrayNode.getElementsAs(StringNode.class).stream() - .map(StringNode::getValue) - .collect(Collectors.toList()))); - objectNode.getArrayMember("imports").ifPresent(arrayNode -> - projectConfigBuilder.imports(arrayNode.getElementsAs(StringNode.class).stream() - .map(StringNode::getValue) - .collect(Collectors.toList()))); - objectNode.getStringMember("outputDirectory").ifPresent(stringNode -> - projectConfigBuilder.outputDirectory(stringNode.getValue())); - objectNode.getArrayMember("dependencies").ifPresent(arrayNode -> - projectConfigBuilder.dependencies(arrayNode.getElements().stream() - .map(ProjectDependency::fromNode) - .collect(Collectors.toList()))); - return projectConfigBuilder; - } - - public Builder sources(List sources) { - this.sources.clear(); - this.sources.addAll(sources); - return this; - } - - public Builder addSources(List sources) { - this.sources.addAll(sources); - return this; - } - - public Builder imports(List imports) { - this.imports.clear(); - this.imports.addAll(imports); - return this; - } - - public Builder addImports(List imports) { - this.imports.addAll(imports); - return this; - } - - public Builder outputDirectory(String outputDirectory) { - this.outputDirectory = outputDirectory; - return this; - } - - public Builder dependencies(List dependencies) { - this.dependencies.clear(); - this.dependencies.addAll(dependencies); - return this; - } - - public Builder mavenConfig(MavenConfig mavenConfig) { - this.mavenConfig = mavenConfig; - return this; - } - - public Builder buildFiles(Map buildFiles) { - this.buildFiles.putAll(buildFiles); - return this; - } - - public ProjectConfig build() { - return new ProjectConfig(this); - } + List resolvedDependencies() { + return resolvedDependencies; } } 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 05e0aeda..c99897e1 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -6,172 +6,610 @@ package software.amazon.smithy.lsp.project; import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.file.Files; 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.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.logging.Logger; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Stream; +import software.amazon.smithy.build.model.MavenConfig; +import software.amazon.smithy.build.model.MavenRepository; import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.ManagedFiles; -import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; +import software.amazon.smithy.cli.EnvironmentVariable; +import software.amazon.smithy.cli.dependencies.DependencyResolver; +import software.amazon.smithy.cli.dependencies.DependencyResolverException; +import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; +import software.amazon.smithy.cli.dependencies.ResolvedArtifact; +import software.amazon.smithy.model.SourceException; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.ModelDiscovery; +import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.NodeMapper; import software.amazon.smithy.model.node.ObjectNode; -import software.amazon.smithy.utils.IoUtils; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; /** - * Loads {@link ProjectConfig}s from a given root directory - * - *

This aggregates configuration from multiple sources, including - * {@link ProjectConfigLoader#SMITHY_BUILD}, - * {@link ProjectConfigLoader#SMITHY_BUILD_EXTS}, and - * {@link ProjectConfigLoader#SMITHY_PROJECT}. Each of these are looked - * for in the project root directory. If none are found, an empty smithy-build - * is assumed. Any exceptions that occur are aggregated and will fail the load. - * - *

Aggregation is done as follows: - *

    - *
  1. - * Start with an empty {@link SmithyBuildConfig.Builder}. This will - * aggregate {@link SmithyBuildConfig} and {@link SmithyBuildExtensions} - *
  2. - *
  3. - * If a smithy-build.json exists, try to load it. If one doesn't exist, - * use an empty {@link SmithyBuildConfig} (with version "1"). Merge the result - * into the builder - *
  4. - *
  5. - * If any {@link ProjectConfigLoader#SMITHY_BUILD_EXTS} exist, try to load - * and merge them into a single {@link SmithyBuildExtensions.Builder} - *
  6. - *
  7. - * If a {@link ProjectConfigLoader#SMITHY_PROJECT} exists, try to load it. - * Otherwise use an empty {@link ProjectConfig.Builder}. This will be the - * result of the load - *
  8. - *
  9. - * Merge any {@link ProjectConfigLoader#SMITHY_BUILD_EXTS} into the original - * {@link SmithyBuildConfig.Builder} and build it - *
  10. - *
  11. - * Add all sources, imports, and MavenConfig from the {@link SmithyBuildConfig} - * to the {@link ProjectConfig.Builder} - *
  12. - *
  13. - * If the {@link ProjectConfig.Builder} doesn't specify an outputDirectory, - * use the one in {@link SmithyBuildConfig}, if present - *
  14. - *
+ * Loads {@link ProjectConfig}s from {@link BuildFiles}. */ -public final class ProjectConfigLoader { - public static final String SMITHY_BUILD = "smithy-build.json"; - public static final String[] SMITHY_BUILD_EXTS = { - "build" + File.separator + "smithy-dependencies.json", ".smithy.json"}; - public static final String SMITHY_PROJECT = ".smithy-project.json"; - public static final List PROJECT_BUILD_FILES = new ArrayList<>(2 + SMITHY_BUILD_EXTS.length); - - private static final Logger LOGGER = Logger.getLogger(ProjectConfigLoader.class.getName()); - private static final SmithyBuildConfig DEFAULT_SMITHY_BUILD = SmithyBuildConfig.builder().version("1").build(); +final class ProjectConfigLoader { private static final NodeMapper NODE_MAPPER = new NodeMapper(); + private static final BuildFileType[] EXTS = {BuildFileType.SMITHY_BUILD_EXT_0, BuildFileType.SMITHY_BUILD_EXT_1}; - static { - PROJECT_BUILD_FILES.add(SMITHY_BUILD); - PROJECT_BUILD_FILES.add(SMITHY_PROJECT); - PROJECT_BUILD_FILES.addAll(Arrays.asList(SMITHY_BUILD_EXTS)); - } + private final BuildFiles buildFiles; + private final List events = new ArrayList<>(); + private final Map smithyNodes = new HashMap<>(BuildFileType.values().length); - private ProjectConfigLoader() { + private ProjectConfigLoader(BuildFiles buildFiles) { + this.buildFiles = buildFiles; } - static Result> loadFromRoot(Path workspaceRoot, ManagedFiles managedFiles) { - 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(() -> { - BuildFile buildFile = addBuildFile(buildFiles, smithyBuildPath, managedFiles); - 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."); - builder.merge(DEFAULT_SMITHY_BUILD); - } - - SmithyBuildExtensions.Builder extensionsBuilder = SmithyBuildExtensions.builder(); - for (String ext : SMITHY_BUILD_EXTS) { - Path extPath = workspaceRoot.resolve(ext); - if (Files.isRegularFile(extPath)) { - Result result = Result.ofFallible(() -> { - BuildFile buildFile = addBuildFile(buildFiles, extPath, managedFiles); - return loadSmithyBuildExtensions(buildFile); - }); - result.get().ifPresent(extensionsBuilder::merge); - result.getErr().ifPresent(exceptions::add); - } - } - - ProjectConfig.Builder finalConfigBuilder = ProjectConfig.builder(); - Path smithyProjectPath = workspaceRoot.resolve(SMITHY_PROJECT); - if (Files.isRegularFile(smithyProjectPath)) { - LOGGER.info("Loading .smithy-project.json from " + smithyProjectPath); - Result result = Result.ofFallible(() -> { - BuildFile buildFile = addBuildFile(buildFiles, smithyProjectPath, managedFiles); - return ProjectConfig.Builder.load(buildFile); + /** + * Runs structural validation on each of the given {@link BuildFiles}, + * without performing dependency resolution or constructing a new + * {@link ProjectConfig}. + * + * @param buildFiles The build files to validate + * @return The list of validation events + */ + static List validateBuildFiles(BuildFiles buildFiles) { + List events = new ArrayList<>(); + for (BuildFile buildFile : buildFiles) { + LoadBuildFile loader = switch (buildFile.type()) { + case SMITHY_BUILD -> LoadBuildFile.LOAD_SMITHY_BUILD; + case SMITHY_BUILD_EXT_0, SMITHY_BUILD_EXT_1 -> LoadBuildFile.LOAD_BUILD_EXT; + case SMITHY_PROJECT -> LoadBuildFile.LOAD_SMITHY_PROJECT; + }; + + loadFile(buildFile, loader, events::add, (type, node) -> { }); - if (result.isOk()) { - finalConfigBuilder = result.unwrap(); - } else { - exceptions.add(result.unwrapErr()); + } + return events; + } + + /** + * Result of loading the config. Used in place of {@link ValidatedResult} + * because its value may not be present, which we don't want here. + * + * @param config The loaded config, non-nullable + * @param events The events that occurred during loading, non-nullable + */ + record Result(ProjectConfig config, List events) {} + + /** + * Loads a project's config from the given {@link BuildFiles}, resolving + * dependencies using the default Maven dependency resolver. + * + * @param root The root of the project whose config is being loaded + * @param buildFiles The build files to load config from + * @return The result of loading the config + */ + static Result load(Path root, BuildFiles buildFiles) { + return load(root, buildFiles, Resolver.DEFAULT_RESOLVER_FACTORY); + } + + /** + * Loads a project's config from the given {@link BuildFiles}, resolving + * dependencies using the given factory. + * + * @param root The root of the project whose config is being loaded + * @param buildFiles The build files to load config from + * @param dependencyResolverFactory A factory to get the Maven dependency + * resolver to use + * @return The result of loading the config + */ + static Result load(Path root, BuildFiles buildFiles, Supplier dependencyResolverFactory) { + var loader = new ProjectConfigLoader(buildFiles); + SmithyBuildConfig smithyBuildConfig = loader.loadSmithyBuild(); + SmithyBuildExtensions.Builder extBuilder = loader.loadExts(); + SmithyBuildConfig merged = loader.mergeSmithyBuildConfig(smithyBuildConfig, extBuilder); + SmithyProjectJson smithyProjectJson = loader.loadSmithyProject(); + + List sources = new ArrayList<>(); + List imports = new ArrayList<>(); + MavenConfig mavenConfig = null; + List projectDependencies = new ArrayList<>(); + + if (merged != null) { + sources.addAll(merged.getSources()); + imports.addAll(merged.getImports()); + var mavenOpt = merged.getMaven(); + if (mavenOpt.isPresent()) { + mavenConfig = mavenOpt.get(); } } - if (!exceptions.isEmpty()) { - return Result.err(exceptions); + if (smithyProjectJson != null) { + sources.addAll(smithyProjectJson.sources()); + imports.addAll(smithyProjectJson.imports()); + projectDependencies.addAll(smithyProjectJson.dependencies()); } - builder.merge(extensionsBuilder.build().asSmithyBuildConfig()); - SmithyBuildConfig config = builder.build(); - finalConfigBuilder.addSources(config.getSources()).addImports(config.getImports()); - config.getMaven().ifPresent(finalConfigBuilder::mavenConfig); - if (finalConfigBuilder.outputDirectory == null) { - config.getOutputDirectory().ifPresent(finalConfigBuilder::outputDirectory); + var resolver = new Resolver(root, loader.events, loader.smithyNodes, dependencyResolverFactory); + ProjectConfig resolved = resolver.resolve(sources, imports, mavenConfig, projectDependencies); + + return new Result(resolved, resolver.events()); + } + + private SmithyBuildConfig loadSmithyBuild() { + return loadFile( + buildFiles.getByType(BuildFileType.SMITHY_BUILD), + LoadBuildFile.LOAD_SMITHY_BUILD, + events::add, + smithyNodes::put + ); + } + + private SmithyProjectJson loadSmithyProject() { + return loadFile( + buildFiles.getByType(BuildFileType.SMITHY_PROJECT), + LoadBuildFile.LOAD_SMITHY_PROJECT, + events::add, + smithyNodes::put + ); + } + + private SmithyBuildExtensions.Builder loadExts() { + SmithyBuildExtensions.Builder extBuilder = null; + for (BuildFileType extType : EXTS) { + SmithyBuildExtensions ext = loadFile( + buildFiles.getByType(extType), + LoadBuildFile.LOAD_BUILD_EXT, + events::add, + smithyNodes::put + ); + if (ext != null) { + if (extBuilder == null) { + extBuilder = SmithyBuildExtensions.builder(); + } + extBuilder.merge(ext); + } } - finalConfigBuilder.buildFiles(buildFiles); - return Result.ok(finalConfigBuilder.build()); + return extBuilder; } - private static BuildFile addBuildFile(Map buildFiles, Path path, ManagedFiles managedFiles) { - String pathString = path.toString(); - String uri = LspAdapter.toUri(pathString); - Document managed = managedFiles.getManagedDocument(uri); - BuildFile buildFile; - if (managed != null) { - buildFile = new BuildFile(pathString, managed); + private static T loadFile( + BuildFile buildFile, + LoadBuildFile loadBuildFile, + Consumer eventConsumer, + BiConsumer nodeConsumer + ) { + if (buildFile == null) { + return null; + } + + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + nodeResult.getValidationEvents().forEach(eventConsumer); + Node smithyNode = nodeResult.getResult().orElse(null); + if (smithyNode != null) { + nodeConsumer.accept(buildFile.type(), smithyNode); + try { + return loadBuildFile.load(smithyNode); + } catch (Exception e) { + eventConsumer.accept(toEvent(e, buildFile)); + } + } + + return null; + } + + private SmithyBuildConfig mergeSmithyBuildConfig( + SmithyBuildConfig smithyBuildConfig, + SmithyBuildExtensions.Builder extBuilder + ) { + if (smithyBuildConfig == null && extBuilder == null) { + return null; + } else if (extBuilder == null) { + return smithyBuildConfig; + } else if (smithyBuildConfig == null) { + try { + return extBuilder.build().asSmithyBuildConfig(); + } catch (Exception e) { + // Add the event to any ext file + for (BuildFileType ext : EXTS) { + BuildFile buildFile = buildFiles.getByType(ext); + if (buildFile != null) { + events.add(toEvent(e, buildFile)); + break; + } + } + } } else { - Document document = Document.of(IoUtils.readUtf8File(path)); - buildFile = new BuildFile(pathString, document); + try { + var extConfig = extBuilder.build().asSmithyBuildConfig(); + return smithyBuildConfig.toBuilder().merge(extConfig).build(); + } catch (Exception e) { + // Add the event to either smithy-build.json, or an ext file + for (BuildFile buildFile : buildFiles) { + if (buildFile.type().supportsMavenConfiguration()) { + events.add(toEvent(e, buildFile)); + break; + } + } + } } - buildFiles.put(buildFile.path(), buildFile); - return buildFile; + + return null; } - private static SmithyBuildExtensions loadSmithyBuildExtensions(BuildFile buildFile) { - // NOTE: This is the legacy way we loaded build extensions. It used to throw a checked exception. - ObjectNode node = Node.parseJsonWithComments( - buildFile.document().copyText(), buildFile.path()).expectObjectNode(); - SmithyBuildExtensions config = NODE_MAPPER.deserialize(node, SmithyBuildExtensions.class); - config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.fromNode(node)); - return config; + private static ValidationEvent toEvent(Exception e, BuildFile fallbackBuildFile) { + // Most exceptions thrown will be from structural validation, i.e. is the Node in the expected format for the + // build file. These exceptions will be SourceExceptions most likely, which are easy to map to a source + // location. + SourceException asSourceException = null; + if (e instanceof SourceException sourceException) { + asSourceException = sourceException; + } else if (e.getCause() instanceof SourceException sourceException) { + asSourceException = sourceException; + } + + // If the source location is NONE, the filename won't map to any actual file so you won't see the error + if (asSourceException != null && !SourceLocation.NONE.equals(asSourceException.getSourceLocation())) { + return ValidationEvent.fromSourceException(asSourceException); + } + + // Worst case, just put the error at the top of the file. If this happens enough that it is a problem, we + // can revisit how this validation works, or manually map the specific cases. + return ValidationEvent.builder() + .id("SmithyBuildConfig") + .severity(Severity.ERROR) + .message(e.getMessage()) + .sourceLocation(new SourceLocation(fallbackBuildFile.path(), 1, 1)) + .build(); + } + + /** + * Strategy for deserializing a {@link Node} into a {@code T}, differently + * depending on {@link BuildFileType}. + * + * @param The deserialized type + */ + private interface LoadBuildFile { + LoadBuildFile LOAD_SMITHY_BUILD = SmithyBuildConfig::fromNode; + + LoadBuildFile LOAD_BUILD_EXT = (node) -> { + var config = NODE_MAPPER.deserialize(node, SmithyBuildExtensions.class); + config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.fromNode(node)); + return config; + }; + + LoadBuildFile LOAD_SMITHY_PROJECT = SmithyProjectJson::fromNode; + + T load(Node node); + } + + /** + * Handles resolving dependencies, and finding all model paths that will be + * loaded in the project. It also keeps track of any errors that occur in + * this process, and tries to map them back to a specific location in a + * build file so we can show a diagnostic. + * + * @param root The root of the project, used to resolve model paths + * @param events The list to add any events that occur to + * @param smithyNodes The loaded smithy nodes for each build file type, + * used to map errors to a specific location + * @param dependencyResolverFactory Provides the Maven dependency resolver + * implementation to use + */ + private record Resolver( + Path root, + List events, + Map smithyNodes, + Supplier dependencyResolverFactory + ) { + // Taken from smithy-cli ConfigurationUtils + private static final Supplier CENTRAL = () -> MavenRepository.builder() + .url("https://repo.maven.apache.org/maven2") + .build(); + private static final Supplier DEFAULT_RESOLVER_FACTORY = () -> + new MavenDependencyResolver(EnvironmentVariable.SMITHY_MAVEN_CACHE.get()); + + private ProjectConfig resolve( + List sources, + List imports, + MavenConfig mavenConfig, + List projectDependencies + ) { + Set resolvedMaven = resolveMaven(mavenConfig); + Set resolveProjectDependencies = resolveProjectDependencies(projectDependencies); + + List resolvedDependencies = new ArrayList<>(); + try { + for (var dep : resolvedMaven) { + resolvedDependencies.add(dep.toUri().toURL()); + } + for (var dep : resolveProjectDependencies) { + resolvedDependencies.add(dep.toUri().toURL()); + } + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + + Set uniqueModelPaths = collectAllSmithyFilePaths(sources, imports); + List modelPaths = new ArrayList<>(uniqueModelPaths); + + return new ProjectConfig( + sources, + imports, + projectDependencies, + mavenConfig, + modelPaths, + resolvedDependencies + ); + } + + private Set resolveMaven(MavenConfig maven) { + if (maven == null || (maven.getRepositories().isEmpty() && maven.getDependencies().isEmpty())) { + return Set.of(); + } + + List exceptions = new ArrayList<>(); + DependencyResolver resolver = dependencyResolverFactory.get(); + + Set repositories = getConfiguredMavenRepos(maven); + for (MavenRepository repo : repositories) { + try { + resolver.addRepository(repo); + } catch (DependencyResolverException e) { + exceptions.add(e); + } + } + + for (String dependency : maven.getDependencies()) { + try { + resolver.addDependency(dependency); + } catch (DependencyResolverException e) { + exceptions.add(e); + } + } + + List resolvedArtifacts; + try { + resolvedArtifacts = resolver.resolve(); + } catch (DependencyResolverException e) { + exceptions.add(e); + resolvedArtifacts = List.of(); + } + + handleDependencyResolverExceptions(exceptions); + + Set dependencyPaths = new HashSet<>(resolvedArtifacts.size()); + for (ResolvedArtifact resolvedArtifact : resolvedArtifacts) { + Path path = resolvedArtifact.getPath().toAbsolutePath(); + if (!Files.exists(path)) { + throw new RuntimeException(String.format( + "Dependency was resolved (%s), but it was not found on disk at %s", + resolvedArtifact, path)); + } + dependencyPaths.add(path); + } + + return dependencyPaths; + } + + // Taken from smithy-cli ConfigurationUtils::getConfiguredMavenRepos + private static Set getConfiguredMavenRepos(MavenConfig config) { + Set repositories = new LinkedHashSet<>(); + + String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get(); + if (envRepos != null) { + for (String repo : envRepos.split("\\|")) { + repositories.add(MavenRepository.builder().url(repo.trim()).build()); + } + } + + Set configuredRepos = config.getRepositories(); + + if (!configuredRepos.isEmpty()) { + repositories.addAll(configuredRepos); + } else if (envRepos == null) { + repositories.add(CENTRAL.get()); + } + return repositories; + } + + private void handleDependencyResolverExceptions(List exceptions) { + if (exceptions.isEmpty()) { + return; + } + + StringBuilder builder = new StringBuilder(); + for (DependencyResolverException exception : exceptions) { + builder.append(exception.getMessage()).append("\n"); + } + String message = builder.toString(); + + for (Node smithyNode : smithyNodes.values()) { + if (!(smithyNode instanceof ObjectNode objectNode)) { + continue; + } + + for (StringNode memberNameNode : objectNode.getMembers().keySet()) { + String memberName = memberNameNode.getValue(); + if ("maven".equals(memberName)) { + events.add(ValidationEvent.builder() + .id("DependencyResolver") + .severity(Severity.ERROR) + .message("Dependency resolution failed: " + message) + .sourceLocation(memberNameNode) + .build()); + break; + } + } + } + } + + private Set resolveProjectDependencies(List projectDependencies) { + Set notFoundDependencies = new HashSet<>(); + Set dependencies = new HashSet<>(); + + for (var dependency : projectDependencies) { + Path path = root.resolve(dependency.path()).normalize(); + if (!Files.exists(path)) { + notFoundDependencies.add(dependency.path()); + } else { + dependencies.add(path); + } + } + + handleNotFoundProjectDependencies(notFoundDependencies); + + return dependencies; + } + + private void handleNotFoundProjectDependencies(Set notFound) { + if (notFound.isEmpty()) { + return; + } + + if (!(smithyNodes.get(BuildFileType.SMITHY_PROJECT) instanceof ObjectNode objectNode)) { + return; + } + + if (objectNode.getMember("dependencies").orElse(null) instanceof ArrayNode arrayNode) { + for (Node elem : arrayNode) { + if (elem instanceof ObjectNode depNode + && depNode.getMember("path").orElse(null) instanceof StringNode depPathNode + && notFound.contains(depPathNode.getValue())) { + events.add(ValidationEvent.builder() + .id("FileNotFound") + .severity(Severity.ERROR) + .message("File not found") + .sourceLocation(depPathNode) + .build()); + } + } + } + } + + // sources and imports can contain directories or files, relative or absolute. + // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential + // here for inconsistent behavior. + private Set collectAllSmithyFilePaths(List sources, List imports) { + Set notFound = new HashSet<>(); + Set paths = new HashSet<>(); + + collectFilePaths(paths, sources, notFound); + collectFilePaths(paths, imports, notFound); + + handleNotFoundSourcesAndImports(notFound); + + return paths; + } + + private void collectFilePaths(Set accumulator, List paths, Set notFound) { + for (String file : paths) { + Path path = root.resolve(file).normalize(); + if (!Files.exists(path)) { + notFound.add(path.toString()); + } else { + collectDirectory(accumulator, root, path); + } + } + } + + private void handleNotFoundSourcesAndImports(Set notFound) { + for (Node smithyNode : smithyNodes.values()) { + if (!(smithyNode instanceof ObjectNode objectNode)) { + continue; + } + + if (objectNode.getMember("sources").orElse(null) instanceof ArrayNode sourcesNode) { + addNotFoundEvents(sourcesNode, notFound); + } + + if (objectNode.getMember("imports").orElse(null) instanceof ArrayNode importsNode) { + addNotFoundEvents(importsNode, notFound); + } + } + } + + private void addNotFoundEvents(ArrayNode searchNode, Set notFound) { + for (Node elem : searchNode) { + if (elem instanceof StringNode stringNode) { + String fullPath = root.resolve(stringNode.getValue()).normalize().toString(); + if (notFound.contains(fullPath)) { + events.add(ValidationEvent.builder() + .id("FileNotFound") + .severity(Severity.ERROR) + .message("File not found: " + fullPath) + .sourceLocation(stringNode) + .build()); + } + } + } + } + + // All of this copied from smithy-build SourcesPlugin, except I changed the `accumulator` to + // be a Collection instead of a list. + private static void collectDirectory(Collection accumulator, Path root, Path current) { + try { + if (Files.isDirectory(current)) { + try (Stream paths = Files.list(current)) { + paths.filter(p -> !p.equals(current)) + .filter(p -> Files.isDirectory(p) || Files.isRegularFile(p)) + .forEach(p -> collectDirectory(accumulator, root, p)); + } + } else if (Files.isRegularFile(current)) { + if (current.toString().endsWith(".jar")) { + String jarRoot = root.equals(current) + ? current.toString() + : (current + File.separator); + collectJar(accumulator, jarRoot, current); + } else { + collectFile(accumulator, current); + } + } + } catch (IOException ignored) { + // For now just ignore this - the assembler would have run into the same issues + } + } + + private static void collectJar(Collection accumulator, String jarRoot, Path jarPath) { + URL manifestUrl = ModelDiscovery.createSmithyJarManifestUrl(jarPath.toString()); + + String prefix = computeJarFilePrefix(jarRoot, jarPath); + for (URL model : ModelDiscovery.findModels(manifestUrl)) { + String name = ModelDiscovery.getSmithyModelPathFromJarUrl(model); + Path target = Paths.get(prefix + name); + collectFile(accumulator, target); + } + } + + private static String computeJarFilePrefix(String jarRoot, Path jarPath) { + Path jarFilenamePath = jarPath.getFileName(); + + if (jarFilenamePath == null) { + return jarRoot; + } + + String jarFilename = jarFilenamePath.toString(); + return jarRoot + jarFilename.substring(0, jarFilename.length() - ".jar".length()) + File.separator; + } + + private static void collectFile(Collection accumulator, Path target) { + if (target == null) { + return; + } + String filename = target.toString(); + if (filename.endsWith(".smithy") || filename.endsWith(".json")) { + accumulator.add(target); + } + } } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java deleted file mode 100644 index 0c5fd650..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java +++ /dev/null @@ -1,25 +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 software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.node.ObjectNode; - -/** - * An arbitrary project dependency, used to specify non-maven dependencies - * that exist locally. - * - * @param name The name of the dependency - * @param path The path of the dependency - */ -record ProjectDependency(String name, String path) { - static ProjectDependency fromNode(Node node) { - ObjectNode objectNode = node.expectObjectNode(); - String name = objectNode.expectStringMember("name").getValue(); - String path = objectNode.expectStringMember("path").getValue(); - return new ProjectDependency(name, path); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java deleted file mode 100644 index eca2ecd8..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java +++ /dev/null @@ -1,113 +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.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import software.amazon.smithy.build.SmithyBuild; -import software.amazon.smithy.build.model.MavenConfig; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.EnvironmentVariable; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; -import software.amazon.smithy.lsp.util.Result; - -/** - * Resolves all Maven dependencies and {@link ProjectDependency} for a project. - * - *

Resolving a {@link ProjectDependency} is as simple as getting its path - * relative to the project root, but is done here in order to be loaded the - * same way as Maven dependencies. - * TODO: There are some things in here taken from smithy-cli. Should figure out - * if we can expose them through smithy-cli instead of duplicating them here to - * avoid drift. - */ -final class ProjectDependencyResolver { - // Taken from smithy-cli ConfigurationUtils - private static final Supplier CENTRAL = () -> MavenRepository.builder() - .url("https://repo.maven.apache.org/maven2") - .build(); - - private ProjectDependencyResolver() { - } - - static Result, Exception> resolveDependencies(Path root, ProjectConfig config) { - return Result.ofFallible(() -> { - List deps = ProjectDependencyResolver.create(config).resolve() - .stream() - .map(ResolvedArtifact::getPath) - .collect(Collectors.toCollection(ArrayList::new)); - config.dependencies().forEach((projectDependency) -> { - // TODO: Not sure if this needs to check for existence - Path path = root.resolve(projectDependency.path()).normalize(); - deps.add(path); - }); - return deps; - }); - } - - // Taken (roughly) from smithy-cli ClasspathAction::resolveDependencies - private static DependencyResolver create(ProjectConfig config) { - // TODO: Seeing what happens if we just don't use the file cache. When we do, at least for testing, the - // server writes a classpath.json to build/smithy/ which is used by all tests, messing everything up. - DependencyResolver resolver = new MavenDependencyResolver(EnvironmentVariable.SMITHY_MAVEN_CACHE.get()); - - Set configuredRepositories = getConfiguredMavenRepos(config); - configuredRepositories.forEach(resolver::addRepository); - - // TODO: Support lock file ? - config.maven().ifPresent(maven -> maven.getDependencies().forEach(resolver::addDependency)); - - return resolver; - } - - // TODO: If this cache file is necessary for the server's use cases, we may - // want to keep an in memory version of it so we don't write stuff to - // people's build dirs. Right now, we just don't use it at all. - // Taken (roughly) from smithy-cli ClasspathAction::getCacheFile - private static File getCacheFile(ProjectConfig config) { - return getOutputDirectory(config).resolve("classpath.json").toFile(); - } - - // Taken from smithy-cli BuildOptions::resolveOutput - private static Path getOutputDirectory(ProjectConfig config) { - return config.outputDirectory() - .map(Paths::get) - .orElseGet(SmithyBuild::getDefaultOutputDirectory); - } - - // Taken from smithy-cli ConfigurationUtils::getConfiguredMavenRepos - private static Set getConfiguredMavenRepos(ProjectConfig config) { - Set repositories = new LinkedHashSet<>(); - - String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get(); - if (envRepos != null) { - for (String repo : envRepos.split("\\|")) { - repositories.add(MavenRepository.builder().url(repo.trim()).build()); - } - } - - Set configuredRepos = config.maven() - .map(MavenConfig::getRepositories) - .orElse(Collections.emptySet()); - - if (!configuredRepos.isEmpty()) { - repositories.addAll(configuredRepos); - } else if (envRepos == null) { - repositories.add(CENTRAL.get()); - } - return repositories; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java index 55f88ea5..0ddfe67f 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java @@ -21,4 +21,9 @@ public sealed interface ProjectFile permits SmithyFile, BuildFile { * @return The underlying document of the file */ Document document(); + + /** + * Reparse the underlying document. + */ + void reparse(); } 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 77f1d26e..b2a18f9f 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -5,42 +5,28 @@ package software.amazon.smithy.lsp.project; -import java.io.File; -import java.io.IOException; -import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Supplier; -import java.util.logging.Logger; -import java.util.stream.Stream; import software.amazon.smithy.lsp.ManagedFiles; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.ModelAssembler; -import software.amazon.smithy.model.loader.ModelDiscovery; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; import software.amazon.smithy.utils.TriConsumer; /** * Loads {@link Project}s. - * - * TODO: There's a lot of duplicated logic and redundant code here to refactor. */ public final class ProjectLoader { - private static final Logger LOGGER = Logger.getLogger(ProjectLoader.class.getName()); - private ProjectLoader() { } @@ -56,12 +42,6 @@ private ProjectLoader() { * @return The loaded project */ public static Project loadDetached(String uri, String text) { - LOGGER.info("Loading detachedProjects project at " + uri); - - String asPath = LspAdapter.toPath(uri); - Path path = Paths.get(asPath); - List allSmithyFilePaths = List.of(path); - Document document = Document.of(text); ManagedFiles managedFiles = (fileUri) -> { if (uri.equals(fileUri)) { @@ -70,26 +50,20 @@ public static Project loadDetached(String uri, String text) { return null; }; - List dependencies = List.of(); - - LoadModelResult result; - try { - result = doLoad(managedFiles, dependencies, allSmithyFilePaths); - } catch (IOException e) { - // Note: This can't happen because we aren't doing any fallible IO, - // as only the prelude will be read from disk - throw new RuntimeException(e); - } + Path path = Paths.get(LspAdapter.toPath(uri)); + ProjectConfig config = ProjectConfig.detachedConfig(path); + LoadModelResult result = doLoad(managedFiles, config); return new Project( path, - ProjectConfig.empty(), - dependencies, + config, + BuildFiles.of(List.of()), result.smithyFiles(), result.assemblerFactory(), Project.Type.DETACHED, result.modelResult(), - result.rebuildIndex() + result.rebuildIndex(), + List.of() ); } @@ -109,45 +83,26 @@ public static Project loadDetached(String uri, String text) { * @param managedFiles Files managed by the server * @return Result of loading the project */ - public static Result> load(Path root, ManagedFiles managedFiles) { - Result> configResult = ProjectConfigLoader.loadFromRoot(root, managedFiles); - if (configResult.isErr()) { - return Result.err(configResult.unwrapErr()); + public static Project load(Path root, ManagedFiles managedFiles) throws Exception { + var buildFiles = BuildFiles.load(root, managedFiles); + if (buildFiles.isEmpty()) { + return Project.empty(root); } - ProjectConfig config = configResult.unwrap(); - - if (config.buildFiles().isEmpty()) { - return Result.ok(Project.empty(root)); - } - - Result, Exception> resolveResult = ProjectDependencyResolver.resolveDependencies(root, config); - if (resolveResult.isErr()) { - return Result.err(Collections.singletonList(resolveResult.unwrapErr())); - } - - List dependencies = resolveResult.unwrap(); - // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential - // here for inconsistent behavior. - List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); + ProjectConfigLoader.Result configResult = ProjectConfigLoader.load(root, buildFiles); + LoadModelResult result = doLoad(managedFiles, configResult.config()); - LoadModelResult result; - try { - result = doLoad(managedFiles, dependencies, allSmithyFilePaths); - } catch (Exception e) { - return Result.err(Collections.singletonList(e)); - } - - return Result.ok(new Project( + return new Project( root, - config, - dependencies, + configResult.config(), + buildFiles, result.smithyFiles(), result.assemblerFactory(), Project.Type.NORMAL, result.modelResult(), - result.rebuildIndex() - )); + result.rebuildIndex(), + configResult.events() + ); } private record LoadModelResult( @@ -158,24 +113,15 @@ private record LoadModelResult( ) { } - private static LoadModelResult doLoad( - ManagedFiles managedFiles, - List dependencies, - List allSmithyFilePaths - ) throws IOException { + private static LoadModelResult doLoad(ManagedFiles managedFiles, ProjectConfig config) { // The model assembler factory is used to get assemblers that already have the correct // dependencies resolved for future loads - Supplier assemblerFactory = createModelAssemblerFactory(dependencies); + Supplier assemblerFactory = createModelAssemblerFactory(config.resolvedDependencies()); - Map smithyFiles = new HashMap<>(allSmithyFilePaths.size()); + Map smithyFiles = new HashMap<>(config.modelPaths().size()); - // TODO: Assembler can fail if a file is not found. We can be more intelligent about - // handling this case to allow partially loading the project, but we will need to - // collect and report the errors somehow. For now, using collectAllSmithyPaths skips - // any files that don't exist, so we're essentially side-stepping the issue by - // coincidence. ModelAssembler assembler = assemblerFactory.get(); - ValidatedResult modelResult = loadModel(managedFiles, allSmithyFilePaths, assembler, smithyFiles); + ValidatedResult modelResult = loadModel(managedFiles, config.modelPaths(), assembler, smithyFiles); Project.RebuildIndex rebuildIndex = Project.RebuildIndex.create(modelResult); addDependencySmithyFiles(managedFiles, rebuildIndex.filesToDefinedShapes().keySet(), smithyFiles); @@ -256,8 +202,7 @@ private static void findOrReadDocument( consumer.accept(filePath, text, document); } - private static Supplier createModelAssemblerFactory(List dependencies) - throws MalformedURLException { + private static Supplier createModelAssemblerFactory(List dependencies) { // We don't want the model to be broken when there are unknown traits, // because that will essentially disable language server features, so // we need to allow unknown traits for each factory. @@ -266,89 +211,10 @@ private static Supplier createModelAssemblerFactory(List d return () -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); } - URLClassLoader classLoader = createDependenciesClassLoader(dependencies); + URL[] urls = dependencies.toArray(new URL[0]); + URLClassLoader classLoader = new URLClassLoader(urls); return () -> Model.assembler(classLoader) .discoverModels(classLoader) .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); } - - private static URLClassLoader createDependenciesClassLoader(List dependencies) throws MalformedURLException { - // Taken (roughly) from smithy-ci IsolatedRunnable - URL[] urls = new URL[dependencies.size()]; - int i = 0; - for (Path dependency : dependencies) { - urls[i++] = dependency.toUri().toURL(); - } - return new URLClassLoader(urls); - } - - // sources and imports can contain directories or files, relative or absolute - private static List collectAllSmithyPaths(Path root, List sources, List imports) { - List paths = new ArrayList<>(); - for (String file : sources) { - Path path = root.resolve(file).normalize(); - collectDirectory(paths, root, path); - } - for (String file : imports) { - Path path = root.resolve(file).normalize(); - collectDirectory(paths, root, path); - } - return paths; - } - - // All of this copied from smithy-build SourcesPlugin - private static void collectDirectory(List accumulator, Path root, Path current) { - try { - if (Files.isDirectory(current)) { - try (Stream paths = Files.list(current)) { - paths.filter(p -> !p.equals(current)) - .filter(p -> Files.isDirectory(p) || Files.isRegularFile(p)) - .forEach(p -> collectDirectory(accumulator, root, p)); - } - } else if (Files.isRegularFile(current)) { - if (current.toString().endsWith(".jar")) { - String jarRoot = root.equals(current) - ? current.toString() - : (current + File.separator); - collectJar(accumulator, jarRoot, current); - } else { - collectFile(accumulator, current); - } - } - } catch (IOException ignored) { - // For now just ignore this - the assembler would have run into the same issues - } - } - - private static void collectJar(List accumulator, String jarRoot, Path jarPath) { - URL manifestUrl = ModelDiscovery.createSmithyJarManifestUrl(jarPath.toString()); - - String prefix = computeJarFilePrefix(jarRoot, jarPath); - for (URL model : ModelDiscovery.findModels(manifestUrl)) { - String name = ModelDiscovery.getSmithyModelPathFromJarUrl(model); - Path target = Paths.get(prefix + name); - collectFile(accumulator, target); - } - } - - private static String computeJarFilePrefix(String jarRoot, Path jarPath) { - Path jarFilenamePath = jarPath.getFileName(); - - if (jarFilenamePath == null) { - return jarRoot; - } - - String jarFilename = jarFilenamePath.toString(); - return jarRoot + jarFilename.substring(0, jarFilename.length() - ".jar".length()) + File.separator; - } - - private static void collectFile(List accumulator, Path target) { - if (target == null) { - return; - } - String filename = target.toString(); - if (filename.endsWith(".smithy") || filename.endsWith(".json")) { - accumulator.add(target); - } - } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java new file mode 100644 index 00000000..3474f7b8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java @@ -0,0 +1,64 @@ +/* + * 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.List; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; + +record SmithyProjectJson( + List sources, + List imports, + List dependencies, + String outputDirectory +) { + static SmithyProjectJson empty() { + return new SmithyProjectJson(List.of(), List.of(), List.of(), null); + } + + static SmithyProjectJson fromNode(Node node) { + ObjectNode objectNode = node.expectObjectNode(); + + List sources = objectNode.getArrayMember("sources") + .map(arrayNode -> arrayNode.getElementsAs(StringNode.class).stream() + .map(StringNode::getValue) + .toList()) + .orElse(List.of()); + + List imports = objectNode.getArrayMember("imports") + .map(arrayNode -> arrayNode.getElementsAs(StringNode.class).stream() + .map(StringNode::getValue) + .toList()) + .orElse(List.of()); + + List dependencies = objectNode.getArrayMember("dependencies") + .map(arrayNode -> arrayNode.getElements().stream() + .map(ProjectDependency::fromNode) + .toList()) + .orElse(List.of()); + + String outputDirectory = objectNode.getStringMemberOrDefault("outputDirectory", null); + + return new SmithyProjectJson(sources, imports, dependencies, outputDirectory); + } + + /** + * An arbitrary project dependency, used to specify non-maven projectDependencies + * that exist locally. + * + * @param name The name of the dependency + * @param path The path of the dependency + */ + record ProjectDependency(String name, String path) { + static ProjectDependency fromNode(Node node) { + ObjectNode objectNode = node.expectObjectNode(); + String name = objectNode.expectStringMember("name").getValue(); + String path = objectNode.expectStringMember("path").getValue(); + return new ProjectDependency(name, path); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java b/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java new file mode 100644 index 00000000..54600032 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java @@ -0,0 +1,109 @@ +/* + * 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.List; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.ModelSyntaxException; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.BooleanNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NullNode; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; + +/** + * Converts a {@link BuildFile#getParse()} into a Smithy {@link Node}, + * and turning any parse errors into {@link ValidationEvent}s. + * + *

Since the language server's parser is much more lenient than the regular + * {@link Node} parser, the converted {@link Node} will contain only + * the parts of the original text that make up valid {@link Node}s. + */ +final class ToSmithyNode { + private final String path; + private final Document document; + + private ToSmithyNode(String path, Document document) { + this.path = path; + this.document = document; + } + + static ValidatedResult toSmithyNode(BuildFile buildFile) { + var toSmithyNode = new ToSmithyNode(buildFile.path(), buildFile.document()); + + var smithyNode = toSmithyNode.toSmithyNode(buildFile.getParse().value()); + var events = toSmithyNode.getValidationEvents(); + + return new ValidatedResult<>(smithyNode, events); + } + + private List getValidationEvents() { + // The language server's parser isn't going to produce the same errors + // because of its leniency. Reparsing like this does incur a cost, but + // I think it's ok for now considering we get the added benefit of + // having the same errors Smithy itself would produce. + try { + Node.parseJsonWithComments(document.copyText(), path); + return List.of(); + } catch (ModelSyntaxException e) { + return List.of(ValidationEvent.fromSourceException(e)); + } + } + + private Node toSmithyNode(Syntax.Node syntaxNode) { + if (syntaxNode == null) { + return null; + } + + SourceLocation sourceLocation = nodeStartSourceLocation(syntaxNode); + return switch (syntaxNode) { + case Syntax.Node.Obj obj -> { + ObjectNode.Builder builder = ObjectNode.builder().sourceLocation(sourceLocation); + for (Syntax.Node.Kvp kvp : obj.kvps().kvps()) { + String keyValue = kvp.key().stringValue(); + StringNode key = new StringNode(keyValue, nodeStartSourceLocation(kvp.key())); + Node value = toSmithyNode(kvp.value()); + if (value != null) { + builder.withMember(key, value); + } + } + yield builder.build(); + } + case Syntax.Node.Arr arr -> { + ArrayNode.Builder builder = ArrayNode.builder().sourceLocation(sourceLocation); + for (Syntax.Node elem : arr.elements()) { + Node elemNode = toSmithyNode(elem); + if (elemNode != null) { + builder.withValue(elemNode); + } + } + yield builder.build(); + } + case Syntax.Ident ident -> { + String value = ident.stringValue(); + yield switch (value) { + case "true", "false" -> new BooleanNode(Boolean.parseBoolean(value), sourceLocation); + case "null" -> new NullNode(sourceLocation); + default -> null; + }; + } + case Syntax.Node.Str str -> new StringNode(str.stringValue(), sourceLocation); + case Syntax.Node.Num num -> new NumberNode(num.value(), sourceLocation); + default -> null; + }; + } + + private SourceLocation nodeStartSourceLocation(Syntax.Node node) { + return LspAdapter.toSourceLocation(path, document.rangeBetween(node.start(), node.end())); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java index 1bd9e540..8335786e 100644 --- a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java @@ -163,8 +163,34 @@ public static Position toPosition(SourceLocation sourceLocation) { */ public static Location toLocation(FromSourceLocation fromSourceLocation) { SourceLocation sourceLocation = fromSourceLocation.getSourceLocation(); - return new Location(toUri(sourceLocation.getFilename()), point( - new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); + return new Location(toUri(sourceLocation.getFilename()), point(toPosition(sourceLocation))); + } + + /** + * Get a {@link SourceLocation} with the given path, at the start of the given + * range. + * + * @param path The path of the source location + * @param range The range of the source location + * @return The source location + */ + public static SourceLocation toSourceLocation(String path, Range range) { + return toSourceLocation(path, range.getStart()); + } + + /** + * Get a {@link SourceLocation} with the given path, at the given position. + * + * @param path The path of the source location + * @param position The position of the source location + * @return The source location + */ + public static SourceLocation toSourceLocation(String path, Position position) { + return new SourceLocation( + path, + position.getLine() + 1, + position.getCharacter() + 1 + ); } /** diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index 9f2684be..ecc73da4 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -22,10 +22,20 @@ final class Parser extends SimpleParser { final List errors = new ArrayList<>(); final List statements = new ArrayList<>(); private final Document document; + private final boolean isJson; - Parser(Document document) { + private Parser(Document document, boolean isJson) { super(document.borrowText()); this.document = document; + this.isJson = isJson; + } + + static Parser forIdl(Document document) { + return new Parser(document, false); + } + + static Parser forJson(Document document) { + return new Parser(document, true); } Syntax.Node parseNode() { @@ -221,12 +231,25 @@ private Syntax.Node.Obj obj() { return obj; } + if (isJson && is(',')) { + Syntax.Node.Err err = new Syntax.Node.Err("expected key"); + setStart(err); + skip(); + setEnd(err); + ws(); + continue; + } + Syntax.Err kvpErr = kvp(obj.kvps, '}'); if (kvpErr != null) { addError(kvpErr); } ws(); + if (isJson && is(',')) { + skip(); + ws(); + } } Syntax.Node.Err err = new Syntax.Node.Err("missing }"); @@ -336,6 +359,10 @@ private Syntax.Node.Arr arr() { arr.elements.add(elem); } ws(); + if (is(',')) { + skip(); + } + ws(); } Syntax.Node.Err err = nodeErr("missing ]"); @@ -988,7 +1015,8 @@ private boolean isNl() { private boolean isWs() { return switch (peek()) { - case '\n', '\r', ' ', ',', '\t' -> true; + case '\n', '\r', ' ', '\t' -> true; + case ',' -> !isJson; default -> false; }; } diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java index e6b27667..18182399 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -105,7 +105,7 @@ public record IdlParseResult( * @return The IDL parse result. */ public static IdlParseResult parseIdl(Document document) { - Parser parser = new Parser(document); + Parser parser = Parser.forIdl(document); parser.parseIdl(); List statements = parser.statements; DocumentParser documentParser = DocumentParser.forStatements(document, statements); @@ -130,7 +130,7 @@ public record NodeParseResult(Node value, List errors) {} * @return The Node parse result. */ public static NodeParseResult parseNode(Document document) { - Parser parser = new Parser(document); + Parser parser = Parser.forJson(document); Node node = parser.parseNode(); return new NodeParseResult(node, parser.errors); } diff --git a/src/main/java/software/amazon/smithy/lsp/util/Result.java b/src/main/java/software/amazon/smithy/lsp/util/Result.java deleted file mode 100644 index d4c4112c..00000000 --- a/src/main/java/software/amazon/smithy/lsp/util/Result.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.util; - -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Type representing the result of an operation that could be successful - * or fail. - * - * @param Type of successful result - * @param Type of failed result - */ -public final class Result { - private final T value; - private final E error; - - private Result(T value, E error) { - this.value = value; - this.error = error; - } - - /** - * @param value The success value - * @param Type of successful result - * @param Type of failed result - * @return The successful result - */ - public static Result ok(T value) { - return new Result<>(value, null); - } - - /** - * @param error The failed value - * @param Type of successful result - * @param Type of failed result - * @return The failed result - */ - public static Result err(E error) { - return new Result<>(null, error); - } - - /** - * @param fallible A function that may fail - * @param Type of successful result - * @return A result containing the result of calling {@code fallible} - */ - public static Result ofFallible(Supplier fallible) { - try { - return Result.ok(fallible.get()); - } catch (Exception e) { - return Result.err(e); - } - } - - /** - * @param throwing A function that may throw - * @param Type of successful result - * @return A result containing the result of calling {@code throwing} - */ - public static Result ofThrowing(ThrowingSupplier throwing) { - try { - return Result.ok(throwing.get()); - } catch (Exception e) { - return Result.err(e); - } - } - - /** - * @return Whether this result is successful - */ - public boolean isOk() { - return this.value != null; - } - - /** - * @return Whether this result is failed - */ - public boolean isErr() { - return this.error != null; - } - - /** - * @return The successful value, or throw an exception if this Result is failed - */ - public T unwrap() { - if (get().isEmpty()) { - throw new RuntimeException("Called unwrap on an Err Result: " + getErr().get()); - } - return get().get(); - } - - /** - * @return The failed value, or throw an exception if this Result is successful - */ - public E unwrapErr() { - if (getErr().isEmpty()) { - throw new RuntimeException("Called unwrapErr on an Ok Result: " + get().get()); - } - return getErr().get(); - } - - /** - * @return Get the successful value if present - */ - public Optional get() { - return Optional.ofNullable(value); - } - - /** - * @return Get the failed value if present - */ - public Optional getErr() { - return Optional.ofNullable(error); - } - - /** - * Transforms the successful value of this Result, if present. - * - * @param mapper Function to apply to the successful value of this result - * @param The type to map to - * @return A new result with {@code mapper} applied, if this result is a - * successful one - */ - public Result map(Function mapper) { - if (isOk()) { - return Result.ok(mapper.apply(unwrap())); - } - return Result.err(unwrapErr()); - } - - /** - * Transforms the failed value of this Result, if present. - * - * @param mapper Function to apply to the failed value of this result - * @param The type to map to - * @return A new result with {@code mapper} applied, if this result is a - * failed one - */ - public Result mapErr(Function mapper) { - if (isErr()) { - return Result.err(mapper.apply(unwrapErr())); - } - return Result.ok(unwrap()); - } - - - /** - * A supplier that throws a checked exception. - * - * @param The output of the supplier - * @param The exception type that can be thrown - */ - @FunctionalInterface - public interface ThrowingSupplier { - T get() throws E; - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java index 9ac13497..d74b4901 100644 --- a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -15,7 +15,7 @@ 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.ProjectTest; import software.amazon.smithy.utils.ListUtils; public class FilePatternsTest { @@ -39,7 +39,7 @@ public void createsProjectPathMatchers() { .build()) .build(); - Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + Project project = ProjectTest.load(workspace.getRoot()); PathMatcher smithyMatcher = FilePatterns.getSmithyFilesPathMatcher(project); PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); @@ -65,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 ServerState()).unwrap(); + Project fooProject = ProjectTest.load(fooWorkspace.getRoot()); 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 073317df..c150dbc9 100644 --- a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java @@ -17,7 +17,7 @@ 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.ProjectTest; import software.amazon.smithy.utils.ListUtils; public class FileWatcherRegistrationsTest { @@ -42,7 +42,7 @@ public void createsCorrectRegistrations() { .build()) .build(); - Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + Project project = ProjectTest.load(workspace.getRoot()); List matchers = FileWatcherRegistrations.getSmithyFileWatcherRegistrations(List.of(project)) .stream() .map(Registration::getRegisterOptions) diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java index 6dd38cfd..cb60abc7 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -11,6 +11,7 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.Prelude; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; @@ -72,6 +73,15 @@ public void describeMismatchSafely(ValidationEvent event, Description descriptio }; } + public static Matcher eventWithSourceLocation(Matcher sourceLocationMatcher) { + return new CustomTypeSafeMatcher<>("has source location " + sourceLocationMatcher.toString()) { + @Override + protected boolean matchesSafely(ValidationEvent item) { + return sourceLocationMatcher.matches(item.getSourceLocation()); + } + }; + } + public static Matcher eventWithId(Matcher id) { return new CustomTypeSafeMatcher<>("has id matching " + id.toString()) { @Override diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java index 1982dec6..aaac2cd6 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -25,13 +25,12 @@ import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.LspMatchers; import software.amazon.smithy.lsp.RequestBuilders; -import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.project.IdlFile; 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.project.SmithyFile; public class CompletionHandlerTest { @@ -1086,7 +1085,7 @@ private static List getCompLabels(String text, Position... positions) { private static List getCompItems(String text, Position... positions) { TestWorkspace workspace = TestWorkspace.singleModel(text); - Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + Project project = ProjectTest.load(workspace.getRoot()); String uri = workspace.getUri("main.smithy"); IdlFile smithyFile = (IdlFile) (SmithyFile) project.getProjectFile(uri); diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java index 8f680cfa..cd16243f 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -22,12 +22,11 @@ import org.eclipse.lsp4j.Position; import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.RequestBuilders; -import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.project.IdlFile; 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.project.SmithyFile; import software.amazon.smithy.lsp.syntax.StatementView; import software.amazon.smithy.lsp.syntax.Syntax; @@ -366,7 +365,7 @@ private static GetLocationsResult getLocations(TextWithPositions textWithPositio private static GetLocationsResult getLocations(String text, Position... positions) { TestWorkspace workspace = TestWorkspace.singleModel(text); - Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + Project project = ProjectTest.load(workspace.getRoot()); String uri = workspace.getUri("main.smithy"); SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); diff --git a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java index 0242eab8..434c8612 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java @@ -12,11 +12,10 @@ import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.project.IdlFile; 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.project.SmithyFile; public class DocumentSymbolTest { @@ -49,7 +48,7 @@ public void documentSymbols() { private static List getDocumentSymbolNames(String text) { TestWorkspace workspace = TestWorkspace.singleModel(text); - Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + Project project = ProjectTest.load(workspace.getRoot()); String uri = workspace.getUri("main.smithy"); IdlFile idlFile = (IdlFile) (SmithyFile) project.getProjectFile(uri); diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java index 7a22bc66..8fec8f52 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -16,12 +16,11 @@ import org.eclipse.lsp4j.Position; import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.RequestBuilders; -import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.project.IdlFile; 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.project.SmithyFile; import software.amazon.smithy.model.validation.Severity; @@ -154,7 +153,7 @@ private static List getHovers(TextWithPositions text) { private static List getHovers(String text, Position... positions) { TestWorkspace workspace = TestWorkspace.singleModel(text); - Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + Project project = ProjectTest.load(workspace.getRoot()); String uri = workspace.getUri("main.smithy"); SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java deleted file mode 100644 index 6b4c0bb5..00000000 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java +++ /dev/null @@ -1,85 +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.containsString; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.hasSize; -import static software.amazon.smithy.lsp.project.ProjectTest.toPath; - -import java.nio.file.Path; -import java.util.List; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.build.model.MavenConfig; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.lsp.ServerState; -import software.amazon.smithy.lsp.util.Result; - -public class ProjectConfigLoaderTest { - @Test - public void loadsConfigWithEnvVariable() { - System.setProperty("FOO", "bar"); - Path root = toPath(getClass().getResource("env-config")); - Result> result = load(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.maven().isPresent(), is(true)); - MavenConfig mavenConfig = config.maven().get(); - assertThat(mavenConfig.getRepositories(), hasSize(1)); - MavenRepository repository = mavenConfig.getRepositories().stream().findFirst().get(); - assertThat(repository.getUrl(), containsString("example.com/maven/my_repo")); - assertThat(repository.getHttpCredentials().isPresent(), is(true)); - assertThat(repository.getHttpCredentials().get(), containsString("my_user:bar")); - } - - @Test - public void loadsLegacyConfig() { - Path root = toPath(getClass().getResource("legacy-config")); - Result> result = load(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.maven().isPresent(), is(true)); - MavenConfig mavenConfig = config.maven().get(); - assertThat(mavenConfig.getDependencies(), containsInAnyOrder("baz")); - assertThat(mavenConfig.getRepositories().stream() - .map(MavenRepository::getUrl) - .collect(Collectors.toList()), containsInAnyOrder("foo", "bar")); - } - - @Test - public void prefersNonLegacyConfig() { - Path root = toPath(getClass().getResource("legacy-config-with-conflicts")); - Result> result = load(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.maven().isPresent(), is(true)); - MavenConfig mavenConfig = config.maven().get(); - assertThat(mavenConfig.getDependencies(), containsInAnyOrder("dep1", "dep2")); - assertThat(mavenConfig.getRepositories().stream() - .map(MavenRepository::getUrl) - .collect(Collectors.toList()), containsInAnyOrder("url1", "url2")); - } - - @Test - public void mergesBuildExts() { - Path root = toPath(getClass().getResource("build-exts")); - Result> result = load(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.imports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); - } - - private static Result> load(Path root) { - return ProjectConfigLoader.loadFromRoot(root, new ServerState()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java new file mode 100644 index 00000000..2383bd5c --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java @@ -0,0 +1,277 @@ +/* + * 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.allOf; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithSourceLocation; +import static software.amazon.smithy.lsp.project.ProjectTest.toPath; +import static software.amazon.smithy.lsp.protocol.LspAdapter.toSourceLocation; + +import java.nio.file.Path; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.MavenRepository; +import software.amazon.smithy.cli.dependencies.DependencyResolver; +import software.amazon.smithy.cli.dependencies.DependencyResolverException; +import software.amazon.smithy.cli.dependencies.ResolvedArtifact; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; + +public class ProjectConfigTest { + @Test + public void loadsConfigWithEnvVariable() { + System.setProperty("FOO", "bar"); + Path root = toPath(getClass().getResource("env-config")); + ProjectConfig config = load(root).config(); + + assertThat(config.maven().getRepositories(), hasSize(1)); + MavenRepository repository = config.maven().getRepositories().stream().findFirst().get(); + assertThat(repository.getUrl(), containsString("example.com/maven/my_repo")); + assertThat(repository.getHttpCredentials().isPresent(), is(true)); + assertThat(repository.getHttpCredentials().get(), containsString("my_user:bar")); + } + + @Test + public void loadsLegacyConfig() { + Path root = toPath(getClass().getResource("legacy-config")); + ProjectConfig config = load(root).config(); + + assertThat(config.maven().getDependencies(), containsInAnyOrder("baz")); + assertThat(config.maven().getRepositories().stream() + .map(MavenRepository::getUrl) + .collect(Collectors.toList()), containsInAnyOrder("foo", "bar")); + } + + @Test + public void prefersNonLegacyConfig() { + Path root = toPath(getClass().getResource("legacy-config-with-conflicts")); + ProjectConfig config = load(root).config(); + + assertThat(config.maven().getDependencies(), containsInAnyOrder("dep1", "dep2")); + assertThat(config.maven().getRepositories().stream() + .map(MavenRepository::getUrl) + .collect(Collectors.toList()), containsInAnyOrder("url1", "url2")); + } + + @Test + public void mergesBuildExts() { + Path root = toPath(getClass().getResource("build-exts")); + ProjectConfig config = load(root).config(); + + assertThat(config.imports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); + assertThat(config.maven().getDependencies(), containsInAnyOrder("foo")); + } + + @Test + public void validatesSmithyBuildJson() { + var text = TextWithPositions.from(""" + { + "version" : %1, // Should be a string + "sources": ["foo"] + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + )); + assertThat(result.config().sources(), empty()); + } + + @Test + public void validatesSmithyProjectJson() { + var text = TextWithPositions.from(""" + { + "sources": ["foo"], + "dependencies": [ + %"foo" // Should be an object + ] + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_PROJECT, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_PROJECT); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + )); + assertThat(result.config().sources(), empty()); + } + + @Test + public void validatesMavenConfig() { + // "httpCredentials" is invalid, but we don't get the source location in the exception + var text = TextWithPositions.from(""" + %{ + "version" : "1", + "sources": ["foo"], + "maven": { + "repositories": [ + { + "url": "foo", + "httpCredentials": "bar" + } + ] + } + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder(allOf( + eventWithMessage(containsString("httpCredentials")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + ))); + assertThat(result.config().sources(), empty()); + } + + @Test + public void resolveValidatesFilesExist() { + var text = TextWithPositions.from(""" + { + "sources": [%"foo"], + "imports": [%"bar"], + "dependencies": [ + { + "name": "baz", + "path": %"baz" + } + ] + } + """); + var notFoundSourcePosition = text.positions()[0]; + var notFoundImportPosition = text.positions()[1]; + var notFoundDepPosition = text.positions()[2]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_PROJECT, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_PROJECT); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + allOf( + eventWithId(equalTo("FileNotFound")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), notFoundSourcePosition))) + ), + allOf( + eventWithId(equalTo("FileNotFound")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), notFoundImportPosition))) + ), + allOf( + eventWithId(equalTo("FileNotFound")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), notFoundDepPosition))) + ) + )); + assertThat(result.config().sources(), containsInAnyOrder(equalTo("foo"))); + assertThat(result.config().imports(), containsInAnyOrder(equalTo("bar"))); + assertThat(result.config().projectDependencies().getFirst().path(), equalTo("baz")); + } + + @Test + public void resolveValidatesMavenDependencies() { + var text = TextWithPositions.from(""" + { + "version": "1", + %"maven": { + "dependencies": ["foo"], + "repositories": [ + { + "url": "bar" + } + ] + } + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + Supplier resolverFactory = () -> new DependencyResolver() { + @Override + public void addRepository(MavenRepository mavenRepository) { + throw new DependencyResolverException("repo " + mavenRepository.getUrl()); + } + + @Override + public void addDependency(String s) { + throw new DependencyResolverException("dep " + s); + } + + @Override + public List resolve() { + throw new DependencyResolverException("call resolve"); + } + }; + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, text.text()); + var result = ProjectConfigLoader.load(root, buildFiles, resolverFactory); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + allOf( + eventWithId(equalTo("DependencyResolver")), + eventWithMessage(allOf( + containsString("repo bar"), + containsString("dep foo"), + containsString("call resolve") + )), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + ) + )); + } + + private record NoOpResolver() implements DependencyResolver { + @Override + public void addRepository(MavenRepository mavenRepository) { + } + + @Override + public void addDependency(String s) { + } + + @Override + public List resolve() { + return List.of(); + } + } + + private static BuildFiles createBuildFiles(Path root, BuildFileType type, String content) { + var buildFile = BuildFile.create(root.resolve(type.filename()).toString(), Document.of(content), type); + return BuildFiles.of(List.of(buildFile)); + } + + private static ProjectConfigLoader.Result load(Path root, BuildFiles buildFiles) { + return ProjectConfigLoader.load(root, buildFiles, NoOpResolver::new); + } + + private static ProjectConfigLoader.Result load(Path root) { + var buildFiles = BuildFiles.load(root, new ServerState()); + return ProjectConfigLoader.load(root, buildFiles, NoOpResolver::new); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java new file mode 100644 index 00000000..0e240790 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java @@ -0,0 +1,230 @@ +/* + * 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.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.validation.Severity; + +public class ProjectLoaderTest { + @Test + public void loadsFlatProject() { + Path root = ProjectTest.toPath(getClass().getResource("flat")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.config().imports(), empty()); + assertThat(project.config().resolvedDependencies(), empty()); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsProjectWithMavenDep() { + Path root = ProjectTest.toPath(getClass().getResource("maven-dep")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.config().imports(), empty()); + assertThat(project.config().resolvedDependencies(), hasSize(3)); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsProjectWithSubdir() { + Path root = ProjectTest.toPath(getClass().getResource("subdirs")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItems( + endsWith("model"), + endsWith("model2"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( + equalTo(root.resolve("model/main.smithy").toString()), + equalTo(root.resolve("model/subdir/sub.smithy").toString()), + equalTo(root.resolve("model2/subdir2/sub2.smithy").toString()), + equalTo(root.resolve("model2/subdir2/subsubdir/subsub.smithy").toString()))); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Baz")); + } + + @Test + public void loadsModelWithUnknownTrait() { + Path root = ProjectTest.toPath(getClass().getResource("unknown-trait")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.modelResult().isBroken(), is(false)); // unknown traits don't break it + assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(containsString("UnresolvedTrait")))); + assertThat(project.modelResult().getResult().isPresent(), is(true)); + assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsWhenModelHasInvalidSyntax() { + Path root = ProjectTest.toPath(getClass().getResource("invalid-syntax")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(equalTo("Model")))); + assertThat(project.modelResult(), hasValue(allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Foo$bar")))); + assertThat(project.getAllSmithyFilePaths(), hasItem(containsString("main.smithy"))); + } + + @Test + public void loadsProjectWithMultipleNamespaces() { + Path root = ProjectTest.toPath(getClass().getResource("multiple-namespaces")); + Project project = ProjectTest.load(root); + + assertThat(project.config().sources(), hasItem(endsWith("model"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + assertThat(project.getAllSmithyFilePaths(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); + + assertThat(project.modelResult(), hasValue(allOf( + hasShapeWithId("a#Hello"), + hasShapeWithId("a#HelloInput"), + hasShapeWithId("a#HelloOutput"), + hasShapeWithId("b#Hello"), + hasShapeWithId("b#HelloInput"), + hasShapeWithId("b#HelloOutput")))); + } + + @Test + public void loadsProjectWithExternalJars() { + Path root = ProjectTest.toPath(getClass().getResource("external-jars")); + Project project = ProjectTest.load(root); + + assertThat(project.config().sources(), containsInAnyOrder( + endsWith("test-traits.smithy"), + endsWith("test-validators.smithy"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( + containsString("test-traits.smithy"), + containsString("test-validators.smithy"), + // Note: Depending on the order of how jar dependencies are added to the model assembler, + // this may or may not be present. This is because we're relying on the shapes loaded in + // the model to determine all Smithy files, and this file re-defines a shape, so the shape + // definition is super-seeded. + // containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"), + containsString("alloy-core.jar!/META-INF/smithy/uuid.smithy"))); + + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); + + assertThat(project.modelResult().getResult().isPresent(), is(true)); + Model model = project.modelResult().getResult().get(); + assertThat(model, hasShapeWithId("smithy.test#test")); + assertThat(model, hasShapeWithId("ns.test#Weather")); + assertThat(model.expectShape(ShapeId.from("ns.test#Weather")).hasTrait("smithy.test#test"), is(true)); + } + + @Test + public void loadsProjectWithInvalidSmithyBuildJson() { + Path root = ProjectTest.toPath(getClass().getResource("broken/missing-version")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents(), hasItem(eventWithMessage(containsString("version")))); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithUnparseableSmithyBuildJson() { + Path root = ProjectTest.toPath(getClass().getResource("broken/parse-failure")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents().isEmpty(), is(false)); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithNonExistingSource() { + Path root = ProjectTest.toPath(getClass().getResource("broken/source-doesnt-exist")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents(), hasItem(eventWithId(equalTo("FileNotFound")))); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.getAllSmithyFiles().size(), equalTo(1)); // still have the prelude + } + + @Test + public void loadsProjectWithUnresolvableMavenDependency() { + Path root = ProjectTest.toPath(getClass().getResource("broken/unresolvable-maven-dependency")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents(), hasItem(eventWithId(equalTo("DependencyResolver")))); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithUnresolvableProjectDependency() { + Path root = ProjectTest.toPath(getClass().getResource("broken/unresolvable-project-dependency")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_PROJECT); + assertThat(project.configEvents(), hasItem(eventWithId(equalTo("FileNotFound")))); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithUnNormalizedDirs() throws Exception { + Path root = ProjectTest.toPath(getClass().getResource("unnormalized-dirs")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItems( + root.resolve("model"), + root.resolve("model2"))); + assertThat(project.imports(), hasItem(root.resolve("model3"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( + equalTo(root.resolve("model/test-traits.smithy").toString()), + equalTo(root.resolve("model/one.smithy").toString()), + equalTo(root.resolve("model2/two.smithy").toString()), + equalTo(root.resolve("model3/three.smithy").toString()), + containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"))); + assertThat(project.config().resolvedDependencies(), hasItem( + root.resolve("smithy-test-traits.jar").toUri().toURL())); + } + + private static void assertHasBuildFile(Project project, BuildFileType expectedType) { + String uri = LspAdapter.toUri(project.root().resolve(expectedType.filename()).toString()); + var file = project.getProjectFile(uri); + assertThat(file, instanceOf(BuildFile.class)); + assertThat(((BuildFile) file).type(), is(expectedType)); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java index 8d030bcb..2b8c2ccb 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -5,21 +5,10 @@ package software.amazon.smithy.lsp.project; -import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.endsWith; -import static org.hamcrest.Matchers.hasSize; -import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; -import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; -import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; -import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; import java.net.URISyntaxException; @@ -27,201 +16,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.List; import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; -import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.traits.LengthTrait; import software.amazon.smithy.model.traits.PatternTrait; import software.amazon.smithy.model.traits.TagsTrait; -import software.amazon.smithy.model.validation.Severity; public class ProjectTest { - @Test - public void loadsFlatProject() { - Path root = toPath(getClass().getResource("flat")); - Project project = load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); - assertThat(project.config().imports(), empty()); - assertThat(project.dependencies(), empty()); - assertThat(project.modelResult().isBroken(), is(false)); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - } - - @Test - public void loadsProjectWithMavenDep() { - Path root = toPath(getClass().getResource("maven-dep")); - Project project = load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); - assertThat(project.config().imports(), empty()); - assertThat(project.dependencies(), hasSize(3)); - assertThat(project.modelResult().isBroken(), is(false)); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - } - - @Test - public void loadsProjectWithSubdir() { - Path root = toPath(getClass().getResource("subdirs")); - Project project = load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.config().sources(), hasItems( - endsWith("model"), - endsWith("model2"))); - assertThat(project.getAllSmithyFilePaths(), hasItems( - equalTo(root.resolve("model/main.smithy").toString()), - equalTo(root.resolve("model/subdir/sub.smithy").toString()), - equalTo(root.resolve("model2/subdir2/sub2.smithy").toString()), - equalTo(root.resolve("model2/subdir2/subsubdir/subsub.smithy").toString()))); - assertThat(project.modelResult().isBroken(), is(false)); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Baz")); - } - - @Test - public void loadsModelWithUnknownTrait() { - Path root = toPath(getClass().getResource("unknown-trait")); - Project project = load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); - assertThat(project.modelResult().isBroken(), is(false)); // unknown traits don't break it - assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(containsString("UnresolvedTrait")))); - assertThat(project.modelResult().getResult().isPresent(), is(true)); - assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); - } - - @Test - public void loadsWhenModelHasInvalidSyntax() { - Path root = toPath(getClass().getResource("invalid-syntax")); - Project project = load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); - assertThat(project.modelResult().isBroken(), is(true)); - assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(equalTo("Model")))); - assertThat(project.modelResult(), hasValue(allOf( - hasShapeWithId("com.foo#Foo"), - hasShapeWithId("com.foo#Foo$bar")))); - assertThat(project.getAllSmithyFilePaths(), hasItem(containsString("main.smithy"))); - } - - @Test - public void loadsProjectWithMultipleNamespaces() { - Path root = toPath(getClass().getResource("multiple-namespaces")); - Project project = load(root).unwrap(); - - assertThat(project.config().sources(), hasItem(endsWith("model"))); - assertThat(project.modelResult().getValidationEvents(), empty()); - assertThat(project.getAllSmithyFilePaths(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); - - assertThat(project.modelResult(), hasValue(allOf( - hasShapeWithId("a#Hello"), - hasShapeWithId("a#HelloInput"), - hasShapeWithId("a#HelloOutput"), - hasShapeWithId("b#Hello"), - hasShapeWithId("b#HelloInput"), - hasShapeWithId("b#HelloOutput")))); - } - - @Test - public void loadsProjectWithExternalJars() { - Path root = toPath(getClass().getResource("external-jars")); - Result> result = load(root); - - assertThat(result.isOk(), is(true)); - Project project = result.unwrap(); - assertThat(project.config().sources(), containsInAnyOrder( - endsWith("test-traits.smithy"), - endsWith("test-validators.smithy"))); - assertThat(project.getAllSmithyFilePaths(), hasItems( - containsString("test-traits.smithy"), - containsString("test-validators.smithy"), - containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"), - containsString("alloy-core.jar!/META-INF/smithy/uuid.smithy"))); - - assertThat(project.modelResult().isBroken(), is(true)); - assertThat(project.modelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); - - assertThat(project.modelResult().getResult().isPresent(), is(true)); - Model model = project.modelResult().getResult().get(); - assertThat(model, hasShapeWithId("smithy.test#test")); - assertThat(model, hasShapeWithId("ns.test#Weather")); - assertThat(model.expectShape(ShapeId.from("ns.test#Weather")).hasTrait("smithy.test#test"), is(true)); - } - - @Test - public void failsLoadingInvalidSmithyBuildJson() { - Path root = toPath(getClass().getResource("broken/missing-version")); - Result> result = load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void failsLoadingUnparseableSmithyBuildJson() { - Path root = toPath(getClass().getResource("broken/parse-failure")); - Result> result = load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void doesntFailLoadingProjectWithNonExistingSource() { - Path root = toPath(getClass().getResource("broken/source-doesnt-exist")); - Result> result = load(root); - - assertThat(result.isErr(), is(false)); - assertThat(result.unwrap().getAllSmithyFiles().size(), equalTo(1)); // still have the prelude - } - - - @Test - public void failsLoadingUnresolvableMavenDependency() { - Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); - Result> result = load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void failsLoadingUnresolvableProjectDependency() { - Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); - Result> result = load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void loadsProjectWithUnNormalizedDirs() { - Path root = toPath(getClass().getResource("unnormalized-dirs")); - Project project = load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItems( - root.resolve("model"), - root.resolve("model2"))); - assertThat(project.imports(), hasItem(root.resolve("model3"))); - assertThat(project.getAllSmithyFilePaths(), hasItems( - equalTo(root.resolve("model/test-traits.smithy").toString()), - equalTo(root.resolve("model/one.smithy").toString()), - equalTo(root.resolve("model2/two.smithy").toString()), - equalTo(root.resolve("model3/three.smithy").toString()), - containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"))); - assertThat(project.dependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); - } - @Test public void changeFileApplyingSimpleTrait() { String m1 = """ @@ -236,7 +42,7 @@ public void changeFileApplyingSimpleTrait() { string Bar """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("length"), is(true)); @@ -267,7 +73,7 @@ public void changeFileApplyingListTrait() { string Bar """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); @@ -304,7 +110,7 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { apply Baz @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); Shape baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); @@ -346,7 +152,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { apply Bar @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); @@ -386,7 +192,7 @@ public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { apply Bar @tags(["bar"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); @@ -417,7 +223,7 @@ public void changingFileWithDependencies() { apply Foo @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("length"), is(true)); @@ -448,7 +254,7 @@ public void changingFileWithArrayDependencies() { apply Foo @tags(["foo"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); @@ -480,7 +286,7 @@ public void changingFileWithMixedArrayDependencies() { apply Foo @tags(["foo"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); @@ -516,7 +322,7 @@ public void changingFileWithArrayDependenciesWithDependencies() { apply Bar @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); @@ -557,7 +363,7 @@ public void removingSimpleApply() { apply Bar @pattern("a") """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("pattern"), is(true)); @@ -595,7 +401,7 @@ public void removingArrayApply() { apply Bar @tags(["bar"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); @@ -615,15 +421,19 @@ public void removingArrayApply() { @Test public void loadsEmptyProjectWhenThereAreNoConfigFiles() throws Exception { Path root = Files.createTempDirectory("foo"); - Project project = load(root).unwrap(); + Project project = load(root); assertThat(project.type(), equalTo(Project.Type.EMPTY)); } - private static Result> load(Path root) { - return ProjectLoader.load(root, new ServerState()); + public static Project load(Path root) { + try { + return ProjectLoader.load(root, new ServerState()); + } catch (Exception e) { + throw new RuntimeException(e); + } } - + public static Path toPath(URL url) { try { return Paths.get(url.toURI()); diff --git a/src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java b/src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java new file mode 100644 index 00000000..e9bdf20e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java @@ -0,0 +1,105 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithSourceLocation; +import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; + +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeType; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.validation.ValidatedResult; + +public class ToSmithyNodeTest { + @ParameterizedTest + @MethodSource("differentNodeTypesProvider") + public void convertsDifferentNodeTypes(String text, NodeType expectedNodeType) { + BuildFile buildFile = BuildFile.create("foo", Document.of(text), BuildFileType.SMITHY_BUILD); + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + + assertThat(nodeResult.getResult().map(Node::getType), anOptionalOf(equalTo(expectedNodeType))); + } + + private static Stream differentNodeTypesProvider() { + return Stream.of( + Arguments.of("null", NodeType.NULL), + Arguments.of("true", NodeType.BOOLEAN), + Arguments.of("false", NodeType.BOOLEAN), + Arguments.of("0", NodeType.NUMBER), + Arguments.of("\"foo\"", NodeType.STRING), + Arguments.of("[]", NodeType.ARRAY), + Arguments.of("{}", NodeType.OBJECT) + ); + } + + @Test + public void skipsMissingElements() { + var text = """ + { + "version": , + "imports": [ + , + "foo" + ], + "projections": { + , + "bar": {} + } + } + """; + BuildFile buildFile = BuildFile.create("foo", Document.of(text), BuildFileType.SMITHY_BUILD); + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + + ObjectNode node = nodeResult.getResult().get().expectObjectNode(); + assertThat(node.getStringMap().keySet(), containsInAnyOrder("imports", "projections")); + + List imports = node.expectArrayMember("imports") + .getElementsAs(elem -> elem.expectStringNode().getValue()); + assertThat(imports, containsInAnyOrder(equalTo("foo"))); + + Set projections = node.expectObjectMember("projections") + .getStringMap() + .keySet(); + assertThat(projections, containsInAnyOrder("bar")); + } + + @Test + public void emitsValidationEventsForParseErrors() { + var twp = TextWithPositions.from(""" + { + "version": %, + "imports": [] + } + """); + Position eventPosition = twp.positions()[0]; + BuildFile buildFile = BuildFile.create("foo", Document.of(twp.text()), BuildFileType.SMITHY_BUILD); + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + + assertThat(nodeResult.getValidationEvents(), containsInAnyOrder(allOf( + eventWithId(equalTo("Model")), + eventWithMessage(containsString("Error parsing JSON")), + eventWithSourceLocation(equalTo(LspAdapter.toSourceLocation("foo", eventPosition))) + ))); + } +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json new file mode 100644 index 00000000..166f52e0 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "mavenDependencies": ["foo"] +} From fcaa7d5ba0f6aac4b02e9ec30a91ce469ac6baf3 Mon Sep 17 00:00:00 2001 From: Yefri Gaitan <34199474+yefrig@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:24:04 -0500 Subject: [PATCH 14/43] Update label details to include fully qualified name in description (#195) This change better aligns with LSP spec and fixes https://github.com/smithy-lang/smithy-language-server/issues/194 --- .../software/amazon/smithy/lsp/language/ShapeCompleter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java index 7c5cc339..1544cf17 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java @@ -130,7 +130,7 @@ private CompletionItem shapeCompletion(String shapeLabel, Shape shape) { completionItem.setDetail(shape.getType().toString()); var labelDetails = new CompletionItemLabelDetails(); - labelDetails.setDetail(shape.getId().getNamespace()); + labelDetails.setDescription(shape.getId().toString()); completionItem.setLabelDetails(labelDetails); TextEdit edit = new TextEdit(insertRange, shapeLabel); From 6c4eef4fd7527bdd7a6f1a5bc7664fc622ad231d Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:43:35 -0500 Subject: [PATCH 15/43] Add completions for for build files (#193) The server now can provide completions for smithy-build.json and .smithy-project.json. The implementation works roughly the same as other node-like completions, using a new builtins model that specifies the structure of the build files. I also had to override the completion item mapper to make sure it wrapped object keys in strings, which is necessary in json. --- .../smithy/lsp/SmithyLanguageServer.java | 21 +- .../lsp/language/BuildCompletionHandler.java | 59 ++++ .../amazon/smithy/lsp/language/Builtins.java | 14 + .../lsp/language/CompletionHandler.java | 4 +- .../smithy/lsp/language/SimpleCompleter.java | 50 ++- .../amazon/smithy/lsp/language/build.smithy | 90 +++++ .../amazon/smithy/lsp/LspMatchers.java | 10 + .../language/BuildCompletionHandlerTest.java | 308 ++++++++++++++++++ 8 files changed, 532 insertions(+), 24 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/build.smithy create mode 100644 src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 78587faa..0f05a334 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -16,6 +16,8 @@ package software.amazon.smithy.lsp; import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.CompletableFuture.supplyAsync; +import static org.eclipse.lsp4j.jsonrpc.CompletableFutures.computeAsync; import java.io.IOException; import java.util.ArrayList; @@ -79,7 +81,6 @@ import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; import org.eclipse.lsp4j.WorkspaceServerCapabilities; -import org.eclipse.lsp4j.jsonrpc.CompletableFutures; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; @@ -93,6 +94,7 @@ import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.ext.ServerStatus; import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; +import software.amazon.smithy.lsp.language.BuildCompletionHandler; import software.amazon.smithy.lsp.language.CompletionHandler; import software.amazon.smithy.lsp.language.DefinitionHandler; import software.amazon.smithy.lsp.language.DocumentSymbolHandler; @@ -537,13 +539,18 @@ public CompletableFuture, CompletionList>> completio return completedFuture(Either.forLeft(Collections.emptyList())); } - if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { - return completedFuture(Either.forLeft(List.of())); - } - Project project = projectAndFile.project(); - var handler = new CompletionHandler(project, smithyFile); - return CompletableFutures.computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); + return switch (projectAndFile.file()) { + case IdlFile idlFile -> { + var handler = new CompletionHandler(project, idlFile); + yield computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); + } + case BuildFile buildFile -> { + var handler = new BuildCompletionHandler(project, buildFile); + yield supplyAsync(() -> Either.forLeft(handler.handle(params))); + } + default -> completedFuture(Either.forLeft(List.of())); + }; } @Override diff --git a/src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java new file mode 100644 index 00000000..1506e98c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.model.shapes.Shape; + +/** + * Handles completions requests for {@link BuildFile}s. + */ +public final class BuildCompletionHandler { + private final Project project; + private final BuildFile buildFile; + + public BuildCompletionHandler(Project project, BuildFile buildFile) { + this.project = project; + this.buildFile = buildFile; + } + + /** + * @param params The request params + * @return A list of possible completions + */ + public List handle(CompletionParams params) { + Position position = CompletionHandler.getTokenPosition(params); + DocumentId id = buildFile.document().copyDocumentId(position); + Range insertRange = CompletionHandler.getInsertRange(id, position); + + Shape buildFileShape = Builtins.getBuildFileShape(buildFile.type()); + + if (buildFileShape == null) { + return List.of(); + } + + NodeCursor cursor = NodeCursor.create( + buildFile.getParse().value(), + buildFile.document().indexOfPosition(position) + ); + NodeSearch.Result searchResult = NodeSearch.search(cursor, Builtins.MODEL, buildFileShape); + var candidates = CompletionCandidates.fromSearchResult(searchResult); + + var context = CompleterContext.create(id, insertRange, project) + .withExclude(searchResult.getOtherPresentKeys()); + var mapper = new SimpleCompleter.BuildFileMapper(context); + + return new SimpleCompleter(context, mapper).getCompletionItems(candidates); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java index cad276e3..924d83dc 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java +++ b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java @@ -8,6 +8,7 @@ import java.util.Arrays; import java.util.Map; import java.util.stream.Collectors; +import software.amazon.smithy.lsp.project.BuildFileType; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; @@ -35,6 +36,7 @@ final class Builtins { .addImport(Builtins.class.getResource("control.smithy")) .addImport(Builtins.class.getResource("metadata.smithy")) .addImport(Builtins.class.getResource("members.smithy")) + .addImport(Builtins.class.getResource("build.smithy")) .assemble() .unwrap(); @@ -51,6 +53,10 @@ final class Builtins { static final Shape SHAPE_MEMBER_TARGETS = MODEL.expectShape(id("ShapeMemberTargets")); + static final Shape SMITHY_BUILD_JSON = MODEL.expectShape(id("SmithyBuildJson")); + + static final Shape SMITHY_PROJECT_JSON = MODEL.expectShape(id("SmithyProjectJson")); + static final Map VALIDATOR_CONFIG_MAPPING = VALIDATORS.members().stream() .collect(Collectors.toMap( MemberShape::getMemberName, @@ -104,6 +110,14 @@ static Shape getMemberTargetForShapeType(String shapeType, String memberName) { .orElse(null); } + static Shape getBuildFileShape(BuildFileType type) { + return switch (type) { + case SMITHY_BUILD -> SMITHY_BUILD_JSON; + case SMITHY_PROJECT -> SMITHY_PROJECT_JSON; + default -> null; + }; + } + private static ShapeId id(String name) { return ShapeId.fromParts(NAMESPACE, name); } diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java index 48fc881e..9e26e402 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -96,7 +96,7 @@ public List handle(CompletionParams params, CancelChecker cc) { }; } - private static Position getTokenPosition(CompletionParams params) { + static Position getTokenPosition(CompletionParams params) { Position position = params.getPosition(); CompletionContext context = params.getContext(); if (context != null @@ -107,7 +107,7 @@ private static Position getTokenPosition(CompletionParams params) { return position; } - private static Range getInsertRange(DocumentId id, Position position) { + static Range getInsertRange(DocumentId id, Position position) { if (id == null || id.idSlice().isEmpty()) { // When we receive the completion request, we're always on the // character either after what has just been typed, or we're in diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java index 75c9c31b..dc19b712 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java @@ -22,10 +22,16 @@ * Maps simple {@link CompletionCandidates} to {@link CompletionItem}s. * * @param context The context for creating completions. + * @param mapper The mapper used to map candidates to completion items. + * Defaults to {@link Mapper} * * @see ShapeCompleter for what maps {@link CompletionCandidates.Shapes}. */ -record SimpleCompleter(CompleterContext context) { +record SimpleCompleter(CompleterContext context, Mapper mapper) { + SimpleCompleter(CompleterContext context) { + this(context, new Mapper(context)); + } + List getCompletionItems(CompletionCandidates candidates) { Matcher matcher; if (context.exclude().isEmpty()) { @@ -34,12 +40,10 @@ List getCompletionItems(CompletionCandidates candidates) { matcher = new ExcludingMatcher(context.matchToken(), context.exclude()); } - Mapper mapper = new Mapper(context().insertRange(), context().literalKind()); - - return getCompletionItems(candidates, matcher, mapper); + return getCompletionItems(candidates, matcher); } - private List getCompletionItems(CompletionCandidates candidates, Matcher matcher, Mapper mapper) { + private List getCompletionItems(CompletionCandidates candidates, Matcher matcher) { return switch (candidates) { case CompletionCandidates.Constant(var value) when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value)); @@ -64,11 +68,11 @@ private List getCompletionItems(CompletionCandidates candidates, .map(mapper::elided) .toList(); - case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher, mapper); + case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher); case CompletionCandidates.And(var one, var two) -> { - List oneItems = getCompletionItems(one); - List twoItems = getCompletionItems(two); + List oneItems = getCompletionItems(one, matcher); + List twoItems = getCompletionItems(two, matcher); List completionItems = new ArrayList<>(oneItems.size() + twoItems.size()); completionItems.addAll(oneItems); completionItems.addAll(twoItems); @@ -157,12 +161,16 @@ public boolean test(String s) { /** * Maps different kinds of completion candidates to {@link CompletionItem}s. - * - * @param insertRange The range the completion text will occupy. - * @param literalKind The completion item kind that will be shown in the - * client for {@link CompletionCandidates.Literals}. */ - private record Mapper(Range insertRange, CompletionItemKind literalKind) { + static class Mapper { + private final Range insertRange; + private final CompletionItemKind literalKind; + + Mapper(CompleterContext context) { + this.insertRange = context.insertRange(); + this.literalKind = context.literalKind(); + } + CompletionItem constant(String value) { return textEditCompletion(value, CompletionItemKind.Constant); } @@ -184,11 +192,11 @@ CompletionItem elided(String memberName) { return textEditCompletion("$" + memberName, CompletionItemKind.Field); } - private CompletionItem textEditCompletion(String label, CompletionItemKind kind) { + protected CompletionItem textEditCompletion(String label, CompletionItemKind kind) { return textEditCompletion(label, kind, label); } - private CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) { + protected CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) { CompletionItem item = new CompletionItem(label); item.setKind(kind); TextEdit textEdit = new TextEdit(insertRange, insertText); @@ -196,4 +204,16 @@ private CompletionItem textEditCompletion(String label, CompletionItemKind kind, return item; } } + + static final class BuildFileMapper extends Mapper { + BuildFileMapper(CompleterContext context) { + super(context); + } + + @Override + CompletionItem member(Map.Entry entry) { + String value = "\"" + entry.getKey() + "\": " + entry.getValue().value(); + return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value); + } + } } diff --git a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy new file mode 100644 index 00000000..75f4b85c --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy @@ -0,0 +1,90 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure SmithyProjectJson { + sources: Strings + imports: Strings + outputDirectory: String + dependencies: ProjectDependencies +} + +list ProjectDependencies { + member: ProjectDependency +} + +structure ProjectDependency { + name: String + + @required + path: String +} + +structure SmithyBuildJson { + @required + version: SmithyBuildVersion + + outputDirectory: String + sources: Strings + imports: Strings + projections: Projections + plugins: Plugins + ignoreMissingPlugins: Boolean + maven: Maven +} + +@default("1") +string SmithyBuildVersion + +map Projections { + key: String + value: Projection +} + +structure Projection { + abstract: Boolean + imports: Strings + transforms: Transforms + plugins: Plugins +} + +map Plugins { + key: String + value: Document +} + +list Transforms { + member: Transform +} + +structure Transform { + @required + name: String + + args: TransformArgs +} + +structure TransformArgs { +} + +structure Maven { + dependencies: Strings + repositories: MavenRepositories +} + +list MavenRepositories { + member: MavenRepository +} + +structure MavenRepository { + @required + url: String + + httpCredentials: String + proxyHost: String + proxyCredentials: String +} + +list Strings { + member: String +} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index dbe118d1..e9452086 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -36,6 +36,16 @@ public void describeMismatchSafely(CompletionItem item, Description description) }; } + public static Matcher hasLabelAndEditText(String label, String editText) { + return new CustomTypeSafeMatcher<>("label " + label + " editText " + editText) { + @Override + protected boolean matchesSafely(CompletionItem item) { + return label.equals(item.getLabel()) + && editText.trim().equals(item.getTextEdit().getLeft().getNewText().trim()); + } + }; + } + public static Matcher makesEditedDocument(Document document, String expected) { return new CustomTypeSafeMatcher<>("makes an edited document " + expected) { @Override diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java new file mode 100644 index 00000000..499e7129 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java @@ -0,0 +1,308 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static software.amazon.smithy.lsp.LspMatchers.hasLabelAndEditText; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.BuildFileType; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class BuildCompletionHandlerTest { + @Test + public void completesSmithyBuildJsonTopLevel() { + var text = TextWithPositions.from(""" + { + % + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1" + """), + hasLabelAndEditText("outputDirectory", """ + "outputDirectory": "" + """), + hasLabelAndEditText("sources", """ + "sources": [] + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("projections", """ + "projections": {} + """), + hasLabelAndEditText("plugins", """ + "plugins": {} + """), + hasLabelAndEditText("ignoreMissingPlugins", """ + "ignoreMissingPlugins": + """), + hasLabelAndEditText("maven", """ + "maven": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonProjectionMembers() { + var text = TextWithPositions.from(""" + { + "projections": { + "foo": { + % + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("abstract", """ + "abstract": + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("transforms", """ + "transforms": [] + """), + hasLabelAndEditText("plugins", """ + "plugins": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonTransformMembers() { + var text = TextWithPositions.from(""" + { + "projections": { + "foo": { + "transforms": [ + { + % + } + ] + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("name", """ + "name": "" + """), + hasLabelAndEditText("args", """ + "args": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonMavenMembers() { + var text = TextWithPositions.from(""" + { + "maven": { + % + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("dependencies", """ + "dependencies": [] + """), + hasLabelAndEditText("repositories", """ + "repositories": [] + """) + )); + } + + @Test + public void completesSmithyBuildJsonMavenRepoMembers() { + var text = TextWithPositions.from(""" + { + "maven": { + "repositories": [ + { + % + } + ] + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("url", """ + "url": "" + """), + hasLabelAndEditText("httpCredentials", """ + "httpCredentials": "" + """), + hasLabelAndEditText("proxyHost", """ + "proxyHost": "" + """), + hasLabelAndEditText("proxyCredentials", """ + "proxyCredentials": "" + """) + )); + } + + @Test + public void completesSmithyProjectJsonTopLevel() { + var text = TextWithPositions.from(""" + { + % + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_PROJECT); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("sources", """ + "sources": [] + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("outputDirectory", """ + "outputDirectory": "" + """), + hasLabelAndEditText("dependencies", """ + "dependencies": [] + """) + )); + } + + @Test + public void completesSmithyProjectJsonDependencyMembers() { + var text = TextWithPositions.from(""" + { + "dependencies": [ + { + % + } + ] + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_PROJECT); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("name", """ + "name": "" + """), + hasLabelAndEditText("path", """ + "path": "" + """) + )); + } + + @Test + public void matchesStringKeys() { + var text = TextWithPositions.from(""" + { + "v%" + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1" + """) + )); + } + + @Test + public void matchesNonStringKeys() { + var text = TextWithPositions.from(""" + { + v% + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1" + """) + )); + } + + @Test + public void completesKeyValues() { + var text = TextWithPositions.from(""" + { + "version": %, + "projections": { + "a": { + "abstract": % + }, + "b": { + "imports": % + }, + "c": { + "plugins": % + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("\"1\"", """ + "1" + """), + hasLabelAndEditText("false", "false"), + hasLabelAndEditText("true", "true"), + hasLabelAndEditText("[]", "[]"), + hasLabelAndEditText("{}", "{}") + )); + } + + private static List getCompItems(TextWithPositions twp, BuildFileType type) { + try { + Path root = Files.createTempDirectory("test"); + Path path = root.resolve(type.filename()); + Files.writeString(path, twp.text()); + Project project = ProjectTest.load(root); + String uri = LspAdapter.toUri(path.toString()); + BuildFile buildFile = (BuildFile) project.getProjectFile(uri); + List completionItems = new ArrayList<>(); + BuildCompletionHandler handler = new BuildCompletionHandler(project, buildFile); + for (Position position : twp.positions()) { + CompletionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildCompletion(); + completionItems.addAll(handler.handle(params)); + } + return completionItems; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} From a39e65b6b1242acbac10f31a2c7ee56625b2ab74 Mon Sep 17 00:00:00 2001 From: Joe Wu Date: Thu, 13 Feb 2025 14:12:45 -0800 Subject: [PATCH 16/43] Added FoldingRange Feature to LSP (#190) Added folds for imports, blocks, multiline traits, and node values. Also fixed parser to not skip ')' in trait applications, and to make enum values not contain the rest of their trailing whitespace. --- .../smithy/lsp/SmithyLanguageServer.java | 24 + .../lsp/language/FoldingRangeHandler.java | 140 ++++++ .../amazon/smithy/lsp/syntax/Parser.java | 8 +- .../lsp/language/FoldingRangeHandlerTest.java | 456 ++++++++++++++++++ 4 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 0f05a334..d5ce4661 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -51,6 +51,8 @@ import org.eclipse.lsp4j.DocumentFormattingParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.FoldingRange; +import org.eclipse.lsp4j.FoldingRangeRequestParams; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; @@ -98,6 +100,7 @@ import software.amazon.smithy.lsp.language.CompletionHandler; import software.amazon.smithy.lsp.language.DefinitionHandler; import software.amazon.smithy.lsp.language.DocumentSymbolHandler; +import software.amazon.smithy.lsp.language.FoldingRangeHandler; import software.amazon.smithy.lsp.language.HoverHandler; import software.amazon.smithy.lsp.project.BuildFile; import software.amazon.smithy.lsp.project.IdlFile; @@ -128,6 +131,7 @@ public class SmithyLanguageServer implements capabilities.setHoverProvider(true); capabilities.setDocumentFormattingProvider(true); capabilities.setDocumentSymbolProvider(true); + capabilities.setFoldingRangeProvider(true); WorkspaceFoldersOptions workspaceFoldersOptions = new WorkspaceFoldersOptions(); workspaceFoldersOptions.setSupported(true); @@ -581,6 +585,26 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return CompletableFuture.supplyAsync(handler::handle); } + @Override + public CompletableFuture> foldingRange(FoldingRangeRequestParams params) { + LOGGER.finest("FoldingRange"); + + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "folding range"); + return completedFuture(Collections.emptyList()); + } + + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(List.of()); + } + + List statements = idlFile.getParse().statements(); + var handler = new FoldingRangeHandler(idlFile.document(), idlFile.getParse().imports(), statements); + return CompletableFuture.supplyAsync(handler::handle); + } + @Override public CompletableFuture, List>> definition(DefinitionParams params) { diff --git a/src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java b/src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java new file mode 100644 index 00000000..62653db5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java @@ -0,0 +1,140 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import org.eclipse.lsp4j.FoldingRange; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.document.DocumentImports; +import software.amazon.smithy.lsp.syntax.Syntax; + + +public record FoldingRangeHandler(Document document, DocumentImports documentImports, + List statements) { + /** + * Main public handle function in the handler class. + * + * @return A list of FoldingRange + */ + public List handle() { + return generateFoldingRanges(); + } + + private boolean isFoldable(int startLine, int endLine) { + // If the statement or node takes up at least two lines, it is foldable + return endLine > startLine; + } + + private void addFoldingRange(List foldingRanges, int startIndex, int endIndex) { + int startLine = document.lineOfIndex(startIndex); + int endLine = document.lineOfIndex(endIndex); + if (isFoldable(startLine, endLine)) { + foldingRanges.add(new FoldingRange(startLine, endLine)); + } + } + + private void addFoldingRangeForImports(List foldingRanges) { + Range range = documentImports.importsRange(); + if (range != null && isFoldable(range.getStart().getLine(), range.getEnd().getLine())) { + foldingRanges.add(new FoldingRange(range.getStart().getLine(), range.getEnd().getLine())); + } + } + + private List generateFoldingRanges() { + List foldingRanges = new ArrayList<>(); + + addFoldingRangeForImports(foldingRanges); + + ListIterator iterator = statements.listIterator(); + + while (iterator.hasNext()) { + var statement = iterator.next(); + switch (statement) { + case Syntax.Statement.TraitApplication trait -> + processFoldingRangeForTraitApplication(foldingRanges, trait, iterator); + + case Syntax.Statement.Metadata metadata -> + processFoldingRangeForNode(foldingRanges, metadata.value()); + + case Syntax.Statement.Block blk -> + processFoldingRangeForBlock(foldingRanges, blk); + + case Syntax.Statement.NodeMemberDef nodeMember -> + processFoldingRangeForNode(foldingRanges, nodeMember.value()); + + case Syntax.Statement.InlineMemberDef inlineMember -> + addFoldingRange(foldingRanges, inlineMember.start(), inlineMember.end()); + // Skip the statements don't need to be folded. + default -> { + } + } + } + return foldingRanges; + } + + private void processFoldingRangeForBlock(List foldingRanges, Syntax.Statement.Block blk) { + // If the block is empty, the last statement index will not be set. + if (blk.lastStatementIndex() == blk.statementIndex()) { + return; + } + addFoldingRange(foldingRanges, blk.start(), blk.end()); + } + + private void processFoldingRangeForTraitApplication(List foldingRanges, + Syntax.Statement.TraitApplication trait, + ListIterator iterator) { + int traitBlockStart = trait.start(); + int traitBlockEnd = -1; + // Create folding range for the start trait statement. + processFoldingRangeForNode(foldingRanges, trait.value()); + // Find next non-trait statement and create folding range for the statement traversed. + while (iterator.hasNext()) { + var nextStatement = iterator.next(); + + if (nextStatement instanceof Syntax.Statement.TraitApplication nextTrait) { + traitBlockEnd = nextTrait.value() == null ? nextTrait.end() : nextTrait.value().end(); + processFoldingRangeForNode(foldingRanges, nextTrait.value()); + } else { + iterator.previous(); + break; + } + } + //Single nested trait is handled by processFoldingRangeForNode. + if (traitBlockEnd != -1) { + addFoldingRange(foldingRanges, traitBlockStart, traitBlockEnd); + } + } + + private void processFoldingRangeForNode(List foldingRanges, Syntax.Node node) { + if (node == null) { + return; + } + + switch (node) { + case Syntax.Node.Kvps kvps -> { + if (!kvps.kvps().isEmpty()) { + addFoldingRange(foldingRanges, kvps.start(), kvps.end()); + kvps.kvps().forEach(kvp -> processFoldingRangeForNode(foldingRanges, kvp.value())); + } + } + // Obj only contains kvps, will use kvps as its folding range + case Syntax.Node.Obj obj -> processFoldingRangeForNode(foldingRanges, obj.kvps()); + + case Syntax.Node.Arr arr -> { + if (!arr.elements().isEmpty()) { + addFoldingRange(foldingRanges, arr.start(), arr.end()); + arr.elements().forEach(element -> processFoldingRangeForNode(foldingRanges, element)); + } + } + // Skip the Nodes don't need to be folded + default -> { + } + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index ecc73da4..27eb7087 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -165,7 +165,6 @@ private Syntax.Node traitNode() { Syntax.Node.Kvps kvps = new Syntax.Node.Kvps(); setStart(kvps); setEnd(kvps); - skip(); yield kvps; } @@ -228,6 +227,8 @@ private Syntax.Node.Obj obj() { if (is('}')) { skip(); setEnd(obj); + obj.kvps.start = obj.start; + obj.kvps.end = obj.end; return obj; } @@ -251,7 +252,6 @@ private Syntax.Node.Obj obj() { ws(); } } - Syntax.Node.Err err = new Syntax.Node.Err("missing }"); setStart(err); setEnd(err); @@ -331,6 +331,7 @@ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { err = e; } else if (err == null) { kvp.value = value; + kvp.end = value.end; if (is(',')) { skip(); } @@ -840,6 +841,7 @@ private void enumMember(Syntax.Statement.Block parent) { Syntax.Ident name = ident(); var enumMemberDef = new Syntax.Statement.EnumMemberDef(parent, name); enumMemberDef.start = start; + setEnd(enumMemberDef); // Set the enumMember end right after ident processed for simple enum member. addStatement(enumMemberDef); ws(); @@ -847,8 +849,8 @@ private void enumMember(Syntax.Statement.Block parent) { skip(); // '=' ws(); enumMemberDef.value = parseNode(); + setEnd(enumMemberDef); // Override the previous enumMember end if assignment exists. } - setEnd(enumMemberDef); } else { addErr(position(), position(), "unexpected token " + peekSingleCharForMessage() + " expected trait or member"); diff --git a/src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java new file mode 100644 index 00000000..d6b05ba7 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java @@ -0,0 +1,456 @@ +package software.amazon.smithy.lsp.language; + + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectTest; + + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +public class FoldingRangeHandlerTest { + @Test + public void foldingRangeForMultipleImports() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use example.test1% + use example.test2 + use example.test3% + + structure foo {% + bar: String + }% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(2)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + + } + + @Test + public void foldingRangeForSingleStructure() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure foo { + bar: String + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(1)); + assertArrayEquals(new int[]{3, 5}, ranges.getFirst()); + } + + @Test + public void foldingRangeForSingleEmptyShape() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure foo { + + } + resource foo { + + } + operation foo { + + } + union foo{ + + + } + service foo{ + + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(0)); + + } + + @Test + public void foldingRangeForNestedEmptyShape() { + String model = safeString(""" + resource foo { + bar:{ + + } + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(1)); + + } + + @Test + public void foldingRangeForMultipleAdjacentBlocks() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure First { + a: String + } + structure Second { + b: String + } + structure Third { + c: String + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(3)); + + } + + @Test + public void foldingRangeForStructureWithComment() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + // Comment before + structure WithComments {% + // Comment inside + field1: String, + field2: Integer // Inline comment + // Comment between fields + field3: Boolean + }% // Comment after + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(1)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + } + + @Test + public void foldingRangeForNestedStructures() { + String model = safeString(""" + $version: "2" + namespace com.foo + + resource Person { + name: { + firstName: String, + lastName: String + }, + address: { + street: String, + city: String, + country: String + } + operations: [GetName, + GetAddress, + GetCountry, + GetStreet, + GetCity] + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(4)); + assertArrayEquals(new int[]{3, 18}, ranges.get(0)); + assertArrayEquals(new int[]{4, 7}, ranges.get(1)); + assertArrayEquals(new int[]{8, 12}, ranges.get(2)); + assertArrayEquals(new int[]{13, 17}, ranges.get(3)); + } + + @Test + public void foldingRangeForMultipleAndNestedTraits() { + TextWithPositions model = TextWithPositions.from(""" + @restJson1% + @title("") + @cors() + @sigv4() + @aws.api#service(% + foo: "bar", + foo2: "bar" + )% + @documentation("foo bar")% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(2)); + assertEquals(ranges.get(0)[0], model.positions()[1].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + } + + @Test + public void foldingRangeTest() { + TextWithPositions model = TextWithPositions.from(""" + @required + """); + + List ranges = getFoldingRanges(model.text()); + + } + + @Test + public void foldingRangeForTraitsBlockContainNewline() { + TextWithPositions model = TextWithPositions.from(""" + @restJson1% + @title("") + @cors() + + @sigv4() + + @aws.api#service(% + foo: "bar", + foo2: "bar" + )% + @documentation("foo bar")% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(2)); + assertEquals(ranges.get(0)[0], model.positions()[1].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + } + + @Test + public void foldingRangeForMultipleTraitsBlocks() { + TextWithPositions model = TextWithPositions.from(""" + @restJson1% + @title("") + @cors()% + structure foo{% + bar: String + }% + + @restJson1% + @title("") + @cors()% + + structure foo2{% + bar: String + }% + + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(4)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + assertEquals(ranges.get(2)[0], model.positions()[4].getLine()); + assertEquals(ranges.get(2)[1], model.positions()[5].getLine()); + assertEquals(ranges.get(3)[0], model.positions()[6].getLine()); + assertEquals(ranges.get(3)[1], model.positions()[7].getLine()); + } + + @Test + public void foldingRangeForTraitWithNestedMembers() { + TextWithPositions model = TextWithPositions.from(""" + @integration(% + requestTemplates: {% + "application/json": {% + "field1": "value1", + }% + }% + )% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(3)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[5].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[4].getLine()); + assertEquals(ranges.get(2)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(2)[1], model.positions()[3].getLine()); + } + + @Test + public void foldingRangeForNestedTraitsWithOperation() { + TextWithPositions model = TextWithPositions.from(""" + @integration(% + requestParameters: {% + "param1": "a", + "param2": "b", + "param3": "c", + },% + requestTemplates: {% + "application/json": "{}" + }% + )% + + @http(% + uri: "a/b/c" + method: "POST" + code: 200 + )% + @documentation("foo bar")% + operation CreateBeer {% + input: Beer + output: Beer + errors: [% + fooException + ]% + }% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(7)); + } + + @Test + public void foldingRangeForMixedStructuresAndTraits() { + TextWithPositions model = TextWithPositions.from(""" + @deprecated% + @documentation("Additional docs")% + structure DocumentedStruct {% + @required% + @range(min: 1, max: 100)% + field: Integer + }% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(3)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[5].getLine()); + assertEquals(ranges.get(2)[0], model.positions()[3].getLine()); + assertEquals(ranges.get(2)[1], model.positions()[4].getLine()); + } + + @Test + public void foldingRangeForMetaData() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + metadata test = [ + { + id: "foo" + namespace: "m1" + }, + { + id: "foo2" + namespace: "m2" + } + ] + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(3)); + } + + @Test + public void foldingRangeForInlineDefinition() { + TextWithPositions model = TextWithPositions.from(""" + operation foo { + input := { + string: String + } + output := { + string: String + } + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(3)); + } + + @Test + public void foldingRangeForEnumWithSimpleMember() { + TextWithPositions model = TextWithPositions.from(""" + enum Stage { + ABC + DEF + GHI + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(1)); + } + + @Test + public void foldingRangeForEnumWithAssignedMember() { + TextWithPositions model = TextWithPositions.from(""" + enum Stage { + ABC = 1 + DEF = 2 + GHI = 3 + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(1)); + } + + @Test + public void foldingRangeForListAndMap() { + TextWithPositions model = TextWithPositions.from(""" + list StringList{ + @required + @length(min: 1, max: 10) + @documentation("Member docs") + member: String + } + map StringMap { + key: String, + value: String + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(3)); + } + + private static List getFoldingRanges(String text) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + List ranges = new ArrayList<>(); + var handler = new FoldingRangeHandler(idlFile.document(), idlFile.getParse().imports(), idlFile.getParse().statements()); + + for (var range : handler.handle()) { + ranges.add(new int[]{range.getStartLine(), range.getEndLine()}); + } + + return ranges; + } +} + From 7ddd163f3fe2440d1a41df44a0a8eb27f1badd49 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:41:28 -0500 Subject: [PATCH 17/43] Fix rebuilding when SourceLocation is NONE (#196) The rebuild index checks which shapes have traits that are applied in other files (i.e. via an `apply`), so we can preserve those traits in a reload. However, if a trait's source location is `SourceLocation.NONE`, which can happen if it wasn't set on the trait's builder, the rebuild index would consider that "a trait applied from another file". If the trait isn't actually being applied from another file, this would cause a duplicate trait conflict, since the trait would be added both from the rebuild index, and the ModelAssembler reparsing. This commit changes two things: 1. The trait's _node_ source location is now used for the comparison, since it should always have a source location as it is constructed within smithy-model, not by trait provider implementations 2. This source location is checked to see if it is NONE. --- .../amazon/smithy/lsp/project/Project.java | 24 +++++--- .../smithy/lsp/project/ProjectTest.java | 59 +++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) 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 b2922b5d..a6bf1599 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -345,13 +345,15 @@ private void removeFileForReload( // those traits. // This shape's dependencies files will be removed and re-loaded - this.rebuildIndex.getDependenciesFiles(toShapeId).forEach((depPath) -> - removeFileForReload(assembler, builder, depPath, visited)); + for (String depPath : this.rebuildIndex.getDependenciesFiles(toShapeId)) { + removeFileForReload(assembler, builder, depPath, visited); + } // Traits applied in other files are re-added to the assembler so if/when the shape // is reloaded, it will have those traits - this.rebuildIndex.getTraitsAppliedInOtherFiles(toShapeId).forEach((trait) -> - assembler.addTrait(toShapeId.toShapeId(), trait)); + for (Trait trait : this.rebuildIndex.getTraitsAppliedInOtherFiles(toShapeId)) { + assembler.addTrait(toShapeId.toShapeId(), trait); + } } } @@ -507,8 +509,9 @@ RebuildIndex recompute(ValidatedResult modelResult) { Node traitNode = traitApplication.toNode(); if (traitNode.isArrayNode()) { for (Node element : traitNode.expectArrayNode()) { - String elementSourceFilename = element.getSourceLocation().getFilename(); - if (!elementSourceFilename.equals(shapeSourceFilename)) { + SourceLocation elementSourceLocation = element.getSourceLocation(); + String elementSourceFilename = elementSourceLocation.getFilename(); + if (!isNone(elementSourceLocation) && !elementSourceFilename.equals(shapeSourceFilename)) { newIndex.filesToDependentFiles .computeIfAbsent(elementSourceFilename, (f) -> new HashSet<>()) .add(shapeSourceFilename); @@ -518,8 +521,9 @@ RebuildIndex recompute(ValidatedResult modelResult) { } } } else { - String traitSourceFilename = traitApplication.getSourceLocation().getFilename(); - if (!traitSourceFilename.equals(shapeSourceFilename)) { + SourceLocation traitSourceLocation = traitNode.getSourceLocation(); + String traitSourceFilename = traitSourceLocation.getFilename(); + if (!isNone(traitSourceLocation) && !traitSourceFilename.equals(shapeSourceFilename)) { newIndex.shapesToAppliedTraitsInOtherFiles .computeIfAbsent(shape.getId(), (i) -> new ArrayList<>()) .add(traitApplication); @@ -534,5 +538,9 @@ RebuildIndex recompute(ValidatedResult modelResult) { return newIndex; } + + private static boolean isNone(SourceLocation sourceLocation) { + return sourceLocation.equals(SourceLocation.NONE); + } } } diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java index 2b8c2ccb..80fb2b4a 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -9,6 +9,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; import java.net.URISyntaxException; @@ -16,16 +17,24 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.SmithyMatchers; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StringShape; import software.amazon.smithy.model.traits.LengthTrait; import software.amazon.smithy.model.traits.PatternTrait; import software.amazon.smithy.model.traits.TagsTrait; +import software.amazon.smithy.model.validation.ValidatedResult; public class ProjectTest { @Test @@ -426,6 +435,56 @@ public void loadsEmptyProjectWhenThereAreNoConfigFiles() throws Exception { assertThat(project.type(), equalTo(Project.Type.EMPTY)); } + @Test + public void changingTraitWithSourceLocationNone() { + // Manually construct a Project with a model containing a trait with SourceLocation.NONE, + // since this test can't rely on any specific trait always having SourceLocation.NONE, as + // it may be fixed upstream. + Path root = Path.of("foo").toAbsolutePath(); + String fooPath = root.resolve("foo.smithy").toString(); + SmithyFile fooSmithyFile = SmithyFile.create(fooPath, Document.of(""" + $version: "2" + namespace com.foo + @length(max: 10) + string Foo + """)); + Map smithyFiles = new HashMap<>(); + smithyFiles.put(fooPath, fooSmithyFile); + Model model = Model.builder() + .addShape(StringShape.builder() + .id("com.foo#Foo") + .source(fooPath, 4, 1) + .addTrait(LengthTrait.builder() + .sourceLocation(SourceLocation.NONE) + .min(1L) + .build()) + .build()) + .build(); + ValidatedResult modelResult = ValidatedResult.fromValue(model); + Project.RebuildIndex rebuildIndex = Project.RebuildIndex.create(modelResult); + + Project project = new Project( + root, + ProjectConfig.empty(), + BuildFiles.of(List.of()), + smithyFiles, + Model::assembler, + Project.Type.DETACHED, + modelResult, + rebuildIndex, + List.of() + ); + + assertThat(project.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + + String fooUri = LspAdapter.toUri(fooPath); + project.updateModelWithoutValidating(fooUri); + + assertThat(project.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + } + public static Project load(Path root) { try { return ProjectLoader.load(root, new ServerState()); From 795159b5b873c336d062e347e7acf156165ec1b8 Mon Sep 17 00:00:00 2001 From: smithy-automation <127955164+smithy-automation@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:42:03 -0800 Subject: [PATCH 18/43] Update Smithy Version (#186) Co-authored-by: Smithy Automation --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6090d27b..6d012361 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -smithyVersion=1.53.0 +smithyVersion=1.54.0 From 8999cb9c0ba1873847a4d83b766952aaf11e2dd3 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:44:30 -0500 Subject: [PATCH 19/43] Fix trait application and empty string parsing (#197) https://github.com/smithy-lang/smithy-language-server/pull/190 fixed trait application parsing for traits like `@foo()`, making it so the end of the trait doesn't include trailing whitespace. This commit fixes the case for non-empty traits, like `@foo(bar: "")`. It also fixes parsing of empty strings. Previously the character _after_ the end of the string would be skipped. --- .../amazon/smithy/lsp/syntax/Parser.java | 2 -- .../amazon/smithy/lsp/TextWithPositions.java | 8 +++++ .../smithy/lsp/syntax/IdlParserTest.java | 30 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index 27eb7087..a9da011d 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -193,7 +193,6 @@ private Syntax.Node traitValueKvps(int from) { while (!eof()) { if (is(')')) { setEnd(kvps); - skip(); return kvps; } @@ -398,7 +397,6 @@ private Syntax.Node str() { } // Empty string - skip(); int strEnd = position(); return new Syntax.Node.Str(start, strEnd, ""); } diff --git a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java index 6cb2b594..60bd2872 100644 --- a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java +++ b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java @@ -38,6 +38,8 @@ public record TextWithPositions(String text, Position... positions) { public static TextWithPositions from(String raw) { Document document = Document.of(safeString(raw)); List positions = new ArrayList<>(); + + Position lastPosition = null; int i = 0; while (true) { int next = document.nextIndexOf(POSITION_MARKER, i); @@ -45,6 +47,12 @@ public static TextWithPositions from(String raw) { break; } Position position = document.positionAtIndex(next); + if (lastPosition != null && position.getLine() == lastPosition.getLine()) { + // If there's two or more markers on the same line, any markers after the + // first will be off by one when we do the replacement. + position.setCharacter(position.getCharacter() - 1); + } + lastPosition = position; positions.add(position); i = next + 1; } diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java index 004cceb0..d3607b1e 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.document.Document; public class IdlParserTest { @@ -304,6 +305,27 @@ public void stringKeysInTraits() { Syntax.Node.Type.Str)); } + @Test + public void traitApplicationsDontContainTrailingWhitespace() { + var twp = TextWithPositions.from(""" + %@foo(foo: "")% + structure Foo { + foo: Foo + } + """); + Document document = Document.of(twp.text()); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + + assertThat(getTypes(parse), contains( + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef)); + + Syntax.Statement traitApplication = parse.statements().get(0); + assertThat(document.positionAtIndex(traitApplication.start()), equalTo(twp.positions()[0])); + assertThat(document.positionAtIndex(traitApplication.end()), equalTo(twp.positions()[1])); + } + @ParameterizedTest @MethodSource("brokenProvider") public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { @@ -460,10 +482,14 @@ private static Stream brokenProvider() { private static void assertTypesEqual(String text, Syntax.Statement.Type... types) { Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); - List actualTypes = parse.statements().stream() + var actualTypes = getTypes(parse); + assertThat(actualTypes, contains(types)); + } + + private static List getTypes(Syntax.IdlParseResult parse) { + return parse.statements().stream() .map(Syntax.Statement::type) .filter(type -> type != Syntax.Statement.Type.Block) .toList(); - assertThat(actualTypes, contains(types)); } } From b124b3d02672cfb1a6241acc86baaa1d190c3c10 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:59:04 -0500 Subject: [PATCH 20/43] Cleanup some noisy logs and debugging in tests (#198) Logging for unknown file in ServerState is unnecessary because it will always log for newly opened detached files, plus callers have to handle a file not being found anyway. Logging for deprecated properties in SmithyBuildExtensions aren't going to be visible enough to customers to be useful, plus we emit a warning for build exts anyways. I also had some old debugging code lingering in tests that I cleaned up. --- src/main/java/software/amazon/smithy/lsp/ServerState.java | 2 -- .../amazon/smithy/lsp/project/SmithyBuildExtensions.java | 4 ---- .../software/amazon/smithy/lsp/document/DocumentTest.java | 8 -------- .../software/amazon/smithy/lsp/syntax/IdlParserTest.java | 3 --- 4 files changed, 17 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index 9760c20d..3b4b0c11 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -101,8 +101,6 @@ ProjectAndFile findProjectAndFile(String uri) { } } - LOGGER.warning(() -> "Tried to unknown file: " + uri); - return null; } diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java index 83af09b8..cc561fc7 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java @@ -159,8 +159,6 @@ public Builder mavenRepositories(Collection mavenRepositories) { .map(repo -> MavenRepository.builder().url(repo).build()) .collect(Collectors.toList())) .build(); - LOGGER.warning("Read deprecated `mavenRepositories` in smithy-build.json. Update smithy-build.json to " - + "{\"maven\": {\"repositories\": [{\"url\": \"repo url\"}]}}"); } this.maven = config; @@ -183,8 +181,6 @@ public Builder mavenDependencies(Collection mavenDependencies) { config = config.toBuilder() .dependencies(mavenDependencies) .build(); - LOGGER.warning("Read deprecated `mavenDependencies` in smithy-build.json. Update smithy-build.json to " - + "{\"maven\": {\"dependencies\": [\"dependencyA\", \"dependencyB\"]}}"); } this.maven = config; return this; diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java index a3424b8c..3a9975b9 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -254,14 +254,6 @@ public void getsEnd() { assertThat(end.getCharacter(), equalTo(3)); } - @Test - public void foo() { - Document a = makeDocument("abc"); - Document b = makeDocument("def\n"); - - System.out.println(); - } - @Test public void getsNextIndexOf() { Document document = makeDocument("abc\ndef"); diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java index d3607b1e..4f6841e5 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -329,9 +329,6 @@ public void traitApplicationsDontContainTrailingWhitespace() { @ParameterizedTest @MethodSource("brokenProvider") public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { - if (desc.equals("trait missing member value")) { - System.out.println(); - } Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); List types = parse.statements().stream() From a2f3125662a0cf2469ebd741485905d8235a1a17 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:21:11 -0500 Subject: [PATCH 21/43] Fix didopen for build files (#199) #168 started tracking build file changes via lifecycle methods, didOpen, etc. But it didn't make a distinction between what was a build file, and what was a Smithy file. There are two paths didOpen can take - the first is when the file being opened is known to be part of a project. In this case, the file is already tracked by its owning Project, so its basically a no-op. The second path is when the file does not belong to any project, in which case we created a "detached" project, which is a project with no build files and just a single Smithy file. So if you opened a build file that wasn't known to be part of a Project, the language server tried to make a detached project containing the build file as a smithy file. This is obviously wrong, but wasn't observable to clients AFAICT because clients weren't set up to send requests to the server for build files (specifically, you wouldn't get diagnostics or anything, only for .smithy files). However, recent commits, including #188, now want to provide language support for smithy-build.json. In testing these new commits with local changes to have VSCode send requests for smithy-build.json, the issue could be observed. Specifically, the issue happens when you open a new smithy-build.json before we receive the didChangeWatchedFiles notification that tells us a new build file was created. didChangeWatchedFiles is the way we actually updated the state of projects to include new build files, or create new Projects. Since we can receive didOpen for a build file before didChangeWatchedFiles, we needed to do something with the build file on didOpen. This commit addresses the problem by adding a new Project type, UNRESOLVED, which is a project containing a single build file that no existing projects are aware of. We do this by modifying the didOpen path when the file isn't known to any project, checking if it is a build file using a PathMatcher, and if it is, creating an unresolved project for it. Then, when we load projects following a didChangeWatchedFiles, we just drop any unresolved projects with the same path as any of the build files in the newly loaded projects (see ServerState::resolveProjects). I also made some (upgrades?) to FilePatterns to better handle the discrepancy between matching behavior of PathMatchers and clients (see #191). Now there are (private) *PatternOptions enums that FilePatterns uses to configure the pattern for different use cases. For example, the new FilePatterns::getSmithyFileWatchPathMatchers provides a list of PathMatchers which should match the same paths as the watcher patterns we send back to clients, which is useful for testing. I also fixed an issue where parsing an empty build file would cause an NPE when trying to map validation events to ranges. Document::rangeBetween would return null if the document was empty, but I wasn't checking for that in ToSmithyNode (which creates parse events). The reason the range is null is because Document.lineOfIndex returns oob for an index of 0 into an empty document. Makes sense, as an empty document has no lines. I updated a DocumentTest to clarify this behavior. By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. --- .../amazon/smithy/lsp/FilePatterns.java | 90 ++++-- .../amazon/smithy/lsp/ServerState.java | 50 +++- .../amazon/smithy/lsp/project/BuildFiles.java | 18 ++ .../amazon/smithy/lsp/project/Project.java | 14 + .../smithy/lsp/project/ProjectLoader.java | 31 ++ .../smithy/lsp/project/ToSmithyNode.java | 7 +- .../amazon/smithy/lsp/FilePatternsTest.java | 61 +++- .../lsp/FileWatcherRegistrationsTest.java | 63 ---- .../smithy/lsp/SmithyLanguageServerTest.java | 279 ++++++++++++++++++ .../amazon/smithy/lsp/TestWorkspace.java | 10 + .../smithy/lsp/document/DocumentTest.java | 1 + .../smithy/lsp/project/ProjectConfigTest.java | 17 ++ 12 files changed, 532 insertions(+), 109 deletions(-) delete mode 100644 src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java index f232fffb..536135a8 100644 --- a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java +++ b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java @@ -9,6 +9,7 @@ import java.nio.file.FileSystems; import java.nio.file.Path; import java.nio.file.PathMatcher; +import java.util.EnumSet; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -20,16 +21,37 @@ * or build files in Projects and workspaces. */ final class FilePatterns { + static final PathMatcher GLOBAL_BUILD_FILES_MATCHER = toPathMatcher(escapeBackslashes( + String.format("**%s{%s}", File.separator, String.join(",", BuildFileType.ALL_FILENAMES)))); + private FilePatterns() { } + private enum SmithyFilePatternOptions { + IS_WATCHER_PATTERN, + IS_PATH_MATCHER_PATTERN; + + private static final EnumSet WATCHER = EnumSet.of(IS_WATCHER_PATTERN); + private static final EnumSet PATH_MATCHER = EnumSet.of(IS_PATH_MATCHER_PATTERN); + private static final EnumSet ALL = EnumSet.allOf(SmithyFilePatternOptions.class); + } + + private enum BuildFilePatternOptions { + IS_WORKSPACE_PATTERN, + IS_PATH_MATCHER_PATTERN; + + private static final EnumSet WORKSPACE = EnumSet.of(IS_WORKSPACE_PATTERN); + private static final EnumSet PATH_MATCHER = EnumSet.of(IS_PATH_MATCHER_PATTERN); + private static final EnumSet ALL = EnumSet.allOf(BuildFilePatternOptions.class); + } + /** * @param project The project to get watch patterns for * @return A list of glob patterns used to watch Smithy files in the given project */ static List getSmithyFileWatchPatterns(Project project) { return Stream.concat(project.sources().stream(), project.imports().stream()) - .map(path -> getSmithyFilePattern(path, true)) + .map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.WATCHER)) .toList(); } @@ -39,17 +61,28 @@ static List getSmithyFileWatchPatterns(Project project) { */ static PathMatcher getSmithyFilesPathMatcher(Project project) { String pattern = Stream.concat(project.sources().stream(), project.imports().stream()) - .map(path -> getSmithyFilePattern(path, false)) + .map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.PATH_MATCHER)) .collect(Collectors.joining(",")); return toPathMatcher("{" + pattern + "}"); } + /** + * @param project The project to get a path matcher for + * @return A list of path matchers that match watched Smithy files in the given project + */ + static List getSmithyFileWatchPathMatchers(Project project) { + return Stream.concat(project.sources().stream(), project.imports().stream()) + .map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.ALL)) + .map(FilePatterns::toPathMatcher) + .toList(); + } + /** * @param root The root to get the watch pattern for * @return A glob pattern used to watch build files in the given workspace */ static String getWorkspaceBuildFilesWatchPattern(Path root) { - return getBuildFilesPattern(root, true); + return getBuildFilesPattern(root, BuildFilePatternOptions.WORKSPACE); } /** @@ -57,7 +90,7 @@ static String getWorkspaceBuildFilesWatchPattern(Path root) { * @return A path matcher that can check if a file is a build file within the given workspace */ static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) { - String pattern = getWorkspaceBuildFilesWatchPattern(root); + String pattern = getBuildFilesPattern(root, BuildFilePatternOptions.ALL); return toPathMatcher(pattern); } @@ -66,7 +99,7 @@ static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) { * @return A path matcher that can check if a file is a build file belonging to the given project */ static PathMatcher getProjectBuildFilesPathMatcher(Project project) { - String pattern = getBuildFilesPattern(project.root(), false); + String pattern = getBuildFilesPattern(project.root(), BuildFilePatternOptions.PATH_MATCHER); return toPathMatcher(pattern); } @@ -74,27 +107,11 @@ private static PathMatcher toPathMatcher(String globPattern) { return FileSystems.getDefault().getPathMatcher("glob:" + globPattern); } - // Patterns for the workspace need to match on all build files in all subdirectories, - // whereas patterns for projects only look at the top level (because project locations - // are defined by the presence of these build files). - private static String getBuildFilesPattern(Path root, boolean isWorkspacePattern) { - String rootString = root.toString(); - if (!rootString.endsWith(File.separator)) { - rootString += File.separator; - } - - if (isWorkspacePattern) { - rootString += "**" + File.separator; - } - - return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}"); - } - // When computing the pattern used for telling the client which files to watch, we want // to only watch .smithy/.json files. We don't need it in the PathMatcher pattern because // we only need to match files, not listen for specific changes (and it is impossible anyway // because we can't have a nested pattern). - private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) { + private static String getSmithyFilePattern(Path path, EnumSet options) { String glob = path.toString(); if (glob.endsWith(".smithy") || glob.endsWith(".json")) { return escapeBackslashes(glob); @@ -105,13 +122,38 @@ private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) } glob += "**"; - if (isWatcherPattern) { - glob += "/*.{smithy,json}"; + if (options.contains(SmithyFilePatternOptions.IS_WATCHER_PATTERN)) { + // For some reason, the glob pattern matching works differently on vscode vs + // PathMatcher. See https://github.com/smithy-lang/smithy-language-server/issues/191 + if (options.contains(SmithyFilePatternOptions.IS_PATH_MATCHER_PATTERN)) { + glob += ".{smithy,json}"; + } else { + glob += "/*.{smithy,json}"; + } } return escapeBackslashes(glob); } + // Patterns for the workspace need to match on all build files in all subdirectories, + // whereas patterns for projects only look at the top level (because project locations + // are defined by the presence of these build files). + private static String getBuildFilesPattern(Path root, EnumSet options) { + String rootString = root.toString(); + if (!rootString.endsWith(File.separator)) { + rootString += File.separator; + } + + if (options.contains(BuildFilePatternOptions.IS_WORKSPACE_PATTERN)) { + rootString += "**"; + if (!options.contains(BuildFilePatternOptions.IS_PATH_MATCHER_PATTERN)) { + rootString += File.separator; + } + } + + return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}"); + } + // In glob patterns, '\' is an escape character, so it needs to escaped // itself to work as a separator (i.e. for windows) private static String escapeBackslashes(String pattern) { diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index 3b4b0c11..70a70a3e 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -118,6 +118,17 @@ ProjectAndFile open(String uri, String text) { if (projectAndFile != null) { projectAndFile.file().document().applyEdit(null, text); } else { + // A newly created build file or smithy file may be opened before we receive the + // `didChangeWatchedFiles` notification, so we either need to load an unresolved + // project (build file), or load a detached project (smithy file). When we receive + // `didChangeWatchedFiles` we will move the file into a regular project, if applicable. + Path path = Path.of(LspAdapter.toPath(uri)); + if (FilePatterns.GLOBAL_BUILD_FILES_MATCHER.matches(path)) { + Project project = ProjectLoader.loadUnresolved(path, text); + projects.put(uri, project); + return findProjectAndFile(uri); + } + createDetachedProject(uri, text); projectAndFile = findProjectAndFile(uri); // Note: This will always be present } @@ -129,13 +140,16 @@ void close(String uri) { managedUris.remove(uri); ProjectAndFile projectAndFile = findProjectAndFile(uri); - if (projectAndFile != null && projectAndFile.project().type() == Project.Type.DETACHED) { - // Only cancel tasks for detached projects, since we're dropping the project + if (projectAndFile != null && shouldDropOnClose(projectAndFile.project())) { lifecycleTasks.cancelTask(uri); projects.remove(uri); } } + private static boolean shouldDropOnClose(Project project) { + return project.type() == Project.Type.DETACHED || project.type() == Project.Type.UNRESOLVED; + } + List tryInitProject(Path root) { LOGGER.finest("Initializing project at " + root); lifecycleTasks.cancelAllTasks(); @@ -145,9 +159,9 @@ List tryInitProject(Path root) { Project updatedProject = ProjectLoader.load(root, this); if (updatedProject.type() == Project.Type.EMPTY) { - removeProjectAndResolveDetached(projectName); + removeProjectAndResolve(projectName); } else { - resolveDetachedProjects(projects.get(projectName), updatedProject); + resolveProjects(projects.get(projectName), updatedProject); projects.put(projectName, updatedProject); } @@ -191,7 +205,7 @@ void removeWorkspace(WorkspaceFolder folder) { } for (String projectName : projectsToRemove) { - removeProjectAndResolveDetached(projectName); + removeProjectAndResolve(projectName); } } @@ -230,14 +244,18 @@ List applyFileEvents(List events) { return errors; } - private void removeProjectAndResolveDetached(String projectName) { + private void removeProjectAndResolve(String projectName) { Project removedProject = projects.remove(projectName); if (removedProject != null) { - resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); + resolveProjects(removedProject, Project.empty(removedProject.root())); } } - private void resolveDetachedProjects(Project oldProject, Project updatedProject) { + private void resolveProjects(Project oldProject, Project updatedProject) { + // There may be unresolved projects that have been resolved by the updated project, so + // we need to remove them here. + removeDetachedOrUnresolvedProjects(updatedProject.getAllBuildFilePaths()); + // 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) { @@ -246,10 +264,7 @@ private void resolveDetachedProjects(Project oldProject, Project updatedProject) Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); addedPaths.removeAll(currentProjectSmithyPaths); - for (String addedPath : addedPaths) { - String addedUri = LspAdapter.toUri(addedPath); - projects.remove(addedUri); // Remove any detached projects - } + removeDetachedOrUnresolvedProjects(addedPaths); Set removedPaths = new HashSet<>(currentProjectSmithyPaths); removedPaths.removeAll(updatedProjectSmithyPaths); @@ -261,6 +276,17 @@ private void resolveDetachedProjects(Project oldProject, Project updatedProject) createDetachedProject(removedUri, removedDocument.copyText()); } } + } else { + // This is a new project, so there may be detached projects that are resolved by + // this new project. + removeDetachedOrUnresolvedProjects(updatedProject.getAllSmithyFilePaths()); + } + } + + private void removeDetachedOrUnresolvedProjects(Set filePaths) { + for (String filePath : filePaths) { + String uri = LspAdapter.toUri(filePath); + projects.remove(uri); } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java index c9123b84..b1d2b07a 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java @@ -10,7 +10,9 @@ import java.util.Collection; import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; +import java.util.Set; import software.amazon.smithy.lsp.ManagedFiles; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; @@ -49,6 +51,10 @@ boolean isEmpty() { return buildFiles.isEmpty(); } + Set getAllPaths() { + return buildFiles.keySet(); + } + static BuildFiles of(Collection buildFiles) { Map buildFileMap = new HashMap<>(buildFiles.size()); for (BuildFile buildFile : buildFiles) { @@ -57,6 +63,18 @@ static BuildFiles of(Collection buildFiles) { return new BuildFiles(buildFileMap); } + static BuildFiles of(Path path, Document document) { + for (BuildFileType type : BuildFileType.values()) { + if (path.endsWith(type.filename())) { + String pathString = path.toString(); + BuildFile buildFile = BuildFile.create(pathString, document, type); + return new BuildFiles(Map.of(pathString, buildFile)); + } + } + + return BuildFiles.of(List.of()); + } + static BuildFiles load(Path root, ManagedFiles managedFiles) { Map buildFiles = new HashMap<>(BuildFileType.values().length); for (BuildFileType type : BuildFileType.values()) { 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 a6bf1599..c06e8218 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -86,6 +86,16 @@ public enum Type { */ DETACHED, + /** + * A project loaded from a single build file. + * + *

This occurs when a newly created build file is opened before we + * receive its `didChangeWatchedFiles` notification, which takes care + * of both adding new build files to an existing project, and creating + * a new project in a new root. + */ + UNRESOLVED, + /** * A project loaded with no source or build configuration files. */ @@ -156,6 +166,10 @@ public Set getAllSmithyFilePaths() { return this.smithyFiles.keySet(); } + public Set getAllBuildFilePaths() { + return this.buildFiles.getAllPaths(); + } + /** * @return All the Smithy files loaded in the project. */ 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 b2a18f9f..1c5025a0 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -67,6 +67,37 @@ public static Project loadDetached(String uri, String text) { ); } + /** + * Loads an unresolved (single config file) {@link Project} with the given file. + * + * @param path Path of the file to load into a project + * @param text Text of the file to load into a project + * @return The loaded project + */ + public static Project loadUnresolved(Path path, String text) { + Document document = Document.of(text); + BuildFiles buildFiles = BuildFiles.of(path, document); + + // An unresolved project is meant to be resolved at a later point, so we don't + // even try loading its configuration from the build file. + ProjectConfig config = ProjectConfig.empty(); + + // We aren't loading any smithy files in this project, so use a no-op ManagedFiles. + LoadModelResult result = doLoad((fileUri) -> null, config); + + return new Project( + path, + config, + buildFiles, + result.smithyFiles(), + result.assemblerFactory(), + Project.Type.UNRESOLVED, + result.modelResult(), + result.rebuildIndex(), + List.of() + ); + } + /** * Loads a {@link Project} at the given root path, using any {@code managedDocuments} * instead of loading from disk. diff --git a/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java b/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java index 54600032..865a959e 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp.project; import java.util.List; +import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.syntax.Syntax; @@ -104,6 +105,10 @@ yield switch (value) { } private SourceLocation nodeStartSourceLocation(Syntax.Node node) { - return LspAdapter.toSourceLocation(path, document.rangeBetween(node.start(), node.end())); + Range range = document.rangeBetween(node.start(), node.end()); + if (range == null) { + range = LspAdapter.origin(); + } + return LspAdapter.toSourceLocation(path, range); } } diff --git a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java index d74b4901..d6d2cf53 100644 --- a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -6,12 +6,15 @@ package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.not; +import static software.amazon.smithy.lsp.UtilMatchers.canMatchPath; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; +import java.util.List; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.project.Project; @@ -44,11 +47,11 @@ public void createsProjectPathMatchers() { PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); Path root = project.root(); - assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("abc.smithy"))); - assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("foo/bar/baz.smithy"))); - assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("other/bar.smithy"))); - assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve("smithy-build.json"))); - assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve(".smithy-project.json"))); + assertThat(smithyMatcher, canMatchPath(root.resolve("abc.smithy"))); + assertThat(smithyMatcher, canMatchPath(root.resolve("foo/bar/baz.smithy"))); + assertThat(smithyMatcher, canMatchPath(root.resolve("other/bar.smithy"))); + assertThat(buildMatcher, canMatchPath(root.resolve("smithy-build.json"))); + assertThat(buildMatcher, canMatchPath(root.resolve(".smithy-project.json"))); } @Test @@ -70,9 +73,49 @@ public void createsWorkspacePathMatchers() throws IOException { PathMatcher fooBuildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(fooProject); PathMatcher workspaceBuildMatcher = FilePatterns.getWorkspaceBuildFilesPathMatcher(workspaceRoot); - assertThat(fooBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); - assertThat(fooBuildMatcher, not(UtilMatchers.canMatchPath(workspaceRoot.resolve("bar/smithy-build.json")))); - assertThat(workspaceBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); - assertThat(workspaceBuildMatcher, UtilMatchers.canMatchPath(workspaceRoot.resolve("bar/smithy-build.json"))); + assertThat(fooBuildMatcher, canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(fooBuildMatcher, not(canMatchPath(workspaceRoot.resolve("bar/smithy-build.json")))); + assertThat(workspaceBuildMatcher, canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(workspaceBuildMatcher, canMatchPath(workspaceRoot.resolve("bar/smithy-build.json"))); + } + + @Test + public void smithyFileWatchPatternsMatchCorrectSmithyFiles() { + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(new TestWorkspace.Dir() + .withPath("foo") + .withSourceDir(new TestWorkspace.Dir() + .withPath("bar") + .withSourceFile("bar.smithy", "") + .withSourceFile("baz.smithy", "")) + .withSourceFile("baz.smithy", "")) + .withSourceDir(new TestWorkspace.Dir() + .withPath("other") + .withSourceFile("other.smithy", "")) + .withSourceFile("abc.smithy", "") + .withConfig(SmithyBuildConfig.builder() + .version("1") + .sources(ListUtils.of("foo", "other/", "abc.smithy")) + .build()) + .build(); + + Project project = ProjectTest.load(workspace.getRoot()); + List matchers = FilePatterns.getSmithyFileWatchPathMatchers(project); + + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("foo/abc.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("foo/foo/abc/def.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("other/abc.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("other/foo/abc.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("abc.smithy")))); + } + + @Test + public void matchingAnyBuildFile() { + PathMatcher global = FilePatterns.GLOBAL_BUILD_FILES_MATCHER; + + assertThat(global, canMatchPath(Path.of("/smithy-build.json"))); + assertThat(global, canMatchPath(Path.of("foo/bar/smithy-build.json"))); + assertThat(global, canMatchPath(Path.of("/foo/bar/smithy-build.json"))); + assertThat(global, not(canMatchPath(Path.of("/foo/bar/foo-smithy-build.json")))); } } diff --git a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java b/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java deleted file mode 100644 index c150dbc9..00000000 --- a/src/test/java/software/amazon/smithy/lsp/FileWatcherRegistrationsTest.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasItem; - -import java.nio.file.FileSystems; -import java.nio.file.PathMatcher; -import java.util.List; -import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; -import org.eclipse.lsp4j.Registration; -import org.junit.jupiter.api.Disabled; -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.ProjectTest; -import software.amazon.smithy.utils.ListUtils; - -public class FileWatcherRegistrationsTest { - @Test - @Disabled("https://github.com/smithy-lang/smithy-language-server/issues/191") - public void createsCorrectRegistrations() { - TestWorkspace workspace = TestWorkspace.builder() - .withSourceDir(new TestWorkspace.Dir() - .withPath("foo") - .withSourceDir(new TestWorkspace.Dir() - .withPath("bar") - .withSourceFile("bar.smithy", "") - .withSourceFile("baz.smithy", "")) - .withSourceFile("baz.smithy", "")) - .withSourceDir(new TestWorkspace.Dir() - .withPath("other") - .withSourceFile("other.smithy", "")) - .withSourceFile("abc.smithy", "") - .withConfig(SmithyBuildConfig.builder() - .version("1") - .sources(ListUtils.of("foo", "other/", "abc.smithy")) - .build()) - .build(); - - Project project = ProjectTest.load(workspace.getRoot()); - List matchers = FileWatcherRegistrations.getSmithyFileWatcherRegistrations(List.of(project)) - .stream() - .map(Registration::getRegisterOptions) - .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) - .flatMap(options -> options.getWatchers().stream()) - .map(watcher -> watcher.getGlobPattern().getLeft()) - // The watcher glob patterns will look different between windows/unix, so turning - // them into path matchers lets us do platform-agnostic assertions. - .map(pattern -> FileSystems.getDefault().getPathMatcher("glob:" + pattern)) - .toList(); - - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("foo/abc.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("foo/foo/abc/def.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("other/abc.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("other/foo/abc.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("abc.smithy")))); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 9395eada..aad25ceb 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1942,6 +1942,285 @@ public void testFromInitializeParamsWithPartialOptions() { assertThat(options.getOnlyReloadOnSave(), equalTo(true)); // Explicitly set value } + @Test + public void openingNewBuildFileInExistingProjectBeforeDidChangeWatchedFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithNoConfig("test"); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(workspace.getRoot()) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + SmithyLanguageServer server = initFromRoot(workspace.getRoot()); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + String bazUri = workspaceFoo.getUri("baz.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(bazUri) + .text(bazModel) + .build()); + + assertThat(server.getState().workspacePaths(), contains(workspace.getRoot())); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); + + String smithyProjectJson = """ + { + "sources": ["baz.smithy"] + }"""; + workspaceFoo.addModel(".smithy-project.json", smithyProjectJson); + String smithyProjectJsonUri = workspaceFoo.getUri(".smithy-project.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(smithyProjectJsonUri) + .text(smithyProjectJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); + assertManagedMatches(server, smithyProjectJsonUri, Project.Type.UNRESOLVED, smithyProjectJsonUri); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(smithyProjectJsonUri, FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, smithyProjectJsonUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertThat(server.getState().getAllProjects().size(), is(1)); + } + + @Test + public void openingNewBuildFileInNewProjectBeforeDidChangeWatchedFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithNoConfig("test"); + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(workspace.getRoot()) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + SmithyLanguageServer server = initFromRoot(workspace.getRoot()); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(workspace.getRoot()) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + assertThat(server.getState().workspacePaths(), contains(workspace.getRoot())); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + + String barSmithyBuildJson = """ + { + "version": "1", + "sources": ["bar.smithy"] + }"""; + workspaceBar.addModel("smithy-build.json", barSmithyBuildJson); + String barSmithyBuildUri = workspaceBar.getUri("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(barSmithyBuildUri) + .text(barSmithyBuildJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.UNRESOLVED, barSmithyBuildUri); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(barSmithyBuildUri, FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.NORMAL, workspaceBar.getRoot()); + } + + @Test + public void openingConfigFileInEmptyWorkspaceBeforeDidChangeWatchedFiles() { + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.singleModel(fooModel); + + SmithyLanguageServer server = initFromWorkspace(workspaceFoo); + + TestWorkspace workspaceBar = TestWorkspace.emptyWithNoConfig("bar"); + server.didChangeWorkspaceFolders(RequestBuilders.didChangeWorkspaceFolders() + .added(workspaceBar.getRoot().toUri().toString(), "bar") + .build()); + + assertThat(server.getState().workspacePaths(), containsInAnyOrder( + workspaceFoo.getRoot(), + workspaceBar.getRoot())); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + workspaceBar.addModel("bar.smithy", barModel); + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String fooUri = workspaceFoo.getUri("main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + + String barSmithyBuildJson = """ + { + "version": "1", + "sources": ["bar.smithy"] + }"""; + workspaceBar.addModel("smithy-build.json", barSmithyBuildJson); + String barSmithyBuildUri = workspaceBar.getUri("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(barSmithyBuildUri) + .text(barSmithyBuildJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.UNRESOLVED, barSmithyBuildUri); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(barSmithyBuildUri, FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.NORMAL, workspaceBar.getRoot()); + } + + @Test + public void openingConfigFileInEmptyWorkspaceAfterDidChangeWatchedFiles() { + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.singleModel(fooModel); + + SmithyLanguageServer server = initFromWorkspace(workspaceFoo); + + TestWorkspace workspaceBar = TestWorkspace.emptyWithNoConfig("bar"); + server.didChangeWorkspaceFolders(RequestBuilders.didChangeWorkspaceFolders() + .added(workspaceBar.getRoot().toUri().toString(), "bar") + .build()); + + assertThat(server.getState().workspacePaths(), containsInAnyOrder( + workspaceFoo.getRoot(), + workspaceBar.getRoot())); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + workspaceBar.addModel("bar.smithy", barModel); + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String fooUri = workspaceFoo.getUri("main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + + String barSmithyBuildJson = """ + { + "version": "1", + "sources": ["bar.smithy"] + }"""; + workspaceBar.addModel("smithy-build.json", barSmithyBuildJson); + String barSmithyBuildUri = workspaceBar.getUri("smithy-build.json"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(barSmithyBuildUri, FileChangeType.Created) + .build()); + + server.didOpen(RequestBuilders.didOpen() + .uri(barSmithyBuildUri) + .text(barSmithyBuildJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.NORMAL, workspaceBar.getRoot()); + } + + @Test + public void foo() { + TestWorkspace workspace = TestWorkspace.emptyWithNoConfig("foo"); + SmithyLanguageServer server = initFromRoot(workspace.getRoot()); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + workspace.addModel("foo.smithy", fooModel); + String fooUri = workspace.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.DETACHED, fooUri); + } + private void assertManagedMatches( SmithyLanguageServer server, String uri, diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index 8edab024..0e362add 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -128,6 +128,16 @@ public static TestWorkspace multipleModels(String... models) { return builder.build(); } + public static TestWorkspace emptyWithNoConfig(String prefix) { + Path root; + try { + root = Files.createTempDirectory(prefix); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new TestWorkspace(root, null); + } + public static Builder builder() { return new Builder(); } diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java index 3a9975b9..32e9e0e0 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -293,6 +293,7 @@ public void getsLineOfIndex() { Document leadingAndTrailingWs = makeDocument("\nabc\n"); Document threeLine = makeDocument("abc\ndef\nhij\n"); + assertThat(empty.lineOfIndex(0), is(-1)); // empty has no lines, so oob assertThat(empty.lineOfIndex(1), is(-1)); // oob assertThat(single.lineOfIndex(0), is(0)); // start assertThat(single.lineOfIndex(2), is(0)); // end diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java index 2383bd5c..2ff26946 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.function.Supplier; import java.util.stream.Collectors; +import org.eclipse.lsp4j.Position; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.MavenRepository; import software.amazon.smithy.cli.dependencies.DependencyResolver; @@ -32,6 +33,7 @@ import software.amazon.smithy.lsp.ServerState; import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; public class ProjectConfigTest { @Test @@ -78,6 +80,21 @@ public void mergesBuildExts() { assertThat(config.maven().getDependencies(), containsInAnyOrder("foo")); } + @Test + public void handlesEmptyFiles() { + var root = Path.of("foo"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, ""); + var result = load(root, buildFiles); + + var smithyBuild = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(smithyBuild, notNullValue()); + assertThat(result.events(), containsInAnyOrder(allOf( + eventWithId(equalTo("Model")), + eventWithMessage(containsString("Error parsing JSON")), + eventWithSourceLocation(equalTo(LspAdapter.toSourceLocation(smithyBuild.path(), new Position(0, 0)))) + ))); + } + @Test public void validatesSmithyBuildJson() { var text = TextWithPositions.from(""" From 7c33ab1f515fe6ebb9d4f016844ff0492f5bd0f1 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:13:23 -0500 Subject: [PATCH 22/43] Use 1.0 for smithy-build version completion (#203) Was just `1`, which doesn't match the docs' default of `1.0`. --- .../software/amazon/smithy/lsp/language/build.smithy | 2 +- .../lsp/language/BuildCompletionHandlerTest.java | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy index 75f4b85c..c1503a84 100644 --- a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy +++ b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy @@ -33,7 +33,7 @@ structure SmithyBuildJson { maven: Maven } -@default("1") +@default("1.0") string SmithyBuildVersion map Projections { diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java index 499e7129..664a8b73 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java @@ -38,7 +38,7 @@ public void completesSmithyBuildJsonTopLevel() { assertThat(items, containsInAnyOrder( hasLabelAndEditText("version", """ - "version": "1" + "version": "1.0" """), hasLabelAndEditText("outputDirectory", """ "outputDirectory": "" @@ -231,7 +231,7 @@ public void matchesStringKeys() { assertThat(items, containsInAnyOrder( hasLabelAndEditText("version", """ - "version": "1" + "version": "1.0" """) )); } @@ -247,7 +247,7 @@ public void matchesNonStringKeys() { assertThat(items, containsInAnyOrder( hasLabelAndEditText("version", """ - "version": "1" + "version": "1.0" """) )); } @@ -273,8 +273,8 @@ public void completesKeyValues() { var items = getCompItems(text, BuildFileType.SMITHY_BUILD); assertThat(items, containsInAnyOrder( - hasLabelAndEditText("\"1\"", """ - "1" + hasLabelAndEditText("\"1.0\"", """ + "1.0" """), hasLabelAndEditText("false", "false"), hasLabelAndEditText("true", "true"), From 958f8ab93ad0af159594aa6d61e40f2e8ef13501 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:14:00 -0500 Subject: [PATCH 23/43] Add hover for smithy-build.json (#202) The language server now provides hover content for smithy-build.json. Specifically, when hovering on json keys (and only the keys), hover content will include the documentation for that property from https://smithy.io/2.0/guides/smithy-build-json.html, in addition to a link to the corresponding section in the docs page where applicable. This uses the `build.smithy` builtin model. I've added documentation traits to all relevant members, including externalDocumentation for the links. I didn't do the same for .smithy-project.json, as I'll need to think through properly documenting that and plan on doing so later. This commit will have a few followups based on things I've learned from this commit: 1. I should add support for externalDocumentation to IDL hover. Can probably share a lot of the code. 2. I can probably remove the serialization of validation events into IDL hover content. I'm not sure why exactly I put them there, maybe it was an artifact from the history of the language server, but I think that diagnostics are already displayed with hover content, so doing the extra work is unnecessary. --- .../smithy/lsp/SmithyLanguageServer.java | 22 ++-- .../lsp/language/BuildHoverHandler.java | 91 ++++++++++++++++ .../amazon/smithy/lsp/language/build.smithy | 89 +++++++++++++++ .../lsp/language/BuildHoverHandlerTest.java | 101 ++++++++++++++++++ 4 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index d5ce4661..723037a4 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -97,6 +97,7 @@ import software.amazon.smithy.lsp.ext.ServerStatus; import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; import software.amazon.smithy.lsp.language.BuildCompletionHandler; +import software.amazon.smithy.lsp.language.BuildHoverHandler; import software.amazon.smithy.lsp.language.CompletionHandler; import software.amazon.smithy.lsp.language.DefinitionHandler; import software.amazon.smithy.lsp.language.DocumentSymbolHandler; @@ -637,15 +638,20 @@ public CompletableFuture hover(HoverParams params) { return completedFuture(null); } - if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { - return completedFuture(null); - } - - Project project = projectAndFile.project(); + return switch (projectAndFile.file()) { + case IdlFile idlFile -> { + Project project = projectAndFile.project(); - // TODO: Abstract away passing minimum severity - var handler = new HoverHandler(project, smithyFile, this.serverOptions.getMinimumSeverity()); - return CompletableFuture.supplyAsync(() -> handler.handle(params)); + // TODO: Abstract away passing minimum severity + var handler = new HoverHandler(project, idlFile, this.serverOptions.getMinimumSeverity()); + yield CompletableFuture.supplyAsync(() -> handler.handle(params)); + } + case BuildFile buildFile -> { + var handler = new BuildHoverHandler(buildFile); + yield CompletableFuture.supplyAsync(() -> handler.handle(params)); + } + default -> completedFuture(null); + }; } @Override diff --git a/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java new file mode 100644 index 00000000..0cd999bd --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java @@ -0,0 +1,91 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Optional; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.DocumentationTrait; +import software.amazon.smithy.model.traits.ExternalDocumentationTrait; + +/** + * Handles hover requests for build files. + */ +public final class BuildHoverHandler { + private final BuildFile buildFile; + + public BuildHoverHandler(BuildFile buildFile) { + this.buildFile = buildFile; + } + + /** + * @param params The request params + * @return The hover content + */ + public Hover handle(HoverParams params) { + Shape buildFileShape = Builtins.getBuildFileShape(buildFile.type()); + + if (buildFileShape == null) { + return null; + } + + Position position = params.getPosition(); + NodeCursor cursor = NodeCursor.create( + buildFile.getParse().value(), + buildFile.document().indexOfPosition(position) + ); + NodeSearch.Result searchResult = NodeSearch.search(cursor, Builtins.MODEL, buildFileShape); + + return getMemberShape(searchResult) + .map(BuildHoverHandler::withShapeDocs) + .orElse(null); + } + + private static Optional getMemberShape(NodeSearch.Result searchResult) { + // We only provide hover on properties (json keys). Otherwise, the hover content could + // be noisy if your cursor is just sitting somewhere. + if (searchResult instanceof NodeSearch.Result.ObjectKey objectKey) { + if (!objectKey.containerShape().isMapShape()) { + return objectKey.containerShape().getMember(objectKey.key().name()); + } + } + + return Optional.empty(); + } + + private static Hover withShapeDocs(MemberShape memberShape) { + StringBuilder builder = new StringBuilder(); + + var docs = memberShape.getTrait(DocumentationTrait.class).orElse(null); + var externalDocs = memberShape.getTrait(ExternalDocumentationTrait.class).orElse(null); + + if (docs != null) { + builder.append(docs.getValue()); + } + + if (externalDocs != null) { + if (docs != null) { + // Add some extra space between regular docs and external + builder.append(System.lineSeparator()).append(System.lineSeparator()); + } + + externalDocs.getUrls() + .forEach((name, url) -> builder.append(String.format("[%s](%s)%n", name, url))); + } + + if (builder.isEmpty()) { + return null; + } + + return new Hover(new MarkupContent("markdown", builder.toString())); + } +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy index c1503a84..eb01f223 100644 --- a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy +++ b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy @@ -21,15 +21,55 @@ structure ProjectDependency { } structure SmithyBuildJson { + /// Defines the version of smithy-build. Set to 1.0. @required version: SmithyBuildVersion + /// The location where projections are written. Each projection will create a + /// subdirectory named after the projection, and the artifacts from the projection, + /// including a model.json file, will be placed in the directory. outputDirectory: String + + /// Provides a list of relative files or directories that contain the models + /// that are considered the source models of the build. When a directory is + /// encountered, all files in the entire directory tree are added as sources. + /// Sources are relative to the configuration file. sources: Strings + + /// Provides a list of model files and directories to load when validating and + /// building the model. Imports are a local dependency: they are not considered + /// part of model package being built, but are required to build the model package. + /// Models added through imports are not present in the output of the built-in + /// sources plugin. + /// When a directory is encountered, all files in the entire directory tree are + /// imported. Imports defined at the top-level are used in every projection. + /// Imports are relative to the configuration file. imports: Strings + + /// A map of projection names to projection configurations. + @externalDocumentation( + "Projections Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#projections" + ) projections: Projections + + /// Defines the plugins to apply to the model when building every projection. + /// Plugins are a mapping of plugin IDs to plugin-specific configuration objects. + @externalDocumentation( + "Plugins Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#plugins" + ) plugins: Plugins + + /// If a plugin can't be found, Smithy will by default fail the build. This setting + /// can be set to true to allow the build to progress even if a plugin can't be + /// found on the classpath. ignoreMissingPlugins: Boolean + + /// Defines Java Maven dependencies needed to build the model. Dependencies are + /// used to bring in model imports, build plugins, validators, transforms, and + /// other extensions. + @externalDocumentation( + "Maven Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-configuration" + ) maven: Maven } @@ -42,9 +82,35 @@ map Projections { } structure Projection { + /// Defines the projection as a placeholder that other projections apply. Smithy + /// will not build artifacts for abstract projections. Abstract projections must + /// not define imports or plugins. abstract: Boolean + + /// Provides a list of relative imports to include when building this specific + /// projection (in addition to any imports defined at the top-level). When a + /// directory is encountered, all files in the directory tree are imported. + /// Note: imports are relative to the configuration file. imports: Strings + + /// Defines the transformations to apply to the projection. Transformations are + /// used to remove shapes, remove traits, modify trait contents, and any other + /// kind of transformation necessary for the projection. Transforms are applied + /// in the order defined. + @externalDocumentation( + "Transforms Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#transforms" + ) transforms: Transforms + + /// Defines the plugins to apply to the model when building this projection. + /// plugins is a mapping of a plugin IDs to plugin-specific configuration objects. + /// smithy-build will attempt to resolve plugin names using Java SPI to locate + /// an instance of software.amazon.smithy.build.SmithyBuildPlugin that returns a + /// matching name when calling getName. smithy-build will emit a warning when a + /// plugin cannot be resolved. + @externalDocumentation( + "Plugins Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#plugins" + ) plugins: Plugins } @@ -58,9 +124,11 @@ list Transforms { } structure Transform { + /// The required name of the transform. @required name: String + /// A structure that contains configuration key-value pairs. args: TransformArgs } @@ -68,7 +136,13 @@ structure TransformArgs { } structure Maven { + /// A list of Maven dependency coordinates in the form of groupId:artifactId:version. + /// The Smithy CLI will search each registered Maven repository for the dependency. dependencies: Strings + + /// A list of Maven repositories to search for dependencies. If no repositories + /// are defined and the SMITHY_MAVEN_REPOS environment variable is not defined, + /// then this value defaults to Maven Central. repositories: MavenRepositories } @@ -77,11 +151,26 @@ list MavenRepositories { } structure MavenRepository { + /// The URL of the repository (for example, https://repo.maven.apache.org/maven2). @required url: String + /// HTTP basic or digest credentials to use with the repository. Credentials are + /// provided in the form of "username:password". + /// + /// **WARNING** Credentials SHOULD NOT be defined statically in a smithy-build.json + /// file. Instead, use environment variables to keep credentials out of source control. httpCredentials: String + + /// The URL of the proxy to configure for this repository (for example, + /// http://proxy.maven.apache.org:8080). proxyHost: String + + /// HTTP credentials to use with the proxy for the repository. Credentials are + /// provided in the form of "username:password". + /// + /// **WARNING** Credentials SHOULD NOT be defined statically in a smithy-build.json + /// file. Instead, use environment variables to keep credentials out of source control. proxyCredentials: String } diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java new file mode 100644 index 00000000..5d792d88 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java @@ -0,0 +1,101 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.BuildFileType; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; + +public class BuildHoverHandlerTest { + @Test + public void includesDocs() { + var twp = TextWithPositions.from(""" + { + %"version": "1.0" + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("version"))); + } + + @Test + public void includesExternalDocs() { + var twp = TextWithPositions.from(""" + { + %"projections": {} + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("https://smithy.io"))); + } + + @Test + public void nested() { + var twp = TextWithPositions.from(""" + { + "maven": { + %"dependencies": [] + } + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("coordinates"))); + } + + @Test + public void noHoverForValues() { + var twp = TextWithPositions.from(""" + %{ + "version": %"1.0", + "sources": %[] + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, empty()); + } + + private static List getHovers(BuildFileType buildFileType, TextWithPositions twp) { + var workspace = TestWorkspace.emptyWithNoConfig("test"); + workspace.addModel(buildFileType.filename(), twp.text()); + + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri(buildFileType.filename()); + BuildFile buildFile = (BuildFile) project.getProjectFile(uri); + + List hover = new ArrayList<>(); + BuildHoverHandler handler = new BuildHoverHandler(buildFile); + for (Position position : twp.positions()) { + HoverParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildHover(); + Hover result = handler.handle(params); + if (result != null) { + hover.add(result.getContents().getRight().getValue()); + } + } + + return hover; + } +} From 4df11b4b59fb54205016a2226cf98cb3eec57135 Mon Sep 17 00:00:00 2001 From: Joe Wu Date: Tue, 25 Feb 2025 09:29:10 -0800 Subject: [PATCH 24/43] Added Inlay Hint Feature for LSP (#200) * Added Inlay Hint Feature for LSP * Fixed style check errors. * Fixed style check errors. * Fixed style check errors. * Added LspMatcher for inlay hint test, modified inlayhint generation process * Fixed style check errors. * Modified redundant if statement. --- .../smithy/lsp/SmithyLanguageServer.java | 24 ++ .../smithy/lsp/language/InlayHintHandler.java | 111 +++++ .../amazon/smithy/lsp/LspMatchers.java | 26 ++ .../lsp/language/InlayHintHandlerTest.java | 407 ++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 723037a4..bb701d9e 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -58,6 +58,8 @@ import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.InlayHintParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; import org.eclipse.lsp4j.ProgressParams; @@ -103,6 +105,7 @@ import software.amazon.smithy.lsp.language.DocumentSymbolHandler; import software.amazon.smithy.lsp.language.FoldingRangeHandler; import software.amazon.smithy.lsp.language.HoverHandler; +import software.amazon.smithy.lsp.language.InlayHintHandler; import software.amazon.smithy.lsp.project.BuildFile; import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; @@ -133,6 +136,7 @@ public class SmithyLanguageServer implements capabilities.setDocumentFormattingProvider(true); capabilities.setDocumentSymbolProvider(true); capabilities.setFoldingRangeProvider(true); + capabilities.setInlayHintProvider(true); WorkspaceFoldersOptions workspaceFoldersOptions = new WorkspaceFoldersOptions(); workspaceFoldersOptions.setSupported(true); @@ -606,6 +610,26 @@ public CompletableFuture> foldingRange(FoldingRangeRequestPar return CompletableFuture.supplyAsync(handler::handle); } + @Override + public CompletableFuture> inlayHint(InlayHintParams params) { + LOGGER.finest("InlayHint"); + + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "inlay hint"); + return completedFuture(Collections.emptyList()); + } + + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(List.of()); + } + + List statements = idlFile.getParse().statements(); + var handler = new InlayHintHandler(idlFile.document(), statements, params.getRange()); + return CompletableFuture.supplyAsync(handler::handle); + } + @Override public CompletableFuture, List>> definition(DefinitionParams params) { diff --git a/src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java b/src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java new file mode 100644 index 00000000..4610831c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java @@ -0,0 +1,111 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; + +public record InlayHintHandler(Document document, + List statements, + Range hintRange) { + + private static final String OPERATION_TYPE = "operation"; + private static final String INPUT_TYPE = "input"; + private static final String OUTPUT_TYPE = "output"; + private static final String DEFAULT_INPUT_SUFFIX = "Input"; + private static final String DEFAULT_OUTPUT_SUFFIX = "Output"; + private static final String OPERATION_INPUT_SUFFIX = "operationInputSuffix"; + private static final String OPERATION_OUTPUT_SUFFIX = "operationOutputSuffix"; + + /** + * Main public handle function in the handler class. + * + * @return A list of Inlay hints + */ + public List handle() { + return processInlayHints(); + } + + private IOSuffix getIOSuffix(ListIterator iterator) { + // Default value for IO Suffix + String inputSuffix = DEFAULT_INPUT_SUFFIX; + String outputSuffix = DEFAULT_OUTPUT_SUFFIX; + + while (iterator.hasNext()) { + var statement = iterator.next(); + // Pattern match used for the following two statement to cast them to ideal Statement or Node type. + if (statement instanceof Syntax.Statement.Control control) { + if (control.value() instanceof Syntax.Node.Str str) { + String key = control.key().stringValue(); + String suffix = str.stringValue(); + if (key.equals(OPERATION_INPUT_SUFFIX)) { + inputSuffix = suffix; + } else if (key.equals(OPERATION_OUTPUT_SUFFIX)) { + outputSuffix = suffix; + } + } + } else if (statement instanceof Syntax.Statement.ShapeDef) { + // Customized suffix can only appear at the head of file. Once hit the shapedef statement, we can break. + iterator.previous(); + break; + } + } + return new IOSuffix(inputSuffix, outputSuffix); + } + + private boolean coveredByRange(Syntax.Statement statement, int rangeStart, int rangeEnd) { + // Check if the statement is totally or partially covered by range. + return statement.end() >= rangeStart && statement.start() <= rangeEnd; + } + + private List processInlayHints() { + List inlayHints = new ArrayList<>(); + ListIterator iterator = statements.listIterator(); + IOSuffix ioSuffix = getIOSuffix(iterator); + // Convert the window range into document character index. + int rangeStartIndex = document.indexOfPosition(hintRange.getStart()); + int rangeEndIndex = document.indexOfPosition(hintRange.getEnd()); + String lastOperationName = ""; + while (iterator.hasNext()) { + var statement = iterator.next(); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef + && shapeDef.shapeType().stringValue().equals(OPERATION_TYPE)) { + lastOperationName = shapeDef.shapeName().stringValue(); + continue; + } + if (statement instanceof Syntax.Statement.InlineMemberDef inlineMemberDef) { + if (!coveredByRange(statement, rangeStartIndex, rangeEndIndex)) { + continue; + } + + String inlayHintLabel = switch (inlineMemberDef.name().stringValue()) { + case INPUT_TYPE -> lastOperationName + ioSuffix.inputSuffix(); + case OUTPUT_TYPE -> lastOperationName + ioSuffix.outputSuffix(); + default -> null; + }; + + if (inlayHintLabel == null) { + continue; + } + + Position position = document.positionAtIndex(inlineMemberDef.end()); + InlayHint inlayHint = new InlayHint(position, Either.forLeft(inlayHintLabel)); + inlayHints.add(inlayHint); + } + } + return inlayHints; + } + + private record IOSuffix(String inputSuffix, String outputSuffix) { + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index e9452086..2c4b889a 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -8,6 +8,8 @@ import java.util.Collection; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; import org.hamcrest.CustomTypeSafeMatcher; @@ -131,4 +133,28 @@ public void describeMismatchSafely(Diagnostic event, Description description) { } }; } + + public static Matcher inlayHint(String label, Position position) { + return new CustomTypeSafeMatcher<>("Inlay Hint label " + label + " position " + + position.getLine() + "," + position.getCharacter()) { + @Override + protected boolean matchesSafely(InlayHint item) { + return item.getLabel().getLeft().equals(label) && position.equals(item.getPosition()); + } + @Override + public void describeMismatchSafely(InlayHint item, Description description) { + if (!item.getLabel().getLeft().equals(label)) { + description.appendText("Expected inlay hint item with label '" + + label + "' but was '" + item.getLabel().getLeft() + "'"); + } + if (!position.equals(item.getPosition())) { + description.appendText("Expected inlay hint item with position '" + + position.getLine() + "," + position.getCharacter() + + "' but was '" + item.getPosition().getLine() + + "," + item.getPosition().getCharacter()+ "'"); + } + + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java new file mode 100644 index 00000000..7d2d3d21 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java @@ -0,0 +1,407 @@ +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.LspMatchers; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectTest; + + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class InlayHintHandlerTest { + @Test + public void inlayHintForInlineOperationWithCustomizedSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + $operationInputSuffix: "Request" + $operationOutputSuffix: "Response" + + namespace smithy.example + + operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(2)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserRequest", positions[0]), + LspMatchers.inlayHint("GetUserResponse", positions[1]) + )); + + } + + @Test + public void inlayHintForInlineOperationWithoutCustomizedSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + + operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(2)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[0]), + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + @Test + public void inlayHintForInputInlineOperation() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + + operation GetUser { + input :=% { + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[0]) + )); + } + + @Test + public void inlayHintForInputInlineOperationWithMismatchedSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + $operationOutputSuffix: "Response" + + operation GetUser { + input :=% { + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[0]) + )); + } + + @Test + public void inlayHintForOperationWithoutInlineMemberDef() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + $operationOutputSuffix: "Response" + + operation GetUser { + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[0]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(0)); + } + + @Test + public void inlayHintForInlineOperationOffRangeSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + $operationInputSuffix: "Request" + $operationOutputSuffix: "Response" + + namespace smithy.example + + %operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[3]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(2)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserRequest", positions[1]), + LspMatchers.inlayHint("GetUserResponse", positions[2]) + )); + } + + @Test + public void inlayHintForInlineOperationPartiallyInRange() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + $operationInputSuffix: "Request" + $operationOutputSuffix: "Response" + + namespace smithy.example + + %operation GetUser { + input :=% { + userId: String + } + % + output := { + username: String + userId: String + } + } + """); + var positions = model.positions(); + Position startPosition =positions[0]; + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserRequest", positions[1]) + )); + } + + @Test + public void inlayHintForInlineOperationNotInRange() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + operation GetUser { + input := { + userId: String + } + + % output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition =positions[0]; + Position endPosition =positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + @Test + public void inlayHintForInlineOperationInOneLine() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + operation GetUser { + input := { + userId: String + } + + % output :=% { + username: String + userId: String + } + } + + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + @Test + public void inlayHintForMultipleInlineOperations() { + TextWithPositions model = TextWithPositions.from(""" + %$version: "2" + + operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + + operation GetAddress { + input :=% { + userId: String + } + + output :=% { + address: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[5]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(4)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[1]), + LspMatchers.inlayHint("GetUserOutput", positions[2]), + LspMatchers.inlayHint("GetAddressInput", positions[3]), + LspMatchers.inlayHint("GetAddressOutput", positions[4]) + )); + } + + @Test + public void inlayHintForMultipleInlineOperationWithLimitedRange() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + operation GetUser { + input := { + userId: String + } + + output := { + username: String + userId: String + } + } + structure foo{ + id: String + } + + %operation GetAddress { + input :=% { + userId: String + } + + output := { + address: String + } + } + + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetAddressInput", positions[1]) + )); + } + + @Test + public void inlayHintsForInlineOperationWithMixinAndFor() { + TextWithPositions model = TextWithPositions.from(""" + %$version: "2" + + operation GetUser { + output :=% for foo with [bar] { + @required + check: Boolean + } + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertEquals("GetUserOutput", hints.get(0).getLabel().getLeft()); + assertEquals(positions[1], hints.get(0).getPosition()); + + } + + @Test + public void inlayHintsForInvalidInlineOperation() { + TextWithPositions model = TextWithPositions.from(""" + %$version: "2" + + operation GetUser { + output :=% test + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + private static List getInlayHints(String text, Position startPosition, Position endPosition) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + var handler = new InlayHintHandler(idlFile.document(), + idlFile.getParse().statements(), + new Range(startPosition, endPosition)); + return handler.handle(); + } +} From 790f870a3a54b545213cefd0a8afa8f2d4fab95c Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 27 Feb 2025 14:08:04 -0500 Subject: [PATCH 25/43] Add missing hover docs (#204) This adds hover info for a bunch of things that I didn't get around to in the initial hover upgrade, including: All shape types Non shape type keywords, metadata, namespace, use, apply, with, and for Member names in service, resource, and operation shapes Links to Smithy docs site for all these "builtins" Documentation traits on user-defined members (before it just wasn't rendered) I had to add new builtin model for the non-shape keywords, but reused ShapeMemberTargets for shape type keyword hover. I also had to add a way to check if the cursor was inside the keyword of a statement, which simplifies IdlPosition a bit too. The Smithy docs links use externalDocumentation, like the smithy-build.json model, and I also added a way to include docs links to all members of a builtin shape, because there's some things which don't have a great link for themselves specifically, but it would still be nice to allow navigating to some docs page that could be useful. This works by adding the externalDocumentation trait to the root shape in the builtins model, and having the hover implementation add any external docs from the container shape when constructing hover for a builtin. So, for example, hovering over "version": "1.0" in smithy-build.json now also includes a link to the smithy-build.json page. --- .../lsp/language/BuildHoverHandler.java | 32 +-- .../amazon/smithy/lsp/language/Builtins.java | 3 + .../smithy/lsp/language/HoverHandler.java | 106 +++++++++- .../smithy/lsp/language/IdlPosition.java | 11 +- .../amazon/smithy/lsp/syntax/Syntax.java | 45 ++++ .../amazon/smithy/lsp/language/build.smithy | 16 ++ .../smithy/lsp/language/keywords.smithy | 49 +++++ .../amazon/smithy/lsp/language/members.smithy | 189 ++++++++++++++++- .../smithy/lsp/language/metadata.smithy | 65 +++++- .../lsp/language/BuildHoverHandlerTest.java | 12 ++ .../smithy/lsp/language/HoverHandlerTest.java | 193 +++++++++++++++++- 11 files changed, 673 insertions(+), 48 deletions(-) create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy diff --git a/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java index 0cd999bd..94e01cdd 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/BuildHoverHandler.java @@ -8,14 +8,11 @@ import java.util.Optional; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.MarkupContent; import org.eclipse.lsp4j.Position; import software.amazon.smithy.lsp.project.BuildFile; import software.amazon.smithy.lsp.syntax.NodeCursor; import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.traits.DocumentationTrait; -import software.amazon.smithy.model.traits.ExternalDocumentationTrait; /** * Handles hover requests for build files. @@ -46,7 +43,7 @@ public Hover handle(HoverParams params) { NodeSearch.Result searchResult = NodeSearch.search(cursor, Builtins.MODEL, buildFileShape); return getMemberShape(searchResult) - .map(BuildHoverHandler::withShapeDocs) + .flatMap(HoverHandler::withBuiltinShapeDocs) .orElse(null); } @@ -61,31 +58,4 @@ private static Optional getMemberShape(NodeSearch.Result searchResu return Optional.empty(); } - - private static Hover withShapeDocs(MemberShape memberShape) { - StringBuilder builder = new StringBuilder(); - - var docs = memberShape.getTrait(DocumentationTrait.class).orElse(null); - var externalDocs = memberShape.getTrait(ExternalDocumentationTrait.class).orElse(null); - - if (docs != null) { - builder.append(docs.getValue()); - } - - if (externalDocs != null) { - if (docs != null) { - // Add some extra space between regular docs and external - builder.append(System.lineSeparator()).append(System.lineSeparator()); - } - - externalDocs.getUrls() - .forEach((name, url) -> builder.append(String.format("[%s](%s)%n", name, url))); - } - - if (builder.isEmpty()) { - return null; - } - - return new Hover(new MarkupContent("markdown", builder.toString())); - } } diff --git a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java index 924d83dc..c3f11046 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java +++ b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java @@ -37,6 +37,7 @@ final class Builtins { .addImport(Builtins.class.getResource("metadata.smithy")) .addImport(Builtins.class.getResource("members.smithy")) .addImport(Builtins.class.getResource("build.smithy")) + .addImport(Builtins.class.getResource("keywords.smithy")) .assemble() .unwrap(); @@ -57,6 +58,8 @@ final class Builtins { static final Shape SMITHY_PROJECT_JSON = MODEL.expectShape(id("SmithyProjectJson")); + static final Shape NON_SHAPE_KEYWORDS = MODEL.expectShape(id("NonShapeKeywords")); + static final Map VALIDATOR_CONFIG_MAPPING = VALIDATORS.members().stream() .collect(Collectors.toMap( MemberShape::getMemberName, diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java index 79ba7073..55e3aaad 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -7,6 +7,7 @@ import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -26,9 +27,10 @@ import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; +import software.amazon.smithy.model.shapes.StructureShape; import software.amazon.smithy.model.traits.DocumentationTrait; +import software.amazon.smithy.model.traits.ExternalDocumentationTrait; import software.amazon.smithy.model.traits.IdRefTrait; -import software.amazon.smithy.model.traits.StringTrait; import software.amazon.smithy.model.validation.Severity; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.model.validation.ValidationEvent; @@ -76,18 +78,28 @@ public Hover handle(HoverParams params) { return switch (idlPosition) { case IdlPosition.ControlKey ignored -> Builtins.CONTROL.getMember(id.copyIdValueForElidedMember()) - .map(HoverHandler::withShapeDocs) + .flatMap(HoverHandler::withBuiltinShapeDocs) .orElse(EMPTY); case IdlPosition.MetadataKey ignored -> Builtins.METADATA.getMember(id.copyIdValue()) - .map(HoverHandler::withShapeDocs) + .flatMap(HoverHandler::withBuiltinShapeDocs) .orElse(EMPTY); case IdlPosition.MetadataValue metadataValue -> takeShapeReference( ShapeSearch.searchMetadataValue(metadataValue)) - .map(HoverHandler::withShapeDocs) + .flatMap(HoverHandler::withBuiltinShapeDocs) .orElse(EMPTY); + case IdlPosition.StatementKeyword ignored -> Builtins.SHAPE_MEMBER_TARGETS.getMember(id.copyIdValue()) + .or(() -> Builtins.NON_SHAPE_KEYWORDS.getMember(id.copyIdValue())) + .flatMap(HoverHandler::withBuiltinShapeDocs) + .orElse(EMPTY); + + case IdlPosition.MemberName memberName -> getBuiltinMember(memberName) + .flatMap(HoverHandler::withBuiltinShapeDocs) + // Fall back to user model hover, since we didn't find a matching builtin shape with docs + .orElseGet(() -> modelSensitiveHover(id, memberName)); + case null -> EMPTY; default -> modelSensitiveHover(id, idlPosition); @@ -106,6 +118,21 @@ private static Optional takeShapeReference(NodeSearch.Result re }; } + private static Optional getBuiltinMember(IdlPosition.MemberName memberName) { + var shapeDef = memberName.view().nearestShapeDefBefore(); + if (shapeDef == null) { + return Optional.empty(); + } + + String shapeType = shapeDef.shapeType().stringValue(); + StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType); + if (shapeMembersDef == null) { + return Optional.empty(); + } + + return shapeMembersDef.getMember(memberName.name()); + } + private Hover modelSensitiveHover(DocumentId id, IdlPosition idlPosition) { ValidatedResult validatedModel = project.modelResult(); if (validatedModel.getResult().isEmpty()) { @@ -183,11 +210,57 @@ private String serializeValidationEvents(List events, Shape sha return serialized.toString(); } - private static Hover withShapeDocs(Shape shape) { - return shape.getTrait(DocumentationTrait.class) - .map(StringTrait::getValue) - .map(HoverHandler::withMarkupContents) - .orElse(EMPTY); + // Note: This isn't used for user-defined shapes because we include docs + // in the serialized hover content. + static Optional withBuiltinShapeDocs(Shape shape) { + StringBuilder builder = new StringBuilder(); + + var builtinShapeDocs = BuiltinShapeDocs.forShape(shape); + + if (!builtinShapeDocs.shapeDocs.isEmpty()) { + builder.append(builtinShapeDocs.shapeDocs); + + if (!builtinShapeDocs.externalDocs.isEmpty()) { + // Space out regular docs and external docs so they're easier to read. + builder.append(System.lineSeparator()).append(System.lineSeparator()); + } + } + + builtinShapeDocs.externalDocs + .forEach((url, doc) -> builder.append(String.format("[%s](%s)%n", url, doc))); + + if (builder.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(new Hover(new MarkupContent("markdown", builder.toString()))); + } + + private record BuiltinShapeDocs(String shapeDocs, Map externalDocs) { + private static BuiltinShapeDocs forShape(Shape shape) { + var shapeDocs = shape.getTrait(DocumentationTrait.class) + .map(DocumentationTrait::getValue) + .orElse(""); + + Map externalDocs = new HashMap<>(); + + shape.getTrait(ExternalDocumentationTrait.class) + .map(ExternalDocumentationTrait::getUrls) + .ifPresent(externalDocs::putAll); + + // The builtins model defines some external docs on root shapes, which are meant to be + // included in the hover content for all members so we can always provide a link to + // Smithy's docs, even if the member itself doesn't have a specific link that would + // make sense. + shape.asMemberShape() + .map(MemberShape::getContainer) + .flatMap(Builtins.MODEL::getShape) + .flatMap(container -> container.getTrait(ExternalDocumentationTrait.class)) + .map(ExternalDocumentationTrait::getUrls) + .ifPresent(externalDocs::putAll); + + return new BuiltinShapeDocs(shapeDocs, externalDocs); + } } private static Hover withMarkupContents(String text) { @@ -202,6 +275,10 @@ private static String serializeMember(MemberShape memberShape) { .append(System.lineSeparator()) .append(System.lineSeparator()); + memberShape.getTrait(DocumentationTrait.class) + .map(DocumentationTrait::getValue) + .ifPresent(docs -> addMemberDocs(contents, docs)); + for (var trait : memberShape.getAllTraits().values()) { if (trait.toShapeId().equals(DocumentationTrait.ID)) { continue; @@ -222,6 +299,17 @@ private static String serializeMember(MemberShape memberShape) { return contents.toString(); } + private static void addMemberDocs(StringBuilder builder, String docs) { + builder.append("/// ") + // Replace newline literals in the doc string with actual newlines, and /// so we can render + // an IDL doc comment. + .append(docs.replaceAll( + Matcher.quoteReplacement(System.lineSeparator()), System.lineSeparator() + "/// ") + .trim()) + .append(System.lineSeparator()); + + } + private static String serializeShape(Model model, Shape shape) { SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder() .metadataFilter(key -> false) diff --git a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java index a13a4697..00b5f7e8 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java +++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java @@ -70,13 +70,15 @@ record Unknown(StatementView view) implements IdlPosition {} static IdlPosition of(StatementView view) { int documentIndex = view.documentIndex(); + + if (view.getStatement().isInKeyword(documentIndex)) { + return new StatementKeyword(view); + } + return switch (view.getStatement()) { case Syntax.Statement.Incomplete incomplete when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); - case Syntax.Statement.ShapeDef shapeDef - when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); - case Syntax.Statement.Apply apply when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(view); @@ -110,6 +112,9 @@ static IdlPosition of(StatementView view) { case Syntax.Statement.TraitApplication t when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue(view, t); + case Syntax.Statement.InlineMemberDef m + when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view, m.name().stringValue()); + case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember(view); case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(view); diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java index 18182399..f83d052d 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -367,6 +367,16 @@ public final Type type() { }; } + /** + * @param pos The character offset in the file to check + * @return Whether {@code pos} is within the keyword at the start + * of this statement. Always returns {@code false} if this + * statement doesn't start with a keyword. + */ + public boolean isInKeyword(int pos) { + return false; + } + public enum Type { Incomplete, Control, @@ -442,6 +452,11 @@ public Ident key() { public Node value() { return value; } + + @Override + public boolean isInKeyword(int pos) { + return pos >= start && pos < start + "metadata".length(); + } } /** @@ -457,6 +472,11 @@ public static final class Namespace extends Statement { public Ident namespace() { return namespace; } + + @Override + public boolean isInKeyword(int pos) { + return pos >= start && pos < start + "namespace".length(); + } } /** @@ -472,6 +492,11 @@ public static final class Use extends Statement { public Ident use() { return use; } + + @Override + public boolean isInKeyword(int pos) { + return pos >= start && pos < start + "use".length(); + } } /** @@ -488,6 +513,11 @@ public static final class Apply extends Statement { public Ident id() { return id; } + + @Override + public boolean isInKeyword(int pos) { + return pos >= start && pos < start + "apply".length(); + } } /** @@ -509,6 +539,11 @@ public Ident shapeType() { public Ident shapeName() { return shapeName; } + + @Override + public boolean isInKeyword(int pos) { + return shapeType.isIn(pos); + } } /** @@ -525,6 +560,11 @@ public static final class ForResource extends Statement { public Ident resource() { return resource; } + + @Override + public boolean isInKeyword(int pos) { + return pos >= start && pos < start + "for".length(); + } } /** @@ -538,6 +578,11 @@ public static final class Mixins extends Statement { public List mixins() { return mixins; } + + @Override + public boolean isInKeyword(int pos) { + return pos >= start && pos < start + "with".length(); + } } /** diff --git a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy index eb01f223..d16f6e09 100644 --- a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy +++ b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy @@ -20,6 +20,7 @@ structure ProjectDependency { path: String } +@externalDocumentation("Smithy Build Reference": "https://smithy.io/2.0/guides/smithy-build-json.html") structure SmithyBuildJson { /// Defines the version of smithy-build. Set to 1.0. @required @@ -81,6 +82,9 @@ map Projections { value: Projection } +@externalDocumentation( + "Projections Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#projections" +) structure Projection { /// Defines the projection as a placeholder that other projections apply. Smithy /// will not build artifacts for abstract projections. Abstract projections must @@ -123,6 +127,9 @@ list Transforms { member: Transform } +@externalDocumentation( + "Transforms Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#transforms" +) structure Transform { /// The required name of the transform. @required @@ -135,6 +142,9 @@ structure Transform { structure TransformArgs { } +@externalDocumentation( + "Maven Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-configuration" +) structure Maven { /// A list of Maven dependency coordinates in the form of groupId:artifactId:version. /// The Smithy CLI will search each registered Maven repository for the dependency. @@ -143,6 +153,9 @@ structure Maven { /// A list of Maven repositories to search for dependencies. If no repositories /// are defined and the SMITHY_MAVEN_REPOS environment variable is not defined, /// then this value defaults to Maven Central. + @externalDocumentation( + "Maven Repositories Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-repositories" + ) repositories: MavenRepositories } @@ -150,6 +163,9 @@ list MavenRepositories { member: MavenRepository } +@externalDocumentation( + "Maven Repositories Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-repositories" +) structure MavenRepository { /// The URL of the repository (for example, https://repo.maven.apache.org/maven2). @required diff --git a/src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy b/src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy new file mode 100644 index 00000000..5705629c --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy @@ -0,0 +1,49 @@ +$version: "2.0" + +namespace smithy.lang.server + +union NonShapeKeywords { + /// Metadata is a schema-less extensibility mechanism used to associate metadata to + /// an entire model. + @externalDocumentation( + "Metadata Reference": "https://smithy.io/2.0/spec/model.html#model-metadata" + ) + metadata: Unit + + /// A namespace is a mechanism for logically grouping shapes in a way that makes them + /// reusable alongside other models without naming conflicts. + @externalDocumentation( + "Namespace Statement Reference": "https://smithy.io/2.0/spec/idl.html#namespaces" + "Shape ID Reference": "https://smithy.io/2.0/spec/model.html#shape-id" + ) + namespace: Unit + + /// The use section of the IDL is used to import shapes into the current namespace so + /// that they can be referred to using a relative shape ID. + @externalDocumentation( + "Use Statement Reference": "https://smithy.io/2.0/spec/idl.html#referring-to-shapes" + ) + use: Unit + + /// Applies a trait to a shape outside of the shape's definition + @externalDocumentation( + "Apply Statement Reference": "https://smithy.io/2.0/spec/idl.html#apply-statement" + "Applying Traits Reference": "https://smithy.io/2.0/spec/model.html#applying-traits" + ) + apply: Unit + + /// Allows referencing a resource's identifiers and properties in members to create + /// resource bindings using target elision syntax. + @externalDocumentation( + "Identifier Bindings Reference": "https://smithy.io/2.0/spec/service-types.html#binding-identifiers-to-operations" + "Property Bindings Reference": "https://smithy.io/2.0/spec/service-types.html#binding-members-to-properties" + "Target Elision Syntax Reference": "https://smithy.io/2.0/spec/idl.html#idl-target-elision" + ) + for: Unit + + /// Mixes in a list of mixins to a shape. + @externalDocumentation( + "Mixins Reference": "https://smithy.io/2.0/spec/idl.html#mixins" + ) + with: Unit +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/members.smithy b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy index 42b50fe8..ab2304f7 100644 --- a/src/main/resources/software/amazon/smithy/lsp/language/members.smithy +++ b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy @@ -2,19 +2,128 @@ $version: "2.0" namespace smithy.lang.server -structure ShapeMemberTargets { +union ShapeMemberTargets { + /// A service is the entry point of an API that aggregates resources and operations together. + @externalDocumentation("Service Reference": "https://smithy.io/2.0/spec/service-types.html#service") service: ServiceShape + + /// The operation type represents the input, output, and possible errors of an API operation. + @externalDocumentation("Operation Reference": "https://smithy.io/2.0/spec/service-types.html#operation") operation: OperationShape + + /// Smithy defines a resource as an entity with an identity that has a set of operations. + @externalDocumentation("Resource Reference": "https://smithy.io/2.0/spec/service-types.html#resource") resource: ResourceShape + + /// The list type represents an ordered homogeneous collection of values. + @externalDocumentation("List Reference": "https://smithy.io/2.0/spec/aggregate-types.html#list") list: ListShape + + /// The map type represents a map data structure that maps string keys to homogeneous values. + @externalDocumentation("Map Reference": "https://smithy.io/2.0/spec/aggregate-types.html#map") map: MapShape + + /// The structure type represents a fixed set of named, unordered, heterogeneous values. + @externalDocumentation("Structure Reference": "https://smithy.io/2.0/spec/aggregate-types.html#structure") + structure: Unit + + /// The union type represents a tagged union data structure that can take on several different, but fixed, types. + @externalDocumentation("Union Reference": "https://smithy.io/2.0/spec/aggregate-types.html#union") + union: Unit + + /// A blob is uninterpreted binary data. + blob: Unit + + /// A boolean is a Boolean value type. + boolean: Unit + + /// A string is a UTF-8 encoded string. + string: Unit + + /// A byte is an 8-bit signed integer ranging from -128 to 127 (inclusive). + byte: Unit + + /// A short is a 16-bit signed integer ranging from -32,768 to 32,767 (inclusive). + short: Unit + + /// An integer is a 32-bit signed integer ranging from -2^31 to (2^31)-1 (inclusive). + integer: Unit + + /// A long is a 64-bit signed integer ranging from -2^63 to (2^63)-1 (inclusive). + long: Unit + + /// A float is a single precision IEEE-754 floating point number. + float: Unit + + /// A double is a double precision IEEE-754 floating point number. + double: Unit + + /// A bigInteger is an arbitrarily large signed integer. + bigInteger: Unit + + /// A bigDecimal is an arbitrary precision signed decimal number. + bigDecimal: Unit + + /// A timestamp represents an instant in time in the proleptic Gregorian calendar, + /// independent of local times or timezones. Timestamps support an allowable date + /// range between midnight January 1, 0001 CE to 23:59:59.999 on December 31, 9999 CE, + /// with a temporal resolution of 1 millisecond. + @externalDocumentation("Timestamp Reference": "https://smithy.io/2.0/spec/simple-types.html#timestamp") + timestamp: Unit + + /// A document represents protocol-agnostic open content that functions as a kind of + /// "any" type. Document types are represented by a JSON-like data model and can + /// contain UTF-8 strings, arbitrary precision numbers, booleans, nulls, a list of + /// these values, and a map of UTF-8 strings to these values. + @externalDocumentation("Document Reference": "https://smithy.io/2.0/spec/simple-types.html#document") + document: Unit + + /// The enum shape is used to represent a fixed set of one or more string values. + @externalDocumentation("Enum Reference": "https://smithy.io/2.0/spec/simple-types.html#enum") + enum: Unit + + /// An intEnum is used to represent an enumerated set of one or more integer values. + @externalDocumentation("IntEnum Reference": "https://smithy.io/2.0/spec/simple-types.html#intenum") + intEnum: Unit } +@externalDocumentation("Service Reference": "https://smithy.io/2.0/spec/service-types.html#service") structure ServiceShape { + /// Defines the optional version of the service. The version can be provided in any + /// format (e.g., 2017-02-11, 2.0, etc). version: String + + /// Binds a set of operation shapes to the service. Each element in the given list + /// MUST be a valid shape ID that targets an operation shape. + @externalDocumentation( + "Operations Reference": "https://smithy.io/2.0/spec/service-types.html#service-operations" + ) operations: Operations + + /// Binds a set of resource shapes to the service. Each element in the given list MUST + /// be a valid shape ID that targets a resource shape. + @externalDocumentation( + "Resources Reference": "https://smithy.io/2.0/spec/service-types.html#service-resources" + ) resources: Resources + + /// Defines a list of common errors that every operation bound within the closure of + /// the service can return. Each provided shape ID MUST target a structure shape that + /// is marked with the error trait. errors: Errors + + /// Disambiguates shape name conflicts in the service closure. Map keys are shape IDs + /// contained in the service, and map values are the disambiguated shape names to use + /// in the context of the service. Each given shape ID MUST reference a shape contained + /// in the closure of the service. Each given map value MUST match the smithy:Identifier + /// production used for shape IDs. Renaming a shape does not give the shape a new shape ID. + /// - No renamed shape name can case-insensitively match any other renamed shape name + /// or the name of a non-renamed shape contained in the service. + /// - Member shapes MAY NOT be renamed. + /// - Resource and operation shapes MAY NOT be renamed. Renaming shapes is intended for + /// incidental naming conflicts, not for renaming the fundamental concepts of a service. + /// - Shapes from other namespaces marked as private MAY be renamed. + /// - A rename MUST use a name that is case-sensitively different from the original shape ID name. rename: Rename } @@ -35,23 +144,99 @@ map Rename { value: String } +@externalDocumentation("Operation Reference": "https://smithy.io/2.0/spec/service-types.html#operation") structure OperationShape { + /// The input of the operation defined using a shape ID that MUST target a structure. + /// - Every operation SHOULD define a dedicated input shape marked with the + /// input trait. Creating a dedicated input shape ensures that input members + /// can be added in the future if needed. + /// - Input defaults to smithy.api#Unit if no input is defined, indicating that + /// the operation has no meaningful input. input: AnyMemberTarget + + /// The output of the operation defined using a shape ID that MUST target a structure. + /// - Every operation SHOULD define a dedicated output shape marked with the + /// output trait. Creating a dedicated output shape ensures that output members + /// can be added in the future if needed. + /// - Output defaults to smithy.api#Unit if no output is defined, indicating that + /// the operation has no meaningful output. output: AnyMemberTarget + + /// The errors that an operation can return. Each string in the list is a shape ID that + /// MUST target a structure shape marked with the error trait. errors: Errors } +@externalDocumentation("Resource Reference": "https://smithy.io/2.0/spec/service-types.html#resource") structure ResourceShape { + /// Defines a map of identifier string names to Shape IDs used to identify the resource. + /// Each shape ID MUST target a string shape. + @externalDocumentation( + "Identifiers Reference": "https://smithy.io/2.0/spec/service-types.html#resource-identifiers" + ) identifiers: Identifiers + + /// Defines a map of property string names to Shape IDs that enumerate the properties + /// of the resource. + @externalDocumentation( + "Properties Reference": "https://smithy.io/2.0/spec/service-types.html#resource-properties" + ) properties: Properties + + /// Defines the lifecycle operation used to create a resource using one or more + /// identifiers created by the service. The value MUST be a valid Shape ID that targets an operation shape. + @externalDocumentation( + "Create Reference": "https://smithy.io/2.0/spec/service-types.html#create-lifecycle" + ) create: AnyOperation + + /// Defines an idempotent lifecycle operation used to create a resource using identifiers + /// provided by the client. The value MUST be a valid Shape ID that targets an operation shape. + @externalDocumentation( + "Put Reference": "https://smithy.io/2.0/spec/service-types.html#put-lifecycle" + ) put: AnyOperation + + /// Defines the lifecycle operation used to retrieve the resource. The value MUST be + /// a valid Shape ID that targets an operation shape. + @externalDocumentation( + "Read Reference": "https://smithy.io/2.0/spec/service-types.html#read-lifecycle" + ) read: AnyOperation + + /// Defines the lifecycle operation used to update the resource. The value MUST be a + /// valid Shape ID that targets an operation shape. + @externalDocumentation( + "Update Reference": "https://smithy.io/2.0/spec/service-types.html#update-lifecycle" + ) update: AnyOperation + + /// Defines the lifecycle operation used to delete the resource. The value MUST be a + /// valid Shape ID that targets an operation shape. + @externalDocumentation( + "Delete Reference": "https://smithy.io/2.0/spec/service-types.html#delete-lifecycle" + ) delete: AnyOperation + + /// Defines the lifecycle operation used to list resources of this type. The value + /// MUST be a valid Shape ID that targets an operation shape. + @externalDocumentation( + "List Reference": "https://smithy.io/2.0/spec/service-types.html#list-lifecycle" + ) list: AnyOperation + + /// Binds a list of non-lifecycle instance operations to the resource. Each value in + /// the list MUST be a valid Shape ID that targets an operation shape. operations: Operations + + /// Binds a list of non-lifecycle collection operations to the resource. Each value in + /// the list MUST be a valid Shape ID that targets an operation shape. collectionOperations: Operations + + /// Binds a list of resources to this resource as a child resource, forming a containment + /// relationship. Each value in the list MUST be a valid Shape ID that targets a resource. + /// The resources MUST NOT have a cyclical containment hierarchy, and a resource can not + /// be bound more than once in the entire closure of a resource or service. resources: Resources } @@ -65,6 +250,8 @@ map Properties { value: AnyMemberTarget } +// Note: No builtin docs for list/map members, because they could clobber user-defined docs. +// We could add some logic to merge them, but I don't think it is worth it. structure ListShape { member: AnyMemberTarget } diff --git a/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy index a3c38cbb..6322b5a5 100644 --- a/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy +++ b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy @@ -4,15 +4,15 @@ namespace smithy.lang.server structure BuiltinMetadata { /// Suppressions are used to suppress specific validation events. - /// See [Suppressions](https://smithy.io/2.0/spec/model-validation.html#suppressions) + @externalDocumentation("Suppressions Reference": "https://smithy.io/2.0/spec/model-validation.html#suppressions") suppressions: Suppressions /// An array of validator objects used to constrain the model. - /// See [Validators](https://smithy.io/2.0/spec/model-validation.html#validators) + @externalDocumentation("Validators Reference": "https://smithy.io/2.0/spec/model-validation.html#validators") validators: Validators /// An array of severity override objects used to raise the severity of non-suppressed validation events. - /// See [Severity overrides](https://smithy.io/2.0/spec/model-validation.html#severity-overrides) + @externalDocumentation("Severity Overrides Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-overrides") severityOverrides: SeverityOverrides } @@ -28,6 +28,7 @@ list SeverityOverrides { member: SeverityOverride } +@externalDocumentation("Suppressions Reference": "https://smithy.io/2.0/spec/model-validation.html#suppressions") structure Suppression { /// The hierarchical validation event ID to suppress. id: String @@ -41,13 +42,46 @@ structure Suppression { reason: String } +@externalDocumentation("Validators Reference": "https://smithy.io/2.0/spec/model-validation.html#validators") structure Validator { + /// The name of the validator to apply. This name is used in implementations to find and configure + /// the appropriate validator implementation. Validators only take effect if a Smithy processor + /// implements the validator. name: ValidatorName + + /// Defines a custom identifier for the validator. + /// Multiple instances of a single validator can be configured for a model. Providing + /// an `id` allows suppressions to suppress a specific instance of a validator. + /// If `id` is not specified, it will default to the name property of the validator definition. + /// IDs that contain dots (.) are hierarchical. For example, the ID "Foo.Bar" contains + /// the ID "Foo". Event ID hierarchies can be leveraged to group validation events and + /// allow more granular suppressions. id: String + + /// Provides a custom message to use when emitting validation events. The special `{super}` + /// string can be added to a custom message to inject the original error message of + /// the validation event into the custom message. message: String + + /// Provides a custom severity level to use when a validation event occurs. If no severity + /// is provided, then the default severity of the validator is used. + /// + /// **Note** The severity of user-defined validators cannot be set to `ERROR`. + @externalDocumentation("Severity Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-definition") severity: ValidatorSeverity + + /// Provides a list of the namespaces that are targeted by the validator. The validator + /// will ignore any validation events encountered that are not specific to the given namespaces. namespaces: AnyNamespaces + + /// A valid selector that causes the validator to only validate shapes that match the + /// selector. The validator will ignore any validation events encountered that do not + /// satisfy the selector. + @externalDocumentation("Selector Reference": "https://smithy.io/2.0/spec/selectors.html#selectors") selector: String + + /// Object that provides validator configuration. The available properties are defined + /// by each validator. Validators MAY require that specific configuration properties are provided. configuration: ValidatorConfig } @@ -61,9 +95,18 @@ list AnyNamespaces { member: AnyNamespace } +@externalDocumentation("Severity Overrides Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-overrides") structure SeverityOverride { + /// The hierarchical validation event ID to elevate. id: String + + /// The validation event is only elevated if it matches the supplied namespace. + /// A value of `*` can be provided to match any namespace. namespace: AnyNamespace + + /// Defines the severity to elevate matching events to. This value can only be set + /// to `WARNING` or `DANGER`. + @externalDocumentation("Severity Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-definition") severity: SeverityOverrideSeverity } @@ -73,19 +116,35 @@ enum SeverityOverrideSeverity { } structure BuiltinValidators { + /// Emits a validation event for each shape that matches the given selector. + @externalDocumentation("EmitEachSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emiteachselector") EmitEachSelector: EmitEachSelectorConfig + + /// Emits a validation event if no shape in the model matches the given selector. + @externalDocumentation("EmitNoneSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emitnoneselector") EmitNoneSelector: EmitNoneSelectorConfig + UnreferencedShapes: UnreferencedShapesConfig } +@externalDocumentation("EmitEachSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emiteachselector") structure EmitEachSelectorConfig { + /// A valid selector. A validation event is emitted for each shape in the model that matches the selector. @required selector: Selector + + /// An optional string that MUST be a valid shape ID that targets a trait definition. + /// A validation event is only emitted for shapes that have this trait. bindToTrait: AnyTrait + + /// A custom template that is expanded for each matching shape and assigned as the message + /// for the emitted validation event. messageTemplate: String } +@externalDocumentation("EmitNoneSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emitnoneselector") structure EmitNoneSelectorConfig { + /// A valid selector. If no shape in the model is returned by the selector, then a validation event is emitted. @required selector: Selector } diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java index 5d792d88..3ffc76df 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java @@ -75,6 +75,18 @@ public void noHoverForValues() { assertThat(hovers, empty()); } + @Test + public void membersIncludeInheritedDocs() { + var twp = TextWithPositions.from(""" + %{ + %"version": "1.0" + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("Smithy Build Reference"))); + } + private static List getHovers(BuildFileType buildFileType, TextWithPositions twp) { var workspace = TestWorkspace.emptyWithNoConfig("test"); workspace.addModel(buildFileType.filename(), twp.text()); diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java index 8fec8f52..0b2566c3 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -42,7 +42,7 @@ public void metadataKey() { """); List hovers = getHovers(text, new Position(0, 9)); - assertThat(hovers, contains(containsString("suppressions"))); + assertThat(hovers, contains(containsString("Suppressions"))); } @Test @@ -147,6 +147,197 @@ public void selfMemberDefinition() { assertThat(hovers, contains(containsString("bar: String"))); } + @Test + public void shapeKeywordHover() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + %service MyService {} + %operation MyOperation {} + %resource MyResource {} + %list MyList {} + %map MyMap {} + %structure MyStructure {} + %union MyUnion {} + %blob MyBlob + %timestamp MyTimestamp + %document MyDocument + %enum MyEnum {} + %intEnum MyIntEnum {} + """); + var hovers = getHovers(twp); + assertThat(hovers, contains( + containsString("Service Reference"), + containsString("Operation Reference"), + containsString("Resource Reference"), + containsString("List Reference"), + containsString("Map Reference"), + containsString("Structure Reference"), + containsString("Union Reference"), + containsString("binary data"), + containsString("Timestamp Reference"), + containsString("Document Reference"), + containsString("Enum Reference"), + containsString("IntEnum Reference") + )); + } + + @Test + public void builtinMemberHover() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + service MyService { + %version: "" + %operations: [] + %resources: [] + %errors: [] + %rename: {} + } + + operation MyOperation { + %input := {} + %output := {} + %errors: [] + } + + operation NoInlineOperation { + %input: Foo + %output: Foo + %errors: [] + } + + resource MyResource { + %identifiers: {} + %properties: {} + %create: {} + %put: {} + %read: {} + %update: {} + %delete: {} + %list: {} + %operations: [] + %collectionOperations: [] + %resources: [] + } + """); + var hovers = getHovers(twp); + assertThat(hovers, contains( + containsString("optional version"), + containsString("operation shapes"), + containsString("resource shapes"), + containsString("common errors"), + containsString("Disambiguates"), + containsString("input of the operation"), + containsString("output of the operation"), + containsString("errors that an operation"), + containsString("input of the operation"), + containsString("output of the operation"), + containsString("errors that an operation"), + containsString("map of identifier"), + containsString("map of property"), + containsString("create a resource"), + containsString("idempotent"), + containsString("retrieve the resource"), + containsString("update the resource"), + containsString("delete the resource"), + containsString("list resources"), + containsString("instance operations"), + containsString("collection operations"), + containsString("child resource") + )); + } + + @Test + public void nonShapeKeywordHover() { + var twp = TextWithPositions.from(""" + $version: "2" + + %metadata foo = "foo" + + %namespace com.foo + + %use com.foo#Foo + + %apply Foo @bar + + structure Foo %for Bar {} + + structure Baz %with [Foo] {} + """); + var hovers = getHovers(twp); + + assertThat(hovers, contains( + containsString("schema-less"), + containsString("A namespace is"), + containsString("The use section"), + containsString("Applies a trait"), + containsString("Allows referencing"), + containsString("Mixes in") + )); + } + + @Test + public void builtinsHoverIncludeInheritedDocs() { + var twp = TextWithPositions.from(""" + $version: "2" + + metadata validators = [ + { + %name: "" + } + ] + + namespace com.foo + + operation MyOperation { + %input := {} + } + """); + var hovers = getHovers(twp); + + assertThat(hovers, contains( + containsString("Validators Reference"), + containsString("Operation Reference") + )); + } + + @Test + public void builtinHoverDoesntClobberUserDocs() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + list Foo { + /// One + %member: String + } + + map Bar { + /// Two + %key: String + + /// Three + %value: String + } + + structure Baz { + /// Four + /// Five + %baz: String + } + """); + var hovers = getHovers(twp); + + assertThat(hovers, contains( + containsString("One"), + containsString("Two"), + containsString("Three"), + containsString("Four") + )); + } + private static List getHovers(TextWithPositions text) { return getHovers(text.text(), text.positions()); } From bfcbfe8d1b2aa619267f1a61501f3e0d76473e69 Mon Sep 17 00:00:00 2001 From: smithy-automation <127955164+smithy-automation@users.noreply.github.com> Date: Thu, 6 Mar 2025 08:35:23 -0800 Subject: [PATCH 26/43] Update Smithy Version (#207) Co-authored-by: Smithy Automation --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6d012361..b5716421 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -smithyVersion=1.54.0 +smithyVersion=1.55.0 From 753fa671669a6dc885fbc998d7df02bdae46214d Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Mon, 10 Mar 2025 13:56:54 -0400 Subject: [PATCH 27/43] Fix idRef completions (#208) The original implementation didn't check members for the idRef trait, and in general didn't handle idRefs in trait values. This commit fixes both of those issues by adding a `targetOf` field to TerminalShape, so that member can be checked for the idRef trait, and uses ShapeCompleter when a trait value completion is for an idRef. I also added some extra tests for definition and hover to make sure it was working correctly there too. --- .../lsp/language/CompletionCandidates.java | 34 ++++++++---- .../lsp/language/CompletionHandler.java | 8 ++- .../smithy/lsp/language/HoverHandler.java | 5 +- .../smithy/lsp/language/NodeSearch.java | 22 ++++++-- .../smithy/lsp/language/ShapeSearch.java | 53 ++++++++++++------- .../lsp/language/CompletionHandlerTest.java | 34 ++++++++++++ .../lsp/language/DefinitionHandlerTest.java | 21 ++++++++ .../smithy/lsp/language/HoverHandlerTest.java | 23 ++++++++ 8 files changed, 161 insertions(+), 39 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java index 44b2fa8b..13a31046 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java @@ -17,6 +17,7 @@ import software.amazon.smithy.model.shapes.IntEnumShape; import software.amazon.smithy.model.shapes.ListShape; import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.traits.DefaultTrait; import software.amazon.smithy.model.traits.IdRefTrait; @@ -83,8 +84,8 @@ static Constant defaultCandidates(Shape shape) { */ static CompletionCandidates fromSearchResult(NodeSearch.Result result) { return switch (result) { - case NodeSearch.Result.TerminalShape(Shape shape, var ignored) -> - terminalCandidates(shape); + case NodeSearch.Result.TerminalShape(Shape shape, MemberShape targetOf, var ignored) -> + terminalCandidates(shape, targetOf); case NodeSearch.Result.ObjectKey(var ignored, Shape shape, Model model) -> membersCandidates(model, shape); @@ -94,7 +95,7 @@ static CompletionCandidates fromSearchResult(NodeSearch.Result result) { case NodeSearch.Result.ArrayShape(var ignored, ListShape shape, Model model) -> model.getShape(shape.getMember().getTarget()) - .map(CompletionCandidates::terminalCandidates) + .map(target -> terminalCandidates(target, shape.getMember())) .orElse(NONE); default -> NONE; @@ -134,37 +135,48 @@ static CompletionCandidates membersCandidates(Model model, Shape shape) { } else if (shape instanceof MapShape mapShape) { return model.getShape(mapShape.getKey().getTarget()) .flatMap(Shape::asEnumShape) - .map(CompletionCandidates::terminalCandidates) + .map(CompletionCandidates::enumCandidates) .orElse(NONE); } return NONE; } - private static CompletionCandidates terminalCandidates(Shape shape) { + private static CompletionCandidates terminalCandidates(Shape shape, MemberShape targetOf) { Builtins.BuiltinShape builtinShape = Builtins.BUILTIN_SHAPES.get(shape.getId()); if (builtinShape != null) { return forBuiltin(builtinShape); } + if (isIdRef(shape, targetOf)) { + return Shapes.ANY_SHAPE; + } + return switch (shape) { - case EnumShape enumShape -> new Labeled(enumShape.getEnumValues() - .entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> "\"" + entry.getValue() + "\""))); + case EnumShape enumShape -> enumCandidates(enumShape); case IntEnumShape intEnumShape -> new Labeled(intEnumShape.getEnumValues() .entrySet() .stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().toString()))); - case Shape s when s.hasTrait(IdRefTrait.class) -> Shapes.ANY_SHAPE; - case Shape s when s.isBooleanShape() -> BOOL; default -> defaultCandidates(shape); }; } + private static CompletionCandidates enumCandidates(EnumShape enumShape) { + return new Labeled(enumShape.getEnumValues() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> "\"" + entry.getValue() + "\""))); + } + + private static boolean isIdRef(Shape shape, MemberShape targetOf) { + return shape.hasTrait(IdRefTrait.class) + || (targetOf != null && targetOf.hasTrait(IdRefTrait.class)); + } + private static CompletionCandidates forBuiltin(Builtins.BuiltinShape builtinShape) { return switch (builtinShape) { case SmithyIdlVersion -> SMITHY_IDL_VERSION; diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java index 9e26e402..4fe3dc87 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -175,8 +175,14 @@ private List traitValueCompletions( ) { var result = ShapeSearch.searchTraitValue(traitValue, model); Set excludeKeys = result.getOtherPresentKeys(); + var contextWithExclude = context.withExclude(excludeKeys); + CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); - return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates); + if (candidates instanceof CompletionCandidates.Shapes shapes) { + return new ShapeCompleter(traitValue, model, contextWithExclude).getCompletionItems(shapes); + } + + return new SimpleCompleter(contextWithExclude).getCompletionItems(candidates); } private List memberNameCompletions(IdlPosition.MemberName memberName, CompleterContext context) { diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java index 55e3aaad..f1ae1799 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -30,7 +30,6 @@ import software.amazon.smithy.model.shapes.StructureShape; import software.amazon.smithy.model.traits.DocumentationTrait; import software.amazon.smithy.model.traits.ExternalDocumentationTrait; -import software.amazon.smithy.model.traits.IdRefTrait; import software.amazon.smithy.model.validation.Severity; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.model.validation.ValidationEvent; @@ -108,8 +107,8 @@ public Hover handle(HoverParams params) { private static Optional takeShapeReference(NodeSearch.Result result) { return switch (result) { - case NodeSearch.Result.TerminalShape(Shape shape, var ignored) - when shape.hasTrait(IdRefTrait.class) -> Optional.of(shape); + case NodeSearch.Result.TerminalShape terminalShape + when terminalShape.isIdRef() -> Optional.of(terminalShape.shape()); case NodeSearch.Result.ObjectKey(NodeCursor.Key key, Shape containerShape, var ignored) when !containerShape.isMapShape() -> containerShape.getMember(key.name()); diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java index a81de9f4..8a3956b4 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java @@ -16,6 +16,7 @@ import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.IdRefTrait; /** * Searches models along the path of {@link NodeCursor}s, with support for @@ -112,9 +113,18 @@ record None() implements Result {} * The path ended on a shape. * * @param shape The shape at the end of the path. + * @param targetOf The nullable member {@code shape} is the target of. * @param model The model {@code shape} is within. */ - record TerminalShape(Shape shape, Model model) implements Result {} + record TerminalShape(Shape shape, MemberShape targetOf, Model model) implements Result { + /** + * @return Whether the shape at the end of the path, or the member + * it was targeted by, is an idRef. + */ + boolean isIdRef() { + return shape.hasTrait(IdRefTrait.class) || (targetOf != null && targetOf.hasTrait(IdRefTrait.class)); + } + } /** * The path ended on a key or member name of an object-like shape. @@ -152,6 +162,10 @@ private DefaultSearch(Model model) { } Result search(NodeCursor cursor, Shape shape) { + return search(cursor, shape, null); + } + + protected final Result search(NodeCursor cursor, Shape shape, MemberShape targetOf) { if (!cursor.hasNext() || shape == null) { return Result.NONE; } @@ -164,7 +178,7 @@ Result search(NodeCursor cursor, Shape shape) { case NodeCursor.Arr arr when shape instanceof ListShape list -> searchArr(cursor, arr, list); - case NodeCursor.Terminal ignored -> new Result.TerminalShape(shape, model); + case NodeCursor.Terminal ignored -> new Result.TerminalShape(shape, targetOf, model); default -> Result.NONE; }; @@ -206,7 +220,7 @@ private Result searchArr(NodeCursor cursor, NodeCursor.Arr arr, ListShape shape) } protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) { - return search(cursor, model.getShape(memberShape.getTarget()).orElse(null)); + return search(cursor, model.getShape(memberShape.getTarget()).orElse(null), memberShape); } } @@ -229,7 +243,7 @@ protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) { Shape target = dynamicMemberTarget.getTarget(cursor, model); cursor.returnToCheckpoint(); if (target != null) { - return search(cursor, target); + return search(cursor, target, memberShape); } } diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java index d86e9be9..a0666c00 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -19,7 +19,6 @@ import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeIdSyntaxException; -import software.amazon.smithy.model.traits.IdRefTrait; /** * Provides methods to search for shapes, using context and syntax specific @@ -94,25 +93,10 @@ private static Optional tryFromParts(String namespace, String name) { */ static Optional findShapeDefinition(IdlPosition idlPosition, DocumentId id, Model model) { return switch (idlPosition) { - case IdlPosition.TraitValue traitValue -> { - var result = searchTraitValue(traitValue, model); - if (result instanceof NodeSearch.Result.TerminalShape(var s, var m) && s.hasTrait(IdRefTrait.class)) { - yield findShape(idlPosition.view().parseResult(), id.copyIdValue(), m); - } else if (result instanceof NodeSearch.Result.ObjectKey(var key, var container, var m) - && !container.isMapShape()) { - yield container.getMember(key.name()); - } - yield Optional.empty(); - } + case IdlPosition.TraitValue traitValue -> findShapeDefinitionInTrait(traitValue, id, model); - case IdlPosition.NodeMemberTarget nodeMemberTarget -> { - var result = searchNodeMemberTarget(nodeMemberTarget); - if (result instanceof NodeSearch.Result.TerminalShape(Shape shape, var ignored) - && shape.hasTrait(IdRefTrait.class)) { - yield findShape(nodeMemberTarget.view().parseResult(), id.copyIdValue(), model); - } - yield Optional.empty(); - } + case IdlPosition.NodeMemberTarget nodeMemberTarget -> + findShapeDefinitionInNodeMemberTarget(nodeMemberTarget, id, model); // Note: This could be made more specific, at least for mixins case IdlPosition.ElidedMember elidedMember -> @@ -134,6 +118,35 @@ static Optional findShapeDefinition(IdlPosition idlPosition, Do }; } + private static Optional findShapeDefinitionInTrait( + IdlPosition.TraitValue traitValue, + DocumentId id, + Model model + ) { + var result = searchTraitValue(traitValue, model); + return switch (result) { + case NodeSearch.Result.TerminalShape terminal when terminal.isIdRef() -> + findShape(traitValue.view().parseResult(), id.copyIdValue(), model); + + case NodeSearch.Result.ObjectKey objectKey when !objectKey.containerShape().isMapShape() -> + objectKey.containerShape().getMember(objectKey.key().name()); + + default -> Optional.empty(); + }; + } + + private static Optional findShapeDefinitionInNodeMemberTarget( + IdlPosition.NodeMemberTarget nodeMemberTarget, + DocumentId id, + Model model + ) { + var result = searchNodeMemberTarget(nodeMemberTarget); + if (result instanceof NodeSearch.Result.TerminalShape terminal && terminal.isIdRef()) { + return findShape(nodeMemberTarget.view().parseResult(), id.copyIdValue(), model); + } + return Optional.empty(); + } + /** * @param forResource The nullable for-resource statement. * @param view A statement view containing the for-resource statement. @@ -275,7 +288,7 @@ static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nod // TODO: Note that searchTraitValue has to do a similar thing, but parsing // trait values always yields at least an empty Kvps, so it is kind of the same. if (nodeMemberTarget.nodeMember().value() == null) { - return new NodeSearch.Result.TerminalShape(memberShapeDef, Builtins.MODEL); + return new NodeSearch.Result.TerminalShape(memberShapeDef, null, Builtins.MODEL); } NodeCursor cursor = NodeCursor.create( diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java index aaac2cd6..53c2df7e 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -1075,6 +1075,40 @@ public void completesUseTarget() { assertThat(item.getAdditionalTextEdits(), nullValue()); } + @Test + public void completesIdRefs() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @trait + structure myId { + ref: ShapeId + } + @idRef + string ShapeId + @myId(ref: %) + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("myId", "ShapeId")); + } + + @Test + public void completesIdRefsOnMember() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @trait + structure myId { + @idRef + ref: String + } + @myId(ref: %) + """); + List comps = getCompLabels(text); + assertThat(comps, hasItems("myId", "String")); + } + private static List getCompLabels(TextWithPositions textWithPositions) { return getCompLabels(textWithPositions.text(), textWithPositions.positions()); } diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java index cd16243f..e117f8e4 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -312,6 +312,27 @@ public void idRefTraitValue() { assertIsShapeDef(result, result.locations.getFirst(), "string Bar"); } + @Test + public void idRefMemberTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + @idRef + id: String + } + + @foo(id: %Bar) + string Bar + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string Bar"); + } + @Test public void absoluteShapeId() { TextWithPositions text = TextWithPositions.from(""" diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java index 0b2566c3..d99d40e9 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -7,6 +7,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static software.amazon.smithy.lsp.document.DocumentTest.safeString; @@ -338,6 +339,28 @@ public void builtinHoverDoesntClobberUserDocs() { )); } + @Test + public void idRefMemberTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + @idRef + id: String + } + + @foo(id: %Bar) + string Bar + """); + var hovers = getHovers(text); + + assertThat(hovers, containsInAnyOrder( + containsString("string Bar") + )); + } + private static List getHovers(TextWithPositions text) { return getHovers(text.text(), text.positions()); } From dcc41712291a7599a56e5798096c68bc72c96f5b Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Mon, 10 Mar 2025 13:57:04 -0400 Subject: [PATCH 28/43] Change source location none comparison (#209) RebuildIndex checks the source location of traits to see if they are SourceLocation.NONE when determining which files depend on eachother. SourceLocation's equals does a toString to compare though, which is unnecessarily slow, so I changed our comparison to just check the filename. --- src/main/java/software/amazon/smithy/lsp/project/Project.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c06e8218..37aafcbb 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -554,7 +554,7 @@ RebuildIndex recompute(ValidatedResult modelResult) { } private static boolean isNone(SourceLocation sourceLocation) { - return sourceLocation.equals(SourceLocation.NONE); + return sourceLocation.getFilename().equals(SourceLocation.NONE.getFilename()); } } } From cb49245e3d299041e95257a3f1407feacccf3fdf Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Mon, 10 Mar 2025 14:00:34 -0400 Subject: [PATCH 29/43] Fix mixin parsing (#211) There could have been an infinite loop if you had something that wasn't an identifier in a mixin, like: ``` structure Foo with [123] {} structure Foo with 123 {} ``` so I changed the parser to recover to either an identifier, or some structural breakpoint when that happens. I don't think this specific strategy generalizes enough to just put it in `ident()`, but if we find any other cases where this can happen we could add some more error recover methods, or specific `ident()` methods that recover in a particular way. I also modified the invalid test cases in IdlParserTest to include blocks - for some reason I had the test filtering out blocks before the assertion, but we definitely want to check for them. I also removed an erroneous `ShapeNode` Statement type. --- .../amazon/smithy/lsp/syntax/Parser.java | 21 +++-- .../amazon/smithy/lsp/syntax/Syntax.java | 1 - .../smithy/lsp/syntax/IdlParserTest.java | 83 ++++++++++++++++--- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index a9da011d..0e655796 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -470,6 +470,12 @@ private void skipUntilStatementStart() { } } + private void skipUntilIdentifierOrBreakpoint() { + while (!isIdentStart() && !isStructuralBreakpoint() && !eof()) { + skip(); + } + } + private void statement() { if (is('@')) { traitApplication(null); @@ -759,19 +765,20 @@ private void optionalForResourceAndMixins() { if (!is('[')) { addErr(position(), position(), "expected ["); - - // If we're on an identifier, just assume the [ was meant to be there - if (!isIdentStart()) { - setEnd(mixins); - addStatement(mixins); - return; - } } else { skip(); } ws(); while (!isStructuralBreakpoint() && !eof()) { + if (!isIdentStart()) { + var errStart = position(); + skipUntilIdentifierOrBreakpoint(); + var errEnd = position(); + addErr(errStart, errEnd, "expected identifier"); + continue; + } + mixins.mixins.add(ident()); ws(); } diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java index f83d052d..f6556d00 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -384,7 +384,6 @@ public enum Type { Namespace, Use, Apply, - ShapeNode, ShapeDef, ForResource, Mixins, diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java index 4f6841e5..27cb12fe 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -333,7 +333,6 @@ public void broken(String desc, String text, List expectedErrorMessages, List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); List types = parse.statements().stream() .map(Syntax.Statement::type) - .filter(type -> type != Syntax.Statement.Type.Block) .toList(); assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); @@ -388,49 +387,75 @@ record InvalidSyntaxTestCase( "enum missing {", "enum Foo\nBAR}", List.of("expected {"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.EnumMemberDef) ), new InvalidSyntaxTestCase( "enum missing }", "enum Foo {BAR", List.of("expected }"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.EnumMemberDef) ), new InvalidSyntaxTestCase( "regular shape missing {", "structure Foo\nbar: String}", List.of("expected {"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef) ), new InvalidSyntaxTestCase( "regular shape missing }", "structure Foo {bar: String", List.of("expected }"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef) ), new InvalidSyntaxTestCase( "op with inline missing {", "operation Foo\ninput := {}}", List.of("expected {"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Block) ), new InvalidSyntaxTestCase( "op with inline missing }", "operation Foo{input:={}", List.of("expected }"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Block) ), new InvalidSyntaxTestCase( "node shape with missing {", "resource Foo\nidentifiers:{}}", List.of("expected {"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.NodeMemberDef) ), new InvalidSyntaxTestCase( "node shape with missing }", "service Foo{operations:[]", List.of("expected }"), - List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef) + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.NodeMemberDef) ), new InvalidSyntaxTestCase( "apply missing @", @@ -442,7 +467,10 @@ record InvalidSyntaxTestCase( "apply missing }", "apply Foo {@bar", List.of("expected }"), - List.of(Syntax.Statement.Type.Apply, Syntax.Statement.Type.TraitApplication) + List.of( + Syntax.Statement.Type.Apply, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.TraitApplication) ), new InvalidSyntaxTestCase( "trait missing member value", @@ -463,9 +491,44 @@ record InvalidSyntaxTestCase( List.of("expected identifier"), List.of( Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, Syntax.Statement.Type.InlineMemberDef, Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.Block, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "invalid mixin identifier", + """ + structure Foo with [123] {} + """, + List.of("expected identifier"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "mixin missing []", + """ + structure Foo with abc {} + """, + List.of("expected [", "expected ]"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "invalid mixin identifier missing []", + """ + structure Foo with 123, abc {} + """, + List.of("expected [", "expected identifier", "expected ]"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block) ) ); From 15ffa65a61e4a90d07cfae27f4a6b6a302cbaded Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Mon, 10 Mar 2025 14:07:36 -0400 Subject: [PATCH 30/43] Bump version to 0.6.0 (#210) --- CHANGELOG.md | 14 ++++++++++++++ VERSION | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 634731f5..59071973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Smithy Language Server Changelog +## 0.6.0 (2025-03-10) + +### Features +* Improved completions, definition, and hover for everything in the IDL. ([#166](https://github.com/smithy-lang/smithy-language-server/pull/166)) +* Diagnostics for smithy-build.json. ([#188](https://github.com/smithy-lang/smithy-language-server/pull/188)) +* Completions for smithy-build.json. ([#193](https://github.com/smithy-lang/smithy-language-server/pull/193)) +* Hover for smithy-build.json. ([#202](https://github.com/smithy-lang/smithy-language-server/pull/202)) +* Folding range for traits and shape blocks. ([#190](https://github.com/smithy-lang/smithy-language-server/pull/190)) +* Inlay hints of the name of inline operation input/output. ([#200](https://github.com/smithy-lang/smithy-language-server/pull/200)) + +### Bug fixes +* Fixed crash when calling setTrace or cancelProgress. ([#183](https://github.com/smithy-lang/smithy-language-server/pull/183)) +* Fixed potential conflicting trait definition when rebuilding. ([#196](https://github.com/smithy-lang/smithy-language-server/pull/196)) + ## 0.5.0 (2024-11-06) ### Features diff --git a/VERSION b/VERSION index 8f0916f7..a918a2aa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 From 21f0d5cbab3430614e5ffb8401405fdabd63b242 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 27 Mar 2025 15:23:27 -0400 Subject: [PATCH 31/43] Implement references and rename (#213) * Implement references and rename Adds support for 'textDocument/references', 'textDocument/rename', and 'textDocument/prepareRename'. These all work very similarly, so I implemented them together. This implementation only works for root shapes, not members or resource identifiers/properties (which I call resource fields for brevity). Originally I intended to make it work for members, but for 'complete' member support, you really need to support resource fields, otherwise if you tried to rename a member, it wouldn't rename the resource field it references, and finding references wouldn't show fully accurate information. The problem is, the relationship between members and resource fields can be implicit, and/or determined by a number of different traits (`resourceIdentifier`, `property`, `references`, `nestedProperties`). And some of these traits refer to the resource field by string name. Current KnowledgeIndex implementations don't support going from an arbitrary member -> field bindings and back. Plus, we can't reliably use KnowledgeIndicies because they usually operate on a complete, valid model, whereas the language server wants to operate on the minimum subset of a possibly invalid model. I think it would be possible still to support members in the future, either with some clever code in the language server or enhancements to the smithy library, but I didn't want to block rename/references for regular shapes on that. Some specific implementation details to note: 1. Rename/References throw ResponseErrorException on an unsupported position (like a member name), or when the rename would be invalid (i.e. renaming a shape from a dependency, or providing an invalid shape name). This is what the spec says you should do, and we should update other features to do the same. 2. Rename disambiguates shapes that would be conflicting after the rename by using an absolute shape id, and removes conflicting use statements. 3. Rename/References both work on references to members of the target shape, i.e. `com.foo#Foo$bar`, only if the request position is before the `$`, otherwise it assumes you're getting the reference of a member. 4. Prepare returns the range of the identifier to be renamed (or throws an error as specified in 1.), so if you go to rename `com.foo#Bar`, the rename range will be the range of `Bar`. Other notable changes: 1. Changed `DocumentId` to only have distinct types for root shapes and members. The type will be `ROOT` when the cursor is in any part of the shape id before a member, and `MEMBER` only when the cursor is actually within the member part of the shape id. The rest of the other possible id types didn't seem to be useful enough to keep. 2. Added a `line` field to Syntax.Node.Str, which makes it _much_ more efficient to get the range of a Str or Ident. 3. Fixed an issue in TextWithPositions when there are multiple positions on the same line. * Fix shape search resolution Fixes an issue with ShapeSearch::findShape where the local namespace was checked before imports. It was inconsistent with the way the model loader resolves shapes, so you could end up jumping to the wrong definition/references, or renaming the wrong shape. --- .../smithy/lsp/SmithyLanguageServer.java | 67 ++ .../amazon/smithy/lsp/document/Document.java | 104 +- .../smithy/lsp/document/DocumentId.java | 26 +- .../amazon/smithy/lsp/language/Builtins.java | 2 + .../smithy/lsp/language/References.java | 371 ++++++ .../lsp/language/ReferencesHandler.java | 115 ++ .../smithy/lsp/language/RenameHandler.java | 259 +++++ .../smithy/lsp/language/ShapeSearch.java | 156 ++- .../amazon/smithy/lsp/project/Project.java | 9 + .../amazon/smithy/lsp/syntax/Parser.java | 14 +- .../smithy/lsp/syntax/StatementView.java | 9 + .../amazon/smithy/lsp/syntax/Syntax.java | 14 +- .../amazon/smithy/lsp/LspMatchers.java | 30 + .../amazon/smithy/lsp/RequestBuilders.java | 27 + .../amazon/smithy/lsp/TextWithPositions.java | 17 +- .../amazon/smithy/lsp/UtilMatchers.java | 30 + .../smithy/lsp/document/DocumentTest.java | 87 +- .../lsp/language/ReferencesHandlerTest.java | 329 ++++++ .../lsp/language/RenameHandlerTest.java | 1016 +++++++++++++++++ 19 files changed, 2525 insertions(+), 157 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/language/References.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index bb701d9e..1d977d4b 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -62,11 +62,17 @@ import org.eclipse.lsp4j.InlayHintParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; +import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.PrepareRenameResult; import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceParams; import org.eclipse.lsp4j.Registration; import org.eclipse.lsp4j.RegistrationParams; +import org.eclipse.lsp4j.RenameOptions; +import org.eclipse.lsp4j.RenameParams; import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.SetTraceParams; import org.eclipse.lsp4j.SymbolInformation; @@ -82,10 +88,12 @@ import org.eclipse.lsp4j.WorkDoneProgressBegin; import org.eclipse.lsp4j.WorkDoneProgressCancelParams; import org.eclipse.lsp4j.WorkDoneProgressEnd; +import org.eclipse.lsp4j.WorkspaceEdit; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; import org.eclipse.lsp4j.WorkspaceServerCapabilities; import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.jsonrpc.messages.Either3; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; import org.eclipse.lsp4j.services.LanguageServer; @@ -106,6 +114,8 @@ import software.amazon.smithy.lsp.language.FoldingRangeHandler; import software.amazon.smithy.lsp.language.HoverHandler; import software.amazon.smithy.lsp.language.InlayHintHandler; +import software.amazon.smithy.lsp.language.ReferencesHandler; +import software.amazon.smithy.lsp.language.RenameHandler; import software.amazon.smithy.lsp.project.BuildFile; import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; @@ -137,6 +147,8 @@ public class SmithyLanguageServer implements capabilities.setDocumentSymbolProvider(true); capabilities.setFoldingRangeProvider(true); capabilities.setInlayHintProvider(true); + capabilities.setReferencesProvider(true); + capabilities.setRenameProvider(new RenameOptions(true)); WorkspaceFoldersOptions workspaceFoldersOptions = new WorkspaceFoldersOptions(); workspaceFoldersOptions.setSupported(true); @@ -712,6 +724,61 @@ public CompletableFuture> formatting(DocumentFormatting return completedFuture(Collections.singletonList(edit)); } + @Override + public CompletableFuture> references(ReferenceParams params) { + LOGGER.finest("References"); + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "references"); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(null); + } + + var handler = new ReferencesHandler(projectAndFile.project(), idlFile); + return supplyAsync(() -> handler.handle(params)); + } + + @Override + public CompletableFuture rename(RenameParams params) { + LOGGER.finest("Rename"); + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "rename"); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(null); + } + + var handler = new RenameHandler(projectAndFile.project(), idlFile); + return supplyAsync(() -> handler.handle(params)); + } + + @Override + public CompletableFuture> + prepareRename(PrepareRenameParams params) { + LOGGER.finest("PrepareRename"); + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "prepareRename"); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(null); + } + + var handler = new RenameHandler(projectAndFile.project(), idlFile); + return supplyAsync(() -> Either3.forFirst(handler.prepare(params))); + } + private void sendFileDiagnosticsForManagedDocuments() { for (ProjectAndFile managed : state.getAllManaged()) { state.lifecycleTasks().putOrComposeTask(managed.uri(), sendFileDiagnostics(managed)); diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java index d5f7e7ae..671e6c4c 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -11,6 +11,7 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; /** * In-memory representation of a text document, indexed by line, which can @@ -203,6 +204,35 @@ public Range rangeBetween(int start, int end) { return new Range(startPos, endPos); } + /** + * @param item The item to get the range of + * @return The range of the item + */ + public Range rangeOf(Syntax.Item item) { + return rangeBetween(item.start(), item.end()); + } + + /** + * @param token The token to get the range of + * @return The range of the token, excluding enclosing "" + */ + public Range rangeOfValue(Syntax.Node.Str token) { + int lineStart = indexOfLine(token.lineNumber()); + if (lineStart < 0) { + return null; + } + + int startChar = token.start() - lineStart; + int endChar = token.end() - lineStart; + + if (token.type() == Syntax.Node.Type.Str) { + startChar += 1; + endChar -= 1; + } + + return new Range(new Position(token.lineNumber(), startChar), new Position(token.lineNumber(), endChar)); + } + /** * @param line The line to find the end of * @return The index of the end of the given line, or {@code -1} if the @@ -306,79 +336,45 @@ public DocumentId copyDocumentId(Position position) { return null; } - boolean hasHash = false; - boolean hasDollar = false; - boolean hasDot = false; + boolean isMember = false; int startIdx = idx; while (startIdx >= 0) { char c = buffer.charAt(startIdx); - if (isIdChar(c)) { - switch (c) { - case '#': - hasHash = true; - break; - case '$': - hasDollar = true; - break; - case '.': - hasDot = true; - break; - default: - break; - } - startIdx -= 1; - } else { + if (!isIdChar(c)) { break; } + + if (c == '$') { + isMember = true; + } + + startIdx -= 1; } int endIdx = idx; while (endIdx < buffer.length()) { char c = buffer.charAt(endIdx); - if (isIdChar(c)) { - switch (c) { - case '#': - hasHash = true; - break; - case '$': - hasDollar = true; - break; - case '.': - hasDot = true; - break; - default: - break; - } + if (!isIdChar(c)) { + break; + } - endIdx += 1; - } else { + if (!isMember && c == '$') { break; } - } + endIdx += 1; + } - // TODO: This can be improved to do some extra validation, like if - // there's more than 1 hash or $, its invalid. Additionally, we - // should only give a type of *WITH_MEMBER if the position is on - // the member itself. We will probably need to add some logic or - // keep track of the member itself in order to properly match the - // RELATIVE_WITH_MEMBER type in handlers. DocumentId.Type type; - if (hasHash && hasDollar) { - type = DocumentId.Type.ABSOLUTE_WITH_MEMBER; - } else if (hasHash) { - type = DocumentId.Type.ABSOLUTE_ID; - } else if (hasDollar) { - type = DocumentId.Type.RELATIVE_WITH_MEMBER; - } else if (hasDot) { - type = DocumentId.Type.NAMESPACE; + if (isMember) { + type = DocumentId.Type.MEMBER; } else { - type = DocumentId.Type.ID; + type = DocumentId.Type.ROOT; } - // We go past the start and end in each loop, so startIdx is before the start character, and endIdx - // is after the end character. Since end is exclusive (both for creating the buffer and getting the - // range) we can leave it. + // Add one since we went past the start when breaking from the loop. + // Not necessary for endIdx, because we want it to be one past the last + // character. int startCharIdx = startIdx + 1; CharBuffer wrapped = CharBuffer.wrap(buffer, startCharIdx, endIdx); Range range = rangeBetween(startCharIdx, endIdx); diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java index f20dd67b..9571805a 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -23,29 +23,17 @@ public record DocumentId(Type type, CharBuffer idSlice, Range range) { */ public enum Type { /** - * Just a shape name, no namespace or member. + * A root shape id, i.e. without trailing '$member'. May or may not + * have a leading namespace. */ - ID, + ROOT, /** - * Same as {@link Type#ID}, but with a namespace. + * A shape id with a member, i.e. with trailing '$member'. May or may + * not have a leading namespace. May or may not have a root shape name + * before the '$member'. */ - ABSOLUTE_ID, - - /** - * Just a namespace - will have one or more {@code .}. - */ - NAMESPACE, - - /** - * Same as {@link Type#ABSOLUTE_ID}, but with a member - will have a {@code $}. - */ - ABSOLUTE_WITH_MEMBER, - - /** - * Same as {@link Type#ID}, but with a member - will have a {@code $}. - */ - RELATIVE_WITH_MEMBER + MEMBER } public String copyIdValue() { diff --git a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java index c3f11046..2ab2e4d7 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java +++ b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java @@ -65,6 +65,8 @@ final class Builtins { MemberShape::getMemberName, memberShape -> memberShape.getTarget())); + static final ShapeId SERVICE_RENAME_ID = id("Rename"); + private Builtins() { } diff --git a/src/main/java/software/amazon/smithy/lsp/language/References.java b/src/main/java/software/amazon/smithy/lsp/language/References.java new file mode 100644 index 00000000..bc90dd75 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/References.java @@ -0,0 +1,371 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.IdRefTrait; + +/** + * Collects references to a shape across a project or within a specific file. + */ +final class References { + private final Model model; + private final Shape shape; + private final ShapeReferencesNodeWalker traitValueWalker; + private final ShapeReferencesNodeWalker nodeMemberWalker; + private final List fileReferences = new ArrayList<>(); + private final List definitionReferences = new ArrayList<>(); + private List pendingUseRefs = new ArrayList<>(); + private List pendingRefs = new ArrayList<>(); + private IdlFile currentFile; + private Syntax.IdlParseResult currentParseResult; + + private References(Model model, Shape shape) { + this.model = model; + this.shape = shape; + this.traitValueWalker = new ShapeReferencesNodeWalker(model, this); + this.nodeMemberWalker = new ShapeReferencesNodeWalker(Builtins.MODEL, this); + } + + /** + * References to a shape in a specific file, excluding the shape's definition. + * + * @param idlFile The file the references are in + * @param useRefs The references in use statements + * @param refs All other references in the file + */ + record FileReferences(IdlFile idlFile, List useRefs, List refs) {} + + /** + * The definition of a shape. + * + * @param idlFile The file the shape is defined in + * @param ref The shape's name token + */ + record DefinitionReference(IdlFile idlFile, Syntax.Node.Str ref) {} + + /** + * Finds all references to {@code shape} across all files in the given {@code project}. + * + * @param model The model the shape is in + * @param shape The shape to find references to + * @param project The project to find references in + * @return All found references, including the shape's definition + */ + static References findReferences(Model model, Shape shape, Project project) { + var references = new References(model, shape); + references.findReferences(project); + return references; + } + + /** + * Finds all references to {@code shape} in the given {@code idlFile}. + * + * @param model The model the shape is in + * @param shape The shape to find references to + * @param idlFile The file to find references in + * @return All found references, not including the shape's definition + */ + static References findReferences(Model model, Shape shape, IdlFile idlFile) { + var references = new References(model, shape); + references.findReferences(idlFile); + return references; + } + + /** + * @return A list of all found references + */ + List fileReferences() { + return fileReferences; + } + + /** + * @return A list of all found definitions + */ + List definitionReferences() { + return definitionReferences; + } + + private void addPendingReferences() { + // Don't create a new FileReferences when there weren't any refs in + // the file. + if (!pendingUseRefs.isEmpty() || !pendingRefs.isEmpty()) { + fileReferences.add(new FileReferences( + currentFile, + pendingUseRefs, + pendingRefs + )); + + // Create a fresh pending list so subsequent modifications don't mutate a + // list in the FileReferences we just made. + pendingUseRefs = new ArrayList<>(); + pendingRefs = new ArrayList<>(); + } + } + + private void findReferences(Project project) { + for (SmithyFile smithyFile : project.getAllSmithyFiles()) { + if (!(smithyFile instanceof IdlFile idlFile)) { + continue; + } + + findReferences(idlFile); + } + + // Include the shape's definition, which won't be collected otherwise. + // Note: This doesn't add the definition of an inline shape, because it + // doesn't have an identifier to ref. + addDefinitionReference(project); + } + + private void findReferences(IdlFile idlFile) { + currentFile = idlFile; + currentParseResult = idlFile.getParse(); + + for (Syntax.Statement statement : currentParseResult.statements()) { + collect(statement); + } + + addPendingReferences(); + } + + private void collect(Syntax.Statement statement) { + switch (statement) { + case Syntax.Statement.Use use -> { + if (use.use().stringValue().equals(shape.getId().toString())) { + pendingUseRefs.add(use); + } + } + + case Syntax.Statement.Mixins mixins -> { + for (var mixin : mixins.mixins()) { + addShapeReference(mixin); + } + } + + case Syntax.Statement.ForResource forResource -> addShapeReference(forResource.resource()); + + case Syntax.Statement.MemberDef memberDef -> { + if (memberDef.target() != null) { + addShapeReference(memberDef.target()); + } + } + + case Syntax.Statement.TraitApplication traitApplication -> collectTrait(traitApplication); + + case Syntax.Statement.NodeMemberDef nodeMemberDef -> collectNodeMember(nodeMemberDef); + + default -> { + } + } + } + + private void collectTrait(Syntax.Statement.TraitApplication traitApplication) { + findShape(traitApplication.id().stringValue()).ifPresent(traitShape -> { + if (traitShape.getId().equals(shape.getId())) { + pendingRefs.add(traitApplication.id()); + } + traitValueWalker.walk(traitApplication.value(), traitShape); + }); + } + + private void collectNodeMember(Syntax.Statement.NodeMemberDef nodeMemberDef) { + createView(nodeMemberDef) + .map(StatementView::nearestShapeDefBefore) + .map(shapeDef -> Builtins.getMemberTargetForShapeType( + shapeDef.shapeType().stringValue(), + nodeMemberDef.name().stringValue())) + .ifPresent(memberTarget -> nodeMemberWalker.walk(nodeMemberDef.value(), memberTarget)); + } + + private boolean startsWithId(Shape s) { + return s.getId().getNamespace().equals(shape.getId().getNamespace()) + && s.getId().getName().equals(shape.getId().getName()); + } + + private void addShapeReference(Syntax.Node.Str token) { + if (findShape(token.stringValue()) + .filter(s -> s.getId().equals(shape.getId())) + .isPresent()) { + pendingRefs.add(token); + } + } + + private Optional findShape(String nameOrId) { + return ShapeSearch.findShape(currentParseResult, nameOrId, model); + } + + private Optional createView(Syntax.Statement statement) { + return StatementView.createAt(currentParseResult, statement); + } + + private void addDefinitionReference(Project project) { + var sourceLocation = shape.getSourceLocation(); + var projectFile = project.getProjectFile(LspAdapter.toUri(sourceLocation.getFilename())); + if (!(projectFile instanceof IdlFile idl)) { + return; + } + + var parseResult = idl.getParse(); + int documentIndex = idl.document().indexOfPosition(LspAdapter.toPosition(sourceLocation)); + var statement = StatementView.createAt(parseResult, documentIndex) + .map(StatementView::getStatement) + .orElse(null); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + definitionReferences.add(new DefinitionReference(idl, shapeDef.shapeName())); + } + } + + private void addIdRef(Syntax.Node.Str id) { + if (findShape(id.stringValue()) + .filter(this::startsWithId) + .isPresent()) { + pendingRefs.add(id); + } + } + + /** + * Walks a {@link Syntax.Node}, whose structure is defined by a {@link Shape}, + * to find all references to shapes in that node. + * + * @param model The model with the shape defining the node's structure + * @param references The references to consume found shape references + * + * @implNote This is very similar to {@link NodeSearch}, but walks all children + * instead of along a specific path, and only looks for shapes with {@code idRef}. + * It also doesn't use {@link DynamicMemberTarget}s, as right now there aren't + * any cases where a dynamic member target could provide shape references. + */ + private record ShapeReferencesNodeWalker(Model model, References references) { + private void walk(Syntax.Node node, Shape nodeShape) { + if (nodeShape == null) { + return; + } + + switch (node) { + case Syntax.Node.Obj obj -> walk(obj.kvps(), nodeShape); + + case Syntax.Node.Kvps kvps -> walkKvps(kvps, nodeShape); + + case Syntax.Node.Arr arr -> walkArr(arr, nodeShape); + + case Syntax.Node.Str str -> { + if (nodeShape.hasTrait(IdRefTrait.class)) { + references.addIdRef(str); + } + } + + case null, default -> { + } + } + } + + private void walkArr(Syntax.Node.Arr arr, Shape nodeShape) { + if (!(nodeShape instanceof ListShape listShape)) { + return; + } + + if (listShape.getMember().hasTrait(IdRefTrait.ID)) { + for (var elem : arr.elements()) { + walkIdRef(elem); + } + } else { + var target = getTarget(listShape.getMember()); + if (target == null) { + return; + } + for (var elem : arr.elements()) { + walk(elem, target); + } + } + } + + private void walkKvps(Syntax.Node.Kvps kvps, Shape nodeShape) { + if (!ShapeSearch.isObjectShape(nodeShape)) { + return; + } + + if (nodeShape instanceof MapShape mapShape) { + walkMap(kvps, mapShape); + } else { + walkAggregate(kvps, nodeShape); + } + } + + private void walkMap(Syntax.Node.Kvps kvps, MapShape mapShape) { + var keyMember = mapShape.getKey(); + var valueMember = mapShape.getValue(); + + boolean keyHasIdRef = keyMember.hasTrait(IdRefTrait.ID); + boolean valueHasIdRef = valueMember.hasTrait(IdRefTrait.ID); + + if (keyHasIdRef && valueHasIdRef) { + for (var kvp : kvps.kvps()) { + walkIdRef(kvp.key()); + walkIdRef(kvp.value()); + } + } else if (keyHasIdRef) { + var valueTarget = model.getShape(mapShape.getValue().getTarget()).orElse(null); + for (var kvp : kvps.kvps()) { + walkIdRef(kvp.key()); + walk(kvp.value(), valueTarget); + } + } else if (valueHasIdRef) { + var keyTarget = model.getShape(mapShape.getKey().getTarget()).orElse(null); + for (var kvp : kvps.kvps()) { + walk(kvp.key(), keyTarget); + walkIdRef(kvp.value()); + } + } else { + var keyTarget = getTarget(keyMember); + var valueTarget = getTarget(valueMember); + for (var kvp : kvps.kvps()) { + walk(kvp.key(), keyTarget); + walk(kvp.value(), valueTarget); + } + } + } + + private void walkAggregate(Syntax.Node.Kvps kvps, Shape nodeShape) { + for (var kvp : kvps.kvps()) { + var member = nodeShape.getMember(kvp.key().stringValue()).orElse(null); + if (member == null) { + continue; + } + + if (member.hasTrait(IdRefTrait.ID)) { + walkIdRef(kvp.value()); + } else { + var target = getTarget(member); + walk(kvp.value(), target); + } + } + } + + private void walkIdRef(Syntax.Node node) { + if (node instanceof Syntax.Node.Str str) { + references.addIdRef(str); + } + } + + private Shape getTarget(MemberShape member) { + return model.getShape(member.getTarget()).orElse(null); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java b/src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java new file mode 100644 index 00000000..32a46e91 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java @@ -0,0 +1,115 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceParams; +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.Shape; + +public record ReferencesHandler(Project project, IdlFile idlFile) { + /** + * @param params The request params + * @return A list of locations of the found refs + */ + public List handle(ReferenceParams params) { + var config = Config.create(project, idlFile, params.getPosition()); + var references = References.findReferences(config.model(), config.shape(), project); + return toLocations(references); + } + + record Config(DocumentId id, Shape shape, Model model, IdlFile definitionFile) { + static Config create(Project project, IdlFile idlFile, Position position) { + DocumentId id = idlFile.document().copyDocumentId(position); + if (id == null || id.idSlice().isEmpty()) { + throw notSupported(); + } + + var parseResult = idlFile.getParse(); + int documentIndex = idlFile.document().indexOfPosition(position); + var idlPosition = StatementView.createAt(parseResult, documentIndex) + .map(IdlPosition::of) + .orElse(null); + if (idlPosition == null) { + throw notSupported(); + } + + var model = project.modelResult().getResult().orElse(null); + if (model == null) { + throw noModel(); + } + + var shapeReference = ShapeSearch.getShapeReference(idlPosition, id, model); + if (shapeReference.isEmpty()) { + throw notSupported(); + } + + var shape = shapeReference.get(); + var definitionFile = project.getDefinitionFile(shape); + + IdlFile idlDefinitionFile = null; + if (definitionFile instanceof IdlFile idl) { + idlDefinitionFile = idl; + } + + return new Config(id, shape, model, idlDefinitionFile); + } + + private static ResponseErrorException notSupported() { + var error = new ResponseError(); + error.setCode(ResponseErrorCode.RequestFailed); + error.setMessage("Finding references not supported here."); + return new ResponseErrorException(error); + } + + private static ResponseErrorException noModel() { + var error = new ResponseError(); + error.setCode(ResponseErrorCode.RequestFailed); + error.setMessage("Model is too broken to find references."); + return new ResponseErrorException(error); + } + } + + private List toLocations(References references) { + List locations = new ArrayList<>(); + for (var fileReferences : references.fileReferences()) { + String uri = LspAdapter.toUri(fileReferences.idlFile().path()); + + for (var ref : fileReferences.refs()) { + addLocation(locations, uri, fileReferences.idlFile().document().rangeOfValue(ref)); + } + + for (var use : fileReferences.useRefs()) { + addLocation(locations, uri, fileReferences.idlFile().document().rangeOfValue(use.use())); + } + } + + for (var definitionRef : references.definitionReferences()) { + String uri = LspAdapter.toUri(definitionRef.idlFile().path()); + addLocation(locations, uri, definitionRef.idlFile().document().rangeOfValue(definitionRef.ref())); + } + + return locations; + } + + private void addLocation(List locations, String uri, Range range) { + if (range != null) { + locations.add(new Location(uri, range)); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java b/src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java new file mode 100644 index 00000000..827988fd --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java @@ -0,0 +1,259 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.RenameParams; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ShapeIdSyntaxException; + +public record RenameHandler(Project project, IdlFile idlFile) { + /** + * @param params The request params + * @return The range of the identifier to rename + */ + public Range prepare(PrepareRenameParams params) { + var config = ReferencesHandler.Config.create(project, idlFile, params.getPosition()); + return getRenameRange(config.id().range(), config.id().copyIdValue()); + } + + /** + * @param params The request params + * @return A workspace edit that applies the rename + */ + public WorkspaceEdit handle(RenameParams params) { + var config = ReferencesHandler.Config.create(project, idlFile, params.getPosition()); + var edits = getEdits(config, params.getNewName()); + return new WorkspaceEdit(edits); + } + + private Map> getEdits(ReferencesHandler.Config config, String newName) { + String namespace = config.shape().getId().getNamespace(); + + ShapeId renamedId; + try { + renamedId = ShapeId.fromRelative(namespace, newName); + } catch (ShapeIdSyntaxException e) { + throw invalidShapeId(e); + } + + var projectEdits = new ProjectEdits(config, newName, renamedId, new HashMap<>()); + projectEdits.collect(project); + + return projectEdits.edits; + } + + private record ProjectEdits( + ReferencesHandler.Config config, + String newName, + ShapeId renamedShapeId, + Map> edits + ) { + private enum FileEditType { + CONFLICT, + SIMPLE + } + + private void collect(Project project) { + var references = References.findReferences(config.model(), config.shape(), project); + + addEdits(references); + deconflictDefinition(); + } + + private void addEdits(References allReferences) { + for (var fileReferences : allReferences.fileReferences()) { + FileEditType fileEditType = getEditType(fileReferences.idlFile()); + if (fileEditType == FileEditType.CONFLICT) { + addConflictRenames(fileReferences, renamedShapeId.toString()); + } else { + addSimpleRenames(fileReferences); + } + } + + for (var definitionReference : allReferences.definitionReferences()) { + var uri = checkIfJar(definitionReference.idlFile()); + + addSimpleRename(uri, definitionReference.idlFile(), definitionReference.ref()); + } + } + + private void deconflictDefinition() { + var sourceFile = config.definitionFile(); + if (sourceFile == null) { + return; + } + + String conflictingId = getConflictingImport(sourceFile, newName); + if (conflictingId == null) { + return; + } + + var conflictingShape = ShapeSearch.findShape(sourceFile.getParse(), conflictingId, config.model()); + if (conflictingShape.isEmpty()) { + return; + } + + var references = References.findReferences(config.model(), conflictingShape.get(), sourceFile); + for (var fileReferences : references.fileReferences()) { + addConflictRenames(fileReferences, conflictingId); + } + // Note: No deconflict needed for the definition (plus it won't be picked up by allReferences) + } + + private void addConflictRenames(References.FileReferences fileReferences, String conflictingId) { + var uri = checkIfJar(fileReferences.idlFile()); + + for (var ref : fileReferences.refs()) { + var range = fileReferences.idlFile().document().rangeOfValue(ref); + if (range == null) { + continue; + } + + var referenceId = ref.stringValue(); + var renamedId = conflictingId; + if (referenceId.contains("$")) { + renamedId = conflictingId + "$" + referenceId.split("\\$")[1]; + } + + add(uri, range, renamedId); + } + + for (var use : fileReferences.useRefs()) { + var range = fileReferences.idlFile().document().rangeOf(use); + if (range == null) { + continue; + } + + add(uri, range, ""); + } + } + + private void addSimpleRenames(References.FileReferences fileReferences) { + var uri = checkIfJar(fileReferences.idlFile()); + + for (var ref : fileReferences.refs()) { + addSimpleRename(uri, fileReferences.idlFile(), ref); + } + + for (var use : fileReferences.useRefs()) { + var range = fileReferences.idlFile().document().rangeOfValue(use.use()); + if (range == null) { + continue; + } + + var referenceId = use.use().stringValue(); + var renameRange = getRenameRange(range, referenceId); + add(uri, renameRange, newName); + } + } + + private void addSimpleRename(String uri, IdlFile idlFile, Syntax.Node.Str ref) { + var range = idlFile.document().rangeOfValue(ref); + if (range == null) { + return; + } + + var referenceId = ref.stringValue(); + var renameRange = getRenameRange(range, referenceId); + add(uri, renameRange, newName); + } + + private String checkIfJar(IdlFile idlFile) { + String uri = LspAdapter.toUri(idlFile.path()); + if (!LspAdapter.isJarFile(uri) && !LspAdapter.isSmithyJarFile(uri)) { + return uri; + } + + throw referencedInJar(uri); + } + + private FileEditType getEditType(IdlFile idlFile) { + if (isDefinitionFile(idlFile) || !conflicts(idlFile)) { + return FileEditType.SIMPLE; + } else { + return FileEditType.CONFLICT; + } + } + + private boolean isDefinitionFile(IdlFile idlFile) { + return config.definitionFile() != null && config.definitionFile().path().equals(idlFile.path()); + } + + private boolean conflicts(IdlFile idlFile) { + if (renamedShapeId == null) { + return false; + } + + String fileNamespace = idlFile.getParse().namespace().namespace(); + if (!renamedShapeId.getNamespace().equals(fileNamespace)) { + ShapeId renamedCurrentScope = ShapeId.fromRelative(fileNamespace, newName); + if (config.model().getShape(renamedCurrentScope).isPresent()) { + return true; + } + } + + return getConflictingImport(idlFile, newName) != null; + } + + private void add(String uri, Range renamedRange, String renamed) { + var edit = new TextEdit(renamedRange, renamed); + edits.computeIfAbsent(uri, k -> new ArrayList<>()).add(edit); + } + } + + private static ResponseErrorException invalidShapeId(ShapeIdSyntaxException e) { + var responseError = new ResponseError(); + responseError.setCode(ResponseErrorCode.RequestFailed); + responseError.setMessage("Renamed shape id would be invalid: " + e.getMessage()); + return new ResponseErrorException(responseError); + } + + private static ResponseErrorException referencedInJar(String uri) { + var error = new ResponseError(); + error.setCode(ResponseErrorCode.RequestFailed); + error.setMessage("Can't rename shape referenced in jar: " + uri); + return new ResponseErrorException(error); + } + + private static Range getRenameRange(Range range, String idString) { + int originalStartCharacter = range.getStart().getCharacter(); + int hashIdx = idString.indexOf('#'); + if (hashIdx >= 0) { + int currentCharacter = range.getStart().getCharacter(); + range.getStart().setCharacter(currentCharacter + hashIdx + 1); + } + int dollarIdx = idString.indexOf('$'); + if (dollarIdx >= 0) { + range.getEnd().setCharacter(originalStartCharacter + dollarIdx); + } + return range; + } + + private static String getConflictingImport(IdlFile idlFile, String newName) { + String matcher = "#" + newName; + for (String imported : idlFile.getParse().imports().imports()) { + if (imported.endsWith(matcher)) { + return imported; + } + } + return null; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java index a0666c00..5ddf02fe 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -9,16 +9,19 @@ import java.util.List; import java.util.Optional; import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.document.DocumentImports; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.syntax.NodeCursor; import software.amazon.smithy.lsp.syntax.StatementView; import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.shapes.MapShape; import software.amazon.smithy.model.shapes.ResourceShape; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeIdSyntaxException; +import software.amazon.smithy.model.traits.IdRefTrait; /** * Provides methods to search for shapes, using context and syntax specific @@ -32,9 +35,9 @@ private ShapeSearch() { * Attempts to find a shape using a token, {@code nameOrId}. * *

When {@code nameOrId} does not contain a '#', this searches for shapes - * either in {@code idlParse}'s namespace, in {@code idlParse}'s - * imports, or the prelude, in that order. When {@code nameOrId} does contain - * a '#', it is assumed to be a full shape id and is searched for directly. + * either in {@code idlParse}'s imports, in {@code idlParse}'s namespace, or + * the prelude, in that order. When {@code nameOrId} does contain a '#', it + * is assumed to be a full shape id and is searched for directly. * * @param parseResult The parse result of the file {@code nameOrId} is within. * @param nameOrId The name or shape id of the shape to find. @@ -43,42 +46,69 @@ private ShapeSearch() { */ static Optional findShape(Syntax.IdlParseResult parseResult, String nameOrId, Model model) { return switch (nameOrId) { + case null -> Optional.empty(); + case String s when s.isEmpty() -> Optional.empty(); - case String s when s.contains("#") -> tryFrom(s).flatMap(model::getShape); - case String s -> { - Optional fromCurrent = tryFromParts(parseResult.namespace().namespace(), s) - .flatMap(model::getShape); - if (fromCurrent.isPresent()) { - yield fromCurrent; - } - for (String fileImport : parseResult.imports().imports()) { - Optional imported = tryFrom(fileImport) - .filter(importId -> importId.getName().equals(s)) - .flatMap(model::getShape); - if (imported.isPresent()) { - yield imported; - } - } + case String s when s.contains("#") -> tryFrom(s, model); - yield tryFromParts(Prelude.NAMESPACE, s).flatMap(model::getShape); - } - case null -> Optional.empty(); + default -> fromImports(parseResult.imports(), nameOrId, model) + .or(() -> tryFromRelative(parseResult.namespace().namespace(), nameOrId, model)) + .or(() -> tryFromRelative(Prelude.NAMESPACE, nameOrId, model)); }; } - private static Optional tryFrom(String id) { + private static Optional fromImports(DocumentImports imports, String nameOrId, Model model) { + if (imports.imports().isEmpty()) { + return Optional.empty(); + } + + if (nameOrId.contains("$")) { + // Relative member id, so it could be a member of an imported shape + String[] split = nameOrId.split("\\$"); + String containerName = split[0]; + String memberName = split[1]; + String matchString = "#" + containerName; + for (String fileImport : imports.imports()) { + if (fileImport.endsWith(matchString)) { + return tryWithMember(fileImport, memberName, model); + } + } + } else { + String matchString = "#" + nameOrId; + for (String fileImport : imports.imports()) { + if (fileImport.endsWith(matchString)) { + return tryFrom(fileImport, model); + } + } + } + + return Optional.empty(); + } + + private static Optional tryFrom(String id, Model model) { + try { + ShapeId shapeId = ShapeId.from(id); + return model.getShape(shapeId); + } catch (ShapeIdSyntaxException e) { + return Optional.empty(); + } + } + + private static Optional tryWithMember(String rootId, String memberName, Model model) { try { - return Optional.of(ShapeId.from(id)); - } catch (ShapeIdSyntaxException ignored) { + ShapeId shapeId = ShapeId.from(rootId).withMember(memberName); + return model.getShape(shapeId); + } catch (ShapeIdSyntaxException e) { return Optional.empty(); } } - private static Optional tryFromParts(String namespace, String name) { + private static Optional tryFromRelative(String namespace, String name, Model model) { try { - return Optional.of(ShapeId.fromRelative(namespace, name)); - } catch (ShapeIdSyntaxException ignored) { + ShapeId shapeId = ShapeId.fromRelative(namespace, name); + return model.getShape(shapeId); + } catch (ShapeIdSyntaxException e) { return Optional.empty(); } } @@ -147,6 +177,78 @@ private static Optional findShapeDefinitionInNodeMemberTarget( return Optional.empty(); } + static Optional getShapeReference(IdlPosition idlPosition, DocumentId id, Model model) { + Optional shape = switch (idlPosition) { + case IdlPosition.TraitValue traitValue -> traitValueReference(traitValue, id, model); + + case IdlPosition.NodeMemberTarget nodeMemberTarget -> + nodeMemberTargetReference(nodeMemberTarget, id, model); + + case IdlPosition pos when pos.isRootShapeReference() -> { + String nameOrId = id.copyIdValue(); + yield findShape(pos.view().parseResult(), nameOrId, model); + } + + default -> Optional.empty(); + }; + + return shape.filter(s -> !s.isMemberShape()); + } + + private static Optional traitValueReference(IdlPosition.TraitValue traitValue, DocumentId id, Model model) { + // Find the shape corresponding to the given traitValue position. + var searchResult = ShapeSearch.searchTraitValue(traitValue, model); + + // We only care about results that could be shape refs, so trait members + // or idRefs. + return switch (searchResult) { + case NodeSearch.Result.TerminalShape terminal when terminal.isIdRef() -> { + String nameOrId = id.copyIdValue(); + yield findShape(traitValue.view().parseResult(), nameOrId, model); + } + + case NodeSearch.Result.ObjectKey objectKey -> { + if (objectKey.containerShape() instanceof MapShape mapShape) { + if (mapShape.getKey().getMemberTrait(model, IdRefTrait.class).isPresent()) { + String nameOrId = id.copyIdValue(); + yield findShape(traitValue.view().parseResult(), nameOrId, model); + } + } + yield Optional.empty(); + } + + default -> Optional.empty(); + }; + } + + private static Optional nodeMemberTargetReference( + IdlPosition.NodeMemberTarget target, + DocumentId id, + Model model + ) { + var searchResult = ShapeSearch.searchNodeMemberTarget(target); + return switch (searchResult) { + // The cursor is on some node value nested within a member of a service, resource, or operation + // shape. When this value is supposed to represent a shape id, provide refs for that id. + case NodeSearch.Result.TerminalShape terminal when terminal.isIdRef() -> { + String nameOrId = id.copyIdValue(); + yield findShape(target.view().parseResult(), nameOrId, model); + } + + // The cursor is on some key of a node nested within a member of a service or resource shape. + // We want to provide refs when the key is a service closure shape rename. + case NodeSearch.Result.ObjectKey objectKey -> { + var containerId = objectKey.containerShape().getId(); + if (Builtins.SERVICE_RENAME_ID.equals(containerId)) { + yield findShape(target.view().parseResult(), objectKey.key().name(), model); + } else { + yield Optional.empty(); + } + } + default -> Optional.empty(); + }; + } + /** * @param forResource The nullable for-resource statement. * @param view A statement view containing the for-resource statement. 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 37aafcbb..0c00a391 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -203,6 +203,15 @@ public ProjectFile getProjectFile(String uri) { return buildFiles.getByPath(path); } + /** + * @param shape The shape to get the definition file of + * @return The file the shape is defined in, or {@code null} if the file + * isn't in this project + */ + public SmithyFile getDefinitionFile(Shape shape) { + return smithyFiles.get(shape.getSourceLocation().getFilename()); + } + public synchronized void validateConfig() { this.configEvents = ProjectConfigLoader.validateBuildFiles(buildFiles); } diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index 0e655796..7c9e5239 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -132,6 +132,10 @@ private void rewindTo(int pos) { this.rewind(pos, line + 1, pos - lineIndex + 1); } + private int currentLine() { + return line() - 1; + } + private Syntax.Node traitNode() { skip(); // '(' ws(); @@ -214,7 +218,7 @@ private Syntax.Node nodeIdent() { skip(); } while (!isWs() && !isStructuralBreakpoint() && !eof()); int end = position(); - return new Syntax.Ident(start, end, document.copySpan(start, end)); + return new Syntax.Ident(currentLine(), start, end, document.copySpan(start, end)); } private Syntax.Node.Obj obj() { @@ -393,12 +397,12 @@ private Syntax.Node str() { rewindTo(end + 3); int strEnd = position(); - return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 3, strEnd - 3)); + return new Syntax.Node.Str(currentLine(), start, strEnd, document.copySpan(start + 3, strEnd - 3)); } // Empty string int strEnd = position(); - return new Syntax.Node.Str(start, strEnd, ""); + return new Syntax.Node.Str(currentLine(), start, strEnd, ""); } int last = '"'; @@ -408,7 +412,7 @@ private Syntax.Node str() { if (is('"') && last != '\\') { skip(); // '"' int strEnd = position(); - return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 1, strEnd - 1)); + return new Syntax.Node.Str(currentLine(), start, strEnd, document.copySpan(start + 1, strEnd - 1)); } last = peek(); skip(); @@ -972,7 +976,7 @@ private Syntax.Ident ident() { addErr(start, end, "expected identifier"); return Syntax.Ident.EMPTY; } - return new Syntax.Ident(start, end, document.copySpan(start, end)); + return new Syntax.Ident(currentLine(), start, end, document.copySpan(start, end)); } private void addErr(int start, int end, String message) { diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java b/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java index b9884e38..3d2727cc 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java @@ -32,6 +32,15 @@ public static Optional createAtStart(Syntax.IdlParseResult parseR return createAt(parseResult, parseResult.statements().getFirst().start()); } + /** + * @param parseResult The parse result to create a view of + * @param statement The statement to create the view at + * @return An optional view of the given statement + */ + public static Optional createAt(Syntax.IdlParseResult parseResult, Syntax.Statement statement) { + return createAt(parseResult, statement.start()); + } + /** * @param parseResult The parse result to create a view of * @param documentIndex The index within the underlying document diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java index f6556d00..6da44e49 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -293,14 +293,20 @@ public List elements() { * identifiers, so this class a single subclass {@link Ident}. */ public static sealed class Str extends Node { + final int lineNumber; final String value; - Str(int start, int end, String value) { + Str(int lineNumber, int start, int end, String value) { + this.lineNumber = lineNumber; this.start = start; this.end = end; this.value = value; } + public int lineNumber() { + return lineNumber; + } + public String stringValue() { return value; } @@ -798,10 +804,10 @@ public String message() { * (i.e. `.`, `#`, `$`, `_` digits, alphas). */ public static final class Ident extends Node.Str { - static final Ident EMPTY = new Ident(-1, -1, ""); + static final Ident EMPTY = new Ident(-1, -1, -1, ""); - Ident(int start, int end, String value) { - super(start, end, value); + Ident(int lineNumber, int start, int end, String value) { + super(lineNumber, start, end, value); } public boolean isEmpty() { diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index 2c4b889a..9dc5476f 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -9,6 +9,7 @@ import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; @@ -157,4 +158,33 @@ public void describeMismatchSafely(InlayHint item, Description description) { } }; } + + public static Matcher isLocationIncluding(String uri, Position position) { + return new CustomTypeSafeMatcher<>("a location in " + uri + " on the same line of, and including " + position) { + @Override + protected boolean matchesSafely(Location item) { + return rangeMatches(item.getRange()) && item.getUri().equals(uri); + } + + private boolean rangeMatches(Range range) { + var start = range.getStart(); + var end = range.getEnd(); + return start.getLine() == position.getLine() + && end.getLine() == position.getLine() + && start.getCharacter() <= position.getCharacter() + && end.getCharacter() > position.getCharacter(); + } + + @Override + protected void describeMismatchSafely(Location item, Description mismatchDescription) { + if (!item.getUri().equals(uri)) { + mismatchDescription.appendText("uri was " + item.getUri()); + } + + if (!rangeMatches(item.getRange())) { + mismatchDescription.appendText("range was " + item.getRange()); + } + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java index e0f2bc9e..055228c8 100644 --- a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -26,7 +26,11 @@ import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PrepareRenameParams; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceContext; +import org.eclipse.lsp4j.ReferenceParams; +import org.eclipse.lsp4j.RenameParams; import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextDocumentItem; @@ -242,6 +246,29 @@ public CompletionParams buildCompletion() { new Position(line, character), new CompletionContext(CompletionTriggerKind.Invoked)); } + + public ReferenceParams buildReference() { + return new ReferenceParams( + new TextDocumentIdentifier(uri), + new Position(line, character), + new ReferenceContext(true) + ); + } + + public RenameParams buildRename(String newName) { + return new RenameParams( + new TextDocumentIdentifier(uri), + new Position(line, character), + newName + ); + } + + public PrepareRenameParams buildPrepareRename() { + return new PrepareRenameParams( + new TextDocumentIdentifier(uri), + new Position(line, character) + ); + } } public static final class DidChangeWatchedFiles { diff --git a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java index 60bd2872..2d81afae 100644 --- a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java +++ b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java @@ -39,7 +39,8 @@ public static TextWithPositions from(String raw) { Document document = Document.of(safeString(raw)); List positions = new ArrayList<>(); - Position lastPosition = null; + int lastLine = -1; + int lineMarkerCount = 0; int i = 0; while (true) { int next = document.nextIndexOf(POSITION_MARKER, i); @@ -47,12 +48,16 @@ public static TextWithPositions from(String raw) { break; } Position position = document.positionAtIndex(next); - if (lastPosition != null && position.getLine() == lastPosition.getLine()) { - // If there's two or more markers on the same line, any markers after the - // first will be off by one when we do the replacement. - position.setCharacter(position.getCharacter() - 1); + + // If there's two or more markers on the same line, any markers after the + // first will be off by one when we do the replacement. + if (position.getLine() != lastLine) { + lastLine = position.getLine(); + lineMarkerCount = 1; + } else { + position.setCharacter(position.getCharacter() - lineMarkerCount); + lineMarkerCount++; } - lastPosition = position; positions.add(position); i = next + 1; } diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java index 58b3f6d0..f2af0163 100644 --- a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -5,6 +5,8 @@ package software.amazon.smithy.lsp; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.Optional; @@ -67,4 +69,32 @@ protected void describeMismatchSafely(Path item, Description mismatchDescription } }; } + + public static Matcher stringEquals(String expected) { + return new CustomTypeSafeMatcher<>(expected) { + @Override + protected boolean matchesSafely(String item) { + return safeString(expected).equals(item); + } + + @Override + protected void describeMismatchSafely(String item, Description mismatchDescription) { + mismatchDescription.appendText("was: " + item); + } + }; + } + + public static Matcher throwsWithMessage(Matcher message) { + return new CustomTypeSafeMatcher<>("Throws " + message) { + @Override + protected boolean matchesSafely(Runnable item) { + try { + item.run(); + return false; + } catch (Exception e) { + return message.matches(e.getMessage()); + } + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java index 32e9e0e0..80ca1788 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -16,6 +16,7 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.protocol.RangeBuilder; public class DocumentTest { @@ -311,45 +312,47 @@ public void getsLineOfIndex() { } @Test - public void borrowsDocumentShapeId() { - Document empty = makeDocument(""); - Document notId = makeDocument("?!&"); - Document onlyId = makeDocument("abc"); - Document split = makeDocument("abc.def hij"); - Document technicallyBroken = makeDocument("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); - Document technicallyValid = makeDocument("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); - - assertThat(empty.copyDocumentId(new Position(0, 0)), nullValue()); - assertThat(notId.copyDocumentId(new Position(0, 0)), nullValue()); - assertThat(notId.copyDocumentId(new Position(0, 2)), nullValue()); - assertThat(onlyId.copyDocumentId(new Position(0, 0)), documentShapeId("abc", DocumentId.Type.ID)); - assertThat(onlyId.copyDocumentId(new Position(0, 2)), documentShapeId("abc", DocumentId.Type.ID)); - assertThat(onlyId.copyDocumentId(new Position(0, 3)), nullValue()); - assertThat(split.copyDocumentId(new Position(0, 0)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); - assertThat(split.copyDocumentId(new Position(0, 6)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); - assertThat(split.copyDocumentId(new Position(0, 7)), nullValue()); - assertThat(split.copyDocumentId(new Position(0, 8)), documentShapeId("hij", DocumentId.Type.ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 0)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 3)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 7)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 9)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 16)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 18)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 25)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 27)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 30)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 37)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 39)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 43)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 49)), documentShapeId("$foo", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 54)), documentShapeId(".foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 59)), documentShapeId("#foo", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 0)), documentShapeId("com.foo#bar", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 12)), documentShapeId("com.foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 28)), documentShapeId("com.foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 36)), documentShapeId("foo#bar", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 44)), documentShapeId("foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 56)), documentShapeId("foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); + public void copiesDocumentIds() { + assertThat("%", isDocumentShapeId(nullValue())); + + assertThat("%?!&", isDocumentShapeId(nullValue())); + assertThat("?!&%", isDocumentShapeId(nullValue())); + + assertThat("%abc.def hij", isDocumentShapeId(withValueAndType("abc.def", DocumentId.Type.ROOT))); + assertThat("abc.def% hij", isDocumentShapeId(nullValue())); + assertThat("abc.def %hij", isDocumentShapeId(withValueAndType("hij", DocumentId.Type.ROOT))); + assertThat("abc.def hij%", isDocumentShapeId(nullValue())); + + makeDocument("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); + makeDocument("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); + + assertThat("%com.foo#bar", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + assertThat("com.foo#%bar", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + + assertThat("%com.foo#bar$baz", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + assertThat("com.foo#%bar$baz", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + assertThat("com.foo#bar$%baz", isDocumentShapeId(withValueAndType("com.foo#bar$baz", DocumentId.Type.MEMBER))); + assertThat("com.foo#bar%$baz", isDocumentShapeId(withValueAndType("com.foo#bar$baz", DocumentId.Type.MEMBER))); + + assertThat("%foo$bar", isDocumentShapeId(withValueAndType("foo", DocumentId.Type.ROOT))); + assertThat("fo%o$bar", isDocumentShapeId(withValueAndType("foo", DocumentId.Type.ROOT))); + + assertThat("foo%$bar", isDocumentShapeId(withValueAndType("foo$bar", DocumentId.Type.MEMBER))); + assertThat("foo$%bar", isDocumentShapeId(withValueAndType("foo$bar", DocumentId.Type.MEMBER))); + + assertThat("%$foo", isDocumentShapeId(withValueAndType("$foo", DocumentId.Type.MEMBER))); + } + + public static Matcher isDocumentShapeId(Matcher matcher) { + return new CustomTypeSafeMatcher<>("a DocumentShapeId matching " + matcher) { + @Override + protected boolean matchesSafely(String item) { + var twp = TextWithPositions.from(item); + var document = Document.of(twp.text()); + var id = document.copyDocumentId(twp.positions()[0]); + return matcher.matches(id); + } + }; } // This is used to convert the character offset in a file that assumes a single character @@ -366,7 +369,7 @@ public static int safeIndex(int standardOffset, int line) { public static String safeString(String s) { return s.replace("\n", System.lineSeparator()); } - + private static Document makeDocument(String s) { return Document.of(safeString(s)); } @@ -387,11 +390,11 @@ public void describeMismatchSafely(CharSequence item, Description description) { }; } - public static Matcher documentShapeId(String other, DocumentId.Type type) { + public static Matcher withValueAndType(String other, DocumentId.Type type) { return new CustomTypeSafeMatcher<>(other + " with type: " + type) { @Override protected boolean matchesSafely(DocumentId item) { - return other.equals(item.copyIdValue()) && item.type() == type; + return item != null && other.equals(item.copyIdValue()) && item.type() == type; } }; } diff --git a/src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java new file mode 100644 index 00000000..670a6014 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java @@ -0,0 +1,329 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.everyItem; +import static software.amazon.smithy.lsp.LspMatchers.isLocationIncluding; +import static software.amazon.smithy.lsp.UtilMatchers.throwsWithMessage; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.ReferenceParams; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; + +public class ReferencesHandlerTest { + @Test + public void shapeDef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string %Foo + + structure Bar { + foo: %Foo + } + + resource Baz { + identifiers: { + foo: %Foo + } + properties: { + foo: %Foo + } + put: %Foo + } + + service Bux { + operations: [%Foo] + rename: { + "%com.foo#Foo": "Renamed" + } + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void traitId() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure %myTrait { + ref: ShapeId + } + + @idRef + string ShapeId + + @%myTrait + string Foo + + @%myTrait(ref: %myTrait) + string Bar + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void idRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + ref: %ShapeId + } + + @idRef + string %ShapeId + + @myTrait(ref: %ShapeId) + string Foo + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void stringIdRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: "%com.foo#Foo") + string %Foo + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void mapIdRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + map myTrait { + @idRef + key: String + + @idRef + value: String + } + + @myTrait( + "%com.foo#Foo": %Foo + "%com.foo#Foo$foo": %Foo$foo + ) + structure %Foo { + foo: %Foo + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void rootShapeReferencesIncludeIdsWithMembers() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %Foo$foo) + structure %Foo { + foo: String + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void inlineIoReferences() { + // No refs on the actual inline shape def. It isn't named in the text, so + // don't consider it a ref. + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %OpInput) + operation Op { + input := {} + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void serviceRenameReferences() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + service Foo { + version: "1" + rename: { + "%com.foo#Bar": "Baz" + } + } + + @myTrait(ref: %Bar) + structure %Bar {} + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void referencesInNodeMembers() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: %Bar + } + + resource Foo { + identifiers: { + id: %Bar + } + } + + @myTrait(ref: %Bar) + string %Bar + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void unsupportedReferences() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @mixin + @myTrait(ref: Foo$%foo) + structure Foo { + %foo: String + } + + operation Op { + %input := { + %foo: String + } + %output: OpOutput + %errors: [] + } + + structure OpOutput with [Foo] { + %$foo + } + """); + var workspace = TestWorkspace.singleModel(twp.text()); + var project = ProjectTest.load(workspace.getRoot()); + var uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + for (var position : twp.positions()) { + assertThat(() -> ReferencesHandler.Config.create(project, idlFile, position), + throwsWithMessage(containsString("not supported"))); + } + } + + private static void assertHasAllLocations(GetLocationsResult result, Position... positions) { + String uri = result.workspace.getUri("main.smithy"); + List> matchers = new ArrayList<>(); + for (Position position : positions) { + matchers.add(isLocationIncluding(uri, position)); + } + assertThat(result.locations, everyItem(containsInAnyOrder(matchers))); + } + + private record GetLocationsResult(TestWorkspace workspace, List> locations) {} + + private static GetLocationsResult getLocations(TextWithPositions twp) { + TestWorkspace workspace = TestWorkspace.singleModel(twp.text()); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + List> locations = new ArrayList<>(); + ReferencesHandler handler = new ReferencesHandler(project, idlFile); + for (Position position : twp.positions()) { + ReferenceParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildReference(); + var result = handler.handle(params); + locations.add(new ArrayList<>(result)); + } + + return new GetLocationsResult(workspace, locations); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java new file mode 100644 index 00000000..d665299b --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java @@ -0,0 +1,1016 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static software.amazon.smithy.lsp.UtilMatchers.stringEquals; +import static software.amazon.smithy.lsp.UtilMatchers.throwsWithMessage; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; + +public class RenameHandlerTest { + @Test + public void renamesRootShapesInTheSameFile() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string %Foo + + @myTrait(ref: %Foo) + structure Bar { + foo: %Foo + } + """); + var result = getEdits("Baz", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string Baz + + @myTrait(ref: Baz) + structure Bar { + foo: Baz + } + """); + } + + @Test + public void renamesAbsoluteIds() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string %Foo + + @myTrait(ref: com.foo#%Foo) + structure Bar { + foo: com.foo#%Foo + } + """); + var result = getEdits("Baz", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string Baz + + @myTrait(ref: com.foo#Baz) + structure Bar { + foo: com.foo#Baz + } + """); + } + + @Test + public void renamesRootShapeAbsoluteIdsWithMember() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %com.foo#Foo$foo) + structure %Foo { + foo: String + } + """); + var result = getEdits("Bar", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: com.foo#Bar$foo) + structure Bar { + foo: String + } + """); + } + + @Test + public void multiFileSameNamespace() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @trait + structure myTrait { + @idRef + ref: String + } + + string %Foo + + @myTrait(ref: com.foo#%Foo) + structure Bar { + foo: %Foo + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @myTrait(ref: com.foo#%Foo) + structure Abc { + foo: %Foo + } + """); + var result = getEdits("Baz", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edited0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edited0, stringEquals(""" + $version: "2" + namespace com.foo + @trait + structure myTrait { + @idRef + ref: String + } + + string Baz + + @myTrait(ref: com.foo#Baz) + structure Bar { + foo: Baz + } + """)); + var edited1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edited1, stringEquals(""" + $version: "2" + namespace com.foo + + @myTrait(ref: com.foo#Baz) + structure Abc { + foo: Baz + } + """)); + } + } + + @Test + public void differentNamespaces() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + + structure Bar { + foo: %Foo + } + """); + var result = getEdits("Baz", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edited0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edited0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Baz {} + """)); + + var edited1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edited1, stringEquals(""" + $version: "2" + namespace com.bar + + use com.foo#Baz + + structure Bar { + foo: Baz + } + """)); + } + } + + @Test + public void localConflicts() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + + structure Bar { + foo: %Foo + } + + string Baz + """); + var result = getEdits("Baz", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edited0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edited0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Baz {} + """)); + + var edited1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + // Note: Formatter can take care of cleaning this up. + assertThat(edited1, stringEquals(""" + $version: "2" + namespace com.bar + + + + structure Bar { + foo: com.foo#Baz + } + + string Baz + """)); + } + } + + @Test + public void importConflicts() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + use com.baz#Baz + + structure Bar { + foo: %Foo + baz: Baz + } + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.baz + + structure Baz {} + """); + var result = getEdits("Baz", twp1, twp2, twp3); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Baz {} + """)); + var edit1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edit1, stringEquals(""" + $version: "2" + namespace com.bar + + + use com.baz#Baz + + structure Bar { + foo: com.foo#Baz + baz: Baz + } + """)); + + String uri = result.workspace.getUri("model-2.smithy"); + var unrelatedEdit = workspaceEdit.getChanges().get(uri); + assertThat(unrelatedEdit, nullValue()); + } + } + + @Test + public void importConflictsInDefinitionFile() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure %Foo { + foo: %Foo + bar: Bar + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + structure Bar {} + """); + var result = getEdits("Bar", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + structure Bar { + foo: Bar + bar: com.bar#Bar + } + """)); + + String uri = result.workspace.getUri("model-1.smithy"); + var unrelatedEdit = workspaceEdit.getChanges().get(uri); + assertThat(unrelatedEdit, nullValue()); + } + } + + @Test + public void importConflictsInSameNamespaceFile() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + structure Bar {} + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure Baz { + foo: %Foo + bar: Bar + } + """); + var result = getEdits("Bar", twp1, twp2, twp3); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Bar {} + """)); + + String uri = result.workspace.getUri("model-1.smithy"); + var unrelatedEdit = workspaceEdit.getChanges().get(uri); + assertThat(unrelatedEdit, nullValue()); + + var edit2 = getEditedText(result.workspace, workspaceEdit, "model-2.smithy"); + assertThat(edit2, stringEquals(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure Baz { + foo: com.foo#Bar + bar: Bar + } + """)); + } + } + + @Test + public void importConflictsAcrossFiles() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + + structure Bar { + foo: %Foo + } + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure A { + foo: %Foo + bar: Bar + } + """); + var twp4 = TextWithPositions.from(""" + $version: "2" + namespace com.baz + + use com.foo#%Foo + use com.bar#Bar + + structure B { + foo: %Foo + bar: Bar + } + """); + var result = getEdits("Bar", twp1, twp2, twp3, twp4); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3, twp4); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + structure Bar {} + """)); + + var edit1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edit1, stringEquals(""" + $version: "2" + namespace com.bar + + + + structure Bar { + foo: com.foo#Bar + } + """)); + + var edit2 = getEditedText(result.workspace, workspaceEdit, "model-2.smithy"); + assertThat(edit2, stringEquals(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure A { + foo: com.foo#Bar + bar: Bar + } + """)); + + var edit3 = getEditedText(result.workspace, workspaceEdit, "model-3.smithy"); + assertThat(edit3, stringEquals(""" + $version: "2" + namespace com.baz + + + use com.bar#Bar + + structure B { + foo: com.foo#Bar + bar: Bar + } + """)); + } + } + + @Test + public void importConflictsInTraitsSimple() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + @trait + structure myTrait { + @idRef + ref: String + } + + structure %Foo { + @myTrait(ref: Bar$bar) + bar: Bar + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#myTrait + + structure Bar { + bar: Bar + } + """); + var result = getEdits("Bar", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + @trait + structure myTrait { + @idRef + ref: String + } + + structure Bar { + @myTrait(ref: com.bar#Bar$bar) + bar: com.bar#Bar + } + """)); + } + } + + @Test + public void importConflictsInTraits() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: Bar) + structure %Foo { + @myTrait(ref: com.bar#Bar) + bar: Bar + + @myTrait(ref: com.bar#Bar$bar) + bar2: com.bar#Bar + + @myTrait(ref: Bar$bar) + foo: %Foo + + @myTrait(ref: %Foo$bar) + foo2: com.foo#%Foo + + @myTrait(ref: com.foo#%Foo$bar) + foo3: %Foo + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#myTrait + use com.foo#%Foo + + @myTrait(ref: %Foo) + structure Bar { + @myTrait(ref: com.foo#%Foo) + foo: %Foo + + @myTrait(ref: com.foo#%Foo$bar) + foo2: com.foo#%Foo + + @myTrait(ref: Foo$bar) + foo3: %Foo + + bar: Bar + } + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.baz + + use com.foo#myTrait + use com.foo#%Foo + use com.bar#Bar + + @myTrait(ref: %Foo) + structure Baz { + @myTrait(ref: com.foo#%Foo) + foo: %Foo + + @myTrait(ref: com.foo#%Foo$bar) + bar: Bar + + @myTrait(ref: %Foo$bar) + foo2: com.foo#%Foo + } + """); + var result = getEdits("Bar", twp1, twp2, twp3); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: com.bar#Bar) + structure Bar { + @myTrait(ref: com.bar#Bar) + bar: com.bar#Bar + + @myTrait(ref: com.bar#Bar$bar) + bar2: com.bar#Bar + + @myTrait(ref: com.bar#Bar$bar) + foo: Bar + + @myTrait(ref: Bar$bar) + foo2: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + foo3: Bar + } + """)); + + var edit1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edit1, stringEquals(""" + $version: "2" + namespace com.bar + + use com.foo#myTrait + + + @myTrait(ref: com.foo#Bar) + structure Bar { + @myTrait(ref: com.foo#Bar) + foo: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + foo2: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + foo3: com.foo#Bar + + bar: Bar + } + """)); + + var edit2 = getEditedText(result.workspace, workspaceEdit, "model-2.smithy"); + assertThat(edit2, stringEquals(""" + $version: "2" + namespace com.baz + + use com.foo#myTrait + + use com.bar#Bar + + @myTrait(ref: com.foo#Bar) + structure Baz { + @myTrait(ref: com.foo#Bar) + foo: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + bar: Bar + + @myTrait(ref: com.foo#Bar$bar) + foo2: com.foo#Bar + } + """)); + } + } + + @Test + public void multipleEditsOnSameLine() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + + @idRef + ref2: String + } + + @myTrait(ref: %Foo, ref2: %Foo) + structure %Foo { + foo: %Foo + } + """); + var result = getEdits("A", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + + @idRef + ref2: String + } + + @myTrait(ref: A, ref2: A) + structure A { + foo: A + } + """); + } + + @Test + public void stringIdRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: "%com.foo#Foo") + structure %Foo { + @myTrait(ref: "%com.foo#Foo$foo") + foo: String + } + """); + var result = getEdits("Bar", twp); + + assertHasEditsForAllPositions(result, twp); + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: "com.foo#Bar") + structure Bar { + @myTrait(ref: "com.foo#Bar$foo") + foo: String + } + """); + } + + @Test + public void prepare() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %com.foo#%Foo%$foo) + structure %Foo% { + @myTrait(ref: %Foo%$foo) + foo: %Foo% + } + + @myTrait(ref: %Foo%) + string Bar + """); + var onNs = twp.positions()[0]; + var onAbsNameWithMember = twp.positions()[1]; + var onAbsNameWithMemberEnd = twp.positions()[2]; + var onDef = twp.positions()[3]; + var onDefEnd = twp.positions()[4]; + var onRelNameWithMember = twp.positions()[5]; + var onRelNameWithMemberEnd = twp.positions()[6]; + var onTarget = twp.positions()[7]; + var onTargetEnd = twp.positions()[8]; + var onRelName = twp.positions()[9]; + var onRelNameEnd = twp.positions()[10]; + + TestWorkspace workspace = TestWorkspace.singleModel(twp.text()); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + RenameHandler handler = new RenameHandler(project, idlFile); + + var rangeOnNs = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onNs).buildPrepareRename()); + var rangeOnAbsNameWithMember = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onAbsNameWithMember).buildPrepareRename()); + var rangeOnDef = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onDef).buildPrepareRename()); + var rangeOnRelNameWithMember = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onRelNameWithMember).buildPrepareRename()); + var rangeOnTarget = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onTarget).buildPrepareRename()); + var rangeOnRelName = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onRelName).buildPrepareRename()); + + assertThat(rangeOnNs, equalTo(new Range(onAbsNameWithMember, onAbsNameWithMemberEnd))); + assertThat(rangeOnAbsNameWithMember, equalTo(new Range(onAbsNameWithMember, onAbsNameWithMemberEnd))); + assertThat(rangeOnDef, equalTo(new Range(onDef, onDefEnd))); + assertThat(rangeOnRelNameWithMember, equalTo(new Range(onRelNameWithMember, onRelNameWithMemberEnd))); + assertThat(rangeOnTarget, equalTo(new Range(onTarget, onTargetEnd))); + assertThat(rangeOnRelName, equalTo(new Range(onRelName, onRelNameEnd))); + } + + @Test + public void invalidShapeId() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string %Foo + """); + + assertThat(() -> getEdits("123", twp), throwsWithMessage(containsString("id would be invalid"))); + } + + @Test + public void referenceInJar() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure Foo { + foo: %String + } + """); + + assertThat(() -> getEdits("Bar", twp), throwsWithMessage(containsString("jar"))); + } + + private static void assertHasEditsForAllPositions(GetEditsResult result, TextWithPositions... twps) { + int sum = 0; + for (TextWithPositions twp : twps) { + sum += twp.positions().length; + } + assertThat(result.edits, hasSize(sum)); + } + + private static void assertAllEditsMake(GetEditsResult result, String expected) { + assertThat(result.edits, not(empty())); + + for (var edit : result.edits) { + var editedText = getEditedText(result.workspace, edit, "model-0.smithy"); + assertThat(editedText, stringEquals(expected)); + } + } + + private static String getEditedText(TestWorkspace workspace, WorkspaceEdit edit, String filename) { + String uri = workspace.getUri(filename); + var textEdits = edit.getChanges().get(uri); + assertThat(textEdits, notNullValue()); + assertThat(textEdits, not(empty())); + + String text = workspace.readFile(filename); + var document = Document.of(text); + // Edits have to be applied in reverse order so that an edit earlier in the + // file doesn't clobber the range a later edit would occupy. + textEdits.sort((l, r) -> { + int lIdx = document.indexOfPosition(l.getRange().getStart()); + int rIdx = document.indexOfPosition(r.getRange().getStart()); + return Integer.compare(rIdx, lIdx); + }); + + for (var textEdit : textEdits) { + var s = document.indexOfPosition(textEdit.getRange().getStart()); + var e = document.indexOfPosition(textEdit.getRange().getEnd()); + var span = document.copySpan(s, e); + document.applyEdit(textEdit.getRange(), textEdit.getNewText()); + var tmp = document.copyText(); + System.out.println(); + } + return document.copyText(); + } + + private record GetEditsResult(TestWorkspace workspace, List edits) {} + + private static GetEditsResult getEdits(String newName, TextWithPositions... twps) { + var files = Arrays.stream(twps).map(TextWithPositions::text).toArray(String[]::new); + TestWorkspace workspace = TestWorkspace.multipleModels(files); + Project project = ProjectTest.load(workspace.getRoot()); + List edits = new ArrayList<>(); + for (int i = 0; i < twps.length; i++) { + String uri = workspace.getUri("model-" + i + ".smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + RenameHandler handler = new RenameHandler(project, idlFile); + for (Position position : twps[i].positions()) { + var params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildRename(newName); + var edit = handler.handle(params); + edits.add(edit); + } + } + return new GetEditsResult(workspace, edits); + } +} From 4941b5022869f3669f15561abcbd00eba5fe0f77 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 27 Mar 2025 16:49:51 -0400 Subject: [PATCH 32/43] Simplify NodeSearch (#215) Previously, NodeSearch used NodeCursor::setCheckpoint to store the current position in the cursor, inspect previous edges for computing dynamic member targets, and return to the checkpoint to continue traversal. Maybe I thought there were complex use-cases that had to look further back in the path, but right now all we need is to look at the previous Node. So I updated NodeSearch to use the previous Node to compute dynamic member targets, and removed the checkpoint stuff from NodeCursor. --- .../lsp/language/DynamicMemberTarget.java | 27 ++++++------- .../smithy/lsp/language/NodeSearch.java | 18 ++++----- .../amazon/smithy/lsp/syntax/NodeCursor.java | 38 +------------------ 3 files changed, 21 insertions(+), 62 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java index 70082804..503305aa 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java +++ b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java @@ -29,11 +29,11 @@ */ sealed interface DynamicMemberTarget { /** - * @param cursor The cursor being used to traverse the model. + * @param parent The parent node containing the member. * @param model The model being traversed. * @return The target of the member shape at the cursor's current position. */ - Shape getTarget(NodeCursor cursor, Model model); + Shape getTarget(Syntax.Node parent, Model model); static Map forTrait(Shape traitShape, IdlPosition.TraitValue traitValue) { Syntax.IdlParseResult syntaxInfo = traitValue.view().parseResult(); @@ -84,7 +84,7 @@ static Map forMetadata(String metadataKey) { */ record OperationInput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget { @Override - public Shape getTarget(NodeCursor cursor, Model model) { + public Shape getTarget(Syntax.Node parent, Model model) { return ShapeSearch.findTraitTarget(traitValue, model) .flatMap(Shape::asOperationShape) .flatMap(operationShape -> model.getShape(operationShape.getInputShape())) @@ -100,7 +100,7 @@ public Shape getTarget(NodeCursor cursor, Model model) { */ record OperationOutput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget { @Override - public Shape getTarget(NodeCursor cursor, Model model) { + public Shape getTarget(Syntax.Node parent, Model model) { return ShapeSearch.findTraitTarget(traitValue, model) .flatMap(Shape::asOperationShape) .flatMap(operationShape -> model.getShape(operationShape.getOutputShape())) @@ -117,8 +117,8 @@ public Shape getTarget(NodeCursor cursor, Model model) { */ record ShapeIdDependent(String memberName, Syntax.IdlParseResult parseResult) implements DynamicMemberTarget { @Override - public Shape getTarget(NodeCursor cursor, Model model) { - Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor); + public Shape getTarget(Syntax.Node parent, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, parent); if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) { String id = str.stringValue(); return ShapeSearch.findShape(parseResult, id, model).orElse(null); @@ -138,8 +138,8 @@ public Shape getTarget(NodeCursor cursor, Model model) { */ record MappedDependent(String memberName, Map mapping) implements DynamicMemberTarget { @Override - public Shape getTarget(NodeCursor cursor, Model model) { - Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor); + public Shape getTarget(Syntax.Node parent, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, parent); if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) { String value = str.stringValue(); ShapeId targetId = mapping.get(value); @@ -155,15 +155,10 @@ public Shape getTarget(NodeCursor cursor, Model model) { // comparison to parsing or NodeCursor construction, which are optimized for // speed and memory usage (instead of key lookup), and the number of keys // is assumed to be low in most cases. - private static Syntax.Node.Kvp findMatchingKvp(String keyName, NodeCursor cursor) { + private static Syntax.Node.Kvp findMatchingKvp(String keyName, Syntax.Node parent) { // This will be called after skipping a ValueForKey, so that will be previous - if (!cursor.hasPrevious()) { - // TODO: Log - return null; - } - NodeCursor.Edge edge = cursor.previous(); - if (edge instanceof NodeCursor.ValueForKey(var ignored, Syntax.Node.Kvps parent)) { - for (Syntax.Node.Kvp kvp : parent.kvps()) { + if (parent instanceof Syntax.Node.Kvps kvps) { + for (Syntax.Node.Kvp kvp : kvps.kvps()) { String key = kvp.key().stringValue(); if (!keyName.equals(key)) { continue; diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java index 8a3956b4..686a8c6c 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java @@ -194,11 +194,11 @@ private Result searchObj(NodeCursor cursor, NodeCursor.Obj obj, Shape shape) { case NodeCursor.Key key -> new Result.ObjectKey(key, shape, model); - case NodeCursor.ValueForKey ignored - when shape instanceof MapShape map -> searchTarget(cursor, map.getValue()); + case NodeCursor.ValueForKey value + when shape instanceof MapShape map -> searchTarget(cursor, value.parent(), map.getValue()); case NodeCursor.ValueForKey value -> shape.getMember(value.keyName()) - .map(member -> searchTarget(cursor, member)) + .map(member -> searchTarget(cursor, value.parent(), member)) .orElse(Result.NONE); default -> Result.NONE; @@ -213,13 +213,13 @@ private Result searchArr(NodeCursor cursor, NodeCursor.Arr arr, ListShape shape) return switch (cursor.next()) { case NodeCursor.Terminal ignored -> new Result.ArrayShape(arr.node(), shape, model); - case NodeCursor.Elem ignored -> searchTarget(cursor, shape.getMember()); + case NodeCursor.Elem elem -> searchTarget(cursor, elem.parent(), shape.getMember()); default -> Result.NONE; }; } - protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) { + protected Result searchTarget(NodeCursor cursor, Syntax.Node parent, MemberShape memberShape) { return search(cursor, model.getShape(memberShape.getTarget()).orElse(null), memberShape); } } @@ -236,18 +236,16 @@ private SearchWithDynamicMemberTargets( } @Override - protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) { + protected Result searchTarget(NodeCursor cursor, Syntax.Node parent, MemberShape memberShape) { DynamicMemberTarget dynamicMemberTarget = dynamicMemberTargets.get(memberShape.getId()); if (dynamicMemberTarget != null) { - cursor.setCheckpoint(); - Shape target = dynamicMemberTarget.getTarget(cursor, model); - cursor.returnToCheckpoint(); + Shape target = dynamicMemberTarget.getTarget(parent, model); if (target != null) { return search(cursor, target, memberShape); } } - return super.searchTarget(cursor, memberShape); + return super.searchTarget(cursor, parent, memberShape); } } } diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java index ac1cc3a5..9546a06e 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java @@ -10,14 +10,12 @@ /** * A moveable index into a path from the root of a {@link Syntax.Node} to a - * position somewhere within that node. The path supports iteration both - * forward and backward, as well as storing a 'checkpoint' along the path - * that can be returned to at a later point. + * position somewhere within that node. The path supports iteration forward + * only. */ public final class NodeCursor { private final List edges; private int pos = 0; - private int checkpoint = 0; NodeCursor(List edges) { this.edges = edges; @@ -117,22 +115,6 @@ public Edge next() { return edge; } - /** - * @return Whether the cursor is not at the start of the path. A return value - * of {@code true} means {@link #previous()} may be called safely. - */ - public boolean hasPrevious() { - return edges.size() - pos >= 0; - } - - /** - * @return The previous edge along the path. Also moves the cursor backward. - */ - public Edge previous() { - pos--; - return edges.get(pos); - } - /** * @return Whether the path consists of a single, terminal, node. */ @@ -140,22 +122,6 @@ public boolean isTerminal() { return edges.size() == 1 && edges.getFirst() instanceof Terminal; } - /** - * Store the current cursor position to be returned to later. Subsequent - * calls overwrite the checkpoint. - */ - public void setCheckpoint() { - this.checkpoint = pos; - } - - /** - * Return to a previously set checkpoint. Subsequent calls continue to - * the same checkpoint, unless overwritten. - */ - public void returnToCheckpoint() { - this.pos = checkpoint; - } - @Override public String toString() { StringBuilder builder = new StringBuilder(); From 86059a07fe9e93b3faf4335f33b80adc55bbd68d Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 27 Mar 2025 16:50:06 -0400 Subject: [PATCH 33/43] Remove diagnostics from hover (#214) Diagnostics are already displayed by clients when hovering, so adding them in our hover implementation duplicates them. --- .../smithy/lsp/SmithyLanguageServer.java | 3 +- .../smithy/lsp/language/HoverHandler.java | 43 ++----------------- .../smithy/lsp/language/HoverHandlerTest.java | 3 +- 3 files changed, 6 insertions(+), 43 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 1d977d4b..053db2d6 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -678,8 +678,7 @@ public CompletableFuture hover(HoverParams params) { case IdlFile idlFile -> { Project project = projectAndFile.project(); - // TODO: Abstract away passing minimum severity - var handler = new HoverHandler(project, idlFile, this.serverOptions.getMinimumSeverity()); + var handler = new HoverHandler(project, idlFile); yield CompletableFuture.supplyAsync(() -> handler.handle(params)); } case BuildFile buildFile -> { diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java index f1ae1799..1be5be6b 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -8,7 +8,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.regex.Matcher; @@ -30,9 +29,7 @@ import software.amazon.smithy.model.shapes.StructureShape; import software.amazon.smithy.model.traits.DocumentationTrait; import software.amazon.smithy.model.traits.ExternalDocumentationTrait; -import software.amazon.smithy.model.validation.Severity; import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidationEvent; /** * Handles hover requests for the Smithy IDL. @@ -45,17 +42,14 @@ public final class HoverHandler { private final Project project; private final IdlFile smithyFile; - private final Severity minimumSeverity; /** * @param project Project the hover is in * @param smithyFile Smithy file the hover is in - * @param minimumSeverity Minimum severity of validation events to show */ - public HoverHandler(Project project, IdlFile smithyFile, Severity minimumSeverity) { + public HoverHandler(Project project, IdlFile smithyFile) { this.project = project; this.smithyFile = smithyFile; - this.minimumSeverity = minimumSeverity; } /** @@ -152,10 +146,10 @@ private Hover modelSensitiveHover(DocumentId id, IdlPosition idlPosition) { return EMPTY; } - return withShapeAndValidationEvents(matchingShape.get(), model, validatedModel.getValidationEvents()); + return withShape(matchingShape.get(), model); } - private Hover withShapeAndValidationEvents(Shape shape, Model model, List events) { + private Hover withShape(Shape shape, Model model) { String serializedShape = switch (shape) { case MemberShape memberShape -> serializeMember(memberShape); default -> serializeShape(model, shape); @@ -165,14 +159,11 @@ private Hover withShapeAndValidationEvents(Shape shape, Model model, List events, Shape shape) { - StringBuilder serialized = new StringBuilder(); - List applicableEvents = events.stream() - .filter(event -> event.getShapeId().isPresent()) - .filter(event -> event.getShapeId().get().equals(shape.getId())) - .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0) - .toList(); - - if (!applicableEvents.isEmpty()) { - for (ValidationEvent event : applicableEvents) { - serialized.append("**") - .append(event.getSeverity()) - .append("**") - .append(": ") - .append(event.getMessage()); - } - serialized.append(System.lineSeparator()) - .append(System.lineSeparator()) - .append("---") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - } - - return serialized.toString(); - } - // Note: This isn't used for user-defined shapes because we include docs // in the serialized hover content. static Optional withBuiltinShapeDocs(Shape shape) { diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java index d99d40e9..2c7ac313 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -23,7 +23,6 @@ import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectTest; import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.model.validation.Severity; public class HoverHandlerTest { @Test @@ -372,7 +371,7 @@ private static List getHovers(String text, Position... positions) { SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); List hover = new ArrayList<>(); - HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile, Severity.WARNING); + HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile); for (Position position : positions) { HoverParams params = RequestBuilders.positionRequest() .uri(uri) From 77b522dbc2c90e852fee377b44f6242a151a7179 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Thu, 27 Mar 2025 19:35:39 -0400 Subject: [PATCH 34/43] Properly implement document/documentSymbol (#206) When I re-wrote the language feature implementations in #166, I refactored documentSymbol. But it mostly just copied the previous implementation, which produced a flat list of symbols. This meant that member symbols would appear at the top-level, but documentSymbol is meant to provide a tree-like view, where symbols can have children. So this commit re-writes documentSymbol to produce a list of only the top-level shapes' symbols (and the namespace statement's symbol), with members' symbols being added as children to the top-level symbols, and members of inline i/o are children of the inline i/o symbol. I also changed up the Symbol kind a bit, so root shapes are of type `Class` (except enum/intEnum, which are of type `Enum`), members are of type `Field` (except enum/intEnum members, which are of type `EnumMember`), and service/resource/operation members are of type `Property`. I explored having different symbol kinds for different types of shapes, like making service-type shapes be of kind `Interface`, and primitive types being of their corresponding kind (for example, an integer shape would have kind `Number`), but I wasn't sure what some shape types should be. For example, what should a `blob` shape be? So I decided to just make all top-level shapes just be of kind `Class`. I also fixed the range and selection range of symbols. Range now covers everything from shape type -> end of block (if applicable), and selection range is just the shape/member name. I considered adding more children for service-type shape members, like making the `operations` child symbol of a service shape have a child for each of the operations in the list, but I think it would make the tree view more noisy, plus I think the intent is to show symbol definitions. I also made a minor adjustment to the parsing of operations members, making them always be inline or node member defs, instead of possibly a regular member def for non-inline i/o. This is consistent with resource/service members, which are always node members. --- .../lsp/language/DocumentSymbolHandler.java | 186 ++++++++-- .../smithy/lsp/protocol/LspAdapter.java | 14 +- .../amazon/smithy/lsp/syntax/Parser.java | 21 +- .../lsp/language/DocumentSymbolTest.java | 326 ++++++++++++++++-- .../smithy/lsp/syntax/IdlParserTest.java | 8 +- 5 files changed, 472 insertions(+), 83 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java index 7aa47fa0..da8d8a9c 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java @@ -5,59 +5,191 @@ package software.amazon.smithy.lsp.language; +import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.ListIterator; import java.util.function.Consumer; import org.eclipse.lsp4j.DocumentSymbol; -import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.SymbolKind; import org.eclipse.lsp4j.jsonrpc.messages.Either; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.syntax.Syntax; public record DocumentSymbolHandler(Document document, List statements) { + // Statement types that may appear before the start of a shape's members, which + // we need to skip. + private static final EnumSet BEFORE_MEMBER_TYPES = EnumSet.of( + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block + ); + /** * @return A list of DocumentSymbol */ public List> handle() { - return statements.stream() - .mapMulti(this::addSymbols) - .toList(); + List> result = new ArrayList<>(); + // Passing around the list would make the code super noisy, and we'd have + // to do Either.forRight everywhere, so use a consumer. + addSymbols((symbol) -> result.add(Either.forRight(symbol))); + return result; + } + + private void addSymbols(Consumer consumer) { + var listIterator = statements.listIterator(); + while (listIterator.hasNext()) { + var statement = listIterator.next(); + if (statement instanceof Syntax.Statement.Namespace namespace) { + consumer.accept(namespaceSymbol(namespace)); + } else if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + var symbol = rootSymbol(shapeDef); + consumer.accept(symbol); + addMemberSymbols(listIterator, symbol); + } + } + } + + private void addMemberSymbols(ListIterator listIterator, DocumentSymbol parent) { + // We only want to collect members within the block, so we can use Block's lastMemberIndex + // to tell us when to stop. + int lastMemberIndex = 0; + while (listIterator.hasNext()) { + var statement = listIterator.next(); + + if (!BEFORE_MEMBER_TYPES.contains(statement.type())) { + // No members + listIterator.previous(); + return; + } + + if (statement instanceof Syntax.Statement.Block block) { + // Update the parent's range to cover all its members + var blockEnd = document.positionAtIndex(block.end()); + parent.getRange().setEnd(blockEnd); + lastMemberIndex = block.lastStatementIndex(); + break; + } + } + + List children = childrenSymbols(listIterator, lastMemberIndex); + if (!children.isEmpty()) { + parent.setChildren(children); + } } - private void addSymbols(Syntax.Statement statement, Consumer> consumer) { - switch (statement) { - case Syntax.Statement.TraitApplication app -> addSymbol(consumer, app.id(), SymbolKind.Class); + private List childrenSymbols(ListIterator listIterator, int lastChildIndex) { + List children = new ArrayList<>(); - case Syntax.Statement.ShapeDef def -> addSymbol(consumer, def.shapeName(), SymbolKind.Class); + while (listIterator.nextIndex() <= lastChildIndex) { + var statement = listIterator.next(); - case Syntax.Statement.EnumMemberDef def -> addSymbol(consumer, def.name(), SymbolKind.Enum); + switch (statement) { + case Syntax.Statement.MemberDef def -> children.add(memberDefSymbol(def)); - case Syntax.Statement.ElidedMemberDef def -> addSymbol(consumer, def.name(), SymbolKind.Property); + case Syntax.Statement.EnumMemberDef def -> children.add(enumMemberDefSymbol(def)); - case Syntax.Statement.MemberDef def -> { - addSymbol(consumer, def.name(), SymbolKind.Property); - if (def.target() != null) { - addSymbol(consumer, def.target(), SymbolKind.Class); + case Syntax.Statement.ElidedMemberDef def -> children.add(elidedMemberDefSymbol(def)); + + case Syntax.Statement.NodeMemberDef def -> children.add(nodeMemberDefSymbol(def)); + + case Syntax.Statement.InlineMemberDef def -> children.add(inlineMemberSymbol(listIterator, def)); + + default -> { } } - default -> { - } } + + return children; + } + + private DocumentSymbol namespaceSymbol(Syntax.Statement.Namespace namespace) { + return new DocumentSymbol( + namespace.namespace().stringValue(), + SymbolKind.Namespace, + document.rangeOf(namespace), + document.rangeOfValue(namespace.namespace()) + ); + } + + private DocumentSymbol rootSymbol(Syntax.Statement.ShapeDef shapeDef) { + return new DocumentSymbol( + shapeDef.shapeName().stringValue(), + getSymbolKind(shapeDef), + document.rangeOf(shapeDef), + document.rangeOfValue(shapeDef.shapeName()) + ); + } + + private static SymbolKind getSymbolKind(Syntax.Statement.ShapeDef shapeDef) { + return switch (shapeDef.shapeType().stringValue()) { + case "enum", "intEnum" -> SymbolKind.Enum; + case "operation", "service", "resource" -> SymbolKind.Interface; + default -> SymbolKind.Class; + }; + } + + private DocumentSymbol memberDefSymbol(Syntax.Statement.MemberDef memberDef) { + var detail = memberDef.target() == null + ? null + : memberDef.target().stringValue(); + + return new DocumentSymbol( + memberDef.name().stringValue(), + SymbolKind.Field, + document.rangeOf(memberDef), + document.rangeOfValue(memberDef.name()), + detail + ); + } + + private DocumentSymbol enumMemberDefSymbol(Syntax.Statement.EnumMemberDef enumMemberDef) { + return new DocumentSymbol( + enumMemberDef.name().stringValue(), + SymbolKind.EnumMember, + document.rangeOf(enumMemberDef), + document.rangeOfValue(enumMemberDef.name()) + ); + } + + private DocumentSymbol elidedMemberDefSymbol(Syntax.Statement.ElidedMemberDef elidedMemberDef) { + var range = document.rangeOf(elidedMemberDef); + return new DocumentSymbol( + "$" + elidedMemberDef.name().stringValue(), + SymbolKind.Field, + range, + range + ); } - private void addSymbol( - Consumer> consumer, - Syntax.Ident ident, - SymbolKind symbolKind + private DocumentSymbol nodeMemberDefSymbol(Syntax.Statement.NodeMemberDef nodeMemberDef) { + String detail = switch (nodeMemberDef.value()) { + case Syntax.Ident ident -> ident.stringValue(); + case null, default -> null; + }; + + return new DocumentSymbol( + nodeMemberDef.name().stringValue(), + SymbolKind.Property, + document.rangeOf(nodeMemberDef), + document.rangeOfValue(nodeMemberDef.name()), + detail + ); + } + + private DocumentSymbol inlineMemberSymbol( + ListIterator listIterator, + Syntax.Statement.InlineMemberDef inlineMemberDef ) { - Range range = LspAdapter.identRange(ident, document); - if (range == null) { - return; - } + var inlineSymbol = new DocumentSymbol( + inlineMemberDef.name().stringValue(), + SymbolKind.Property, + document.rangeOf(inlineMemberDef), + document.rangeOfValue(inlineMemberDef.name()) + ); - DocumentSymbol symbol = new DocumentSymbol(ident.stringValue(), symbolKind, range, range); - consumer.accept(Either.forRight(symbol)); + addMemberSymbols(listIterator, inlineSymbol); + return inlineSymbol; } } diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java index 8335786e..41fca697 100644 --- a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java @@ -120,19 +120,7 @@ public static Range of(int startLine, int startCharacter, int endLine, int endCh * @return The range of the identifier in the given document */ public static Range identRange(Syntax.Ident ident, Document document) { - int line = document.lineOfIndex(ident.start()); - if (line < 0) { - return null; - } - - int lineStart = document.indexOfLine(line); - if (lineStart < 0) { - return null; - } - - int startCharacter = ident.start() - lineStart; - int endCharacter = ident.end() - lineStart; - return LspAdapter.lineSpan(line, startCharacter, endCharacter); + return document.rangeOfValue(ident); } /** diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index 7c9e5239..2e02cda3 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -642,21 +642,12 @@ private void operationMembers(Syntax.Statement.Block parent) { ws(); - if (isIdentStart()) { - var opMemberDef = new Syntax.Statement.MemberDef(parent, memberName); - opMemberDef.start = opMemberStart; - opMemberDef.colonPos = colonPos; - opMemberDef.target = ident(); - setEnd(opMemberDef); - addStatement(opMemberDef); - } else { - var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName); - nodeMemberDef.start = opMemberStart; - nodeMemberDef.colonPos = colonPos; - nodeMemberDef.value = parseNode(); - setEnd(nodeMemberDef); - addStatement(nodeMemberDef); - } + var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName); + nodeMemberDef.start = opMemberStart; + nodeMemberDef.colonPos = colonPos; + nodeMemberDef.value = parseNode(); + setEnd(nodeMemberDef); + addStatement(nodeMemberDef); ws(); } diff --git a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java index 434c8612..a2cef703 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java @@ -6,22 +6,25 @@ package software.amazon.smithy.lsp.language; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasItems; -import static software.amazon.smithy.lsp.document.DocumentTest.safeString; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static software.amazon.smithy.lsp.LspMatchers.hasText; import java.util.ArrayList; import java.util.List; +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.SymbolKind; import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.project.IdlFile; -import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectTest; -import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; public class DocumentSymbolTest { @Test public void documentSymbols() { - String model = safeString(""" + var document = Document.of(""" $version: "2" namespace com.foo @@ -31,32 +34,307 @@ public void documentSymbols() { structure Foo { @required bar: Bar + + baz: Baz + } + + operation MyOp { + input := { + foo: String + } + + output: MyOpOutput + } + + resource MyResource { + identifiers: { + myId: String + myOtherId: String + } + properties: { + myProperty: Foo + myOtherProperty: String + } + get: MyOp + operations: [ + MyOp + ] } + """); + var symbols = getDocumentSymbols(document); - structure Bar { - @myTrait("foo") - baz: Baz + assertThat(symbols, hasSize(5)); + + var nsSymbol = symbols.get(0); + assertThat(nsSymbol.getName(), equalTo("com.foo")); + assertThat(nsSymbol.getKind(), equalTo(SymbolKind.Namespace)); + assertThat(nsSymbol.getRange(), hasText(document, equalTo("namespace com.foo"))); + assertThat(nsSymbol.getSelectionRange(), hasText(document, equalTo("com.foo"))); + assertThat(nsSymbol.getDetail(), nullValue()); + assertThat(nsSymbol.getChildren(), nullValue()); + + var myTraitSymbol = symbols.get(1); + assertThat(myTraitSymbol.getName(), equalTo("myTrait")); + assertThat(myTraitSymbol.getKind(), equalTo(SymbolKind.Class)); + assertThat(myTraitSymbol.getRange(), hasText(document, equalTo("string myTrait"))); + assertThat(myTraitSymbol.getSelectionRange(), hasText(document, equalTo("myTrait"))); + assertThat(myTraitSymbol.getDetail(), nullValue()); + assertThat(myTraitSymbol.getChildren(), nullValue()); + + var fooSymbol = symbols.get(2); + assertThat(fooSymbol.getName(), equalTo("Foo")); + assertThat(fooSymbol.getKind(), equalTo(SymbolKind.Class)); + assertThat(fooSymbol.getRange(), hasText(document, allOf( + containsString("structure Foo"), + containsString("bar: Bar"), + containsString("baz: Baz") + ))); + assertThat(fooSymbol.getSelectionRange(), hasText(document, equalTo("Foo"))); + assertThat(fooSymbol.getDetail(), nullValue()); + assertThat(fooSymbol.getChildren(), hasSize(2)); + + var barMemberSymbol = fooSymbol.getChildren().get(0); + assertThat(barMemberSymbol.getName(), equalTo("bar")); + assertThat(barMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(barMemberSymbol.getRange(), hasText(document, equalTo("bar: Bar"))); + assertThat(barMemberSymbol.getSelectionRange(), hasText(document, equalTo("bar"))); + assertThat(barMemberSymbol.getDetail(), equalTo("Bar")); + assertThat(barMemberSymbol.getChildren(), nullValue()); + + var myOpSymbol = symbols.get(3); + assertThat(myOpSymbol.getName(), equalTo("MyOp")); + assertThat(myOpSymbol.getKind(), equalTo(SymbolKind.Interface)); + assertThat(myOpSymbol.getRange(), hasText(document, allOf( + containsString("operation MyOp"), + containsString("input :="), + containsString("output: MyOpOutput") + ))); + assertThat(myOpSymbol.getSelectionRange(), hasText(document, equalTo("MyOp"))); + assertThat(myOpSymbol.getChildren(), hasSize(2)); + + var myOpInputSymbol = myOpSymbol.getChildren().get(0); + assertThat(myOpInputSymbol.getName(), equalTo("input")); + assertThat(myOpInputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpInputSymbol.getRange(), hasText(document, allOf( + containsString("input :="), + containsString("foo: String") + ))); + assertThat(myOpInputSymbol.getSelectionRange(), hasText(document, equalTo("input"))); + assertThat(myOpInputSymbol.getDetail(), nullValue()); + assertThat(myOpInputSymbol.getChildren(), hasSize(1)); + + var myOpInputFooMemberSymbol = myOpInputSymbol.getChildren().get(0); + assertThat(myOpInputFooMemberSymbol.getName(), equalTo("foo")); + assertThat(myOpInputFooMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(myOpInputFooMemberSymbol.getRange(), hasText(document, equalTo("foo: String"))); + assertThat(myOpInputFooMemberSymbol.getSelectionRange(), hasText(document, equalTo("foo"))); + assertThat(myOpInputFooMemberSymbol.getDetail(), equalTo("String")); + assertThat(myOpInputFooMemberSymbol.getChildren(), nullValue()); + + var myOpOutputSymbol = myOpSymbol.getChildren().get(1); + assertThat(myOpOutputSymbol.getName(), equalTo("output")); + assertThat(myOpOutputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpOutputSymbol.getRange(), hasText(document, equalTo("output: MyOpOutput"))); + assertThat(myOpOutputSymbol.getSelectionRange(), hasText(document, equalTo("output"))); + assertThat(myOpOutputSymbol.getDetail(), equalTo("MyOpOutput")); + assertThat(myOpOutputSymbol.getChildren(), nullValue()); + + var myResourceSymbol = symbols.get(4); + assertThat(myResourceSymbol.getName(), equalTo("MyResource")); + assertThat(myResourceSymbol.getKind(), equalTo(SymbolKind.Interface)); + assertThat(myResourceSymbol.getRange(), hasText(document, allOf( + containsString("resource MyResource"), + containsString("myId: String"), + containsString("get: MyOp") + ))); + assertThat(myResourceSymbol.getSelectionRange(), hasText(document, equalTo("MyResource"))); + assertThat(myResourceSymbol.getDetail(), nullValue()); + assertThat(myResourceSymbol.getChildren(), hasSize(4)); + + var myResourceIdentifiersSymbol = myResourceSymbol.getChildren().get(0); + assertThat(myResourceIdentifiersSymbol.getName(), equalTo("identifiers")); + assertThat(myResourceIdentifiersSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourceIdentifiersSymbol.getRange(), hasText(document, allOf( + containsString("identifiers:"), + containsString("myId: String"), + containsString("myOtherId: String") + ))); + assertThat(myResourceIdentifiersSymbol.getSelectionRange(), hasText(document, equalTo("identifiers"))); + assertThat(myResourceIdentifiersSymbol.getDetail(), nullValue()); + assertThat(myResourceIdentifiersSymbol.getChildren(), nullValue()); + var myResourcePropertiesSymbol = myResourceSymbol.getChildren().get(1); + assertThat(myResourcePropertiesSymbol.getName(), equalTo("properties")); + assertThat(myResourcePropertiesSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourcePropertiesSymbol.getRange(), hasText(document, allOf( + containsString("properties:"), + containsString("myProperty: Foo"), + containsString("myOtherProperty: String") + ))); + assertThat(myResourcePropertiesSymbol.getSelectionRange(), hasText(document, equalTo("properties"))); + assertThat(myResourcePropertiesSymbol.getDetail(), nullValue()); + assertThat(myResourcePropertiesSymbol.getChildren(), nullValue()); + var myResourceGetSymbol = myResourceSymbol.getChildren().get(2); + assertThat(myResourceGetSymbol.getName(), equalTo("get")); + assertThat(myResourceGetSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourceGetSymbol.getRange(), hasText(document, equalTo("get: MyOp"))); + assertThat(myResourceGetSymbol.getSelectionRange(), hasText(document, equalTo("get"))); + assertThat(myResourceGetSymbol.getDetail(), equalTo("MyOp")); + assertThat(myResourceGetSymbol.getChildren(), nullValue()); + var myResourceOperationsSymbol = myResourceSymbol.getChildren().get(3); + assertThat(myResourceOperationsSymbol.getName(), equalTo("operations")); + assertThat(myResourceOperationsSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourceOperationsSymbol.getRange(), hasText(document, allOf( + containsString("operations: ["), + containsString("MyOp") + ))); + assertThat(myResourceOperationsSymbol.getSelectionRange(), hasText(document, equalTo("operations"))); + assertThat(myResourceOperationsSymbol.getDetail(), nullValue()); + assertThat(myResourceOperationsSymbol.getChildren(), nullValue()); + } + + @Test + public void handlesForResourceAndMixins() { + var document = Document.of(""" + operation MyOp for MyResource with [MyMixin] { + input: MyOpInput + output := for MyResource with [MyMixin] { + foo: String + $bar + } } + """); + var symbols = getDocumentSymbols(document); + assertThat(symbols.size(), equalTo(1)); + + var myOpSymbol = symbols.get(0); + assertThat(myOpSymbol.getName(), equalTo("MyOp")); + assertThat(myOpSymbol.getKind(), equalTo(SymbolKind.Interface)); + assertThat(myOpSymbol.getRange(), hasText(document, allOf( + containsString("operation MyOp for MyResource with [MyMixin]"), + containsString("input: MyOpInput"), + containsString("output :="), + containsString("$bar") + ))); + assertThat(myOpSymbol.getSelectionRange(), hasText(document, equalTo("MyOp"))); + assertThat(myOpSymbol.getDetail(), nullValue()); + assertThat(myOpSymbol.getChildren(), hasSize(2)); + + var myOpInputSymbol = myOpSymbol.getChildren().get(0); + assertThat(myOpInputSymbol.getName(), equalTo("input")); + assertThat(myOpInputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpInputSymbol.getRange(), hasText(document, equalTo("input: MyOpInput"))); + assertThat(myOpInputSymbol.getSelectionRange(), hasText(document, equalTo("input"))); + assertThat(myOpInputSymbol.getDetail(), equalTo("MyOpInput")); + assertThat(myOpInputSymbol.getChildren(), nullValue()); + + var myOpOutputSymbol = myOpSymbol.getChildren().get(1); + assertThat(myOpOutputSymbol.getName(), equalTo("output")); + assertThat(myOpOutputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpOutputSymbol.getRange(), hasText(document, allOf( + containsString("output := for MyResource with [MyMixin]"), + containsString("foo: String"), + containsString("$bar") + ))); + assertThat(myOpOutputSymbol.getSelectionRange(), hasText(document, equalTo("output"))); + assertThat(myOpOutputSymbol.getDetail(), nullValue()); + assertThat(myOpOutputSymbol.getChildren(), hasSize(2)); + + var fooMemberSymbol = myOpOutputSymbol.getChildren().get(0); + assertThat(fooMemberSymbol.getName(), equalTo("foo")); + assertThat(fooMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(fooMemberSymbol.getRange(), hasText(document, equalTo("foo: String"))); + assertThat(fooMemberSymbol.getSelectionRange(), hasText(document, equalTo("foo"))); + assertThat(fooMemberSymbol.getDetail(), equalTo("String")); + assertThat(fooMemberSymbol.getChildren(), nullValue()); + + var barMemberSymbol = myOpOutputSymbol.getChildren().get(1); + assertThat(barMemberSymbol.getName(), equalTo("$bar")); + assertThat(barMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(barMemberSymbol.getRange(), hasText(document, equalTo("$bar"))); + assertThat(barMemberSymbol.getSelectionRange(), hasText(document, equalTo("$bar"))); + assertThat(barMemberSymbol.getDetail(), nullValue()); + assertThat(barMemberSymbol.getChildren(), nullValue()); + } - @myTrait("abc") - integer Baz + @Test + public void enums() { + var document = Document.of(""" + enum MyEnum { + FOO + BAR = "bar" + } + + intEnum MyIntEnum { + FOO + BAR = 1 + } """); - List names = getDocumentSymbolNames(model); + var symbols = getDocumentSymbols(document); + + assertThat(symbols.size(), equalTo(2)); + var myEnumSymbol = symbols.get(0); + assertThat(myEnumSymbol.getName(), equalTo("MyEnum")); + assertThat(myEnumSymbol.getKind(), equalTo(SymbolKind.Enum)); + assertThat(myEnumSymbol.getRange(), hasText(document, allOf( + containsString("enum MyEnum"), + containsString("BAR = \"bar\"") + ))); + assertThat(myEnumSymbol.getSelectionRange(), hasText(document, equalTo("MyEnum"))); + assertThat(myEnumSymbol.getDetail(), nullValue()); + assertThat(myEnumSymbol.getChildren(), hasSize(2)); + + var myEnumFooSymbol = myEnumSymbol.getChildren().get(0); + assertThat(myEnumFooSymbol.getName(), equalTo("FOO")); + assertThat(myEnumFooSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myEnumFooSymbol.getRange(), hasText(document, equalTo("FOO"))); + assertThat(myEnumFooSymbol.getSelectionRange(), hasText(document, equalTo("FOO"))); + assertThat(myEnumFooSymbol.getDetail(), nullValue()); + assertThat(myEnumFooSymbol.getChildren(), nullValue()); + + var myEnumBarSymbol = myEnumSymbol.getChildren().get(1); + assertThat(myEnumBarSymbol.getName(), equalTo("BAR")); + assertThat(myEnumBarSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myEnumBarSymbol.getRange(), hasText(document, equalTo("BAR = \"bar\""))); + assertThat(myEnumBarSymbol.getSelectionRange(), hasText(document, equalTo("BAR"))); + assertThat(myEnumBarSymbol.getDetail(), nullValue()); + assertThat(myEnumBarSymbol.getChildren(), nullValue()); + + var myIntEnumSymbol = symbols.get(1); + assertThat(myIntEnumSymbol.getName(), equalTo("MyIntEnum")); + assertThat(myIntEnumSymbol.getKind(), equalTo(SymbolKind.Enum)); + assertThat(myIntEnumSymbol.getRange(), hasText(document, allOf( + containsString("intEnum MyIntEnum"), + containsString("BAR = 1") + ))); + assertThat(myIntEnumSymbol.getSelectionRange(), hasText(document, equalTo("MyIntEnum"))); + assertThat(myIntEnumSymbol.getDetail(), nullValue()); + assertThat(myIntEnumSymbol.getChildren(), hasSize(2)); + + var myIntEnumFooSymbol = myIntEnumSymbol.getChildren().get(0); + assertThat(myIntEnumFooSymbol.getName(), equalTo("FOO")); + assertThat(myIntEnumFooSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myIntEnumFooSymbol.getRange(), hasText(document, equalTo("FOO"))); + assertThat(myIntEnumFooSymbol.getSelectionRange(), hasText(document, equalTo("FOO"))); + assertThat(myIntEnumFooSymbol.getDetail(), nullValue()); + assertThat(myIntEnumFooSymbol.getChildren(), nullValue()); - assertThat(names, hasItems("myTrait", "Foo", "bar", "Bar", "baz", "Baz")); + var myIntEnumBarSymbol = myIntEnumSymbol.getChildren().get(1); + assertThat(myIntEnumBarSymbol.getName(), equalTo("BAR")); + assertThat(myIntEnumBarSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myIntEnumBarSymbol.getRange(), hasText(document, equalTo("BAR = 1"))); + assertThat(myIntEnumBarSymbol.getSelectionRange(), hasText(document, equalTo("BAR"))); + assertThat(myIntEnumBarSymbol.getDetail(), nullValue()); + assertThat(myIntEnumBarSymbol.getChildren(), nullValue()); } - private static List getDocumentSymbolNames(String text) { - TestWorkspace workspace = TestWorkspace.singleModel(text); - Project project = ProjectTest.load(workspace.getRoot()); - String uri = workspace.getUri("main.smithy"); - IdlFile idlFile = (IdlFile) (SmithyFile) project.getProjectFile(uri); + private static List getDocumentSymbols(Document document) { + Syntax.IdlParseResult parseResult = Syntax.parseIdl(document); - List names = new ArrayList<>(); - var handler = new DocumentSymbolHandler(idlFile.document(), idlFile.getParse().statements()); + List symbols = new ArrayList<>(); + var handler = new DocumentSymbolHandler(document, parseResult.statements()); for (var sym : handler.handle()) { - names.add(sym.getRight().getName()); + symbols.add(sym.getRight()); } - return names; + return symbols; } } diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java index 27cb12fe..8bd7e550 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -113,12 +113,12 @@ public void parsesOp() { assertTypesEqual(text, Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.ShapeDef, - Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.NodeMemberDef, Syntax.Statement.Type.ShapeDef, - Syntax.Statement.Type.MemberDef, - Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, Syntax.Statement.Type.ShapeDef, - Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.NodeMemberDef, Syntax.Statement.Type.NodeMemberDef); } From 86770cea673391d8452dd008643d42c8ba6ca79b Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Mon, 31 Mar 2025 13:02:04 -0400 Subject: [PATCH 35/43] Fix registration check npe (#216) Fixes a possible npe when checking if the client supports dynamically registering for synchronization notifications. The property being checked is a nullable `Boolean`, and the possible null value wasn't being accounted for. --- .../amazon/smithy/lsp/SmithyLanguageServer.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 053db2d6..5d2f0247 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -51,6 +51,7 @@ import org.eclipse.lsp4j.DocumentFormattingParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.DynamicRegistrationCapabilities; import org.eclipse.lsp4j.FoldingRange; import org.eclipse.lsp4j.FoldingRangeRequestParams; import org.eclipse.lsp4j.Hover; @@ -77,6 +78,7 @@ import org.eclipse.lsp4j.SetTraceParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextDocumentChangeRegistrationOptions; +import org.eclipse.lsp4j.TextDocumentClientCapabilities; import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextDocumentRegistrationOptions; @@ -273,10 +275,11 @@ public void initialized(InitializedParams params) { } private boolean isDynamicSyncRegistrationSupported() { - return clientCapabilities != null - && clientCapabilities.getTextDocument() != null - && clientCapabilities.getTextDocument().getSynchronization() != null - && clientCapabilities.getTextDocument().getSynchronization().getDynamicRegistration(); + return Optional.ofNullable(clientCapabilities) + .map(ClientCapabilities::getTextDocument) + .map(TextDocumentClientCapabilities::getSynchronization) + .map(DynamicRegistrationCapabilities::getDynamicRegistration) + .orElse(false); } private void registerDocumentSynchronization() { From 9c6ead35397dd287af775317641d3d76c1909ca2 Mon Sep 17 00:00:00 2001 From: smithy-automation <127955164+smithy-automation@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:00:17 -0700 Subject: [PATCH 36/43] Update Smithy Version (#217) Co-authored-by: Smithy Automation --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b5716421..675832e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -smithyVersion=1.55.0 +smithyVersion=1.56.0 From a47284a7482036754919caead3521e8e68c60914 Mon Sep 17 00:00:00 2001 From: Joe Wu Date: Thu, 10 Apr 2025 13:54:15 -0700 Subject: [PATCH 37/43] Expand tests on parser to acheive higher coverage and minor fixs (#212) * Expand tests on parser to achieve higher coverage * Delete unreachable branch * Add fix for Node with leading comma * Add fix for duplicate statement when invalid member def statement appears * Add fix for unset end of kvp --- .../amazon/smithy/lsp/syntax/Parser.java | 8 +- .../smithy/lsp/syntax/IdlParserTest.java | 565 +++++++++++++++++- .../smithy/lsp/syntax/NodeParserTest.java | 93 +++ 3 files changed, 662 insertions(+), 4 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index 2e02cda3..8330b831 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -55,7 +55,7 @@ Syntax.Node parseNode() { int start = position(); do { skip(); - } while (!isWs() && !isNodeStructuralBreakpoint() && !eof()); + } while (!isWs() && !isNodeStructuralBreakpoint() && !eof() && is(',')); int end = position(); Syntax.Node.Err err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end)); err.start = start; @@ -310,7 +310,9 @@ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { if (err != null) { addError(err); } - + if (kvp != null) { + setEnd(kvp); + } return nodeErr("expected value"); } @@ -810,7 +812,6 @@ private void member(Syntax.Statement.Block parent) { addErr(position(), position(), "expected :"); if (isWs() || is('}')) { setEnd(memberDef); - addStatement(memberDef); return; } } @@ -963,6 +964,7 @@ private Syntax.Ident ident() { } while (isIdentChar()); int end = position(); + if (start == end) { addErr(start, end, "expected identifier"); return Syntax.Ident.EMPTY; diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java index 8bd7e550..edec5b57 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -305,6 +305,461 @@ public void stringKeysInTraits() { Syntax.Node.Type.Str)); } + @Test + public void goodControlWithEmptyString() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + %$operationInputSuffix: ""% + %$operationOutputSuffix: " "% + """); + Document document = Document.of(text.text()); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + var positions = text.positions(); + assertThat(statements, hasSize(3)); + + assertTypesEqual(text.text(), + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control + ); + assertThat(document.copySpan(statements.get(1).start, statements.get(1).end), equalTo( + "$operationInputSuffix: \"\"".trim())); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo( + "$operationOutputSuffix: \" \"".trim())); + } + + @Test + public void badControlWithoutColon() { + String text = """ + $version 2 + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(1)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("$version 2")); + } + + @Test + public void goodTraitWithNodeDef() { + String text = """ + @integration( + requestParameters: { + "param1": "a" + } + ) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + var trait = (Syntax.Statement.TraitApplication) statements.get(0); + assertThat(document.copySpan(trait.start, trait.end), equalTo( + (""" + @integration( + requestParameters: { + "param1": "a" + } + ) + """).trim())); + var value = (Syntax.Node.Kvps)trait.value(); + assertThat(document.copySpan(value.start, value.end).trim(), equalTo( + (""" + ( + requestParameters: { + "param1": "a" + } + """).trim())); + } + + @Test + public void goodTraitWithEmptyDef() { + String text ="@integration()"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var value = (Syntax.Node.Kvps)trait.value(); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@integration()")); + assertThat(document.copySpan(value.start, value.end), equalTo("(")); + } + + @Test + public void goodTraitWithStringKeyAndKvpsValue() { + String text = """ + @integration( + "foo" :{ + "param1": "a" + } + ) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var kvps = (Syntax.Node.Kvps)trait.value(); + assertThat(parse.statements(), hasSize(1)); + assertThat(document.copySpan(trait.start, trait.end), equalTo((""" + @integration( + "foo" :{ + "param1": "a" + } + ) + """).trim())); + assertThat(document.copySpan(kvps.start, kvps.end), equalTo((""" + ( + "foo" :{ + "param1": "a" + } + """))); + } + + @Test + public void goodTraitWithStrOnly() { + String text = "@integration(\"foo bar\")"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var str = (Syntax.Node.Str)trait.value(); + assertThat(parse.statements(), hasSize(1)); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@integration(\"foo bar\")")); + assertThat(document.copySpan(str.start, str.end), equalTo("(\"foo bar\"")); + } + + @Test + public void goodTraitWithNestedKvps() { + String text = """ + @integration({ + "abc": { + "abc": { + "abc": "abc" + }, + "def": "def" + } + }) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + Syntax.Node.Obj firstObj = (Syntax.Node.Obj)trait.value(); + Syntax.Node.Kvps firstKvps = firstObj.kvps(); + assertThat(document.copySpan(firstKvps.start, firstKvps.end), equalTo((""" + { + "abc": { + "abc": { + "abc": "abc" + }, + "def": "def" + } + } + """).trim())); + Syntax.Node.Obj secondObj = (Syntax.Node.Obj)firstKvps.kvps().get(0).value(); + Syntax.Node.Kvps secondKvps = secondObj.kvps(); + assertThat(document.copySpan(secondKvps.start, secondKvps.end), equalTo((""" + { + "abc": { + "abc": "abc" + }, + "def": "def" + } + """).trim())); + Syntax.Node.Obj thirdObj = (Syntax.Node.Obj)secondKvps.kvps().get(0).value(); + Syntax.Node.Kvps thirdKvps = thirdObj.kvps(); + assertThat(document.copySpan(thirdKvps.start, thirdKvps.end), equalTo((""" + { + "abc": "abc" + } + """).trim())); + } + + @Test + public void goodTraitWithNum() { + String text = """ + @a(1) + @b(-2) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication firstTrait = (Syntax.Statement.TraitApplication) statements.get(0); + Syntax.Statement.TraitApplication secondTrait = (Syntax.Statement.TraitApplication) statements.get(1); + var firstTraitValue = firstTrait.value(); + var secondTraitValue = secondTrait.value(); + assertThat(document.copySpan(firstTrait.start, firstTrait.end), + equalTo("@a(1)")); + assertThat(document.copySpan(firstTraitValue.start, firstTraitValue.end), + equalTo("(1")); + assertThat(document.copySpan(secondTrait.start, secondTrait.end), + equalTo("@b(-2)")); + assertThat(document.copySpan(secondTraitValue.start, secondTraitValue.end), + equalTo("(-2")); + } + + @Test + public void badInlineMemberHitEof() { + String text = """ + operation foo{ + input:= + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var errors = parse.errors(); + assertThat(errors.get(0).message(), equalTo("expected {")); + } + + @Test + public void goodTraitWithIdent() { + String text = "@a(b)"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + var traitValue = trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a(b)")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(b")); + } + + @Test + public void goodTraitWithEmptyValue() { + String text = "@a()"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + var traitValue = trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a()")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(")); + } + + @Test + public void badTraitWithInvalidNode() { + String text = "@a(?)"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + Syntax.Node.Err traitValue = (Syntax.Node.Err) trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a(?)")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(?")); + assertThat((traitValue.message), equalTo("unexpected token ?")); + } + + @Test + public void badTraitWithInvalidNodeAndUnclosed() { + String text = "@a(?"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + Syntax.Node.Err traitValue = (Syntax.Node.Err) trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a(?")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(?")); + assertThat((traitValue.message), equalTo("unexpected eof")); + } + + @Test + public void badTraitWithUnclosedTextBlock() { + String text = "@a(\"\"\")"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication)statements.get(0); + Syntax.Node value = trait.value(); + assertThat(value, instanceOf(Syntax.Node.Err.class)); + } + + @Test + public void badTraitWithTextBlockKey() { + String text = "@a(\"\"\"b\"\"\":1)"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication)statements.get(0); + Syntax.Node node = trait.value(); + assertThat(node, instanceOf(Syntax.Node.Kvps.class)); + Syntax.Node.Kvps kvps = (Syntax.Node.Kvps)node; + Syntax.Node.Kvp kvp = kvps.kvps().get(0); + assertThat(kvp.key().stringValue(), equalTo("b")); + } + + @Test + public void badTraitWithNestedUnclosedKvps() { + String text = """ + @test({ + key1: { + key2: { + key3: { + key4: { + key5: { + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication traitApplication = (Syntax.Statement.TraitApplication)statements.get(0); + Syntax.Node.Obj obj = (Syntax.Node.Obj) traitApplication.value(); + for (int i = 1; i <= 5; i++){ + Syntax.Node.Kvps kvps = obj.kvps(); + assertThat(kvps.kvps().get(0).key().stringValue(), equalTo("key" + i)); + obj = (Syntax.Node.Obj) kvps.kvps().get(0).value(); + } + } + + @Test + public void badMetadataWithUnclosedArr() { + String text = "metadata foo = [a,b,c"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + Syntax.Statement.Metadata metadata = (Syntax.Statement.Metadata ) parse.statements().get(0); + assertThat(document.copySpan(metadata.start, metadata.end), equalTo("metadata foo = [a,b,c")); + assertThat(document.copySpan(metadata.value.start, metadata.value.end), equalTo("[a,b,c")); + } + + @Test + public void badMetadataWithoutEqual() { + String text = """ + metadata a + metadata b + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + Syntax.Statement.Metadata metadataOne = (Syntax.Statement.Metadata ) parse.statements().get(0); + Syntax.Statement.Metadata metadataTwo = (Syntax.Statement.Metadata) parse.statements().get(1); + assertThat(document.copySpan(metadataOne.start, metadataOne.end), equalTo("metadata a")); + assertThat(document.copySpan(metadataTwo.start, metadataTwo.end), equalTo("metadata b")); + } + + @Test + public void goodApplyWithSingularTrait() { + String text = "apply foo @examples "; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + Syntax.Statement.Apply apply = (Syntax.Statement.Apply) parse.statements().get(0); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) parse.statements().get(1); + assertThat(document.copySpan(apply.start, apply.end), equalTo("apply foo")); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@examples")); + } + + @Test + public void badApplyWithMissingTraitMark() { + String text = "apply foo{bar,@buz}"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + + Syntax.Statement.Apply apply = (Syntax.Statement.Apply) parse.statements().get(0); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) parse.statements().get(3); + assertThat(document.copySpan(apply.start, apply.end), equalTo("apply foo")); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@buz")); + } + + @Test + public void goodEnumShapeDef() { + String text = """ + enum foo { + } + intEnum bar { + @test + a = 1 + } + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(6)); + assertThat(statements.get(0), instanceOf(Syntax.Statement.ShapeDef.class)); + assertThat(statements.get(1), instanceOf(Syntax.Statement.Block.class)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("enum foo")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("intEnum bar")); + assertThat(document.copySpan(statements.get(3).start, statements.get(3).end), equalTo(""" + { + @test + a = 1 + }""")); + assertThat(document.copySpan(statements.get(4).start, statements.get(4).end), equalTo("@test")); + assertThat(document.copySpan(statements.get(5).start, statements.get(5).end), equalTo("a = 1")); + } + + @Test + public void goodStructListMapUnion() { + String text = """ + structure a { + } + list b { + } + map c { + } + union d { + } + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(8)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("structure a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("list b")); + assertThat(document.copySpan(statements.get(4).start, statements.get(4).end), equalTo("map c")); + assertThat(document.copySpan(statements.get(6).start, statements.get(6).end), equalTo("union d")); + } + + @Test + public void goodResourceService() { + String text = """ + resource a { + } + service b { + } + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(4)); + assertThat(statements.get(0), instanceOf(Syntax.Statement.ShapeDef.class)); + assertThat(statements.get(1), instanceOf(Syntax.Statement.Block.class)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("resource a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("service b")); + } + + @Test + public void goodElideMember() { + String text = "structure a {foo:$bar}"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(4)); + assertThat(statements.get(0), instanceOf(Syntax.Statement.ShapeDef.class)); + assertThat(statements.get(1), instanceOf(Syntax.Statement.Block.class)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("structure a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("foo:")); + assertThat(document.copySpan(statements.get(3).start, statements.get(3).end), equalTo("$bar")); + } + + @Test + public void badTraitWithArrWithoutLeftBrace() { + String text = "@test(a,b,c])"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("@test(a,")); + assertThat(document.copySpan(statements.get(1).start, statements.get(1).end), equalTo("b")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("c")); + } + + @Test + public void badStructureWithUseStatement() { + String text = "structure a {use abc}"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("structure a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("use abc")); + } + @Test public void traitApplicationsDontContainTrailingWhitespace() { var twp = TextWithPositions.from(""" @@ -401,6 +856,12 @@ record InvalidSyntaxTestCase( Syntax.Statement.Type.Block, Syntax.Statement.Type.EnumMemberDef) ), + new InvalidSyntaxTestCase( + "enum using invalid value", + "enum Foo {?}", + List.of("unexpected token ? expected trait or member", "expected member or trait"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block) + ), new InvalidSyntaxTestCase( "regular shape missing {", "structure Foo\nbar: String}", @@ -419,6 +880,29 @@ record InvalidSyntaxTestCase( Syntax.Statement.Type.Block, Syntax.Statement.Type.MemberDef) ), + new InvalidSyntaxTestCase( + "regular shape missing :", + "structure Foo {bar String}", + List.of("expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing assignment", + """ + structure Foo { + foo + bar + } + """, + List.of("expected :","expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.MemberDef, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape with invalid member", + "structure Foo {?}", + List.of("unexpected token ? expected trait or member", "expected member or trait"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block) + ), new InvalidSyntaxTestCase( "op with inline missing {", "operation Foo\ninput := {}}", @@ -457,12 +941,41 @@ record InvalidSyntaxTestCase( Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef) ), + new InvalidSyntaxTestCase( + "node shape with missing :", + "service Foo{operations {}}", + List.of("expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing node", + "service Foo{bar:}", + List.of("expected node"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape missing assignment", + """ + service Foo{ + a + b + } + """, + List.of("expected :", "expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef, Syntax.Statement.Type.NodeMemberDef) + ), new InvalidSyntaxTestCase( "apply missing @", "apply Foo", List.of("expected trait or block"), List.of(Syntax.Statement.Type.Apply) ), + new InvalidSyntaxTestCase( + "apply missing trait in block", + "apply Foo {@bar,buz}", + List.of("expected trait", "expected identifier"), + List.of(Syntax.Statement.Type.Apply, Syntax.Statement.Type.Block, Syntax.Statement.Type.TraitApplication, Syntax.Statement.Type.Incomplete) + ), new InvalidSyntaxTestCase( "apply missing }", "apply Foo {@bar", @@ -487,7 +1000,8 @@ record InvalidSyntaxTestCase( { foo:\s } - }""", + } + """, List.of("expected identifier"), List.of( Syntax.Statement.Type.ShapeDef, @@ -529,6 +1043,55 @@ record InvalidSyntaxTestCase( Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Mixins, Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "operation using member value without :", + """ + operation Op { + input + output + } + """, + List.of("expected :", "expected :"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "trait use unexpected key", + """ + @integration(String: + """, + List.of("unexpected token "), + List.of( + Syntax.Statement.Type.TraitApplication) + ), + new InvalidSyntaxTestCase( + "control without value", + """ + $version + $operationInputSuffix "Request" + $operationInputSuffix: "Request" + """, + List.of("expected :", "expected :"), + List.of( + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control) + ), + new InvalidSyntaxTestCase( + "metadata without equal", + """ + metadata bar + structure foo{} + """, + List.of("expected ="), + List.of( + Syntax.Statement.Type.Metadata, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block) ) ); diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java index 6f45d5f7..70641049 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java @@ -87,6 +87,21 @@ public void goodNestedObjs() { Syntax.Node.Type.Str, Syntax.Node.Type.Str); } + @Test + public void goodObjSingleKeyWithTrailingComma() { + String text = """ + {"a":{"abc": "def"} , }"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } @Test public void goodEmptyArr() { @@ -320,6 +335,14 @@ record InvalidSyntaxTestCase( List.of("expected value"), List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) ), + new InvalidSyntaxTestCase( + "String key with : but no }", + "{\"1\": abc, \"2\": def", + List.of("missing }"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, Syntax.Node.Type.Ident, Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, Syntax.Node.Type.Ident) + ), new InvalidSyntaxTestCase( "Invalid key", "{\"abc}", @@ -331,6 +354,34 @@ record InvalidSyntaxTestCase( "{\"abc\" 1}", List.of("expected :"), List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "Missing key in obj", + "{,}", + List.of(), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Missing colon and unexpected value in obj", + "{foo ?}", + List.of("expected :","unexpected token ?"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "Unclosed text block", + """ + \"\"\"abc + """, + List.of(), + List.of(Syntax.Node.Type.Err) + ), + new InvalidSyntaxTestCase( + "Invalid number", + """ + 123? + """, + List.of(), + List.of(Syntax.Node.Type.Err) ) ); @@ -394,6 +445,48 @@ public void stringValues() { assertThat(((Syntax.Node.Str) third).stringValue().trim(), equalTo("foo")); } + @Test + public void badKvpWithTrailingIncompleteKvp() { + String text = "{\"foo\":bar, \"buz\"}"; + Document document = Document.of(text); + Syntax.Node.Obj node = (Syntax.Node.Obj)Syntax.parseNode(document).value(); + Syntax.Node.Kvps kvps = node.kvps(); + assertThat(document.copySpan(kvps.start, kvps.end), equalTo("{\"foo\":bar, \"buz\"}")); + Syntax.Node.Kvp first = kvps.kvps().get(0); + assertThat(document.copySpan(first.start, first.end), equalTo("\"foo\":bar")); + Syntax.Node.Kvp second = kvps.kvps().get(1); + assertThat(document.copySpan(second.start, second.end), equalTo("\"buz\"")); + } + + @Test + public void badKvpWithLeadingComma() { + String text = "{,\"foo\":bar}"; + Document document = Document.of(text); + Syntax.Node.Obj node = (Syntax.Node.Obj)Syntax.parseNode(document).value(); + Syntax.Node.Kvps kvps = node.kvps(); + assertThat(document.copySpan(kvps.start, kvps.end), equalTo("{,\"foo\":bar}")); + Syntax.Node.Kvp first = kvps.kvps().get(0); + assertThat(document.copySpan(first.start, first.end), equalTo("\"foo\":bar")); + } + + @Test + public void badArrWithLeadingComma() { + String text = "[,a,"; + Document document = Document.of(text); + Syntax.Node.Arr arr = (Syntax.Node.Arr)Syntax.parseNode(document).value(); + assertThat(arr.elements(), hasSize(1)); + assertThat(document.copySpan(arr.elements.get(0).start, arr.elements.get(0).end), equalTo("a")); + } + + @Test + public void badArrWithInvalidNum() { + String text = "[456?,123]"; + Document document = Document.of(text); + Syntax.Node.Arr arr = (Syntax.Node.Arr)Syntax.parseNode(document).value(); + assertThat(arr.elements(), hasSize(1)); + assertThat(document.copySpan(arr.elements.get(0).start, arr.elements.get(0).end), equalTo("123")); + } + private static void assertTypesEqual(String text, Syntax.Node.Type... types) { assertThat(getNodeTypes(Syntax.parseNode(Document.of(text)).value()), contains(types)); } From 6341ac7f13f224a63e4b6d5b9706afa876640d5a Mon Sep 17 00:00:00 2001 From: Hayden Baker Date: Fri, 11 Apr 2025 08:58:42 -0700 Subject: [PATCH 38/43] Add runtime plugin for generating stand-alone images (#159) * add runtime plugin for generating stand-alone images * add JReleaser configuration for github release and runtimes * Use runtime plugin fork Updates the runtime plugin to a fork with support for java 21, instead of the upstream https://github.com/beryx/badass-runtime-plugin. See https://github.com/beryx/badass-runtime-plugin/pull/154 for more details. Also changed the java version back to 21. * Remove deploy github workflow We had a github workflow that would run on a tag push, creating a new release and uploading a zip of the language server. Jreleaser should take care of all that now, so the workflow is unnecessary. * Add changelog to github release notes So we don't have to manually add the release notes to the github release after it is published. I added a custom task to read in the full changelog, and parse out the entry for the latest version. The result is written out to a resources file that I pointed the github release config to. The jreleaser docs don't seem to be 100% clear on whether the changelog is actually what ends up in the release notes, but it would make sense to me, and the text in https://jreleaser.org/guide/latest/reference/release/github.html#_release_notes suggests it. --------- Co-authored-by: Miles Ziemer --- .github/workflows/deploy.yml | 36 -------- build.gradle | 160 +++++++++++++++++++++++++++++++---- 2 files changed, 144 insertions(+), 52 deletions(-) delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 25ab2137..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,36 +0,0 @@ ---- - -name: deploy -on: - push: - tags: - - "0*" - -jobs: - deploy: - permissions: - contents: write # needed for the release script - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set variables - run: | - VER=$(cat VERSION) - echo "VERSION=$VER" >> $GITHUB_ENV - echo "Version is $VER" - - - name: Setup JDK - uses: actions/setup-java@v1 - with: - java-version: "11" - - - name: Clean and build - run: ./gradlew clean build distZip - - - name: Release - uses: softprops/action-gh-release@v1 - with: - files: ./build/distributions/smithy-language-server-${{ env.VERSION }}.zip diff --git a/build.gradle b/build.gradle index e050c8ba..88417f98 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent +import java.util.regex.Pattern buildscript { repositories { @@ -33,10 +34,13 @@ plugins { id "application" id "maven-publish" - id "signing" id "com.palantir.git-version" version "0.12.3" id "checkstyle" id "org.jreleaser" version "1.13.0" + + // Fork of runtime plugin with java 21 support, until https://github.com/beryx/badass-runtime-plugin/issues/153 + // is resolved. + id "com.dua3.gradle.runtime" version "1.13.1-patch-1" } @@ -69,6 +73,8 @@ task javadocJar(type: Jar) { ext { // Load the Smithy Language Server version from VERSION. libraryVersion = project.file("VERSION").getText('UTF-8').replace(System.lineSeparator(), "") + imageJreVersion = "21" + correttoRoot = "https://corretto.aws/downloads/latest/amazon-corretto-${imageJreVersion}" } println "Smithy Language Server version: '${libraryVersion}'" @@ -78,7 +84,6 @@ def stagingDirectory = rootProject.layout.buildDirectory.dir("staging") allprojects { apply plugin: "java" apply plugin: "maven-publish" - apply plugin: "signing" group = "software.amazon.smithy" version = libraryVersion description = "Language Server Protocol implementation for Smithy" @@ -228,31 +233,154 @@ jar { } } + +runtime { + addOptions("--compress", "2", "--strip-debug", "--no-header-files", "--no-man-pages") + addModules("java.logging") + + launcher { + jvmArgs = [ + '-XX:-UsePerfData', + '-Xshare:auto', + '-XX:SharedArchiveFile={{BIN_DIR}}/../lib/smithy.jsa' + ] + } + + targetPlatform("linux-x86_64") { + jdkHome = jdkDownload("${correttoRoot}-x64-linux-jdk.tar.gz") + } + + targetPlatform("linux-aarch64") { + jdkHome = jdkDownload("${correttoRoot}-aarch64-linux-jdk.tar.gz") + } + + targetPlatform("darwin-x86_64") { + jdkHome = jdkDownload("${correttoRoot}-x64-macos-jdk.tar.gz") + } + + targetPlatform("darwin-aarch64") { + jdkHome = jdkDownload("${correttoRoot}-aarch64-macos-jdk.tar.gz") + } + + targetPlatform("windows-x64") { + jdkHome = jdkDownload("${correttoRoot}-x64-windows-jdk.zip") + } + + // Because we're using target-platforms, it will use this property as a prefix for each target zip + imageZip = layout.buildDirectory.file("image/smithy-language-server.zip") +} + +tasks["assembleDist"].dependsOn("publish") +tasks["assembleDist"].dependsOn("runtimeZip") + +// Generate a changelog that only includes the changes for the latest version +// which Jreleaser will add to the release notes of the github release. +def releaseChangelogFile = project.layout.buildDirectory.file("resources/RELEASE_CHANGELOG.md").get().asFile +tasks.register("createReleaseChangelog") { + dependsOn processResources + + doLast { + def changelog = project.file("CHANGELOG.md").text + // Copy the text in between the first two version headers + def matcher = Pattern.compile("^## \\d+\\.\\d+\\.\\d+", Pattern.MULTILINE).matcher(changelog) + def getIndex = { + matcher.find() + return matcher.start() + } + def result = changelog.substring(getIndex(), getIndex()).trim() + releaseChangelogFile.write(result) + } +} + +tasks.jreleaserRelease.dependsOn(tasks.createReleaseChangelog) + jreleaser { dryrun = false - // Used for creating a tagged release, uploading files and generating changelog. - // In the future we can set this up to push release tags to GitHub, but for now it's - // set up to do nothing. - // https://jreleaser.org/guide/latest/reference/release/index.html + project { + website = 'https://smithy.io' + authors = ['Smithy'] + vendor = "Smithy" + license = 'Apache-2.0' + description = "Smithy Language Server - A Language Server Protocol implementation for the Smithy IDL." + copyright = "2019" + } + release { - generic { - enabled = true - skipRelease = true + github { + overwrite = true + tagName = '{{projectVersion}}' + releaseName = 'Smithy Language Server v{{{projectVersion}}}' + changelog { + external = releaseChangelogFile.absolutePath + } + commitAuthor { + name = "smithy-automation" + email = "github-smithy-automation@amazon.com" + } } } - // Used to announce a release to configured announcers. - // https://jreleaser.org/guide/latest/reference/announce/index.html - announce { - active = "NEVER" + files { + active = "ALWAYS" + artifact { + // We'll include the VERSION file in the release artifacts so that the version can be easily + // retrieving by hitting the GitHub `releases/latest` url + path = "VERSION" + extraProperties.put('skipSigning', true) + } + } + + platform { + // These replacements are for the names of files that are released, *not* for names within this build config + replacements = [ + 'osx': 'darwin', + 'aarch_64': 'aarch64', + 'windows_x86_64': 'windows_x64' + ] + } + + distributions { + 'smithy-language-server' { + distributionType = 'JLINK' + stereotype = 'CLI' + + artifact { + path = "build/image/smithy-language-server-linux-x86_64.zip" + platform = "linux-x86_64" + } + + artifact { + path = "build/image/smithy-language-server-linux-aarch64.zip" + platform = "linux-aarch_64" + } + + artifact { + path = "build/image/smithy-language-server-darwin-x86_64.zip" + platform = "osx-x86_64" + } + + artifact { + path = "build/image/smithy-language-server-darwin-aarch64.zip" + platform = "osx-aarch_64" + } + + artifact { + path = "build/image/smithy-language-server-windows-x64.zip" + platform = "windows-x86_64" + } + } + } + + checksum { + individual = true + files = false } - // Signing configuration. - // https://jreleaser.org/guide/latest/reference/signing.html signing { - active = "ALWAYS" + active = "RELEASE" armored = true + verify = true } // Configuration for deploying to Maven Central. From 3b681c86ac5407a41f18fb16ed54564cfc703473 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:16:42 -0400 Subject: [PATCH 39/43] Add missing modules to runtime (#219) Updates the runtime plugin config to include the same modules the smithy-cli does: https://github.com/smithy-lang/smithy/blob/fb9ef6dafe89742eefb87f0072b4c7762afd70d9/smithy-cli/build.gradle.kts#L99. Otherwise, we get some class defs not found when resolving dependencies. I don't have a good idea for how to avoid the duplication between the repos. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 88417f98..3052f362 100644 --- a/build.gradle +++ b/build.gradle @@ -236,7 +236,7 @@ jar { runtime { addOptions("--compress", "2", "--strip-debug", "--no-header-files", "--no-man-pages") - addModules("java.logging") + addModules("java.logging", "java.naming", "java.xml", "jdk.crypto.ec") launcher { jvmArgs = [ From 5511ee582acb765577412fc245fedf40d897443e Mon Sep 17 00:00:00 2001 From: Joe Wu Date: Mon, 14 Apr 2025 15:18:12 -0700 Subject: [PATCH 40/43] Add argument parser feature to the LSP. (#218) Add the argument parser feature to the LSP and modified the Main class. The argument parser will be able to parse the help and port-number arguments for now. For port-number argument, both flag argument and positional argument are supported. Port number is optional, and defaults to `0` to use standard in/out. The positional port argument is deprecated, but still supported. --- .../java/software/amazon/smithy/lsp/Main.java | 113 ++++++------------ .../amazon/smithy/lsp/ServerArguments.java | 101 ++++++++++++++++ .../smithy/lsp/ServerArgumentsTest.java | 111 +++++++++++++++++ 3 files changed, 246 insertions(+), 79 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/ServerArguments.java create mode 100644 src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java diff --git a/src/main/java/software/amazon/smithy/lsp/Main.java b/src/main/java/software/amazon/smithy/lsp/Main.java index 87add549..d7a7054f 100644 --- a/src/main/java/software/amazon/smithy/lsp/Main.java +++ b/src/main/java/software/amazon/smithy/lsp/Main.java @@ -15,14 +15,13 @@ package software.amazon.smithy.lsp; -import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; -import java.util.Optional; -import org.eclipse.lsp4j.jsonrpc.Launcher; import org.eclipse.lsp4j.launch.LSPLauncher; -import org.eclipse.lsp4j.services.LanguageClient; +import software.amazon.smithy.cli.AnsiColorFormatter; +import software.amazon.smithy.cli.CliPrinter; +import software.amazon.smithy.cli.HelpPrinter; /** * Main launcher for the Language server, started by the editor. @@ -32,90 +31,46 @@ private Main() { } /** - * Launch the LSP and wait for it to terminate. - * - * @param in input stream for communication - * @param out output stream for communication - * @return Empty Optional if service terminated successfully, error otherwise + * Main entry point for the language server. + * @param args Arguments passed to the server. + * @throws Exception If there is an error starting the server. */ - public static Optional launch(InputStream in, OutputStream out) { - SmithyLanguageServer server = new SmithyLanguageServer(); - Launcher launcher = LSPLauncher.createServerLauncher( - server, - exitOnClose(in), - out); - - LanguageClient client = launcher.getRemoteProxy(); - - server.connect(client); - try { - launcher.startListening().get(); - return Optional.empty(); - } catch (Exception e) { - return Optional.of(e); + public static void main(String[] args) throws Exception { + var serverArguments = ServerArguments.create(args); + if (serverArguments.help()) { + printHelp(serverArguments); + System.exit(0); } + + launch(serverArguments); } - private static InputStream exitOnClose(InputStream delegate) { - return new InputStream() { - @Override - public int read() throws IOException { - int result = delegate.read(); - if (result < 0) { - System.exit(0); - } - return result; + private static void launch(ServerArguments serverArguments) throws Exception { + if (serverArguments.useSocket()) { + try (var socket = new Socket("localhost", serverArguments.port())) { + startServer(socket.getInputStream(), socket.getOutputStream()); } - }; + } else { + startServer(System.in, System.out); + } } - /** - * @param args Arguments passed to launch server. First argument must either be - * a port number for socket connection, or 0 to use STDIN and STDOUT - * for communication - */ - public static void main(String[] args) { - - Socket socket = null; - InputStream in; - OutputStream out; + private static void startServer(InputStream in, OutputStream out) throws Exception { + var server = new SmithyLanguageServer(); + var launcher = LSPLauncher.createServerLauncher(server, in, out); - try { - String port = args[0]; - // If port is set to "0", use System.in/System.out. - if (port.equals("0")) { - in = System.in; - out = System.out; - } else { - socket = new Socket("localhost", Integer.parseInt(port)); - in = socket.getInputStream(); - out = socket.getOutputStream(); - } - - Optional launchFailure = launch(in, out); + var client = launcher.getRemoteProxy(); + server.connect(client); - if (launchFailure.isPresent()) { - throw launchFailure.get(); - } else { - System.out.println("Server terminated without errors"); - } - } catch (ArrayIndexOutOfBoundsException e) { - System.out.println("Missing port argument"); - } catch (NumberFormatException e) { - System.out.println("Port number must be a valid integer"); - } catch (Exception e) { - System.out.println(e); + launcher.startListening().get(); + } - e.printStackTrace(); - } finally { - try { - if (socket != null) { - socket.close(); - } - } catch (Exception e) { - System.out.println("Failed to close the socket"); - System.out.println(e); - } - } + private static void printHelp(ServerArguments serverArguments) { + CliPrinter printer = CliPrinter.fromOutputStream(System.out); + HelpPrinter helpPrinter = new HelpPrinter("smithy-language-server"); + serverArguments.registerHelp(helpPrinter); + helpPrinter.summary("Run the Smithy Language Server."); + helpPrinter.print(AnsiColorFormatter.AUTO, printer); + printer.flush(); } } diff --git a/src/main/java/software/amazon/smithy/lsp/ServerArguments.java b/src/main/java/software/amazon/smithy/lsp/ServerArguments.java new file mode 100644 index 00000000..345bb3ba --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerArguments.java @@ -0,0 +1,101 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.function.Consumer; +import software.amazon.smithy.cli.ArgumentReceiver; +import software.amazon.smithy.cli.Arguments; +import software.amazon.smithy.cli.CliError; +import software.amazon.smithy.cli.HelpPrinter; + +/** + * Options and Params available for LSP. + */ +final class ServerArguments implements ArgumentReceiver { + + private static final int MIN_PORT = 0; + private static final int MAX_PORT = 65535; + private static final int DEFAULT_PORT = 0; // Default value for unset port number. + private static final String HELP = "--help"; + private static final String HELP_SHORT = "-h"; + private static final String PORT = "--port"; + private static final String PORT_SHORT = "-p"; + private static final String PORT_POSITIONAL = ""; + private int port = DEFAULT_PORT; + private boolean help = false; + + + static ServerArguments create(String[] args) { + Arguments arguments = Arguments.of(args); + var serverArguments = new ServerArguments(); + arguments.addReceiver(serverArguments); + var positional = arguments.getPositional(); + if (!positional.isEmpty()) { + serverArguments.port = serverArguments.validatePortNumber(positional.getFirst()); + } + return serverArguments; + } + + @Override + public void registerHelp(HelpPrinter printer) { + printer.option(HELP, HELP_SHORT, "Print this help output."); + printer.param(PORT, PORT_SHORT, "PORT", + "The port to use for talking to the client. When not specified, or set to 0, " + + "standard in/out is used. Standard in/out is preferred, " + + "so usually this shouldn't be specified."); + printer.option(PORT_POSITIONAL, null, "Deprecated: use --port instead. When not specified, or set to 0, " + + "standard in/out is used. Standard in/out is preferred, so usually this shouldn't be specified."); + } + + @Override + public boolean testOption(String name) { + if (name.equals(HELP) || name.equals(HELP_SHORT)) { + help = true; + return true; + } + return false; + } + + @Override + public Consumer testParameter(String name) { + if (name.equals(PORT_SHORT) || name.equals(PORT)) { + return value -> { + port = validatePortNumber(value); + }; + } + return null; + } + + int port() { + return port; + } + + boolean help() { + return help; + } + + boolean useSocket() { + return port != 0; + } + + private int validatePortNumber(String portStr) { + try { + int portNumber = Integer.parseInt(portStr); + if (portNumber < MIN_PORT || portNumber > MAX_PORT) { + throw invalidPort(portStr); + } else { + return portNumber; + } + } catch (NumberFormatException e) { + throw invalidPort(portStr); + } + } + + private static CliError invalidPort(String portStr) { + return new CliError("Invalid port number: expected an integer between " + + MIN_PORT + " and " + MAX_PORT + ", inclusive. Was: " + portStr); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java b/src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java new file mode 100644 index 00000000..048b83a1 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java @@ -0,0 +1,111 @@ +package software.amazon.smithy.lsp; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.cli.CliError; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ServerArgumentsTest { + @Test + void validPositionalPortNumber() { + String[] args = {"1"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(1, serverArguments.port()); + assertFalse(serverArguments.help()); + assertTrue(serverArguments.useSocket()); + } + + @Test + void invalidPositionalPortNumber() { + String[] args = {"65536"}; + assertThrows(CliError.class,()-> {ServerArguments.create(args);}); + + } + + @Test + void invalidFlagPortNumber() { + String[] args = {"-p","65536"}; + assertThrows(CliError.class,()-> {ServerArguments.create(args);}); + } + + @Test + void validFlagPortNumberShort() { + String[] args = {"-p","100"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(100, serverArguments.port()); + assertFalse(serverArguments.help()); + assertTrue(serverArguments.useSocket()); + } + + @Test + void defaultPortNumber() { + String[] args = {}; + ServerArguments serverArguments = ServerArguments.create(args); + + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void defaultPortNumberInArg() { + String[] args = {"0"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void defaultPortNumberWithFlag() { + String[] args = {"--port","0"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void defaultPortNumberWithShotFlag() { + String[] args = {"-p","0"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void validFlagPortNumber() { + String[] args = {"--port","200"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(200, serverArguments.port()); + } + + @Test + void invalidFlag() { + String[] args = {"--foo"}; + assertThrows(CliError.class,()-> {ServerArguments.create(args);}); + } + + @Test + void validHelpShort() { + String[] args = {"-h"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertTrue(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void validHelp() { + String[] args = {"--help"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertTrue(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } +} From 86088c039114b4af64acb3e4e9a682d9c7b2eb07 Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:47:07 -0400 Subject: [PATCH 41/43] Bump version to 0.7.0 (#220) --- CHANGELOG.md | 13 +++++++++++++ VERSION | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59071973..618ad3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Smithy Language Server Changelog +## 0.7.0 (2025-04-15) + +### Features +* Added standalone runtime images to GitHub release, to run the language server without a local Java installation. ([#159](https://github.com/smithy-lang/smithy-language-server/pull/159)) +* Improved how the language server is launched from the CLI. ([#218](https://github.com/smithy-lang/smithy-language-server/pull/218)) +* Added textDocument/rename support. ([#213](https://github.com/smithy-lang/smithy-language-server/pull/213)) +* Added textDocument/references support. ([#213](https://github.com/smithy-lang/smithy-language-server/pull/213)) +* Made textDocument/documentSymbol return hierarchical symbols. ([#206](https://github.com/smithy-lang/smithy-language-server/pull/206)) + +### Bug fixes +* Fixed possible crash on initialization. ([#216](https://github.com/smithy-lang/smithy-language-server/pull/216)) +* Removed extraneous validation events from hover content. ([#214](https://github.com/smithy-lang/smithy-language-server/pull/214)) + ## 0.6.0 (2025-03-10) ### Features diff --git a/VERSION b/VERSION index a918a2aa..faef31a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.7.0 From ff3b7e0cffdbe6f6d10d3d41681020443683987b Mon Sep 17 00:00:00 2001 From: Miles Ziemer <45497130+milesziemer@users.noreply.github.com> Date: Wed, 16 Apr 2025 17:57:27 -0400 Subject: [PATCH 42/43] Switch build scripts to kotlin (#221) Kotlin is easier to understand for me, and Intellij apparently. I tried to keep this as much as a 1:1 conversion as I could for this commit, but will followup with another to clean it up. --- build.gradle => build.gradle.kts | 225 ++++++++++++------------- settings.gradle => settings.gradle.kts | 2 +- 2 files changed, 111 insertions(+), 116 deletions(-) rename build.gradle => build.gradle.kts (57%) rename settings.gradle => settings.gradle.kts (88%) diff --git a/build.gradle b/build.gradle.kts similarity index 57% rename from build.gradle rename to build.gradle.kts index 3052f362..fae02d51 100644 --- a/build.gradle +++ b/build.gradle.kts @@ -15,12 +15,16 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.jreleaser.model.Active +import org.jreleaser.model.Distribution.DistributionType +import org.jreleaser.model.Stereotype +import java.util.Properties import java.util.regex.Pattern buildscript { repositories { - maven { url "https://plugins.gradle.org/m2/" } + maven { url = uri("https://plugins.gradle.org/m2/") } mavenLocal() } } @@ -28,62 +32,56 @@ buildscript { plugins { // Apply the java plugin to add support for Java - id "java" + id("java") // Apply the application plugin to add support for building a CLI application. - id "application" + id("application") - id "maven-publish" - id "com.palantir.git-version" version "0.12.3" - id "checkstyle" - id "org.jreleaser" version "1.13.0" + id("maven-publish") + id("com.palantir.git-version") version "0.12.3" + id("checkstyle") + id("org.jreleaser") version "1.13.0" // Fork of runtime plugin with java 21 support, until https://github.com/beryx/badass-runtime-plugin/issues/153 // is resolved. - id "com.dua3.gradle.runtime" version "1.13.1-patch-1" + id("com.dua3.gradle.runtime") version "1.13.1-patch-1" } -version gitVersion().replaceFirst("v", "") +val gitVersion: groovy.lang.Closure by extra +version = gitVersion().replaceFirst("v", "") // Reusable license copySpec for building JARs -def licenseSpec = copySpec { - from "${project.rootDir}/LICENSE" - from "${project.rootDir}/NOTICE" +val licenseSpec = copySpec { + from("${project.rootDir}/LICENSE") + from("${project.rootDir}/NOTICE") } // Set up tasks that build source and javadoc jars. -task sourcesJar(type: Jar) { +tasks.register("sourcesJar") { metaInf.with(licenseSpec) - from { - sourceSets.main.allJava - } + from(sourceSets.main.get().allJava) archiveClassifier = "sources" } // Build a javadoc JAR too. -task javadocJar(type: Jar) { +tasks.register("javadocJar") { metaInf.with(licenseSpec) - from { - tasks.javadoc - } + from(tasks.javadoc) archiveClassifier = "javadoc" } -ext { - // Load the Smithy Language Server version from VERSION. - libraryVersion = project.file("VERSION").getText('UTF-8').replace(System.lineSeparator(), "") - imageJreVersion = "21" - correttoRoot = "https://corretto.aws/downloads/latest/amazon-corretto-${imageJreVersion}" -} +val libraryVersion = project.file("VERSION").readText().trim() +val imageJreVersion = "21" +val correttoRoot = "https://corretto.aws/downloads/latest/amazon-corretto-${imageJreVersion}" -println "Smithy Language Server version: '${libraryVersion}'" +println("Smithy Language Server version: '${libraryVersion}'") -def stagingDirectory = rootProject.layout.buildDirectory.dir("staging") +val stagingDirectory = rootProject.layout.buildDirectory.dir("staging") allprojects { - apply plugin: "java" - apply plugin: "maven-publish" + apply(plugin = "java") + apply(plugin = "maven-publish") group = "software.amazon.smithy" version = libraryVersion description = "Language Server Protocol implementation for Smithy" @@ -98,25 +96,23 @@ publishing { repositories { maven { name = "localStaging" - url = stagingDirectory + url = uri(stagingDirectory) } } publications { - mavenJava(MavenPublication) { - groupId = project.group + create("mavenJava") { + groupId = project.group.toString() artifactId = "smithy-language-server" - from components.java - - jar + from(components["java"]) // Ship the source and javadoc jars. artifact(tasks["sourcesJar"]) artifact(tasks["javadocJar"]) // Include extra information in the POMs. - project.afterEvaluate { + afterEvaluate { pom { name.set("Smithy Language Server") description.set(project.description) @@ -151,49 +147,48 @@ checkstyle { } dependencies { - implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1" - implementation "software.amazon.smithy:smithy-build:[smithyVersion, 2.0[" - implementation "software.amazon.smithy:smithy-cli:[smithyVersion, 2.0[" - implementation "software.amazon.smithy:smithy-model:[smithyVersion, 2.0[" - implementation "software.amazon.smithy:smithy-syntax:[smithyVersion, 2.0[" + implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1") + implementation("software.amazon.smithy:smithy-build:[smithyVersion, 2.0[") + implementation("software.amazon.smithy:smithy-cli:[smithyVersion, 2.0[") + implementation("software.amazon.smithy:smithy-model:[smithyVersion, 2.0[") + implementation("software.amazon.smithy:smithy-syntax:[smithyVersion, 2.0[") - testImplementation "org.junit.jupiter:junit-jupiter:5.10.0" - testImplementation "org.hamcrest:hamcrest:2.2" + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + testImplementation("org.hamcrest:hamcrest:2.2") - testRuntimeOnly "org.junit.platform:junit-platform-launcher" + testRuntimeOnly("org.junit.platform:junit-platform-launcher") - checkstyle "com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}" + checkstyle("com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}") } -tasks.withType(Javadoc).all { - options.addStringOption('Xdoclint:none', '-quiet') +tasks.withType { + (options as StandardJavadocDocletOptions).addStringOption("Xdoclint:none", "-quiet") } -tasks.withType(Test).configureEach { +tasks.withType().configureEach { useJUnitPlatform() testLogging { - events TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR - exceptionFormat TestExceptionFormat.FULL - showExceptions true - showCauses true - showStackTraces true + events = setOf(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR) + exceptionFormat = TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true } } -tasks.register('createProperties') { - dependsOn processResources +tasks.register("createProperties") { + dependsOn(tasks.processResources) doLast { - new File("$buildDir/resources/main/version.properties").withWriter { w -> - Properties p = new Properties() - p['version'] = project.version.toString() - p.store w, null - } + val file = project.layout.buildDirectory.file("resources/main/version.properties").get().asFile + val properties = Properties() + properties["version"] = project.version.toString() + properties.store(file.writer(), null) } } -classes { - dependsOn createProperties +tasks.classes { + dependsOn(tasks["createProperties"]) } application { @@ -203,7 +198,7 @@ application { // ==== CheckStyle ==== // https://docs.gradle.org/current/userguide/checkstyle_plugin.html -apply plugin: "checkstyle" +apply(plugin = "checkstyle") tasks.named("checkstyleTest") { enabled = false } @@ -214,22 +209,22 @@ java { } } -jar { - from (configurations.compileClasspath.collect { entry -> zipTree(entry) }) { - exclude "about.html" - exclude "META-INF/LICENSE" - exclude "META-INF/LICENSE.txt" - exclude "META-INF/NOTICE" - exclude "META-INF/MANIFEST.MF" - exclude "META-INF/*.SF" - exclude "META-INF/*.DSA" - exclude "META-INF/*.RSA" - exclude "reflect.properties" +tasks.jar { + from (configurations.compileClasspath.get().map { zipTree(it) }) { + exclude("about.html") + exclude("META-INF/LICENSE") + exclude("META-INF/LICENSE.txt") + exclude("META-INF/NOTICE") + exclude("META-INF/MANIFEST.MF") + exclude("META-INF/*.SF") + exclude("META-INF/*.DSA") + exclude("META-INF/*.RSA") + exclude("reflect.properties") // Included by dependencies in later versions of java, causes duplicate entries in the output jar - exclude "**/module-info.class" + exclude("**/module-info.class") } manifest { - attributes("Main-Class": "software.amazon.smithy.lsp.Main") + attributes("Main-Class" to "software.amazon.smithy.lsp.Main") } } @@ -239,11 +234,11 @@ runtime { addModules("java.logging", "java.naming", "java.xml", "jdk.crypto.ec") launcher { - jvmArgs = [ - '-XX:-UsePerfData', - '-Xshare:auto', - '-XX:SharedArchiveFile={{BIN_DIR}}/../lib/smithy.jsa' - ] + jvmArgs = listOf( + "-XX:-UsePerfData", + "-Xshare:auto", + "-XX:SharedArchiveFile={{BIN_DIR}}/../lib/smithy.jsa" + ) } targetPlatform("linux-x86_64") { @@ -275,33 +270,33 @@ tasks["assembleDist"].dependsOn("runtimeZip") // Generate a changelog that only includes the changes for the latest version // which Jreleaser will add to the release notes of the github release. -def releaseChangelogFile = project.layout.buildDirectory.file("resources/RELEASE_CHANGELOG.md").get().asFile +val releaseChangelogFile = project.layout.buildDirectory.file("resources/RELEASE_CHANGELOG.md").get() tasks.register("createReleaseChangelog") { - dependsOn processResources + dependsOn(tasks.processResources) doLast { - def changelog = project.file("CHANGELOG.md").text + val changelog = project.file("CHANGELOG.md").readText() // Copy the text in between the first two version headers - def matcher = Pattern.compile("^## \\d+\\.\\d+\\.\\d+", Pattern.MULTILINE).matcher(changelog) - def getIndex = { + val matcher = Pattern.compile("^## \\d+\\.\\d+\\.\\d+", Pattern.MULTILINE).matcher(changelog) + val getIndex = fun(): Int { matcher.find() return matcher.start() } - def result = changelog.substring(getIndex(), getIndex()).trim() - releaseChangelogFile.write(result) + val result = changelog.substring(getIndex(), getIndex()).trim() + releaseChangelogFile.asFile.writeText(result) } } -tasks.jreleaserRelease.dependsOn(tasks.createReleaseChangelog) +tasks.jreleaserRelease.get().dependsOn(tasks.processResources) jreleaser { dryrun = false project { - website = 'https://smithy.io' - authors = ['Smithy'] + website = "https://smithy.io" + authors = listOf("Smithy") vendor = "Smithy" - license = 'Apache-2.0' + license = "Apache-2.0" description = "Smithy Language Server - A Language Server Protocol implementation for the Smithy IDL." copyright = "2019" } @@ -309,10 +304,10 @@ jreleaser { release { github { overwrite = true - tagName = '{{projectVersion}}' - releaseName = 'Smithy Language Server v{{{projectVersion}}}' + tagName = "{{projectVersion}}" + releaseName = "Smithy Language Server v{{{projectVersion}}}" changelog { - external = releaseChangelogFile.absolutePath + external = releaseChangelogFile } commitAuthor { name = "smithy-automation" @@ -322,51 +317,51 @@ jreleaser { } files { - active = "ALWAYS" + active = Active.ALWAYS artifact { // We'll include the VERSION file in the release artifacts so that the version can be easily // retrieving by hitting the GitHub `releases/latest` url - path = "VERSION" - extraProperties.put('skipSigning', true) + path = file("VERSION") + extraProperties.put("skipSigning", true) } } platform { // These replacements are for the names of files that are released, *not* for names within this build config - replacements = [ - 'osx': 'darwin', - 'aarch_64': 'aarch64', - 'windows_x86_64': 'windows_x64' - ] + replacements = mapOf( + "osx" to "darwin", + "aarch_64" to "aarch64", + "windows_x86_64" to "windows_x64" + ) } distributions { - 'smithy-language-server' { - distributionType = 'JLINK' - stereotype = 'CLI' + create("smithy-language-server") { + distributionType = DistributionType.JLINK + stereotype = Stereotype.CLI artifact { - path = "build/image/smithy-language-server-linux-x86_64.zip" + path = file("build/image/smithy-language-server-linux-x86_64.zip") platform = "linux-x86_64" } artifact { - path = "build/image/smithy-language-server-linux-aarch64.zip" + path = file("build/image/smithy-language-server-linux-aarch64.zip") platform = "linux-aarch_64" } artifact { - path = "build/image/smithy-language-server-darwin-x86_64.zip" + path = file("build/image/smithy-language-server-darwin-x86_64.zip") platform = "osx-x86_64" } artifact { - path = "build/image/smithy-language-server-darwin-aarch64.zip" + path = file("build/image/smithy-language-server-darwin-aarch64.zip") platform = "osx-aarch_64" } artifact { - path = "build/image/smithy-language-server-windows-x64.zip" + path = file("build/image/smithy-language-server-windows-x64.zip") platform = "windows-x86_64" } } @@ -378,7 +373,7 @@ jreleaser { } signing { - active = "RELEASE" + active = Active.RELEASE armored = true verify = true } @@ -388,8 +383,8 @@ jreleaser { deploy { maven { nexus2 { - "maven-central" { - active = "ALWAYS" + create("maven-central") { + active = Active.ALWAYS url = "https://aws.oss.sonatype.org/service/local" snapshotUrl = "https://aws.oss.sonatype.org/content/repositories/snapshots" closeRepository = true diff --git a/settings.gradle b/settings.gradle.kts similarity index 88% rename from settings.gradle rename to settings.gradle.kts index 5b29b664..f6ca20a3 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -7,4 +7,4 @@ * in the user manual at https://docs.gradle.org/6.6.1/userguide/multi_project_builds.html */ -rootProject.name = 'smithy-language-server' +rootProject.name = "smithy-language-server" From 101210969dd8574cdcb58da9fcdba50c9438e9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Thu, 17 Apr 2025 01:18:54 +0200 Subject: [PATCH 43/43] bump smithy --- build.sc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.sc b/build.sc index dd95130d..d216e069 100644 --- a/build.sc +++ b/build.sc @@ -14,10 +14,10 @@ object lsp extends MavenModule with CiReleaseModule { def ivyDeps = Agg( ivy"org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1", - ivy"software.amazon.smithy:smithy-build:1.50.0", - ivy"software.amazon.smithy:smithy-cli:1.50.0", - ivy"software.amazon.smithy:smithy-model:1.50.0", - ivy"software.amazon.smithy:smithy-syntax:1.50.0" + ivy"software.amazon.smithy:smithy-build:1.56.0", + ivy"software.amazon.smithy:smithy-cli:1.56.0", + ivy"software.amazon.smithy:smithy-model:1.56.0", + ivy"software.amazon.smithy:smithy-syntax:1.56.0" ) def javacOptions = T {