diff --git a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java index f232fff..536135a 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 3b4b0c1..70a70a3 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 c9123b8..b1d2b07 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 a6bf159..c06e821 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 b2a18f9..1c5025a 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 5460003..865a959 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 d74b490..d6d2cf5 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 c150dbc..0000000 --- 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 9395ead..aad25ce 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 8edab02..0e362ad 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 3a9975b..32e9e0e 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 2383bd5..2ff2694 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("""