From cc605d7e5e5254051340ccf00939d3ff11e26ce0 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Tue, 26 Mar 2024 09:26:54 -0400 Subject: [PATCH 01/15] Refactor for performance improvements Re-writes almost everything in the language server to improve performance, and lay the ground work for further progress and features. Given the scope of these changes, this should be considered a WIP as I may have broken some things. Overview of performance improvements: - Per-file model updates on change events - Model validation only run on save - Async execution of model building and validation with cancellation - Async execution of some language features like completion and document symbol with cancellation - Incremental file change updates - Reduced file reads from disk and string copies From the end user's perspective, only one major change has been made what the language server can do. It now uses a smithy-build.json in the root of the workspace as the source of truth for what is part of the project. Previously, the server loaded all Smithy files it found in any subdir of the root. This doesn't scale well with multi-root workspaces, and leads to an issue where Smithy files in the build directory are added to the project, duplicating sources. The new process looks for a smithy-build.json and uses only its `sources` and `imports` as files to load into the model (maven deps are still supported, this is just referring to project files). For backward compatibility, the old SmithyBuildExtensions `build/smithy-dependencies.json` and `.smithy.json` are still supported. A new file, `.smithy-project.json`, is being developed which allows projects that are configured outside of a smithy-build.json (such as a Gradle project) to specify their project files _and_ dependencies. Right now these dependencies are local, paths to JARs, but it may make sense to support Maven dependencies in there as well. More to come on how `.smithy-project.json` works in documentation updates. To support using the language server without a smithy-build.json, a future update is in progress to allow a 'detached' project which loads whatever files you open. Other updates: - Use smithy-syntax formatter - Report progress to client on load - Add configuration option for the minimum severity of validation events - Update dependencies --- VERSION | 2 +- build.gradle | 24 +- .../smithy/lsp/DocumentLifecycleManager.java | 44 + .../java/software/amazon/smithy/lsp/Main.java | 24 +- .../amazon/smithy/lsp/ProtocolAdapter.java | 127 --- .../amazon/smithy/lsp/SmithyInterface.java | 72 -- .../smithy/lsp/SmithyLanguageClient.java | 151 +++ .../smithy/lsp/SmithyLanguageServer.java | 763 ++++++++++--- .../smithy/lsp/SmithyTextDocumentService.java | 894 ---------------- .../smithy/lsp/SmithyWorkspaceService.java | 59 -- .../software/amazon/smithy/lsp/Utils.java | 230 ---- .../codeactions/DefineVersionCodeAction.java | 1 - .../lsp/diagnostics/VersionDiagnostics.java | 116 +- .../amazon/smithy/lsp/document/Document.java | 502 +++++++++ .../smithy/lsp/document/DocumentImports.java | 37 + .../lsp/document/DocumentNamespace.java | 36 + .../smithy/lsp/document/DocumentParser.java | 684 ++++++++++++ .../lsp/document/DocumentPositionContext.java | 39 + .../smithy/lsp/document/DocumentShape.java | 75 ++ .../smithy/lsp/document/DocumentVersion.java | 35 + .../amazon/smithy/lsp/editor/SmartInput.java | 93 -- .../amazon/smithy/lsp/ext/Completions.java | 258 ----- .../amazon/smithy/lsp/ext/Constants.java | 40 - .../amazon/smithy/lsp/ext/Document.java | 167 --- .../smithy/lsp/ext/DocumentPreamble.java | 97 -- .../smithy/lsp/ext/FileCachingCollector.java | 417 -------- .../lsp/ext/ShapeLocationCollector.java | 38 - .../smithy/lsp/ext/SmithyBuildLoader.java | 70 -- .../smithy/lsp/ext/SmithyCompletionItem.java | 50 - .../amazon/smithy/lsp/ext/SmithyProject.java | 315 ------ .../smithy/lsp/ext/ValidationException.java | 25 - .../smithy/lsp/handler/CompletionHandler.java | 283 +++++ .../smithy/lsp/handler/DefinitionHandler.java | 89 ++ .../FileWatcherRegistrationHandler.java | 102 ++ .../smithy/lsp/handler/HoverHandler.java | 159 +++ .../amazon/smithy/lsp/project/Project.java | 283 +++++ .../smithy/lsp/project/ProjectConfig.java | 123 +++ .../lsp/project/ProjectConfigLoader.java | 163 +++ .../smithy/lsp/project/ProjectDependency.java | 44 + .../project/ProjectDependencyResolver.java | 117 ++ .../smithy/lsp/project/ProjectLoader.java | 291 +++++ .../SmithyBuildExtensions.java | 26 +- .../amazon/smithy/lsp/project/SmithyFile.java | 196 ++++ .../smithy/lsp/protocol/LocationAdapter.java | 30 + .../smithy/lsp/protocol/PositionAdapter.java | 28 + .../smithy/lsp/protocol/RangeAdapter.java | 178 ++++ .../smithy/lsp/protocol/UriAdapter.java | 111 ++ .../amazon/smithy/lsp/util/Result.java | 151 +++ .../smithy/lsp/util/ThrowingSupplier.java | 17 + .../amazon/smithy/lsp/LspMatchers.java | 75 ++ .../amazon/smithy/lsp/RequestBuilders.java | 231 ++++ .../smithy/lsp/SmithyInterfaceTest.java | 124 --- .../smithy/lsp/SmithyLanguageServerTest.java | 939 ++++++++++++++-- .../amazon/smithy/lsp/SmithyMatchers.java | 52 + .../lsp/SmithyTextDocumentServiceTest.java | 999 ------------------ .../lsp/SmithyVersionRefactoringTest.java | 214 ++-- .../amazon/smithy/lsp/StubClient.java | 68 ++ .../amazon/smithy/lsp/TestWorkspace.java | 186 ++++ .../lsp/document/DocumentParserTest.java | 316 ++++++ .../smithy/lsp/document/DocumentTest.java | 460 ++++++++ .../smithy/lsp/ext/CompletionsTest.java | 166 --- .../amazon/smithy/lsp/ext/DocumentTest.java | 206 ---- .../amazon/smithy/lsp/ext/Harness.java | 133 --- .../lsp/ext/MockDependencyResolver.java | 32 - .../smithy/lsp/ext/ProtocolAdapterTests.java | 37 - .../lsp/ext/SmithyBuildExtensionsTest.java | 118 --- .../smithy/lsp/ext/SmithyBuildLoaderTest.java | 31 - .../smithy/lsp/ext/SmithyProjectTest.java | 513 --------- .../lsp/project/ProjectConfigLoaderTest.java | 80 ++ .../smithy/lsp/project/ProjectTest.java | 225 ++++ .../project/SmithyBuildExtensionsTest.java | 48 + .../amazon/smithy/lsp/ext/empty-config.json | 3 - .../models/document-symbols/another.smithy | 3 - .../models/document-symbols/current.smithy | 5 - .../lsp/ext/models/unknown-trait.smithy | 16 - .../smithy/lsp/ext/models/v1/apply.smithy | 6 - .../smithy/lsp/ext/models/v1/broken.smithy | 7 - .../ext/models/v1/cluttered-preamble.smithy | 33 - .../v1/empty-source-location-trait.smithy | 8 - .../lsp/ext/models/v1/extras-to-import.smithy | 7 - .../smithy/lsp/ext/models/v1/main.smithy | 97 -- .../smithy/lsp/ext/models/v1/preamble.smithy | 11 - .../smithy/lsp/ext/models/v1/test.smithy | 15 - .../smithy/lsp/ext/models/v1/trait-def.smithy | 86 -- .../lsp/ext/models/v2/apply-imports.smithy | 10 - .../smithy/lsp/ext/models/v2/apply.smithy | 38 - .../smithy/lsp/ext/models/v2/broken.smithy | 7 - .../ext/models/v2/cluttered-preamble.smithy | 39 - .../v2/empty-source-location-trait.smithy | 8 - .../lsp/ext/models/v2/extras-to-import.smithy | 7 - .../smithy/lsp/ext/models/v2/main.smithy | 218 ---- .../smithy/lsp/ext/models/v2/preamble.smithy | 13 - .../smithy/lsp/ext/models/v2/test.smithy | 15 - .../smithy/lsp/ext/models/v2/trait-def.smithy | 79 -- .../smithy/lsp/project/apply/model/bar.smithy | 9 + .../smithy/lsp/project/apply/model/foo.smithy | 25 + .../lsp/project/apply/smithy-build.json | 4 + .../broken/missing-version/smithy-build.json | 1 + .../broken/parse-failure/smithy-build.json | 3 + .../source-doesnt-exist/smithy-build.json | 4 + .../smithy-build.json | 8 + .../.smithy-project.json | 8 + .../lsp/project/build-exts/.smithy.json | 4 + .../smithy/lsp/project/build-exts/main.smithy | 5 + .../lsp/project/build-exts/other.smithy | 5 + .../lsp/project/build-exts/smithy-build.json | 4 + .../env-config/smithy-build.json} | 2 +- .../external-jars/.smithy-project.json | 12 + .../external-jars/alloy-core.jar | Bin .../project/external-jars/smithy-build.json | 4 + .../external-jars/smithy-test-traits.jar | Bin .../external-jars/test-traits.smithy | 0 .../external-jars/test-validators.smithy | 0 .../smithy/lsp/project/flat/main.smithy | 9 + .../smithy/lsp/project/flat/smithy-build.json | 4 + .../lsp/project/invalid-syntax/main.smithy | 9 + .../project/invalid-syntax/smithy-build.json | 4 + .../legacy-config-with-conflicts/.smithy.json | 16 + .../lsp/project/legacy-config/.smithy.json | 5 + .../smithy/lsp/project/maven-dep/main.smithy | 5 + .../lsp/project/maven-dep/smithy-build.json | 9 + .../multiple-namespaces/model}/a.smithy | 6 +- .../multiple-namespaces/model}/b.smithy | 8 +- .../multiple-namespaces/smithy-build.json | 4 + .../lsp/project/subdirs/model/main.smithy | 5 + .../project/subdirs/model/subdir/sub.smithy | 5 + .../lsp/project/subdirs/smithy-build.json | 4 + .../lsp/project/unknown-trait/main.smithy | 6 + .../project/unknown-trait/smithy-build.json | 4 + 129 files changed, 7621 insertions(+), 6440 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/SmithyInterface.java create mode 100644 src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/SmithyWorkspaceService.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/Utils.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/Document.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/editor/SmartInput.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/Completions.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/Constants.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/Document.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/DocumentPreamble.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/FileCachingCollector.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/ShapeLocationCollector.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/SmithyBuildLoader.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/SmithyCompletionItem.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java create mode 100644 src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/Project.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java rename src/main/java/software/amazon/smithy/lsp/{ext/model => project}/SmithyBuildExtensions.java (93%) create mode 100644 src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/util/Result.java create mode 100644 src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java create mode 100644 src/test/java/software/amazon/smithy/lsp/LspMatchers.java create mode 100644 src/test/java/software/amazon/smithy/lsp/RequestBuilders.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/StubClient.java create mode 100644 src/test/java/software/amazon/smithy/lsp/TestWorkspace.java create mode 100644 src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/Harness.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java delete mode 100644 src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy delete mode 100644 src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json rename src/test/resources/software/amazon/smithy/lsp/{ext/config-with-env.json => project/env-config/smithy-build.json} (93%) create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json rename src/test/resources/software/amazon/smithy/lsp/{ => project}/external-jars/alloy-core.jar (100%) create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json rename src/test/resources/software/amazon/smithy/lsp/{ => project}/external-jars/smithy-test-traits.jar (100%) rename src/test/resources/software/amazon/smithy/lsp/{ => project}/external-jars/test-traits.smithy (100%) rename src/test/resources/software/amazon/smithy/lsp/{ => project}/external-jars/test-validators.smithy (100%) create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json rename src/test/resources/software/amazon/smithy/lsp/{ext/models/operation-name-conflict => project/multiple-namespaces/model}/a.smithy (58%) rename src/test/resources/software/amazon/smithy/lsp/{ext/models/operation-name-conflict => project/multiple-namespaces/model}/b.smithy (53%) create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json diff --git a/VERSION b/VERSION index abd41058..0d91a54c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.4 +0.3.0 diff --git a/build.gradle b/build.gradle index 9965cfdc..927cdb28 100644 --- a/build.gradle +++ b/build.gradle @@ -156,21 +156,27 @@ publishing { dependencies { - implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.14.0" + implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.20.0" 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 'com.disneystreaming.smithy:smithytranslate-formatter-jvm-java-api:0.3.10' + implementation "software.amazon.smithy:smithy-syntax:[smithyVersion, 2.0[" - // Use JUnit test framework - testImplementation "junit:junit:4.13" + + testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.0" + testImplementation "org.junit.jupiter:junit-jupiter-params:5.10.0" + testImplementation "org.hamcrest:hamcrest:2.1" + + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0" } tasks.withType(Javadoc).all { options.addStringOption('Xdoclint:none', '-quiet') } -tasks.withType(Test) { +tasks.withType(Test).configureEach { + useJUnitPlatform() + testLogging { events TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR exceptionFormat TestExceptionFormat.FULL @@ -180,7 +186,8 @@ tasks.withType(Test) { } } -task createProperties(dependsOn: processResources) { +tasks.register('createProperties') { + dependsOn processResources doLast { new File("$buildDir/resources/main/version.properties").withWriter { w -> Properties p = new Properties() @@ -202,7 +209,9 @@ application { // ==== CheckStyle ==== // https://docs.gradle.org/current/userguide/checkstyle_plugin.html apply plugin: "checkstyle" -tasks["checkstyleTest"].enabled = false +tasks.named("checkstyleTest") { + enabled = false +} java { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -213,6 +222,7 @@ 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" diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java new file mode 100644 index 00000000..1302535a --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Tracks asynchronous lifecycle tasks. Allows cancelling of an ongoing task + * if a new task needs to be started + */ +final class DocumentLifecycleManager { + private final Map> tasks = new HashMap<>(); + + DocumentLifecycleManager() { + } + + CompletableFuture getTask(String uri) { + return tasks.get(uri); + } + + void cancelTask(String uri) { + if (tasks.containsKey(uri)) { + CompletableFuture task = tasks.get(uri); + if (!task.isDone() && !task.isCancelled()) { + task.cancel(true); + } + } + } + + void putTask(String uri, CompletableFuture future) { + tasks.put(uri, future); + } + + void cancelAllTasks() { + for (CompletableFuture task : tasks.values()) { + task.cancel(true); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/Main.java b/src/main/java/software/amazon/smithy/lsp/Main.java index 5a7bf9f4..d92bafe0 100644 --- a/src/main/java/software/amazon/smithy/lsp/Main.java +++ b/src/main/java/software/amazon/smithy/lsp/Main.java @@ -15,6 +15,7 @@ package software.amazon.smithy.lsp; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; @@ -39,7 +40,11 @@ private Main() { */ public static Optional launch(InputStream in, OutputStream out) { SmithyLanguageServer server = new SmithyLanguageServer(); - Launcher launcher = LSPLauncher.createServerLauncher(server, in, out); + Launcher launcher = LSPLauncher.createServerLauncher( + server, + exitOnClose(in), + out); + LanguageClient client = launcher.getRemoteProxy(); server.connect(client); @@ -51,6 +56,23 @@ public static Optional launch(InputStream in, OutputStream out) { } } + private static InputStream exitOnClose(InputStream delegate) { + return new InputStream() { + @Override + public int read() throws IOException { + return exitIfNegative(delegate.read()); + } + + int exitIfNegative(int result) { + if (result < 0) { + System.exit(0); + } + + return result; + } + }; + } + /** * @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 diff --git a/src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java b/src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java deleted file mode 100644 index fc738b12..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import java.util.Optional; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.SymbolKind; -import software.amazon.smithy.model.shapes.ShapeType; -import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; - -public final class ProtocolAdapter { - private ProtocolAdapter() { - - } - - /** - * @param event ValidationEvent to be converted to a Diagnostic. - * @return Returns a Diagnostic from a ValidationEvent. - */ - public static Diagnostic toDiagnostic(ValidationEvent event) { - int line = event.getSourceLocation().getLine() - 1; - int col = event.getSourceLocation().getColumn() - 1; - - DiagnosticSeverity severity = toDiagnosticSeverity(event.getSeverity()); - - Range range = new Range(new Position(line, 0), new Position(line, col)); - - final String message = event.getId() + ": " + event.getMessage(); - - return new Diagnostic(range, message, severity, "Smithy LSP"); - } - - /** - * @param severity Severity to be converted to a DiagnosticSeverity. - * @return Returns a DiagnosticSeverity from a Severity. - */ - public static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { - if (severity == Severity.DANGER) { - return DiagnosticSeverity.Error; - } else if (severity == Severity.ERROR) { - return DiagnosticSeverity.Error; - } else if (severity == Severity.WARNING) { - return DiagnosticSeverity.Warning; - } else if (severity == Severity.NOTE) { - return DiagnosticSeverity.Information; - } else { - return DiagnosticSeverity.Hint; - } - } - - /** - * @param shapeType The type to be converted to a SymbolKind - * @param parentType An optional type of the shape's enclosing definition - * @return An lsp4j SymbolKind - */ - public static SymbolKind toSymbolKind(ShapeType shapeType, Optional parentType) { - switch (shapeType) { - case BYTE: - case BIG_INTEGER: - case DOUBLE: - case BIG_DECIMAL: - case FLOAT: - case LONG: - case INTEGER: - case SHORT: - return SymbolKind.Number; - case BLOB: - // technically a sequence of bytes, so due to the lack of a better alternative, an array - case LIST: - case SET: - return SymbolKind.Array; - case BOOLEAN: - return SymbolKind.Boolean; - case STRING: - return SymbolKind.String; - case TIMESTAMP: - case UNION: - return SymbolKind.Interface; - - case DOCUMENT: - return SymbolKind.Class; - case ENUM: - case INT_ENUM: - return SymbolKind.Enum; - case MAP: - return SymbolKind.Object; - case STRUCTURE: - return SymbolKind.Struct; - case MEMBER: - if (!parentType.isPresent()) { - return SymbolKind.Field; - } - switch (parentType.get()) { - case ENUM: - return SymbolKind.EnumMember; - case UNION: - return SymbolKind.Class; - default: return SymbolKind.Field; - } - case SERVICE: - case RESOURCE: - return SymbolKind.Module; - case OPERATION: - return SymbolKind.Method; - default: - // This case shouldn't be reachable - return SymbolKind.Key; - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java b/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java deleted file mode 100644 index bafe93dc..00000000 --- a/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import java.io.File; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; -import java.util.Collection; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.lsp.ext.LspLog; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.loader.ModelAssembler; -import software.amazon.smithy.model.validation.ValidatedResult; - -public final class SmithyInterface { - - private SmithyInterface() { - - } - - /** - * Reads the model in a specified file, adding external jars to model builder. - * - * @param files list of smithy files - * @param externalJars set of external jars - * @return either an exception encountered during model building, or the result - * of model building - */ - public static Either> readModel(Collection files, - Collection externalJars) { - try { - URL[] urls = externalJars.stream().map(SmithyInterface::fileToUrl).toArray(URL[]::new); - URLClassLoader urlClassLoader = new URLClassLoader(urls); - ModelAssembler assembler = Model.assembler(urlClassLoader) - .discoverModels(urlClassLoader) - // We don't want the model to be broken when there are unknown traits, - // because that will essentially disable language server features. - .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); - - for (File file : files) { - assembler.addImport(file.getAbsolutePath()); - } - - return Either.forRight(assembler.assemble()); - } catch (Exception e) { - LspLog.println(e); - return Either.forLeft(e); - } - } - - private static URL fileToUrl(File file) { - try { - return file.toURI().toURL(); - } catch (MalformedURLException e) { - throw new RuntimeException("Failed to get file's URL", e); - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java new file mode 100644 index 00000000..af8ef575 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java @@ -0,0 +1,151 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.eclipse.lsp4j.ApplyWorkspaceEditParams; +import org.eclipse.lsp4j.ApplyWorkspaceEditResponse; +import org.eclipse.lsp4j.ConfigurationParams; +import org.eclipse.lsp4j.LogTraceParams; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.ProgressParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.RegistrationParams; +import org.eclipse.lsp4j.ShowDocumentParams; +import org.eclipse.lsp4j.ShowDocumentResult; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.WorkDoneProgressCreateParams; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.services.LanguageClient; + +/** + * Wrapper around a delegate {@link LanguageClient} that provides convenience + * methods and/or Smithy-specific language client features. + */ +public class SmithyLanguageClient implements LanguageClient { + private final LanguageClient delegate; + + SmithyLanguageClient(LanguageClient delegate) { + this.delegate = delegate; + } + + /** + * Log a {@link MessageType#Info} message on the client. + * + * @param message Message to log + */ + public void info(String message) { + delegate.logMessage(new MessageParams(MessageType.Info, message)); + } + + /** + * Log a {@link MessageType#Error} message on the client. + * + * @param message Message to log + */ + public void error(String message) { + delegate.logMessage(new MessageParams(MessageType.Error, message)); + } + + @Override + public CompletableFuture applyEdit(ApplyWorkspaceEditParams params) { + return delegate.applyEdit(params); + } + + @Override + public CompletableFuture registerCapability(RegistrationParams params) { + return delegate.registerCapability(params); + } + + @Override + public CompletableFuture unregisterCapability(UnregistrationParams params) { + return delegate.unregisterCapability(params); + } + + @Override + public void telemetryEvent(Object object) { + delegate.telemetryEvent(object); + } + + @Override + public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { + delegate.publishDiagnostics(diagnostics); + } + + @Override + public void showMessage(MessageParams messageParams) { + delegate.showMessage(messageParams); + } + + @Override + public CompletableFuture showMessageRequest(ShowMessageRequestParams requestParams) { + return delegate.showMessageRequest(requestParams); + } + + @Override + public CompletableFuture showDocument(ShowDocumentParams params) { + return delegate.showDocument(params); + } + + @Override + public void logMessage(MessageParams message) { + delegate.logMessage(message); + } + + @Override + public CompletableFuture> workspaceFolders() { + return delegate.workspaceFolders(); + } + + @Override + public CompletableFuture> configuration(ConfigurationParams configurationParams) { + return delegate.configuration(configurationParams); + } + + @Override + public CompletableFuture createProgress(WorkDoneProgressCreateParams params) { + return delegate.createProgress(params); + } + + @Override + public void notifyProgress(ProgressParams params) { + delegate.notifyProgress(params); + } + + @Override + public void logTrace(LogTraceParams params) { + delegate.logTrace(params); + } + + @Override + public CompletableFuture refreshSemanticTokens() { + return delegate.refreshSemanticTokens(); + } + + @Override + public CompletableFuture refreshCodeLenses() { + return delegate.refreshCodeLenses(); + } + + @Override + public CompletableFuture refreshInlayHints() { + return delegate.refreshInlayHints(); + } + + @Override + public CompletableFuture refreshInlineValues() { + return delegate.refreshInlineValues(); + } + + @Override + public CompletableFuture refreshDiagnostics() { + return delegate.refreshDiagnostics(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 9af1b029..6a1c73b8 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -15,28 +15,68 @@ package software.amazon.smithy.lsp; +import static java.util.concurrent.CompletableFuture.completedFuture; + import com.google.gson.JsonObject; -import java.io.File; -import java.io.IOException; import java.net.URI; -import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Properties; import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; import java.util.stream.Collectors; +import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionOptions; +import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; import org.eclipse.lsp4j.CompletionOptions; +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; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.DocumentFormattingParams; +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; +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; +import org.eclipse.lsp4j.Registration; +import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SymbolInformation; +import org.eclipse.lsp4j.SymbolKind; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextDocumentSyncKind; -import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.Unregistration; +import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.WorkDoneProgressBegin; +import org.eclipse.lsp4j.WorkDoneProgressEnd; +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; @@ -44,156 +84,569 @@ import org.eclipse.lsp4j.services.TextDocumentService; import org.eclipse.lsp4j.services.WorkspaceService; import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; +import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; +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.LspLog; -import software.amazon.smithy.utils.ListUtils; - -public class SmithyLanguageServer implements LanguageServer, LanguageClientAware, SmithyProtocolExtensions { - File tempWorkspaceRoot; - private final Optional client = Optional.empty(); - private File workspaceRoot; - private Optional tds = Optional.empty(); - - @Override - public CompletableFuture shutdown() { - return Utils.completableFuture(new Object()); - } - - private void loadSmithyBuild(File root) { - this.tds.ifPresent(tds -> tds.createProject(root)); - } - - @Override - public CompletableFuture initialize(InitializeParams params) { - if (params.getRootUri() != null) { - try { - workspaceRoot = new File(new URI(params.getRootUri())); - loadSmithyBuild(workspaceRoot); - } catch (Exception e) { - LspLog.println("Failure trying to load extensions from workspace root: " + workspaceRoot.getAbsolutePath()); - e.printStackTrace(); - } - } else { - LspLog.println("Workspace root was null"); - } - - if (params.getWorkspaceFolders() == null) { - try { - tempWorkspaceRoot = Files.createTempDirectory("smithy-lsp-workspace").toFile(); - LspLog.println("Created temporary workspace root: " + tempWorkspaceRoot); - tempWorkspaceRoot.deleteOnExit(); - WorkspaceFolder workspaceFolder = new WorkspaceFolder(tempWorkspaceRoot.toURI().toString()); - params.setWorkspaceFolders(ListUtils.of(workspaceFolder)); - } catch (IOException e) { - e.printStackTrace(); - } - } - - // TODO: Replace with a Gson Type Adapter if more config options are added beyond `logToFile`. - Object initializationOptions = params.getInitializationOptions(); - if (initializationOptions instanceof JsonObject) { - JsonObject jsonObject = (JsonObject) initializationOptions; - if (jsonObject.has("logToFile")) { - String setting = jsonObject.get("logToFile").getAsString(); - if (setting.equals("enabled")) { - LspLog.enable(); - } - } - } - - // TODO: This will break on multi-root workspaces - for (WorkspaceFolder ws : params.getWorkspaceFolders()) { - try { - File root = new File(new URI(ws.getUri())); - LspLog.setWorkspaceFolder(root); - loadSmithyBuild(root); - } catch (Exception e) { - LspLog.println("Error when loading workspace folder " + ws.toString() + ": " + e); - e.printStackTrace(); - } - } - - ServerCapabilities capabilities = new ServerCapabilities(); - capabilities.setTextDocumentSync(TextDocumentSyncKind.Full); - capabilities.setCodeActionProvider(new CodeActionOptions(SmithyCodeActions.all())); - capabilities.setDefinitionProvider(true); - capabilities.setDeclarationProvider(true); - capabilities.setCompletionProvider(new CompletionOptions(true, null)); - capabilities.setHoverProvider(true); - capabilities.setDocumentFormattingProvider(true); - capabilities.setDocumentSymbolProvider(true); - - return Utils.completableFuture(new InitializeResult(capabilities)); - } - - @Override - public void exit() { - System.exit(0); - } - - @Override - public WorkspaceService getWorkspaceService() { - return new SmithyWorkspaceService(this.tds); - } - - @Override - public TextDocumentService getTextDocumentService() { - File temp = null; - try { - temp = Files.createTempDirectory("smithy-lsp").toFile(); - LspLog.println("Created a temporary folder for file contents " + temp); - temp.deleteOnExit(); - } catch (IOException e) { - LspLog.println("Failed to create a temporary folder " + e); - } - SmithyTextDocumentService local = new SmithyTextDocumentService(this.client, temp); - tds = Optional.of(local); - return local; - } - - @Override - public void connect(LanguageClient client) { - Properties props = new Properties(); - String message = "Hello from smithy-language-server!"; - try { - props.load(SmithyLanguageServer.class.getClassLoader().getResourceAsStream("version.properties")); - message = "Hello from smithy-language-server " + props.getProperty("version") + "!"; - } catch (Exception e) { - LspLog.println("Could not read Language Server version: " + e); - } - tds.ifPresent(tds -> tds.setClient(client)); - client.showMessage(new MessageParams(MessageType.Info, message)); - } - - @Override - public CompletableFuture jarFileContents(TextDocumentIdentifier documentUri) { - String uri = documentUri.getUri(); - - try { - LspLog.println("Trying to resolve " + uri); - List lines = Utils.jarFileContents(uri); - String contents = lines.stream().collect(Collectors.joining(System.lineSeparator())); - return CompletableFuture.completedFuture(contents); - } catch (IOException e) { - LspLog.println("Failed to resolve " + uri + " error: " + e); - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(e); - return future; - } - } - - @Override - public CompletableFuture> selectorCommand(SelectorParams selectorParams) { - LspLog.println("Received selector Command: " + selectorParams.getExpression()); - if (this.tds.isPresent()) { - Either> result = this.tds.get().runSelector(selectorParams.getExpression()); - if (result.isRight()) { - List locations = result.getRight(); - LspLog.println(String.format("Selector command found %s matching shapes.", locations.size())); - return CompletableFuture.completedFuture(locations); - } else { - LspLog.println("Resolve model validation errors and re-run selector command: " + result.getLeft()); - } - } - return CompletableFuture.completedFuture(Collections.emptyList()); - } +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.ProjectConfigLoader; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LocationAdapter; +import software.amazon.smithy.lsp.protocol.PositionAdapter; +import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.lsp.protocol.UriAdapter; +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; +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; + +public class SmithyLanguageServer implements + LanguageServer, LanguageClientAware, SmithyProtocolExtensions, WorkspaceService, TextDocumentService { + private static final Logger LOGGER = Logger.getLogger(SmithyLanguageServer.class.getName()); + private static final ServerCapabilities CAPABILITIES; + + static { + ServerCapabilities capabilities = new ServerCapabilities(); + capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental); + capabilities.setCodeActionProvider(new CodeActionOptions(SmithyCodeActions.all())); + capabilities.setDefinitionProvider(true); + capabilities.setDeclarationProvider(true); + capabilities.setCompletionProvider(new CompletionOptions(true, null)); + capabilities.setHoverProvider(true); + capabilities.setDocumentFormattingProvider(true); + capabilities.setDocumentSymbolProvider(true); + CAPABILITIES = capabilities; + } + + private SmithyLanguageClient client; + private Project project; + private Severity minimumSeverity = Severity.WARNING; + private boolean onlyReloadOnSave = false; + private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); + + public SmithyLanguageServer() { + } + + SmithyLanguageServer(LanguageClient client, Project project) { + this.client = new SmithyLanguageClient(client); + this.project = project; + } + + Project getProject() { + return this.project; + } + + SmithyLanguageClient getClient() { + return this.client; + } + + DocumentLifecycleManager getLifecycleManager() { + return this.lifecycleManager; + } + + @Override + public void connect(LanguageClient client) { + LOGGER.info("Connect"); + Properties props = new Properties(); + String message = "smithy-language-server"; + try { + props.load(SmithyLanguageServer.class.getClassLoader().getResourceAsStream("version.properties")); + message += " version " + props.getProperty("version"); + } catch (Exception ignored) { + } + this.client = new SmithyLanguageClient(client); + this.client.info(message + " started."); + } + + @Override + public CompletableFuture initialize(InitializeParams params) { + LOGGER.info("Initialize"); + + // TODO: Use this to manage shutdown if the parent process exits, after upgrading jdk + // Optional.ofNullable(params.getProcessId()) + // .flatMap(ProcessHandle::of) + // .ifPresent(processHandle -> { + // processHandle.onExit().thenRun(this::exit); + // }); + + // TODO: Replace with a Gson Type Adapter if more config options are added beyond `logToFile`. + Object initializationOptions = params.getInitializationOptions(); + if (initializationOptions instanceof JsonObject) { + JsonObject jsonObject = (JsonObject) initializationOptions; + if (jsonObject.has("logToFile")) { + String setting = jsonObject.get("logToFile").getAsString(); + if (setting.equals("enabled")) { + LspLog.enable(); + } + } + 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("Invalid value for 'diagnostics.minimumSeverity': " + configuredMinimumSeverity + + ".\nMust be one of 'NOTE', 'WARNING', 'DANGER', 'ERROR'"); + } + } + if (jsonObject.has("onlyReloadOnSave")) { + this.onlyReloadOnSave = jsonObject.get("onlyReloadOnSave").getAsBoolean(); + client.info("Configured only reload on save: " + this.onlyReloadOnSave); + } + } + + Path root = null; + // TODO: Handle multiple workspaces + if (params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { + String uri = params.getWorkspaceFolders().get(0).getUri(); + root = Paths.get(URI.create(uri)); + } else if (params.getRootUri() != null) { + String uri = params.getRootUri(); + root = Paths.get(URI.create(uri)); + } else if (params.getRootPath() != null) { + String uri = params.getRootPath(); + root = Paths.get(URI.create(uri)); + } + + if (root != null) { + // TODO: Support this for other tasks. Need to create a progress token with the client + // through createProgress. + Either workDoneProgressToken = params.getWorkDoneToken(); + if (workDoneProgressToken != null) { + WorkDoneProgressBegin notification = new WorkDoneProgressBegin(); + notification.setTitle("Initializing"); + client.notifyProgress(new ProgressParams(workDoneProgressToken, Either.forLeft(notification))); + } + + tryInitProject(root); + + if (workDoneProgressToken != null) { + WorkDoneProgressEnd notification = new WorkDoneProgressEnd(); + client.notifyProgress(new ProgressParams(workDoneProgressToken, Either.forLeft(notification))); + } + } + + LOGGER.info("Done initialize"); + return completedFuture(new InitializeResult(CAPABILITIES)); + } + + private void tryInitProject(Path root) { + LOGGER.info("Initializing project at " + root); + lifecycleManager.cancelAllTasks(); + Result> loadResult = ProjectLoader.load(root); + if (loadResult.isOk()) { + this.project = loadResult.unwrap(); + LOGGER.info("Initialized project at " + root); + // TODO: If this is a project reload, there are open files which need to have updated diagnostics reported. + } 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. + this.project = Project.empty(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()); + 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 = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project); + return client.registerCapability(new RegistrationParams(registrations)); + } + + private CompletableFuture unregisterSmithyFileWatchers() { + List unregistrations = FileWatcherRegistrationHandler.getSmithyFileWatcherUnregistrations(); + return client.unregisterCapability(new UnregistrationParams(unregistrations)); + } + + @Override + public void initialized(InitializedParams params) { + List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations(); + client.registerCapability(new RegistrationParams(registrations)); + registerSmithyFileWatchers(); + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public CompletableFuture shutdown() { + // TODO: Cancel all in-progress requests + return completedFuture(new Object()); + } + + @Override + public void exit() { + System.exit(0); + } + + @Override + public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { + LOGGER.info("JarFileContents"); + String uri = textDocumentIdentifier.getUri(); + Document document = project.getDocument(uri); + if (document != null) { + return completedFuture(document.copyText()); + } else { + // Technically this can throw if the uri is invalid + return completedFuture(IoUtils.readUtf8Url(UriAdapter.jarUrl(uri))); + } + } + + @Override + public CompletableFuture> selectorCommand(SelectorParams selectorParams) { + LOGGER.info("SelectorCommand"); + Selector selector; + try { + selector = Selector.parse(selectorParams.getExpression()); + } catch (Exception e) { + LOGGER.info("Invalid selector"); + // TODO: Respond with error somehow + return completedFuture(Collections.emptyList()); + } + + // TODO: Might also want to tell user if the model isn't loaded + // TODO: Use proper location (source is just a point) + return completedFuture(project.getModelResult().getResult() + .map(selector::select) + .map(shapes -> shapes.stream() + .map(Shape::getSourceLocation) + .map(LocationAdapter::fromSource) + .collect(Collectors.toList())) + .orElse(Collections.emptyList())); + } + + @Override + public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { + // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), + // or the smithy-build.json itself was changed + boolean projectFilesChanged = params.getChanges().stream().anyMatch(change -> { + String changedUri = change.getUri(); + if (changedUri.endsWith(".smithy") && (change.getType().equals(FileChangeType.Created) + || change.getType().equals(FileChangeType.Deleted))) { + return true; + } + if (changedUri.endsWith(ProjectConfigLoader.SMITHY_BUILD)) { + return true; + } + if (changedUri.endsWith(ProjectConfigLoader.SMITHY_PROJECT)) { + return true; + } + for (String extFile : ProjectConfigLoader.SMITHY_BUILD_EXTS) { + if (changedUri.endsWith(extFile)) { + return true; + } + } + return false; + }); + + if (projectFilesChanged) { + tryInitProject(project.getRoot()); + // TODO: Ideally we can reload the project more intelligently - maybe we specifically add/remove + // specific smithy files, or check the changed smithy-build.json to see if dependencies, sources, or + // imports changed. + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + } + } + + @Override + public void didChangeConfiguration(DidChangeConfigurationParams params) { + } + + @Override + public void didChange(DidChangeTextDocumentParams params) { + LOGGER.info("DidChange"); + + if (params.getContentChanges().isEmpty()) { + LOGGER.info("Received empty DidChange"); + return; + } + + String uri = params.getTextDocument().getUri(); + + lifecycleManager.cancelTask(uri); + + Document document = project.getDocument(uri); + if (document == null) { + client.info("document not open in project: " + uri); + // TODO: In this case, the document isn't attached to the project. I'm not sure what the best + // way to handle this is. + return; + } + + for (TextDocumentContentChangeEvent contentChangeEvent : params.getContentChanges()) { + if (contentChangeEvent.getRange() != null) { + document.applyEdit(contentChangeEvent.getRange(), contentChangeEvent.getText()); + } else { + document.applyEdit(document.getFullRange(), contentChangeEvent.getText()); + } + } + + if (!onlyReloadOnSave) { + // TODO: A consequence of this is that any existing validation events are cleared, which + // is kinda annoying. + // Report any parse/shape/trait loading errors + triggerUpdate(uri); + } + } + + @Override + public void didOpen(DidOpenTextDocumentParams params) { + LOGGER.info("DidOpen"); + + String uri = params.getTextDocument().getUri(); + + lifecycleManager.cancelTask(uri); + + Document document = project.getDocument(uri); + if (document != null) { + document.applyEdit(null, params.getTextDocument().getText()); + } + // TODO: Do we need to handle canceling this? + sendFileDiagnostics(uri); + } + + @Override + public void didClose(DidCloseTextDocumentParams params) { + LOGGER.info("DidClose"); + // TODO: Clear diagnostics? Can do this by sending an empty list + } + + @Override + public void didSave(DidSaveTextDocumentParams params) { + LOGGER.info("DidSave"); + + String uri = params.getTextDocument().getUri(); + lifecycleManager.cancelTask(uri); + if (params.getText() != null) { + Document document = project.getDocument(uri); + if (document != null) { + document.applyEdit(null, params.getText()); + } + } + + if (project.getDocument(uri) == null) { + client.info("document not open in project: " + uri); + } + + triggerUpdateAndValidate(uri); + } + + @Override + public CompletableFuture, CompletionList>> completion(CompletionParams params) { + LOGGER.info("Completion"); + return CompletableFutures.computeAsync((cc) -> { + CompletionHandler handler = new CompletionHandler(project); + return Either.forLeft(handler.handle(params, cc)); + }); + } + + @Override + public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { + LOGGER.info("ResolveCompletion"); + // TODO: Use this to add the import when a completion item is selected, if its expensive + return completedFuture(unresolved); + } + + @Override + public CompletableFuture>> + documentSymbol(DocumentSymbolParams params) { + return CompletableFutures.computeAsync((cc) -> { + LOGGER.info("DocumentSymbol"); + String uri = params.getTextDocument().getUri(); + SmithyFile smithyFile = project.getSmithyFile(uri); + if (smithyFile == null) { + return Collections.emptyList(); + } + + Collection documentShapes = smithyFile.getDocumentShapes(); + if (documentShapes.isEmpty()) { + return Collections.emptyList(); + } + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + List> documentSymbols = new ArrayList<>(documentShapes.size()); + for (DocumentShape documentShape : documentShapes) { + if (cc.isCanceled()) { + client.info("canceled document symbols"); + return Collections.emptyList(); + } + 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; + } + String symbolName = documentShape.shapeName().toString(); + Range symbolRange = documentShape.range(); + DocumentSymbol symbol = new DocumentSymbol(symbolName, symbolKind, symbolRange, symbolRange); + documentSymbols.add(Either.forRight(symbol)); + } + + return documentSymbols; + }); + } + + @Override + public CompletableFuture, List>> + definition(DefinitionParams params) { + LOGGER.info("Definition"); + List locations = new DefinitionHandler(project).handle(params); + return completedFuture(Either.forLeft(locations)); + } + + @Override + public CompletableFuture hover(HoverParams params) { + LOGGER.info("Hover"); + // TODO: Abstract away passing minimum severity + Hover hover = new HoverHandler(project).handle(params, minimumSeverity); + return completedFuture(hover); + } + + @Override + public CompletableFuture>> codeAction(CodeActionParams params) { + List> versionCodeActions = + SmithyCodeActions.versionCodeActions(params).stream() + .map(Either::forRight) + .collect(Collectors.toList()); + return completedFuture(versionCodeActions); + } + + @Override + public CompletableFuture> formatting(DocumentFormattingParams params) { + LOGGER.info("Formatting"); + String uri = params.getTextDocument().getUri(); + Document document = project.getDocument(uri); + if (document == null) { + return completedFuture(Collections.emptyList()); + } + + IdlTokenizer tokenizer = IdlTokenizer.create(uri, document.borrowText()); + TokenTree tokenTree = TokenTree.of(tokenizer); + String formatted = Formatter.format(tokenTree); + Range range = document.getFullRange(); + TextEdit edit = new TextEdit(range, formatted); + return completedFuture(Collections.singletonList(edit)); + } + + private void triggerUpdate(String uri) { + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateModelWithoutValidating(uri)) + .thenComposeAsync(unused -> sendFileDiagnostics(uri)); + lifecycleManager.putTask(uri, future); + } + + private void triggerUpdateAndValidate(String uri) { + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateAndValidateModel(uri)) + .thenCompose(unused -> sendFileDiagnostics(uri)); + lifecycleManager.putTask(uri, future); + } + + private CompletableFuture sendFileDiagnostics(String uri) { + return CompletableFuture.runAsync(() -> { + List diagnostics = getFileDiagnostics(uri); + PublishDiagnosticsParams publishDiagnosticsParams = new PublishDiagnosticsParams(uri, diagnostics); + client.publishDiagnostics(publishDiagnosticsParams); + }); + } + + List getFileDiagnostics(String uri) { + String path = UriAdapter.toPath(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + List diagnostics = project.getModelResult().getValidationEvents().stream() + .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) + .filter(validationEvent -> !UriAdapter.isJarFile(validationEvent.getSourceLocation().getFilename())) + .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) + .map(validationEvent -> toDiagnostic(validationEvent, smithyFile)) + .collect(Collectors.toCollection(ArrayList::new)); + if (smithyFile != null && VersionDiagnostics.hasVersionDiagnostic(smithyFile)) { + diagnostics.add(VersionDiagnostics.forSmithyFile(smithyFile)); + } + return diagnostics; + } + + private Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { + DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); + SourceLocation sourceLocation = validationEvent.getSourceLocation(); + + // TODO: Improve location of diagnostics + Range range = RangeAdapter.lineOffset(PositionAdapter.fromSourceLocation(sourceLocation)); + if (validationEvent.getShapeId().isPresent() && smithyFile != null) { + // Event is (probably) on a member target + if (validationEvent.containsId("Target")) { + DocumentShape documentShape = smithyFile.getDocumentShapesByStartPosition() + .get(PositionAdapter.fromSourceLocation(sourceLocation)); + boolean hasMemberTarget = documentShape != null + && documentShape.isKind(DocumentShape.Kind.DefinedMember) + && documentShape.targetReference() != null; + if (hasMemberTarget) { + range = documentShape.targetReference().range(); + } + } else { + // Check if the event location is on a trait application + Range traitRange = DocumentParser.forDocument(smithyFile.getDocument()).traitIdRange(sourceLocation); + if (traitRange != null) { + range = traitRange; + } + } + } + + String message = validationEvent.getId() + ": " + validationEvent.getMessage(); + return new Diagnostic(range, message, severity, "Smithy"); + } + + private static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { + switch (severity) { + case ERROR: + case DANGER: + return DiagnosticSeverity.Error; + case WARNING: + return DiagnosticSeverity.Warning; + case NOTE: + return DiagnosticSeverity.Information; + default: + return DiagnosticSeverity.Hint; + } + } } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java b/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java deleted file mode 100644 index ebb34e3a..00000000 --- a/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java +++ /dev/null @@ -1,894 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import com.google.common.hash.HashFunction; -import com.google.common.hash.Hashing; -import java.io.File; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -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.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import org.eclipse.lsp4j.CodeAction; -import org.eclipse.lsp4j.CodeActionParams; -import org.eclipse.lsp4j.Command; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionList; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DefinitionParams; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidCloseTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.DidSaveTextDocumentParams; -import org.eclipse.lsp4j.DocumentFormattingParams; -import org.eclipse.lsp4j.DocumentSymbol; -import org.eclipse.lsp4j.DocumentSymbolParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.LocationLink; -import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.MessageType; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.SymbolKind; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TextDocumentItem; -import org.eclipse.lsp4j.TextEdit; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.eclipse.lsp4j.services.LanguageClient; -import org.eclipse.lsp4j.services.TextDocumentService; -import smithyfmt.Formatter; -import smithyfmt.Result; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; -import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; -import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; -import software.amazon.smithy.lsp.editor.SmartInput; -import software.amazon.smithy.lsp.ext.Completions; -import software.amazon.smithy.lsp.ext.Constants; -import software.amazon.smithy.lsp.ext.Document; -import software.amazon.smithy.lsp.ext.DocumentPreamble; -import software.amazon.smithy.lsp.ext.LspLog; -import software.amazon.smithy.lsp.ext.SmithyBuildLoader; -import software.amazon.smithy.lsp.ext.SmithyProject; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.knowledge.NeighborProviderIndex; -import software.amazon.smithy.model.loader.ParserUtils; -import software.amazon.smithy.model.neighbor.Walker; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.ShapeType; -import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidationEvent; -import software.amazon.smithy.utils.SimpleParser; - -public class SmithyTextDocumentService implements TextDocumentService { - - private final List baseCompletions = new ArrayList<>(); - private Optional client; - private final List noLocations = Collections.emptyList(); - @Nullable - private SmithyProject project; - private final File temporaryFolder; - - // when files are edited, their contents will be persisted in memory and removed - // on didSave or didClose - private final Map temporaryContents = new ConcurrentHashMap<>(); - - // We use this function to hash filepaths to the same location in temporary - // folder - private final HashFunction hash = Hashing.murmur3_128(); - - /** - * @param client Language Client to be used by text document service. - * @param tempFile Temporary File to be used by text document service. - */ - public SmithyTextDocumentService(Optional client, File tempFile) { - this.client = client; - this.temporaryFolder = tempFile; - } - - public void setClient(LanguageClient client) { - this.client = Optional.of(client); - } - - public Optional getRoot() { - return Optional.ofNullable(project).map(SmithyProject::getRoot); - } - - /** - * Processes extensions. - *

- * 1. Downloads external dependencies as jars 2. Creates a model from just - * external jars 3. Updates locations index with symbols found in external jars - * - * @param ext extensions - * @param root workspace root - */ - public void createProject(SmithyBuildExtensions ext, File root) { - DependencyResolver resolver = createDependencyResolver(root, ext.getLastModifiedInMillis()); - Either loaded = SmithyProject.load(ext, root, resolver); - if (loaded.isRight()) { - SmithyProject project = loaded.getRight(); - this.project = project; - clearAllDiagnostics(); - sendInfo("Project loaded with " + project.getExternalJars().size() + " external jars and " - + project.getSmithyFiles().size() + " discovered smithy files"); - } else { - sendError( - "Failed to create Smithy project. See output panel for details. Uncaught exception: " - + loaded.getLeft().toString() - ); - loaded.getLeft().printStackTrace(); - } - } - - private DependencyResolver createDependencyResolver(File root, long lastModified) { - Path buildPath = Paths.get(root.toString(), "build", "smithy"); - File buildDir = new File(buildPath.toString()); - if (!buildDir.exists()) { - buildDir.mkdirs(); - } - Path cachePath = Paths.get(buildPath.toString(), "classpath.json"); - File dependencyCache = new File(cachePath.toString()); - if (!dependencyCache.exists()) { - try { - Files.createFile(cachePath); - } catch (IOException e) { - LspLog.println("Could not create dependency cache file " + e); - } - } - MavenDependencyResolver delegate = new MavenDependencyResolver(); - return new FileCacheResolver(dependencyCache, lastModified, delegate); - } - - /** - * Discovers Smithy build files and loads the smithy project defined by them. - * - * @param root workspace root - */ - public void createProject(File root) { - LspLog.println("Recreating project from " + root); - SmithyBuildExtensions.Builder result = SmithyBuildExtensions.builder(); - List brokenFiles = new ArrayList<>(); - - for (String file : Constants.BUILD_FILES) { - File smithyBuild = Paths.get(root.getAbsolutePath(), file).toFile(); - if (smithyBuild.isFile()) { - try { - SmithyBuildExtensions local = SmithyBuildLoader.load(smithyBuild.toPath()); - result.merge(local); - LspLog.println("Loaded build extensions " + local + " from " + smithyBuild.getAbsolutePath()); - } catch (Exception e) { - LspLog.println("Failed to load config from" + smithyBuild + ": " + e); - e.printStackTrace(); - brokenFiles.add(smithyBuild.toString()); - } - } - } - - if (brokenFiles.isEmpty()) { - createProject(result.build(), root); - } else { - sendError( - "Failed to load the build, the following build files have problems: \n" - + String.join("\n", brokenFiles) - ); - } - } - - private MessageParams msg(final MessageType sev, final String cont) { - return new MessageParams(sev, cont); - } - - @Override - public CompletableFuture, CompletionList>> completion(CompletionParams params) { - LspLog.println("Asking to complete " + params + " in class " + params.getTextDocument().getClass()); - - try { - String documentUri = params.getTextDocument().getUri(); - String token = findToken(documentUri, params.getPosition()); - DocumentPreamble preamble = Document.detectPreamble(textBufferContents(documentUri)); - - boolean isTraitShapeId = isTraitShapeId(documentUri, params.getPosition()); - Optional target = Optional.empty(); - if (isTraitShapeId) { - target = getTraitTarget(documentUri, params.getPosition(), preamble.getCurrentNamespace()); - } - - List items = Completions.resolveImports(project.getCompletions(token, isTraitShapeId, - target), - preamble); - LspLog.println("Completion items: " + items); - - return Utils.completableFuture(Either.forLeft(items)); - } catch (Exception e) { - LspLog.println( - "Failed to identify token for completion in " + params.getTextDocument().getUri() + ": " + e); - e.printStackTrace(); - } - return Utils.completableFuture(Either.forLeft(baseCompletions)); - } - - // Determine the target of a trait, if present. - private Optional getTraitTarget(String documentUri, Position position, Optional namespace) - throws IOException { - List contents = textBufferContents(documentUri); - String currentLine = contents.get(position.getLine()).trim(); - if (currentLine.startsWith("apply")) { - return getApplyStatementTarget(currentLine, namespace); - } - - // Iterate through the rest of the model file, skipping docs and other traits to get trait's target. - for (int i = position.getLine() + 1; i < contents.size(); i++) { - String line = contents.get(i).trim(); - // If an empty line is encountered, assume the trait's target has not yet been written. - if (line.equals("")) { - return Optional.empty(); - // Skip comments lines - } else if (line.startsWith("//")) { - // Skip other traits. - } else if (line.startsWith("@")) { - // Jump to end of trait. - i = getEndOfTrait(i, contents); - } else { - // Offset the target shape position by accounting for leading whitespace. - String originalLine = contents.get(i); - int offset = 1; - while (originalLine.charAt(offset) == ' ') { - offset++; - } - return project.getShapeIdFromLocation(documentUri, new Position(i, offset)); - } - } - return Optional.empty(); - } - - // Determine target shape from an apply statement. - private Optional getApplyStatementTarget(String applyStatement, Optional namespace) { - SimpleParser parser = new SimpleParser(applyStatement); - parser.expect('a'); - parser.expect('p'); - parser.expect('p'); - parser.expect('l'); - parser.expect('y'); - parser.ws(); - String name = ParserUtils.parseShapeId(parser); - if (namespace.isPresent()) { - return Optional.of(ShapeId.fromParts(namespace.get(), name)); - } - return Optional.empty(); - } - - // Find the line where the trait ends. - private int getEndOfTrait(int lineNumber, List contents) { - String line = contents.get(lineNumber); - if (line.contains("(")) { - if (hasClosingParen(line)) { - return lineNumber; - } - for (int i = lineNumber + 1; i < contents.size(); i++) { - String nextLine = contents.get(i).trim(); - if (hasClosingParen(nextLine)) { - return i; - } - } - } - return lineNumber; - } - - // Determine if the line has an unquoted closing parenthesis. - private boolean hasClosingParen(String line) { - boolean quote = false; - for (int i = 0; i < line.length(); i++) { - char c = line.charAt(i); - if (c == '"' && !quote) { - quote = true; - } else if (c == '"' && quote) { - quote = false; - } - - if (c == ')' && !quote) { - return true; - } - } - return false; - } - - // Work backwards from current position to determine if position is part of a trait shapeId. - private boolean isTraitShapeId(String documentUri, Position position) throws IOException { - String line = getLine(textBufferContents(documentUri), position); - for (int i = position.getCharacter() - 1; i >= 0; i--) { - char c = line.charAt(i); - if (c == '@') { - return true; - } - if (c == ' ') { - return false; - } - } - return false; - } - - @Override - public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { - return Utils.completableFuture(unresolved); - } - - private List readAll(File f) throws IOException { - return Files.readAllLines(f.toPath()); - } - - private File designatedTemporaryFile(File source) { - String hashed = hash.hashString(source.getAbsolutePath(), StandardCharsets.UTF_8).toString(); - - return new File(this.temporaryFolder, hashed + Constants.SMITHY_EXTENSION); - } - - /** - * @return lines in the file or buffer - */ - private List textBufferContents(String path) throws IOException { - List contents; - if (Utils.isSmithyJarFile(path)) { - contents = Utils.jarFileContents(path); - } else { - String tempContents = temporaryContents.get(fileFromUri(path)); - if (tempContents != null) { - LspLog.println("Path " + path + " was found in temporary buffer"); - contents = Arrays.stream(tempContents.split("\n")).collect(Collectors.toList()); - } else { - try { - contents = readAll(new File(URI.create(path))); - } catch (IllegalArgumentException e) { - contents = readAll(new File(path)); - } - } - - } - - return contents; - } - - private String findToken(String path, Position p) throws IOException { - List contents = textBufferContents(path); - - String line = contents.get(p.getLine()); - int col = p.getCharacter(); - - LspLog.println("Trying to find a token in line '" + line + "' at position " + p); - - String before = line.substring(0, col); - String after = line.substring(col, line.length()); - - StringBuilder beforeAcc = new StringBuilder(); - StringBuilder afterAcc = new StringBuilder(); - - int idx = 0; - - while (idx < after.length()) { - if (Character.isLetterOrDigit(after.charAt(idx))) { - afterAcc.append(after.charAt(idx)); - idx = idx + 1; - } else { - idx = after.length(); - } - } - - idx = before.length() - 1; - - while (idx > 0) { - char c = before.charAt(idx); - if (Character.isLetterOrDigit(c)) { - beforeAcc.append(c); - idx = idx - 1; - } else { - idx = 0; - } - } - - return beforeAcc.reverse().append(afterAcc).toString(); - } - - private String getLine(List lines, Position position) { - return lines.get(position.getLine()); - } - - @Override - public CompletableFuture>> documentSymbol( - DocumentSymbolParams params - ) { - try { - Map locations = project.getLocations(); - Model model = project.getModel().unwrap(); - - List symbols = new ArrayList<>(); - - URI documentUri = documentIdentifierToUri(params.getTextDocument()); - - locations.forEach((shapeId, loc) -> { - String[] locSegments = loc.getUri().replace("\\", "/").split(":"); - boolean matchesDocument = documentUri.toString().endsWith(locSegments[locSegments.length - 1]); - - if (!matchesDocument) { - return; - } - - Shape shape = model.expectShape(shapeId); - - Optional parentType = shape.isMemberShape() - ? Optional.of(model.expectShape(shapeId.withoutMember()).getType()) - : Optional.empty(); - - SymbolKind kind = ProtocolAdapter.toSymbolKind(shape.getType(), parentType); - - String symbolName = shapeId.getMember().orElse(shapeId.getName()); - - symbols.add(new DocumentSymbol(symbolName, kind, loc.getRange(), loc.getRange())); - }); - - return Utils.completableFuture( - symbols - .stream() - .map(Either::forRight) - .collect(Collectors.toList()) - ); - } catch (Exception e) { - e.printStackTrace(); - - return Utils.completableFuture(Collections.emptyList()); - } - } - - private URI documentIdentifierToUri(TextDocumentIdentifier ident) throws UnsupportedEncodingException { - return Utils.isSmithyJarFile(ident.getUri()) - ? URI.create(URLDecoder.decode(ident.getUri(), StandardCharsets.UTF_8.name())) - : this.fileUri(ident).toURI(); - } - - @Override - public CompletableFuture, List>> definition( - DefinitionParams params) { - // TODO More granular error handling - try { - // This attempts to return the definition location that corresponds to a position within a text document. - // First, the position is used to find any shapes in the model that are defined at that location. Next, - // a token is extracted from the raw text document. The model is walked from the starting shapeId and any - // the locations of neighboring shapes that match the token are returned. For example, if the position - // is the input of an operation, the token will be the name of the input structure, and the operation will - // be walked to return the location of where the input structure is defined. This allows go-to-definition - // to jump from the input of the operation, to where the input structure is actually defined. - List locations; - Optional initialShapeId = project.getShapeIdFromLocation(params.getTextDocument().getUri(), - params.getPosition()); - String found = findToken(params.getTextDocument().getUri(), params.getPosition()); - if (initialShapeId.isPresent()) { - Model model = project.getModel().unwrap(); - Shape initialShape = model.getShape(initialShapeId.get()).get(); - Optional target = getTargetShape(initialShape, found, model); - - // Use location of target shape or default to the location of the initial shape. - ShapeId shapeId = target.map(Shape::getId).orElse(initialShapeId.get()); - Location shapeLocation = project.getLocations().get(shapeId); - locations = Collections.singletonList(shapeLocation); - } else { - // If the definition params do not have a matching shape at that location, return locations of all - // shapes that match token by shape name. This makes it possible link the shape name in a line - // comment to its definition. - locations = project.getLocations().entrySet().stream() - .filter(entry -> entry.getKey().getName().equals(found)) - .map(Map.Entry::getValue) - .collect(Collectors.toList()); - } - return Utils.completableFuture(Either.forLeft(locations)); - } catch (Exception e) { - // TODO: handle exception - - e.printStackTrace(); - - return Utils.completableFuture(Either.forLeft(noLocations)); - } - } - - @Override - public CompletableFuture hover(HoverParams params) { - Hover hover = new Hover(); - MarkupContent content = new MarkupContent(); - content.setKind("markdown"); - Optional initialShapeId = project.getShapeIdFromLocation(params.getTextDocument().getUri(), - params.getPosition()); - // TODO More granular error handling - try { - Shape shapeToSerialize; - Model model = project.getModel().unwrap(); - String token = findToken(params.getTextDocument().getUri(), params.getPosition()); - LspLog.println("Found token: " + token); - if (initialShapeId.isPresent()) { - Shape initialShape = model.getShape(initialShapeId.get()).get(); - Optional target = initialShape.asMemberShape() - .map(memberShape -> model.getShape(memberShape.getTarget())) - .orElse(getTargetShape(initialShape, token, model)); - shapeToSerialize = target.orElse(initialShape); - } else { - shapeToSerialize = model.shapes() - .filter(shape -> !shape.isMemberShape()) - .filter(shape -> shape.getId().getName().equals(token)) - .findAny() - .orElse(null); - } - - if (shapeToSerialize != null) { - content.setValue(getHoverContentsForShape(shapeToSerialize, model)); - } - } catch (Exception e) { - LspLog.println("Failed to determine hover content: " + e); - e.printStackTrace(); - } - - hover.setContents(content); - return Utils.completableFuture(hover); - } - - // Finds the first non-member neighbor shape or trait applied to a member whose name matches the token. - private Optional getTargetShape(Shape initialShape, String token, Model model) { - LspLog.println("Finding target of: " + initialShape); - Walker shapeWalker = new Walker(NeighborProviderIndex.of(model).getProvider()); - return shapeWalker.walkShapes(initialShape).stream() - .flatMap(shape -> { - if (shape.isMemberShape()) { - return shape.getAllTraits().values().stream() - .map(trait -> trait.toShapeId()); - } else { - return Stream.of(shape.getId()); - } - }) - .filter(shapeId -> shapeId.getName().equals(token)) - .map(shapeId -> model.getShape(shapeId).get()) - .findFirst(); - } - - private String getHoverContentsForShape(Shape shape, Model model) { - List validationEvents = getValidationEventsForShape(shape); - String serializedShape = serializeShape(shape, model); - if (validationEvents.isEmpty()) { - return "```smithy\n" + serializedShape + "\n```"; - } - StringBuilder contents = new StringBuilder(); - contents.append("```smithy\n"); - contents.append(serializedShape); - contents.append("\n"); - contents.append("---\n"); - for (ValidationEvent event : validationEvents) { - contents.append(event.getSeverity() + ": " + event.getMessage() + "\n"); - } - contents.append("```"); - return contents.toString(); - } - - private String serializeShape(Shape shape, Model model) { - SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder() - .metadataFilter(key -> false) - .shapeFilter(s -> s.getId().equals(shape.getId())) - .serializePrelude().build(); - Map serialized = serializer.serialize(model); - Path path = Paths.get(shape.getId().getNamespace() + ".smithy"); - return serialized.get(path).trim(); - } - - private List getValidationEventsForShape(Shape shape) { - return project.getModel().getValidationEvents().stream() - .filter(validationEvent -> shape.getId().equals(validationEvent.getShapeId().orElse(null))) - .collect(Collectors.toList()); - } - - @Override - public CompletableFuture>> codeAction(CodeActionParams params) { - List> versionCodeActions = - SmithyCodeActions.versionCodeActions(params).stream() - .map(Either::forRight) - .collect(Collectors.toList()); - - return Utils.completableFuture(versionCodeActions); - } - - @Override - public void didChange(DidChangeTextDocumentParams params) { - File original = fileUri(params.getTextDocument()); - File tempFile = null; - - try { - if (params.getContentChanges().size() > 0) { - tempFile = designatedTemporaryFile(original); - String contents = params.getContentChanges().get(0).getText(); - - unstableContents(original, contents); - - Files.write(tempFile.toPath(), contents.getBytes()); - } - - } catch (Exception e) { - LspLog.println("Failed to write temporary contents for file " + original + " into temporary file " - + tempFile + " : " + e); - e.printStackTrace(); - } - - report(recompile(original, Optional.ofNullable(tempFile))); - } - - private void stableContents(File file) { - this.temporaryContents.remove(file); - } - - private void unstableContents(File file, String contents) { - LspLog.println("Hashed filename " + file + " into " + designatedTemporaryFile(file)); - this.temporaryContents.put(file, contents); - } - - @Override - public void didOpen(DidOpenTextDocumentParams params) { - String rawUri = params.getTextDocument().getUri(); - if (Utils.isFile(rawUri)) { - report(recompile(fileUri(params.getTextDocument()), Optional.empty())); - } - } - - @Override - public void didClose(DidCloseTextDocumentParams params) { - File file = fileUri(params.getTextDocument()); - stableContents(file); - report(recompile(file, Optional.empty())); - } - - @Override - public void didSave(DidSaveTextDocumentParams params) { - File file = fileUri(params.getTextDocument()); - stableContents(file); - report(recompile(file, Optional.empty())); - } - - @Override - public CompletableFuture> formatting(DocumentFormattingParams params) { - File file = fileUri(params.getTextDocument()); - final CompletableFuture> emptyResult = - Utils.completableFuture(Collections.emptyList()); - - final Optional content = Utils.optOr( - Optional.ofNullable(temporaryContents.get(file)).map(SmartInput::fromInput), - () -> SmartInput.fromPathSafe(file.toPath()) - ); - if (content.isPresent()) { - SmartInput input = content.get(); - final Result result = Formatter.format(input.getInput()); - final Range fullRange = input.getRange(); - if (result.isSuccess() && !result.getValue().equals(input.getInput())) { - return Utils.completableFuture(Collections.singletonList(new TextEdit( - fullRange, - result.getValue() - ))); - } else if (!result.isSuccess()) { - LspLog.println("Failed to format: " + result.getError()); - return emptyResult; - } else { - return emptyResult; - } - } else { - LspLog.println("Content is unavailable, not formatting."); - return emptyResult; - } - } - - private File fileUri(TextDocumentIdentifier tdi) { - return fileFromUri(tdi.getUri()); - } - - private File fileUri(TextDocumentItem tdi) { - return fileFromUri(tdi.getUri()); - } - - private File fileFromUri(String uri) { - try { - return new File(URI.create(uri)); - } catch (IllegalArgumentException e) { - return new File(uri); - } - } - - /** - * @param result Either a fatal error message, or a list of diagnostics to - * publish - */ - public void report(Either> result) { - client.ifPresent(cl -> { - - if (result.isLeft()) { - cl.showMessage(msg(MessageType.Error, result.getLeft())); - } else { - result.getRight().forEach(cl::publishDiagnostics); - } - }); - } - - /** - * Breaks down a list of validation events into a per-file list of diagnostics, - * explicitly publishing an empty list of diagnostics for files not present in - * validation events. - * - * @param events output of the Smithy model builder - * @param allFiles all the files registered for the project - * @return a list of LSP diagnostics to publish - */ - public List createPerFileDiagnostics(List events, List allFiles) { - // URI is used because conversion toString deals with platform specific path separator - Map> byUri = new HashMap<>(); - - for (ValidationEvent ev : events) { - URI finalUri; - try { - // can be a uri in the form of jar:file:/some-path - // if we have a jar we go to smithyjar - // else we make sure `file:` scheme is used - String fileName = ev.getSourceLocation().getFilename(); - String uri = Utils.isJarFile(fileName) - ? Utils.toSmithyJarFile(fileName) - : !Utils.isFile(fileName) ? "file:" + fileName - : fileName; - finalUri = new URI(uri); - } catch (URISyntaxException ex) { - // can also be something like C:\Some\path in which case creating a URI will fail - // so after a file conversion, we call .toURI to produce a standard `file:/C:/Some/path` - finalUri = new File(ev.getSourceLocation().getFilename()).toURI(); - } - - if (byUri.containsKey(finalUri)) { - byUri.get(finalUri).add(ProtocolAdapter.toDiagnostic(ev)); - } else { - List l = new ArrayList<>(); - l.add(ProtocolAdapter.toDiagnostic(ev)); - byUri.put(finalUri, l); - } - } - - allFiles.forEach(f -> { - List versionDiagnostics = VersionDiagnostics.createVersionDiagnostics(f, temporaryContents); - if (!byUri.containsKey(f.toURI())) { - byUri.put(f.toURI(), versionDiagnostics); - } else { - byUri.get(f.toURI()).addAll(versionDiagnostics); - } - }); - - List diagnostics = new ArrayList<>(); - byUri.forEach((key, value) -> diagnostics.add(new PublishDiagnosticsParams(key.toString(), value))); - return diagnostics; - - } - - public void clearAllDiagnostics() { - report(Either.forRight(createPerFileDiagnostics(this.project.getModel().getValidationEvents(), - this.project.getSmithyFiles()))); - } - - /** - * Main recompilation method, responsible for reloading the model, persisting it - * if necessary, and massaging validation events into publishable diagnostics. - * - * @param path file that triggered recompilation - * @param temporary optional location of a temporary file with most recent - * contents - * @return either a fatal error message, or a list of diagnostics - */ - public Either> recompile(File path, Optional temporary) { - // File latestContents = temporary.orElse(path); - Either loadedModel; - if (!temporary.isPresent()) { - // if there's no temporary file present (didOpen/didClose/didSave) - // we want to rebuild the model with the original path - // optionally removing a temporary file - // This protects against a conflict during the didChange -> didSave sequence - loadedModel = this.project.recompile(path, designatedTemporaryFile(path)); - } else { - // If there's a temporary file present (didChange), we want to - // replace the original path with a temporary one (to avoid conflicting - // definitions) - loadedModel = this.project.recompile(temporary.get(), path); - } - - if (loadedModel.isLeft()) { - return Either.forLeft(path + " is not okay!" + loadedModel.getLeft().toString()); - } else { - ValidatedResult result = loadedModel.getRight().getModel(); - // If we're working with a temporary file, we don't want to persist the result - // of the project - if (!temporary.isPresent()) { - this.project = loadedModel.getRight(); - } - - List events = new ArrayList<>(); - List allFiles; - - if (temporary.isPresent()) { - allFiles = project.getSmithyFiles().stream().filter(f -> !f.equals(temporary.get())) - .collect(Collectors.toList()); - // We need to remap some validation events - // from temporary files to the one on which didChange was invoked - for (ValidationEvent ev : result.getValidationEvents()) { - if (ev.getSourceLocation().getFilename().equals(temporary.get().getAbsolutePath())) { - SourceLocation sl = new SourceLocation(path.getAbsolutePath(), ev.getSourceLocation().getLine(), - ev.getSourceLocation().getColumn()); - ValidationEvent newEvent = ev.toBuilder().sourceLocation(sl).build(); - - events.add(newEvent); - } else { - events.add(ev); - } - } - } else { - events.addAll(result.getValidationEvents()); - allFiles = project.getSmithyFiles(); - } - - LspLog.println( - "Recompiling " + path + " (with temporary content " + temporary + ") raised " + events.size() - + " diagnostics"); - return Either.forRight(createPerFileDiagnostics(events, allFiles)); - } - - } - - /** - * Run a selector expression against the loaded model in the workspace. - * @param expression the selector expression - * @return list of locations of shapes that match expression - */ - public Either> runSelector(String expression) { - return this.project.runSelector(expression); - } - - private void sendInfo(String msg) { - this.client.ifPresent(client -> client.showMessage(new MessageParams(MessageType.Info, msg))); - } - - private void sendError(String msg) { - this.client.ifPresent(client -> client.showMessage(new MessageParams(MessageType.Error, msg))); - } - -} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyWorkspaceService.java b/src/main/java/software/amazon/smithy/lsp/SmithyWorkspaceService.java deleted file mode 100644 index 013e37b0..00000000 --- a/src/main/java/software/amazon/smithy/lsp/SmithyWorkspaceService.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import java.io.File; -import java.net.URI; -import java.util.Optional; -import org.eclipse.lsp4j.DidChangeConfigurationParams; -import org.eclipse.lsp4j.DidChangeWatchedFilesParams; -import org.eclipse.lsp4j.services.WorkspaceService; -import software.amazon.smithy.lsp.ext.Constants; -import software.amazon.smithy.lsp.ext.LspLog; - -public class SmithyWorkspaceService implements WorkspaceService { - private final Optional tds; - - public SmithyWorkspaceService(Optional tds) { - this.tds = tds; - } - - @Override - public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { - - boolean buildFilesChanged = params.getChanges().stream().anyMatch(change -> { - String filename = fileFromUri(change.getUri()).getName(); - return Constants.BUILD_FILES.contains(filename); - }); - - if (buildFilesChanged) { - LspLog.println("Build files changed, rebuilding the project"); - this.tds.ifPresent(tds -> tds.getRoot().ifPresent(tds::createProject)); - } - - } - - @Override - public void didChangeConfiguration(DidChangeConfigurationParams params) { - // TODO Auto-generated method stub - - } - - private File fileFromUri(String uri) { - return new File(URI.create(uri)); - } - -} diff --git a/src/main/java/software/amazon/smithy/lsp/Utils.java b/src/main/java/software/amazon/smithy/lsp/Utils.java deleted file mode 100644 index f79a203c..00000000 --- a/src/main/java/software/amazon/smithy/lsp/Utils.java +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.UnsupportedEncodingException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; -import java.util.jar.JarFile; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import java.util.zip.ZipEntry; - -public final class Utils { - private Utils() { - - } - - /** - * @param value Value to be used. - * @param Type of Value. - * @return Returns the value of a specific type as a CompletableFuture. - */ - public static CompletableFuture completableFuture(U value) { - Supplier supplier = () -> value; - - return CompletableFuture.supplyAsync(supplier); - } - - /** - * @param rawUri String - * @return Returns whether the uri points to a file in jar. - * @throws IOException when rawUri cannot be URL-decoded - */ - public static boolean isSmithyJarFile(String rawUri) { - try { - String uri = java.net.URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); - return uri.startsWith("smithyjar:"); - } catch (IOException e) { - return false; - } - } - - /** - * @param uri String - * @return Returns whether the uri points to a file in jar. - */ - public static boolean isJarFile(String uri) { - return uri.startsWith("jar:"); - } - - /** - * @param uri String - * @return Remove the jar:file: part and replace it with "smithyjar" - */ - public static String toSmithyJarFile(String uri) { - return "smithyjar:" + uri.substring(9); - } - - /** - * @param rawUri String - * @return Returns whether the uri points to a file in the filesystem (as - * opposed to a file in a jar). - */ - public static boolean isFile(String rawUri) { - try { - String uri = java.net.URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); - return uri.startsWith("file:"); - } catch (IOException e) { - return false; - } - } - - private static List getLines(InputStream is) throws IOException { - List result = null; - try { - if (is != null) { - InputStreamReader isr = new InputStreamReader(is); - BufferedReader reader = new BufferedReader(isr); - result = reader.lines().collect(Collectors.toList()); - } - } finally { - is.close(); - } - - return result; - } - - /** - * @param rawUri the uri to a file in a jar. - * @return the lines of the file in a jar - * @throws IOException when rawUri cannot be URI-decoded. - */ - public static List jarFileContents(String rawUri) throws IOException { - String uri = java.net.URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); - String[] pathArray = uri.split("!/"); - String jarPath = Utils.jarPath(rawUri); - String file = pathArray[1]; - - try (JarFile jar = new JarFile(new File(jarPath))) { - ZipEntry entry = jar.getEntry(file); - - return getLines(jar.getInputStream(entry)); - } - } - - /** - * Extracts just the .jar part from a URI. - * - * @param rawUri URI of a symbol/file in a jar - * @return Jar path - * @throws UnsupportedEncodingException when rawUri cannot be URL-decoded - */ - public static String jarPath(String rawUri) throws UnsupportedEncodingException { - String uri = java.net.URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); - if (uri.startsWith("smithyjar:")) { - uri = uri.replaceFirst("smithyjar:", ""); - } - String[] pathArray = uri.split("!/"); - return pathArray[0]; - } - - - /** - * Read only the first N lines of a file. - * @param file file to read - * @param n number of lines to read, must be >= 0. if n is 3, we'll return lines 0, 1, 2 - * @return list of numbered lines, empty if the file does not exist or - * is empty. - */ - public static List readFirstNLines(File file, int n) throws IOException { - if (n < 0) { - throw new IllegalArgumentException("n must be greater or equal to 0"); - } - - Path filePath = file.toPath(); - if (!Files.exists(filePath)) { - return Collections.emptyList(); - } - - final ArrayList list = new ArrayList<>(); - try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) { - reader.lines().limit(n).forEach(s -> list.add(new NumberedLine(s, list.size()))); - } - return list; - - } - - /** - * Given a content, split it on new line and extract the first n lines. - * @param content content to look at - * @param n number of lines to extract - * @return list of numbered lines, empty if the content has no newline in it. - */ - public static List contentFirstNLines(String content, int n) { - if (n < 0) { - throw new IllegalArgumentException("n must be greater or equal to 0"); - } - - if (content == null) { - throw new IllegalArgumentException("content must not be null"); - } - - String[] contentLines = content.split("\n"); - - if (contentLines.length == 0) { - return Collections.emptyList(); - } - - return IntStream.range(0, Math.min(n, contentLines.length)) - .mapToObj(i -> new NumberedLine(contentLines[i], i)) - .collect(Collectors.toList()); - } - - public static class NumberedLine { - private final String content; - private final int lineNumber; - - NumberedLine(String content, int lineNumber) { - this.content = content; - this.lineNumber = lineNumber; - } - - public String getContent() { - return content; - } - - public int getLineNumber() { - return lineNumber; - } - } - - /** - * Helper to provide an alternative Optional if the first is empty. - * @param o1 first optional - * @param o2get supplier to retrieve the second optional - * @return the first optional if not empty, otherwise get the second optional - */ - public static Optional optOr(Optional o1, Supplier> o2get) { - if (o1.isPresent()) { - return o1; - } else { - return o2get.get(); - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/codeactions/DefineVersionCodeAction.java b/src/main/java/software/amazon/smithy/lsp/codeactions/DefineVersionCodeAction.java index 9b30169c..02f17b17 100644 --- a/src/main/java/software/amazon/smithy/lsp/codeactions/DefineVersionCodeAction.java +++ b/src/main/java/software/amazon/smithy/lsp/codeactions/DefineVersionCodeAction.java @@ -24,7 +24,6 @@ import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.WorkspaceEdit; - public final class DefineVersionCodeAction { private static final int DEFAULT_VERSION = 1; diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java index 898f1c60..c4daa192 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java @@ -15,20 +15,13 @@ package software.amazon.smithy.lsp.diagnostics; -import java.io.File; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; 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.Utils; +import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.RangeAdapter; public final class VersionDiagnostics { public static final String SMITHY_UPDATE_VERSION = "migrating-idl-1-to-2"; @@ -51,16 +44,46 @@ private static Diagnostic build(String title, String code, Range range) { ); } + /** + * @param smithyFile The Smithy file to check for a version diagnostic + * @return Whether the given {@code smithyFile} has a version diagnostic + */ + public static boolean hasVersionDiagnostic(SmithyFile smithyFile) { + return smithyFile.getDocumentVersion() + .map(documentVersion -> documentVersion.version().charAt(0) != '2') + .orElse(true); + } + + /** + * @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 + */ + public static Diagnostic forSmithyFile(SmithyFile smithyFile) { + // TODO: This can be cached + if (smithyFile.getDocumentVersion().isPresent()) { + DocumentVersion documentVersion = smithyFile.getDocumentVersion().get(); + if (!documentVersion.version().toString().startsWith("2")) { + return updateVersion(documentVersion.range()); + } + } else if (smithyFile.getDocument() != null) { + int end = smithyFile.getDocument().lineEnd(0); + Range range = RangeAdapter.lineSpan(0, 0, end); + return defineVersion(range); + } + return null; + } + /** * Build a diagnostic for an outdated Smithy version. * @param range range where the $version statement is found * @return a Diagnostic with a code that refer to the codeAction to take */ - public static Diagnostic updateVersion(Range range) { + static Diagnostic updateVersion(Range range) { Diagnostic diag = build( - "You can upgrade to version 2.", - SMITHY_UPDATE_VERSION, - range + "You can upgrade to version 2.", + SMITHY_UPDATE_VERSION, + range ); diag.setCodeDescription(SMITHY_UPDATE_VERSION_CODE_DIAGNOSTIC); return diag; @@ -71,68 +94,11 @@ public static Diagnostic updateVersion(Range range) { * @param range range where the $version is expected to be * @return a Diagnostic with a code that refer to the codeAction to take */ - public static Diagnostic defineVersion(Range range) { + static Diagnostic defineVersion(Range range) { return build( - "You should define a version for your Smithy file.", - SMITHY_DEFINE_VERSION, - range + "You should define a version for your Smithy file.", + SMITHY_DEFINE_VERSION, + range ); } - - - /** - * Produces a diagnostic for each file which w/o a `$version` control statement or - * file which have a `$version` control statement, but it is out dated. - * - * Before looking into a file, we look into `temporaryContents` to make sure - * it's not an open buffer currently being modified. If it is, we should use this content - * rather than what's on disk for this specific file. This avoids showing diagnostic for - * content that's on disk but different from what's in the buffer. - * - * @param f a smithy file to inspect - * @param temporaryContents a map of file to content (represent opened file that are not saved) - * @return a list of PublishDiagnosticsParams - */ - public static List createVersionDiagnostics(File f, Map temporaryContents) { - // number of line to read in which we expect the $version statement - int n = 5; - String editedContent = temporaryContents.get(f); - - List lines; - try { - lines = editedContent == null ? Utils.readFirstNLines(f, n) : Utils.contentFirstNLines(editedContent, n); - } catch (IOException e) { - return Collections.emptyList(); - } - - Optional version = - lines.stream().filter(nl -> nl.getContent().startsWith("$version")).findFirst(); - Stream diagStream = version.map(nl -> { - // version is set, its 1 - if (nl.getContent().contains("\"1\"")) { - return Stream.of( - VersionDiagnostics.updateVersion( - new Range( - new Position(nl.getLineNumber(), 0), - new Position(nl.getLineNumber(), nl.getContent().length()) - ) - ) - ); - } else { - // version is set, it is not 1 - return Stream.empty(); - } - }).orElseGet(() -> { - // we use the first line to show the diagnostic, as the $version is at the top of the file - // if 0 is used, only the first _word_ is highlighted by the IDE(vscode). It also means that - // you can only apply the code action if you position your cursor at the very start of the file. - Integer firstLineLength = lines.stream() - .findFirst().map(nl -> nl.getContent().length()) - .orElse(0); - return Stream.of(// version is not set - VersionDiagnostics.defineVersion(new Range(new Position(0, 0), new Position(0, firstLineLength))) - ); - }); - return diagStream.collect(Collectors.toList()); - } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java new file mode 100644 index 00000000..e166f278 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -0,0 +1,502 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.nio.CharBuffer; +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.RangeAdapter; + +/** + * In-memory representation of a text document, indexed by line, which can + * be patched in-place. + * + *

Methods on this class will often return {@code -1} or {@code null} for + * failure cases to reduce allocations, since these methods may be called + * frequently. + */ +public final class Document { + private final StringBuilder buffer; + private int[] lineIndices; + + private Document(StringBuilder buffer, int[] lineIndices) { + this.buffer = buffer; + this.lineIndices = lineIndices; + } + + /** + * @param string String to create a document for + * @return The created document + */ + public static Document of(String string) { + StringBuilder buffer = new StringBuilder(string); + int[] lineIndicies = computeLineIndicies(buffer); + return new Document(buffer, lineIndicies); + } + + /** + * @return A copy of this document + */ + public Document copy() { + // Probably don't need to recompute the line indicies + return Document.of(copyText()); + } + + /** + * @param range The range to apply the edit to. Providing {@code null} will + * replace the text in the document + * @param text The text of the edit to apply + */ + public void applyEdit(Range range, String text) { + if (range == null) { + buffer.replace(0, buffer.length(), text); + } else { + Position start = range.getStart(); + Position end = range.getEnd(); + if (start.getLine() >= lineIndices.length) { + buffer.append(text); + } else { + int startIndex = lineIndices[start.getLine()] + start.getCharacter(); + if (end.getLine() >= lineIndices.length) { + buffer.replace(startIndex, buffer.length(), text); + } else { + int endIndex = lineIndices[end.getLine()] + end.getCharacter(); + buffer.replace(startIndex, endIndex, text); + } + } + } + this.lineIndices = computeLineIndicies(buffer); + } + + /** + * @return The range of the document, from (0, 0) to {@link #end()} + */ + public Range getFullRange() { + return RangeAdapter.offset(end()); + } + + /** + * @param line The line to find the index of + * @return The index of the start of the given {@code line}, or {@code -1} + * if the line doesn't exist + */ + public int indexOfLine(int line) { + if (line >= lineIndices.length || line < 0) { + return -1; + } + return lineIndices[line]; + } + + /** + * @param idx Index to find the line of + * @return The line that {@code idx} is within or {@code -1} if the 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; + } + } + + return lastLine(); + } + + /** + * @param position The position to find the index of + * @return The index of the position in this document, or {@code -1} if the + * position is out of bounds + */ + public int indexOfPosition(Position position) { + return indexOfPosition(position.getLine(), position.getCharacter()); + } + + /** + * @param line The line of the index to find + * @param character The character offset in the line + * @return The index of the position in this document, or {@code -1} if the + * position is out of bounds + */ + public int indexOfPosition(int line, int character) { + int startLineIdx = indexOfLine(line); + if (startLineIdx < 0) { + // line is oob + return -1; + } + + + int idx = startLineIdx + character; + if (line == lastLine()) { + if (idx >= buffer.length()) { + // index is oob + return -1; + } + } else { + if (idx >= indexOfLine(line + 1)) { + // index is onto next line + return -1; + } + } + + return idx; + } + + /** + * @param index The index to find the position of + * @return The position of the index in this document, or {@code null} if + * the index is out of bounds + */ + public Position positionAtIndex(int index) { + int line = lineOfIndex(index); + if (line < 0) { + return null; + } + int lineStart = indexOfLine(line); + int character = index - lineStart; + return new Position(line, character); + } + + /** + * @param line The line to find the end of + * @return The index of the end of the given line, or {@code -1} if the + * line is out of bounds + */ + public int lineEnd(int line) { + if (line > lastLine() || line < 0) { + return -1; + } + + if (line == lastLine()) { + return length() - 1; + } else { + return indexOfLine(line + 1) - 1; + } + } + + /** + * @return The line number of the last line in this document + */ + public int lastLine() { + return lineIndices.length - 1; + } + + /** + * @return The end position of this document + */ + public Position end() { + return new Position( + lineIndices.length - 1, + buffer.length() - lineIndices[lineIndices.length - 1]); + } + + /** + * @param s The string to find the next index of + * @param after The index to start the search at + * @return The index of the next occurrence of {@code s} after {@code after} + * or {@code -1} if one doesn't exist + */ + public int nextIndexOf(String s, int after) { + return buffer.indexOf(s, after); + } + + /** + * @param s The string to find the last index of + * @param before The index to end the search at + * @return The index of the last occurrence of {@code s} before {@code before} + * or {@code -1} if one doesn't exist + */ + 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 + */ + 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 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) { + int idx = indexOfPosition(position); + if (idx < 0) { + return null; + } + + char atIdx = buffer.charAt(idx); + if (!isIdChar(atIdx)) { + return null; + } + + int startIdx = idx; + while (startIdx >= 0) { + if (isIdChar(buffer.charAt(startIdx))) { + startIdx -= 1; + } else { + break; + } + } + + int endIdx = idx; + while (endIdx < buffer.length()) { + if (isIdChar(buffer.charAt(endIdx))) { + endIdx += 1; + } else { + break; + } + } + + return CharBuffer.wrap(buffer, startIdx + 1, endIdx); + } + + private static boolean isIdChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; + } + + /** + * @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 + */ + public String copyText() { + return buffer.toString(); + } + + /** + * @param range The range to copy the text of + * @return A copy of the text in this document within the given {@code range} + * or {@code null} if the range is out of bounds + */ + public String copyRange(Range range) { + CharBuffer borrowed = borrowRange(range); + if (borrowed == null) { + return null; + } + + 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 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 + * @return A copy of the text within the indicies {@code start} and + * {@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) { + return null; + } + return borrowed.toString(); + } + + /** + * @return The length of the document + */ + 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; + int off = 0; + int next; + // Have to box sadly, unless there's some IntArray I'm not aware of. Maybe IntBuffer + List indicies = new ArrayList<>(); + indicies.add(0); + while ((next = buffer.indexOf(System.lineSeparator(), off)) != -1) { + indicies.add(next + 1); + off = next + 1; + ++matchCount; + } + return indicies.stream().mapToInt(Integer::intValue).toArray(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java new file mode 100644 index 00000000..057aaa50 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.util.Set; +import org.eclipse.lsp4j.Range; + +/** + * The imports of a document, including the range they occupy. + */ +public final class DocumentImports { + private final Range importsRange; + private final Set imports; + + DocumentImports(Range importsRange, Set imports) { + this.importsRange = importsRange; + this.imports = imports; + } + + /** + * @return The range of the imports + */ + public Range importsRange() { + return importsRange; + } + + /** + * @return The set of imported shape ids. They are not guaranteed + * to be valid shape ids + */ + public Set imports() { + return imports; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java new file mode 100644 index 00000000..52181a6e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * The namespace of the document, including the range it occupies. + */ +public final class DocumentNamespace { + private final Range statementRange; + private final CharSequence namespace; + + DocumentNamespace(Range statementRange, CharSequence namespace) { + this.statementRange = statementRange; + this.namespace = namespace; + } + + /** + * @return The range of the statement, including {@code namespace} + */ + public Range statementRange() { + return statementRange; + } + + /** + * @return The namespace of the document. Not guaranteed to be + * a valid namespace + */ + public CharSequence namespace() { + return namespace; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java new file mode 100644 index 00000000..cf4c6a82 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -0,0 +1,684 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.PositionAdapter; +import software.amazon.smithy.lsp.protocol.RangeAdapter; +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. + */ +public final class DocumentParser extends SimpleParser { + private final Document document; + + private DocumentParser(Document document) { + super(document.borrowText()); + this.document = document; + } + + static DocumentParser of(String text) { + return DocumentParser.forDocument(Document.of(text)); + } + + /** + * @param document Document to create a parser for + * @return A parser for the given document + */ + public static DocumentParser forDocument(Document document) { + return new DocumentParser(document); + } + + /** + * @return The {@link DocumentNamespace} for the underlying document, or + * {@code null} if it couldn't be found + */ + 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); + } + consumeRemainingCharactersOnLine(); + lastUseEnd = currentPosition(); + nextNonWsNonComment(); + } while (isUse()); + + if (imports.isEmpty()) { + return null; + } + + return new DocumentImports(new Range(firstUsePosition, lastUseEnd), imports); + } + + /** + * @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} + */ + public Map documentShapes(Set shapes) { + Map documentShapes = new HashMap<>(shapes.size()); + for (Shape shape : shapes) { + if (!jumpToSource(shape.getSourceLocation())) { + continue; + } + + if (shape.isMemberShape()) { + DocumentShape.Kind kind = DocumentShape.Kind.DefinedMember; + if (is('$')) { + kind = DocumentShape.Kind.Elided; + } + DocumentShape member = documentShape(kind); + documentShapes.put(member.range().getStart(), member); + sp(); + if (peek() == ':') { + skip(); + // get target + sp(); + DocumentShape target = documentShape(DocumentShape.Kind.Targeted); + documentShapes.put(target.range().getStart(), target); + member.setTargetReference(target); + } + } else { + skipAlpha(); // shape type + sp(); + DocumentShape shapeDef = documentShape(DocumentShape.Kind.DefinedShape); + if (shapeDef.shapeName().length() == 0) { + // Not sure if we should set the shape name here + shapeDef.setKind(DocumentShape.Kind.Inline); + } + documentShapes.put(shapeDef.range().getStart(), shapeDef); + } + } + 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); + return new DocumentShape(range, shapeName, kind); + } + + /** + * @return The {@link DocumentVersion} for the underlying document, or + * {@code null} if it couldn't be found + */ + public DocumentVersion documentVersion() { + firstIndexOfNonWsNonComment(); + if (!is('$')) { + return null; + } + while (is('$') && !isVersion()) { + // Skip this line + if (!jumpToLine(line())) { + return null; + } + // 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 = RangeAdapter.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(PositionAdapter.fromSourceLocation(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; + } + + /** + * Jumps the parser location to the given {@code source}. + * + * @param source The location to jump to. The filename must be the same as + * the underlying document's filename (this is not checked) + * @return Whether the parser successfully jumped + */ + public boolean jumpToSource(SourceLocation source) { + int idx = this.document.indexOfPosition(source.getLine() - 1, source.getColumn() - 1); + if (idx < 0) { + return false; + } + this.rewind(idx, source.getLine(), source.getColumn()); + return true; + } + + /** + * @return The current position of the parser + */ + public Position currentPosition() { + return new Position(line() - 1, column() - 1); + } + + /** + * @return The underlying document + */ + 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 { + 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 (!isSp(-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 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; + } + + private boolean isWs() { + return isNl() || isSp(); + } + + private boolean isNl() { + return is('\n') || is('\r'); + } + + private boolean isSp() { + return is(' ') || is('\t'); + } + + private boolean isSp(int offset) { + return is(' ', offset) || is('\t', offset); + } + + 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; + } + + switch (token.toString()) { + case "structure": + case "operation": + case "string": + case "integer": + case "list": + case "map": + case "boolean": + case "enum": + case "union": + case "blob": + case "byte": + case "short": + case "long": + case "float": + case "double": + case "timestamp": + case "intEnum": + case "document": + case "service": + case "resource": + case "bigDecimal": + case "bigInteger": + return true; + default: + return false; + } + } + + private int firstIndexOfWithOnlyLeadingWs(String s) { + int searchFrom = 0; + 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 < document.length()); + 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); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java new file mode 100644 index 00000000..9f193289 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java @@ -0,0 +1,39 @@ +/* + * 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, + + /** + * 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 new file mode 100644 index 00000000..0913b924 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java @@ -0,0 +1,75 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.util.Objects; +import org.eclipse.lsp4j.Range; + +public final class DocumentShape { + private final Range range; + private final CharSequence shapeName; + private Kind kind; + private DocumentShape targetReference; + + DocumentShape(Range range, CharSequence shapeName, Kind kind) { + this.range = range; + this.shapeName = shapeName; + this.kind = kind; + } + + public Range range() { + return range; + } + + public CharSequence shapeName() { + return shapeName; + } + + public Kind kind() { + return kind; + } + + void setKind(Kind kind) { + this.kind = kind; + } + + public DocumentShape targetReference() { + return targetReference; + } + + void setTargetReference(DocumentShape targetReference) { + this.targetReference = targetReference; + } + + public boolean isKind(Kind other) { + return this.kind.equals(other); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DocumentShape that = (DocumentShape) o; + return Objects.equals(range, that.range) && Objects.equals(shapeName, that.shapeName) && kind == that.kind; + } + + @Override + public int hashCode() { + return Objects.hash(range, shapeName, kind); + } + + 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 new file mode 100644 index 00000000..308bdeb7 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java @@ -0,0 +1,35 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import org.eclipse.lsp4j.Range; + +/** + * The Smithy version of the document, including the range it occupies. + */ +public final class DocumentVersion { + private final Range range; + private final String version; + + DocumentVersion(Range range, String version) { + this.range = range; + this.version = version; + } + + /** + * @return The range of the version statement + */ + public Range range() { + return range; + } + + /** + * @return The literal text of the version value + */ + public String version() { + return version; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/editor/SmartInput.java b/src/main/java/software/amazon/smithy/lsp/editor/SmartInput.java deleted file mode 100644 index 072a9b74..00000000 --- a/src/main/java/software/amazon/smithy/lsp/editor/SmartInput.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.editor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; - -public final class SmartInput { - public static final Position POS_0_0 = new Position(0, 0); - private final String input; - private final Range range; - - private SmartInput(List lines) { - Position endPos; - if (lines.isEmpty()) { - endPos = POS_0_0; - } else { - final int lastLineI = lines.size() - 1; - final String lastLine = lines.get(lastLineI); - endPos = new Position(lastLineI, lastLine.length()); - } - this.input = String.join("\n", lines); - this.range = new Range(POS_0_0, endPos); - } - - /** - * Read the file at `p`. - * @param p path to the file to read - * @return the content if no exception occurs, otherwise throws. - */ - public static SmartInput fromPath(Path p) throws IOException { - return fromInput(new String(Files.readAllBytes(p), StandardCharsets.UTF_8)); - } - - /** - * Read the file at `p` and wrap it into an Optional. - * @param p path to the file to read - * @return Optional with the content if no exception occurs, otherwise Optional.empty. - */ - public static Optional fromPathSafe(Path p) { - try { - return Optional.of(fromPath(p)); - } catch (IOException e) { - return Optional.empty(); - } - } - - public static SmartInput fromInput(String input) { - String[] split = input.split("\\n", -1); // keep trailing new lines - return new SmartInput(Arrays.asList(split)); - } - - public String getInput() { - return input; - } - - public Range getRange() { - return range; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - SmartInput that = (SmartInput) o; - return input.equals(that.input) && range.equals(that.range); - } - - @Override - public int hashCode() { - return Objects.hash(input, range); - } - - @Override - public String toString() { - return "SmartInput{" + "input='" + input + '\'' + ", range=" + range + '}'; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/Completions.java b/src/main/java/software/amazon/smithy/lsp/ext/Completions.java deleted file mode 100644 index 86a9d564..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/Completions.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionItemKind; -import org.eclipse.lsp4j.TextEdit; -import software.amazon.smithy.model.Model; -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.RequiredTrait; -import software.amazon.smithy.model.traits.TraitDefinition; -import software.amazon.smithy.utils.ListUtils; - -public final class Completions { - private static final List KEYWORD_COMPLETIONS = Constants.KEYWORDS.stream() - .map(kw -> new SmithyCompletionItem(createCompletion(kw, CompletionItemKind.Keyword))) - .collect(Collectors.toList()); - - private Completions() { - } - - /** - * From a model and (potentially partial) token, build a list of completions. - * Empty list is returned for empty tokens. Current implementation is prefix - * based. - * - * @param model Smithy model - * @param token token - * @param isTraitShapeId boolean - * @param target Optional ShapeId of the target trait target - * @return list of completion items - */ - public static List find(Model model, String token, boolean isTraitShapeId, - Optional target) { - Map comps = new HashMap<>(); - String lcase = token.toLowerCase(); - - Set shapeIdSet; - // If the token is part of a trait shapeId, filter the set to trait shapes which can be applied to the shape - // that the trait targets. - if (isTraitShapeId) { - shapeIdSet = getTraitShapeIdSet(model, target); - } else { - // Otherwise, use all shapes in model the as potential completions. - shapeIdSet = model.getShapeIds(); - } - - if (!token.trim().isEmpty()) { - shapeIdSet.forEach(shapeId -> { - if (shapeId.getName().toLowerCase().startsWith(lcase) && !comps.containsKey(shapeId.getName())) { - String name = shapeId.getName(); - String namespace = shapeId.getNamespace(); - if (isTraitShapeId) { - Shape shape = model.expectShape(shapeId); - List completions = createTraitCompletions(shape, model, - CompletionItemKind.Class); - for (CompletionItem item : completions) { - // Use the label to merge traits without required members and the default version. - comps.put(item.getLabel(), smithyCompletionItem(item, namespace, name)); - } - } else { - CompletionItem completionItem = createCompletion(name, CompletionItemKind.Class); - comps.put(name, smithyCompletionItem(completionItem, namespace, name)); - } - } - }); - KEYWORD_COMPLETIONS.forEach(kw -> { - if (!isTraitShapeId && kw.getCompletionItem().getLabel().toLowerCase().startsWith(lcase) - && !comps.containsKey(kw.getCompletionItem().getLabel())) { - comps.put(kw.getCompletionItem().getLabel(), kw); - } - }); - } - return ListUtils.copyOf(comps.values()); - } - - /** - * For a given list of completion items and a live document preamble, create a list - * of completion items with necessary text edits to support auto-imports. - * - * @param items list of model-specific completion items - * @param preamble live document preamble - * @return list of completion items (optionally with text edits) - */ - public static List resolveImports(List items, DocumentPreamble preamble) { - return items.stream().map(sci -> { - CompletionItem result = sci.getCompletionItem(); - Optional qualifiedImport = sci.getQualifiedImport(); - Optional importNamespace = sci.getImportNamespace(); - Optional currentNamespace = preamble.getCurrentNamespace(); - - - qualifiedImport.ifPresent(qi -> { - boolean matchesCurrent = importNamespace.equals(currentNamespace); - boolean matchesPrelude = importNamespace.equals(Optional.of(Constants.SMITHY_PRELUDE_NAMESPACE)); - boolean shouldImport = !preamble.hasImport(qi) && !matchesPrelude && !matchesCurrent; - - if (shouldImport) { - TextEdit te = Document.insertPreambleLine("use " + qualifiedImport.get(), preamble); - result.setAdditionalTextEdits(ListUtils.of(te)); - } - }); - - return result; - }).collect(Collectors.toList()); - } - - // Get set of trait shapes from model that can be applied to an optional shapeId. - private static Set getTraitShapeIdSet(Model model, Optional target) { - return model.shapes() - .filter(shape -> shape.hasTrait(ShapeId.from("smithy.api#trait"))) - .filter(shape -> { - if (!target.isPresent()) { - return true; - } - return shape.expectTrait(TraitDefinition.class).getSelector().shapes(model) - .anyMatch(matchingShape -> matchingShape.getId().equals(target.get())); - }) - .map(shape -> shape.getId()) - .collect(Collectors.toSet()); - } - - private static CompletionItem createCompletion(String s, CompletionItemKind kind) { - CompletionItem ci = new CompletionItem(s); - ci.setKind(kind); - return ci; - } - - private static SmithyCompletionItem smithyCompletionItem(CompletionItem item, String namespace, String name) { - return new SmithyCompletionItem(item, namespace, name); - } - - private static List createTraitCompletions(Shape shape, Model model, CompletionItemKind kind) { - List completions = new ArrayList<>(); - completions.add(createTraitCompletion(shape, model, kind)); - // Add a default completion for structure shapes with members. - if (shape.isStructureShape() && !shape.members().isEmpty()) { - if (shape.members().stream().anyMatch(member -> member.hasTrait(RequiredTrait.class))) { - // If the structure has required members, add a default with empty parens. - completions.add(createCompletion(shape.getId().getName() + "()", kind)); - } else { - // Otherwise, add a completion without any parens. - completions.add(createCompletion(shape.getId().getName(), kind)); - } - - } - return completions; - } - - private static CompletionItem createTraitCompletion(Shape shape, Model model, CompletionItemKind kind) { - String traitBody = shape.accept(new TraitBodyVisitor(model)); - // Strip outside pair of brackets from any structure traits. - if (traitBody.charAt(0) == '{') { - traitBody = traitBody.substring(1, traitBody.length() - 1); - } - if (shape.members().isEmpty()) { - return createCompletion(shape.getId().getName(), kind); - } - return createCompletion(shape.getId().getName() + "(" + traitBody + ")", kind); - } - - 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) { - return "\"\""; - } - - @Override - public String unionShape(UnionShape shape) { - return "{}"; - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/Constants.java b/src/main/java/software/amazon/smithy/lsp/ext/Constants.java deleted file mode 100644 index b16d26bf..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/Constants.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.util.Arrays; -import java.util.List; - -public final class Constants { - public static final String SMITHY_EXTENSION = ".smithy"; - - public static final List BUILD_FILES = Arrays.asList("build/smithy-dependencies.json", ".smithy.json", - "smithy-build.json"); - - public 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"); - - public static final String SMITHY_PRELUDE_NAMESPACE = "smithy.api"; - - private Constants() { - } - -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/Document.java b/src/main/java/software/amazon/smithy/lsp/ext/Document.java deleted file mode 100644 index bce0d691..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/Document.java +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.TextEdit; - -public final class Document { - - public static Position blankPosition = new Position(-1, 0); - public static Position startPosition = new Position(0, 0); - - private Document() { - } - - /** - * Identify positions of all parts of document preamble. - * - * @param lines lines of the source file - * @return document preamble - */ - public static DocumentPreamble detectPreamble(List lines) { - Range namespaceRange = new Range(blankPosition, blankPosition); - Range useBlockRange = new Range(blankPosition, blankPosition); - Set imports = new HashSet<>(); - int firstUseStatementLine = 0; - String firstUseStatement = ""; - int lastUseStatementLine = 0; - String lastUseStatement = ""; - boolean collectUseBlock = true; - boolean collectNamespace = true; - int endOfPreamble = 0; - Optional currentNamespace = Optional.empty(); - Optional idlVersion = Optional.empty(); - Optional operationInputSuffix = Optional.empty(); - Optional operationOutputSuffix = Optional.empty(); - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i).trim(); - if (line.startsWith("namespace ") && collectNamespace) { - currentNamespace = Optional.of(line.substring(10)); - namespaceRange = getNamespaceRange(i, lines.get(i)); - collectNamespace = false; - } else if (line.startsWith("use ") && collectUseBlock) { - imports.add(getImport(line)); - if (firstUseStatement.isEmpty()) { - firstUseStatementLine = i; - firstUseStatement = lines.get(i); - } - if (i > lastUseStatementLine || lastUseStatement.isEmpty()) { - lastUseStatementLine = i; - lastUseStatement = lines.get(i); - } - } else if (line.startsWith("$version:")) { - idlVersion = getControlStatementValue(line, "version"); - } else if (line.startsWith("$operationInputSuffix:")) { - operationInputSuffix = getControlStatementValue(line, "operationInputSuffix"); - } else if (line.startsWith("$operationOutputSuffix:")) { - operationOutputSuffix = getControlStatementValue(line, "operationOutputSuffix"); - } else if (line.startsWith("//") || line.isEmpty() || line.startsWith("metadata")) { - // Skip docs, empty lines and single-line metadata. - } else if (collectNamespace) { - // While the namespace has not been collected, skip any lines related to the metadata section. - } else { - // Stop collecting use statements. - collectUseBlock = false; - if (endOfPreamble == 0) { - endOfPreamble = i - 1; - } - } - } - - if (!firstUseStatement.isEmpty()) { - useBlockRange = getUseBlockRange(firstUseStatementLine, firstUseStatement, lastUseStatementLine, - lastUseStatement); - } - - boolean blankSeparated = lines.get(endOfPreamble).trim().isEmpty(); - - return new DocumentPreamble(currentNamespace, namespaceRange, idlVersion, operationInputSuffix, - operationOutputSuffix, useBlockRange, imports, blankSeparated); - } - - // Strip control statement key, trim whitespace and then remove quotes. - private static Optional getControlStatementValue(String line, String key) { - String quotedValue = line.substring(key.length() + 2).trim(); - return Optional.of(quotedValue.substring(1, quotedValue.length() - 1)); - } - - private static String getImport(String useStatement) { - return useStatement.trim().split("use ", 2)[1].trim(); - } - - private static Range getUseBlockRange(int startLine, String startLineStatement, - int endLine, String endLineStatement) { - return new Range(getStartPosition(startLine, startLineStatement), new Position(endLine, - endLineStatement.length())); - } - - private static Range getNamespaceRange(int lineNumber, String content) { - return new Range(getStartPosition(lineNumber, content), new Position(lineNumber, content.length())); - } - - private static Position getStartPosition(int lineNumber, String content) { - return new Position(lineNumber, getStartOffset(content)); - } - - private static int getStartOffset(String line) { - int offset = 0; - while (line.charAt(offset) == ' ') { - offset++; - } - return offset; - } - - /** - * Constructs a text edit that inserts a statement (usually `use ...`) in the correct place - * in the preamble. - * - * @param line text to insert - * @param preamble document preamble - * @return a text edit - */ - public static TextEdit insertPreambleLine(String line, DocumentPreamble preamble) { - String trailingNewLine; - if (!preamble.isBlankSeparated()) { - trailingNewLine = "\n"; - } else { - trailingNewLine = ""; - } - // case 1 - there's no use block at all, so we need to insert the line directly - // under namespace - if (preamble.getUseBlockRange().getStart() == Document.blankPosition) { - // case 1.a - there's no namespace - that means the document is invalid - // so we'll just insert the line at the beginning of the document - if (preamble.getNamespaceRange().getStart() == Document.blankPosition) { - return new TextEdit(new Range(Document.startPosition, Document.startPosition), line + trailingNewLine); - } else { - Position namespaceEnd = preamble.getNamespaceRange().getEnd(); - namespaceEnd.setCharacter(namespaceEnd.getCharacter() + 1); - return new TextEdit(new Range(namespaceEnd, namespaceEnd), "\n" + line + trailingNewLine); - } - } else { - Position useBlockEnd = preamble.getUseBlockRange().getEnd(); - return new TextEdit(new Range(useBlockEnd, useBlockEnd), "\n" + line + trailingNewLine); - } - } - - -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/DocumentPreamble.java b/src/main/java/software/amazon/smithy/lsp/ext/DocumentPreamble.java deleted file mode 100644 index cb6bc82e..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/DocumentPreamble.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.util.Optional; -import java.util.Set; -import org.eclipse.lsp4j.Range; - -public final class DocumentPreamble { - private final Optional currentNamespace; - private final Optional idlVersion; - private final Optional operationInputSuffix; - private final Optional operationOutputSuffix; - private final Range namespace; - private final Range useBlock; - private final Set imports; - private final boolean blankSeparated; - - /** - * Document preamble represents some meta information about a Smithy document (potentially mid-edit) - * This information is required to correctly implement features such as auto-import of definitions - * on completions. - * - * @param currentNamespace namespace value in the document - * @param namespace position of namespace declaration - * @param idlVersion IDL version - * @param operationInputSuffix suffix applied to name of inline operation inputs - * @param operationOutputSuffix suffix applied to name of inline operation outputs - * @param useBlock start and end of the use statements block - * @param imports set of imported (fully qualified) identifiers - * @param blankSeparated whether document preamble is separated from other definitions by newline(s) - */ - public DocumentPreamble( - Optional currentNamespace, Range namespace, Optional idlVersion, - Optional operationInputSuffix, Optional operationOutputSuffix, Range useBlock, - Set imports, boolean blankSeparated - ) { - this.currentNamespace = currentNamespace; - this.namespace = namespace; - this.idlVersion = idlVersion; - this.operationInputSuffix = operationInputSuffix; - this.operationOutputSuffix = operationOutputSuffix; - this.useBlock = useBlock; - this.imports = imports; - this.blankSeparated = blankSeparated; - } - - public Range getUseBlockRange() { - return useBlock; - } - - public Range getNamespaceRange() { - return namespace; - } - - public boolean hasImport(String i) { - return imports.contains(i); - } - - public boolean isBlankSeparated() { - return this.blankSeparated; - } - - public Optional getCurrentNamespace() { - return this.currentNamespace; - } - - public Optional getIdlVersion() { - return this.idlVersion; - } - - public Optional getOperationInputSuffix() { - return this.operationInputSuffix; - } - - public Optional getOperationOutputSuffix() { - return this.operationOutputSuffix; - } - - @Override - public String toString() { - return "DocumentPreamble(namespace=" + namespace + ", useBlock=" + useBlock + ")"; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/FileCachingCollector.java b/src/main/java/software/amazon/smithy/lsp/ext/FileCachingCollector.java deleted file mode 100644 index 334bcd28..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/FileCachingCollector.java +++ /dev/null @@ -1,417 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.Utils; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.OperationShape; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.traits.InputTrait; -import software.amazon.smithy.model.traits.OutputTrait; -import software.amazon.smithy.model.traits.Trait; - -/** - * Creates a cache of {@link ModelFile} and uses it to collect the locations of container - * shapes in all files, then collects their members. - */ -final class FileCachingCollector implements ShapeLocationCollector { - - private Model model; - private Map locations; - private Map fileCache; - private Map> operationsWithInlineInputOutputMap; - private Map> containerMembersMap; - private Map membersToUpdateMap; - - @Override - public Map collectDefinitionLocations(Model model) { - this.locations = new HashMap<>(); - this.model = model; - this.fileCache = createModelFileCache(model); - this.operationsWithInlineInputOutputMap = new HashMap<>(); - this.containerMembersMap = new HashMap<>(); - this.membersToUpdateMap = new HashMap<>(); - - for (ModelFile modelFile : this.fileCache.values()) { - try { - collectContainerShapeLocationsInModelFile(modelFile); - } catch (Exception e) { - throw new RuntimeException("Exception while collecting container shape locations in model file: " - + modelFile.filename, e); - } - } - - operationsWithInlineInputOutputMap.forEach((this::collectInlineInputOutputLocations)); - containerMembersMap.forEach(this::collectMemberLocations); - // Make final pass to set locations for mixed-in member locations that weren't available on first pass. - membersToUpdateMap.forEach(this::updateElidedMemberLocation); - return this.locations; - } - - private static Map createModelFileCache(Model model) { - Map fileCache = new HashMap<>(); - List modelFilenames = getAllFilenamesFromModel(model); - for (String filename : modelFilenames) { - List shapes = getReverseSortedShapesInFileFromModel(model, filename); - List lines = getFileLines(filename); - DocumentPreamble preamble = Document.detectPreamble(lines); - ModelFile modelFile = new ModelFile(filename, lines, preamble, shapes); - fileCache.put(filename, modelFile); - } - return fileCache; - } - - private void collectContainerShapeLocationsInModelFile(ModelFile modelFile) { - String filename = modelFile.filename; - int endMarker = getInitialEndMarker(modelFile.lines); - - for (Shape shape : modelFile.shapes) { - SourceLocation sourceLocation = shape.getSourceLocation(); - Position startPosition = getStartPosition(sourceLocation); - Position endPosition; - if (endMarker < sourceLocation.getLine()) { - endPosition = new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1); - } else { - endPosition = getEndPosition(endMarker, modelFile.lines); - } - // If a shape belongs to an operation as an inlined input or output, collect a map of the operation - // with the reversed ordered list of inputs and outputs within that operation. Once the location of - // the containing operation has been determined, the map can be revisited to determine the locations of - // the inlined inputs and outputs. - Optional matchingOperation = getOperationForInlinedInputOrOutput(shape, modelFile); - - if (matchingOperation.isPresent()) { - operationsWithInlineInputOutputMap.computeIfAbsent(matchingOperation.get(), s -> - new ArrayList<>()).add(shape); - // Collect a map of container shapes and a list of member shapes, reverse ordered by source location - // in the model file. This map will be revisited after the location of the containing shape has been - // determined since it is needed to determine the locations of each member. - } else if (shape.isMemberShape()) { - MemberShape memberShape = shape.asMemberShape().get(); - ShapeId containerId = memberShape.getContainer(); - containerMembersMap.computeIfAbsent(containerId, s -> new ArrayList<>()).add(memberShape); - } else { - endMarker = advanceMarkerOnNonMemberShapes(startPosition, shape, modelFile); - locations.put(shape.getId(), createLocation(filename, startPosition, endPosition)); - } - } - } - - // Determine the location of inlined inputs and outputs can be determined using the containing operation. - private void collectInlineInputOutputLocations(OperationShape operation, List shapes) { - int operationEndMarker = locations.get(operation.getId()).getRange().getEnd().getLine(); - for (Shape shape : shapes) { - SourceLocation sourceLocation = shape.getSourceLocation(); - ModelFile modelFile = fileCache.get(sourceLocation.getFilename()); - Position startPosition = getStartPosition(sourceLocation); - Position endPosition = getEndPosition(operationEndMarker, modelFile.lines); - Location location = createLocation(modelFile.filename, startPosition, endPosition); - locations.put(shape.getId(), location); - operationEndMarker = sourceLocation.getLine() - 1; - } - } - - private void collectMemberLocations(ShapeId containerId, List members) { - - Location containerLocation = locations.get(containerId); - Range containerLocationRange = containerLocation.getRange(); - int memberEndMarker = containerLocationRange.getEnd().getLine(); - // Keep track of previous line to make sure that end marker has been advanced. - String previousLine = ""; - // The member shapes were reverse ordered by source location when assembling this list, so we can - // iterate through it as-is to work from bottom to top in the model file. - for (MemberShape memberShape : members) { - ModelFile modelFile = fileCache.get(memberShape.getSourceLocation().getFilename()); - int memberShapeSourceLocationLine = memberShape.getSourceLocation().getLine(); - - boolean isContainerInAnotherFile = !containerLocation.getUri().equals(getUri(modelFile.filename)); - // If the member's source location is within the container location range, it is being defined - // or re-defined there. - boolean isMemberDefinedInContainer = - memberShapeSourceLocationLine >= containerLocationRange.getStart().getLine() - && memberShapeSourceLocationLine <= containerLocationRange.getEnd().getLine(); - - // If the member has mixins, and was not defined within the container, use the mixin source location. - if (memberShape.getMixins().size() > 0 && !isMemberDefinedInContainer) { - ShapeId mixinSource = memberShape.getMixins().iterator().next(); - // If the mixin source location has been determined, use its location now. - if (locations.containsKey(mixinSource)) { - locations.put(memberShape.getId(), locations.get(mixinSource)); - // If the mixin source location has not yet been determined, save to re-visit later. - } else { - membersToUpdateMap.put(memberShape.getId(), mixinSource); - } - } else if (isContainerInAnotherFile) { - locations.put(memberShape.getId(), createInheritedMemberLocation(containerLocation)); - // Otherwise, determine the correct location by trimming comments, empty lines and applied traits. - } else { - String currentLine = modelFile.lines.get(memberEndMarker - 1).trim(); - while (currentLine.startsWith("//") - || currentLine.equals("") - || currentLine.equals("}") - || currentLine.startsWith("@") - || currentLine.equals(previousLine) - ) { - memberEndMarker = memberEndMarker - 1; - currentLine = modelFile.lines.get(memberEndMarker - 1).trim(); - } - Position startPosition = getStartPosition(memberShape.getSourceLocation()); - Position endPosition = getEndPosition(memberEndMarker, modelFile.lines); - - // Advance the member end marker on any non-mixin traits on the current member, so that the next - // member location end is correct. Mixin traits will have been declared outside the - // containing shape and shouldn't impact determining the end location of the next member. - List traits = memberShape.getAllTraits().values().stream() - .filter(trait -> !trait.getSourceLocation().equals(SourceLocation.NONE)) - .filter(trait -> trait.getSourceLocation().getFilename().equals(modelFile.filename)) - .filter(trait -> !isFromMixin(containerLocationRange, trait)) - .collect(Collectors.toList()); - - if (!traits.isEmpty()) { - traits.sort(Comparator.comparing(Trait::getSourceLocation)); - memberEndMarker = traits.get(0).getSourceLocation().getLine(); - } - - locations.put(memberShape.getId(), createLocation(modelFile.filename, startPosition, endPosition)); - previousLine = currentLine; - } - } - } - - // If a mixed-in member is not redefined within its containing structure, set its location to the mixin member. - private void updateElidedMemberLocation(ShapeId member, ShapeId sourceMember) { - if (locations.containsKey(sourceMember)) { - locations.put(member, locations.get(sourceMember)); - } - } - - // Use an empty range at the container's start since inherited members are not present in the model file. - private static Location createInheritedMemberLocation(Location containerLocation) { - Position startPosition = containerLocation.getRange().getStart(); - Range memberRange = new Range(startPosition, startPosition); - return new Location(containerLocation.getUri(), memberRange); - } - - // If the trait was defined outside the container, it was mixed in. - private static boolean isFromMixin(Range containerRange, Trait trait) { - int traitLocationLine = trait.getSourceLocation().getLine(); - return traitLocationLine < containerRange.getStart().getLine() - || traitLocationLine > containerRange.getEnd().getLine(); - } - - // Get the operation that matches an inlined input or output structure. - private Optional getOperationForInlinedInputOrOutput(Shape shape, ModelFile modelFile) { - DocumentPreamble preamble = modelFile.preamble; - if (preamble.getIdlVersion().isPresent() - && preamble.getIdlVersion().get().startsWith("2") - && shape.isStructureShape() - && (shape.hasTrait(OutputTrait.class) || shape.hasTrait(InputTrait.class)) - ) { - String suffix = getOperationInputOrOutputSuffix(shape, preamble); - String shapeName = shape.getId().getName(); - - String matchingOperationName = - shapeName.endsWith(suffix) - ? shapeName.substring(0, shapeName.length() - suffix.length()) - : shapeName; - ShapeId matchingOperationId = ShapeId.fromParts(shape.getId().getNamespace(), matchingOperationName); - - return model.shapes(OperationShape.class) - .filter(operationShape -> operationShape.getId().equals(matchingOperationId)) - .findFirst() - .filter(operation -> shapeWasDefinedInline(operation, shape, modelFile)); - } - return Optional.empty(); - } - - private static String getOperationInputOrOutputSuffix(Shape shape, DocumentPreamble preamble) { - if (shape.hasTrait(InputTrait.class)) { - return preamble.getOperationInputSuffix().orElse("Input"); - } - if (shape.hasTrait(OutputTrait.class)) { - return preamble.getOperationOutputSuffix().orElse("Output"); - } - return ""; - } - - // Iterate through lines in reverse order from current shape start, to the beginning of the above shape, or the - // start of the operation. If the inline structure assignment operator is encountered, the current shape was - // defined inline. This check eliminates instances where an operation and its input or output matches the inline - // structure naming convention. - private Boolean shapeWasDefinedInline(OperationShape operation, Shape shape, ModelFile modelFile) { - int shapeStartLine = shape.getSourceLocation().getLine(); - int priorShapeLine = 0; - if (shape.hasTrait(InputTrait.class) && operation.getOutput().isPresent()) { - Shape output = model.expectShape(operation.getOutputShape().toShapeId()); - if (output.getSourceLocation().getLine() < shape.getSourceLocation().getLine()) { - priorShapeLine = output.getSourceLocation().getLine(); - } - } - if (shape.hasTrait(OutputTrait.class) && operation.getInput().isPresent()) { - Shape input = model.expectShape(operation.getInputShape().toShapeId()); - if (input.getSourceLocation().getLine() < shape.getSourceLocation().getLine()) { - priorShapeLine = input.getSourceLocation().getLine(); - } - } - int boundary = Math.max(priorShapeLine, operation.getSourceLocation().getLine()); - while (shapeStartLine >= boundary) { - String line = modelFile.lines.get(shapeStartLine - 1); - - // note: this doesn't take code inside comments into account - if (line.contains(":=")) { - return true; - } - shapeStartLine--; - } - return false; - } - - private static Location createLocation(String file, Position startPosition, Position endPosition) { - return new Location(getUri(file), new Range(startPosition, endPosition)); - } - - private static int advanceMarkerOnNonMemberShapes(Position startPosition, Shape shape, ModelFile modelFile) { - // When handling non-member shapes, advance the end marker for traits and comments above the current - // shape, ignoring applied traits - int marker = startPosition.getLine(); - - List traits = shape.getAllTraits().values().stream() - .filter(trait -> !trait.getSourceLocation().equals(SourceLocation.NONE)) - .filter(trait -> trait.getSourceLocation().getLine() <= startPosition.getLine()) - .filter(trait -> trait.getSourceLocation().getFilename().equals(modelFile.filename)) - .filter(trait -> !modelFile.lines.get(trait.getSourceLocation().getLine()).trim().startsWith("apply")) - .collect(Collectors.toList()); - - // If the shape has traits, advance the end marker again. - if (!traits.isEmpty()) { - traits.sort(Comparator.comparing(Trait::getSourceLocation)); - marker = traits.get(0).getSourceLocation().getLine() - 1; - } - - // Move the end marker when encountering line comments or empty lines. - if (modelFile.lines.size() > marker) { - marker = getNextEndMarker(modelFile.lines, marker); - } - - return marker; - } - - private static int getInitialEndMarker(List lines) { - return getNextEndMarker(lines, lines.size()); - - } - - private static int getNextEndMarker(List lines, int currentEndMarker) { - if (lines.size() == 0) { - return currentEndMarker; - } - int endMarker = currentEndMarker; - while (endMarker > 0 && shouldIgnoreLine(lines.get(endMarker - 1))) { - endMarker--; - } - return endMarker; - } - - // Blank lines, comments, and apply statements are ignored because they are unmodeled - private static boolean shouldIgnoreLine(String line) { - String trimmed = line.trim(); - return trimmed.isEmpty() || trimmed.startsWith("//") || trimmed.startsWith("apply"); - } - - private static Position getStartPosition(SourceLocation sourceLocation) { - return new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1); - } - - private static String getUri(String fileName) { - return Utils.isJarFile(fileName) - ? Utils.toSmithyJarFile(fileName) - : addFilePrefix(fileName); - } - - private static String addFilePrefix(String fileName) { - return !fileName.startsWith("file:") ? "file:" + fileName : fileName; - } - - private static List getAllFilenamesFromModel(Model model) { - return model.shapes() - .map(shape -> shape.getSourceLocation().getFilename()) - .distinct() - .collect(Collectors.toList()); - } - - private static List getReverseSortedShapesInFileFromModel(Model model, String filename) { - return model.shapes() - .filter(shape -> shape.getSourceLocation().getFilename().equals(filename)) - .sorted(Comparator.comparing(Shape::getSourceLocation).reversed()) - .collect(Collectors.toList()); - } - - private static List getFileLines(String file) { - try { - if (Utils.isSmithyJarFile(file) || Utils.isJarFile(file)) { - return Utils.jarFileContents(Utils.toSmithyJarFile(file)); - } else { - return Files.readAllLines(Paths.get(file)); - } - } catch (IOException e) { - LspLog.println("File " + file + " could not be loaded."); - } - return Collections.emptyList(); - } - - private static Position getEndPosition(int currentEndMarker, List fileLines) { - // Skip any blank lines, comments, or apply statements - int endLine = getNextEndMarker(fileLines, currentEndMarker); - - // Return end position of actual shape line if we have the lines, or set it to the start of the next line - if (fileLines.size() >= endLine) { - return new Position(endLine - 1, fileLines.get(endLine - 1).length()); - } - return new Position(endLine, 0); - } - - private static final class ModelFile { - private final String filename; - private final List lines; - private final DocumentPreamble preamble; - private final List shapes; - - private ModelFile(String filename, List lines, DocumentPreamble preamble, List shapes) { - this.filename = filename; - this.lines = lines; - this.preamble = preamble; - this.shapes = shapes; - } - } - -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/ShapeLocationCollector.java b/src/main/java/software/amazon/smithy/lsp/ext/ShapeLocationCollector.java deleted file mode 100644 index 179a5010..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/ShapeLocationCollector.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.util.Map; -import org.eclipse.lsp4j.Location; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.utils.SmithyUnstableApi; - -/** - * Interface used to get the shape location information - * used by the language server. - */ -@SmithyUnstableApi -interface ShapeLocationCollector { - - /** - * Collects the definition locations of all shapes in the model. - * - * @param model Model to collect shape definition locations for - * @return Map of {@link ShapeId} to its definition {@link Location} - */ - Map collectDefinitionLocations(Model model); -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/SmithyBuildLoader.java b/src/main/java/software/amazon/smithy/lsp/ext/SmithyBuildLoader.java deleted file mode 100644 index 2ad9234e..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/SmithyBuildLoader.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.nio.file.Path; -import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.loader.ModelSyntaxException; -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; - -public final class SmithyBuildLoader { - private SmithyBuildLoader() { - } - - /** - * Loads Smithy build definition from a json file. - * - * @param path json file with build definition - * @return loaded build definition - * @throws ValidationException if any errors are encountered - */ - public static SmithyBuildExtensions load(Path path) throws ValidationException { - try { - String content = IoUtils.readUtf8File(path); - return loadAndMerge(path, content); - } catch (ModelSyntaxException e) { - throw new ValidationException(e.toString()); - } - } - - static SmithyBuildExtensions load(Path path, String content) throws ValidationException { - try { - return loadAndMerge(path, content); - } catch (ModelSyntaxException e) { - throw new ValidationException(e.toString()); - } - } - - private static SmithyBuildExtensions loadAndMerge(Path path, String content) { - SmithyBuildExtensions config = loadExtension(loadWithJson(path, content).expectObjectNode()); - config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.load(path)); - return config; - } - - private static Node loadWithJson(Path path, String contents) { - return Node.parseJsonWithComments(contents, path.toString()); - } - - private static SmithyBuildExtensions loadExtension(ObjectNode node) { - NodeMapper mapper = new NodeMapper(); - return mapper.deserialize(node, SmithyBuildExtensions.class); - } - -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/SmithyCompletionItem.java b/src/main/java/software/amazon/smithy/lsp/ext/SmithyCompletionItem.java deleted file mode 100644 index 4b1a6db8..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/SmithyCompletionItem.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.util.Optional; -import org.eclipse.lsp4j.CompletionItem; -import software.amazon.smithy.utils.Pair; - -public class SmithyCompletionItem { - private final CompletionItem ci; - private Optional> smithyImport = Optional.empty(); - - public SmithyCompletionItem(CompletionItem ci) { - this.ci = ci; - } - - public SmithyCompletionItem(CompletionItem ci, String namespace, String id) { - this.ci = ci; - this.smithyImport = Optional.of(Pair.of(namespace, id)); - } - - public Optional> getImport() { - return smithyImport; - } - - public CompletionItem getCompletionItem() { - return ci; - } - - public Optional getQualifiedImport() { - return smithyImport.map(pair -> pair.left + "#" + pair.right); - } - - public Optional getImportNamespace() { - return smithyImport.map(f -> f.left); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java b/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java deleted file mode 100644 index 3f31d639..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.EnvironmentVariable; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.lsp.SmithyInterface; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.selector.Selector; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidatedResultException; - -public final class SmithyProject { - private static final MavenRepository CENTRAL = MavenRepository.builder() - .url("https://repo.maven.apache.org/maven2") - .build(); - private final List imports; - private final List smithyFiles; - private final List externalJars; - private Map locations = Collections.emptyMap(); - private final ValidatedResult model; - private final File root; - - SmithyProject( - List imports, - List smithyFiles, - List externalJars, - File root, - ValidatedResult model - ) { - this.imports = imports; - this.root = root; - this.model = model; - this.smithyFiles = smithyFiles; - this.externalJars = externalJars; - model.getResult().ifPresent(m -> this.locations = collectLocations(m)); - } - - /** - * Recompile the model, adding a file to list of tracked files, potentially - * excluding some other file. - *

- * This version of the method above is used when the - * file is in ephemeral storage (temporary location when file is being changed) - * - * @param changed file which may or may not be already tracked by this project. - * @param exclude file to exclude from being recompiled. - * @return either an error, or a loaded project. - */ - public Either recompile(File changed, File exclude) { - HashSet fileSet = new HashSet<>(); - - for (File existing : onlyExistingFiles(this.smithyFiles)) { - if (exclude != null && !existing.equals(exclude)) { - fileSet.add(existing); - } - } - - if (changed.isFile()) { - fileSet.add(changed); - } - - return load(this.imports, new ArrayList<>(fileSet), this.externalJars, this.root); - } - - public ValidatedResult getModel() { - return this.model; - } - - public List getExternalJars() { - return this.externalJars; - } - - public List getSmithyFiles() { - return this.smithyFiles; - } - - public List getCompletions(String token, boolean isTrait, Optional target) { - return this.model.getResult().map(model -> Completions.find(model, token, isTrait, target)) - .orElse(Collections.emptyList()); - } - - public Map getLocations() { - return this.locations; - } - - /** - * Load the project using a SmithyBuildExtensions configuration and workspace - * root. - * - * @param config configuration. - * @param root workspace root. - * @return either an error or a loaded project. - */ - public static Either load(SmithyBuildExtensions config, File root, - DependencyResolver resolver) { - List imports = config.getImports().stream().map(p -> Paths.get(root.getAbsolutePath(), p).normalize()) - .collect(Collectors.toList()); - - if (imports.isEmpty()) { - imports.add(root.toPath()); - } - - LspLog.println("Imports from config: " + imports + " will be resolved against root " + root); - - List smithyFiles = discoverSmithyFiles(imports, root); - LspLog.println("Discovered smithy files: " + smithyFiles); - - List externalJars = downloadExternalDependencies(config, resolver); - LspLog.println("Downloaded external jars: " + externalJars); - - return load(imports, smithyFiles, externalJars, root); - - } - - /** - * Run a selector expression against the loaded model in the workspace. - * @param expression the selector expression. - * @return list of locations of shapes that match expression. - */ - public Either> runSelector(String expression) { - try { - Selector selector = Selector.parse(expression); - Set shapes = selector.select(this.model.unwrap()); - return Either.forRight(shapes.stream() - .map(shape -> this.locations.get(shape.getId())) - .collect(Collectors.toList())); - } catch (ValidatedResultException e) { - return Either.forLeft(e); - } - } - - private static Either load( - List imports, - List smithyFiles, - List externalJars, - File root - ) { - Either> model = createModel(smithyFiles, externalJars); - - if (model.isLeft()) { - return Either.forLeft(model.getLeft()); - } else { - model.getRight().getValidationEvents().forEach(LspLog::println); - - try { - SmithyProject p = new SmithyProject(imports, smithyFiles, externalJars, root, model.getRight()); - return Either.forRight(p); - } catch (Exception e) { - return Either.forLeft(e); - } - } - } - - private static Either> createModel( - List discoveredFiles, - List externalJars - ) { - return SmithyInterface.readModel(discoveredFiles, externalJars); - } - - public File getRoot() { - return this.root; - } - - private static Map collectLocations(Model model) { - ShapeLocationCollector collector = new FileCachingCollector(); - return collector.collectDefinitionLocations(model); - } - - /** - * Returns the shapeId of the shape that corresponds to the file uri and position within the model. - * - * @param uri String uri of model file. - * @param position Cursor position within model file. - * @return ShapeId of corresponding shape defined at location. - */ - public Optional getShapeIdFromLocation(String uri, Position position) { - Comparator> rangeSize = Comparator.comparing(entry -> - entry.getValue().getRange().getEnd().getLine() - entry.getValue().getRange().getStart().getLine()); - return locations.entrySet().stream() - .filter(entry -> entry.getValue().getUri().endsWith(Paths.get(uri).toString())) - .filter(entry -> isPositionInRange(entry.getValue().getRange(), position)) - // Since the position is in each of the overlapping shapes, return the location with the smallest range. - .sorted(rangeSize) - .map(Map.Entry::getKey) - .findFirst(); - } - - private boolean isPositionInRange(Range range, Position position) { - if (range.getStart().getLine() > position.getLine()) { - return false; - } - if (range.getEnd().getLine() < position.getLine()) { - return false; - } - // For single-line ranges, make sure position is between start and end chars. - if (range.getStart().getLine() == position.getLine() - && range.getEnd().getLine() == position.getLine()) { - return (range.getStart().getCharacter() <= position.getCharacter() - && range.getEnd().getCharacter() >= position.getCharacter()); - } else if (range.getStart().getLine() == position.getLine()) { - return range.getStart().getCharacter() <= position.getCharacter(); - } else if (range.getEnd().getLine() == position.getLine()) { - return range.getEnd().getCharacter() >= position.getCharacter(); - } - return true; - } - - private static Boolean isValidSmithyFile(Path file) { - String fName = file.getFileName().toString(); - return fName.endsWith(Constants.SMITHY_EXTENSION); - } - - private static List walkSmithyFolder(Path path, File root) { - - try (Stream walk = Files.walk(path)) { - return walk.filter(Files::isRegularFile).filter(SmithyProject::isValidSmithyFile).map(Path::toFile) - .collect(Collectors.toList()); - } catch (IOException e) { - LspLog.println("Failed to walk import '" + path + "' from root " + root + ": " + e); - return new ArrayList<>(); - } - } - - private static List discoverSmithyFiles(List imports, File root) { - List smithyFiles = new ArrayList<>(); - - imports.forEach(path -> { - if (Files.isDirectory(path)) { - smithyFiles.addAll(walkSmithyFolder(path, root)); - } else if (isValidSmithyFile(path)) { - smithyFiles.add(path.resolve(root.toPath()).toFile()); - } - }); - return smithyFiles; - } - - private static List downloadExternalDependencies(SmithyBuildExtensions extensions, - DependencyResolver resolver) { - LspLog.println("Downloading external dependencies for " + extensions); - try { - addConfiguredMavenRepos(extensions, resolver); - extensions.getMavenConfig().getDependencies().forEach(resolver::addDependency); - - return resolver.resolve().stream() - .map(artifact -> artifact.getPath().toFile()).collect(Collectors.toList()); - } catch (Exception e) { - LspLog.println("Failed to download external jars for " + extensions + ": " + e); - return Collections.emptyList(); - } - } - - private static void addConfiguredMavenRepos(SmithyBuildExtensions extensions, DependencyResolver resolver) { - // Environment variables take precedence over config files. - String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get(); - if (envRepos != null) { - for (String repo : envRepos.split("\\|")) { - resolver.addRepository(MavenRepository.builder().url(repo.trim()).build()); - } - } - - Set configuredRepos = extensions.getMavenConfig().getRepositories(); - - if (!configuredRepos.isEmpty()) { - configuredRepos.forEach(resolver::addRepository); - } else if (envRepos == null) { - LspLog.println(String.format("maven.repositories is not defined in smithy-build.json and the %s " - + "environment variable is not set. Defaulting to Maven Central.", - EnvironmentVariable.SMITHY_MAVEN_REPOS)); - resolver.addRepository(CENTRAL); - } - } - - private static List onlyExistingFiles(Collection files) { - return files.stream().filter(File::isFile).collect(Collectors.toList()); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java b/src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java deleted file mode 100644 index 98e82441..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -/** - * General exception thrown during loading of Smithy build files. - */ -public class ValidationException extends Exception { - public ValidationException(String msg) { - super(msg); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java new file mode 100644 index 00000000..a099e3f3 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java @@ -0,0 +1,283 @@ +/* + * 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.function.BiConsumer; +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 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.RangeAdapter; +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; + +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; + + public CompletionHandler(Project project) { + this.project = project; + } + + /** + * @param params The request params + * @return A list of possible completions + */ + public List handle(CompletionParams params, CancelChecker cc) { + String uri = params.getTextDocument().getUri(); + SmithyFile smithyFile = project.getSmithyFile(uri); + if (smithyFile == null) { + return Collections.emptyList(); + } + + 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(); + } + + String token = smithyFile.getDocument().copyToken(position); + if (token == null || token.isEmpty()) { + return Collections.emptyList(); + } + String matchToken = token.toLowerCase(); + + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + if (!project.getModelResult().getResult().isPresent()) { + return Collections.emptyList(); + } + Model model = project.getModelResult().getResult().get(); + DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) + .determineContext(position); + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + return contextualShapes(model, context) + .filter(shape -> shape.getId().getName().toLowerCase().startsWith(matchToken)) + // TODO: Use mapMulti when we upgrade jdk>16 + .collect(ArrayList::new, completionsFactory(context, model, smithyFile), ArrayList::addAll); + } + + private static BiConsumer, Shape> completionsFactory( + DocumentPositionContext context, + Model model, + SmithyFile smithyFile + ) { + TraitBodyVisitor visitor = new TraitBodyVisitor(model); + return (acc, shape) -> { + String shapeName = 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(shapeName + "(" + traitBody + ")"); + addTextEdits(traitWithMembersItem, shape.getId(), smithyFile); + acc.add(traitWithMembersItem); + } + CompletionItem defaultCompletionItem; + if (shape.isStructureShape() && !shape.members().isEmpty()) { + defaultCompletionItem = createCompletion(shapeName + "()"); + } else { + defaultCompletionItem = createCompletion(shapeName); + } + addTextEdits(defaultCompletionItem, shape.getId(), smithyFile); + acc.add(defaultCompletionItem); + break; + case MEMBER_TARGET: + case MIXIN: + CompletionItem item = createCompletion(shapeName); + addTextEdits(item, shape.getId(), smithyFile); + acc.add(item); + break; + case SHAPE_DEF: + case OTHER: + default: + break; + } + }; + } + + private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, SmithyFile smithyFile) { + String importId = shapeId.toString(); + String importNamespace = shapeId.getNamespace(); + CharSequence currentNamespace = smithyFile.getNamespace(); + + 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 = "\n" + "use " + importId; + // We can only know where to put the import if there's already use statements, or a namespace + if (smithyFile.getDocumentImports().isPresent()) { + Range importsRange = smithyFile.getDocumentImports().get().importsRange(); + Range editRange = RangeAdapter.point(importsRange.getEnd()); + return new TextEdit(editRange, insertText); + } else if (smithyFile.getDocumentNamespace().isPresent()) { + Range namespaceStatementRange = smithyFile.getDocumentNamespace().get().statementRange(); + Range editRange = RangeAdapter.point(namespaceStatementRange.getEnd()); + return new TextEdit(editRange, insertText); + } + + return null; + } + + private Stream contextualShapes(Model model, DocumentPositionContext context) { + switch (context) { + case TRAIT: + return model.getShapesWithTrait(TraitDefinition.class).stream(); + case MEMBER_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.hasTrait(TraitDefinition.class)); + case MIXIN: + return model.getShapesWithTrait(MixinTrait.class).stream(); + case SHAPE_DEF: + case OTHER: + default: + return Stream.empty(); + } + } + + private static CompletionItem createCompletion(String label) { + CompletionItem completionItem = new CompletionItem(label); + completionItem.setKind(CompletionItemKind.Class); + 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) { + 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 new file mode 100644 index 00000000..81eab0d0 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java @@ -0,0 +1,89 @@ +/* + * 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.Set; +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.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.LocationAdapter; +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; +import software.amazon.smithy.model.validation.ValidatedResult; + +public final class DefinitionHandler { + private final Project project; + + public DefinitionHandler(Project project) { + this.project = project; + } + + /** + * @param params The request params + * @return A list of possible definition locations + */ + public List handle(DefinitionParams params) { + String uri = params.getTextDocument().getUri(); + SmithyFile smithyFile = project.getSmithyFile(uri); + if (smithyFile == null) { + return Collections.emptyList(); + } + + Position position = params.getPosition(); + String token = smithyFile.getDocument().copyId(position); + if (token == null) { + return Collections.emptyList(); + } + + ValidatedResult modelResult = project.getModelResult(); + if (!modelResult.getResult().isPresent()) { + return Collections.emptyList(); + } + + Model model = modelResult.getResult().get(); + Set imports = smithyFile.getImports(); + CharSequence namespace = smithyFile.getNamespace(); + DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) + .determineContext(position); + return contextualShapes(model, context) + .filter(shape -> Prelude.isPublicPreludeShape(shape) + || shape.getId().getNamespace().contentEquals(namespace) + || imports.contains(shape.getId().toString())) + .filter(shape -> shape.getId().getName().equals(token)) + .findFirst() + .map(Shape::getSourceLocation) + .map(LocationAdapter::fromSource) + .map(Collections::singletonList) + .orElse(Collections.emptyList()); + } + + private Stream contextualShapes(Model model, DocumentPositionContext context) { + switch (context) { + case TRAIT: + return model.getShapesWithTrait(TraitDefinition.class).stream(); + case MEMBER_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.hasTrait(TraitDefinition.class)); + case MIXIN: + return model.getShapesWithTrait(MixinTrait.class).stream(); + case SHAPE_DEF: + case OTHER: + default: + return model.shapes().filter(shape -> !shape.isMemberShape()); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java new file mode 100644 index 00000000..a416c8d9 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java @@ -0,0 +1,102 @@ +/* + * 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.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; +import org.eclipse.lsp4j.FileSystemWatcher; +import org.eclipse.lsp4j.Registration; +import org.eclipse.lsp4j.Unregistration; +import org.eclipse.lsp4j.WatchKind; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectConfigLoader; + +/** + * Handles computing the {@link Registration}s and {@link Unregistration}s for + * that tell the client which files and directories to watch for changes + * + *

The server needs to know when files are added or removed from the project's + * sources or imports. Instead of watching the client's file system, we tell the + * client to send us notifications when these events occur, so we can reload the + * project. + * + *

Clients don't de-duplicate file watchers, so we have to unregister all + * file watchers before sending a new list to watch, or keep track of them to make + * more granular changes. The current behavior is to just unregister and re-register + * everything, since these events should be rarer. But we can optimize it in the + * future. + */ +public final class FileWatcherRegistrationHandler { + 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"; + private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles"; + + private FileWatcherRegistrationHandler() { + } + + /** + * @return The registrations to watch for build file changes + */ + public static List getBuildFileWatcherRegistrations() { + List buildFileWatchers = new ArrayList<>(); + buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_BUILD))); + buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_PROJECT))); + for (String ext : ProjectConfigLoader.SMITHY_BUILD_EXTS) { + buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ext))); + } + + return Collections.singletonList(new Registration( + WATCH_BUILD_FILES_ID, + WATCH_FILES_METHOD, + new DidChangeWatchedFilesRegistrationOptions(buildFileWatchers))); + } + + /** + * @param project The Project to get registrations for + * @return The registrations to watch for Smithy file changes + */ + public static List getSmithyFileWatcherRegistrations(Project project) { + List smithyFileWatchers = Stream.concat(project.getSources().stream(), + project.getImports().stream()) + .map(FileWatcherRegistrationHandler::smithyFileWatcher) + .collect(Collectors.toList()); + + return Collections.singletonList(new Registration( + WATCH_SMITHY_FILES_ID, + WATCH_FILES_METHOD, + new DidChangeWatchedFilesRegistrationOptions(smithyFileWatchers))); + } + + /** + * @return The unregistrations to stop watching for Smithy file changes + */ + public static List getSmithyFileWatcherUnregistrations() { + return Collections.singletonList(new Unregistration( + WATCH_SMITHY_FILES_ID, + WATCH_FILES_METHOD)); + } + + private static FileSystemWatcher smithyFileWatcher(Path path) { + String glob = path.toString(); + if (!glob.endsWith(".smithy") && !glob.endsWith(".json")) { + // we have a directory + if (glob.endsWith("/")) { + glob = glob + "**"; + } else { + glob = glob + "/**"; + } + } + // Watch the absolute path, either a directory or file + return new FileSystemWatcher(Either.forLeft(glob), SMITHY_WATCH_FILE_KIND); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java new file mode 100644 index 00000000..734d4cf5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java @@ -0,0 +1,159 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.handler; + +import static java.util.regex.Matcher.quoteReplacement; + +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.Set; +import java.util.stream.Collectors; +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.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; + +public final class HoverHandler { + private final Project project; + + public HoverHandler(Project project) { + this.project = project; + } + + /** + * @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 = new Hover(); + hover.setContents(new MarkupContent("markdown", "")); + String uri = params.getTextDocument().getUri(); + SmithyFile smithyFile = project.getSmithyFile(uri); + if (smithyFile == null) { + return hover; + } + + Position position = params.getPosition(); + // TODO: Handle shape id + String token = smithyFile.getDocument().copyToken(position); + if (token == null) { + return hover; + } + + ValidatedResult modelResult = project.getModelResult(); + if (!modelResult.getResult().isPresent()) { + return hover; + } + + Model model = modelResult.getResult().get(); + Set imports = smithyFile.getImports(); + CharSequence namespace = smithyFile.getNamespace(); + DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) + .determineContext(position); + Optional matchingShape = contextualShapes(model, context) + .filter(shape -> Prelude.isPublicPreludeShape(shape) + || shape.getId().getNamespace().contentEquals(namespace) + || imports.contains(shape.getId().toString())) + .filter(shape -> shape.getId().getName().equals(token)) + .findFirst(); + + if (!matchingShape.isPresent()) { + 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) + .collect(Collectors.toList()); + if (!validationEvents.isEmpty()) { + for (ValidationEvent event : validationEvents) { + hoverContent.append("**") + .append(event.getSeverity()) + .append("**") + .append(": ") + .append(event.getMessage()); + } + hoverContent.append("\n\n---\n\n"); + } + + String serializedShape = serialized.get(path) + .substring(15) + .trim() + .replaceAll(quoteReplacement("\n\n"), "\n"); + int eol = serializedShape.indexOf('\n'); + String namespaceLine = serializedShape.substring(0, eol); + serializedShape = serializedShape.substring(eol + 1); + hoverContent.append(String.format("```smithy\n" + + "%s\n" + + "%s\n" + + "```\n", namespaceLine, 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 Stream contextualShapes(Model model, DocumentPositionContext context) { + switch (context) { + case TRAIT: + return model.getShapesWithTrait(TraitDefinition.class).stream(); + case MEMBER_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.hasTrait(TraitDefinition.class)); + case MIXIN: + return model.getShapesWithTrait(MixinTrait.class).stream(); + case SHAPE_DEF: + case OTHER: + default: + return model.shapes().filter(shape -> !shape.isMemberShape()); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/Project.java b/src/main/java/software/amazon/smithy/lsp/project/Project.java new file mode 100644 index 00000000..b64247f6 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -0,0 +1,283 @@ +/* + * 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.Path; +import java.util.ArrayList; +import java.util.HashMap; +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; +import java.util.stream.Collectors; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.UriAdapter; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.ModelAssembler; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.validation.ValidatedResult; + +/** + * A Smithy project open on the client. It keeps track of its Smithy files and + * dependencies, and the currently loaded model. + */ +public final class Project { + private static final Logger LOGGER = Logger.getLogger(Project.class.getName()); + private final Path root; + private final List sources; + private final List imports; + private final List dependencies; + private final Map smithyFiles; + private final Supplier assemblerFactory; + private ValidatedResult modelResult; + + private Project(Builder builder) { + this.root = Objects.requireNonNull(builder.root); + this.sources = builder.sources; + this.imports = builder.imports; + this.dependencies = builder.dependencies; + this.smithyFiles = builder.smithyFiles; + this.modelResult = builder.modelResult; + this.assemblerFactory = builder.assemblerFactory; + } + + /** + * Create an empty project with no Smithy files, dependencies, or loaded model. + * + * @param root Root path of the project + * @return The empty project + */ + public static Project empty(Path root) { + return builder() + .root(root) + .modelResult(ValidatedResult.empty()) + .build(); + } + + /** + * @return The path of the root directory of the project + */ + public Path getRoot() { + return root; + } + + /** + * @return The paths of all Smithy sources, exactly as they were specified + * in this project's smithy build configuration files + */ + public List getSources() { + return sources; + } + + /** + * @return The paths of all imports, exactly as they were specified in this + * project's smithy build configuration files + */ + public List getImports() { + return imports; + } + + /** + * @return The paths of all resolved dependencies + */ + public List getDependencies() { + return dependencies; + } + + /** + * @return A map of paths to the {@link SmithyFile} at that path, containing + * all smithy files loaded in the project. + */ + public Map getSmithyFiles() { + return this.smithyFiles; + } + + /** + * @return The latest result of loading this project + */ + public ValidatedResult getModelResult() { + return 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 = UriAdapter.toPath(uri); + SmithyFile smithyFile = smithyFiles.get(path); + if (smithyFile == null) { + return null; + } + return smithyFile.getDocument(); + } + + /** + * @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 = UriAdapter.toPath(uri); + return smithyFiles.get(path); + } + + /** + * Update this project's model without running validation. + * + * @param uri The URI of the Smithy file to update + */ + public void updateModelWithoutValidating(String uri) { + Document document = getDocument(uri); + updateModel(uri, document, false); + } + + /** + * Update this project's model and run validation. + * + * @param uri The URI of the Smithy file to update + */ + public void updateAndValidateModel(String uri) { + Document document = getDocument(uri); + updateModel(uri, document, true); + } + + // TODO: This is a little all over the place + /** + * Update the model with the contents of the given {@code document}, optionally + * running validation. + * + * @param uri The URI of the Smithy file to update + * @param document The {@link Document} with updated contents + * @param validate Whether to run validation + */ + public void updateModel(String uri, Document document, boolean validate) { + if (document == null || !modelResult.getResult().isPresent()) { + // TODO: At one point in testing, the server got stuck with a certain validation event + // always being present, and no other features working. I haven't been able to reproduce + // it, but I added these logs to check for it. + if (document == null) { + LOGGER.info("No document loaded for " + uri + ", skipping model load."); + } + if (!modelResult.getResult().isPresent()) { + LOGGER.info("No model loaded, skipping updating model with " + uri); + } + // 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 + // just compute the shapes later? + return; + } + + String path = UriAdapter.toPath(uri); + + SmithyFile previous = smithyFiles.get(path); + Model currentModel = modelResult.getResult().get(); // unwrap would throw if the model is broken + + Model.Builder builder = currentModel.toBuilder(); + for (Shape shape : previous.getShapes()) { + builder.removeShape(shape.getId()); + } + Model rest = builder.build(); + + ModelAssembler assembler = assemblerFactory.get() + .addModel(rest) + .addUnparsedModel(path, document.copyText()); + + if (!validate) { + assembler.disableValidation(); + } + + this.modelResult = assembler.assemble(); + + Set updatedShapes = modelResult.getResult() + .map(model -> model.shapes() + .filter(shape -> shape.getSourceLocation().getFilename().equals(path)) + .collect(Collectors.toSet())) + .orElse(previous.getShapes()); + + // TODO: Could cache validation events + SmithyFile updated = ProjectLoader.buildSmithyFile(path, document, updatedShapes).build(); + this.smithyFiles.put(path, updated); + } + + static Builder builder() { + return new Builder(); + } + + static final class Builder { + private Path root; + private final List sources = new ArrayList<>(); + private final List imports = new ArrayList<>(); + private final List dependencies = new ArrayList<>(); + private final Map smithyFiles = new HashMap<>(); + private ValidatedResult modelResult; + private Supplier assemblerFactory = Model::assembler; + + private Builder() { + } + + public Builder root(Path root) { + this.root = root; + return this; + } + + public Builder sources(List paths) { + this.sources.clear(); + this.sources.addAll(paths); + return this; + } + + public Builder addSource(Path path) { + this.sources.add(path); + return this; + } + + public Builder imports(List paths) { + this.imports.clear(); + this.imports.addAll(paths); + return this; + } + + public Builder addImport(Path path) { + this.imports.add(path); + 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 Project build() { + return new Project(this); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java new file mode 100644 index 00000000..f9d03c4e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -0,0 +1,123 @@ +/* + * 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.List; +import java.util.Optional; +import software.amazon.smithy.build.model.MavenConfig; + +/** + * A complete view of all a project's configuration that is needed to load it, + * merged from all configuration sources. + */ +final class ProjectConfig { + private final List sources; + private final List imports; + private final String outputDirectory; + private final List dependencies; + private final MavenConfig mavenConfig; + + private ProjectConfig(Builder builder) { + this.sources = builder.sources; + this.imports = builder.imports; + this.outputDirectory = builder.outputDirectory; + this.dependencies = builder.dependencies; + this.mavenConfig = builder.mavenConfig; + } + + static Builder builder() { + return new Builder(); + } + + /** + * @return All explicitly configured sources + */ + public List getSources() { + return sources; + } + + /** + * @return All explicitly configured imports + */ + public List getImports() { + return imports; + } + + /** + * @return The configured output directory, if one is present + */ + public Optional getOutputDirectory() { + return Optional.ofNullable(outputDirectory); + } + + /** + * @return All configured external (non-maven) dependencies + */ + public List getDependencies() { + return dependencies; + } + + /** + * @return The Maven configuration, if present + */ + public Optional getMaven() { + return Optional.ofNullable(mavenConfig); + } + + static final class Builder { + final List sources = new ArrayList<>(); + final List imports = new ArrayList<>(); + String outputDirectory; + final List dependencies = new ArrayList<>(); + MavenConfig mavenConfig; + + private Builder() { + } + + 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 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 new file mode 100644 index 00000000..ad86747e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -0,0 +1,163 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.util.Result; +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.model.node.StringNode; +import software.amazon.smithy.utils.IoUtils; + +/** + * 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. + *
+ */ +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_PROJECT = ".smithy-project.json"; + + private static final Logger LOGGER = Logger.getLogger(ProjectConfigLoader.class.getName()); + private static final Supplier DEFAULT_SMITHY_BUILD = () -> + SmithyBuildConfig.builder().version("1").build(); + private static final NodeMapper NODE_MAPPER = new NodeMapper(); + + private ProjectConfigLoader() { + } + + static Result> loadFromRoot(Path workspaceRoot) { + 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.getErr().ifPresent(exceptions::add); + } else { + LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); + builder.merge(DEFAULT_SMITHY_BUILD.get()); + } + + SmithyBuildExtensions.Builder extensionsBuilder = SmithyBuildExtensions.builder(); + for (String ext : SMITHY_BUILD_EXTS) { + Path extPath = workspaceRoot.resolve(ext); + if (Files.isRegularFile(extPath)) { + Result result = Result.ofFallible(() -> + loadSmithyBuildExtensions(extPath)); + 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(() -> { + String json = IoUtils.readUtf8File(smithyProjectPath); + Node node = Node.parseJsonWithComments(json, smithyProjectPath.toString()); + 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; + }); + if (result.isOk()) { + finalConfigBuilder = result.unwrap(); + } else { + exceptions.add(result.unwrapErr()); + } + } + + if (!exceptions.isEmpty()) { + return Result.err(exceptions); + } + + 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); + } + return Result.ok(finalConfigBuilder.build()); + } + + private static SmithyBuildExtensions loadSmithyBuildExtensions(Path path) { + // 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(); + SmithyBuildExtensions config = NODE_MAPPER.deserialize(node, SmithyBuildExtensions.class); + config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.load(path)); + return config; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java new file mode 100644 index 00000000..3b156205 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java @@ -0,0 +1,44 @@ +/* + * 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. + */ +final class ProjectDependency { + private final String name; + private final String path; + + private ProjectDependency(String name, String path) { + this.name = name; + this.path = 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); + } + + /** + * @return The name of the dependency + */ + public String getName() { + return name; + } + + /** + * @return The path of the dependency + */ + public String getPath() { + return 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 new file mode 100644 index 00000000..a0bd7368 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java @@ -0,0 +1,117 @@ +/* + * 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. + */ +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.getDependencies().forEach((projectDependency) -> { + // TODO: Not sure if this needs to check for existence + Path path = root.resolve(projectDependency.getPath()); + deps.add(path); + }); + return deps; + }); + } + + // Taken (roughly) from smithy-cli ClasspathAction::resolveDependencies + private static DependencyResolver create(ProjectConfig config) { + // DependencyResolver delegate = new MavenDependencyResolver(EnvironmentVariable.SMITHY_MAVEN_CACHE.get()); + // long lastModified = config.getLastModifiedInMillis(); + // DependencyResolver resolver = new FileCacheResolver(getCacheFile(config), lastModified, delegate); + + // 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.getMaven().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.getOutputDirectory() + .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.getMaven() + .map(MavenConfig::getRepositories) + .orElse(Collections.emptySet()); + + if (!configuredRepos.isEmpty()) { + repositories.addAll(configuredRepos); + } else if (envRepos == null) { +// LOGGER.finest(() -> String.format("maven.repositories is not defined in `smithy-build.json` and the %s " +// + "environment variable is not set. Defaulting to Maven Central.", +// EnvironmentVariable.SMITHY_MAVEN_REPOS)); + repositories.add(CENTRAL.get()); + } + return repositories; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java new file mode 100644 index 00000000..8b92ddb6 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -0,0 +1,291 @@ +/* + * 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.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.Collectors; +import java.util.stream.Stream; +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.DocumentParser; +import software.amazon.smithy.lsp.document.DocumentShape; +import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.lsp.protocol.UriAdapter; +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.shapes.Shape; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.utils.IoUtils; + +/** + * Loads {@link Project}s. + */ +public final class ProjectLoader { + private static final Logger LOGGER = Logger.getLogger(ProjectLoader.class.getName()); + + private ProjectLoader() { + } + + /** + * Loads a {@link Project} from a given root path. + * + *

This will return a failed result if loading the project config, resolving + * the dependencies, or creating the model assembler fail. + * + *

The build configuration files are the single source of truth for what will + * be loaded. Previous behavior in the language server was to walk all subdirs of + * the root and find all the .smithy files, but this made it challenging to + * reason about how the project was structured. + * + * @param root Path of the project root + * @return Result of loading the project + */ + public static Result> load(Path root) { + Result> configResult = ProjectConfigLoader.loadFromRoot(root); + if (configResult.isErr()) { + return Result.err(configResult.unwrapErr()); + } + ProjectConfig config = configResult.unwrap(); + + Result, Exception> resolveResult = ProjectDependencyResolver.resolveDependencies(root, config); + if (resolveResult.isErr()) { + return Result.err(Collections.singletonList(resolveResult.unwrapErr())); + } + + List dependencies = resolveResult.unwrap(); + + // TODO: We need some default behavior for when no project files are specified, like running in + // 'detached' mode or something + List sources = config.getSources().stream().map(root::resolve).collect(Collectors.toList()); + List imports = config.getImports().stream().map(root::resolve).collect(Collectors.toList()); + + // 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 = assemblerFactoryResult.unwrap(); + ModelAssembler assembler = assemblerFactory.get(); + + Result, Exception> loadModelResult = Result.ofFallible(() -> + loadModel(assembler, sources, imports)); + // 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 the errors somehow. For now, just fail + if (loadModelResult.isErr()) { + return Result.err(Collections.singletonList(loadModelResult.unwrapErr())); + } + + ValidatedResult modelResult = loadModelResult.unwrap(); + + Project.Builder projectBuilder = Project.builder() + .root(root) + .sources(sources) + .imports(imports) + .dependencies(dependencies) + .modelResult(modelResult) + .assemblerFactory(assemblerFactory); + + Map> shapes; + if (modelResult.getResult().isPresent()) { + Model model = modelResult.getResult().get(); + shapes = model.shapes().collect(Collectors.groupingByConcurrent( + shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); + } else { + shapes = new HashMap<>(0); + } + + // There may be smithy files part of the project that aren't part of the model + List allSmithyFilePaths = collectAllSmithyPaths(root, config.getSources(), config.getImports()); + for (Path path : allSmithyFilePaths) { + if (!shapes.containsKey(path.toString())) { + shapes.put(path.toString(), Collections.emptySet()); + } + } + + Map smithyFiles = new HashMap<>(shapes.size()); + for (Map.Entry> entry : shapes.entrySet()) { + String path = entry.getKey(); + Document document; + if (UriAdapter.isSmithyJarFile(path) || UriAdapter.isJarFile(path)) { + // Technically this can throw + document = Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(path))); + } else { + // There may be a more efficient way of reading this + document = Document.of(IoUtils.readUtf8File(path)); + } + Set fileShapes = entry.getValue(); + SmithyFile smithyFile = buildSmithyFile(path, document, fileShapes).build(); + smithyFiles.put(path, smithyFile); + } + projectBuilder.smithyFiles(smithyFiles); + + return Result.ok(projectBuilder.build()); + } + + /** + * 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); + } + + private static Result, Exception> 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. + + // 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)); + } + + 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); + }); + } + + private static ValidatedResult loadModel(ModelAssembler assembler, List sources, List imports) { + for (Path path : sources) { + assembler.addImport(path); + } + for (Path path : imports) { + assembler.addImport(path); + } + + return assembler.assemble(); + } + + private static Result createDependenciesClassLoader(List dependencies) { + // 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); + } + } + + // 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); + collectDirectory(paths, root, path); + } + for (String file : imports) { + Path path = root.resolve(file); + 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) throws IOException { + 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/ext/model/SmithyBuildExtensions.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java similarity index 93% rename from src/main/java/software/amazon/smithy/lsp/ext/model/SmithyBuildExtensions.java rename to src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java index e75ad1aa..536301d3 100644 --- a/src/main/java/software/amazon/smithy/lsp/ext/model/SmithyBuildExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -package software.amazon.smithy.lsp.ext.model; +package software.amazon.smithy.lsp.project; import java.util.ArrayList; import java.util.Collection; @@ -27,6 +27,10 @@ import software.amazon.smithy.utils.SmithyBuilder; import software.amazon.smithy.utils.ToSmithyBuilder; +/** + * Legacy build config that supports a subset of {@link SmithyBuildConfig}, in addition to + * top-level {@code mavenRepositories} and {@code mavenDependencies} properties. + */ public final class SmithyBuildExtensions implements ToSmithyBuilder { private final List imports; private final List mavenRepositories; @@ -76,6 +80,18 @@ public void mergeMavenFromSmithyBuildConfig(SmithyBuildConfig config) { } } + /** + * @return This as {@link SmithyBuildConfig} + */ + public SmithyBuildConfig asSmithyBuildConfig() { + return SmithyBuildConfig.builder() + .version("1") + .imports(getImports()) + .maven(getMavenConfig()) + .lastModifiedInMillis(getLastModifiedInMillis()) + .build(); + } + public static final class Builder implements SmithyBuilder { private final List mavenRepositories = new ArrayList<>(); private final List mavenDependencies = new ArrayList<>(); @@ -124,12 +140,12 @@ public Builder merge(SmithyBuildExtensions other) { } /** - * @deprecated Use {@link MavenConfig.Builder#repositories(Collection)} - * * Adds resolvers to the builder. * * @param mavenRepositories list of maven-compatible repositories * @return builder + * + * @deprecated Use {@link MavenConfig.Builder#repositories(Collection)} */ @Deprecated public Builder mavenRepositories(Collection mavenRepositories) { @@ -150,12 +166,12 @@ public Builder mavenRepositories(Collection mavenRepositories) { } /** - * @deprecated use {@link MavenConfig.Builder#dependencies(Collection)} - * * Adds dependencies to the builder. * * @param mavenDependencies list of artifacts in the org:name:version format * @return builder + * + * @deprecated use {@link MavenConfig.Builder#dependencies(Collection)} */ @Deprecated public Builder mavenDependencies(Collection mavenDependencies) { diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java new file mode 100644 index 00000000..ac748d17 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -0,0 +1,196 @@ +/* + * 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.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; + +/** + * 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 { + private final String path; + private final Document document; + private final 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; + } + + /** + * @return The path of this Smithy file + */ + public String getPath() { + return path; + } + + /** + * @return The {@link Document} backing this Smithy file + */ + public Document getDocument() { + return document; + } + + /** + * @return The Shapes defined in this Smithy file + */ + public Set getShapes() { + return shapes; + } + + /** + * @return This Smithy file's imports, if they exist + */ + public Optional getDocumentImports() { + return Optional.ofNullable(this.imports); + } + + /** + * @return The ids of shapes imported into this Smithy file + */ + public Set getImports() { + return getDocumentImports() + .map(DocumentImports::imports) + .orElse(Collections.emptySet()); + } + + /** + * @return This Smithy file's namespace, if one exists + */ + public Optional getDocumentNamespace() { + return Optional.ofNullable(namespace); + } + + /** + * @return The shapes in this Smithy file, including referenced shapes + */ + public Collection getDocumentShapes() { + 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 getDocumentShapesByStartPosition() { + if (documentShapes == null) { + return Collections.emptyMap(); + } + return documentShapes; + } + + /** + * @return The string literal namespace of this Smithy file, or an empty string + */ + public CharSequence getNamespace() { + return getDocumentNamespace() + .map(DocumentNamespace::namespace) + .orElse(""); + } + + /** + * @return This Smithy file's version, if it exists + */ + public Optional getDocumentVersion() { + return Optional.ofNullable(documentVersion); + } + + /** + * @param shapeId The shape id to check + * @return Whether {@code shapeId} is in this SmithyFile's imports + */ + public boolean hasImport(String shapeId) { + if (imports == null) { + 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); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java new file mode 100644 index 00000000..38658fd5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java @@ -0,0 +1,30 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.model.SourceLocation; + +/** + * Utility methods for working with LSP's {@link Location}. + */ +public final class LocationAdapter { + private LocationAdapter() { + } + + /** + * 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 + * @return The equivalent Location + */ + public static Location fromSource(SourceLocation sourceLocation) { + return new Location(UriAdapter.toUri(sourceLocation.getFilename()), RangeAdapter.point( + new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java new file mode 100644 index 00000000..41b0e354 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java @@ -0,0 +1,28 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.model.SourceLocation; + +/** + * Utility methods for working with LSP's {@link Position}. + */ +public final class PositionAdapter { + private PositionAdapter() { + } + + /** + * Get a {@link Position} from a {@link SourceLocation}, making the line/columns + * 0-indexed. + * + * @param sourceLocation The source location to get the position of + * @return The position + */ + public static Position fromSourceLocation(SourceLocation sourceLocation) { + return new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java new file mode 100644 index 00000000..34da5348 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java @@ -0,0 +1,178 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +/** + * Builder and utility methods for working with LSP's {@link Range}. + */ +public final class RangeAdapter { + private int startLine; + private int startCharacter; + private int endLine; + private int endCharacter; + + /** + * @return Range of (0, 0) - (0, 0) + */ + public static Range origin() { + return new RangeAdapter() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(0) + .build(); + } + + /** + * @param point Position to create a point range of + * @return Range of (point) - (point) + */ + public static Range point(Position point) { + return new Range(point, point); + } + + /** + * @param line Line of the point + * @param character Character offset on the line + * @return Range of (line, character) - (line, character) + */ + public static Range point(int line, int character) { + return point(new Position(line, character)); + } + + /** + * @param line Line the span is on + * @param startCharacter Start character of the span + * @param endCharacter End character of the span + * @return Range of (line, startCharacter) - (line, endCharacter) + */ + public static Range lineSpan(int line, int startCharacter, int endCharacter) { + return of(line, startCharacter, line, endCharacter); + } + + /** + * @param offset Offset from (0, 0) + * @return Range of (0, 0) - (offset) + */ + public static Range offset(Position offset) { + return new RangeAdapter() + .startLine(0) + .startCharacter(0) + .endLine(offset.getLine()) + .endCharacter(offset.getCharacter()) + .build(); + } + + /** + * @param offset Offset from (offset.line, 0) + * @return Range of (offset.line, 0) - (offset) + */ + public static Range lineOffset(Position offset) { + return new RangeAdapter() + .startLine(offset.getLine()) + .startCharacter(0) + .endLine(offset.getLine()) + .endCharacter(offset.getCharacter()) + .build(); + } + + /** + * @param startLine Range start line + * @param startCharacter Range start character + * @param endLine Range end line + * @param endCharacter Range end character + * @return Range of (startLine, startCharacter) - (endLine, endCharacter) + */ + public static Range of(int startLine, int startCharacter, int endLine, int endCharacter) { + return new RangeAdapter() + .startLine(startLine) + .startCharacter(startCharacter) + .endLine(endLine) + .endCharacter(endCharacter) + .build(); + } + + /** + * @return This range adapter, with the start/end characters incremented by one + */ + public RangeAdapter shiftRight() { + return this.shiftRight(1); + } + + /** + * @param offset Offset to shift + * @return This range adapter, with the start/end characters incremented by {@code offset} + */ + public RangeAdapter shiftRight(int offset) { + this.startCharacter += offset; + this.endCharacter += offset; + + return this; + } + + /** + * @return This range adapter, with start/end lines incremented by one, and the start/end + * characters span shifted to begin at 0 + */ + public RangeAdapter shiftNewLine() { + this.startLine = this.startLine + 1; + this.endLine = this.endLine + 1; + + int charDiff = this.endCharacter - this.startCharacter; + this.startCharacter = 0; + this.endCharacter = charDiff; + + return this; + } + + /** + * @param startLine The start line for the range + * @return The updated range adapter + */ + public RangeAdapter startLine(int startLine) { + this.startLine = startLine; + return this; + } + + /** + * @param startCharacter The start character for the range + * @return The updated range adapter + */ + public RangeAdapter startCharacter(int startCharacter) { + this.startCharacter = startCharacter; + return this; + } + + /** + * @param endLine The end line for the range + * @return The updated range adapter + */ + public RangeAdapter endLine(int endLine) { + this.endLine = endLine; + return this; + } + + /** + * @param endCharacter The end character for the range + * @return The updated range adapter + */ + public RangeAdapter endCharacter(int endCharacter) { + this.endCharacter = endCharacter; + return this; + } + + /** + * @return The built Range + */ + public Range build() { + return new Range( + new Position(startLine, startCharacter), + new Position(endLine, endCharacter)); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java new file mode 100644 index 00000000..bf2b53d3 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.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.protocol; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.logging.Logger; + +/** + * Utility methods for working with LSP's URI (which is just a string). + */ +public final class UriAdapter { + private static final Logger LOGGER = Logger.getLogger(UriAdapter.class.getName()); + + private UriAdapter() { + } + + /** + * @param uri LSP URI to convert to a path + * @return A path representation of the {@code uri}, with the scheme removed + */ + public static String toPath(String uri) { + if (uri.startsWith("file://")) { + return uri.replaceFirst("file://", ""); + } else if (isSmithyJarFile(uri)) { + String decoded = decode(uri); + return fixJarScheme(decoded); + } + return uri; + } + + /** + * @param path Path to convert to LSP URI + * @return A URI representation of the given {@code path}, modified to have the + * correct scheme for jars + */ + public static String toUri(String path) { + if (path.startsWith("/")) { + return "file://" + path; + } else if (path.startsWith("jar:file")) { + return path.replaceFirst("jar:file", "smithyjar"); + } else { + return path; + } + } + + /** + * Checks if a given LSP URI is a file in a Smithy jar, which is a Smithy + * Language Server specific file scheme (smithyjar:) used for providing + * contents of Smithy files within Jars. + * + * @param uri LSP URI to check + * @return Returns whether the uri points to a smithy file in a jar + */ + public static boolean isSmithyJarFile(String uri) { + return uri.startsWith("smithyjar:"); + } + + /** + * @param uri LSP URI to check + * @return Returns whether the uri points to a file in jar + */ + public static boolean isJarFile(String uri) { + return uri.startsWith("jar:"); + } + + /** + * Get a {@link URL} for the Jar represented by the given URI or path. + * + * @param uriOrPath LSP URI or regular path + * @return The {@link URL}, or throw if the uri/path cannot be decoded + */ + public static URL jarUrl(String uriOrPath) { + try { + String decodedUri = decode(uriOrPath); + return URI.create(fixJarScheme(decodedUri)).toURL(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String decode(String uriOrPath) { + try { + // Some clients encode parts of the jar, like !/ + return URLDecoder.decode(uriOrPath, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + LOGGER.severe("Failed to decode " + uriOrPath + " : " + e.getMessage()); + return uriOrPath; + } + } + + private static String fixJarScheme(String uriOrPath) { + if (uriOrPath.startsWith("smithyjar:")) { + uriOrPath = uriOrPath.replaceFirst("smithyjar:", ""); + } + if (uriOrPath.startsWith("jar:")) { + return uriOrPath; + } else if (uriOrPath.startsWith("file:")) { + return "jar:" + uriOrPath; + } else { + return "jar:file:" + uriOrPath; + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/util/Result.java b/src/main/java/software/amazon/smithy/lsp/util/Result.java new file mode 100644 index 00000000..631919ee --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/util/Result.java @@ -0,0 +1,151 @@ +/* + * 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().isPresent()) { + 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().isPresent()) { + 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()); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java b/src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java new file mode 100644 index 00000000..030b0bab --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.util; + +/** + * 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/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java new file mode 100644 index 00000000..a46bc9e5 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -0,0 +1,75 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import software.amazon.smithy.lsp.document.Document; + +public final class LspMatchers { + private LspMatchers() {} + + public static Matcher hasLabel(String label) { + return new CustomTypeSafeMatcher("a completion item with the right label") { + @Override + protected boolean matchesSafely(CompletionItem item) { + return item.getLabel().equals(label); + } + + @Override + public void describeMismatchSafely(CompletionItem item, Description description) { + description.appendText("Expected completion item with label '" + + label + "' but was '" + item.getLabel() + "'"); + } + }; + } + + public static Matcher makesEditedDocument(Document document, String expected) { + return new CustomTypeSafeMatcher("the right edit") { + @Override + protected boolean matchesSafely(TextEdit item) { + Document copy = document.copy(); + copy.applyEdit(item.getRange(), item.getNewText()); + return copy.copyText().equals(expected); + } + + @Override + public void describeMismatchSafely(TextEdit textEdit, Description description) { + Document copy = document.copy(); + copy.applyEdit(textEdit.getRange(), textEdit.getNewText()); + String actual = copy.copyText(); + description.appendText("expected:\n'" + expected + "'\nbut was: \n'" + actual + "'\n"); + } + }; + } + + public static Matcher hasText(Document document, Matcher expected) { + return new CustomTypeSafeMatcher("text in range") { + @Override + protected boolean matchesSafely(Range item) { + CharSequence borrowed = document.borrowRange(item); + if (borrowed == null) { + return false; + } + return expected.matches(borrowed.toString()); + } + + @Override + public void describeMismatchSafely(Range range, Description description) { + if (document.borrowRange(range) == null) { + description.appendText("text was null"); + } else { + description.appendDescriptionOf(expected) + .appendText("was " + document.borrowRange(range).toString()); + } + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java new file mode 100644 index 00000000..79b3506a --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -0,0 +1,231 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.eclipse.lsp4j.ClientCapabilities; +import org.eclipse.lsp4j.CompletionContext; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.CompletionTriggerKind; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; +import software.amazon.smithy.utils.IoUtils; + +/** + * Contains builder classes for LSP requests/notifications used for testing + */ +public final class RequestBuilders { + private RequestBuilders() {} + + public static DidChange didChange() { + return new DidChange(); + } + + public static DidOpen didOpen() { + return new DidOpen(); + } + + public static DidSave didSave() { + return new DidSave(); + } + + public static DidClose didClose() { + return new DidClose(); + } + + public static Initialize initialize() { + return new Initialize(); + } + + public static PositionRequest positionRequest() { + return new PositionRequest(); + } + + public static final class DidChange { + public String uri; + public Integer version; + public Range range; + public String text; + + public DidChange next() { + this.version += 1; + return this; + } + + public DidChange uri(String uri) { + this.uri = uri; + return this; + } + + public DidChange version(Integer version) { + this.version = version; + return this; + } + + public DidChange range(Range range) { + this.range = range; + return this; + } + + public DidChange text(String text) { + this.text = text; + return this; + } + + public DidChangeTextDocumentParams build() { + VersionedTextDocumentIdentifier id = new VersionedTextDocumentIdentifier(uri, version); + TextDocumentContentChangeEvent change; + if (range != null) { + change = new TextDocumentContentChangeEvent(range, text); + } else { + change = new TextDocumentContentChangeEvent(text); + } + return new DidChangeTextDocumentParams(id, Collections.singletonList(change)); + } + + } + + public static final class Initialize { + public List workspaceFolders = new ArrayList<>(); + public Object initializationOptions; + + public Initialize workspaceFolder(String uri, String name) { + this.workspaceFolders.add(new WorkspaceFolder(uri, name)); + return this; + } + + public Initialize initializationOptions(Object object) { + this.initializationOptions = object; + return this; + } + + public InitializeParams build() { + InitializeParams params = new InitializeParams(); + params.setCapabilities(new ClientCapabilities()); // non-null + params.setWorkspaceFolders(workspaceFolders); + if (initializationOptions != null) { + params.setInitializationOptions(initializationOptions); + } + return params; + } + } + + public static final class DidClose { + public String uri; + + public DidClose uri(String uri) { + this.uri = uri; + return this; + } + + public DidCloseTextDocumentParams build() { + return new DidCloseTextDocumentParams(new TextDocumentIdentifier(uri)); + } + } + + public static final class DidOpen { + public String uri; + public String languageId = "smithy"; + public int version = 1; + public String text; + + public DidOpen uri(String uri) { + this.uri = uri; + return this; + } + + public DidOpen languageId(String languageId) { + this.languageId = languageId; + return this; + } + + public DidOpen version(int version) { + this.version = version; + return this; + } + + public DidOpen text(String text) { + this.text = text; + return this; + } + + public DidOpenTextDocumentParams build() { + if (text == null) { + text = IoUtils.readUtf8File(URI.create(uri).getPath()); + } + return new DidOpenTextDocumentParams(new TextDocumentItem(uri, languageId, version, text)); + } + } + + public static final class DidSave { + String uri; + + public DidSave uri(String uri) { + this.uri = uri; + return this; + } + + public DidSaveTextDocumentParams build() { + return new DidSaveTextDocumentParams(new TextDocumentIdentifier(uri)); + } + } + + public static final class PositionRequest { + String uri; + int line; + int character; + + public PositionRequest uri(String uri) { + this.uri = uri; + return this; + } + + public PositionRequest line(int line) { + this.line = line; + return this; + } + + public PositionRequest character(int character) { + this.character = character; + return this; + } + + public PositionRequest position(Position position) { + this.line = position.getLine(); + this.character = position.getCharacter(); + return this; + } + + public HoverParams buildHover() { + return new HoverParams(new TextDocumentIdentifier(uri), new Position(line, character)); + } + + public DefinitionParams buildDefinition() { + return new DefinitionParams(new TextDocumentIdentifier(uri), new Position(line, character)); + } + + public CompletionParams buildCompletion() { + return new CompletionParams( + new TextDocumentIdentifier(uri), + new Position(line, character), + new CompletionContext(CompletionTriggerKind.Invoked)); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java deleted file mode 100644 index 77fd2945..00000000 --- a/src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertFalse; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.junit.Test; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidationEvent; - -public class SmithyInterfaceTest { - private static final String baseDirName = "external-jars"; - private static final String testTraitsModelFilename = "test-traits.smithy"; - private static final String testTraitsDependencyFilename = "smithy-test-traits.jar"; - private static final ShapeId testTraitShapeId = ShapeId.from("smithy.test#test"); - - @Test - public void loadModelWithDependencies() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List externalJars = getFiles(testTraitsDependencyFilename); - - Either> result = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(result.isRight()); - assertTrue(result.getRight().getValidationEvents().isEmpty()); - Model model = result.getRight().getResult().get(); - assertTrue(model.getShape(testTraitShapeId).isPresent()); - } - - @Test - public void reloadingModelWithDependencies() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List externalJars = getFiles(testTraitsDependencyFilename); - - Either> result = SmithyInterface.readModel(modelFiles, externalJars); - Either> resultTwo = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(result.isRight()); - assertTrue(result.getRight().getValidationEvents().isEmpty()); - assertTrue(resultTwo.isRight()); - assertTrue(resultTwo.getRight().getValidationEvents().isEmpty()); - } - - @Test - public void addingDependency() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List noExternalJars = new ArrayList<>(); - List externalJars = getFiles(testTraitsDependencyFilename); - - Either> noDependency = SmithyInterface.readModel(modelFiles, noExternalJars); - Either> withDependency = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(noDependency.isRight()); - assertTrue(withDependency.isRight()); - Model modelWithDependency = withDependency.getRight().getResult().get(); - assertTrue(modelWithDependency.getShape(testTraitShapeId).isPresent()); - } - - @Test - public void removingDependency() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List externalJars = getFiles(testTraitsDependencyFilename); - List noExternalJars = new ArrayList<>(); - - Either> withDependency = SmithyInterface.readModel(modelFiles, externalJars); - Either> noDependency = SmithyInterface.readModel(modelFiles, noExternalJars); - - assertTrue(withDependency.isRight()); - assertTrue(noDependency.isRight()); - Model modelWithoutDependency = noDependency.getRight().getResult().get(); - assertFalse(modelWithoutDependency.getShape(testTraitShapeId).isPresent()); - } - - @Test - public void runValidators() throws Exception { - List modelFiles = getFiles("test-validators.smithy"); - List externalJars = getFiles("alloy-core.jar"); - Either> result = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(result.isRight()); - List validationEvents = result.getRight().getValidationEvents(); - assertFalse(validationEvents.isEmpty()); - - String expectedMessage = "Proto index 1 is used muliple times in members name," + - "age of shape (structure: `some.test#MyStruct`)."; - Optional matchingEvent = validationEvents.stream() - .filter(ev ->ev.getMessage().equals(expectedMessage)).findFirst(); - - if (!matchingEvent.isPresent()) { - throw new AssertionError("Expected validation event with message `" + expectedMessage - + "`, but events were " + validationEvents); - } - } - - private static List getFiles(String... filenames) throws Exception { - Path baseDir = Paths.get(SmithyInterface.class.getResource(SmithyInterfaceTest.baseDirName).toURI()); - return Arrays.stream(filenames).map(baseDir::resolve).map(Path::toFile).collect(Collectors.toList()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index c3e1f727..fe97c6f8 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1,78 +1,877 @@ package software.amazon.smithy.lsp; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static software.amazon.smithy.lsp.LspMatchers.hasLabel; +import static software.amazon.smithy.lsp.LspMatchers.hasText; +import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument; +import static software.amazon.smithy.lsp.SmithyMatchers.hasMessage; import com.google.gson.JsonObject; -import java.io.File; -import java.nio.file.Files; - -import org.eclipse.lsp4j.CodeActionOptions; -import org.eclipse.lsp4j.CompletionOptions; -import org.eclipse.lsp4j.InitializeParams; -import org.eclipse.lsp4j.InitializeResult; -import org.eclipse.lsp4j.ServerCapabilities; -import org.eclipse.lsp4j.TextDocumentSyncKind; -import org.eclipse.lsp4j.WorkspaceFolder; -import org.junit.FixMethodOrder; -import org.junit.Test; -import org.junit.runners.MethodSorters; -import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; -import software.amazon.smithy.utils.ListUtils; - -@FixMethodOrder(MethodSorters.NAME_ASCENDING) +import com.google.gson.JsonPrimitive; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +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.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.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.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.RangeAdapter; + public class SmithyLanguageServerTest { @Test - public void initializeServer() throws Exception { - InitializeParams initParams = new InitializeParams(); - File temp = Files.createTempDirectory("smithy-lsp-test").toFile(); - temp.deleteOnExit(); - initParams.setWorkspaceFolders(ListUtils.of(new WorkspaceFolder(temp.toURI().toString()))); - SmithyLanguageServer languageServer = new SmithyLanguageServer(); - InitializeResult initResults = languageServer.initialize(initParams).get(); - ServerCapabilities capabilities = initResults.getCapabilities(); - File lspLog = new File(temp + "/.smithy.lsp.log"); - - assertNull(languageServer.tempWorkspaceRoot); - assertEquals(TextDocumentSyncKind.Full, capabilities.getTextDocumentSync().getLeft()); - assertEquals(new CodeActionOptions(SmithyCodeActions.all()), capabilities.getCodeActionProvider().getRight()); - assertTrue(capabilities.getDefinitionProvider().getLeft()); - assertTrue(capabilities.getDeclarationProvider().getLeft()); - assertEquals(new CompletionOptions(true, null), capabilities.getCompletionProvider()); - assertTrue(capabilities.getHoverProvider().getLeft()); - // LspLog is disabled by default. - assertFalse(lspLog.exists()); - } - - @Test - public void initializeWithTemporaryWorkspace() { - InitializeParams initParams = new InitializeParams(); - SmithyLanguageServer languageServer = new SmithyLanguageServer(); - languageServer.initialize(initParams); - - assertNotNull(languageServer.tempWorkspaceRoot); - assertTrue(languageServer.tempWorkspaceRoot.exists()); - assertTrue(languageServer.tempWorkspaceRoot.isDirectory()); - assertTrue(languageServer.tempWorkspaceRoot.canWrite()); - } - - @Test - public void lspLogCanBeEnabled() throws Exception { - InitializeParams initParams = new InitializeParams(); - File temp = Files.createTempDirectory("smithy-lsp-log-test").toFile(); - temp.deleteOnExit(); - initParams.setWorkspaceFolders(ListUtils.of(new WorkspaceFolder(temp.toURI().toString()))); - JsonObject initOptions = new JsonObject(); - initOptions.addProperty("logToFile", "enabled"); - initParams.setInitializationOptions(initOptions); - SmithyLanguageServer languageServer = new SmithyLanguageServer(); - languageServer.initialize(initParams); - - File expectedLspLog = new File(temp + "/.smithy.lsp.log"); - - assertTrue(expectedLspLog.exists()); + public void runsSelector() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + SelectorParams params = new SelectorParams("string"); + List locations = server.selectorCommand(params).get(); + + assertThat(locations, not(empty())); + } + + @Test + public void completion() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: String\n" + + "}\n" + + "\n" + + "@default(0)\n" + + "integer Bar\n"; + 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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + "}\n"; + String model2 = "$version: \"2\"\n" + + "namespace com.bar\n" + + "\n" + + "string Bar\n"; + 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 RangeAdapter() + .startLine(3) + .startCharacter(15) + .endLine(3) + .endCharacter(15) + .build()) + .text("\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.getProject().getDocument(uri); + // TODO: The server puts the 'use' on the wrong line + assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, "$version: \"2\"\n" + + "namespace com.foo\n" + + "use com.bar#Bar\n" + + "\n" + + "structure Foo {\n" + + " bar: Ba\n" + + "}\n"))); + } + + @Test + public void definition() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@trait\n" + + "string myTrait\n" + + "\n" + + "structure Foo {\n" + + " bar: Baz\n" + + "}\n" + + "\n" + + "@myTrait(\"\")\n" + + "string Baz\n"; + 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.getProject().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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@trait\n" + + "string myTrait\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "@myTrait(\"\")\n" + + "structure Bar {\n" + + " baz: String\n" + + "}\n"; + 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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + " baz: String\n" + + "}\n"; + 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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@trait\n" + + "string myTrait\n" + + "\n" + + "structure Foo {\n" + + " @required\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "structure Bar {\n" + + " @myTrait(\"foo\")\n" + + " baz: Baz\n" + + "}\n" + + "\n" + + "@myTrait(\"abc\")\n" + + "integer Baz\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + DocumentSymbolParams params = new DocumentSymbolParams(new TextDocumentIdentifier(uri)); + List> response = server.documentSymbol(params).get(); + List documentSymbols = response.stream().map(Either::getRight).collect(Collectors.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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo{\n" + + "bar: Baz}\n" + + "\n" + + "@tags(\n" + + "[\"a\",\n" + + " \"b\"])\n" + + "string Baz\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + TextDocumentIdentifier id = new TextDocumentIdentifier(uri); + DocumentFormattingParams params = new DocumentFormattingParams(id, new FormattingOptions()); + List edits = server.formatting(params).get(); + Document document = server.getProject().getDocument(uri); + + assertThat(edits, (Matcher) containsInAnyOrder(makesEditedDocument(document, "$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Baz\n" + + "}\n" + + "\n" + + "@tags([\"a\", \"b\"])\n" + + "string Baz\n"))); + } + + @Test + public void didChange() throws Exception { + String model = "$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure GetFooInput {\n" + + "}\n" + + "\n" + + "operation GetFoo {\n" + + "}\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + TextDocumentIdentifier id = new TextDocumentIdentifier(uri); + + DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build(); + server.didOpen(openParams); + + RangeAdapter rangeAdapter = new RangeAdapter() + .startLine(7) + .startCharacter(18) + .endLine(7) + .endCharacter(18); + RequestBuilders.DidChange changeBuilder = new RequestBuilders.DidChange().uri(uri); + + // Add new line and leading spaces + server.didChange(changeBuilder.range(rangeAdapter.build()).text("\n ").build()); + // add 'input: G' + server.didChange(changeBuilder.range(rangeAdapter.shiftNewLine().shiftRight(4).build()).text("i").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("n").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("p").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("u").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("t").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text(":").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text(" ").build()); + server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("G").build()); + + server.getLifecycleManager().getTask(uri).get(); + + // mostly so you can see what it looks like + assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure GetFooInput {\n" + + "}\n" + + "\n" + + "operation GetFoo {\n" + + " input: G\n" + + "}\n")); + + // input: G + CompletionParams completionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .position(rangeAdapter.shiftRight().build().getStart()) + .buildCompletion(); + List completions = server.completion(completionParams).get().getLeft(); + + DidSaveTextDocumentParams saveParams = new DidSaveTextDocumentParams(id); + server.didSave(saveParams); + + assertThat(completions, containsInAnyOrder(hasLabel("GetFoo"), hasLabel("GetFooInput"))); + } + + @Test + public void didChangeReloadsModel() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "operation Foo {}\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build(); + server.didOpen(openParams); + assertThat(server.getProject().getModelResult().getValidationEvents(), empty()); + + DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() + .uri(uri) + .text("@http(method:\"\", uri: \"\")\n") + .range(RangeAdapter.point(3, 0)) + .build(); + server.didChange(didChangeParams); + + server.getLifecycleManager().getTask(uri).get(); + + assertThat(server.getProject().getModelResult().getValidationEvents(), + containsInAnyOrder(hasMessage(containsString("Error creating trait")))); + + DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); + server.didSave(didSaveParams); + + assertThat(server.getProject().getModelResult().getValidationEvents(), + containsInAnyOrder(hasMessage(containsString("Error creating trait")))); + } + + @Test + public void didChangeThenDefinition() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "string Bar\n"; + 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))); + + RangeAdapter range = new RangeAdapter() + .startLine(5) + .startCharacter(1) + .endLine(5) + .endCharacter(1); + RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); + server.didChange(change.range(range.build()).text("\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.getLifecycleManager().getTask(uri).get(); + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "string Baz\n" + + "\n" + + "string Bar\n")); + + Location afterChanges = server.definition(definitionParams).get().getLeft().get(0); + assertThat(afterChanges.getUri(), equalTo(uri)); + assertThat(afterChanges.getRange().getStart(), equalTo(new Position(9, 0))); + } + + @Test + public void definitionWithApply() throws Exception { + Path root = Paths.get(getClass().getResource("project/apply").getPath()); + 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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n"; + 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()); + + RangeAdapter range = new RangeAdapter() + .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.getLifecycleManager().getTask(uri).get(); + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar with [F]")); + + Position currentPosition = range.build().getStart(); + CompletionParams completionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .position(range.shiftRight().build().getStart()) + .buildCompletion(); + + assertThat(server.getProject().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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar {}\n"; + 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()); + + RangeAdapter range = new RangeAdapter() + .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.getLifecycleManager().getTask(uri).get(); + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar with [F] {}\n")); + + Position currentPosition = range.build().getStart(); + CompletionParams completionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .position(range.shiftRight().build().getStart()) + .buildCompletion(); + + assertThat(server.getProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + + List completions = server.completion(completionParams).get().getLeft(); + + assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); + } + + @Test + public void diagnosticsOnMemberTarget() { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n"; + 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.get(0); + assertThat(diagnostic.getMessage(), startsWith("Target.UnresolvedShape")); + + Document document = server.getProject().getDocument(uri); + assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); + } + + @Test + public void diagnosticOnTrait() { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " @bar\n" + + " bar: String\n" + + "}\n"; + 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()); + + List diagnostics = server.getFileDiagnostics(uri); + + assertThat(diagnostics, hasSize(1)); + Diagnostic diagnostic = diagnostics.get(0); + assertThat(diagnostic.getMessage(), startsWith("Model.UnresolvedTrait")); + + Document document = server.getProject().getDocument(uri); + assertThat(diagnostic.getRange(), hasText(document, equalTo("@bar"))); + } + + @Test + public void diagnosticsOnShape() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "list Foo {\n" + + " \n" + + "}\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + StubClient client = new StubClient(); + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + JsonObject opts = new JsonObject(); + opts.add("diagnostics.minimumSeverity", new JsonPrimitive("NOTE")); + server.initialize(new RequestBuilders.Initialize() + .workspaceFolder(workspace.getRoot().toUri().toString(), "test") + .initializationOptions(opts) + .build()) + .get(); + + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + + server.didSave(new RequestBuilders.DidSave() + .uri(uri) + .build()); + + List diagnostics = server.getFileDiagnostics(uri); + + 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 + // 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))); + // assertThat(diagnostic.getRange().getEnd(), equalTo(new Position(3, 8))); + } + + @Test + public void insideJar() throws Exception { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: String\n" + + "}\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(model) + .build()); + + Location preludeLocation = server.definition(RequestBuilders.positionRequest() + .uri(uri) + .line(4) + .character(9) + .buildDefinition()) + .get() + .getLeft() + .get(0); + + String preludeUri = preludeLocation.getUri(); + assertThat(preludeUri, startsWith("smithyjar")); + + Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() + .uri(preludeUri) + .line(36) + .character(1) + .buildHover()) + .get(); + String content = appliedTraitInPreludeHover.getContents().getRight().getValue(); + assertThat(content, containsString("document default")); + } + + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { + return initFromWorkspace(workspace, new StubClient()); + } + + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace, LanguageClient client) { + try { + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + server.initialize(RequestBuilders.initialize() + .workspaceFolder(workspace.getRoot().toUri().toString(), "test") + .build()) + .get(); + + return server; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static SmithyLanguageServer initFromRoot(Path root) { + try { + LanguageClient client = new StubClient(); + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + server.initialize(new RequestBuilders.Initialize() + .workspaceFolder(root.toUri().toString(), "test") + .build()) + .get(); + + return server; + } catch (Exception e) { + throw new RuntimeException(e); + } } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java new file mode 100644 index 00000000..6915f586 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.Set; +import java.util.stream.Collectors; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +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.ShapeId; +import software.amazon.smithy.model.validation.ValidationEvent; + +public final class SmithyMatchers { + private SmithyMatchers() {} + + public static Matcher hasShapeWithId(String id) { + return new CustomTypeSafeMatcher("a model with the right shape id") { + @Override + protected boolean matchesSafely(Model item) { + return item.getShape(ShapeId.from(id)).isPresent(); + } + + @Override + public void describeMismatchSafely(Model model, Description description) { + Set nonPreludeIds = model.shapes().filter(shape -> !Prelude.isPreludeShape(shape)) + .map(Shape::toShapeId) + .collect(Collectors.toSet()); + description.appendText("had only these non-prelude shapes: " + nonPreludeIds); + } + }; + } + + public static Matcher hasMessage(Matcher message) { + return new CustomTypeSafeMatcher("has matching message") { + @Override + protected boolean matchesSafely(ValidationEvent item) { + return message.matches(item.getMessage()); + } + + @Override + public void describeMismatchSafely(ValidationEvent event, Description description) { + description.appendDescriptionOf(message).appendText("was " + event.getMessage()); + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java deleted file mode 100644 index 5c0e4773..00000000 --- a/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java +++ /dev/null @@ -1,999 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -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.DiagnosticSeverity; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.DidSaveTextDocumentParams; -import org.eclipse.lsp4j.DocumentSymbol; -import org.eclipse.lsp4j.DocumentSymbolParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.MessageActionItem; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.ShowMessageRequestParams; -import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.SymbolKind; -import org.eclipse.lsp4j.TextDocumentContentChangeEvent; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TextDocumentItem; -import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.eclipse.lsp4j.services.LanguageClient; -import org.junit.Test; -import software.amazon.smithy.lsp.ext.Harness; -import software.amazon.smithy.lsp.ext.SmithyProjectTest; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.MapUtils; -import software.amazon.smithy.utils.SetUtils; - -public class SmithyTextDocumentServiceTest { - - // All successful hover responses are wrapped between these strings - private static final String HOVER_DEFAULT_PREFIX = "```smithy\n$version: \"2.0\"\n\n"; - private static final String HOVER_DEFAULT_SUFFIX = "\n```"; - - @Test - public void correctlyAttributingDiagnostics() throws Exception { - String brokenFileName = "foo/broken.smithy"; - String goodFileName = "good.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(brokenFileName, "$version: \"2\"\nnamespace testFoo\n string_ MyId"), - MapUtils.entry(goodFileName, "$version: \"2\"\nnamespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - tds.createProject(hs.getConfig(), hs.getRoot()); - - File broken = hs.file(brokenFileName); - File good = hs.file(goodFileName); - - // When compiling broken file - Set filesWithDiagnostics = tds.recompile(broken, Optional.empty()).getRight().stream() - .filter(pds -> (pds.getDiagnostics().size() > 0)).map(PublishDiagnosticsParams::getUri) - .collect(Collectors.toSet()); - assertEquals(SetUtils.of(uri(broken)), filesWithDiagnostics); - - // When compiling good file - filesWithDiagnostics = tds.recompile(good, Optional.empty()).getRight().stream() - .filter(pds -> (pds.getDiagnostics().size() > 0)).map(PublishDiagnosticsParams::getUri) - .collect(Collectors.toSet()); - assertEquals(SetUtils.of(uri(broken)), filesWithDiagnostics); - - } - - } - - @Test - public void sendingDiagnosticsToTheClient() throws Exception { - String brokenFileName = "foo/broken.smithy"; - String goodFileName = "good.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(brokenFileName, "$version: \"2\"\nnamespace testFoo; string_ MyId"), - MapUtils.entry(goodFileName, "$version: \"2\"\nnamespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - File broken = hs.file(brokenFileName); - File good = hs.file(goodFileName); - - // OPEN - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(broken, files.get(brokenFileName)))); - - // broken file has a diagnostic published against it - assertEquals(1, filePublishedDiagnostics(broken, client.diagnostics).size()); - assertEquals(ListUtils.of(DiagnosticSeverity.Error), getSeverities(broken, client.diagnostics)); - // To clear diagnostics correctly, we must *explicitly* publish an empty - // list of diagnostics against files with no errors - - assertEquals(1, filePublishedDiagnostics(good, client.diagnostics).size()); - assertEquals(ListUtils.of(), filePublishedDiagnostics(good, client.diagnostics).get(0).getDiagnostics()); - - client.clear(); - - // SAVE - - tds.didSave(new DidSaveTextDocumentParams(new TextDocumentIdentifier(uri(broken)))); - - // broken file has a diagnostic published against it - assertEquals(1, filePublishedDiagnostics(broken, client.diagnostics).size()); - assertEquals(ListUtils.of(DiagnosticSeverity.Error), getSeverities(broken, client.diagnostics)); - // To clear diagnostics correctly, we must *explicitly* publish an empty - // list of diagnostics against files with no errors - assertEquals(1, filePublishedDiagnostics(good, client.diagnostics).size()); - assertEquals(ListUtils.of(), filePublishedDiagnostics(good, client.diagnostics).get(0).getDiagnostics()); - - } - - } - - @Test - public void attributesDiagnosticsForUnknownTraits() throws Exception { - String modelFilename = "ext/models/unknown-trait.smithy"; - Path modelFilePath = Paths.get(getClass().getResource(modelFilename).toURI()); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - File modelFile = hs.file(modelFilename); - - // There must be one warning diagnostic at the unknown trait's location - Range unknownTraitRange = new Range(new Position(6, 0), new Position(6, 0)); - long matchingDiagnostics = tds.recompile(modelFile, Optional.empty()).getRight().stream() - .flatMap(params -> params.getDiagnostics().stream()) - .filter(diagnostic -> diagnostic.getSeverity().equals(DiagnosticSeverity.Warning)) - .filter(diagnostic -> diagnostic.getRange().equals(unknownTraitRange)) - .count(); - assertEquals(1, matchingDiagnostics); - } - } - - @Test - public void allowsDefinitionWhenThereAreUnknownTraits() throws Exception { - Path baseDir = Paths.get(getClass().getResource("ext/models").toURI()); - String modelFilename = "unknown-trait.smithy"; - Path modelFilePath = baseDir.resolve(modelFilename); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - // We should still be able to respond with a location when there are unknown traits in the model - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - int locationCount = tds.definition(definitionParams(tdi, 10, 13)).get().getLeft().size(); - assertEquals(locationCount, 1); - } - } - - @Test - public void allowsHoverWhenThereAreUnknownTraits() throws Exception { - Path baseDir = Paths.get(getClass().getResource("ext/models").toURI()); - String modelFilename = "unknown-trait.smithy"; - Path modelFilePath = baseDir.resolve(modelFilename); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - // We should still be able to respond with hover content when there are unknown traits in the model - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - Hover hover = tds.hover(hoverParams(tdi, 14, 13)).get(); - correctHover("namespace com.foo\n\n", "structure Bar {\n member: Foo\n}", hover); - } - } - - @Test - public void hoverOnBrokenShapeAppendsValidations() throws Exception { - Path baseDir = Paths.get(getClass().getResource("ext/models").toURI()); - String modelFilename = "unknown-trait.smithy"; - Path modelFilePath = baseDir.resolve(modelFilename); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - Hover hover = tds.hover(hoverParams(tdi, 10, 13)).get(); - MarkupContent hoverContent = hover.getContents().getRight(); - assertEquals(hoverContent.getKind(),"markdown"); - assertTrue(hoverContent.getValue().startsWith("```smithy")); - assertTrue(hoverContent.getValue().contains("structure Foo {}")); - assertTrue(hoverContent.getValue().contains("WARNING: Unable to resolve trait `com.external#unknownTrait`")); - } - } - - @Test - public void handlingChanges() throws Exception { - String fileName1 = "foo/bla.smithy"; - String fileName2 = "good.smithy"; - - Map files = MapUtils.ofEntries(MapUtils.entry(fileName1, "namespace testFoo\n string MyId"), - MapUtils.entry(fileName2, "namespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - File file1 = hs.file(fileName1); - File file2 = hs.file(fileName2); - - // OPEN - - tds.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri(file1), 1), - ListUtils.of(new TextDocumentContentChangeEvent("inspect broken")))); - - // Only diagnostics for existing files are reported - assertEquals(SetUtils.of(uri(file1), uri(file2)), SetUtils.copyOf(getUris(client.diagnostics))); - - } - - } - - @Test - public void definitionsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - // Resolves via token => shape name. - DefinitionParams commentParams = definitionParams(mainTdi, 43, 37); - Location commentLocation = tds.definition(commentParams).get().getLeft().get(0); - - // Resolves via shape target location in model. - DefinitionParams memberParams = definitionParams(mainTdi, 12, 18); - Location memberTargetLocation = tds.definition(memberParams).get().getLeft().get(0); - - // Resolves via member shape target location in prelude. - DefinitionParams preludeTargetParams = definitionParams(mainTdi, 36, 12); - Location preludeTargetLocation = tds.definition(preludeTargetParams).get().getLeft().get(0); - - // Resolves via top-level trait location in prelude. - DefinitionParams preludeTraitParams = definitionParams(mainTdi, 25, 3); - Location preludeTraitLocation = tds.definition(preludeTraitParams).get().getLeft().get(0); - - // Resolves via member-applied trait location in prelude. - DefinitionParams preludeMemberTraitParams = definitionParams(mainTdi, 59, 10); - Location preludeMemberTraitLocation = tds.definition(preludeMemberTraitParams).get().getLeft().get(0); - - // Resolves to current location. - DefinitionParams selfParams = definitionParams(mainTdi, 36, 0); - Location selfLocation = tds.definition(selfParams).get().getLeft().get(0); - - // Resolves via operation input. - DefinitionParams inputParams = definitionParams(mainTdi, 52, 16); - Location inputLocation = tds.definition(inputParams).get().getLeft().get(0); - - // Resolves via operation output. - DefinitionParams outputParams = definitionParams(mainTdi, 53, 17); - Location outputLocation = tds.definition(outputParams).get().getLeft().get(0); - - // Resolves via operation error. - DefinitionParams errorParams = definitionParams(mainTdi, 54, 14); - Location errorLocation = tds.definition(errorParams).get().getLeft().get(0); - - // Resolves via resource ids. - DefinitionParams idParams = definitionParams(mainTdi, 75, 29); - Location idLocation = tds.definition(idParams).get().getLeft().get(0); - - // Resolves via resource read. - DefinitionParams readParams = definitionParams(mainTdi, 76, 12); - Location readLocation = tds.definition(readParams).get().getLeft().get(0); - - // Does not correspond to shape. - DefinitionParams noMatchParams = definitionParams(mainTdi, 0, 0); - List noMatchLocationList = (List) tds.definition(noMatchParams).get().getLeft(); - - correctLocation(commentLocation, modelFilename, 20, 0, 21, 14); - correctLocation(memberTargetLocation, modelFilename, 4, 0, 4, 23); - correctLocation(selfLocation, modelFilename, 35, 0, 37, 1); - correctLocation(inputLocation, modelFilename, 57, 0, 61, 1); - correctLocation(outputLocation, modelFilename, 63, 0, 66, 1); - correctLocation(errorLocation, modelFilename, 69, 0, 72, 1); - correctLocation(idLocation, modelFilename, 79, 0, 79, 11); - correctLocation(readLocation, modelFilename, 51, 0, 55, 1); - assertTrue(preludeTargetLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeMemberTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(noMatchLocationList.isEmpty()); - } - } - - @Test - public void definitionsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - // Resolves via token => shape name. - DefinitionParams commentParams = definitionParams(mainTdi, 45, 37); - Location commentLocation = tds.definition(commentParams).get().getLeft().get(0); - - // Resolves via shape target location in model. - DefinitionParams memberParams = definitionParams(mainTdi, 14, 18); - Location memberTargetLocation = tds.definition(memberParams).get().getLeft().get(0); - - // Resolves via member shape target location in prelude. - DefinitionParams preludeTargetParams = definitionParams(mainTdi, 38, 12); - Location preludeTargetLocation = tds.definition(preludeTargetParams).get().getLeft().get(0); - - // Resolves via top-level trait location in prelude. - DefinitionParams preludeTraitParams = definitionParams(mainTdi, 27, 3); - Location preludeTraitLocation = tds.definition(preludeTraitParams).get().getLeft().get(0); - - // Resolves via member-applied trait location in prelude. - DefinitionParams preludeMemberTraitParams = definitionParams(mainTdi, 61, 10); - Location preludeMemberTraitLocation = tds.definition(preludeMemberTraitParams).get().getLeft().get(0); - - // Resolves to current location. - DefinitionParams selfParams = definitionParams(mainTdi, 38, 0); - Location selfLocation = tds.definition(selfParams).get().getLeft().get(0); - - // Resolves via operation input. - DefinitionParams inputParams = definitionParams(mainTdi, 54, 16); - Location inputLocation = tds.definition(inputParams).get().getLeft().get(0); - - // Resolves via operation output. - DefinitionParams outputParams = definitionParams(mainTdi, 55, 17); - Location outputLocation = tds.definition(outputParams).get().getLeft().get(0); - - // Resolves via operation error. - DefinitionParams errorParams = definitionParams(mainTdi, 56, 14); - Location errorLocation = tds.definition(errorParams).get().getLeft().get(0); - - // Resolves via resource ids. - DefinitionParams idParams = definitionParams(mainTdi, 77, 29); - Location idLocation = tds.definition(idParams).get().getLeft().get(0); - - // Resolves via resource read. - DefinitionParams readParams = definitionParams(mainTdi, 78, 12); - Location readLocation = tds.definition(readParams).get().getLeft().get(0); - - // Does not correspond to shape. - DefinitionParams noMatchParams = definitionParams(mainTdi, 0, 0); - List noMatchLocationList = (List) tds.definition(noMatchParams).get().getLeft(); - - // Resolves via mixin target on operation input. - DefinitionParams mixinInputParams = definitionParams(mainTdi, 143, 24); - Location mixinInputLocation = tds.definition(mixinInputParams).get().getLeft().get(0); - - // Resolves via mixin target on operation output. - DefinitionParams mixinOutputParams = definitionParams(mainTdi, 149, 36); - Location mixinOutputLocation = tds.definition(mixinOutputParams).get().getLeft().get(0); - - // Resolves via mixin target on structure. - DefinitionParams mixinStructureParams = definitionParams(mainTdi, 134, 36); - Location mixinStructureLocation = tds.definition(mixinStructureParams).get().getLeft().get(0); - - correctLocation(commentLocation, modelFilename, 22, 0, 23, 14); - correctLocation(memberTargetLocation, modelFilename, 6, 0, 6, 23); - correctLocation(selfLocation, modelFilename, 37, 0, 39, 1); - correctLocation(inputLocation, modelFilename, 59, 0, 63, 1); - correctLocation(outputLocation, modelFilename, 65, 0, 68, 1); - correctLocation(errorLocation, modelFilename, 71, 0, 74, 1); - correctLocation(idLocation, modelFilename, 81, 0, 81, 11); - correctLocation(readLocation, modelFilename, 53, 0, 57, 1); - correctLocation(mixinInputLocation, modelFilename, 112, 0, 118, 1); - correctLocation(mixinOutputLocation, modelFilename, 121, 0, 123, 1); - correctLocation(mixinStructureLocation, modelFilename, 112, 0, 118, 1); - assertTrue(preludeTargetLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeMemberTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(noMatchLocationList.isEmpty()); - } - } - - @Test - public void completionsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - CompletionParams traitParams = completionParams(mainTdi, 85, 10); - List traitCompletionItems = tds.completion(traitParams).get().getLeft(); - - CompletionParams shapeParams = completionParams(mainTdi, 51,16); - List shapeCompletionItems = tds.completion(shapeParams).get().getLeft(); - - CompletionParams applyStatementParams = completionParams(mainTdi,83, 23); - List applyStatementCompletionItems = tds.completion(applyStatementParams).get().getLeft(); - - CompletionParams whiteSpaceParams = completionParams(mainTdi, 0, 0); - List whiteSpaceCompletionItems = tds.completion(whiteSpaceParams).get().getLeft(); - - assertEquals(SetUtils.of("MyOperation", "MyOperationInput", "MyOperationOutput"), - completionLabels(shapeCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(applyStatementCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(traitCompletionItems)); - - assertTrue(whiteSpaceCompletionItems.isEmpty()); - } - } - - @Test - public void hoverV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - String testFilename = "test.smithy"; - Path modelTest = baseDir.resolve(testFilename); - String clutteredPreambleFilename = "cluttered-preamble.smithy"; - Path modelClutteredPreamble = baseDir.resolve(clutteredPreambleFilename); - String extrasToImportFilename = "extras-to-import.smithy"; - Path modelExtras = baseDir.resolve(extrasToImportFilename); - List modelFiles = ListUtils.of(modelMain, modelTest, modelClutteredPreamble, modelExtras); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - TextDocumentIdentifier testTdi = new TextDocumentIdentifier(hs.file(testFilename).toString()); - TextDocumentIdentifier clutteredTdi = new TextDocumentIdentifier(hs.file(clutteredPreambleFilename).toString()); - - // Namespace and use statements in hover response - String preludeHoverPrefix = "namespace smithy.api\n\n"; - String mainHoverPrefix = "namespace com.foo\n\n"; - String testHoverPrefix = "namespace com.example\n\nuse com.foo#emptyTraitStruct\n\n"; - String clutteredHoverWithDependenciesPrefix = "namespace com.clutter\n\nuse " + - "com.example#OtherStructure\nuse com.extras#Extra\n\n"; - String clutteredHoverWithNoDependenciesPrefix = "namespace com.clutter\n\n"; - - // Resolves via top-level trait location in prelude. - Hover preludeTraitHover = tds.hover(hoverParams(mainTdi, 25, 3)).get(); - MarkupContent preludeTraitHoverContents = preludeTraitHover.getContents().getRight(); - assertEquals(preludeTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix + - "/// Specializes a structure for use only as the input")); - assertTrue(preludeTraitHoverContents.getValue().endsWith("structure input {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeMemberTraitHover = tds.hover(hoverParams(mainTdi, 59, 10)).get(); - MarkupContent preludeMemberTraitHoverContents = preludeMemberTraitHover.getContents().getRight(); - assertEquals(preludeMemberTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeMemberTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix + - "/// Marks a structure member as required")); - assertTrue(preludeMemberTraitHoverContents.getValue().endsWith("structure required {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeTargetHover = tds.hover(hoverParams(mainTdi, 36, 12)).get(); - correctHover(preludeHoverPrefix , "string String", preludeTargetHover); - - // Resolves via token => shape name. - Hover commentHover = tds.hover(hoverParams(mainTdi, 43, 37)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"foo\"\n])\nstructure MultiTrait {\n a: String\n}", commentHover); - - // Resolves via shape target location in model. - Hover memberTargetHover = tds.hover(hoverParams(mainTdi, 12, 18)).get(); - correctHover(mainHoverPrefix, "structure SingleLine {}", memberTargetHover); - - // Resolves from member key to shape target location in model. - Hover memberIdentifierHover = tds.hover(hoverParams(mainTdi, 64, 7)).get(); - correctHover(preludeHoverPrefix, "string String", memberIdentifierHover); - - // Resolves to current location. - Hover selfHover = tds.hover(hoverParams(mainTdi, 36, 0)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"a\"\n \"b\"\n \"c\"\n \"d\"\n \"e\"\n \"f\"\n" - + "])\nstructure MultiTraitAndLineComments {\n a: String\n}", selfHover); - - // Resolves via operation input. - Hover inputHover = tds.hover(hoverParams(mainTdi, 52, 16)).get(); - correctHover(mainHoverPrefix, "structure MyOperationInput {\n foo: String\n @required\n myId: MyId\n}", - inputHover); - - // Resolves via operation output. - Hover outputHover = tds.hover(hoverParams(mainTdi, 53, 17)).get(); - correctHover(mainHoverPrefix, "structure MyOperationOutput {\n corge: String\n qux: String\n}", outputHover); - - // Resolves via operation error. - Hover errorHover = tds.hover(hoverParams(mainTdi, 54, 14)).get(); - correctHover(mainHoverPrefix, "@error(\"client\")\nstructure MyError {\n blah: String\n blahhhh: Integer\n}", - errorHover); - - // Resolves via resource ids. - Hover idHover = tds.hover(hoverParams(mainTdi, 75, 29)).get(); - correctHover(mainHoverPrefix, "string MyId", idHover); - - // Resolves via resource read. - Hover readHover = tds.hover(hoverParams(mainTdi, 76, 12)).get(); - assertTrue(readHover.getContents().getRight().getValue().contains("@http(\n method: \"PUT\"\n " - + "uri: \"/bar\"\n code: 200\n)\n@readonly\noperation MyOperation {\n input: " - + "MyOperationInput\n output: MyOperationOutput\n errors: [\n MyError\n ]\n}")); - - // Does not correspond to shape. - Hover noMatchHover = tds.hover(hoverParams(mainTdi, 0, 0)).get(); - assertNull(noMatchHover.getContents().getRight().getValue()); - - // Resolves between multiple model files. - Hover multiFileHover = tds.hover(hoverParams(testTdi, 7, 15)).get(); - correctHover(testHoverPrefix, "@emptyTraitStruct\nstructure OtherStructure {\n foo: String\n bar: String\n" - + " baz: Integer\n}", multiFileHover); - - // Resolves a shape including its dependencies in the preamble - Hover clutteredWithDependenciesHover = tds.hover(hoverParams(clutteredTdi, 25, 17)).get(); - correctHover(clutteredHoverWithDependenciesPrefix, "/// With doc comment\n" - + "structure StructureWithDependencies {\n" - + " extra: Extra\n example: OtherStructure\n}", clutteredWithDependenciesHover); - - // Resolves shape with no dependencies, but doesn't include cluttered preamble - Hover clutteredWithNoDependenciesHover = tds.hover(hoverParams(clutteredTdi, 30, 17)).get(); - correctHover(clutteredHoverWithNoDependenciesPrefix, "structure StructureWithNoDependencies {\n" - + " member: String\n}", clutteredWithNoDependenciesHover); - - } - } - - @Test - public void hoverV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - String testFilename = "test.smithy"; - Path modelTest = baseDir.resolve(testFilename); - String clutteredPreambleFilename = "cluttered-preamble.smithy"; - Path modelClutteredPreamble = baseDir.resolve(clutteredPreambleFilename); - String extrasToImportFilename = "extras-to-import.smithy"; - Path modelExtras = baseDir.resolve(extrasToImportFilename); - List modelFiles = ListUtils.of(modelMain, modelTest, modelClutteredPreamble, modelExtras); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - TextDocumentIdentifier testTdi = new TextDocumentIdentifier(hs.file(testFilename).toString()); - TextDocumentIdentifier clutteredTdi = new TextDocumentIdentifier(hs.file(clutteredPreambleFilename).toString()); - - // Namespace and use statements in hover response - String preludeHoverPrefix = "namespace smithy.api\n\n"; - String mainHoverPrefix = "namespace com.foo\n\n"; - String testHoverPrefix = "namespace com.example\n\nuse com.foo#emptyTraitStruct\n\n"; - String clutteredHoverWithDependenciesPrefix = "namespace com.clutter\n\nuse " + - "com.example#OtherStructure\nuse com.extras#Extra\n\n"; - String clutteredHoverInlineOpPrefix = "namespace com.clutter\n\n"; - - // Resolves via top-level trait location in prelude. - Hover preludeTraitHover = tds.hover(hoverParams(mainTdi, 27, 3)).get(); - MarkupContent preludeTraitHoverContents = preludeTraitHover.getContents().getRight(); - assertEquals(preludeTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix - + "/// Specializes a structure for use only as the" + " input")); - assertTrue(preludeTraitHoverContents.getValue().endsWith("structure input {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeMemberTraitHover = tds.hover(hoverParams(mainTdi, 61, 10)).get(); - MarkupContent preludeMemberTraitHoverContents = preludeMemberTraitHover.getContents().getRight(); - assertEquals(preludeMemberTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeMemberTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix - + "/// Marks a structure member as required")); - assertTrue(preludeMemberTraitHoverContents.getValue().endsWith("structure required {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeTargetHover = tds.hover(hoverParams(mainTdi, 38, 12)).get(); - correctHover(preludeHoverPrefix, "string String", preludeTargetHover); - - // Resolves via token => shape name. - Hover commentHover = tds.hover(hoverParams(mainTdi, 45, 37)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"foo\"\n])\nstructure MultiTrait {\n a: String\n}", commentHover); - - // Resolves via shape target location in model. - Hover memberTargetHover = tds.hover(hoverParams(mainTdi, 14, 18)).get(); - correctHover(mainHoverPrefix, "structure SingleLine {}", memberTargetHover); - - // Resolves from member key to shape target location in model. - Hover memberIdentifierHover = tds.hover(hoverParams(mainTdi, 66, 7)).get(); - correctHover(preludeHoverPrefix, "string String", memberIdentifierHover); - - // Resolves to current location. - Hover selfHover = tds.hover(hoverParams(mainTdi, 38, 0)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"a\"\n \"b\"\n \"c\"\n \"d\"\n \"e\"\n \"f\"\n" - + "])\nstructure MultiTraitAndLineComments {\n a: String\n}", selfHover); - - // Resolves via operation input. - Hover inputHover = tds.hover(hoverParams(mainTdi, 54, 16)).get(); - correctHover(mainHoverPrefix, "structure MyOperationInput {\n foo: String\n @required\n myId: MyId\n}", - inputHover); - - // Resolves via operation output. - Hover outputHover = tds.hover(hoverParams(mainTdi, 55, 17)).get(); - correctHover(mainHoverPrefix, "structure MyOperationOutput {\n corge: String\n qux: String\n}", outputHover); - - // Resolves via operation error. - Hover errorHover = tds.hover(hoverParams(mainTdi, 56, 14)).get(); - correctHover(mainHoverPrefix, "@error(\"client\")\nstructure MyError {\n blah: String\n blahhhh: Integer\n}", - errorHover); - - // Resolves via resource ids. - Hover idHover = tds.hover(hoverParams(mainTdi, 77, 29)).get(); - correctHover(mainHoverPrefix, "string MyId", idHover); - - // Resolves via resource read. - Hover readHover = tds.hover(hoverParams(mainTdi, 78, 12)).get(); - assertTrue(readHover.getContents().getRight().getValue().contains("@http(\n method: \"PUT\"\n " - + "uri: \"/bar\"\n code: 200\n)\n@readonly\noperation MyOperation {\n input: " - + "MyOperationInput\n output: MyOperationOutput\n errors: [\n MyError\n ]\n}")); - - // Does not correspond to shape. - Hover noMatchHover = tds.hover(hoverParams(mainTdi, 0, 0)).get(); - assertNull(noMatchHover.getContents().getRight().getValue()); - - // Resolves between multiple model files. - Hover multiFileHover = tds.hover(hoverParams(testTdi, 7, 15)).get(); - correctHover(testHoverPrefix, "@emptyTraitStruct\nstructure OtherStructure {\n foo: String\n bar: String\n" - + " baz: Integer\n}", multiFileHover); - - // Resolves mixin used within an inlined input/output in an operation shape - Hover operationInlineMixinHover = tds.hover(hoverParams(mainTdi, 143, 36)).get(); - correctHover(mainHoverPrefix, "@mixin\nstructure UserDetails {\n status: String\n}", operationInlineMixinHover); - - // Resolves mixin used on a structure - Hover structureMixinHover = tds.hover(hoverParams(mainTdi, 134, 45)).get(); - correctHover(mainHoverPrefix, "@mixin\nstructure UserDetails {\n status: String\n}", structureMixinHover); - - // Resolves shape with a name that matches operation input/output suffix but is not inlined - Hover falseOperationInlineHover = tds.hover(hoverParams(mainTdi, 176, 18)).get(); - correctHover(mainHoverPrefix, "structure FalseInlinedFooInput {\n a: String\n}", falseOperationInlineHover); - - // Resolves a shape including its dependencies in the preamble - Hover clutteredWithDependenciesHover = tds.hover(hoverParams(clutteredTdi, 26, 17)).get(); - correctHover(clutteredHoverWithDependenciesPrefix, "/// With doc comment\n@mixin\n" - + "structure StructureWithDependencies {\n" - + " extra: Extra\n example: OtherStructure\n}", clutteredWithDependenciesHover); - - // Resolves operation with inlined input/output, but doesn't include cluttered preamble - Hover clutteredInlineOpHover = tds.hover(hoverParams(clutteredTdi, 31, 17)).get(); - correctHover(clutteredHoverInlineOpPrefix, "operation ClutteredInlineOperation {\n" - + " input: ClutteredInlineOperationIn\n" - + " output: ClutteredInlineOperationOut\n}", clutteredInlineOpHover); - } - } - - @Test - public void completionsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - CompletionParams traitParams = completionParams(mainTdi, 87, 10); - List traitCompletionItems = tds.completion(traitParams).get().getLeft(); - - CompletionParams shapeParams = completionParams(mainTdi, 53, 16); - List shapeCompletionItems = tds.completion(shapeParams).get().getLeft(); - - CompletionParams applyStatementParams = completionParams(mainTdi, 85, 23); - List applyStatementCompletionItems = tds.completion(applyStatementParams).get().getLeft(); - - CompletionParams whiteSpaceParams = completionParams(mainTdi, 0,0); - List whiteSpaceCompletionItems = tds.completion(whiteSpaceParams).get().getLeft(); - - assertEquals(SetUtils.of("MyOperation", "MyOperationInput", "MyOperationOutput"), - completionLabels(shapeCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(applyStatementCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(traitCompletionItems)); - - assertTrue(whiteSpaceCompletionItems.isEmpty()); - } - } - - @Test - public void runSelectorV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isRight()); - assertFalse(result.getRight().isEmpty()); - - Optional location = result.getRight().stream() - .filter(location1 -> location1.getRange().getStart().getLine() == 20) - .findFirst(); - - assertTrue(location.isPresent()); - correctLocation(location.get(), modelFilename, 20, 0, 21, 14); - } - } - - @Test - public void runSelectorV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isRight()); - assertFalse(result.getRight().isEmpty()); - - Optional location = result.getRight().stream() - .filter(location1 -> location1.getRange().getStart().getLine() == 22) - .findFirst(); - - assertTrue(location.isPresent()); - correctLocation(location.get(), modelFilename, 22, 0, 23, 14); - } - } - - @Test - public void runSelectorAgainstModelWithErrorsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path broken = baseDir.resolve("broken.smithy"); - List modelFiles = ListUtils.of(broken); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isLeft()); - assertTrue(result.getLeft().getMessage().contains("Result contained ERROR severity validation events:")); - } - } - - @Test - public void runSelectorAgainstModelWithErrorsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path broken = baseDir.resolve("broken.smithy"); - List modelFiles = ListUtils.of(broken); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isLeft()); - assertTrue(result.getLeft().getMessage().contains("Result contained ERROR severity validation events:")); - } - } - - @Test - public void ensureVersionDiagnostic() throws Exception { - String fileName1 = "no-version.smithy"; - String fileName2 = "old-version.smithy"; - String fileName3 = "good-version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(fileName1, "namespace test"), - MapUtils.entry(fileName2, "$version: \"1\"\nnamespace test2"), - MapUtils.entry(fileName3, "$version: \"2\"\nnamespace test3") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName1), files.get(fileName1)))); - assertEquals(1, fileDiagnostics(hs.file(fileName1), client.diagnostics).size()); - - client.clear(); - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName2), files.get(fileName2)))); - assertEquals(1, fileDiagnostics(hs.file(fileName2), client.diagnostics).size()); - - client.clear(); - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName3), files.get(fileName3)))); - assertEquals(0, fileDiagnostics(hs.file(fileName3), client.diagnostics).size()); - } - - } - - @Test - public void documentSymbols() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/document-symbols").toURI()); - - String currentFile = "current.smithy"; - String anotherFile = "another.smithy"; - - List files = ListUtils.of(baseDir.resolve(currentFile),baseDir.resolve(anotherFile)); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - tds.createProject(hs.getConfig(), hs.getRoot()); - - TextDocumentIdentifier currentDocumentIdent = new TextDocumentIdentifier(uri(hs.file(currentFile))); - - List> symbols = - tds.documentSymbol(new DocumentSymbolParams(currentDocumentIdent)).get(); - - assertEquals(2, symbols.size()); - - assertEquals("city", symbols.get(0).getRight().getName()); - assertEquals(SymbolKind.Field, symbols.get(0).getRight().getKind()); - - assertEquals("Weather", symbols.get(1).getRight().getName()); - assertEquals(SymbolKind.Struct, symbols.get(1).getRight().getKind()); - } - - } - - private static class StubClient implements LanguageClient { - public List diagnostics = new ArrayList<>(); - public List shown = new ArrayList<>(); - public List logged = new ArrayList<>(); - - public StubClient() { - } - - public void clear() { - this.diagnostics.clear(); - this.shown.clear(); - this.logged.clear(); - } - - @Override - public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { - this.diagnostics.add(diagnostics); - } - - @Override - public void telemetryEvent(Object object) { - // TODO Auto-generated method stub - - } - - @Override - public void logMessage(MessageParams message) { - this.logged.add(message); - } - - @Override - public void showMessage(MessageParams messageParams) { - this.shown.add(messageParams); - } - - @Override - public CompletableFuture showMessageRequest(ShowMessageRequestParams requestParams) { - // TODO Auto-generated method stub - return null; - } - } - - private Set getUris(Collection diagnostics) { - return diagnostics.stream().map(PublishDiagnosticsParams::getUri).collect(Collectors.toSet()); - } - - private List filePublishedDiagnostics(File f, List diags) { - return diags.stream().filter(pds -> pds.getUri().equals(uri(f))).collect(Collectors.toList()); - } - - private List fileDiagnostics(File f, List diags) { - return diags.stream().filter(pds -> pds.getUri().equals(uri(f))).flatMap(pd -> pd.getDiagnostics().stream()) - .collect(Collectors.toList()); - } - - private List getSeverities(File f, List diags) { - return filePublishedDiagnostics(f, diags).stream() - .flatMap(pds -> pds.getDiagnostics().stream().map(Diagnostic::getSeverity)).collect(Collectors.toList()); - } - - private TextDocumentItem textDocumentItem(File f, String text) { - return new TextDocumentItem(uri(f), "smithy", 1, text); - } - - private String uri(File f) { - return f.toURI().toString(); - } - - private DefinitionParams definitionParams(TextDocumentIdentifier tdi, int line, int character) { - return new DefinitionParams(tdi, new Position(line, character)); - } - - private HoverParams hoverParams(TextDocumentIdentifier tdi, int line, int character) { - return new HoverParams(tdi, new Position(line, character)); - } - - private void correctHover(String expectedPrefix, String expectedBody, Hover hover) { - MarkupContent content = hover.getContents().getRight(); - assertEquals("markdown", content.getKind()); - assertEquals(HOVER_DEFAULT_PREFIX + expectedPrefix + expectedBody + HOVER_DEFAULT_SUFFIX, content.getValue()); - } - - private void correctLocation(Location location, String uri, int startLine, int startCol, int endLine, int endCol) { - assertEquals(startLine, location.getRange().getStart().getLine()); - assertEquals(startCol, location.getRange().getStart().getCharacter()); - assertEquals(endLine, location.getRange().getEnd().getLine()); - assertEquals(endCol, location.getRange().getEnd().getCharacter()); - assertTrue(location.getUri().endsWith(uri)); - } - - private CompletionParams completionParams(TextDocumentIdentifier tdi, int line, int character) { - return new CompletionParams(tdi, new Position(line, character)); - } - - private Set completionLabels(List completionItems) { - return completionItems.stream().map(item -> item.getLabel()).collect(Collectors.toSet()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index f8cca93c..11d68cb5 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -15,23 +15,30 @@ package software.amazon.smithy.lsp; -import java.util.Collections; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyLanguageServerTest.initFromWorkspace; + import java.util.List; -import java.util.Map; +import java.util.stream.Collectors; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionContext; import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.CodeActionTriggerKind; +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.junit.Test; -import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; -import software.amazon.smithy.lsp.ext.Harness; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.MapUtils; - -import static org.junit.Assert.assertEquals; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.RangeAdapter; /** * This test suite test the generation of the correct {@link CodeAction} given {@link CodeActionParams} @@ -39,74 +46,137 @@ * some content in it. */ public class SmithyVersionRefactoringTest { + @Test + public void noVersionDiagnostic() throws Exception { + String model = "namespace com.foo\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + StubClient client = new StubClient(); + SmithyLanguageServer server = initFromWorkspace(workspace, client); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .collect(Collectors.toList()); + assertThat(codes, hasItem(VersionDiagnostics.SMITHY_DEFINE_VERSION)); + + List defineVersionDiagnostics = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .filter(d -> d.getCode().getLeft().equals(VersionDiagnostics.SMITHY_DEFINE_VERSION)) + .collect(Collectors.toList()); + assertThat(defineVersionDiagnostics, hasSize(1)); + + Diagnostic diagnostic = defineVersionDiagnostics.get(0); + assertThat(diagnostic.getRange().getStart(), equalTo(new Position(0, 0))); + assertThat(diagnostic.getRange().getEnd(), equalTo(new Position(0, 17))); + CodeActionContext context = new CodeActionContext(diagnostics); + context.setTriggerKind(CodeActionTriggerKind.Automatic); + CodeActionParams codeActionParams = new CodeActionParams( + new TextDocumentIdentifier(uri), + RangeAdapter.point(0, 3), + context); + List> response = server.codeAction(codeActionParams).get(); + assertThat(response, hasSize(1)); + CodeAction action = response.get(0).getRight(); + assertThat(action.getEdit().getChanges(), hasKey(uri)); + List edits = action.getEdit().getChanges().get(uri); + assertThat(edits, hasSize(1)); + TextEdit edit = edits.get(0); + Document document = server.getProject().getDocument(uri); + document.applyEdit(edit.getRange(), edit.getNewText()); + assertThat(document.copyText(), equalTo("$version: \"1\"\n" + + "\n" + + "namespace com.foo\n" + + "string Foo\n")); + } @Test - public void noVersionCodeAction() throws Exception { - String filename = "no-version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(filename, "namespace test") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - Range range0 = new Range(new Position(0, 0), new Position(0, 0)); - - CodeActionParams params = new CodeActionParams( - new TextDocumentIdentifier(hs.file(filename).toURI().toString()), - range0, - new CodeActionContext(VersionDiagnostics.createVersionDiagnostics(hs.file(filename), Collections.emptyMap())) - ); - List result = SmithyCodeActions.versionCodeActions(params); - assertEquals(1, result.size()); - assertEquals("Define the Smithy version", result.get(0).getTitle()); - // range is (0,0) - assertEquals(range0, result.get(0).getEdit().getChanges().values().stream().findFirst().get().get(0).getRange()); - } + public void oldVersionDiagnostic() throws Exception { + String model = "$version: \"1\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + StubClient client = new StubClient(); + SmithyLanguageServer server = initFromWorkspace(workspace, client); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .collect(Collectors.toList()); + assertThat(codes, hasItem(VersionDiagnostics.SMITHY_UPDATE_VERSION)); + + List updateVersionDiagnostics = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .filter(d -> d.getCode().getLeft().equals(VersionDiagnostics.SMITHY_UPDATE_VERSION)) + .collect(Collectors.toList()); + assertThat(updateVersionDiagnostics, hasSize(1)); + + Diagnostic diagnostic = updateVersionDiagnostics.get(0); + assertThat(diagnostic.getRange().getStart(), equalTo(new Position(0, 0))); + assertThat(diagnostic.getRange().getEnd(), equalTo(new Position(0, 13))); + CodeActionContext context = new CodeActionContext(diagnostics); + context.setTriggerKind(CodeActionTriggerKind.Automatic); + CodeActionParams codeActionParams = new CodeActionParams( + new TextDocumentIdentifier(uri), + RangeAdapter.point(0, 3), + context); + List> response = server.codeAction(codeActionParams).get(); + assertThat(response, hasSize(1)); + CodeAction action = response.get(0).getRight(); + assertThat(action.getEdit().getChanges(), hasKey(uri)); + List edits = action.getEdit().getChanges().get(uri); + assertThat(edits, hasSize(1)); + TextEdit edit = edits.get(0); + Document document = server.getProject().getDocument(uri); + document.applyEdit(edit.getRange(), edit.getNewText()); + assertThat(document.copyText(), equalTo("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n")); } @Test - public void outdatedVersionCodeAction() throws Exception { - String filename = "old-version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(filename, "$version: \"1\"\nnamespace test2") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - Range range0 = new Range(new Position(0, 0), new Position(0, 0)); - - Range firstLineRange = new Range(new Position(0, 0), new Position(0, 13)); - CodeActionParams params = new CodeActionParams( - new TextDocumentIdentifier(hs.file(filename).toURI().toString()), - range0, - new CodeActionContext(VersionDiagnostics.createVersionDiagnostics(hs.file(filename), Collections.emptyMap())) - ); - List result = SmithyCodeActions.versionCodeActions(params); - assertEquals(1, result.size()); - assertEquals("Update the Smithy version to 2", result.get(0).getTitle()); - // range is where the diagnostic is found - assertEquals(firstLineRange, result.get(0).getEdit().getChanges().values().stream().findFirst().get().get(0).getRange()); - } + public void mostRecentVersion() { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + 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()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .filter(c -> c.equals(VersionDiagnostics.SMITHY_DEFINE_VERSION) + || c.equals(VersionDiagnostics.SMITHY_UPDATE_VERSION)) + .collect(Collectors.toList()); + assertThat(codes, hasSize(0)); } @Test - public void correctVersionCodeAction() throws Exception { - String filename = "version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(filename, "$version: \"2\"\nnamespace test2") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - Range range0 = new Range(new Position(0, 0), new Position(0, 0)); - - CodeActionParams params = new CodeActionParams( - new TextDocumentIdentifier(hs.file(filename).toURI().toString()), - range0, - new CodeActionContext(VersionDiagnostics.createVersionDiagnostics(hs.file(filename), Collections.emptyMap())) - ); - List result = SmithyCodeActions.versionCodeActions(params); - assertEquals(0, result.size()); - } + public void noShapes() { + String model = "namespace com.foo\n"; + 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()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .collect(Collectors.toList()); + assertThat(codes, containsInAnyOrder(VersionDiagnostics.SMITHY_DEFINE_VERSION)); } -} \ No newline at end of file +} diff --git a/src/test/java/software/amazon/smithy/lsp/StubClient.java b/src/test/java/software/amazon/smithy/lsp/StubClient.java new file mode 100644 index 00000000..d76a352e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/StubClient.java @@ -0,0 +1,68 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.RegistrationParams; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.services.LanguageClient; + +public final class StubClient implements LanguageClient { + public List diagnostics = new ArrayList<>(); + public List shown = new ArrayList<>(); + public List logged = new ArrayList<>(); + + public StubClient() { + } + + public void clear() { + this.diagnostics.clear(); + this.shown.clear(); + this.logged.clear(); + } + + @Override + public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { + this.diagnostics.add(diagnostics); + } + + @Override + public void telemetryEvent(Object object) { + // TODO Auto-generated method stub + + } + + @Override + public void logMessage(MessageParams message) { + this.logged.add(message); + } + + @Override + public void showMessage(MessageParams messageParams) { + this.shown.add(messageParams); + } + + @Override + public CompletableFuture showMessageRequest(ShowMessageRequestParams requestParams) { + // TODO Auto-generated method stub + return null; + } + + @Override + public CompletableFuture registerCapability(RegistrationParams params) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture unregisterCapability(UnregistrationParams params) { + return CompletableFuture.completedFuture(null); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java new file mode 100644 index 00000000..ddddbf68 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -0,0 +1,186 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeMapper; + +/** + * Sets up a temporary directory containing a Smithy project + */ +public class TestWorkspace { + private static final NodeMapper MAPPER = new NodeMapper(); + private final Path root; + + private TestWorkspace(Path root) { + this.root = root; + } + + /** + * @return The path of the workspace root + */ + public Path getRoot() { + return root; + } + + /** + * @param filename The name of the file to get the URI for. Can be relative to the root + * @return The LSP URI for the given filename + */ + public String getUri(String filename) { + return this.root.resolve(filename).toUri().toString(); + } + + /** + * @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 + * a smithy-build.json with sources = ["main.smithy"] + */ + public static TestWorkspace singleModel(String model) { + return builder() + .withSourceFile("main.smithy", model) + .build(); + } + + /** + * @param models Strings of the models to create in the workspace + * @return A workspace with n models, each "model-n.smithy", with their given contents, + * and a smithy-build.json with sources = [..."model-n.smithy"] + */ + public static TestWorkspace multipleModels(String... models) { + Builder builder = builder(); + for (int i = 0; i < models.length; i++) { + builder.withSourceFile("model-" + i + ".smithy", models[i]); + } + return builder.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Dir { + String path; + Map sourceModels = new HashMap<>(); + Map importModels = new HashMap<>(); + List

sourceDirs = new ArrayList<>(); + List importDirs = new ArrayList<>(); + + public Dir path(String path) { + this.path = path; + return this; + } + + public Dir withSourceFile(String filename, String model) { + this.sourceModels.put(filename, model); + return this; + } + + public Dir withImportFile(String filename, String model) { + this.importModels.put(filename, model); + return this; + } + + public Dir withSourceDir(Dir dir) { + this.sourceDirs.add(dir); + return this; + } + + public Dir withImportDir(Dir dir) { + this.importDirs.add(dir); + return this; + } + + protected void writeModels(Path toDir) { + try { + if (!Files.exists(toDir)) { + Files.createDirectory(toDir); + } + writeModels(toDir, sourceModels); + writeModels(toDir, importModels); + sourceDirs.forEach(d -> d.writeModels(toDir.resolve(d.path))); + importDirs.forEach(d -> d.writeModels(toDir.resolve(d.path))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void writeModels(Path toDir, Map models) throws Exception { + for (Map.Entry entry : models.entrySet()) { + Files.write(toDir.resolve(entry.getKey()), entry.getValue().getBytes(StandardCharsets.UTF_8)); + } + } + } + + public static final class Builder extends Dir { + private Builder() {} + + @Override + public Builder withSourceFile(String filename, String model) { + super.withSourceFile(filename, model); + return this; + } + + @Override + public Builder withImportFile(String filename, String model) { + super.withImportFile(filename, model); + return this; + } + + @Override + public Builder withSourceDir(Dir dir) { + super.withSourceDir(dir); + return this; + } + + @Override + public Builder withImportDir(Dir dir) { + super.withImportDir(dir); + return this; + } + + public TestWorkspace build() { + try { + if (path == null) { + path = "test"; + } + Path root = Files.createTempDirectory(path); + root.toFile().deleteOnExit(); + + List sources = new ArrayList<>(); + sources.addAll(sourceModels.keySet()); + sources.addAll(sourceDirs.stream().map(d -> d.path).collect(Collectors.toList())); + + List imports = new ArrayList<>(); + imports.addAll(importModels.keySet()); + imports.addAll(importDirs.stream().map(d -> d.path).collect(Collectors.toList())); + + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version("1") + .sources(sources) + .imports(imports) + .build(); + String configString = Node.prettyPrintJson(MAPPER.serialize(config)); + Files.write(root.resolve("smithy-build.json"), configString.getBytes(StandardCharsets.UTF_8)); + + writeModels(root); + + return new TestWorkspace(root); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java new file mode 100644 index 00000000..5fccfca3 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -0,0 +1,316 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static software.amazon.smithy.lsp.document.DocumentTest.string; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.RangeAdapter; +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\n" + + "def\n" + + "ghi\n" + + "\n" + + "\n"; + DocumentParser parser = DocumentParser.forDocument(Document.of(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(4, parser.position()); + assertEquals(2, parser.line()); + assertEquals(1, parser.column()); + + parser.jumpToLine(2); + assertEquals(8, parser.position()); + assertEquals(3, parser.line()); + assertEquals(1, parser.column()); + + parser.jumpToLine(3); + assertEquals(12, parser.position()); + assertEquals(4, parser.line()); + assertEquals(1, parser.column()); + + parser.jumpToLine(4); + assertEquals(13, parser.position()); + assertEquals(5, parser.line()); + assertEquals(1, parser.column()); + } + + @Test + public void jumpsToSource() { + String text = "abc\ndef\nghi\n"; + DocumentParser parser = DocumentParser.of(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, 5)); + 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(4)); + 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(11)); + 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("abc\ndef\n"); + DocumentParser incompleteNamespace = DocumentParser.of("abc\nnamespac"); + DocumentParser incompleteNamespaceValue = DocumentParser.of("namespace "); + DocumentParser likeNamespace = DocumentParser.of("anamespace com.foo\n"); + DocumentParser otherLikeNamespace = DocumentParser.of("namespacea com.foo"); + DocumentParser namespaceAtEnd = DocumentParser.of("\n\nnamespace com.foo"); + DocumentParser brokenNamespace = DocumentParser.of("\nname space com.foo\n"); + DocumentParser commentedNamespace = DocumentParser.of("abc\n//namespace com.foo\n"); + DocumentParser wsPrefixedNamespace = DocumentParser.of("abc\n namespace com.foo\n"); + DocumentParser notNamespace = DocumentParser.of("namespace !foo"); + DocumentParser trailingComment = DocumentParser.of("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(namespaceAtEnd.documentNamespace().statementRange(), equalTo(RangeAdapter.of(2, 0, 2, 17))); + assertThat(brokenNamespace.documentNamespace(), nullValue()); + assertThat(commentedNamespace.documentNamespace(), nullValue()); + assertThat(wsPrefixedNamespace.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(RangeAdapter.of(1, 4, 1, 21))); + assertThat(notNamespace.documentNamespace(), nullValue()); + assertThat(trailingComment.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(trailingComment.documentNamespace().statementRange(), equalTo(RangeAdapter.of(0, 0, 0, 22))); + } + + @Test + public void getsDocumentImports() { + DocumentParser noImports = DocumentParser.of("abc\ndef\n"); + DocumentParser incompleteImport = DocumentParser.of("abc\nus"); + DocumentParser incompleteImportValue = DocumentParser.of("use "); + DocumentParser oneImport = DocumentParser.of("use com.foo#bar"); + DocumentParser leadingWsImport = DocumentParser.of(" use com.foo#bar"); + DocumentParser trailingCommentImport = DocumentParser.of("use com.foo#bar//foo"); + DocumentParser commentedImport = DocumentParser.of("//use com.foo#bar"); + DocumentParser multiImports = DocumentParser.of("use com.foo#bar\nuse com.foo#baz"); + DocumentParser notImport = DocumentParser.of("usea com.foo#bar"); + + assertThat(noImports.documentImports(), nullValue()); + assertThat(incompleteImport.documentImports(), nullValue()); + assertThat(incompleteImportValue.documentImports(), nullValue()); + 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(multiImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz")); + assertThat(notImport.documentImports(), nullValue()); + + // Some of these aren't shape ids, but its ok + DocumentParser brokenImport = DocumentParser.of("use com.foo"); + DocumentParser commentSeparatedImports = DocumentParser.of("use com.foo#bar //foo\nuse com.foo#baz\n//abc\nuse com.foo#foo"); + DocumentParser oneBrokenImport = DocumentParser.of("use com.foo\nuse com.foo#bar"); + DocumentParser innerBrokenImport = DocumentParser.of("use com.foo#bar\nuse com.foo\nuse com.foo#baz"); + DocumentParser innerNotImport = DocumentParser.of("use com.foo#bar\nstring Foo\nuse com.foo#baz"); + assertThat(brokenImport.documentImports().imports(), containsInAnyOrder("com.foo")); + assertThat(commentSeparatedImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz", "com.foo#foo")); + assertThat(oneBrokenImport.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo")); + assertThat(innerBrokenImport.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo", "com.foo#baz")); + assertThat(innerNotImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); + } + + @Test + public void getsDocumentVersion() { + DocumentParser noVersion = DocumentParser.of("abc\ndef"); + DocumentParser notVersion = DocumentParser.of("$versionNot: \"2\""); + DocumentParser noDollar = DocumentParser.of("version: \"2\""); + DocumentParser noColon = DocumentParser.of("$version \"2\""); + DocumentParser commented = DocumentParser.of("//$version: \"2\""); + DocumentParser leadingWs = DocumentParser.of(" $version: \"2\""); + DocumentParser leadingLines = DocumentParser.of("\n\n//abc\n$version: \"2\""); + DocumentParser notStringNode = DocumentParser.of("$version: 2"); + DocumentParser trailingComment = DocumentParser.of("$version: \"2\"//abc"); + DocumentParser trailingLine = DocumentParser.of("$version: \"2\"\n"); + DocumentParser invalidNode = DocumentParser.of("$version: \"2"); + DocumentParser notFirst = DocumentParser.of("$foo: \"bar\"\n// abc\n$version: \"2\""); + DocumentParser notSecond = DocumentParser.of("$foo: \"bar\"\n$bar: 1\n// abc\n$baz: 2\n $version: \"2\""); + DocumentParser notFirstNoVersion = DocumentParser.of("$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(leadingWs.documentVersion().version(), equalTo("2")); + assertThat(leadingLines.documentVersion().version(), equalTo("2")); + assertThat(notStringNode.documentVersion(), nullValue()); + assertThat(trailingComment.documentVersion().version(), equalTo("2")); + assertThat(trailingLine.documentVersion().version(), equalTo("2")); + assertThat(invalidNode.documentVersion(), nullValue()); + assertThat(notFirst.documentVersion().version(), equalTo("2")); + assertThat(notSecond.documentVersion().version(), equalTo("2")); + assertThat(notFirstNoVersion.documentVersion(), nullValue()); + + Range leadingWsRange = leadingWs.documentVersion().range(); + Range trailingCommentRange = trailingComment.documentVersion().range(); + Range trailingLineRange = trailingLine.documentVersion().range(); + Range notFirstRange = notFirst.documentVersion().range(); + Range notSecondRange = notSecond.documentVersion().range(); + assertThat(leadingWs.getDocument().copyRange(leadingWsRange), equalTo("$version: \"2\"")); + assertThat(trailingComment.getDocument().copyRange(trailingCommentRange), equalTo("$version: \"2\"")); + assertThat(trailingLine.getDocument().copyRange(trailingLineRange), equalTo("$version: \"2\"")); + assertThat(notFirst.getDocument().copyRange(notFirstRange), equalTo("$version: \"2\"")); + assertThat(notSecond.getDocument().copyRange(notSecondRange), equalTo("$version: \"2\"")); + } + + @Test + public void getsDocumentShapes() { + String text = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "structure Bar {\n" + + " bar: Foo\n" + + "}\n" + + "enum Baz {\n" + + " ONE\n" + + " TWO\n" + + "}\n" + + "intEnum Biz {\n" + + " ONE = 1\n" + + "}\n" + + "@mixin\n" + + "structure Boz {\n" + + " elided: String\n" + + "}\n" + + "structure Mixed with [Boz] {\n" + + " $elided\n" + + "}\n" + + "operation Get {\n" + + " input := {\n" + + " a: Integer\n" + + " }\n" + + "}\n"; + 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(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")); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java new file mode 100644 index 00000000..9b535d27 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -0,0 +1,460 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.RangeAdapter; + +public class DocumentTest { + @Test + public void appliesTrailingReplacementEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(1) + .startCharacter(2) + .endLine(1) + .endCharacter(3) + .build(); + String editText = "g"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("abc\n" + + "deg")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(4)); + } + + @Test + public void appliesAppendingEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(1) + .startCharacter(3) + .endLine(1) + .endCharacter(3) + .build(); + String editText = "g"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("abc\n" + + "defg")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(4)); + } + + @Test + public void appliesLeadingReplacementEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(1) + .build(); + String editText = "z"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("zbc\n" + + "def")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(4)); + } + + @Test + public void appliesPrependingEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(0) + .build(); + String editText = "z"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("zabc\n" + + "def")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(5)); + } + + @Test + public void appliesInnerReplacementEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(0) + .startCharacter(1) + .endLine(1) + .endCharacter(1) + .build(); + String editText = "zy\n" + + "x"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("azy\n" + + "xef")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(4)); + } + + @Test + public void appliesPrependingAndReplacingEdit() { + String s = "abc"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(1) + .build(); + String editText = "zy"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("zybc")); + assertThat(document.indexOfLine(0), equalTo(0)); + } + + @Test + public void appliesInsertionEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(0) + .startCharacter(2) + .endLine(0) + .endCharacter(2) + .build(); + String editText = "zx\n" + + "y"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("abzx\n" + + "yc\n" + + "def")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(5)); + assertThat(document.indexOfLine(2), equalTo(8)); + } + + @Test + public void appliesDeletionEdit() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Range editRange = new RangeAdapter() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(1) + .build(); + String editText = ""; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("bc\n" + + "def")); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(3)); + } + + @Test + public void getsIndexOfLine() { + String s = "abc\n" + + "def\n" + + "hij\n"; + Document document = Document.of(s); + + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(-1), equalTo(-1)); + assertThat(document.indexOfLine(1), equalTo(4)); + assertThat(document.indexOfLine(2), equalTo(8)); + assertThat(document.indexOfLine(3), equalTo(12)); + assertThat(document.indexOfLine(4), equalTo(-1)); + } + + @Test + public void getsIndexOfPosition() { + Document document = Document.of("abc\ndef"); + + assertThat(Document.of("").indexOfPosition(new Position(0, 0)), is(-1)); + assertThat(Document.of("").indexOfPosition(new Position(-1, 0)), is(-1)); + assertThat(document.indexOfPosition(new Position(0, 0)), is(0)); + assertThat(document.indexOfPosition(new Position(0, 3)), is(3)); + assertThat(document.indexOfPosition(new Position(1, 0)), is(4)); + assertThat(document.indexOfPosition(new Position(1, 2)), is(6)); + assertThat(document.indexOfPosition(new Position(1, 3)), is(-1)); + assertThat(document.indexOfPosition(new Position(0, 4)), is(-1)); + assertThat(document.indexOfPosition(new Position(2, 0)), is(-1)); + } + + @Test + public void getsPositionAtIndex() { + Document document = Document.of("abc\ndef\nhij\n"); + + assertThat(Document.of("").positionAtIndex(0), nullValue()); + assertThat(Document.of("").positionAtIndex(-1), nullValue()); + assertThat(document.positionAtIndex(0), equalTo(new Position(0, 0))); + assertThat(document.positionAtIndex(3), equalTo(new Position(0, 3))); + assertThat(document.positionAtIndex(4), equalTo(new Position(1, 0))); + assertThat(document.positionAtIndex(11), equalTo(new Position(2, 3))); + assertThat(document.positionAtIndex(12), nullValue()); + } + + @Test + public void getsEnd() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + Position end = document.end(); + + assertThat(end.getLine(), equalTo(1)); + assertThat(end.getCharacter(), equalTo(3)); + } + + @Test + public void borrowsToken() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(0, 2)); + + assertThat(token, string("abc")); + } + + @Test + public void borrowsTokenWithNoWs() { + String s = "abc"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(0, 1)); + + assertThat(token, string("abc")); + } + + @Test + public void borrowsTokenAtStart() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(0, 0)); + + assertThat(token, string("abc")); + } + + @Test + public void borrowsTokenAtEnd() { + String s = "abc\n" + + "def"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(1, 2)); + + assertThat(token, string("def")); + } + + @Test + public void borrowsTokenAtBoundaryStart() { + String s = "a bc d"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(0, 2)); + + assertThat(token, string("bc")); + } + + @Test + public void borrowsTokenAtBoundaryEnd() { + String s = "a bc d"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(0, 3)); + + assertThat(token, string("bc")); + } + + @Test + public void doesntBorrowNonToken() { + String s = "abc def"; + Document document = Document.of(s); + + CharSequence token = document.borrowToken(new Position(0, 3)); + + assertThat(token, nullValue()); + } + + @Test + public void borrowsLine() { + Document document = Document.of("abc\n\ndef"); + + assertThat(Document.of("").borrowLine(0), string("")); + assertThat(document.borrowLine(0), string("abc\n")); + assertThat(document.borrowLine(1), string("\n")); + assertThat(document.borrowLine(2), string("def")); + assertThat(document.borrowLine(-1), nullValue()); + assertThat(document.borrowLine(3), nullValue()); + } + + @Test + public void getsNextIndexOf() { + Document document = Document.of("abc\ndef"); + + assertThat(Document.of("").nextIndexOf("a", 0), is(-1)); + assertThat(document.nextIndexOf("a", 0), is(0)); + assertThat(document.nextIndexOf("a", 1), is(-1)); + assertThat(document.nextIndexOf("abc", 0), is(0)); + assertThat(document.nextIndexOf("abc", 1), is(-1)); // doesn't match if match goes out of boundary + assertThat(document.nextIndexOf("\n", 3), is(3)); + assertThat(document.nextIndexOf("f", 6), is(6)); + assertThat(document.nextIndexOf("f", 7), is(-1)); // oob + } + + @Test + public void getsLastIndexOf() { + Document document = Document.of("abc\ndef"); + + assertThat(Document.of("").lastIndexOf("a", 1), is(-1)); + assertThat(document.lastIndexOf("a", 0), is(0)); // start + assertThat(document.lastIndexOf("a", 1), is(0)); + assertThat(document.lastIndexOf("a", 6), is(0)); + assertThat(document.lastIndexOf("f", 6), is(6)); + assertThat(document.lastIndexOf("f", 7), is(6)); // oob + assertThat(document.lastIndexOf("\n", 3), is(3)); + assertThat(document.lastIndexOf("ab", 1), is(0)); + assertThat(document.lastIndexOf("ab", 0), is(0)); // can match even if match goes out of boundary + assertThat(document.lastIndexOf("ab", -1), is(-1)); + assertThat(document.lastIndexOf(" ", 8), is(-1)); // not found + } + + @Test + public void borrowsSpan() { + Document empty = Document.of(""); + Document line = Document.of("abc"); + Document multi = Document.of("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, 4), string("abc\n")); // with newline + assertThat(multi.borrowSpan(3, 5), string("\nd")); // inner + assertThat(multi.borrowSpan(5, 9), string("ef\n\n")); // up to end + } + + @Test + public void getsLineOfIndex() { + Document empty = Document.of(""); + Document single = Document.of("abc"); + Document twoLine = Document.of("abc\ndef"); + Document leadingAndTrailingWs = Document.of("\nabc\n"); + Document threeLine = Document.of("abc\ndef\nhij\n"); + + assertThat(empty.lineOfIndex(1), is(-1)); // oob + assertThat(single.lineOfIndex(0), is(0)); // start + assertThat(single.lineOfIndex(2), is(0)); // end + assertThat(single.lineOfIndex(3), is(-1)); // oob + assertThat(twoLine.lineOfIndex(1), is(0)); // first line + assertThat(twoLine.lineOfIndex(4), is(1)); // second line start + assertThat(twoLine.lineOfIndex(3), is(0)); // new line + assertThat(twoLine.lineOfIndex(6), is(1)); // end + assertThat(twoLine.lineOfIndex(7), is(-1)); // oob + assertThat(leadingAndTrailingWs.lineOfIndex(0), is(0)); // new line + assertThat(leadingAndTrailingWs.lineOfIndex(1), is(1)); // start of line + assertThat(leadingAndTrailingWs.lineOfIndex(4), is(1)); // new line + assertThat(threeLine.lineOfIndex(12), is(-1)); + assertThat(threeLine.lineOfIndex(11), is(2)); + } + + @Test + public void borrowsId() { + Document empty = Document.of(""); + Document notId = Document.of("?!&"); + Document onlyId = Document.of("abc"); + Document split = Document.of("abc.def hij"); + Document technicallyBroken = Document.of("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); + Document technicallyValid = Document.of("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); + + assertThat(empty.borrowId(new Position(0, 0)), nullValue()); + assertThat(notId.borrowId(new Position(0, 0)), nullValue()); + assertThat(notId.borrowId(new Position(0, 2)), nullValue()); + assertThat(onlyId.borrowId(new Position(0, 0)), string("abc")); + assertThat(onlyId.borrowId(new Position(0, 2)), string("abc")); + assertThat(onlyId.borrowId(new Position(0, 3)), nullValue()); + assertThat(split.borrowId(new Position(0, 0)), string("abc.def")); + assertThat(split.borrowId(new Position(0, 6)), string("abc.def")); + assertThat(split.borrowId(new Position(0, 7)), nullValue()); + assertThat(split.borrowId(new Position(0, 8)), string("hij")); + assertThat(technicallyBroken.borrowId(new Position(0, 0)), string("com.foo#")); + assertThat(technicallyBroken.borrowId(new Position(0, 3)), string("com.foo#")); + assertThat(technicallyBroken.borrowId(new Position(0, 7)), string("com.foo#")); + assertThat(technicallyBroken.borrowId(new Position(0, 9)), string("com.foo$")); + assertThat(technicallyBroken.borrowId(new Position(0, 16)), string("com.foo$")); + assertThat(technicallyBroken.borrowId(new Position(0, 18)), string("com.foo.")); + assertThat(technicallyBroken.borrowId(new Position(0, 25)), string("com.foo.")); + assertThat(technicallyBroken.borrowId(new Position(0, 27)), string("com$foo$bar")); + assertThat(technicallyBroken.borrowId(new Position(0, 30)), string("com$foo$bar")); + assertThat(technicallyBroken.borrowId(new Position(0, 37)), string("com$foo$bar")); + assertThat(technicallyBroken.borrowId(new Position(0, 39)), string("com...foo")); + assertThat(technicallyBroken.borrowId(new Position(0, 43)), string("com...foo")); + assertThat(technicallyBroken.borrowId(new Position(0, 49)), string("$foo")); + assertThat(technicallyBroken.borrowId(new Position(0, 54)), string(".foo")); + assertThat(technicallyBroken.borrowId(new Position(0, 59)), string("#foo")); + assertThat(technicallyValid.borrowId(new Position(0, 0)), string("com.foo#bar")); + assertThat(technicallyValid.borrowId(new Position(0, 12)), string("com.foo#bar$baz")); + assertThat(technicallyValid.borrowId(new Position(0, 28)), string("com.foo")); + assertThat(technicallyValid.borrowId(new Position(0, 36)), string("foo#bar")); + assertThat(technicallyValid.borrowId(new Position(0, 44)), string("foo#bar$baz")); + assertThat(technicallyValid.borrowId(new Position(0, 56)), string("foo$bar")); + } + + public static Matcher string(String other) { + return new CustomTypeSafeMatcher(other) { + @Override + protected boolean matchesSafely(CharSequence item) { + return other.equals(item.toString()); + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java b/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java deleted file mode 100644 index 4f0fadc9..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -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.Set; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.CompletionItem; -import org.junit.Test; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.utils.IoUtils; -import software.amazon.smithy.utils.MapUtils; -import software.amazon.smithy.utils.SetUtils; - -public class CompletionsTest { - - @Test - public void resolveCurrentNamespace() throws Exception { - String barNamespace = "namespace bar"; - - String barContent = barNamespace + "\nstructure Hello{}\ninteger MyId2"; - String testContent = "namespace test\n@trait\nstructure Foo {}"; - Map files = MapUtils.ofEntries( - MapUtils.entry("bar/def1.smithy", barContent), - MapUtils.entry("test/def2.smithy", testContent) - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyProject proj = hs.getProject(); - - DocumentPreamble testPreamble = Document.detectPreamble(hs.readFile(hs.file("test/def2.smithy"))); - List itemsWithEdit = Completions.resolveImports(proj.getCompletions("Hello", false, Optional.empty()), - testPreamble); - assertEquals("\nuse bar#Hello\n", itemsWithEdit.get(0).getAdditionalTextEdits().get(0).getNewText()); - - DocumentPreamble barPreamble = Document.detectPreamble(hs.readFile(hs.file("bar/def1.smithy"))); - List itemsWithEdit2 = Completions.resolveImports(proj.getCompletions("Hello", false, Optional.empty()), - barPreamble); - assertNull(itemsWithEdit2.get(0).getAdditionalTextEdits()); - } - } - - @Test - public void multiFileV1() throws Exception { - Path baseDir = Paths.get(Completions.class.getResource("models/v1").toURI()); - Path traitDefModel = baseDir.resolve("trait-def.smithy"); - String traitDef = IoUtils.readUtf8File(traitDefModel); - - Map files = MapUtils.ofEntries( - MapUtils.entry("def1.smithy", "namespace test\nstring MyId"), - MapUtils.entry("bar/def2.smithy", "namespace test\nstructure Hello{}\ninteger MyId2"), - MapUtils.entry("foo/hello/def3.smithy", "namespace test\n@test()\n@trait\nstructure Foo {}"), - MapUtils.entry("foo/hello/def4.smithy", "namespace test\n@http()\noperation Bar{}"), - MapUtils.entry("trait-def.smithy", traitDef) - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyProject proj = hs.getProject(); - // Complete match - assertEquals(SetUtils.of("Foo"), completeNames(proj, "Foo", false)); - // Partial match - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "MyI", false)); - // Partial match (case insensitive) - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "myi", false)); - - // no matches - assertEquals(SetUtils.of(), completeNames(proj, "basdasdasdasd", false)); - // empty token - assertEquals(SetUtils.of(), completeNames(proj, "", false)); - // built-in - assertEquals(SetUtils.of("string", "String"), completeNames(proj, "Strin", false)); - assertEquals(SetUtils.of("integer", "Integer"), completeNames(proj, "intege", false)); - // Structure trait with zero required members and default. - assertEquals(SetUtils.of("trait", "trait()"), completeNames(proj, "trai", true, "test#Foo")); - // Completions for each supported node value type. - assertEquals(SetUtils.of("test(blob: \"\", bool: true|false, short: , integer: , long: , float: ," + - " double: , bigDecimal: , bigInteger: , string: \"\", timestamp: \"\", list: []," + - " set: [], map: {}, struct: {nested: {nestedMember: \"\"}}, union: {})", "test()"), - completeNames(proj, "test", true, "test#Foo")); - // Limit completions to traits that can be applied to target shape. - // Other http* traits cannot apply to an operation. - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completeNames(proj, "htt", true, "test#Bar")); - - } - } - - @Test - public void multiFileV2() throws Exception { - Path baseDir = Paths.get(Completions.class.getResource("models/v2").toURI()); - Path traitDefModel = baseDir.resolve("trait-def.smithy"); - String traitDef = IoUtils.readUtf8File(traitDefModel); - - Map files = MapUtils.ofEntries( - MapUtils.entry("def1.smithy", "namespace test\nstring MyId"), - MapUtils.entry("bar/def2.smithy", "namespace test\nstructure Hello{}\ninteger MyId2"), - MapUtils.entry("foo/hello/def3.smithy", "namespace test\n@test()\n@trait\nstructure Foo {}"), - MapUtils.entry("foo/hello/def4.smithy", "namespace test\n@http()\noperation Bar{}"), - MapUtils.entry("trait-def.smithy", traitDef) - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyProject proj = hs.getProject(); - // Complete match - assertEquals(SetUtils.of("Foo"), completeNames(proj, "Foo", false)); - // Partial match - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "MyI", false)); - // Partial match (case insensitive) - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "myi", false)); - - // no matches - assertEquals(SetUtils.of(), completeNames(proj, "basdasdasdasd", false)); - // empty token - assertEquals(SetUtils.of(), completeNames(proj, "", false)); - // built-in - assertEquals(SetUtils.of("string", "String"), completeNames(proj, "Strin", false)); - assertEquals(SetUtils.of("integer", "Integer"), completeNames(proj, "intege", false)); - // Structure trait with zero required members and default. - assertEquals(SetUtils.of("trait", "trait()"), completeNames(proj, "trai", true, "test#Foo")); - // Completions for each supported node value type. - assertEquals(SetUtils.of("test(blob: \"\", bool: true|false, short: , integer: , long: , float: ," + - " double: , bigDecimal: , bigInteger: , string: \"\", timestamp: \"\", list: []," + - " map: {}, struct: {nested: {nestedMember: \"\"}}, union: {})", "test()"), - completeNames(proj, "test", true, "test#Foo")); - // Limit completions to traits that can be applied to target shape. - // Other http* traits cannot apply to an operation. - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completeNames(proj, "htt", true, "test#Bar")); - - } - } - - Set completeNames(SmithyProject proj, String token, boolean isTrait) { - return completeNames(proj, token, isTrait, null); - } - - Set completeNames(SmithyProject proj, String token, boolean isTrait, String shapeId) { - Optional target = Optional.empty(); - if (shapeId != null) { - target = Optional.of(ShapeId.from(shapeId)); - } - return proj.getCompletions(token, isTrait, target).stream().map(ci -> ci.getCompletionItem().getLabel()) - .collect(Collectors.toSet()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java deleted file mode 100644 index 40d2ca90..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; -import org.eclipse.lsp4j.Position; -import org.junit.Test; -import software.amazon.smithy.utils.ListUtils; - -public class DocumentTest { - - @Test - public void detectPreambleV1() throws Exception { - Path baseDir = Paths.get(Document.class.getResource("models/v1").toURI()); - Path preambleModel = baseDir.resolve("preamble.smithy"); - List lines = Files.readAllLines(preambleModel); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertEquals(new Position(2, 0), preamble.getNamespaceRange().getStart()); - assertEquals(new Position(2, 21), preamble.getNamespaceRange().getEnd()); - assertEquals("1.0", preamble.getIdlVersion().get()); - assertEquals(Optional.empty(), preamble.getOperationInputSuffix()); - assertEquals(Optional.empty(), preamble.getOperationOutputSuffix()); - assertEquals(new Position(4, 0), preamble.getUseBlockRange().getStart()); - assertEquals(new Position(6, 19), preamble.getUseBlockRange().getEnd()); - assertTrue(preamble.hasImport("ns.foo#FooTrait")); - assertTrue(preamble.hasImport("ns.bar#BarTrait")); - assertFalse(preamble.hasImport("ns.baz#Baz")); - assertTrue(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleV2() throws Exception { - Path baseDir = Paths.get(Document.class.getResource("models/v2").toURI()); - Path preambleModel = baseDir.resolve("preamble.smithy"); - List lines = Files.readAllLines(preambleModel); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertEquals(new Position(4, 0), preamble.getNamespaceRange().getStart()); - assertEquals(new Position(4, 21), preamble.getNamespaceRange().getEnd()); - assertEquals("2.0", preamble.getIdlVersion().get()); - assertEquals("Request", preamble.getOperationInputSuffix().get()); - assertEquals("Response", preamble.getOperationOutputSuffix().get()); - assertEquals(new Position(6, 0), preamble.getUseBlockRange().getStart()); - assertEquals(new Position(8, 19), preamble.getUseBlockRange().getEnd()); - assertTrue(preamble.hasImport("ns.foo#FooTrait")); - assertTrue(preamble.hasImport("ns.bar#BarTrait")); - assertFalse(preamble.hasImport("ns.baz#Baz")); - assertTrue(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleMajorIdlVersionOnly() { - List linesIdl1 = ListUtils.of( - "$version: \"1\"", - "namespace ns.one", - "@Foo", - "string MyString" - ); - DocumentPreamble preambleIdl1 = Document.detectPreamble(linesIdl1); - - List linesIdl2 = ListUtils.of( - "$version: \"2\"", - "namespace ns.two", - "@Foo", - "string MyString" - ); - DocumentPreamble preambleIdl2 = Document.detectPreamble(linesIdl2); - - assertEquals("ns.one", preambleIdl1.getCurrentNamespace().get()); - assertEquals("1", preambleIdl1.getIdlVersion().get()); - - assertEquals("ns.two", preambleIdl2.getCurrentNamespace().get()); - assertEquals("2", preambleIdl2.getIdlVersion().get()); - } - - @Test - public void detectPreambleNonBlankSeparated() { - List lines = ListUtils.of( - "$version: \"1.0\"", - "namespace ns.preamble", - "use ns.foo#Foo", - "@Foo", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertEquals(new Position(2, 0), preamble.getUseBlockRange().getStart()); - assertEquals(new Position(2, 14), preamble.getUseBlockRange().getEnd()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithoutUseStatements() { - List lines = ListUtils.of( - "$version: \"1.0\"", - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithoutVersionStatement() { - List lines = ListUtils.of( - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithMetadataAndVersion() { - List lines = ListUtils.of( - "$version: \"1.0\"", - "metadata foo = [", - " { bar: \"baz\" }", - "]", - "metadata hello = there", - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithMetadataWithoutVersionStatement() { - List lines = ListUtils.of( - "metadata foo = [", - " { bar: \"baz\" }", - "]", - "metadata hello = there", - "", - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithMetadataWithoutVersionStatementBlankSeparated() { - List lines = ListUtils.of( - "metadata foo = [", - " { bar: \"baz\" }", - "]", - "metadata hello = there", - "", - "namespace ns.preamble", - "", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertTrue(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithoutMetadataOrVersionStatement() { - List lines = ListUtils.of( - "namespace ns.preamble", - "", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertTrue(preamble.isBlankSeparated()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/Harness.java b/src/test/java/software/amazon/smithy/lsp/ext/Harness.java deleted file mode 100644 index 26019edd..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/Harness.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.io.File; -import java.io.FileWriter; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; -import software.amazon.smithy.lsp.Utils; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.IoUtils; -import software.amazon.smithy.utils.ListUtils; - -public class Harness implements AutoCloseable { - private final File root; - private final File temp; - private final SmithyProject project; - private final SmithyBuildExtensions config; - - private Harness(File root, File temporary, SmithyProject project, SmithyBuildExtensions config) { - this.root = root; - this.temp = temporary; - this.project = project; - this.config = config; - } - - public File getRoot() { - return this.root; - } - - public SmithyProject getProject() { - return this.project; - } - - public File getTempFolder() { - return this.temp; - } - - public SmithyBuildExtensions getConfig() { - return this.config; - } - - private static File safeCreateFile(String path, String contents, File root) throws Exception { - File f = Paths.get(root.getAbsolutePath(), path).toFile(); - new File(f.getParent()).mkdirs(); - try (FileWriter fw = new FileWriter(f)) { - fw.write(contents); - fw.flush(); - } - - return f; - } - - public File file(String path) { - return Paths.get(root.getAbsolutePath(), path).toFile(); - } - - public List readFile(File file) throws Exception { - return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); - } - - @Override - public void close() { - root.deleteOnExit(); - } - - public static Harness create(SmithyBuildExtensions ext) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - return loadHarness(ext, hs, tmp, new MockDependencyResolver(ListUtils.of())); - } - - public static Harness create(SmithyBuildExtensions ext, Map files) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - for (Entry entry : files.entrySet()) { - safeCreateFile(entry.getKey(), entry.getValue(), hs); - } - return loadHarness(ext, hs, tmp, new MockDependencyResolver(ListUtils.of())); - } - - public static Harness create(SmithyBuildExtensions ext, List files) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - for (Path path : files) { - if (Utils.isJarFile(path.toString())) { - String contents = String.join(System.lineSeparator(), Utils.jarFileContents(path.toString())); - safeCreateFile(path.getFileName().toString(), contents, hs); - } else { - safeCreateFile(path.getFileName().toString(), IoUtils.readUtf8File(path), hs); - } - } - return loadHarness(ext, hs, tmp, new MockDependencyResolver(ListUtils.of())); - } - - public static Harness create(SmithyBuildExtensions ext, DependencyResolver resolver) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - return loadHarness(ext, hs, tmp, resolver); - } - - private static Harness loadHarness(SmithyBuildExtensions ext, File hs, File tmp, DependencyResolver resolver) throws Exception { - Either loaded = SmithyProject.load(ext, hs, resolver); - if (loaded.isRight()) - return new Harness(hs, tmp, loaded.getRight(), ext); - else - throw loaded.getLeft(); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java b/src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java deleted file mode 100644 index d2489b54..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java +++ /dev/null @@ -1,32 +0,0 @@ -package software.amazon.smithy.lsp.ext; - -import java.util.ArrayList; -import java.util.List; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; - -public class MockDependencyResolver implements DependencyResolver { - final List artifacts; - final List repositories = new ArrayList<>(); - final List coordinates = new ArrayList<>(); - - MockDependencyResolver(List artifacts) { - this.artifacts = artifacts; - } - - @Override - public void addRepository(MavenRepository repository) { - repositories.add(repository); - } - - @Override - public void addDependency(String coordinates) { - this.coordinates.add(coordinates); - } - - @Override - public List resolve() { - return artifacts; - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java b/src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java deleted file mode 100644 index c516ec72..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static software.amazon.smithy.model.validation.Severity.WARNING; - -import org.eclipse.lsp4j.Diagnostic; -import org.junit.Test; -import software.amazon.smithy.lsp.ProtocolAdapter; -import software.amazon.smithy.model.validation.ValidationEvent; - -public class ProtocolAdapterTests { - @Test - public void addIdToDiagnostic() { - final ValidationEvent vEvent = ValidationEvent.builder() - .message("Oops") - .id("should-show-up") - .severity(WARNING) - .build(); - final Diagnostic actual = ProtocolAdapter.toDiagnostic(vEvent); - assertEquals("should-show-up: Oops", actual.getMessage()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java deleted file mode 100644 index b65c5bfa..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.stream.Collectors; -import org.junit.Test; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.SetUtils; - -public class SmithyBuildExtensionsTest { - - @Test - public void parsingSmithyBuildFromString() throws ValidationException { - String mavenConfig = "{\"maven\": {\"dependencies\": [\"d1\", \"d2\"], \"repositories\":" + - "[{\"url\": \"r1\"}, {\"url\": \"r2\"}]}}"; - SmithyBuildExtensions loadedMavenConfig = SmithyBuildLoader.load(getResourcePath(), mavenConfig); - - assertEquals(SetUtils.of("d1", "d2"), loadedMavenConfig.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2"), loadedMavenConfig.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @Test - public void parsingSmithyBuildFromStringWithDeprecatedKeys() throws ValidationException { - String json = "{\"mavenRepositories\": [\"bla\", \"ta\"], \"mavenDependencies\": [\"a1\", \"a2\"]}"; - SmithyBuildExtensions loaded = SmithyBuildLoader.load(getResourcePath(), json); - - assertEquals(SetUtils.of("a1", "a2"), loaded.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("bla", "ta"), loaded.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @Test - public void partialParsing() throws ValidationException { - String noExtensions = "{}"; - SmithyBuildExtensions loadedNoExtensions = SmithyBuildLoader.load(getResourcePath(), noExtensions); - assertNotNull(loadedNoExtensions.getMavenConfig()); - assertEquals(SetUtils.of(), loadedNoExtensions.getMavenConfig().getDependencies()); - assertEquals(SetUtils.of(), loadedNoExtensions.getMavenConfig().getRepositories()); - - String noConfiguration = "{\"imports\": [\".\"]}"; - SmithyBuildExtensions loadedNoConfiguration = SmithyBuildLoader.load(getResourcePath(), noConfiguration); - assertNotNull(loadedNoConfiguration.getMavenConfig()); - assertEquals(SetUtils.of(), loadedNoConfiguration.getMavenConfig().getDependencies()); - assertEquals(SetUtils.of(), loadedNoConfiguration.getMavenConfig().getRepositories()); - - String noRepositories = "{\"mavenDependencies\": [\"a1\", \"a2\"]}"; - SmithyBuildExtensions loadedNoRepositories = SmithyBuildLoader.load(getResourcePath(), noRepositories); - assertNotNull(loadedNoRepositories.getMavenConfig()); - assertEquals(SetUtils.of("a1", "a2"), loadedNoRepositories.getMavenConfig().getDependencies()); - assertEquals(SetUtils.of(), loadedNoRepositories.getMavenConfig().getRepositories()); - - String noArtifacts = "{\"mavenRepositories\": [\"r1\", \"r2\"]}"; - SmithyBuildExtensions loadedNoArtifacts = SmithyBuildLoader.load(getResourcePath(), noArtifacts); - assertNotNull(loadedNoArtifacts.getMavenConfig()); - assertEquals(SetUtils.of(), loadedNoArtifacts.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2"), loadedNoArtifacts.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @Test - public void preferMavenConfig() throws ValidationException { - String conflictingConfig = "{\"maven\": {\"dependencies\": [\"d1\", \"d2\"], \"repositories\":" + - "[{\"url\": \"r1\"}, {\"url\": \"r2\"}]}, \"mavenRepositories\": [\"m1\", \"m2\"]," + - "\"mavenDependencies\": [\"a1\", \"a2\"]}"; - SmithyBuildExtensions loadedConflictingConfig = SmithyBuildLoader.load(getResourcePath(), conflictingConfig); - assertEquals(SetUtils.of("d1", "d2"), loadedConflictingConfig.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2"), loadedConflictingConfig.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @SuppressWarnings("deprecation") - @Test - public void merging() { - SmithyBuildExtensions.Builder builder = SmithyBuildExtensions.builder(); - - SmithyBuildExtensions other = SmithyBuildExtensions.builder().mavenDependencies(Arrays.asList("hello", "world")) - .mavenRepositories(Arrays.asList("hi", "there")).imports(Arrays.asList("i3", "i4")).build(); - - SmithyBuildExtensions result = builder.mavenDependencies(Arrays.asList("d1", "d2")) - .mavenRepositories(Arrays.asList("r1", "r2")).imports(Arrays.asList("i1", "i2")).merge(other).build(); - - assertEquals(SetUtils.of("d1", "d2", "hello", "world"), result.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2", "hi", "there"), result.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - assertEquals(ListUtils.of("i1", "i2", "i3", "i4"), result.getImports()); - } - - private Path getResourcePath() { - try { - return Paths.get(SmithyBuildExtensionsTest.class.getResource("empty-config.json").toURI()); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java deleted file mode 100644 index 5b7194de..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package software.amazon.smithy.lsp.ext; - -import static junit.framework.TestCase.assertTrue; - -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; -import org.junit.Test; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; - -public class SmithyBuildLoaderTest { - - @Test - public void mergesSmithyBuildConfigWhenLoading() throws ValidationException { - System.setProperty("FOO", "bar"); - SmithyBuildExtensions config = SmithyBuildLoader.load(getResourcePath()); - - MavenRepository repository = config.getMavenConfig().getRepositories().stream().findFirst().get(); - assertTrue(repository.getUrl().contains("example.com/maven/my_repo")); - assertTrue(repository.getHttpCredentials().get().contains("my_user:bar")); - } - - private Path getResourcePath() { - try { - return Paths.get(SmithyBuildLoaderTest.class.getResource("config-with-env.json").toURI()); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java deleted file mode 100644 index e7c68347..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java +++ /dev/null @@ -1,513 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.io.File; -import java.nio.charset.StandardCharsets; -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.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.junit.Test; -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.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.StringShape; -import software.amazon.smithy.model.traits.DocumentationTrait; -import software.amazon.smithy.model.traits.SinceTrait; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.utils.IoUtils; -import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.MapUtils; -import software.amazon.smithy.utils.SetUtils; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.StringContains.containsString; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class SmithyProjectTest { - - @Test - public void respectingImports() throws Exception { - List imports = Arrays.asList("bla", "foo"); - Map files = MapUtils.ofEntries(MapUtils.entry("test.smithy", "namespace testRoot"), - MapUtils.entry("bar/test.smithy", "namespace testBar"), - MapUtils.entry("foo/test.smithy", "namespace testFoo"), - MapUtils.entry("bla/test.smithy", "namespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().imports(imports).build(), files)) { - File inFoo = hs.file("foo/test.smithy"); - File inBla = hs.file("bla/test.smithy"); - - List smithyFiles = hs.getProject().getSmithyFiles(); - - assertEquals(ListUtils.of(inBla, inFoo), smithyFiles); - } - } - - @Test - public void respectingEmptyConfig() throws Exception { - Map files = MapUtils.ofEntries(MapUtils.entry("test.smithy", "namespace testRoot"), - MapUtils.entry("bar/test.smithy", "namespace testBar"), - MapUtils.entry("foo/test.smithy", "namespace testFoo"), - MapUtils.entry("bla/test.smithy", "namespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - - List expectedFiles = Files.walk(hs.getRoot().toPath()) - .filter(f -> f.getFileName().toString().endsWith(Constants.SMITHY_EXTENSION)).map(Path::toFile) - .collect(Collectors.toList()); - - List smithyFiles = hs.getProject().getSmithyFiles(); - - assertEquals(expectedFiles, smithyFiles); - } - } - - @Test - public void defaultsToMavenCentral() throws Exception { - SmithyBuildExtensions extensions = SmithyBuildExtensions.builder().build(); - MockDependencyResolver delegate = new MockDependencyResolver(ListUtils.of()); - File cache = File.createTempFile("classpath", ".json"); - DependencyResolver resolver = new FileCacheResolver(cache, System.currentTimeMillis(), delegate); - try (Harness hs = Harness.create(extensions, resolver)) { - assertEquals(delegate.repositories.stream().findFirst().get().getUrl(), "https://repo.maven.apache.org/maven2"); - } - } - - @Test - public void cachesExternalJars() throws Exception { - String repo1 = "https://repo.smithy.io"; - String repo2 = "https://repo.foo.com"; - System.setProperty(EnvironmentVariable.SMITHY_MAVEN_REPOS.toString(), - String.join("|", repo1, repo2)); - String dependency = "com.foo:bar:1.0.0"; - MavenRepository configuredRepo = MavenRepository.builder() - .url("https://repo.example.com") - .httpCredentials("user:pw") - .build(); - MavenConfig maven = MavenConfig.builder() - .dependencies(ListUtils.of(dependency)) - .repositories(SetUtils.of(configuredRepo)) - .build(); - List expectedRepos = ListUtils.of( - MavenRepository.builder().url(repo1).build(), - MavenRepository.builder().url(repo2).build(), - configuredRepo - ); - SmithyBuildExtensions extensions = SmithyBuildExtensions.builder().maven(maven).build(); - File cache = File.createTempFile("classpath", ".json"); - File jar = File.createTempFile("foo", ".json"); - Files.write(jar.toPath(), "{}".getBytes(StandardCharsets.UTF_8)); - ResolvedArtifact artifact = ResolvedArtifact.fromCoordinates(jar.toPath(), "com.foo:bar:1.0.0"); - MockDependencyResolver delegate = new MockDependencyResolver(ListUtils.of(artifact)); - DependencyResolver resolver = new FileCacheResolver(cache, System.currentTimeMillis(), delegate); - try (Harness hs = Harness.create(extensions, resolver)) { - assertTrue(delegate.repositories.containsAll(expectedRepos)); - assertEquals(expectedRepos.size(), delegate.repositories.size()); - assertEquals(dependency, delegate.coordinates.get(0)); - assertThat(IoUtils.readUtf8File(cache.toPath()), containsString(dependency)); - } - } - - @Test - public void ableToLoadWithUnknownTrait() throws Exception { - Path modelFile = Paths.get(getClass().getResource("models/unknown-trait.smithy").toURI()); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFile))) { - ValidatedResult modelValidatedResult = hs.getProject().getModel(); - assertFalse(modelValidatedResult.isBroken()); - } - } - - @Test - public void ignoresUnmodeledApplyStatements() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path main = baseDir.resolve("apply.smithy"); - Path imports = baseDir.resolve("apply-imports.smithy"); - List modelFiles = ListUtils.of(main, imports); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - // Structure shape unchanged by apply - correctLocation(locationMap, "com.main#SomeOpInput", 12, 0, 15, 1); - - // Member is unchanged by apply - correctLocation(locationMap, "com.main#SomeOpInput$body", 14, 4, 14, 16); - - // The mixed in member should have the source location from the mixin. - correctLocation(locationMap, "com.main#SomeOpInput$isTest", 8, 4, 8, 19); - - // Structure shape unchanged by apply - correctLocation(locationMap, "com.main#ArbitraryStructure", 25, 0, 27, 1); - - // Member is unchanged by apply - correctLocation(locationMap, "com.main#ArbitraryStructure$member", 26, 4, 26, 18); - - // Mixed-in member in another namespace unchanged by apply - correctLocation(locationMap, "com.imports#HasIsTestParam$isTest", 8, 4, 8, 19); - - // Structure in another namespace unchanged by apply - correctLocation(locationMap, "com.imports#HasIsTestParam", 7, 0, 9, 1); - } - } - - // https://github.com/awslabs/smithy-language-server/issues/100 - @Test - public void allowsEmptyStructsWithMixins() throws Exception { - String fileText = "$version: \"2\"\n" + - "\n" + - "namespace demo\n" + - "\n" + - "operation MyOp {\n" + - " output: MyOpOutput\n" + - "}\n" + - "\n" + - "@output\n" + - "structure MyOpOutput {}\n"; - - Map files = MapUtils.of("main.smithy", fileText); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - assertNotNull(hs.getProject()); - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "demo#MyOpOutput", 9, 0, 9, 23); - } - } - - // https://github.com/awslabs/smithy-language-server/issues/110 - // Note: This test is flaky, it may succeed even if the code being tested is incorrect. - @Test - public void handlesSameOperationNameBetweenNamespaces() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/operation-name-conflict").toURI()); - Path modelA = baseDir.resolve("a.smithy"); - Path modelB = baseDir.resolve("b.smithy"); - List modelFiles = ListUtils.of(modelA, modelB); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "a#HelloWorld", 4, 0, 13, 1); - correctLocation(locationMap, "b#HelloWorld", 6, 0, 15, 1); - } - } - - @Test - public void definitionLocationsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "com.foo#SingleLine", 4, 0, 4, 23); - correctLocation(locationMap, "com.foo#MultiLine", 6, 8,13, 9); - correctLocation(locationMap, "com.foo#SingleTrait", 16, 4, 16, 22); - correctLocation(locationMap, "com.foo#MultiTrait", 20, 0,21, 14); - correctLocation(locationMap, "com.foo#MultiTraitAndLineComments", 35, 0,37, 1); - correctLocation(locationMap,"com.foo#MultiTraitAndDocComments", 46, 0,48, 1); - correctLocation(locationMap, "com.example#OtherStructure", 7, 0, 11, 1); - } - } - - @Test - public void definitionLocationsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "com.foo#SingleLine", 6, 0, 6, 23); - correctLocation(locationMap, "com.foo#MultiLine", 8, 8,15, 9); - correctLocation(locationMap, "com.foo#SingleTrait", 18, 4, 18, 22); - correctLocation(locationMap, "com.foo#MultiTrait", 22, 0,23, 14); - correctLocation(locationMap, "com.foo#MultiTraitAndLineComments", 37, 0,39, 1); - correctLocation(locationMap, "com.foo#MultiTraitAndDocComments", 48, 0, 50, 1); - correctLocation(locationMap, "com.example#OtherStructure", 7, 0, 11, 1); - correctLocation(locationMap, "com.foo#StructWithDefaultSugar", 97, 0, 99, 1); - correctLocation(locationMap, "com.foo#MyInlineOperation", 101, 0, 109, 1); - correctLocation(locationMap, "com.foo#MyInlineOperationFooInput", 102, 13, 105, 5); - correctLocation(locationMap, "com.foo#MyInlineOperationBarOutput", 106, 14, 108, 5); - correctLocation(locationMap, "com.foo#UserIds", 112, 0, 118, 1); - correctLocation(locationMap, "com.foo#UserIds$email", 114, 4, 114, 17); - correctLocation(locationMap, "com.foo#UserIds$id", 117, 4, 117, 14); - correctLocation(locationMap, "com.foo#UserDetails", 121, 0, 123, 1); - correctLocation(locationMap, "com.foo#UserDetails$status", 122, 4, 122, 18); - correctLocation(locationMap, "com.foo#GetUser", 125, 0, 132, 1); - correctLocation(locationMap, "com.foo#GetUserFooInput", 126, 13, 128, 5); - correctLocation(locationMap, "com.foo#GetUserBarOutput", 129, 14, 131, 5); - correctLocation(locationMap, "com.foo#ElidedUserInfo", 134, 0, 140, 1); - correctLocation(locationMap, "com.foo#ElidedUserInfo$email", 136, 4, 136, 10); - correctLocation(locationMap, "com.foo#ElidedUserInfo$status", 139, 4, 139, 11); - correctLocation(locationMap, "com.foo#ElidedGetUser", 142, 0, 155, 1); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput", 143, 13, 148, 5); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$id", 146, 7, 146, 10); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$optional", 147, 7, 147, 23); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput", 149, 14, 154, 5); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$status", 151, 8, 151, 15); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$description", 153, 8, 153, 27); - correctLocation(locationMap, "com.foo#Suit", 157, 0, 162, 1); - correctLocation(locationMap, "com.foo#Suit$CLUB", 159, 4, 159, 17); - correctLocation(locationMap, "com.foo#Suit$SPADE", 161, 4, 161, 19); - - correctLocation(locationMap, "com.foo#MyInlineOperationReversed", 164, 0, 171, 1); - correctLocation(locationMap, "com.foo#MyInlineOperationReversedFooInput", 168, 13, 170, 5); - correctLocation(locationMap, "com.foo#MyInlineOperationReversedBarOutput", 165, 14, 167, 5); - - correctLocation(locationMap, "com.foo#FalseInlined", 175, 0, 178, 1); - correctLocation(locationMap, "com.foo#FalseInlinedFooInput", 180, 0, 182, 1); - correctLocation(locationMap, "com.foo#FalseInlinedBarOutput", 184, 0, 186, 1); - - correctLocation(locationMap, "com.foo#FalseInlinedReversed", 188, 0, 191, 1); - correctLocation(locationMap, "com.foo#FalseInlinedReversedFooInput", 193, 0, 195, 1); - correctLocation(locationMap, "com.foo#FalseInlinedReversedBarOutput", 197, 0, 199, 1); - - // Elided members from source mixin structure. - correctLocation(locationMap, "com.foo#ElidedUserInfo$id", 117, 4, 117, 14); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$email", 114, 4, 114, 17); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$status", 122, 4, 122, 18); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$email", 114, 4, 114, 17); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$id", 117, 4, 117, 14); - } - } - - @Test - public void definitionLocationsEmptySourceLocationsOnTraitV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path modelMain = baseDir.resolve("empty-source-location-trait.smithy"); - - StringShape stringShapeBar = StringShape.builder() - .id("ns.foo#Bar") - .source(new SourceLocation(modelMain.toString(), 5, 1)) - .build(); - - StringShape stringShapeBaz = StringShape.builder() - .id("ns.foo#Baz") - .addTrait(new DocumentationTrait("docs", SourceLocation.NONE)) - .addTrait(new SinceTrait("2022-05-12", new SourceLocation(modelMain.toString(), 7, 1))) - .source(new SourceLocation(modelMain.toString(), 8, 1)) - .build(); - - Model unvalidatedModel = Model.builder() - .addShape(stringShapeBar) - .addShape(stringShapeBaz) - .build(); - ValidatedResult model = Model.assembler().addModel(unvalidatedModel).assemble(); - SmithyProject project = new SmithyProject(Collections.emptyList(), Collections.emptyList(), - Collections.emptyList(), baseDir.toFile(), model); - Map locationMap = project.getLocations(); - - correctLocation(locationMap, "ns.foo#Bar", 4, 0, 4, 10); - correctLocation(locationMap, "ns.foo#Baz", 7, 0, 7, 10); - } - - @Test - public void definitionLocationsEmptySourceLocationsOnTraitV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path modelMain = baseDir.resolve("empty-source-location-trait.smithy"); - - StringShape stringShapeBar = StringShape.builder() - .id("ns.foo#Bar") - .source(new SourceLocation(modelMain.toString(), 5, 1)) - .build(); - - StringShape stringShapeBaz = StringShape.builder() - .id("ns.foo#Baz") - .addTrait(new DocumentationTrait("docs", SourceLocation.NONE)) - .addTrait(new SinceTrait("2022-05-12", new SourceLocation(modelMain.toString(), 7, 1))) - .source(new SourceLocation(modelMain.toString(), 8, 1)) - .build(); - - Model unvalidatedModel = Model.builder() - .addShape(stringShapeBar) - .addShape(stringShapeBaz) - .build(); - ValidatedResult model = Model.assembler().addModel(unvalidatedModel).assemble(); - SmithyProject project = new SmithyProject(Collections.emptyList(), Collections.emptyList(), - Collections.emptyList(), baseDir.toFile(), model); - Map locationMap = project.getLocations(); - - correctLocation(locationMap, "ns.foo#Bar", 4, 0, 4, 10); - correctLocation(locationMap, "ns.foo#Baz", 7, 0, 7, 10); - } - - @Test - public void shapeIdFromLocationV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyProject project = hs.getProject(); - String uri = hs.file("main.smithy").toString(); - String testUri = hs.file("test.smithy").toString(); - - assertFalse(project.getShapeIdFromLocation("non-existent-model-file.smithy", new Position(0, 0)).isPresent()); - assertFalse(project.getShapeIdFromLocation(uri, new Position(0, 0)).isPresent()); - // Position on shape start line, but before char start - assertFalse(project.getShapeIdFromLocation(uri, new Position(17, 0)).isPresent()); - // Position on shape end line, but after char end - assertFalse(project.getShapeIdFromLocation(uri, new Position(14, 10)).isPresent()); - // Position on shape start line - assertEquals(ShapeId.from("com.foo#SingleLine"), project.getShapeIdFromLocation(uri, - new Position(4, 10)).get()); - // Position on multi-line shape start line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(6, 8)).get()); - // Position on multi-line shape end line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(13, 6)).get()); - // Member positions - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(7,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(10,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(12,14)).get()); - // Member positions on target - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(7,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(10,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(12,18)).get()); - assertEquals(ShapeId.from("com.example#OtherStructure"), project.getShapeIdFromLocation(testUri, - new Position(7, 15)).get()); - } - } - - @Test - public void shapeIdFromLocationV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyProject project = hs.getProject(); - String uri = hs.file("main.smithy").toString(); - String testUri = hs.file("test.smithy").toString(); - - assertFalse(project.getShapeIdFromLocation("non-existent-model-file.smithy", new Position(0, 0)).isPresent()); - assertFalse(project.getShapeIdFromLocation(uri, new Position(0, 0)).isPresent()); - // Position on shape start line, but before char start - assertFalse(project.getShapeIdFromLocation(uri, new Position(19, 0)).isPresent()); - // Position on shape end line, but after char end - assertFalse(project.getShapeIdFromLocation(uri, new Position(16, 10)).isPresent()); - // Position on shape start line - Optional foo = project.getShapeIdFromLocation(uri, new Position(6, 10)); - assertEquals(ShapeId.from("com.foo#SingleLine"), project.getShapeIdFromLocation(uri, - new Position(6, 10)).get()); - // Position on multi-line shape start line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(8, 8)).get()); - // Position on multi-line shape end line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(15, 6)).get()); - // Member positions - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(9,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(12,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(14,14)).get()); - // Member positions on target - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(9,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(12,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(14,18)).get()); - assertEquals(ShapeId.from("com.foo#GetUser"), project.getShapeIdFromLocation(uri, - new Position(125,13)).get()); - assertEquals(ShapeId.from("com.foo#GetUserFooInput$optional"), project.getShapeIdFromLocation(uri, - new Position(127,14)).get()); - assertEquals(ShapeId.from("com.foo#GetUserBarOutput"), project.getShapeIdFromLocation(uri, - new Position(129,19)).get()); - assertEquals(ShapeId.from("com.foo#GetUserBarOutput$description"), project.getShapeIdFromLocation(uri, - new Position(130,12)).get()); - assertEquals(ShapeId.from("com.foo#ElidedUserInfo"), project.getShapeIdFromLocation(uri, - new Position(134,17)).get()); - assertEquals(ShapeId.from("com.foo#ElidedUserInfo$email"), project.getShapeIdFromLocation(uri, - new Position(136,8)).get()); - assertEquals(ShapeId.from("com.foo#ElidedUserInfo$status"), project.getShapeIdFromLocation(uri, - new Position(139,9)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUser"), project.getShapeIdFromLocation(uri, - new Position(142,18)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserFooInput"), project.getShapeIdFromLocation(uri, - new Position(144,18)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserFooInput$id"), project.getShapeIdFromLocation(uri, - new Position(146,10)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserFooInput$optional"), project.getShapeIdFromLocation(uri, - new Position(147,13)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserBarOutput"), project.getShapeIdFromLocation(uri, - new Position(149,16)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserBarOutput$status"), project.getShapeIdFromLocation(uri, - new Position(151,12)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserBarOutput$description"), project.getShapeIdFromLocation(uri, - new Position(153,18)).get()); - assertEquals(ShapeId.from("com.foo#Suit"), project.getShapeIdFromLocation(uri, - new Position(157,8)).get()); - assertEquals(ShapeId.from("com.foo#Suit$DIAMOND"), project.getShapeIdFromLocation(uri, - new Position(158,8)).get()); - assertEquals(ShapeId.from("com.foo#Suit$HEART"), project.getShapeIdFromLocation(uri, - new Position(160,8)).get()); - assertEquals(ShapeId.from("com.example#OtherStructure"), project.getShapeIdFromLocation(testUri, - new Position(7, 15)).get()); - assertEquals(ShapeId.from("com.foo#ShortI"), project.getShapeIdFromLocation(uri, - new Position(210,5)).get()); - assertEquals(ShapeId.from("com.foo#ShortI$c"), project.getShapeIdFromLocation(uri, - new Position(211,6)).get()); - assertEquals(ShapeId.from("com.foo#ShortO"), project.getShapeIdFromLocation(uri, - new Position(215,5)).get()); - assertEquals(ShapeId.from("com.foo#ShortO$d"), project.getShapeIdFromLocation(uri, - new Position(216,6)).get()); - } - } - - private void correctLocation(Map locationMap, String shapeId, int startLine, - int startColumn, int endLine, int endColumn) { - Location location = locationMap.get(ShapeId.from(shapeId)); - Range range = new Range(new Position(startLine, startColumn), new Position(endLine, endColumn)); - assertEquals(range, location.getRange()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java new file mode 100644 index 00000000..5313beec --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java @@ -0,0 +1,80 @@ +/* + * 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.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; + +import java.nio.file.Path; +import java.nio.file.Paths; +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.util.Result; + +public class ProjectConfigLoaderTest { + @Test + public void loadsConfigWithEnvVariable() { + System.setProperty("FOO", "bar"); + Path root = Paths.get(getClass().getResource("env-config").getPath()); + Result> result = ProjectConfigLoader.loadFromRoot(root); + + assertThat(result.isOk(), is(true)); + ProjectConfig config = result.unwrap(); + assertThat(config.getMaven().isPresent(), is(true)); + MavenConfig mavenConfig = config.getMaven().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 = Paths.get(getClass().getResource("legacy-config").getPath()); + Result> result = ProjectConfigLoader.loadFromRoot(root); + + assertThat(result.isOk(), is(true)); + ProjectConfig config = result.unwrap(); + assertThat(config.getMaven().isPresent(), is(true)); + MavenConfig mavenConfig = config.getMaven().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 = Paths.get(getClass().getResource("legacy-config-with-conflicts").getPath()); + Result> result = ProjectConfigLoader.loadFromRoot(root); + + assertThat(result.isOk(), is(true)); + ProjectConfig config = result.unwrap(); + assertThat(config.getMaven().isPresent(), is(true)); + MavenConfig mavenConfig = config.getMaven().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 = Paths.get(getClass().getResource("build-exts").getPath()); + Result> result = ProjectConfigLoader.loadFromRoot(root); + + assertThat(result.isOk(), is(true)); + ProjectConfig config = result.unwrap(); + assertThat(config.getImports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java new file mode 100644 index 00000000..e20dbc4f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -0,0 +1,225 @@ +/* + * 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.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.empty; +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.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.hasMessage; +import static software.amazon.smithy.lsp.document.DocumentTest.string; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.UriAdapter; +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.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; + +public class ProjectTest { + @Test + public void loadsFlatProject() { + Path root = Paths.get(getClass().getResource("flat").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getRoot(), equalTo(root)); + assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.getImports(), empty()); + assertThat(project.getDependencies(), empty()); + assertThat(project.getModelResult().isBroken(), is(false)); + assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsProjectWithMavenDep() { + Path root = Paths.get(getClass().getResource("maven-dep").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getRoot(), equalTo(root)); + assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.getImports(), empty()); + assertThat(project.getDependencies(), hasSize(3)); + assertThat(project.getModelResult().isBroken(), is(false)); + assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsProjectWithSubdir() { + Path root = Paths.get(getClass().getResource("subdirs").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getRoot(), equalTo(root)); + assertThat(project.getSources(), hasItem(root.resolve("model"))); + assertThat(project.getModelResult().isBroken(), is(false)); + assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsModelWithUnknownTrait() { + Path root = Paths.get(getClass().getResource("unknown-trait").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getRoot(), equalTo(root)); + assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.getModelResult().isBroken(), is(false)); // unknown traits don't break it + + List eventIds = project.getModelResult().getValidationEvents().stream() + .map(ValidationEvent::getId) + .collect(Collectors.toList()); + assertThat(eventIds, hasItem(containsString("UnresolvedTrait"))); + assertThat(project.getModelResult().getResult().isPresent(), is(true)); + assertThat(project.getModelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsWhenModelHasInvalidSyntax() { + Path root = Paths.get(getClass().getResource("invalid-syntax").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getRoot(), equalTo(root)); + assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.getModelResult().isBroken(), is(true)); + List eventIds = project.getModelResult().getValidationEvents().stream() + .map(ValidationEvent::getId) + .collect(Collectors.toList()); + assertThat(eventIds, hasItem("Model")); + + assertThat(project.getSmithyFiles().keySet(), hasItem(containsString("main.smithy"))); + SmithyFile main = project.getSmithyFile(UriAdapter.toUri(root.resolve("main.smithy").toString())); + assertThat(main, not(nullValue())); + assertThat(main.getDocument(), not(nullValue())); + assertThat(main.getNamespace(), string("com.foo")); + assertThat(main.getImports(), empty()); + + assertThat(main.getShapes(), hasSize(2)); + List shapeIds = main.getShapes().stream() + .map(Shape::toShapeId) + .map(ShapeId::toString) + .collect(Collectors.toList()); + assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar")); + + assertThat(main.getDocumentShapes(), hasSize(3)); + List documentShapeNames = main.getDocumentShapes().stream() + .map(documentShape -> documentShape.shapeName().toString()) + .collect(Collectors.toList()); + assertThat(documentShapeNames, hasItems("Foo", "bar", "String")); + } + + @Test + public void loadsProjectWithMultipleNamespaces() { + Path root = Paths.get(getClass().getResource("multiple-namespaces").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getSources(), hasItem(root.resolve("model"))); + assertThat(project.getModelResult().getValidationEvents(), empty()); + assertThat(project.getSmithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); + + SmithyFile a = project.getSmithyFile(UriAdapter.toUri(root.resolve("model/a.smithy").toString())); + assertThat(a.getDocument(), not(nullValue())); + assertThat(a.getNamespace(), string("a")); + List aShapeIds = a.getShapes().stream() + .map(Shape::toShapeId) + .map(ShapeId::toString) + .collect(Collectors.toList()); + assertThat(aShapeIds, hasItems("a#Hello", "a#HelloInput", "a#HelloOutput")); + List aDocumentShapeNames = a.getDocumentShapes().stream() + .map(documentShape -> documentShape.shapeName().toString()) + .collect(Collectors.toList()); + assertThat(aDocumentShapeNames, hasItems("Hello", "name", "String")); + + SmithyFile b = project.getSmithyFile(UriAdapter.toUri(root.resolve("model/b.smithy").toString())); + assertThat(b.getDocument(), not(nullValue())); + assertThat(b.getNamespace(), string("b")); + List bShapeIds = b.getShapes().stream() + .map(Shape::toShapeId) + .map(ShapeId::toString) + .collect(Collectors.toList()); + assertThat(bShapeIds, hasItems("b#Hello", "b#HelloInput", "b#HelloOutput")); + List bDocumentShapeNames = b.getDocumentShapes().stream() + .map(documentShape -> documentShape.shapeName().toString()) + .collect(Collectors.toList()); + assertThat(bDocumentShapeNames, hasItems("Hello", "name", "String")); + } + + @Test + public void loadsProjectWithExternalJars() { + Path root = Paths.get(getClass().getResource("external-jars").getPath()); + Result> result = ProjectLoader.load(root); + + assertThat(result.isOk(), is(true)); + Project project = result.unwrap(); + assertThat(project.getSources(), containsInAnyOrder(root.resolve("test-traits.smithy"), root.resolve("test-validators.smithy"))); + assertThat(project.getSmithyFiles().keySet(), 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.getModelResult().isBroken(), is(true)); + assertThat(project.getModelResult().getValidationEvents(Severity.ERROR), hasItem(hasMessage(containsString("Proto index 1")))); + + assertThat(project.getModelResult().getResult().isPresent(), is(true)); + Model model = project.getModelResult().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 = Paths.get(getClass().getResource("broken/missing-version").getPath()); + Result> result = ProjectLoader.load(root); + + assertThat(result.isErr(), is(true)); + } + + @Test + public void failsLoadingUnparseableSmithyBuildJson() { + Path root = Paths.get(getClass().getResource("broken/parse-failure").getPath()); + Result> result = ProjectLoader.load(root); + + assertThat(result.isErr(), is(true)); + } + + @Test + public void failsLoadingProjectWithNonExistingSource() { + Path root = Paths.get(getClass().getResource("broken/source-doesnt-exist").getPath()); + Result> result = ProjectLoader.load(root); + + assertThat(result.isErr(), is(true)); + } + + + @Test + public void failsLoadingUnresolvableMavenDependency() { + Path root = Paths.get(getClass().getResource("broken/unresolvable-maven-dependency").getPath()); + Result> result = ProjectLoader.load(root); + + assertThat(result.isErr(), is(true)); + } + + @Test + public void failsLoadingUnresolvableProjectDependency() { + Path root = Paths.get(getClass().getResource("broken/unresolvable-maven-dependency").getPath()); + Result> result = ProjectLoader.load(root); + + assertThat(result.isErr(), is(true)); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java b/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java new file mode 100644 index 00000000..0143c00f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; + +import java.util.Arrays; +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; + +public class SmithyBuildExtensionsTest { + @SuppressWarnings("deprecation") + @Test + public void merging() { + SmithyBuildExtensions.Builder builder = SmithyBuildExtensions.builder(); + + SmithyBuildExtensions other = SmithyBuildExtensions.builder().mavenDependencies(Arrays.asList("hello", "world")) + .mavenRepositories(Arrays.asList("hi", "there")).imports(Arrays.asList("i3", "i4")).build(); + + SmithyBuildExtensions result = builder.mavenDependencies(Arrays.asList("d1", "d2")) + .mavenRepositories(Arrays.asList("r1", "r2")).imports(Arrays.asList("i1", "i2")).merge(other).build(); + + MavenConfig mavenConfig = result.getMavenConfig(); + assertThat(mavenConfig.getDependencies(), containsInAnyOrder("d1", "d2", "hello", "world")); + List urls = mavenConfig.getRepositories().stream() + .map(MavenRepository::getUrl) + .collect(Collectors.toList()); + assertThat(urls, containsInAnyOrder("r1", "r2", "hi", "there")); + assertThat(result.getImports(), containsInAnyOrder("i1", "i2", "i3", "i4")); + } +} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json b/src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json deleted file mode 100644 index b9f92c0d..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "2.0" -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy deleted file mode 100644 index 9228eaa1..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy +++ /dev/null @@ -1,3 +0,0 @@ -$version: "2" -namespace test -structure City { } diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy deleted file mode 100644 index 9a699186..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy +++ /dev/null @@ -1,5 +0,0 @@ -$version: "2" -namespace test -structure Weather { - @required city: City -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy deleted file mode 100644 index db473a06..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy +++ /dev/null @@ -1,16 +0,0 @@ -$version: "2.0" - -namespace com.foo - -use com.external#unknownTrait - -@unknownTrait -structure Foo {} - -structure Bar { - member: Foo -} - -structure Baz { - member: Bar -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy deleted file mode 100644 index c581ea26..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy +++ /dev/null @@ -1,6 +0,0 @@ -$version: "1.0" - -namespace com.foo - -apply com.foo#MultiTrait @documentation("docs") -apply com.foo#MultiTrait$a @documentation("member docs") \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy deleted file mode 100644 index 18d22ec0..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "1.0" - -namespace foo.com - -structure MyStruct { - a: AnotherStruct -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy deleted file mode 100644 index 3c7679e1..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy +++ /dev/null @@ -1,33 +0,0 @@ -$version: "2.0" - -$operationInputSuffix: "ClutteredInput" -$operationOutputSuffix: "ClutteredOutput" - - -$extraneous: "extraneous" - - -// Comments in preamble -// Whitespace - - - -namespace com.clutter - - -// Use statements -use com.example#OtherStructure - - - -use com.extras#Extra - -/// With doc comment -structure StructureWithDependencies { - extra: Extra - example: OtherStructure -} - -structure StructureWithNoDependencies { - member: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy deleted file mode 100644 index 7269e626..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy +++ /dev/null @@ -1,8 +0,0 @@ -$version: "1.0" - -namespace ns.foo - -string Bar - -@since("2022-05-12") -string Baz diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy deleted file mode 100644 index c75e6264..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "2.0" - -namespace com.extras - -structure Extra { - extraMember: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy deleted file mode 100644 index 158b36e5..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy +++ /dev/null @@ -1,97 +0,0 @@ -$version: "1.0" - -namespace com.foo - -structure SingleLine {} - - structure MultiLine { - a: String, - - - b: String, - @required - c: SingleLine - } - - @pattern("^[A-Za-z0-9 ]+$") - string SingleTrait - -@input -@tags(["foo"]) -structure MultiTrait { - a: String} - -// Line comments -// comments - @input - // comments - @tags(["a", - "b", - "c", - "d", - "e", - "f" - ] -) -structure MultiTraitAndLineComments { - a: String -} - - - - -/// Doc comments -/// Comment about corresponding MultiTrait shape -@input -@tags(["foo"]) -structure MultiTraitAndDocComments { - a: String -} - -@readonly -operation MyOperation { - input: MyOperationInput, - output: MyOperationOutput, - errors: [MyError] -} - -structure MyOperationInput { - foo: String, - @required - myId: MyId -} - -structure MyOperationOutput { - corge: String, - qux: String -} - -@error("client") -structure MyError { - blah: String, - blahhhh: Integer -} - -resource MyResource { - identifiers: { myId: MyId }, - read: MyOperation -} - -string MyId - -string InputString - -apply MyOperation @http(method: "PUT", uri: "/bar", code: 200) - - @http(method: "PUT", uri: "/foo", code: 200) - @documentation("doc has parens ()") - @tags(["foo)", - "bar)", - "baz)"]) - @examples([{ - title: "An)Operation" - }]) - operation AnOperation {} - -@trait -structure emptyTraitStruct {} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy deleted file mode 100644 index 400d8097..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy +++ /dev/null @@ -1,11 +0,0 @@ -$version: "1.0" - -namespace ns.preamble - -use ns.foo#FooTrait - -use ns.bar#BarTrait - -@FooTrait -@BarTrait -string MyString \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy deleted file mode 100644 index cf6ad2d0..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy +++ /dev/null @@ -1,15 +0,0 @@ -$version: "1.0" - -namespace com.example - -use com.foo#emptyTraitStruct - -@emptyTraitStruct -structure OtherStructure { - foo: String, - bar: String, - baz: Integer -} - - - diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy deleted file mode 100644 index 28c45f6d..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy +++ /dev/null @@ -1,86 +0,0 @@ -$version: "1.0" - -namespace test - -@trait -structure test { - @required - blob: Blob, - - @required - bool: Boolean, - - @requird - byte: Byte, - - @required - short: Short, - - @required - integer: Integer, - - @required - long: Long, - - @required - float: Float, - - @required - double: Double, - - @required - bigDecimal: BigDecimal, - - @required - bigInteger: BigInteger, - - @required - string: String, - - @required - timestamp: Timestamp, - - @required - list: ListA, - - @required - set: SetA, - - @required - map: MapA, - - @required - struct: StructureA, - - @required - union: UnionA -} - -list ListA { - member: String -} - -set SetA { - member: String -} - -map MapA { - key: String, - value: Integer -} - -structure StructureA { - @required - nested: StructureB -} - -structure StructureB { - @required - nestedMember: String -} - -union UnionA { - a: Integer, - b: String, - c: Timestamp -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy deleted file mode 100644 index 240708ac..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy +++ /dev/null @@ -1,10 +0,0 @@ -$version: "2.0" - -namespace com.imports - -boolean IsTestInput - -@mixin -structure HasIsTestParam { - isTest: Boolean -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy deleted file mode 100644 index 3a63103c..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy +++ /dev/null @@ -1,38 +0,0 @@ -$version: "2.0" - -namespace com.main - -use com.imports#HasIsTestParam - -// Apply before shape definition -apply SomeOpInput @tags(["someTag"]) - -// Apply as first line in shapes section -apply ArbitraryStructure$member @tags(["someTag"]) - -structure SomeOpInput with [HasIsTestParam] { - @required - body: String -} - -/// Arbitrary doc comment - -// Arbitrary comment - -// Apply targeting a mixed in member from another namespace -apply SomeOpInput$isTest @documentation("Some documentation") - -// Structure to break up applys -structure ArbitraryStructure { - member: String -} - -// Multiple applys before first shape definition -apply ArbitraryStructure @documentation("Some documentation") - -// Apply targeting non-mixed in member -apply SomeOpInput$body @documentation("Some documentation") - - -// Apply targeting a shape from another namespace -apply HasIsTestParam @documentation("Some documentation") diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy deleted file mode 100644 index 029f5759..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "2.0" - -namespace foo.com - -structure MyStruct { - a: AnotherStruct -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy deleted file mode 100644 index 1542b45b..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy +++ /dev/null @@ -1,39 +0,0 @@ -$version: "2.0" - -$operationInputSuffix: "In" -$operationOutputSuffix: "Out" - - -$extraneous: "extraneous" - - -// Comments in preamble -// Whitespace - - - -namespace com.clutter - - -// Use statements -use com.example#OtherStructure - - - -use com.extras#Extra - -/// With doc comment -@mixin -structure StructureWithDependencies { - extra: Extra - example: OtherStructure -} - -operation ClutteredInlineOperation { - input := with [StructureWithDependencies] { - } - output := with [StructureWithDependencies] { - additional: Integer - } -} - diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy deleted file mode 100644 index 3f48ace7..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy +++ /dev/null @@ -1,8 +0,0 @@ -$version: "2.0" - -namespace ns.foo - -string Bar - -@since("2022-05-12") -string Baz diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy deleted file mode 100644 index c75e6264..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "2.0" - -namespace com.extras - -structure Extra { - extraMember: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy deleted file mode 100644 index d6a0e9ce..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy +++ /dev/null @@ -1,218 +0,0 @@ -$version: "2.0" -$operationInputSuffix: "FooInput" -$operationOutputSuffix: "BarOutput" - -namespace com.foo - -structure SingleLine {} - - structure MultiLine { - a: String - - - b: String - @required - c: SingleLine - } - - @pattern("^[A-Za-z0-9 ]+$") - string SingleTrait - -@input -@tags(["foo"]) -structure MultiTrait { - a: String} - -// Line comments -// comments - @input - // comments - @tags(["a", - "b", - "c", - "d", - "e", - "f" - ] -) -structure MultiTraitAndLineComments { - a: String -} - - - - -/// Doc comments -/// Comment about corresponding MultiTrait shape -@input -@tags(["foo"]) -structure MultiTraitAndDocComments { - a: String -} - -@readonly -operation MyOperation { - input: MyOperationInput - output: MyOperationOutput - errors: [MyError] -} - -structure MyOperationInput { - foo: String - @required - myId: MyId -} - -structure MyOperationOutput { - corge: String - qux: String -} - -@error("client") -structure MyError { - blah: String - blahhhh: Integer -} - -resource MyResource { - identifiers: { myId: MyId } - read: MyOperation -} - -string MyId - -string InputString - -apply MyOperation @http(method: "PUT", uri: "/bar", code: 200) - - @http(method: "PUT", uri: "/foo", code: 200) - @documentation("doc has parens ()") - @tags(["foo)", - "bar)", - "baz)"]) - @examples([{ - title: "An)Operation" - }]) - operation AnOperation {} - -structure StructWithDefaultSugar { - foo: String = "bar" -} - -operation MyInlineOperation { - input := { - foo: String - bar: String - } - output := { - baz: String - } -} - -@mixin -structure UserIds { - @required - email: String - - @required - id: String -} - -@mixin -structure UserDetails { - status: String -} - -operation GetUser { - input := with [UserIds, UserDetails] { - optional: String - } - output := with [UserIds, UserDetails] { - description: String - } -} - -structure ElidedUserInfo with [UserIds, UserDetails]{ - @tags(["foo", "bar"]) - $email - - @tags(["baz"]) - $status -} - -operation ElidedGetUser { - input := with [UserIds, UserDetails] { - - @tags(["hello"]) - $id - optional: String - } - output := with [UserIds, UserDetails] { - @tags(["goodbye"]) - $status - - description: String - } -} - -enum Suit { - DIAMOND = "diamond" - CLUB = "club" - HEART = "heart" - SPADE = "spade" -} - -operation MyInlineOperationReversed { - output := { - baz: String - } - input := { - foo: String - } -} - -// The input and output match the name conventions for inline inputs and outputs, -// but are not actually inlined. -operation FalseInlined { - input: FalseInlinedFooInput - output: FalseInlinedBarOutput -} - -structure FalseInlinedFooInput { - a: String -} - -structure FalseInlinedBarOutput { - b: String -} - -operation FalseInlinedReversed { - output: FalseInlinedReversedBarOutput - input: FalseInlinedReversedFooInput -} - -structure FalseInlinedReversedFooInput { - c: String -} - -structure FalseInlinedReversedBarOutput { - d: String -} - -@trait -structure emptyTraitStruct {} - -operation ShortInputOutput { - output: ShortO - input: ShortI -} - -@input -structure ShortI { - c: String -} - -@output -structure ShortO { - d: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy deleted file mode 100644 index 1ca2a8f9..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy +++ /dev/null @@ -1,13 +0,0 @@ -$version: "2.0" -$operationInputSuffix: "Request" -$operationOutputSuffix: "Response" - -namespace ns.preamble - -use ns.foo#FooTrait - -use ns.bar#BarTrait - -@FooTrait -@BarTrait -string MyString diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy deleted file mode 100644 index 6654c3a3..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy +++ /dev/null @@ -1,15 +0,0 @@ -$version: "2.0" - -namespace com.example - -use com.foo#emptyTraitStruct - -@emptyTraitStruct -structure OtherStructure { - foo: String - bar: String - baz: Integer -} - - - diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy deleted file mode 100644 index 3fcfbd3f..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy +++ /dev/null @@ -1,79 +0,0 @@ -$version: "2.0" - -namespace test - -@trait -structure test { - @required - blob: Blob - - @required - bool: Boolean - - @requird - byte: Byte - - @required - short: Short - - @required - integer: Integer - - @required - long: Long - - @required - float: Float - - @required - double: Double - - @required - bigDecimal: BigDecimal - - @required - bigInteger: BigInteger - - @required - string: String - - @required - timestamp: Timestamp - - @required - list: ListA - - @required - map: MapA - - @required - struct: StructureA - - @required - union: UnionA -} - -list ListA { - member: String -} - -map MapA { - key: String - value: Integer -} - -structure StructureA { - @required - nested: StructureB -} - -structure StructureB { - @required - nestedMember: String -} - -union UnionA { - a: Integer - b: String - c: Timestamp -} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy new file mode 100644 index 00000000..c01994b4 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy @@ -0,0 +1,9 @@ +$version: "2.0" +namespace com.bar + +boolean MyBool + +@mixin +structure HasMyBool { + myBool: MyBool +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy new file mode 100644 index 00000000..99b60dd6 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy @@ -0,0 +1,25 @@ +$version: "2.0" +namespace com.foo + +use com.bar#HasMyBool + +apply MyOpInput @tags(["foo"]) + +apply MyStruct$member @tags(["bar"]) + +structure MyOpInput with [HasMyBool] { + @required + body: String +} + +apply MyOpInput$myBool @documentation("docs") + +structure MyStruct { + member: String +} + +apply MyStruct @documentation("more docs") + +apply MyOpInput$body @documentation("even more docs") + +apply HasMyBool @tags(["baz"]) \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json new file mode 100644 index 00000000..905545df --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["model"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json @@ -0,0 +1 @@ +{} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json new file mode 100644 index 00000000..a5f28129 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json @@ -0,0 +1,3 @@ +{ + version +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json new file mode 100644 index 00000000..bc582239 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["missing.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json new file mode 100644 index 00000000..8071c461 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json @@ -0,0 +1,8 @@ +{ + "version": "1", + "maven": { + "dependencies": [ + "software.amazon.smithy.lsp:not-smithy-language-server:0.0.1" + ] + } +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json new file mode 100644 index 00000000..a9261493 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json @@ -0,0 +1,8 @@ +{ + "dependencies": [ + { + "name": "doesn't exist", + "path": "./doesnt-exist.jar" + } + ] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json new file mode 100644 index 00000000..4c6a3109 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "imports": ["main.smithy"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy new file mode 100644 index 00000000..b9febef1 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace main + +string Main diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy new file mode 100644 index 00000000..37608eb6 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace other + +string Other diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json new file mode 100644 index 00000000..3c4e9e0c --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "imports": ["other.smithy"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/config-with-env.json b/src/test/resources/software/amazon/smithy/lsp/project/env-config/smithy-build.json similarity index 93% rename from src/test/resources/software/amazon/smithy/lsp/ext/config-with-env.json rename to src/test/resources/software/amazon/smithy/lsp/project/env-config/smithy-build.json index fd2ea93f..18b95311 100644 --- a/src/test/resources/software/amazon/smithy/lsp/ext/config-with-env.json +++ b/src/test/resources/software/amazon/smithy/lsp/project/env-config/smithy-build.json @@ -1,5 +1,5 @@ { - "version": "2.0", + "version": "1", "maven": { "repositories": [ { diff --git a/src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json new file mode 100644 index 00000000..d36ade43 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json @@ -0,0 +1,12 @@ +{ + "dependencies": [ + { + "name": "alloy-core", + "path": "./alloy-core.jar" + }, + { + "name": "smithy-test-traits", + "path": "./smithy-test-traits.jar" + } + ] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/alloy-core.jar b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/alloy-core.jar similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/alloy-core.jar rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/alloy-core.jar diff --git a/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json new file mode 100644 index 00000000..5cb61a6e --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["test-traits.smithy", "test-validators.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/smithy-test-traits.jar b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-test-traits.jar similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/smithy-test-traits.jar rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-test-traits.jar diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/test-traits.smithy b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-traits.smithy similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/test-traits.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-traits.smithy diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/test-validators.smithy b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-validators.smithy similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/test-validators.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-validators.smithy diff --git a/src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy new file mode 100644 index 00000000..a7cc6e82 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy @@ -0,0 +1,9 @@ +$version: "2.0" + +namespace com.foo + +string Foo + +structure Abc { + foo: String +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json new file mode 100644 index 00000000..e80ed259 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["main.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy new file mode 100644 index 00000000..db5569e9 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy @@ -0,0 +1,9 @@ +$version: "2.0" + +namespace com.foo + +structure Foo { + bar: String +} + +structure A diff --git a/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json new file mode 100644 index 00000000..e80ed259 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["main.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json new file mode 100644 index 00000000..5bcb8be3 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json @@ -0,0 +1,16 @@ +{ + "version": "1", + "maven": { + "dependencies": ["dep1", "dep2"], + "repositories": [ + { + "url": "url1" + }, + { + "url": "url2" + } + ] + }, + "mavenRepositories": ["m1", "m2"], + "mavenDependencies": ["mdep1", "mdep2"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json new file mode 100644 index 00000000..20ed9aa0 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "mavenRepositories": ["foo", "bar"], + "mavenDependencies": ["baz"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy new file mode 100644 index 00000000..638a01ed --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Foo diff --git a/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json new file mode 100644 index 00000000..ee714970 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "sources": ["main.smithy"], + "maven": { + "dependencies": [ + "software.amazon.smithy:smithy-smoke-test-traits:1.45.0" + ] + } +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/a.smithy b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/a.smithy similarity index 58% rename from src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/a.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/a.smithy index 40b1aad0..247452f5 100644 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/a.smithy +++ b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/a.smithy @@ -1,14 +1,12 @@ -$version: "2" +$version: "2.0" namespace a -operation HelloWorld { +operation Hello { input := { - @required name: String } output := { - @required name: String } } diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/b.smithy b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/b.smithy similarity index 53% rename from src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/b.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/b.smithy index b37f9092..90a1a55f 100644 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/b.smithy +++ b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/b.smithy @@ -1,16 +1,12 @@ -$version: "2" +$version: "2.0" namespace b -string Ignored - -operation HelloWorld { +operation Hello { input := { - @required name: String } output := { - @required name: String } } diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json new file mode 100644 index 00000000..905545df --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["model"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy new file mode 100644 index 00000000..638a01ed --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Foo diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy new file mode 100644 index 00000000..bfd9721c --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Bar diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json new file mode 100644 index 00000000..33cb3727 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["model/"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy new file mode 100644 index 00000000..56e5d606 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy @@ -0,0 +1,6 @@ +$version: "2.0" + +namespace com.foo + +@unknown +structure Foo {} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json new file mode 100644 index 00000000..e80ed259 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["main.smithy"] +} From 6fce052ee3f7e4825da50f2630d37e24718a6091 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Mon, 8 Apr 2024 09:25:57 -0400 Subject: [PATCH 02/15] Add support for non-project files This makes the language server work on files that aren't connected (or attached) to a smithy-build.json/other build file. This works by loading said files as they are opened in their own, single-file projects with no dependencies, which are removed when the file is closed. A diagnostic was also added to indicate when a file is 'detached' from a project, and appears on the first line of the file. I could have made all detached files part of their own special project, could be more convenient when doing something quick with multiple files without a smithy-build.json. The smithy cli can work this way, although you still have to specify the files to build in the command, so we could change this in the future. The difference is I don't think we'd have a way of opting out of the single project without some config that would end up being more work to set up than a smithy-build.json. --- .../smithy/lsp/SmithyLanguageServer.java | 175 +++++++++++++----- .../smithy/lsp/SmithyProtocolExtensions.java | 11 ++ .../lsp/diagnostics/DetachedDiagnostics.java | 44 +++++ .../lsp/ext/serverstatus/OpenProject.java | 52 ++++++ .../lsp/ext/serverstatus/ServerStatus.java | 29 +++ .../ext/serverstatus/ServerStatusParams.java | 14 ++ .../amazon/smithy/lsp/project/Project.java | 72 ++++++- .../smithy/lsp/project/ProjectLoader.java | 54 ++++++ .../smithy/lsp/project/ProjectManager.java | 100 ++++++++++ .../amazon/smithy/lsp/RequestBuilders.java | 21 +++ .../smithy/lsp/SmithyLanguageServerTest.java | 138 +++++++++++++- .../amazon/smithy/lsp/StubClient.java | 4 - .../amazon/smithy/lsp/TestWorkspace.java | 40 +++- 13 files changed, 694 insertions(+), 60 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java create mode 100644 src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java create mode 100644 src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java create mode 100644 src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java create mode 100644 src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 6a1c73b8..dc285dfb 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.concurrent.CompletableFuture; @@ -51,6 +52,7 @@ import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; @@ -84,11 +86,15 @@ import org.eclipse.lsp4j.services.TextDocumentService; import org.eclipse.lsp4j.services.WorkspaceService; import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; +import software.amazon.smithy.lsp.diagnostics.DetachedDiagnostics; import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; 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.LspLog; +import software.amazon.smithy.lsp.ext.serverstatus.OpenProject; +import software.amazon.smithy.lsp.ext.serverstatus.ServerStatus; +import software.amazon.smithy.lsp.ext.serverstatus.ServerStatusParams; import software.amazon.smithy.lsp.handler.CompletionHandler; import software.amazon.smithy.lsp.handler.DefinitionHandler; import software.amazon.smithy.lsp.handler.FileWatcherRegistrationHandler; @@ -96,6 +102,7 @@ import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectConfigLoader; import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LocationAdapter; import software.amazon.smithy.lsp.protocol.PositionAdapter; @@ -131,25 +138,29 @@ public class SmithyLanguageServer implements } private SmithyLanguageClient client; - private Project project; + private final ProjectManager projects = new ProjectManager(); + private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); private Severity minimumSeverity = Severity.WARNING; private boolean onlyReloadOnSave = false; - private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); public SmithyLanguageServer() { } SmithyLanguageServer(LanguageClient client, Project project) { this.client = new SmithyLanguageClient(client); - this.project = project; + this.projects.updateMainProject(project); + } + + SmithyLanguageClient getClient() { + return this.client; } Project getProject() { - return this.project; + return projects.getMainProject(); } - SmithyLanguageClient getClient() { - return this.client; + ProjectManager getProjects() { + return projects; } DocumentLifecycleManager getLifecycleManager() { @@ -247,14 +258,14 @@ private void tryInitProject(Path root) { lifecycleManager.cancelAllTasks(); Result> loadResult = ProjectLoader.load(root); if (loadResult.isOk()) { - this.project = loadResult.unwrap(); + projects.updateMainProject(loadResult.unwrap()); LOGGER.info("Initialized project at " + root); // TODO: If this is a project reload, there are open files which need to have updated diagnostics reported. } 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. - this.project = Project.empty(root); + projects.updateMainProject(Project.empty(root)); String baseMessage = "Failed to load Smithy project at " + root; StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); @@ -271,6 +282,7 @@ private void tryInitProject(Path root) { } private CompletableFuture registerSmithyFileWatchers() { + Project project = projects.getMainProject(); List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project); return client.registerCapability(new RegistrationParams(registrations)); } @@ -312,6 +324,7 @@ public void exit() { public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { LOGGER.info("JarFileContents"); String uri = textDocumentIdentifier.getUri(); + Project project = projects.getProject(uri); Document document = project.getDocument(uri); if (document != null) { return completedFuture(document.copyText()); @@ -321,6 +334,7 @@ public CompletableFuture jarFileContents(TextDocumentIdentifier textDocu } } + // TODO: This doesn't really work for multiple projects @Override public CompletableFuture> selectorCommand(SelectorParams selectorParams) { LOGGER.info("SelectorCommand"); @@ -333,6 +347,7 @@ public CompletableFuture> selectorCommand(SelectorParam return completedFuture(Collections.emptyList()); } + Project project = projects.getMainProject(); // TODO: Might also want to tell user if the model isn't loaded // TODO: Use proper location (source is just a point) return completedFuture(project.getModelResult().getResult() @@ -344,37 +359,71 @@ public CompletableFuture> selectorCommand(SelectorParam .orElse(Collections.emptyList())); } + @Override + public CompletableFuture serverStatus(ServerStatusParams params) { + OpenProject openProject = new OpenProject( + UriAdapter.toUri(projects.getMainProject().getRoot().toString()), + projects.getMainProject().getSmithyFiles().keySet().stream() + .map(UriAdapter::toUri) + .collect(Collectors.toList()), + false); + + List openProjects = new ArrayList<>(); + openProjects.add(openProject); + + for (Map.Entry entry : projects.getDetachedProjects().entrySet()) { + openProjects.add(new OpenProject( + entry.getKey(), + Collections.singletonList(entry.getKey()), + true)); + } + + return completedFuture(new ServerStatus(openProjects)); + } + @Override public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { + LOGGER.info("DidChangeWatchedFiles"); // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), // or the smithy-build.json itself was changed - boolean projectFilesChanged = params.getChanges().stream().anyMatch(change -> { - String changedUri = change.getUri(); - if (changedUri.endsWith(".smithy") && (change.getType().equals(FileChangeType.Created) - || change.getType().equals(FileChangeType.Deleted))) { - return true; - } - if (changedUri.endsWith(ProjectConfigLoader.SMITHY_BUILD)) { - return true; - } - if (changedUri.endsWith(ProjectConfigLoader.SMITHY_PROJECT)) { - return true; - } - for (String extFile : ProjectConfigLoader.SMITHY_BUILD_EXTS) { - if (changedUri.endsWith(extFile)) { - return true; + List createdSmithyFiles = new ArrayList<>(); + List deletedSmithyFiles = new ArrayList<>(); + boolean changedBuildFiles = false; + for (FileEvent event : params.getChanges()) { + String changedUri = event.getUri(); + if (changedUri.endsWith(".smithy")) { + if (event.getType().equals(FileChangeType.Created)) { + createdSmithyFiles.add(changedUri); + } else if (event.getType().equals(FileChangeType.Deleted)) { + deletedSmithyFiles.add(changedUri); + } + } else if (changedUri.endsWith(ProjectConfigLoader.SMITHY_BUILD) + || changedUri.endsWith(ProjectConfigLoader.SMITHY_PROJECT)) { + changedBuildFiles = true; + } else { + for (String extFile : ProjectConfigLoader.SMITHY_BUILD_EXTS) { + if (changedUri.endsWith(extFile)) { + changedBuildFiles = true; + break; + } } } - return false; - }); + } - if (projectFilesChanged) { - tryInitProject(project.getRoot()); - // TODO: Ideally we can reload the project more intelligently - maybe we specifically add/remove - // specific smithy files, or check the changed smithy-build.json to see if dependencies, sources, or - // imports changed. - unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + // TODO: Handle files being moved into projects from detached. Will need + // to be able to load project with files managed by the client. + if (changedBuildFiles) { + client.info("Build files changed, reloading project"); + // TODO: Handle more granular updates to build files. + tryInitProject(projects.getMainProject().getRoot()); + } else { + client.info("Project files changed, adding files " + + createdSmithyFiles + " and removing files " + deletedSmithyFiles); + projects.getMainProject().updateFiles(createdSmithyFiles, deletedSmithyFiles); } + + // TODO: Update watchers based on specific changes + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); } @Override @@ -394,11 +443,10 @@ public void didChange(DidChangeTextDocumentParams params) { lifecycleManager.cancelTask(uri); + Project project = projects.getProject(uri); Document document = project.getDocument(uri); if (document == null) { - client.info("document not open in project: " + uri); - // TODO: In this case, the document isn't attached to the project. I'm not sure what the best - // way to handle this is. + client.error("Attempted to change document the server isn't tracking: " + uri); return; } @@ -426,9 +474,13 @@ public void didOpen(DidOpenTextDocumentParams params) { lifecycleManager.cancelTask(uri); + String text = params.getTextDocument().getText(); + Project project = projects.getProject(uri); Document document = project.getDocument(uri); if (document != null) { - document.applyEdit(null, params.getTextDocument().getText()); + document.applyEdit(null, text); + } else { + projects.createDetachedProject(uri, text); } // TODO: Do we need to handle canceling this? sendFileDiagnostics(uri); @@ -437,6 +489,15 @@ public void didOpen(DidOpenTextDocumentParams params) { @Override public void didClose(DidCloseTextDocumentParams params) { LOGGER.info("DidClose"); + + String uri = params.getTextDocument().getUri(); + + if (projects.isDetached(uri)) { + // Only cancel tasks for detached projects, since we're dropping the project + lifecycleManager.cancelTask(uri); + projects.removeDetachedProject(uri); + } + // TODO: Clear diagnostics? Can do this by sending an empty list } @@ -447,14 +508,16 @@ public void didSave(DidSaveTextDocumentParams params) { String uri = params.getTextDocument().getUri(); lifecycleManager.cancelTask(uri); if (params.getText() != null) { + Project project = projects.getProject(uri); Document document = project.getDocument(uri); - if (document != null) { - document.applyEdit(null, params.getText()); + if (document == null) { + // TODO: Could also load a detached project here, but I don't know how this would + // actually happen in practice + client.error("Attempted to save document not tracked by server: " + uri); + return; } - } - if (project.getDocument(uri) == null) { - client.info("document not open in project: " + uri); + document.applyEdit(null, params.getText()); } triggerUpdateAndValidate(uri); @@ -463,6 +526,7 @@ public void didSave(DidSaveTextDocumentParams params) { @Override public CompletableFuture, CompletionList>> completion(CompletionParams params) { LOGGER.info("Completion"); + Project project = projects.getProject(params.getTextDocument().getUri()); return CompletableFutures.computeAsync((cc) -> { CompletionHandler handler = new CompletionHandler(project); return Either.forLeft(handler.handle(params, cc)); @@ -479,10 +543,12 @@ public CompletableFuture resolveCompletionItem(CompletionItem un @Override public CompletableFuture>> documentSymbol(DocumentSymbolParams params) { + LOGGER.info("DocumentSymbol"); + String uri = params.getTextDocument().getUri(); + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + return CompletableFutures.computeAsync((cc) -> { - LOGGER.info("DocumentSymbol"); - String uri = params.getTextDocument().getUri(); - SmithyFile smithyFile = project.getSmithyFile(uri); if (smithyFile == null) { return Collections.emptyList(); } @@ -531,6 +597,7 @@ public CompletableFuture resolveCompletionItem(CompletionItem un public CompletableFuture, List>> definition(DefinitionParams params) { LOGGER.info("Definition"); + Project project = projects.getProject(params.getTextDocument().getUri()); List locations = new DefinitionHandler(project).handle(params); return completedFuture(Either.forLeft(locations)); } @@ -538,6 +605,7 @@ public CompletableFuture resolveCompletionItem(CompletionItem un @Override public CompletableFuture hover(HoverParams params) { LOGGER.info("Hover"); + Project project = projects.getProject(params.getTextDocument().getUri()); // TODO: Abstract away passing minimum severity Hover hover = new HoverHandler(project).handle(params, minimumSeverity); return completedFuture(hover); @@ -556,6 +624,7 @@ public CompletableFuture>> codeAction(CodeActio public CompletableFuture> formatting(DocumentFormattingParams params) { LOGGER.info("Formatting"); String uri = params.getTextDocument().getUri(); + Project project = projects.getProject(uri); Document document = project.getDocument(uri); if (document == null) { return completedFuture(Collections.emptyList()); @@ -570,6 +639,7 @@ public CompletableFuture> formatting(DocumentFormatting } private void triggerUpdate(String uri) { + Project project = projects.getProject(uri); CompletableFuture future = CompletableFuture .runAsync(() -> project.updateModelWithoutValidating(uri)) .thenComposeAsync(unused -> sendFileDiagnostics(uri)); @@ -577,6 +647,7 @@ private void triggerUpdate(String uri) { } private void triggerUpdateAndValidate(String uri) { + Project project = projects.getProject(uri); CompletableFuture future = CompletableFuture .runAsync(() -> project.updateAndValidateModel(uri)) .thenCompose(unused -> sendFileDiagnostics(uri)); @@ -592,21 +663,35 @@ private CompletableFuture sendFileDiagnostics(String uri) { } List getFileDiagnostics(String uri) { - String path = UriAdapter.toPath(uri); + if (UriAdapter.isJarFile(uri) || UriAdapter.isSmithyJarFile(uri)) { + // Don't send diagnostics to jar files since they can't be edited + // and diagnostics could be misleading. + return Collections.emptyList(); + } + + Project project = projects.getProject(uri); SmithyFile smithyFile = project.getSmithyFile(uri); + String path = UriAdapter.toPath(uri); + List diagnostics = project.getModelResult().getValidationEvents().stream() .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) .filter(validationEvent -> !UriAdapter.isJarFile(validationEvent.getSourceLocation().getFilename())) .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) .map(validationEvent -> toDiagnostic(validationEvent, smithyFile)) .collect(Collectors.toCollection(ArrayList::new)); + if (smithyFile != null && VersionDiagnostics.hasVersionDiagnostic(smithyFile)) { diagnostics.add(VersionDiagnostics.forSmithyFile(smithyFile)); } + + if (smithyFile != null && projects.isDetached(uri)) { + diagnostics.add(DetachedDiagnostics.forSmithyFile(smithyFile)); + } + return diagnostics; } - private Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { + private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); SourceLocation sourceLocation = validationEvent.getSourceLocation(); diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java b/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java index 63b1c608..e575ef34 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java @@ -21,6 +21,8 @@ import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; import org.eclipse.lsp4j.jsonrpc.services.JsonSegment; +import software.amazon.smithy.lsp.ext.serverstatus.ServerStatus; +import software.amazon.smithy.lsp.ext.serverstatus.ServerStatusParams; /** * Interface for protocol extensions for Smithy. @@ -33,4 +35,13 @@ public interface SmithyProtocolExtensions { @JsonRequest CompletableFuture> selectorCommand(SelectorParams selectorParams); + + /** + * Get a snapshot of the server's status, useful for debugging purposes. + * + * @param params Request parameters + * @return A future containing the server's status + */ + @JsonRequest + CompletableFuture serverStatus(ServerStatusParams params); } diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java new file mode 100644 index 00000000..2943f10c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.diagnostics; + +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.RangeAdapter; + +/** + * Diagnostics for when a Smithy file is not connected to a Smithy project via + * smithy-build.json or other build file. + */ +public final class DetachedDiagnostics { + public static final String DETACHED_FILE = "detached-file"; + + private DetachedDiagnostics() { + } + + /** + * @param smithyFile The Smithy file to get a detached diagnostic for + * @return The detached diagnostic associated with the Smithy file, or null + * if one doesn't exist (this occurs if the file doesn't have a document + * associated with it) + */ + public static Diagnostic forSmithyFile(SmithyFile smithyFile) { + if (smithyFile.getDocument() != null) { + int end = smithyFile.getDocument().lineEnd(0); + Range range = RangeAdapter.lineSpan(0, 0, end); + return new Diagnostic( + range, + "This file isn't attached to a project", + DiagnosticSeverity.Warning, + "smithy-language-server", + DETACHED_FILE + ); + } + return null; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java new file mode 100644 index 00000000..8d3c3077 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.ext.serverstatus; + +import java.util.List; +import org.eclipse.lsp4j.jsonrpc.validation.NonNull; + +/** + * A snapshot of a project the server has open. + */ +public class OpenProject { + @NonNull + private final String root; + @NonNull + private final List files; + private final boolean isDetached; + + /** + * @param root The root URI of the project + * @param files The list of all file URIs tracked by the project + * @param isDetached Whether the project is detached + */ + public OpenProject(@NonNull final String root, @NonNull final List files, boolean isDetached) { + this.root = root; + this.files = files; + this.isDetached = isDetached; + } + + /** + * @return The root directory of the project + */ + public String getRoot() { + return root; + } + + /** + * @return The list of all file URIs tracked by the project + */ + public List getFiles() { + return files; + } + + /** + * @return Whether the project is detached - tracking just a single open file + */ + public boolean isDetached() { + return isDetached; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java new file mode 100644 index 00000000..a0a0f101 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.ext.serverstatus; + +import java.util.List; +import org.eclipse.lsp4j.jsonrpc.validation.NonNull; + +/** + * A snapshot of the server status, containing the projects it has open. + * We can add more here later as we see fit. + */ +public class ServerStatus { + @NonNull + private final List openProjects; + + public ServerStatus(@NonNull final List openProjects) { + this.openProjects = openProjects; + } + + /** + * @return The open projects tracked by the server + */ + public List getOpenProjects() { + return openProjects; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java new file mode 100644 index 00000000..63b44721 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java @@ -0,0 +1,14 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.ext.serverstatus; + +/** + * LSP request parameters for a ServerStatus request. + */ +public class ServerStatusParams { + public ServerStatusParams() { + } +} 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 b64247f6..14273678 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -7,6 +7,7 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,6 +22,7 @@ import software.amazon.smithy.model.loader.ModelAssembler; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.utils.IoUtils; /** * A Smithy project open on the client. It keeps track of its Smithy files and @@ -195,17 +197,75 @@ public void updateModel(String uri, Document document, boolean validate) { this.modelResult = assembler.assemble(); - Set updatedShapes = modelResult.getResult() - .map(model -> model.shapes() - .filter(shape -> shape.getSourceLocation().getFilename().equals(path)) - .collect(Collectors.toSet())) - .orElse(previous.getShapes()); - + Set updatedShapes = getFileShapes(path, previous.getShapes()); // TODO: Could cache validation events SmithyFile updated = ProjectLoader.buildSmithyFile(path, document, updatedShapes).build(); this.smithyFiles.put(path, updated); } + /** + * Updates this project by adding and removing files. Also runs model validation. + * + * @param addUris URIs of files to add + * @param removeUris URIs of files to remove + */ + public void updateFiles(List addUris, List removeUris) { + if (!modelResult.getResult().isPresent()) { + LOGGER.severe("Attempted to update files in project with no model: " + addUris + " " + removeUris); + return; + } + + if (addUris.isEmpty() && removeUris.isEmpty()) { + LOGGER.info("No files provided to update"); + return; + } + + Model currentModel = modelResult.getResult().get(); + ModelAssembler assembler = assemblerFactory.get(); + if (!removeUris.isEmpty()) { + Model.Builder builder = currentModel.toBuilder(); + for (String uri : removeUris) { + String path = UriAdapter.toPath(uri); + // Note: no need to remove anything from sources/imports, since they're + // based on what's in the build files. + SmithyFile smithyFile = smithyFiles.remove(path); + if (smithyFile == null) { + LOGGER.severe("Attempted to remove file not in project: " + uri); + continue; + } + for (Shape shape : smithyFile.getShapes()) { + builder.removeShape(shape.getId()); + } + } + assembler.addModel(builder.build()); + } else { + assembler.addModel(currentModel); + } + + for (String uri : addUris) { + assembler.addImport(UriAdapter.toPath(uri)); + } + + this.modelResult = assembler.assemble(); + + for (String uri : addUris) { + String path = UriAdapter.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); + } + } + + 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); + } + static Builder builder() { return new Builder(); } 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 8b92ddb6..3f509060 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -48,6 +48,60 @@ public final class ProjectLoader { private ProjectLoader() { } + /** + * Loads a detached (single-file) {@link Project} with the given file. + * + *

Unlike {@link #load(Path)}, this method isn't fallible since it + * doesn't do any IO. + * + * @param uri URI 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 loadDetached(String uri, String text) { + String asPath = UriAdapter.toPath(uri); + ValidatedResult modelResult = Model.assembler() + .addUnparsedModel(asPath, text) + .assemble(); + + Path path = Paths.get(asPath); + List sources = Collections.singletonList(path); + + Project.Builder builder = Project.builder() + .root(path) // TODO: Does this need to be a directory? + .sources(sources) + .modelResult(modelResult); + + Map> shapes; + if (modelResult.getResult().isPresent()) { + Model model = modelResult.getResult().get(); + shapes = model.shapes().collect(Collectors.groupingByConcurrent( + shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); + } else { + shapes = new HashMap<>(0); + } + + Map smithyFiles = new HashMap<>(shapes.size()); + for (Map.Entry> entry : shapes.entrySet()) { + String filePath = entry.getKey(); + Document document; + if (UriAdapter.isSmithyJarFile(filePath) || UriAdapter.isJarFile(filePath)) { + document = Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(filePath))); + } else if (filePath.equals(asPath)) { + document = Document.of(text); + } else { + LOGGER.severe("Found unexpected file when loading detached (single file) project: " + filePath); + continue; + } + Set fileShapes = entry.getValue(); + SmithyFile smithyFile = buildSmithyFile(filePath, document, fileShapes).build(); + smithyFiles.put(filePath, smithyFile); + } + builder.smithyFiles(smithyFiles); + + return builder.build(); + } + /** * Loads a {@link Project} from a given root path. * diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java new file mode 100644 index 00000000..092fba57 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -0,0 +1,100 @@ +/* + * 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 org.eclipse.lsp4j.InitializeParams; +import software.amazon.smithy.lsp.protocol.UriAdapter; + +/** + * Manages open projects tracked by the server. + */ +public final class ProjectManager { + private final Map detached = new HashMap<>(); + // TODO: Handle multiple main projects + private Project mainProject; + + public ProjectManager() { + } + + /** + * @return The main project (the one with a smithy-build.json). Note that + * this will always be present after + * {@link org.eclipse.lsp4j.services.LanguageServer#initialize(InitializeParams)} + * is called. If there's no smithy-build.json, this is just an empty project. + */ + public Project getMainProject() { + return mainProject; + } + + /** + * @param updated The updated main project. Overwrites existing main project + * without doing a partial update + */ + public void updateMainProject(Project updated) { + this.mainProject = updated; + } + + /** + * @return A map of URIs of open files that aren't attached to the main project + * to their own detached projects. These projects contain only the file that + * corresponds to the key in the map. + */ + public Map getDetachedProjects() { + return detached; + } + + /** + * @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) { + // We might be in a state where a file was added to the main 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. + if (mainProject.getSmithyFiles().containsKey(UriAdapter.toPath(uri)) && detached.containsKey(uri)) { + removeDetachedProject(uri); + } + + if (detached.containsKey(uri)) { + return detached.get(uri); + } + + // TODO: Maybe this should take care of loading the detached project, so + // we can assume in other places that any given file always belongs to + // some project. + return mainProject; + } + + /** + * @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) { + return detached.containsKey(uri); + } + + /** + * @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); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java index 79b3506a..9c4c057c 100644 --- a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -2,6 +2,7 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ + package software.amazon.smithy.lsp; import java.net.URI; @@ -14,9 +15,12 @@ import org.eclipse.lsp4j.CompletionTriggerKind; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.Position; @@ -58,6 +62,10 @@ public static PositionRequest positionRequest() { return new PositionRequest(); } + public static DidChangeWatchedFiles didChangeWatchedFiles() { + return new DidChangeWatchedFiles(); + } + public static final class DidChange { public String uri; public Integer version; @@ -228,4 +236,17 @@ public CompletionParams buildCompletion() { new CompletionContext(CompletionTriggerKind.Invoked)); } } + + public static final class DidChangeWatchedFiles { + public final List changes = new ArrayList<>(); + + public DidChangeWatchedFiles event(String uri, FileChangeType type) { + this.changes.add(new FileEvent(uri, type)); + return this; + } + + public DidChangeWatchedFilesParams build() { + return new DidChangeWatchedFilesParams(changes); + } + } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index fe97c6f8..9515b6a5 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -7,7 +7,10 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +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.hasLabel; @@ -20,6 +23,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionParams; @@ -31,6 +35,7 @@ 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; @@ -379,7 +384,6 @@ public void didChange() throws Exception { SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - TextDocumentIdentifier id = new TextDocumentIdentifier(uri); DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() .uri(uri) @@ -427,9 +431,6 @@ public void didChange() throws Exception { .buildCompletion(); List completions = server.completion(completionParams).get().getLeft(); - DidSaveTextDocumentParams saveParams = new DidSaveTextDocumentParams(id); - server.didSave(saveParams); - assertThat(completions, containsInAnyOrder(hasLabel("GetFoo"), hasLabel("GetFooInput"))); } @@ -838,6 +839,135 @@ public void insideJar() throws Exception { assertThat(content, containsString("document default")); } + @Test + public void addingWatchedFile() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String filename = "model/main.smithy"; + String modelText = ""; + workspace.addModel(filename, modelText); + String uri = workspace.getUri(filename); + + // The file may be opened before the client notifies the server it's been created + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + server.documentSymbol(new DocumentSymbolParams(new TextDocumentIdentifier(uri))); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Created) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(RangeAdapter.origin()) + .text("$") + .build()); + + // Make sure the task is running, then wait for it (what if it's already done?) + CompletableFuture future = server.getLifecycleManager().getTask(uri); + assertThat(future, notNullValue()); + future.get(); + + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getMainProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProjects().getMainProject().getDocument(uri), notNullValue()); + assertThat(server.getProjects().getMainProject().getDocument(uri).copyText(), equalTo("$")); + } + + @Test + public void removingWatchedFile() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "model/main.smithy"; + String modelText = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + workspace.deleteModel(filename); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) + .build()); + + assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), nullValue()); + } + + @Test + public void addingDetachedFile() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "main.smithy"; + String modelText = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + assertThat(server.getProjects().isDetached(uri), is(true)); + + String movedFilename = "model/main.smithy"; + workspace.moveModel(filename, movedFilename); + String movedUri = workspace.getUri(movedFilename); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(movedUri, FileChangeType.Created) + .build()); + + assertThat(server.getProjects().isDetached(movedUri), is(false)); + } + + @Test + public void removingAttachedFile() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "model/main.smithy"; + String modelText = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + assertThat(server.getProjects().isDetached(uri), is(false)); + + String movedFilename = "main.smithy"; + workspace.moveModel(filename, movedFilename); + String movedUri = workspace.getUri(movedFilename); + + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(movedUri) + .text(modelText) + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) + .build()); + + assertThat(server.getProjects().isDetached(movedUri), is(true)); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } diff --git a/src/test/java/software/amazon/smithy/lsp/StubClient.java b/src/test/java/software/amazon/smithy/lsp/StubClient.java index d76a352e..59c029a3 100644 --- a/src/test/java/software/amazon/smithy/lsp/StubClient.java +++ b/src/test/java/software/amazon/smithy/lsp/StubClient.java @@ -1,7 +1,3 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ package software.amazon.smithy.lsp; import java.util.ArrayList; diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index ddddbf68..dc6a1e0d 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -5,6 +5,7 @@ package software.amazon.smithy.lsp; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -36,13 +37,41 @@ public Path getRoot() { } /** - * @param filename The name of the file to get the URI for. Can be relative to the root + * @param filename The name of the file to get the URI for, relative to the root * @return The LSP URI for the given filename */ public String getUri(String filename) { return this.root.resolve(filename).toUri().toString(); } + /** + * @param relativePath The path where the model will be added, relative to the root + * @param model The text of the model to add + */ + public void addModel(String relativePath, String model) { + try { + Files.write(root.resolve(relativePath), model.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void moveModel(String currentPath, String toPath) { + try { + Files.move(root.resolve(currentPath), root.resolve(toPath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void deleteModel(String relativePath) { + try { + Files.delete(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 @@ -54,6 +83,15 @@ public static TestWorkspace singleModel(String model) { .build(); } + /** + * @return A workspace with no models, and a smithy-build.json with sources = ["model/"] + */ + public static TestWorkspace emptyWithDirSource() { + return builder() + .withSourceDir(new Dir().path("model")) + .build(); + } + /** * @param models Strings of the models to create in the workspace * @return A workspace with n models, each "model-n.smithy", with their given contents, From 53c688e7e4a6418bf3d31c4dbdeb6693d9b6eedc Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Fri, 26 Apr 2024 12:36:16 -0400 Subject: [PATCH 03/15] Normalize paths in the project loader Fixes an issue where the server can't find project files when smithy-build.json has unnormalized paths. --- .../project/ProjectDependencyResolver.java | 2 +- .../smithy/lsp/project/ProjectLoader.java | 12 ++++--- .../amazon/smithy/lsp/LspMatchers.java | 2 +- .../smithy/lsp/SmithyLanguageServerTest.java | 30 ++++++++++++++++++ .../amazon/smithy/lsp/SmithyMatchers.java | 2 +- .../amazon/smithy/lsp/TestWorkspace.java | 22 ++++++++++--- .../smithy/lsp/project/ProjectTest.java | 30 +++++++++++++++++- .../subdirs/model2/subdir2/sub2.smithy | 5 +++ .../model2/subdir2/subsubdir/subsub.smithy | 5 +++ .../lsp/project/subdirs/smithy-build.json | 2 +- .../unnormalized-dirs/.smithy-project.json | 8 +++++ .../unnormalized-dirs/model/one.smithy | 5 +++ .../model/test-traits.smithy | 26 +++++++++++++++ .../unnormalized-dirs/model2/two.smithy | 5 +++ .../unnormalized-dirs/model3/three.smithy | 5 +++ .../unnormalized-dirs/smithy-build.json | 5 +++ .../unnormalized-dirs/smithy-test-traits.jar | Bin 0 -> 8963 bytes 17 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json create mode 100644 src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-test-traits.jar diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java index a0bd7368..8f7a1f17 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java @@ -48,7 +48,7 @@ static Result, Exception> resolveDependencies(Path root, ProjectConfi .collect(Collectors.toCollection(ArrayList::new)); config.getDependencies().forEach((projectDependency) -> { // TODO: Not sure if this needs to check for existence - Path path = root.resolve(projectDependency.getPath()); + Path path = root.resolve(projectDependency.getPath()).normalize(); deps.add(path); }); return deps; 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 3f509060..8243d831 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -130,10 +130,14 @@ public static Result> load(Path root) { List dependencies = resolveResult.unwrap(); - // TODO: We need some default behavior for when no project files are specified, like running in - // 'detached' mode or something - List sources = config.getSources().stream().map(root::resolve).collect(Collectors.toList()); - List imports = config.getImports().stream().map(root::resolve).collect(Collectors.toList()); + List sources = config.getSources().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); + List imports = config.getImports().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); // The model assembler factory is used to get assemblers that already have the correct // dependencies resolved for future loads diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index a46bc9e5..60ecd166 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -17,7 +17,7 @@ public final class LspMatchers { private LspMatchers() {} public static Matcher hasLabel(String label) { - return new CustomTypeSafeMatcher("a completion item with the right label") { + return new CustomTypeSafeMatcher("a completion item with the label + `" + label + "`") { @Override protected boolean matchesSafely(CompletionItem item) { return item.getLabel().equals(label); diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 9515b6a5..a60b2ced 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -22,6 +22,7 @@ import com.google.gson.JsonPrimitive; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -48,6 +49,7 @@ import org.eclipse.lsp4j.services.LanguageClient; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.RangeAdapter; @@ -968,6 +970,34 @@ public void removingAttachedFile() { assertThat(server.getProjects().isDetached(movedUri), is(true)); } + @Test + public void loadsProjectWithUnNormalizedSourcesDirs() { + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version("1") + .sources(Collections.singletonList("./././smithy")) + .build(); + String filename = "smithy/main.smithy"; + String modelText = "$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(TestWorkspace.dir() + .path("./smithy") + .withSourceFile("main.smithy", modelText)) + .withConfig(config) + .build(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + assertThat(server.getProjects().isDetached(uri), is(false)); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java index 6915f586..ce5d192a 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -20,7 +20,7 @@ public final class SmithyMatchers { private SmithyMatchers() {} public static Matcher hasShapeWithId(String id) { - return new CustomTypeSafeMatcher("a model with the right shape id") { + return new CustomTypeSafeMatcher("a model with the shape id `" + id + "`") { @Override protected boolean matchesSafely(Model item) { return item.getShape(ShapeId.from(id)).isPresent(); diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index dc6a1e0d..1842d426 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -109,6 +109,10 @@ public static Builder builder() { return new Builder(); } + public static Dir dir() { + return new Dir(); + } + public static class Dir { String path; Map sourceModels = new HashMap<>(); @@ -163,6 +167,7 @@ private static void writeModels(Path toDir, Map models) throws E } public static final class Builder extends Dir { + private SmithyBuildConfig config = null; private Builder() {} @Override @@ -189,6 +194,11 @@ public Builder withImportDir(Dir dir) { return this; } + public Builder withConfig(SmithyBuildConfig config) { + this.config = config; + return this; + } + public TestWorkspace build() { try { if (path == null) { @@ -205,11 +215,13 @@ public TestWorkspace build() { imports.addAll(importModels.keySet()); imports.addAll(importDirs.stream().map(d -> d.path).collect(Collectors.toList())); - SmithyBuildConfig config = SmithyBuildConfig.builder() - .version("1") - .sources(sources) - .imports(imports) - .build(); + if (config == null) { + config = SmithyBuildConfig.builder() + .version("1") + .sources(sources) + .imports(imports) + .build(); + } String configString = Node.prettyPrintJson(MAPPER.serialize(config)); Files.write(root.resolve("smithy-build.json"), configString.getBytes(StandardCharsets.UTF_8)); 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 e20dbc4f..2e80f8f6 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -66,9 +66,18 @@ public void loadsProjectWithSubdir() { Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItem(root.resolve("model"))); + assertThat(project.getSources(), hasItems( + root.resolve("model"), + root.resolve("model2"))); + assertThat(project.getSmithyFiles().keySet(), hasItems( + containsString("model/main.smithy"), + containsString("model/subdir/sub.smithy"), + containsString("model2/subdir2/sub2.smithy"), + containsString("model2/subdir2/subsubdir/subsub.smithy"))); assertThat(project.getModelResult().isBroken(), is(false)); assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Baz")); } @Test @@ -222,4 +231,23 @@ public void failsLoadingUnresolvableProjectDependency() { assertThat(result.isErr(), is(true)); } + + @Test + public void loadsProjectWithUnNormalizedDirs() { + Path root = Paths.get(getClass().getResource("unnormalized-dirs").getPath()); + Project project = ProjectLoader.load(root).unwrap(); + + assertThat(project.getRoot(), equalTo(root)); + assertThat(project.getSources(), hasItems( + root.resolve("model"), + root.resolve("model2"))); + assertThat(project.getImports(), hasItem(root.resolve("model3"))); + assertThat(project.getSmithyFiles().keySet(), 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(root.resolve("smithy-test-traits.jar") + "!/META-INF/smithy/smithy.test.json"))); + assertThat(project.getDependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); + } } diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy new file mode 100644 index 00000000..84eda2dd --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Baz diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy new file mode 100644 index 00000000..9ae8aac5 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Boz diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json index 33cb3727..fde48f72 100644 --- a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json @@ -1,4 +1,4 @@ { "version": "1", - "sources": ["model/"] + "sources": ["model", "model2"] } diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json new file mode 100644 index 00000000..93c3a0a7 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json @@ -0,0 +1,8 @@ +{ + "dependencies": [ + { + "name": "smithy-test-traits", + "path": "./././/smithy-test-traits.jar" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy new file mode 100644 index 00000000..9662a7fe --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string One diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy new file mode 100644 index 00000000..4a24f099 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy @@ -0,0 +1,26 @@ +$version: "1.0" + +namespace ns.test + +use smithy.test#test + +@test() +service Weather { + version: "2022-05-24", + operations: [GetCurrentTime] +} + +@readonly +operation GetCurrentTime { + input: GetCurrentTimeInput, + output: GetCurrentTimeOutput +} + +@input +structure GetCurrentTimeInput {} + +@output +structure GetCurrentTimeOutput { + @required + time: Timestamp, +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy new file mode 100644 index 00000000..578a80df --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Two diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy new file mode 100644 index 00000000..d7abefa2 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Three diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json new file mode 100644 index 00000000..4c2ee8e4 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "sources": ["./model/", "model2////"], + "imports": ["././././model3//"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-test-traits.jar b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-test-traits.jar new file mode 100644 index 0000000000000000000000000000000000000000..f775bfa66a26da5f705cb9fa26db306e51492e83 GIT binary patch literal 8963 zcmb7K1yr0%vL4(C?k)j>I|O%k0tAA)yL*BKch{i7g1fuBCAhm=2=-uaZiLO=x7&06 z`Olow-&fUL-PKiHBP#&{3Jm~2LIV06Y7_wv4fyfvp+P-7QbLM+)MC=Yv>*W4-#YS8 zpNc9xba+Id{Mu29Pg+b^NI{WCN*Hc{9^Q)p7SNL7!*c|LrT*Cs5PA?2J0obsFA?0N zaR?MwYE4*KBc~i2+Xfru*iZM-sG*yxyN0}2g#s)_ht&bN@T)jTk=Z851%wvv=LwEO z?w?ASYdb1NZlv`u!2tlNWB>rduS!UY2?$9m2+gai+0L?{c?^GQF)}dR!xR>)-#@5w zm)FgG#TvsH*_go1PeQIOfJDWsI(7Gr3w@5fSNg)9D`D!x`*-hbuZA4q&&1uf4M+QJ zK6OB-=MQ~Urfs;Q)lXqb`RZ_2h0xq<5q&}4#m&NWGR4tO;CiYJ=Jq?EwM`uHwY%ovs7=D< zx?sBIs2G3CoUFek7|2ji@)9qReI`QKt!-I&_iTT2{f#R9-a3KScuGu3$?T}a0(=6n z2^qGv#E0Q%3=U2L<4-zS~x%!vkHdh);vqX5lEbw-a6bSo6;14!LW8!wcr*t2b807m5JZIP{ zk>_*p^^zY}I2tKA^w62&hOEpBwP*VUf2Is35j{&4u9A@8tTcD6vQqF&L6f<~xKdLxR% z6(@@hpGnf(w+Ay?gP9()a?}qs!rzZP539qHi$!&Ia{}6ds+fdF1!`2=#{uK)#3Tc% zU8kC0DN%!>fZU!;6;`!;RaEG^em0#*b%$APB$d5v6j}CyO~p~4F_MA+O2`m=bW3UV zU`fPqXW46c-AKSn9Bk@Yt;pL3437-RE6v?`qwT|`SYiDm>*`xppSIeaGL92QtL_Yw_VMja@zrS;;&b@I*jE zfG{$Mp-~v=Z#`T4vhbT01uv*Ot@ciJJsB=eG0Ab32#qD#d?I4yc^#KA=z(VJ?o^5j z0it->7Hd^dS82=g-bR5yD&qL!3@iM+taKm^ILjf_V6>(T1vub^)Os1|Md;RG5VA65 z``U&okR$NgLommKLW`6$S5T23>HT;0e6jVtwr5{E+yY2u=7sYbsMwK z+~7=VpewZL@N{z4)>Y@V4!dL7MYQU!lO;w3vzc2scCAVtqCjl${OXiX6q4N*ROQS zqm)uEg4~g&01)lp1!1R06HrsWj)1Ps#8%w3f%H%0ctJ%-Iw^V&UXM?SUmnv_X_JYL z87%pMpq%9k9GeCE%lwlHT?wac<25q=2F1h)1-iDgLPES{^5W@rBVmZ%S@CPkjN#`F zDCBWAF4Kq?0^}dUdP>lV5nD~FfL$dbHh0_{QrpOGUgB}+EumA$SnzgqU+5{6Lg-gP zuRxJ!5|Kb;pSsHsxl7L)?X4;;ur-x1Yd1eTsG)0X7}M53ggkd* z!jV!nM%eqa$?Ac;g~lw;<~eLqi#~ z^^SnIx~OZ?D``+`ZmS+K&`N7lQ*4)suzua-g8?`1dERYi8?Cj5xU^)lm~;yP zsL-Nil9xbHHG$dkp$cnGiyR^qo^Rr`m%imu)yXID`#8gd1fmZ;m^U0fVw8n*N=jSu zq8L8@!&V~#a5sw`ft=<^wLU!W^n^@&dk82hRI_LLD~;31R=(kt>~_lHBE82Y@{}Shg4V#6q;j;>I*^gAGpo;C*6k<= zUmrbxZc6a7c&SNW%Mo4k0vKu zJBa!Q$=GP*=L#1TBUXI4cVG1{PAxh(h&!|}40n?O`HVe2bm$$;F-taF3=2%7*O#E* ztC^5D;v#;OQJB{)$(Ku$h{`9nlFW*{$`=yu{fu9Pys4`=A-*52$w(QRC47{8yCkJA z_sm1APl-UdWIK#c3e!%qN*XOuFveBRKh1_N4fJ*D^f*xThoiA+=shRRJD@&ePLM4*$7LhpK|WVt zTgZDc`AQetXqQ9>xWwunea<=1SZ|501Hkx3@_C{X-*5+KOK9$Kx1aOD!pevD^2&S_ zF@EQg0BLZ|1WMTe9+6^(&^4yADX9L&mZfo{!?qa2px%Q(Nq4>^qHHOt!Ik3#C6ctE zPDLW;8he&O3Fzc`C8>NXy?Hi?=PyF6nK1i1q}Q{MAsyi*C#{vcNO)o@qhY`+4?l60@pqz;{p% z>|G=Q`4(YwE%KWo)JkuGPKcOkjiHrD{IBL5#U?2yQ2oJksZH)!LTSl{8oO%Ic<;ogZEI_6(C$2Pmtq80FT@Wd5hr*kA)dZk&t z+!+&NS{9E95mNvkO;!X?e#svtk@LZ?Cslw7)t{=>M-f>}6Ecdcc8w>O2Y+RyOOOplMXZ;vf(`zK>9VXzxs z18!Ys6vvA|<_D~l+2GKRl9?MPMTfUrm<7?B_>bcdSd-#TDn{9t1fAvp(_awFI23kf zo-^iMyDBAiZJ-xz4pMb5@N68h$D^RTkJuDo*KumSMQiT&|&BciAtmGx<*9?OWtO6k?;wPo?Yf7T2S+$hPVNL zn>l;jf7QChPJz!B4Mk(hWWGlRyr%lOZ{E%?te{;Pcq|U&&8H&^u6lPrA7Gt+WhhZC zr<@>k8x12~qvTre1-9ZUH^b0ZoKq$Z%-rU7y@|RV@eyfTLmZ6llWsaz=PM~rBhX>< z81OSG^pkw7S9?c{DEi}_CWi*@@jRF-su(kkewlr-m&Lw9D%-?J z*$Iqpb*F9X&6n74^6*`w&gh}c%I&HFnNXmp49aeU82`M^t2rmvGd*J5tpGGpmJq$W za3Nh`>`ad1yM)h0F(jf?4-UPn>B1CnL zm@eg5oSzP^N>)oD0VOuJW=Z=@Z>vIQ>HAV6@BeZ1#>!(!5BG z#^DY&d2!PlP4j+p^;@x`odtTj%Cc$1R3&R;q3n9U%QlsfRLc$(to9cl^CL2sAdG7r- zJ!#eb*fLVwMrgrTn^JNX1@aV?c$H!v&{*Kmu*`UCoAJZS#;y?dNbb{0VUlr7UvMy!Y`{SG#Wi#4dgzP6ZH*pD0d+KZ$&`9d#fkx_*?*>am*9Br|=C&>F z5Vd-(hH@9{Z|u_-`AI3#JKVS1^z2$B-9dg@@K3i7ww4BVj@mZ*|91QEVBH_r6pxAj zkB-{r+AfwB|JWb>5B+V;jqQw_{}G1spJ8_Tws!xBCH{Y~b~f6^cDDaUF~c4{4+#nY zyoUh*82&p7#RpXsAK7Sh&9rT8Q8y~qV6R#H!rPX4N!l#Kn$#Wk#O+#> zm~XERq&&y^Yt*j^PGj7rv-?Iu=!WPcvk-dj^`D35U^deQUh zBU(I4JXcZyux-M}a(?}JdZuBOOJeG?4XBG+KKc1PE@rzh#D-D(qR)Dpu3htfdIJKQ zY2g&Z8sBJ7%ryN3qOKPDsD~@^U~l-;f)k6ei#g=TQ9!FcpbD01HrHtB48u_6fVp5@ zd9rO_QZiwgd~qy6Kouk_%~D%_jnzXZlCN{X!t`lfw)N)Xf=-~VGf{%;HX07jM!9ma zM}tDO42^9TwSIl8E5Fy@X3L5CUePs&;wyqOs-o;GE3i!Hrq1WX?qqbfu_Sa3N=pXO zV~0fxlhJPvOS6~gJ!FqSdBnepwGQ`?$G2p?4Xcf7&uX#ajWmq3Mnm$DOV4;ths!S3 z=)RXo`Z8*CM|??|q@~w1y~fY;oXYeY-a6~_CEw~;V~ME~pZyWoL4tz-FW3H#Gcqq& zPKr+XD!ohnNxtqh2USV#p?OmAebvm~O+GGX6tFUbezET47*>70?w*Pra>3-S0fJ^C zi{ZN&{E4K6yBvecuj(O&c-P%i%v-f(1Lzf|*$%NwFKLBqyvVM{vl5w7S>Io;wWj+B z&AdEu_pcJsDxF^p+>8&IAHUfb1HO-<<;dCd5a5D zE6@=}=ft~;(Iwh<_eF%w-}+7NLN;%lK^IUnt2cH>^olJeL+I5@-VSt*Wq`3u=Orkl7z=?k#LnP7r9@kC?Vw6l4vOF@q!4sGsw(oR*m9v|CoO>8i z&@r`?ca^Pvz^Pr+unUu)#2TWUaMj+^cZVXy!#BuNYbl+0bUNTbVQ~Z}J80IqL1te+ zvAIJOnu(9}0w`t!vNDeTg!Xqg8%4{@82fNgVm|nmm;a+*A>p?-Hq+C$`R-p7X2sii z5xBiYxUxvz}-V-q$8r%G3a&UI^?-a<=hl zqUcbgz}XhZ4>@D#tf8?H4tC_xp~+aMi5dOsN!0vbn4?RS$h*q4wlO4VI%BLjm(w^z z4zdfAtnN!+Uiw=2zPXt9OqH@&xMrHSCpI__wXUw%cbYt#x`U51#fK;=p}CB~JRMOU z%HO7ffpPbVVcQTc4;O+b_OYRTg~;NHo9HX__Qe@Xuv7jy^(o?~u5qmt&j8qgDA0uE zlP|*@{t+1C*<>RiQJ`iTjQd((APoi_P{HlNdf;=YaS3KqIOPa19!Pp_>yH1swYNeQ`-&f|eE#q- z{jb)h|I5xX!BWs|bO^qSM$DCg7mze>#NRE!3AXyyjKFkYT?-`gBBOF8!aQ5b=s;<0 zJdMZ1Yq)G&2qW`i{{|bJArj!Q@g~>i6}cG3LAL$1Dhdrnrx1d$M9K?Ym=yD@{=o=B zB9f`(xmOdBLE>{E8&xmfc&rY^)tY{Zz2}X>)GnD)oq{1PjEO~HH7oWR`g$STH-VUQ zPq0c4N$r-q4|WJ_ck^uIob?R*3YOWM?dD0PVoq2ajrgIaK@J80aQv_1m$k8UF#fsR z71d0U`B3f*Y(MLj=P;Wzs?o|t#p@{?0SkkEg!E>DhV$i?X3;IzFUK}uLA>6gmB%k6 z4;a29KOSH)=SxhiDcMYBufEzyW<1@!ti}i6eMl98o~G8bZtuwV1+_@5$-XiVqH>f( zk($#LB)3T<%B!CKol|YZl^Kg^XNHKa)oGJ0!c1EIe}-MM`o1H9Br9~@4}E8 zO}JZz<=4i(0sNaEG5~nQzdwvT<1zVZ!@Y`nla1p z>i7LM{H5TUm?x9PjmBRMCBJ{NUWD*;ZNO-DvX@?2t+Qb;Itl*=v@;`%F()N?NuWg+@v2C zj?l}Xb>Q`$(J(^JI0sI9y?`&SccFIFA5YZ*!#44&HwaLZJxmNQ!6vX?;X@n%@&9Krmq{Q z=ij*Pz8UC*`01=3C&1bNXLxWVkc@vak)HI|0FNTI! zf&XFVk$xl6w{bAm)&D)(eiRLUzaH}FA6XtA{y!rA&N68J$}(uaTf^K^Pv4B@uNa=@ zU%|J+Uot3%WGE?QsKh5F2BZr6hR1(Znvz0%fQn)Y2B=JcS2D)y!%f*h^aK&j!zh4& zQ9%B4LH0qhhlT*y=X-qrSRYSePs_89ogZ74H^syE57;lo+Fu#|R9pQQ{9g=@hsh6y z@0Hf4=%=~(PjmzPFX+EA^IyS#N(27_KaSbsVH5prg@>T#KTp-;X8Q@t{T22-Mf@l9 zV~Y3_J^cst_x<=!B##N>PZIct75zi=?<#yJ`B(b*PfU;L<4-2>|A*;&GWnG7X)^gY zWtMlye@FNvwS3C|G^qT^zmEFPL;tSu5B}dn%%^gmhBH6qj6ay|pQiZV zydDRaw;owm U0u20-6Y1fj@emJ&;XeNQKR`A5R{#J2 literal 0 HcmV?d00001 From 4e186d818fb7dae6b95b4a81d4a9cfac5b1958ee Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 6 Jun 2024 14:12:12 -0400 Subject: [PATCH 04/15] remove metadata when reloading single files The performance refactor caused a regression where making changes to a file with metadata would cause that metadata key to be duplicated. Since the server used to reload all files on a change, we never had to worry about this, but since we're trying to now only reload single files, we need to remove any metadata in that file from the model before reloading. This works similarly to how we collect per-file shapes, but is slightly more complex because array metadata with the same key get merged into a single array (as opposed to non-arrays, which cause an error when there are the same keys). --- .../smithy/lsp/DocumentLifecycleManager.java | 7 ++ .../amazon/smithy/lsp/project/Project.java | 45 ++++++- .../smithy/lsp/project/ProjectLoader.java | 43 ++++++- .../smithy/lsp/SmithyLanguageServerTest.java | 113 ++++++++++++++++++ 4 files changed, 202 insertions(+), 6 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java index 1302535a..7ba893f2 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; /** * Tracks asynchronous lifecycle tasks. Allows cancelling of an ongoing task @@ -41,4 +42,10 @@ void cancelAllTasks() { task.cancel(true); } } + + void waitForAllTasks() throws ExecutionException, InterruptedException { + for (CompletableFuture task : tasks.values()) { + task.get(); + } + } } 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 14273678..617bf6ac 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -9,6 +9,7 @@ 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; @@ -20,6 +21,7 @@ import software.amazon.smithy.lsp.protocol.UriAdapter; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.ModelAssembler; +import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; @@ -37,6 +39,7 @@ public final class Project { private final Map smithyFiles; private final Supplier assemblerFactory; private ValidatedResult modelResult; + private Map> perFileMetadata; private Project(Builder builder) { this.root = Objects.requireNonNull(builder.root); @@ -46,6 +49,7 @@ private Project(Builder builder) { this.smithyFiles = builder.smithyFiles; this.modelResult = builder.modelResult; this.assemblerFactory = builder.assemblerFactory; + this.perFileMetadata = builder.perFileMetadata; } /** @@ -181,10 +185,15 @@ public void updateModel(String uri, Document document, boolean validate) { SmithyFile previous = smithyFiles.get(path); Model currentModel = modelResult.getResult().get(); // unwrap would throw if the model is broken - Model.Builder builder = currentModel.toBuilder(); + Model.Builder builder = prepBuilderForReload(currentModel); for (Shape shape : previous.getShapes()) { builder.removeShape(shape.getId()); } + for (Map.Entry> e : this.perFileMetadata.entrySet()) { + if (!e.getKey().equals(path)) { + e.getValue().forEach(builder::putMetadataProperty); + } + } Model rest = builder.build(); ModelAssembler assembler = assemblerFactory.get() @@ -200,6 +209,7 @@ public void updateModel(String uri, Document document, boolean validate) { Set updatedShapes = getFileShapes(path, previous.getShapes()); // TODO: Could cache validation events SmithyFile updated = ProjectLoader.buildSmithyFile(path, document, updatedShapes).build(); + this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); this.smithyFiles.put(path, updated); } @@ -223,12 +233,21 @@ public void updateFiles(List addUris, List removeUris) { Model currentModel = modelResult.getResult().get(); ModelAssembler assembler = assemblerFactory.get(); if (!removeUris.isEmpty()) { - Model.Builder builder = currentModel.toBuilder(); + // Slightly strange way to do this, but we need to remove all model metadata, then + // re-add only metadata for remaining files. + Set remainingFilesWithMetadata = new HashSet<>(perFileMetadata.keySet()); + + Model.Builder builder = prepBuilderForReload(currentModel); + for (String uri : removeUris) { String path = UriAdapter.toPath(uri); + + remainingFilesWithMetadata.remove(path); + // Note: no need to remove anything from sources/imports, since they're // based on what's in the build files. SmithyFile smithyFile = smithyFiles.remove(path); + if (smithyFile == null) { LOGGER.severe("Attempted to remove file not in project: " + uri); continue; @@ -237,6 +256,12 @@ public void updateFiles(List addUris, List removeUris) { builder.removeShape(shape.getId()); } } + for (String remainingFileWithMetadata : remainingFilesWithMetadata) { + Map fileMetadata = perFileMetadata.get(remainingFileWithMetadata); + for (Map.Entry fileMetadataEntry : fileMetadata.entrySet()) { + builder.putMetadataProperty(fileMetadataEntry.getKey(), fileMetadataEntry.getValue()); + } + } assembler.addModel(builder.build()); } else { assembler.addModel(currentModel); @@ -247,6 +272,7 @@ public void updateFiles(List addUris, List removeUris) { } this.modelResult = assembler.assemble(); + this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); for (String uri : addUris) { String path = UriAdapter.toPath(uri); @@ -258,6 +284,15 @@ public void updateFiles(List addUris, List removeUris) { } } + // This mainly exists to explain why we remove the metadata + private Model.Builder prepBuilderForReload(Model model) { + return model.toBuilder() + // clearing the metadata here, and adding back only metadata from other files + // is the only sure-fire way to make sure everything is truly removed, and we + // don't lose anything + .clearMetadata(); + } + private Set getFileShapes(String path, Set orDefault) { return this.modelResult.getResult() .map(model -> model.shapes() @@ -278,6 +313,7 @@ static final class Builder { private final Map smithyFiles = new HashMap<>(); private ValidatedResult modelResult; private Supplier assemblerFactory = Model::assembler; + private Map> perFileMetadata = new HashMap<>(); private Builder() { } @@ -336,6 +372,11 @@ public Builder assemblerFactory(Supplier assemblerFactory) { return this; } + public Builder perFileMetadata(Map> perFileMetadata) { + this.perFileMetadata = perFileMetadata; + return this; + } + public Project build() { return new Project(this); } 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 8243d831..fe9740c6 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -35,6 +35,8 @@ 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.Shape; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; @@ -97,9 +99,10 @@ public static Project loadDetached(String uri, String text) { SmithyFile smithyFile = buildSmithyFile(filePath, document, fileShapes).build(); smithyFiles.put(filePath, smithyFile); } - builder.smithyFiles(smithyFiles); - return builder.build(); + return builder.smithyFiles(smithyFiles) + .perFileMetadata(computePerFileMetadata(modelResult)) + .build(); } /** @@ -200,9 +203,10 @@ public static Result> load(Path root) { SmithyFile smithyFile = buildSmithyFile(path, document, fileShapes).build(); smithyFiles.put(path, smithyFile); } - projectBuilder.smithyFiles(smithyFiles); - return Result.ok(projectBuilder.build()); + return Result.ok(projectBuilder.smithyFiles(smithyFiles) + .perFileMetadata(computePerFileMetadata(modelResult)) + .build()); } /** @@ -230,6 +234,37 @@ public static SmithyFile.Builder buildSmithyFile(String path, Document document, .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 + // 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()); + } + } + return perFileMetadata; + } + private static Result, Exception> 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 diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index a60b2ced..ea5b68f1 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -6,7 +6,9 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; @@ -24,6 +26,7 @@ import java.nio.file.Paths; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.eclipse.lsp4j.CompletionItem; @@ -52,6 +55,8 @@ import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.Node; public class SmithyLanguageServerTest { @Test @@ -433,6 +438,7 @@ public void didChange() throws Exception { .buildCompletion(); List completions = server.completion(completionParams).get().getLeft(); + // TODO: Somehow this has become flaky assertThat(completions, containsInAnyOrder(hasLabel("GetFoo"), hasLabel("GetFooInput"))); } @@ -998,6 +1004,113 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { assertThat(server.getProjects().isDetached(uri), is(false)); } + @Test + public void reloadingProjectWithArrayMetadataValues() throws Exception { + String modelText1 = "$version: \"2\"\n" + + "\n" + + "metadata foo = [1]\n" + + "metadata foo = [2]\n" + + "metadata bar = {a: [1]}\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"; + String modelText2 = "$version: \"2\"\n" + + "\n" + + "metadata foo = [3]\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + Map metadataBefore = server.getProject().getModelResult().unwrap().getMetadata(); + assertThat(metadataBefore, hasKey("foo")); + assertThat(metadataBefore, hasKey("bar")); + assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataBefore.get("foo").expectArrayNode().size(), equalTo(3)); + + String uri = workspace.getUri("model-0.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText1) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(RangeAdapter.lineSpan(8, 0, 0)) + .text("\nstring Baz\n") + .build()); + server.didSave(RequestBuilders.didSave() + .uri(uri) + .build()); + + server.getLifecycleManager().getTask(uri).get(); + + Map metadataAfter = server.getProject().getModelResult().unwrap().getMetadata(); + assertThat(metadataAfter, hasKey("foo")); + assertThat(metadataAfter, hasKey("bar")); + assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataAfter.get("foo").expectArrayNode().size(), equalTo(3)); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(RangeAdapter.of(2, 0, 3, 0)) // removing the first 'foo' metadata + .text("") + .build()); + + server.getLifecycleManager().getTask(uri).get(); + + Map metadataAfter2 = server.getProject().getModelResult().unwrap().getMetadata(); + assertThat(metadataAfter2, hasKey("foo")); + assertThat(metadataAfter2, hasKey("bar")); + assertThat(metadataAfter2.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataAfter2.get("foo").expectArrayNode().size(), equalTo(2)); + } + + @Test + public void changingWatchedFilesWithMetadata() throws Exception { + String modelText1 = "$version: \"2\"\n" + + "\n" + + "metadata foo = [1]\n" + + "metadata foo = [2]\n" + + "metadata bar = {a: [1]}\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"; + String modelText2 = "$version: \"2\"\n" + + "\n" + + "metadata foo = [3]\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + Map metadataBefore = server.getProject().getModelResult().unwrap().getMetadata(); + assertThat(metadataBefore, hasKey("foo")); + assertThat(metadataBefore, hasKey("bar")); + assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataBefore.get("foo").expectArrayNode().size(), equalTo(3)); + + String uri = workspace.getUri("model-1.smithy"); + + workspace.deleteModel("model-1.smithy"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + Map metadataAfter = server.getProject().getModelResult().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)); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } From 4ca22b577b513f247b8258c3c136f66da390c5a5 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Fri, 7 Jun 2024 07:50:02 -0400 Subject: [PATCH 05/15] Various fixes to project file management This commit makes updates to how we manage files that are opened, and what happens when you add/remove files. Previously, if you moved a detached file into your project, the server would load that file from disk, rather than using the in-memory Document. More test cases were added around adding/moving detached files. Also made it so that diagnostics are re-reported back to the client for open files after a reload. --- .../smithy/lsp/DocumentLifecycleManager.java | 31 ++- .../smithy/lsp/SmithyLanguageServer.java | 100 ++++++--- .../amazon/smithy/lsp/project/Project.java | 4 +- .../smithy/lsp/project/ProjectLoader.java | 72 +++++-- .../smithy/lsp/project/ProjectManager.java | 46 +++-- .../amazon/smithy/lsp/LspMatchers.java | 15 ++ .../smithy/lsp/SmithyLanguageServerTest.java | 195 +++++++++++++++++- .../amazon/smithy/lsp/SmithyMatchers.java | 2 +- .../amazon/smithy/lsp/StubClient.java | 6 +- .../amazon/smithy/lsp/TestWorkspace.java | 28 ++- .../smithy/lsp/project/ProjectTest.java | 15 +- 11 files changed, 429 insertions(+), 85 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java index 7ba893f2..2d6f1ca6 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -6,20 +6,33 @@ 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. Allows cancelling of an ongoing task - * if a new task needs to be started + * Tracks asynchronous lifecycle tasks and client-managed documents. + * Allows cancelling 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<>(); DocumentLifecycleManager() { } + Set getManagedDocuments() { + return managedDocumentUris; + } + + boolean isManaged(String uri) { + return getManagedDocuments().contains(uri); + } + CompletableFuture getTask(String uri) { return tasks.get(uri); } @@ -27,7 +40,7 @@ CompletableFuture getTask(String uri) { void cancelTask(String uri) { if (tasks.containsKey(uri)) { CompletableFuture task = tasks.get(uri); - if (!task.isDone() && !task.isCancelled()) { + if (!task.isDone()) { task.cancel(true); } } @@ -37,6 +50,14 @@ void putTask(String uri, CompletableFuture future) { tasks.put(uri, future); } + void putOrComposeTask(String uri, CompletableFuture future) { + if (tasks.containsKey(uri)) { + tasks.computeIfPresent(uri, (k, v) -> v.thenCompose((unused) -> future)); + } else { + tasks.put(uri, future); + } + } + void cancelAllTasks() { for (CompletableFuture task : tasks.values()) { task.cancel(true); @@ -45,7 +66,9 @@ void cancelAllTasks() { void waitForAllTasks() throws ExecutionException, InterruptedException { for (CompletableFuture task : tasks.values()) { - task.get(); + if (!task.isDone()) { + task.get(); + } } } } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index dc285dfb..95fa4417 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -24,10 +24,12 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; 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; @@ -256,11 +258,13 @@ public CompletableFuture initialize(InitializeParams params) { private void tryInitProject(Path root) { LOGGER.info("Initializing project at " + root); lifecycleManager.cancelAllTasks(); - Result> loadResult = ProjectLoader.load(root); + Result> loadResult = ProjectLoader.load( + root, projects, lifecycleManager.getManagedDocuments()); if (loadResult.isOk()) { + Project updatedProject = loadResult.unwrap(); + resolveDetachedProjects(updatedProject); projects.updateMainProject(loadResult.unwrap()); LOGGER.info("Initialized project at " + root); - // TODO: If this is a project reload, there are open files which need to have updated diagnostics reported. } else { LOGGER.severe("Init project failed"); // TODO: Maybe we just start with this anyways by default, and then add to it @@ -281,6 +285,37 @@ private void tryInitProject(Path root) { } } + private void resolveDetachedProjects(Project updatedProject) { + // This is a project reload, so we need to resolve any added/removed files + // that need to be moved to or from detached projects. + if (getProject() != null) { + Set currentProjectSmithyPaths = getProject().getSmithyFiles().keySet(); + Set updatedProjectSmithyPaths = updatedProject.getSmithyFiles().keySet(); + + Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); + addedPaths.removeAll(currentProjectSmithyPaths); + for (String addedPath : addedPaths) { + String addedUri = UriAdapter.toUri(addedPath); + if (projects.isDetached(addedUri)) { + projects.removeDetachedProject(addedUri); + } + } + + Set removedPaths = new HashSet<>(currentProjectSmithyPaths); + removedPaths.removeAll(updatedProjectSmithyPaths); + for (String removedPath : removedPaths) { + String removedUri = UriAdapter.toUri(removedPath); + // Only move to a detached project if the file is managed + if (lifecycleManager.getManagedDocuments().contains(removedUri)) { + // Note: This should always be non-null, since we essentially got this from the current project + Document removedDocument = projects.getDocument(removedUri); + // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings + projects.createDetachedProject(removedUri, removedDocument.copyText()); + } + } + } + } + private CompletableFuture registerSmithyFileWatchers() { Project project = projects.getMainProject(); List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project); @@ -386,8 +421,8 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { LOGGER.info("DidChangeWatchedFiles"); // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), // or the smithy-build.json itself was changed - List createdSmithyFiles = new ArrayList<>(); - List deletedSmithyFiles = new ArrayList<>(); + Set createdSmithyFiles = new HashSet<>(params.getChanges().size()); + Set deletedSmithyFiles = new HashSet<>(params.getChanges().size()); boolean changedBuildFiles = false; for (FileEvent event : params.getChanges()) { String changedUri = event.getUri(); @@ -410,8 +445,6 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { } } - // TODO: Handle files being moved into projects from detached. Will need - // to be able to load project with files managed by the client. if (changedBuildFiles) { client.info("Build files changed, reloading project"); // TODO: Handle more granular updates to build files. @@ -419,11 +452,15 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { } else { client.info("Project files changed, adding files " + createdSmithyFiles + " and removing files " + deletedSmithyFiles); + // We get this notification for watched files, which only includes project files, + // so we don't need to resolve detached projects. projects.getMainProject().updateFiles(createdSmithyFiles, deletedSmithyFiles); } // TODO: Update watchers based on specific changes unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + + sendFileDiagnosticsForManagedDocuments(); } @Override @@ -443,8 +480,7 @@ public void didChange(DidChangeTextDocumentParams params) { lifecycleManager.cancelTask(uri); - Project project = projects.getProject(uri); - Document document = project.getDocument(uri); + Document document = projects.getDocument(uri); if (document == null) { client.error("Attempted to change document the server isn't tracking: " + uri); return; @@ -462,7 +498,15 @@ public void didChange(DidChangeTextDocumentParams params) { // TODO: A consequence of this is that any existing validation events are cleared, which // is kinda annoying. // Report any parse/shape/trait loading errors - triggerUpdate(uri); + Project project = projects.getProject(uri); + if (project == null) { + client.error("Attempted to update a file the server isn't tracking: " + uri); + return; + } + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateModelWithoutValidating(uri)) + .thenComposeAsync(unused -> sendFileDiagnostics(uri)); + lifecycleManager.putTask(uri, future); } } @@ -473,17 +517,17 @@ public void didOpen(DidOpenTextDocumentParams params) { String uri = params.getTextDocument().getUri(); lifecycleManager.cancelTask(uri); + lifecycleManager.getManagedDocuments().add(uri); String text = params.getTextDocument().getText(); - Project project = projects.getProject(uri); - Document document = project.getDocument(uri); + Document document = projects.getDocument(uri); if (document != null) { document.applyEdit(null, text); } else { projects.createDetachedProject(uri, text); } - // TODO: Do we need to handle canceling this? - sendFileDiagnostics(uri); + + lifecycleManager.putTask(uri, sendFileDiagnostics(uri)); } @Override @@ -491,6 +535,7 @@ public void didClose(DidCloseTextDocumentParams params) { LOGGER.info("DidClose"); String uri = params.getTextDocument().getUri(); + lifecycleManager.getManagedDocuments().remove(uri); if (projects.isDetached(uri)) { // Only cancel tasks for detached projects, since we're dropping the project @@ -520,7 +565,11 @@ public void didSave(DidSaveTextDocumentParams params) { document.applyEdit(null, params.getText()); } - triggerUpdateAndValidate(uri); + Project project = projects.getProject(uri); + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateAndValidateModel(uri)) + .thenCompose(unused -> sendFileDiagnostics(uri)); + lifecycleManager.putTask(uri, future); } @Override @@ -638,20 +687,10 @@ public CompletableFuture> formatting(DocumentFormatting return completedFuture(Collections.singletonList(edit)); } - private void triggerUpdate(String uri) { - Project project = projects.getProject(uri); - CompletableFuture future = CompletableFuture - .runAsync(() -> project.updateModelWithoutValidating(uri)) - .thenComposeAsync(unused -> sendFileDiagnostics(uri)); - lifecycleManager.putTask(uri, future); - } - - private void triggerUpdateAndValidate(String uri) { - Project project = projects.getProject(uri); - CompletableFuture future = CompletableFuture - .runAsync(() -> project.updateAndValidateModel(uri)) - .thenCompose(unused -> sendFileDiagnostics(uri)); - lifecycleManager.putTask(uri, future); + private void sendFileDiagnosticsForManagedDocuments() { + for (String managedDocumentUri : lifecycleManager.getManagedDocuments()) { + lifecycleManager.putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); + } } private CompletableFuture sendFileDiagnostics(String uri) { @@ -670,6 +709,11 @@ List getFileDiagnostics(String uri) { } Project project = projects.getProject(uri); + if (project == null) { + client.error("Attempted to get file diagnostics for an untracked file: " + uri); + return Collections.emptyList(); + } + SmithyFile smithyFile = project.getSmithyFile(uri); String path = UriAdapter.toPath(uri); 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 617bf6ac..f9f7ad55 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -7,6 +7,7 @@ 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; @@ -39,6 +40,7 @@ public final class Project { private final Map smithyFiles; private final Supplier assemblerFactory; private ValidatedResult modelResult; + // TODO: Probably should move this into SmithyFile private Map> perFileMetadata; private Project(Builder builder) { @@ -219,7 +221,7 @@ public void updateModel(String uri, Document document, boolean validate) { * @param addUris URIs of files to add * @param removeUris URIs of files to remove */ - public void updateFiles(List addUris, List removeUris) { + public void updateFiles(Collection addUris, Collection removeUris) { if (!modelResult.getResult().isPresent()) { LOGGER.severe("Attempted to update files in project with no model: " + addUris + " " + removeUris); return; 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 fe9740c6..da703958 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -16,6 +16,7 @@ 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; @@ -43,6 +44,8 @@ /** * 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()); @@ -53,8 +56,8 @@ private ProjectLoader() { /** * Loads a detached (single-file) {@link Project} with the given file. * - *

Unlike {@link #load(Path)}, this method isn't fallible since it - * doesn't do any IO. + *

Unlike {@link #load(Path, ProjectManager, Set)}, this method isn't + * fallible since it doesn't do any IO. * * @param uri URI of the file to load into a project * @param text Text of the file to load into a project @@ -106,7 +109,8 @@ public static Project loadDetached(String uri, String text) { } /** - * Loads a {@link Project} from a given root path. + * Loads a {@link Project} at the given root path, using any {@code managedDocuments} + * instead of loading from disk. * *

This will return a failed result if loading the project config, resolving * the dependencies, or creating the model assembler fail. @@ -117,9 +121,15 @@ 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 * @return Result of loading the project */ - public static Result> load(Path root) { + public static Result> load( + Path root, + ProjectManager projects, + Set managedDocuments + ) { Result> configResult = ProjectConfigLoader.loadFromRoot(root); if (configResult.isErr()) { return Result.err(configResult.unwrapErr()); @@ -133,15 +143,6 @@ public static Result> load(Path root) { List dependencies = resolveResult.unwrap(); - List sources = config.getSources().stream() - .map(root::resolve) - .map(Path::normalize) - .collect(Collectors.toList()); - List imports = config.getImports().stream() - .map(root::resolve) - .map(Path::normalize) - .collect(Collectors.toList()); - // The model assembler factory is used to get assemblers that already have the correct // dependencies resolved for future loads Result, Exception> assemblerFactoryResult = createModelAssemblerFactory(dependencies); @@ -152,17 +153,47 @@ public static Result> load(Path root) { Supplier assemblerFactory = assemblerFactoryResult.unwrap(); ModelAssembler assembler = assemblerFactory.get(); - Result, Exception> loadModelResult = Result.ofFallible(() -> - loadModel(assembler, sources, imports)); + // 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.getSources(), config.getImports()); + + Result, Exception> loadModelResult = Result.ofFallible(() -> { + for (Path path : allSmithyFilePaths) { + if (!managedDocuments.isEmpty()) { + String pathString = path.toString(); + String uri = UriAdapter.toUri(pathString); + if (managedDocuments.contains(uri)) { + assembler.addUnparsedModel(pathString, projects.getDocument(uri).copyText()); + } else { + assembler.addImport(path); + } + } else { + assembler.addImport(path); + } + } + + return assembler.assemble(); + }); // 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 the errors somehow. For now, just fail + // 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())); } ValidatedResult modelResult = loadModelResult.unwrap(); + List sources = config.getSources().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); + List imports = config.getImports().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); + Project.Builder projectBuilder = Project.builder() .root(root) .sources(sources) @@ -181,7 +212,6 @@ public static Result> load(Path root) { } // There may be smithy files part of the project that aren't part of the model - List allSmithyFilePaths = collectAllSmithyPaths(root, config.getSources(), config.getImports()); for (Path path : allSmithyFilePaths) { if (!shapes.containsKey(path.toString())) { shapes.put(path.toString(), Collections.emptySet()); @@ -209,6 +239,10 @@ public static Result> load(Path root) { .build()); } + static Result> load(Path root) { + return load(root, new ProjectManager(), new HashSet<>(0)); + } + /** * Computes extra information about what is in the Smithy file and where, * such as the namespace, imports, version number, and shapes. @@ -316,11 +350,11 @@ private static Result createDependenciesClassLoader(L private static List collectAllSmithyPaths(Path root, List sources, List imports) { List paths = new ArrayList<>(); for (String file : sources) { - Path path = root.resolve(file); + Path path = root.resolve(file).normalize(); collectDirectory(paths, root, path); } for (String file : imports) { - Path path = root.resolve(file); + Path path = root.resolve(file).normalize(); collectDirectory(paths, root, path); } return paths; 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 092fba57..03986271 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.Map; import org.eclipse.lsp4j.InitializeParams; +import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.UriAdapter; /** @@ -53,22 +54,17 @@ public Map getDetachedProjects() { * @return The project the given {@code uri} belongs to */ public Project getProject(String uri) { - // We might be in a state where a file was added to the main 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. - if (mainProject.getSmithyFiles().containsKey(UriAdapter.toPath(uri)) && detached.containsKey(uri)) { - removeDetachedProject(uri); - } - - if (detached.containsKey(uri)) { + String path = UriAdapter.toPath(uri); + if (isDetached(uri)) { return detached.get(uri); + } else if (mainProject.getSmithyFiles().containsKey(path)) { + return mainProject; + } else { + // Note: In practice, this shouldn't really happen because the server shouldn't + // be tracking any files that aren't attached to a project. But for testing, this + // is useful to ensure that fact. + return null; } - - // TODO: Maybe this should take care of loading the detached project, so - // we can assume in other places that any given file always belongs to - // some project. - return mainProject; } /** @@ -76,6 +72,15 @@ public Project getProject(String uri) { * @return Whether the given {@code uri} is of a file in a detached project */ public boolean isDetached(String uri) { + // We might be in a state where a file was added to the main project, + // 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 = UriAdapter.toPath(uri); + if (mainProject.getSmithyFiles().containsKey(path) && detached.containsKey(uri)) { + removeDetachedProject(uri); + } + return detached.containsKey(uri); } @@ -97,4 +102,17 @@ public Project createDetachedProject(String uri, String text) { 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 project.getDocument(uri); + } + return null; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index 60ecd166..c315c9f8 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp; import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; import org.hamcrest.CustomTypeSafeMatcher; @@ -72,4 +73,18 @@ public void describeMismatchSafely(Range range, Description description) { } }; } + + public static Matcher diagnosticWithMessage(Matcher message) { + return new CustomTypeSafeMatcher("has matching message") { + @Override + protected boolean matchesSafely(Diagnostic item) { + return message.matches(item.getMessage()); + } + + @Override + public void describeMismatchSafely(Diagnostic event, Description description) { + description.appendDescriptionOf(message).appendText("was " + event.getMessage()); + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index ea5b68f1..936fbb8e 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -15,15 +15,18 @@ 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; import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument; -import static software.amazon.smithy.lsp.SmithyMatchers.hasMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -45,6 +48,7 @@ import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextEdit; @@ -470,13 +474,13 @@ public void didChangeReloadsModel() throws Exception { server.getLifecycleManager().getTask(uri).get(); assertThat(server.getProject().getModelResult().getValidationEvents(), - containsInAnyOrder(hasMessage(containsString("Error creating trait")))); + containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); server.didSave(didSaveParams); assertThat(server.getProject().getModelResult().getValidationEvents(), - containsInAnyOrder(hasMessage(containsString("Error creating trait")))); + containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); } @Test @@ -873,11 +877,12 @@ public void addingWatchedFile() throws Exception { .text("$") .build()); - // Make sure the task is running, then wait for it (what if it's already done?) + // Make sure the task is running, then wait for it CompletableFuture future = server.getLifecycleManager().getTask(uri); assertThat(future, notNullValue()); future.get(); + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); assertThat(server.getProjects().isDetached(uri), is(false)); assertThat(server.getProjects().getMainProject().getSmithyFile(uri), notNullValue()); assertThat(server.getProjects().getMainProject().getDocument(uri), notNullValue()); @@ -908,7 +913,8 @@ public void removingWatchedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), nullValue()); + assertThat(server.getLifecycleManager().isManaged(uri), is(false)); + assertThat(server.getProjects().getDocument(uri), nullValue()); } @Test @@ -928,16 +934,31 @@ 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()); String movedFilename = "model/main.smithy"; workspace.moveModel(filename, movedFilename); String movedUri = workspace.getUri(movedFilename); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(movedUri) + .text(modelText) + .build()); server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) .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()); } @Test @@ -956,7 +977,10 @@ public void removingAttachedFile() { .uri(uri) .text(modelText) .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), notNullValue()); String movedFilename = "main.smithy"; workspace.moveModel(filename, movedFilename); @@ -973,7 +997,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()); } @Test @@ -1001,7 +1030,10 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { .uri(uri) .text(modelText) .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), notNullValue()); } @Test @@ -1111,6 +1143,159 @@ public void changingWatchedFilesWithMetadata() throws Exception { assertThat(metadataAfter.get("foo").expectArrayNode().size(), equalTo(2)); } + // TODO: Somehow this is flaky + @Test + public void addingOpenedDetachedFile() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "main.smithy"; + String modelText = "$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"; + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + assertThat(server.getLifecycleManager().getManagedDocuments(), not(hasItem(uri))); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), nullValue()); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + assertThat(server.getLifecycleManager().getManagedDocuments(), hasItem(uri)); + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(RangeAdapter.point(3, 0)) + .text("string Bar\n") + .build()); + + // Add the already-opened file to the project + List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); + updatedSources.add("main.smithy"); + workspace.updateConfig(workspace.getConfig() + .toBuilder() + .sources(updatedSources) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getLifecycleManager().getManagedDocuments(), hasItem(uri)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProject().getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getProject().getModelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + } + + @Test + public void movingDetachedFile() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "main.smithy"; + String modelText = "$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"; + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + // Moving to an also detached file - the server doesn't send DidChangeWatchedFiles + String movedFilename = "main-2.smithy"; + workspace.moveModel(filename, movedFilename); + String movedUri = workspace.getUri(movedFilename); + + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(movedUri) + .text(modelText) + .build()); + + server.getLifecycleManager().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)); + } + + @Test + public void updatesDiagnosticsAfterReload() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + + String filename1 = "model/main.smithy"; + String modelText1 = "$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "// using an unknown trait\n" + + "@foo\n" + + "string Bar\n"; + workspace.addModel(filename1, modelText1); + + StubClient client = new StubClient(); + SmithyLanguageServer server = initFromWorkspace(workspace, client); + + String uri1 = workspace.getUri(filename1); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri1) + .text(modelText1) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + List publishedDiagnostics1 = client.diagnostics; + assertThat(publishedDiagnostics1, hasSize(1)); + assertThat(publishedDiagnostics1.get(0).getUri(), equalTo(uri1)); + assertThat(publishedDiagnostics1.get(0).getDiagnostics(), containsInAnyOrder( + diagnosticWithMessage(containsString("Model.UnresolvedTrait")))); + + String filename2 = "model/trait.smithy"; + String modelText2 = "$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "// adding the missing trait\n" + + "@trait\n" + + "structure foo {}\n"; + workspace.addModel(filename2, modelText2); + + String uri2 = workspace.getUri(filename2); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri2, FileChangeType.Created) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + List publishedDiagnostics2 = client.diagnostics; + assertThat(publishedDiagnostics2, hasSize(2)); // sent more diagnostics + assertThat(publishedDiagnostics2.get(1).getUri(), equalTo(uri1)); // sent diagnostics for opened file + assertThat(publishedDiagnostics2.get(1).getDiagnostics(), empty()); // adding the trait cleared the event + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java index ce5d192a..9f2510b0 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -36,7 +36,7 @@ public void describeMismatchSafely(Model model, Description description) { }; } - public static Matcher hasMessage(Matcher message) { + public static Matcher eventWithMessage(Matcher message) { return new CustomTypeSafeMatcher("has matching message") { @Override protected boolean matchesSafely(ValidationEvent item) { diff --git a/src/test/java/software/amazon/smithy/lsp/StubClient.java b/src/test/java/software/amazon/smithy/lsp/StubClient.java index 59c029a3..f8b1d130 100644 --- a/src/test/java/software/amazon/smithy/lsp/StubClient.java +++ b/src/test/java/software/amazon/smithy/lsp/StubClient.java @@ -12,7 +12,7 @@ import org.eclipse.lsp4j.services.LanguageClient; public final class StubClient implements LanguageClient { - public List diagnostics = new ArrayList<>(); + public final List diagnostics = new ArrayList<>(); public List shown = new ArrayList<>(); public List logged = new ArrayList<>(); @@ -27,7 +27,9 @@ public void clear() { @Override public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { - this.diagnostics.add(diagnostics); + synchronized (this.diagnostics) { + this.diagnostics.add(diagnostics); + } } @Override diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index 1842d426..675ce3d9 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -24,9 +24,11 @@ public class TestWorkspace { private static final NodeMapper MAPPER = new NodeMapper(); private final Path root; + private SmithyBuildConfig config; - private TestWorkspace(Path root) { + private TestWorkspace(Path root, SmithyBuildConfig config) { this.root = root; + this.config = config; } /** @@ -36,6 +38,10 @@ public Path getRoot() { return root; } + public SmithyBuildConfig getConfig() { + return config; + } + /** * @param filename The name of the file to get the URI for, relative to the root * @return The LSP URI for the given filename @@ -72,6 +78,11 @@ public void deleteModel(String relativePath) { } } + public void updateConfig(SmithyBuildConfig newConfig) { + writeConfig(root, newConfig); + this.config = newConfig; + } + /** * @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 @@ -222,15 +233,24 @@ public TestWorkspace build() { .imports(imports) .build(); } - String configString = Node.prettyPrintJson(MAPPER.serialize(config)); - Files.write(root.resolve("smithy-build.json"), configString.getBytes(StandardCharsets.UTF_8)); + writeConfig(root, config); writeModels(root); - return new TestWorkspace(root); + return new TestWorkspace(root, config); } catch (Exception e) { throw new RuntimeException(e); } } } + + private static void writeConfig(Path root, SmithyBuildConfig config) { + String configString = Node.prettyPrintJson(MAPPER.serialize(config)); + Path configPath = root.resolve("smithy-build.json"); + try { + Files.write(configPath, configString.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } 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 2e80f8f6..9b9d1959 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -6,9 +6,6 @@ package software.amazon.smithy.lsp.project; import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.empty; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.hasItems; @@ -16,8 +13,11 @@ import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.nullValue; 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.eventWithMessage; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; -import static software.amazon.smithy.lsp.SmithyMatchers.hasMessage; import static software.amazon.smithy.lsp.document.DocumentTest.string; import java.nio.file.Path; @@ -182,7 +182,7 @@ public void loadsProjectWithExternalJars() { containsString("alloy-core.jar!/META-INF/smithy/uuid.smithy"))); assertThat(project.getModelResult().isBroken(), is(true)); - assertThat(project.getModelResult().getValidationEvents(Severity.ERROR), hasItem(hasMessage(containsString("Proto index 1")))); + assertThat(project.getModelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); assertThat(project.getModelResult().getResult().isPresent(), is(true)); Model model = project.getModelResult().getResult().get(); @@ -208,11 +208,12 @@ public void failsLoadingUnparseableSmithyBuildJson() { } @Test - public void failsLoadingProjectWithNonExistingSource() { + public void doesntFailLoadingProjectWithNonExistingSource() { Path root = Paths.get(getClass().getResource("broken/source-doesnt-exist").getPath()); Result> result = ProjectLoader.load(root); - assertThat(result.isErr(), is(true)); + assertThat(result.isErr(), is(false)); + assertThat(result.unwrap().getSmithyFiles().size(), equalTo(1)); // still have the prelude } From 5862d57e140b0424cbefb70b341979252af946df Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Sun, 9 Jun 2024 12:49:46 -0400 Subject: [PATCH 06/15] Properly load detached files with invalid syntax Fixes an issue where basically any time you created a file not attached to a project, it wouldn't ever be loaded properly and any updates wouldn't register. This happened because the server wasn't creating a SmithyFile for a detached file if it had no shapes in it. The SmithyFile is created only when the project is initialized, so subsequent changes wouldn't fix it. --- .../smithy/lsp/project/ProjectLoader.java | 115 +++++++++--------- .../smithy/lsp/SmithyLanguageServerTest.java | 114 +++++++++++++++++ .../amazon/smithy/lsp/SmithyMatchers.java | 27 ++++ .../amazon/smithy/lsp/TestWorkspace.java | 2 +- 4 files changed, 199 insertions(+), 59 deletions(-) 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 da703958..0253acff 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -20,6 +20,7 @@ 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; @@ -73,35 +74,25 @@ public static Project loadDetached(String uri, String text) { List sources = Collections.singletonList(path); Project.Builder builder = Project.builder() - .root(path) // TODO: Does this need to be a directory? + .root(path.getParent()) .sources(sources) .modelResult(modelResult); - Map> shapes; - if (modelResult.getResult().isPresent()) { - Model model = modelResult.getResult().get(); - shapes = model.shapes().collect(Collectors.groupingByConcurrent( - shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); - } else { - shapes = new HashMap<>(0); - } - - Map smithyFiles = new HashMap<>(shapes.size()); - for (Map.Entry> entry : shapes.entrySet()) { - String filePath = entry.getKey(); - Document document; + Map smithyFiles = computeSmithyFiles(sources, modelResult, (filePath) -> { + // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but + // the model stores jar paths as URIs if (UriAdapter.isSmithyJarFile(filePath) || UriAdapter.isJarFile(filePath)) { - document = Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(filePath))); + return Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(filePath))); } else if (filePath.equals(asPath)) { - document = Document.of(text); + return Document.of(text); } else { - LOGGER.severe("Found unexpected file when loading detached (single file) project: " + filePath); - continue; + // TODO: Make generic 'please file a bug report' exception + throw new IllegalStateException( + "Attempted to load an unknown source file (" + + filePath + ") in detached project at " + + asPath + ". This is a bug in the language server."); } - Set fileShapes = entry.getValue(); - SmithyFile smithyFile = buildSmithyFile(filePath, document, fileShapes).build(); - smithyFiles.put(filePath, smithyFile); - } + }); return builder.smithyFiles(smithyFiles) .perFileMetadata(computePerFileMetadata(modelResult)) @@ -202,45 +193,64 @@ public static Result> load( .modelResult(modelResult) .assemblerFactory(assemblerFactory); - Map> shapes; + Map smithyFiles = computeSmithyFiles(allSmithyFilePaths, modelResult, (filePath) -> { + // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but + // the model stores jar paths as URIs + if (UriAdapter.isSmithyJarFile(filePath) || UriAdapter.isJarFile(filePath)) { + // Technically this can throw + return Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(filePath))); + } + // TODO: We recompute uri from path and vice-versa very frequently, + // maybe we can cache it. + String uri = UriAdapter.toUri(filePath); + if (managedDocuments.contains(uri)) { + return projects.getDocument(uri); + } + // There may be a more efficient way of reading this + return Document.of(IoUtils.readUtf8File(filePath)); + }); + + return Result.ok(projectBuilder.smithyFiles(smithyFiles) + .perFileMetadata(computePerFileMetadata(modelResult)) + .build()); + } + + static Result> load(Path root) { + return load(root, new ProjectManager(), new HashSet<>(0)); + } + + private static Map computeSmithyFiles( + List allSmithyFilePaths, + ValidatedResult modelResult, + Function documentProvider + ) { + Map> shapesByFile; if (modelResult.getResult().isPresent()) { Model model = modelResult.getResult().get(); - shapes = model.shapes().collect(Collectors.groupingByConcurrent( + shapesByFile = model.shapes().collect(Collectors.groupingByConcurrent( shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); } else { - shapes = new HashMap<>(0); + shapesByFile = new HashMap<>(allSmithyFilePaths.size()); } // There may be smithy files part of the project that aren't part of the model - for (Path path : allSmithyFilePaths) { - if (!shapes.containsKey(path.toString())) { - shapes.put(path.toString(), Collections.emptySet()); + for (Path smithyFilePath : allSmithyFilePaths) { + String pathString = smithyFilePath.toString(); + if (!shapesByFile.containsKey(pathString)) { + shapesByFile.put(pathString, Collections.emptySet()); } } - Map smithyFiles = new HashMap<>(shapes.size()); - for (Map.Entry> entry : shapes.entrySet()) { - String path = entry.getKey(); - Document document; - if (UriAdapter.isSmithyJarFile(path) || UriAdapter.isJarFile(path)) { - // Technically this can throw - document = Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(path))); - } else { - // There may be a more efficient way of reading this - document = Document.of(IoUtils.readUtf8File(path)); - } - Set fileShapes = entry.getValue(); + Map smithyFiles = new HashMap<>(allSmithyFilePaths.size()); + for (Map.Entry> shapesByFileEntry : shapesByFile.entrySet()) { + String path = shapesByFileEntry.getKey(); + Document document = documentProvider.apply(path); + Set fileShapes = shapesByFileEntry.getValue(); SmithyFile smithyFile = buildSmithyFile(path, document, fileShapes).build(); smithyFiles.put(path, smithyFile); } - return Result.ok(projectBuilder.smithyFiles(smithyFiles) - .perFileMetadata(computePerFileMetadata(modelResult)) - .build()); - } - - static Result> load(Path root) { - return load(root, new ProjectManager(), new HashSet<>(0)); + return smithyFiles; } /** @@ -321,17 +331,6 @@ private static Result, Exception> createModelAssemblerF }); } - private static ValidatedResult loadModel(ModelAssembler assembler, List sources, List imports) { - for (Path path : sources) { - assembler.addImport(path); - } - for (Path path : imports) { - assembler.addImport(path); - } - - return assembler.assemble(); - } - private static Result createDependenciesClassLoader(List dependencies) { // Taken (roughly) from smithy-ci IsolatedRunnable try { diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 936fbb8e..c57e163f 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasKey; @@ -20,7 +21,9 @@ import static software.amazon.smithy.lsp.LspMatchers.hasText; import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument; import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithIdAndTraits; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -55,6 +58,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.hamcrest.Matcher; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; @@ -1296,6 +1300,116 @@ public void updatesDiagnosticsAfterReload() throws Exception { assertThat(publishedDiagnostics2.get(1).getDiagnostics(), empty()); // adding the trait cleared the event } + @Test + public void invalidSyntaxModelPartiallyLoads() { + String modelText1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String modelText2 = "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("model-0.smithy"); + + assertThat(server.getProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProject().getModelResult().isBroken(), is(true)); + assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true)); + assertThat(server.getProject().getModelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String filename = "main.smithy"; + String modelText = "string Foo\n"; + workspace.addModel(filename, modelText); + + String uri = workspace.getUri(filename); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getProjects().getProject(uri).getModelResult().isBroken(), is(true)); + assertThat(server.getProjects().getProject(uri).getModelResult().getResult().isPresent(), is(true)); + assertThat(server.getProjects().getProject(uri).getSmithyFiles().keySet(), hasItem(endsWith(filename))); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(RangeAdapter.origin()) + .text("$version: \"2\"\nnamespace com.foo\n") + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getProjects().getProject(uri).getModelResult().isBroken(), is(false)); + assertThat(server.getProjects().getProject(uri).getModelResult().getResult().isPresent(), is(true)); + assertThat(server.getProjects().getProject(uri).getSmithyFiles().keySet(), hasItem(endsWith(filename))); + assertThat(server.getProjects().getProject(uri).getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + @Disabled + public void todoCheckApplysInPartialLoad() throws Exception { + String modelText1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String modelText2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri2 = workspace.getUri("model-1.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri2) + .text(modelText2) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri2) + .range(RangeAdapter.point(3, 23)) + .text("2") + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true)); + assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getProject().getModelResult(), hasValue( + hasShapeWithIdAndTraits("com.foo#Foo", "smithy.api#length"))); + + String uri1 = workspace.getUri("model-0.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri1) + .text(modelText1) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri1) + .range(RangeAdapter.point(3, 0)) + .text("string Another\n") + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true)); + assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Baz"))); + assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Another"))); + assertThat(server.getProject().getModelResult(), hasValue( + hasShapeWithIdAndTraits("com.foo#Foo", "smithy.api#length"))); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java index 9f2510b0..b4fa2b54 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -14,11 +14,21 @@ import software.amazon.smithy.model.loader.Prelude; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.model.validation.ValidationEvent; public final class SmithyMatchers { private SmithyMatchers() {} + public static Matcher> hasValue(Matcher matcher) { + return new CustomTypeSafeMatcher>("a validated result with a value") { + @Override + protected boolean matchesSafely(ValidatedResult item) { + return item.getResult().isPresent() && matcher.matches(item.getResult().get()); + } + }; + } + public static Matcher hasShapeWithId(String id) { return new CustomTypeSafeMatcher("a model with the shape id `" + id + "`") { @Override @@ -36,6 +46,23 @@ public void describeMismatchSafely(Model model, Description description) { }; } + public static Matcher hasShapeWithIdAndTraits(String id, String... traitIds) { + return new CustomTypeSafeMatcher("a model with a shape with id " + id + " that has traits: " + String.join(",", traitIds)) { + @Override + protected boolean matchesSafely(Model item) { + if (!item.getShape(ShapeId.from(id)).isPresent()) { + return false; + } + Shape shape = item.expectShape(ShapeId.from(id)); + boolean hasTraits = true; + for (String traitId : traitIds) { + hasTraits = hasTraits && shape.hasTrait(traitId); + } + return hasTraits; + } + }; + } + public static Matcher eventWithMessage(Matcher message) { return new CustomTypeSafeMatcher("has matching message") { @Override diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index 675ce3d9..4ce11c70 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -106,7 +106,7 @@ public static TestWorkspace emptyWithDirSource() { /** * @param models Strings of the models to create in the workspace * @return A workspace with n models, each "model-n.smithy", with their given contents, - * and a smithy-build.json with sources = [..."model-n.smithy"] + * and a smithy-build.json with sources = ["model-0.smithy", ..., "model-n.smithy"] */ public static TestWorkspace multipleModels(String... models) { Builder builder = builder(); From c9193adfaa91902c44a9ace4cd836d9ae9e6fffb Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Sun, 9 Jun 2024 13:37:50 -0400 Subject: [PATCH 07/15] Refactor Project to have its ProjectConfig Also adds some test cases for moving around detached and/or broken files. --- .../amazon/smithy/lsp/project/Project.java | 50 ++++------ .../smithy/lsp/project/ProjectLoader.java | 19 ++-- .../smithy/lsp/SmithyLanguageServerTest.java | 97 ++++++++++++++++++- 3 files changed, 121 insertions(+), 45 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 f9f7ad55..d0dd97f7 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -34,8 +34,7 @@ public final class Project { private static final Logger LOGGER = Logger.getLogger(Project.class.getName()); private final Path root; - private final List sources; - private final List imports; + private final ProjectConfig config; private final List dependencies; private final Map smithyFiles; private final Supplier assemblerFactory; @@ -45,8 +44,7 @@ public final class Project { private Project(Builder builder) { this.root = Objects.requireNonNull(builder.root); - this.sources = builder.sources; - this.imports = builder.imports; + this.config = Objects.requireNonNull(builder.config); this.dependencies = builder.dependencies; this.smithyFiles = builder.smithyFiles; this.modelResult = builder.modelResult; @@ -75,19 +73,27 @@ public Path getRoot() { } /** - * @return The paths of all Smithy sources, exactly as they were specified - * in this project's smithy build configuration files + * @return The paths of all Smithy sources specified + * in this project's smithy build configuration files, + * normalized and resolved against {@link #getRoot()}. */ public List getSources() { - return sources; + return config.getSources().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); } /** - * @return The paths of all imports, exactly as they were specified in this - * project's smithy build configuration files + * @return The paths of all Smithy imports specified + * in this project's smithy build configuration files, + * normalized and resolved against {@link #getRoot()}. */ public List getImports() { - return imports; + return config.getImports().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); } /** @@ -309,8 +315,7 @@ static Builder builder() { static final class Builder { private Path root; - private final List sources = new ArrayList<>(); - private final List imports = new ArrayList<>(); + private ProjectConfig config; private final List dependencies = new ArrayList<>(); private final Map smithyFiles = new HashMap<>(); private ValidatedResult modelResult; @@ -325,25 +330,8 @@ public Builder root(Path root) { return this; } - public Builder sources(List paths) { - this.sources.clear(); - this.sources.addAll(paths); - return this; - } - - public Builder addSource(Path path) { - this.sources.add(path); - return this; - } - - public Builder imports(List paths) { - this.imports.clear(); - this.imports.addAll(paths); - return this; - } - - public Builder addImport(Path path) { - this.imports.add(path); + public Builder config(ProjectConfig config) { + this.config = config; return this; } 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 0253acff..df712900 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -58,7 +58,8 @@ private ProjectLoader() { * Loads a detached (single-file) {@link Project} with the given file. * *

Unlike {@link #load(Path, ProjectManager, Set)}, this method isn't - * fallible since it doesn't do any IO. + * fallible since it doesn't do any IO that we would want to recover an + * error from. * * @param uri URI of the file to load into a project * @param text Text of the file to load into a project @@ -75,7 +76,9 @@ public static Project loadDetached(String uri, String text) { Project.Builder builder = Project.builder() .root(path.getParent()) - .sources(sources) + .config(ProjectConfig.builder() + .sources(Collections.singletonList(asPath)) + .build()) .modelResult(modelResult); Map smithyFiles = computeSmithyFiles(sources, modelResult, (filePath) -> { @@ -176,19 +179,9 @@ public static Result> load( ValidatedResult modelResult = loadModelResult.unwrap(); - List sources = config.getSources().stream() - .map(root::resolve) - .map(Path::normalize) - .collect(Collectors.toList()); - List imports = config.getImports().stream() - .map(root::resolve) - .map(Path::normalize) - .collect(Collectors.toList()); - Project.Builder projectBuilder = Project.builder() .root(root) - .sources(sources) - .imports(imports) + .config(config) .dependencies(dependencies) .modelResult(modelResult) .assemblerFactory(assemblerFactory); diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index c57e163f..bf718328 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -21,9 +21,9 @@ import static software.amazon.smithy.lsp.LspMatchers.hasText; import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument; import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; -import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithIdAndTraits; +import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -1202,6 +1202,45 @@ public void addingOpenedDetachedFile() throws Exception { assertThat(server.getProject().getModelResult().unwrap(), hasShapeWithId("com.foo#Bar")); } + @Test + public void detachingOpenedFile() throws Exception { + String modelText = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(RangeAdapter.point(3, 0)) + .text("string Bar\n") + .build()); + + workspace.updateConfig(workspace.getConfig() + .toBuilder() + .sources(new ArrayList<>()) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getLifecycleManager().getManagedDocuments(), 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).getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(server.getProjects().getProject(uri).getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + } + @Test public void movingDetachedFile() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); @@ -1356,6 +1395,62 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { assertThat(server.getProjects().getProject(uri).getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } + @Test + public void addingDetachedFileWithInvalidSyntax() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String filename = "main.smithy"; + workspace.addModel(filename, ""); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text("") + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); + + List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); + updatedSources.add(filename); + workspace.updateConfig(workspace.getConfig() + .toBuilder() + .sources(updatedSources) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text("$version: \"2\"\n") + .range(RangeAdapter.origin()) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text("namespace com.foo\n") + .range(RangeAdapter.point(1, 0)) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text("string Foo\n") + .range(RangeAdapter.point(2, 0)) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getDetachedProjects().keySet(), empty()); + assertThat(server.getProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + } + @Test @Disabled public void todoCheckApplysInPartialLoad() throws Exception { From 62413b14da0e37d066a4a077157b07331c9e3332 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Sun, 9 Jun 2024 16:14:10 -0400 Subject: [PATCH 08/15] Fix applying traits in partial load The partial loading strategy, which is meant to reduce the amount of unnecessary re-parsing of unchanged files on every file change, works by removing shapes defined in the file that has changed. But this wasn't taking into account traits applied using `apply` in other files. When a shape is removed, all the traits are removed, and the `apply` isn't persisted. So we need to keep track of all trait applications that aren't in the same file as the shape def. Additionally, list traits have their values merged, so we actually need to either partially remove a trait (i.e. only certain elements), or just reload all the files that contain part of the trait's value. This implementation does the latter, which also requires creating a sort of dependency graph of which files need other files to be loaded with them. There's likely room for optimization here (potentially switching to the first approach), but we will have to guage the performance. This commit also consolidates the project updating logic for adding, removing, and changing files into a single method of Project, and adds a bunch of tests around different situations of `apply`. --- .../amazon/smithy/lsp/project/Project.java | 234 +++++++----- .../smithy/lsp/project/ProjectConfig.java | 4 + .../smithy/lsp/project/ProjectLoader.java | 1 + .../amazon/smithy/lsp/project/SmithyFile.java | 8 +- .../project/SmithyFileDependenciesIndex.java | 124 +++++++ .../smithy/lsp/SmithyLanguageServerTest.java | 25 +- .../amazon/smithy/lsp/SmithyMatchers.java | 28 +- .../amazon/smithy/lsp/UtilMatchers.java | 33 ++ .../smithy/lsp/project/ProjectTest.java | 341 ++++++++++++++++++ 9 files changed, 681 insertions(+), 117 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java create mode 100644 src/test/java/software/amazon/smithy/lsp/UtilMatchers.java 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 d0dd97f7..01535f7f 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -7,7 +7,6 @@ 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; @@ -21,9 +20,12 @@ import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.UriAdapter; 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.Node; +import software.amazon.smithy.model.shapes.AbstractShapeBuilder; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; @@ -39,17 +41,19 @@ public final class Project { private final Map smithyFiles; private final Supplier assemblerFactory; private ValidatedResult modelResult; - // TODO: Probably should move this into SmithyFile + // TODO: Move this into SmithyFileDependenciesIndex private Map> perFileMetadata; + private SmithyFileDependenciesIndex smithyFileDependenciesIndex; private Project(Builder builder) { this.root = Objects.requireNonNull(builder.root); - this.config = Objects.requireNonNull(builder.config); + 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; } /** @@ -148,8 +152,7 @@ public SmithyFile getSmithyFile(String uri) { * @param uri The URI of the Smithy file to update */ public void updateModelWithoutValidating(String uri) { - Document document = getDocument(uri); - updateModel(uri, document, false); + updateFiles(Collections.emptySet(), Collections.emptySet(), Collections.singleton(uri), false); } /** @@ -158,119 +161,90 @@ public void updateModelWithoutValidating(String uri) { * @param uri The URI of the Smithy file to update */ public void updateAndValidateModel(String uri) { - Document document = getDocument(uri); - updateModel(uri, document, true); + updateFiles(Collections.emptySet(), Collections.emptySet(), Collections.singleton(uri), true); } - // TODO: This is a little all over the place /** - * Update the model with the contents of the given {@code document}, optionally - * running validation. + * Updates this project by adding and removing files. Runs model validation. * - * @param uri The URI of the Smithy file to update - * @param document The {@link Document} with updated contents - * @param validate Whether to run validation + *

Added files are assumed to not be managed by the client, and are loaded from disk. + * + * @param addUris URIs of files to add + * @param removeUris URIs of files to remove */ - public void updateModel(String uri, Document document, boolean validate) { - if (document == null || !modelResult.getResult().isPresent()) { - // TODO: At one point in testing, the server got stuck with a certain validation event - // always being present, and no other features working. I haven't been able to reproduce - // it, but I added these logs to check for it. - if (document == null) { - LOGGER.info("No document loaded for " + uri + ", skipping model load."); - } - if (!modelResult.getResult().isPresent()) { - LOGGER.info("No model loaded, skipping updating model with " + uri); - } - // 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 - // just compute the shapes later? - return; - } - - String path = UriAdapter.toPath(uri); - - SmithyFile previous = smithyFiles.get(path); - Model currentModel = modelResult.getResult().get(); // unwrap would throw if the model is broken - - Model.Builder builder = prepBuilderForReload(currentModel); - for (Shape shape : previous.getShapes()) { - builder.removeShape(shape.getId()); - } - for (Map.Entry> e : this.perFileMetadata.entrySet()) { - if (!e.getKey().equals(path)) { - e.getValue().forEach(builder::putMetadataProperty); - } - } - Model rest = builder.build(); - - ModelAssembler assembler = assemblerFactory.get() - .addModel(rest) - .addUnparsedModel(path, document.copyText()); - - if (!validate) { - assembler.disableValidation(); - } - - this.modelResult = assembler.assemble(); - - Set updatedShapes = getFileShapes(path, previous.getShapes()); - // TODO: Could cache validation events - SmithyFile updated = ProjectLoader.buildSmithyFile(path, document, updatedShapes).build(); - this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); - this.smithyFiles.put(path, updated); + public void updateFiles(Set addUris, Set removeUris) { + updateFiles(addUris, removeUris, Collections.emptySet(), true); } /** - * Updates this project by adding and removing files. Also runs model validation. + * Updates this project by adding, removing, and changing files. Can optionally run validation. + * + *

Added files are assumed to not be managed by the client, and are loaded from disk. * * @param addUris URIs of files to add * @param removeUris URIs of files to remove + * @param changeUris URIs of files that changed + * @param validate Whether to run model validation. */ - public void updateFiles(Collection addUris, Collection removeUris) { + public void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) { if (!modelResult.getResult().isPresent()) { - LOGGER.severe("Attempted to update files in project with no model: " + addUris + " " + removeUris); + // 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 + // just compute the shapes later? + LOGGER.severe("Attempted to update files in project with no model: " + + addUris + " " + removeUris + " " + changeUris); return; } - if (addUris.isEmpty() && removeUris.isEmpty()) { + if (addUris.isEmpty() && removeUris.isEmpty() && changeUris.isEmpty()) { LOGGER.info("No files provided to update"); return; } - Model currentModel = modelResult.getResult().get(); + Model currentModel = modelResult.getResult().get(); // unwrap would throw if the model is broken ModelAssembler assembler = assemblerFactory.get(); - if (!removeUris.isEmpty()) { - // Slightly strange way to do this, but we need to remove all model metadata, then - // re-add only metadata for remaining files. - Set remainingFilesWithMetadata = new HashSet<>(perFileMetadata.keySet()); + // 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<>(); + + if (!removeUris.isEmpty() || !changeUris.isEmpty()) { Model.Builder builder = prepBuilderForReload(currentModel); for (String uri : removeUris) { String path = UriAdapter.toPath(uri); + removedPaths.add(path); - remainingFilesWithMetadata.remove(path); + removeFileForReload(assembler, builder, path, visited); + removeDependentsForReload(assembler, builder, path, visited); // Note: no need to remove anything from sources/imports, since they're // based on what's in the build files. - SmithyFile smithyFile = smithyFiles.remove(path); + smithyFiles.remove(path); + } - if (smithyFile == null) { - LOGGER.severe("Attempted to remove file not in project: " + uri); - continue; - } - for (Shape shape : smithyFile.getShapes()) { - builder.removeShape(shape.getId()); - } + for (String uri : changeUris) { + String path = UriAdapter.toPath(uri); + changedPaths.add(path); + + removeFileForReload(assembler, builder, path, visited); + removeDependentsForReload(assembler, builder, path, visited); } - for (String remainingFileWithMetadata : remainingFilesWithMetadata) { - Map fileMetadata = perFileMetadata.get(remainingFileWithMetadata); - for (Map.Entry fileMetadataEntry : fileMetadata.entrySet()) { - builder.putMetadataProperty(fileMetadataEntry.getKey(), fileMetadataEntry.getValue()); + + // visited will be a superset of removePaths + addRemainingMetadataForReload(builder, visited); + + assembler.addModel(builder.build()); + + for (String visitedPath : visited) { + // Only add back stuff we aren't trying to remove. + // Only removed paths will have had their SmithyFile removed. + if (!removedPaths.contains(visitedPath)) { + assembler.addUnparsedModel(visitedPath, smithyFiles.get(visitedPath).getDocument().copyText()); } } - assembler.addModel(builder.build()); } else { assembler.addModel(currentModel); } @@ -279,8 +253,28 @@ public void updateFiles(Collection addUris, Collection removeUri assembler.addImport(UriAdapter.toPath(uri)); } + if (!validate) { + assembler.disableValidation(); + } + this.modelResult = assembler.assemble(); this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); + this.smithyFileDependenciesIndex = SmithyFileDependenciesIndex.compute(this.modelResult); + + for (String visitedPath : visited) { + if (!removedPaths.contains(visitedPath)) { + SmithyFile current = smithyFiles.get(visitedPath); + Set updatedShapes = getFileShapes(visitedPath, smithyFiles.get(visitedPath).getShapes()); + // 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.getDocument(), updatedShapes).build()); + } else { + current.setShapes(updatedShapes); + } + } + } for (String uri : addUris) { String path = UriAdapter.toPath(uri); @@ -301,6 +295,70 @@ private Model.Builder prepBuilderForReload(Model model) { .clearMetadata(); } + private void removeFileForReload( + ModelAssembler assembler, + Model.Builder builder, + String path, + Set visited + ) { + if (path == null || visited.contains(path) || path.equals(SourceLocation.none().getFilename())) { + return; + } + + visited.add(path); + + for (Shape shape : smithyFiles.get(path).getShapes()) { + builder.removeShape(shape.getId()); + + // 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) -> + 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)); + } + } + + private void removeDependentsForReload( + ModelAssembler assembler, + Model.Builder builder, + String path, + Set visited + ) { + // 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. + smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) -> { + removeFileForReload(assembler, builder, depPath, visited); + }); + smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { + Shape shape = builder.getCurrentShapes().get(shapeId); + if (shape != null) { + builder.removeShape(shapeId); + AbstractShapeBuilder b = Shape.shapeToBuilder(shape); + for (Trait trait : traits) { + b.removeTrait(trait.toShapeId()); + } + builder.addShape(b.build()); + } + }); + } + + private void addRemainingMetadataForReload(Model.Builder builder, Set filesToSkip) { + for (Map.Entry> e : this.perFileMetadata.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() @@ -315,12 +373,13 @@ static Builder builder() { static final class Builder { private Path root; - private ProjectConfig config; + 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 = SmithyFileDependenciesIndex.EMPTY; private Builder() { } @@ -367,6 +426,11 @@ public Builder perFileMetadata(Map> 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/ProjectConfig.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java index f9d03c4e..11bb77a5 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -29,6 +29,10 @@ private ProjectConfig(Builder builder) { this.mavenConfig = builder.mavenConfig; } + static ProjectConfig empty() { + return builder().build(); + } + static Builder builder() { return new Builder(); } 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 df712900..625a3f77 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -205,6 +205,7 @@ public static Result> load( return Result.ok(projectBuilder.smithyFiles(smithyFiles) .perFileMetadata(computePerFileMetadata(modelResult)) + .smithyFileDependenciesIndex(SmithyFileDependenciesIndex.compute(modelResult)) .build()); } 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 ac748d17..912e9075 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -27,7 +27,9 @@ public final class SmithyFile { private final String path; private final Document document; - private final Set shapes; + // 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; @@ -64,6 +66,10 @@ public Set getShapes() { return shapes; } + void setShapes(Set shapes) { + this.shapes = shapes; + } + /** * @return This Smithy file's imports, if they exist */ diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java new file mode 100644 index 00000000..5fea7bb8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java @@ -0,0 +1,124 @@ +/* + * 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: + *

    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
  5. Either 1 or 2, but specifically with list traits, which 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. + *
  6. + *
+ */ +final class SmithyFileDependenciesIndex { + static final SmithyFileDependenciesIndex EMPTY = new SmithyFileDependenciesIndex( + new HashMap<>(0), new HashMap<>(0), new HashMap<>(0), new HashMap<>(0)); + + private final Map> filesToDependentFiles; + private final Map> shapeIdsToDependenciesFiles; + private final Map>> filesToTraitsTheyApply; + private final Map> shapesToAppliedTraitsInOtherFiles; + + 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().isPresent()) { + return EMPTY; + } + + 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/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index bf718328..22f52113 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -22,8 +22,8 @@ import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument; import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; -import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithIdAndTraits; import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; +import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -58,13 +58,15 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.hamcrest.Matcher; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.RangeAdapter; 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.ShapeId; +import software.amazon.smithy.model.traits.LengthTrait; public class SmithyLanguageServerTest { @Test @@ -953,7 +955,6 @@ public void addingDetachedFile() { .text(modelText) .build()); server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(uri, FileChangeType.Deleted) .event(movedUri, FileChangeType.Created) .build()); @@ -1452,8 +1453,7 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { } @Test - @Disabled - public void todoCheckApplysInPartialLoad() throws Exception { + public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { String modelText1 = "$version: \"2\"\n" + "namespace com.foo\n" + "string Foo\n"; @@ -1472,16 +1472,16 @@ public void todoCheckApplysInPartialLoad() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri2) - .range(RangeAdapter.point(3, 23)) + .range(RangeAdapter.of(3, 23, 3, 24)) .text("2") .build()); server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true)); assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - assertThat(server.getProject().getModelResult(), hasValue( - hasShapeWithIdAndTraits("com.foo#Foo", "smithy.api#length"))); + Shape foo = server.getProject().getModelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); + assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); String uri1 = workspace.getUri("model-0.smithy"); @@ -1497,12 +1497,11 @@ public void todoCheckApplysInPartialLoad() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true)); assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Baz"))); assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - assertThat(server.getProject().getModelResult(), hasValue( - hasShapeWithIdAndTraits("com.foo#Foo", "smithy.api#length"))); + foo = server.getProject().getModelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); + assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); } 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 b4fa2b54..72f05d03 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -21,11 +21,20 @@ public final class SmithyMatchers { private SmithyMatchers() {} public static Matcher> hasValue(Matcher matcher) { - return new CustomTypeSafeMatcher>("a validated result with a value") { + return new CustomTypeSafeMatcher>("A validated result with value " + matcher.toString()) { @Override protected boolean matchesSafely(ValidatedResult item) { return item.getResult().isPresent() && matcher.matches(item.getResult().get()); } + + @Override + public void describeMismatchSafely(ValidatedResult item, Description description) { + if (item.getResult().isPresent()) { + matcher.describeMismatch(item.getResult().get(), description); + } else { + description.appendText("Expected a value but result was empty."); + } + } }; } @@ -46,23 +55,6 @@ public void describeMismatchSafely(Model model, Description description) { }; } - public static Matcher hasShapeWithIdAndTraits(String id, String... traitIds) { - return new CustomTypeSafeMatcher("a model with a shape with id " + id + " that has traits: " + String.join(",", traitIds)) { - @Override - protected boolean matchesSafely(Model item) { - if (!item.getShape(ShapeId.from(id)).isPresent()) { - return false; - } - Shape shape = item.expectShape(ShapeId.from(id)); - boolean hasTraits = true; - for (String traitId : traitIds) { - hasTraits = hasTraits && shape.hasTrait(traitId); - } - return hasTraits; - } - }; - } - public static Matcher eventWithMessage(Matcher message) { return new CustomTypeSafeMatcher("has matching message") { @Override diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java new file mode 100644 index 00000000..d5e8a8e0 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -0,0 +1,33 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.Optional; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +public final class UtilMatchers { + private UtilMatchers() {} + + public static Matcher> anOptionalOf(Matcher matcher) { + return new CustomTypeSafeMatcher>("An optional that is present with value " + matcher.toString()) { + @Override + protected boolean matchesSafely(Optional item) { + return item.isPresent() && matcher.matches(item.get()); + } + + @Override + public void describeMismatchSafely(Optional item, Description description) { + if (!item.isPresent()) { + description.appendText("was an empty optional"); + } else { + matcher.describeMismatch(item.get(), description); + } + } + }; + } +} 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 9b9d1959..48857821 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -18,6 +18,7 @@ import static org.hamcrest.Matchers.hasSize; 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.nio.file.Path; @@ -25,11 +26,17 @@ import java.util.List; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.RangeAdapter; import software.amazon.smithy.lsp.protocol.UriAdapter; 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; import software.amazon.smithy.model.validation.ValidationEvent; @@ -251,4 +258,338 @@ public void loadsProjectWithUnNormalizedDirs() { containsString(root.resolve("smithy-test-traits.jar") + "!/META-INF/smithy/smithy.test.json"))); assertThat(project.getDependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); } + + @Test + public void changeFileApplyingSimpleTrait() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @length(min: 1)\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void changeFileApplyingListTrait() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + } + + @Test + public void changeFileApplyingListTraitWithUnrelatedDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "string Baz\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Baz @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + Shape baz = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(baz.hasTrait("length"), is(true)); + assertThat(baz.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + + String uri = workspace.getUri("model-0.smithy"); + Document document = project.getDocument(uri); + document.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + baz = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(baz.hasTrait("length"), is(true)); + assertThat(baz.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void changingFileApplyingListTraitWithRelatedDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + 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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @tags([\"bar\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "bar")); + } + + @Test + public void changingFileWithDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.getModelResult().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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.hasTrait("length"), is(true)); + assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void changingFileWithArrayDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @tags([\"foo\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.getModelResult().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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + } + + @Test + public void changingFileWithMixedArrayDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "@tags([\"foo\"])\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @tags([\"foo\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.getModelResult().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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "foo")); + } + + @Test + public void changingFileWithArrayDependenciesWithDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @tags([\"foo\"])\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + 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.applyEdit(RangeAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void removingSimpleApply() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @length(min: 1)\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @pattern(\"a\")\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("pattern"), is(true)); + assertThat(bar.expectTrait(PatternTrait.class).getPattern().pattern(), equalTo("a")); + 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.applyEdit(RangeAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("pattern"), is(true)); + assertThat(bar.expectTrait(PatternTrait.class).getPattern().pattern(), equalTo("a")); + assertThat(bar.hasTrait("length"), is(false)); + } + + @Test + public void removingArrayApply() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @tags([\"bar\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.getModelResult().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.applyEdit(RangeAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); + + project.updateModelWithoutValidating(uri); + + bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("bar")); + } } From d9f82f97035c9fd1fef97f73bc0e57ae2048f154 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Wed, 12 Jun 2024 08:09:02 -0400 Subject: [PATCH 09/15] Keep existing project on load failure Fixes an issue where if you did the following: 1. Created a valid project (ok smithy-build.json, loaded model) 2. Made the smithy-build.json invalid (e.g by adding an invalid dep) 3. Fixed the smithy-build.json The project would not recover, and any open project files would be lost to the server. --- .../smithy/lsp/SmithyLanguageServer.java | 6 ++- .../smithy/lsp/SmithyLanguageServerTest.java | 49 +++++++++++++++++++ 2 files changed, 54 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 95fa4417..b4dfc232 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -269,7 +269,11 @@ private void tryInitProject(Path root) { 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. - projects.updateMainProject(Project.empty(root)); + // 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.getMainProject() == null) { + projects.updateMainProject(Project.empty(root)); + } String baseMessage = "Failed to load Smithy project at " + root; StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 22f52113..c1a2e25c 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -59,6 +59,7 @@ import org.eclipse.lsp4j.services.LanguageClient; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.RangeAdapter; @@ -1504,6 +1505,54 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); } + @Test + public void brokenBuildFileEventuallyConsistent() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + workspace.addModel("model/main.smithy", ""); + String uri = workspace.getUri("model/main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text("") + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Created) + .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) + .build()); + + String fixed = "software.amazon.smithy:smithy-smoke-test-traits:1.49.0"; + workspace.updateConfig(workspace.getConfig().toBuilder() + .maven(MavenConfig.builder() + .dependencies(Collections.singletonList(fixed)) + .build()) + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text("$version: \"2\"\nnamespace com.foo\nstring Foo\n") + .range(RangeAdapter.origin()) + .build()); + server.getLifecycleManager().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).getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } From cfe22dbe6b2a7acc6a06ec467ee425830a5ab5a6 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Wed, 12 Jun 2024 12:28:39 -0400 Subject: [PATCH 10/15] Support direct use completions, absolute shape ids This commit makes it so you can manually type out a use statement, and get completions for the absolute shape id. It also adds support for completion/definition/hover for absolute shape ids in general. --- .../amazon/smithy/lsp/document/Document.java | 75 ++++++++++++- .../smithy/lsp/document/DocumentId.java | 72 ++++++++++++ .../smithy/lsp/document/DocumentParser.java | 54 ++++++++- .../lsp/document/DocumentPositionContext.java | 5 + .../smithy/lsp/handler/CompletionHandler.java | 79 +++++++++---- .../smithy/lsp/handler/DefinitionHandler.java | 28 +++-- .../smithy/lsp/handler/HoverHandler.java | 29 +++-- .../smithy/lsp/SmithyLanguageServerTest.java | 105 ++++++++++++++++++ .../smithy/lsp/document/DocumentTest.java | 73 ++++++------ 9 files changed, 436 insertions(+), 84 deletions(-) create mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentId.java 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 e166f278..4f893cbc 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -319,6 +319,19 @@ public CharBuffer borrowToken(Position position) { * within, or {@code null} if the position is not within an id */ public CharBuffer borrowId(Position position) { + DocumentId id = getDocumentIdAt(position); + if (id == null) { + return null; + } + return id.borrowIdValue(); + } + + /** + * @param position The position within the id to get + * @return A new id that the given {@code position} is + * within, or {@code null} if the position is not within an id + */ + public DocumentId getDocumentIdAt(Position position) { int idx = indexOfPosition(position); if (idx < 0) { return null; @@ -329,9 +342,26 @@ public CharBuffer borrowId(Position position) { return null; } + boolean hasHash = false; + boolean hasDollar = false; + boolean hasDot = false; int startIdx = idx; while (startIdx >= 0) { - if (isIdChar(buffer.charAt(startIdx))) { + 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 { break; @@ -340,14 +370,53 @@ public CharBuffer borrowId(Position position) { int endIdx = idx; while (endIdx < buffer.length()) { - if (isIdChar(buffer.charAt(endIdx))) { + char c = buffer.charAt(endIdx); + if (isIdChar(c)) { + switch (c) { + case '#': + hasHash = true; + break; + case '$': + hasDollar = true; + break; + case '.': + hasDot = true; + break; + default: + break; + } + endIdx += 1; } else { break; } } - return CharBuffer.wrap(buffer, startIdx + 1, endIdx); + // 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; + } else { + 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 + Range range = new Range(start, end); + return new DocumentId(type, wrapped, range); } private static boolean isIdChar(char c) { diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java new file mode 100644 index 00000000..48841e4f --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -0,0 +1,72 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.nio.CharBuffer; +import org.eclipse.lsp4j.Range; + +/** + * An inaccurate representation of an identifier within a model. It is + * inaccurate in the sense that the string value it references isn't + * necessarily a valid identifier, it just looks like an identifier. + */ +public final class DocumentId { + /** + * Represents the different kinds of identifiers that can be used to match. + */ + public enum Type { + /** + * Just a shape name, no namespace or member. + */ + ID, + + /** + * Same as {@link Type#ID}, but with a namespace. + */ + 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; + } + + private final Type type; + private final CharBuffer buffer; + private final Range range; + + DocumentId(Type type, CharBuffer buffer, Range range) { + this.type = type; + this.buffer = buffer; + this.range = range; + } + + public Type getType() { + return type; + } + + public String copyIdValue() { + return buffer.toString(); + } + + public CharBuffer borrowIdValue() { + return buffer; + } + + public Range getRange() { + return range; + } +} 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 cf4c6a82..01026687 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -344,6 +344,8 @@ public DocumentPositionContext determineContext(Position position) { return DocumentPositionContext.SHAPE_DEF; } else if (isMixin(position)) { return DocumentPositionContext.MIXIN; + } else if (isUseTarget(position)) { + return DocumentPositionContext.USE_TARGET; } else { return DocumentPositionContext.OTHER; } @@ -382,7 +384,7 @@ private boolean isMixin(Position position) { } jumpToPosition(document.positionAtIndex(lastWithIndex)); - if (!isSp(-1)) { + if (!isWs(-1)) { return false; } skip(); @@ -483,6 +485,35 @@ private boolean isMemberTarget(Position position) { 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) { @@ -572,8 +603,17 @@ private boolean isSp() { return is(' ') || is('\t'); } - private boolean isSp(int offset) { - return is(' ', offset) || is('\t', offset); + private boolean isWs(int offset) { + char peeked = peek(offset); + switch (peeked) { + case '\n': + case '\r': + case ' ': + case '\t': + return true; + default: + return false; + } } private boolean isEof() { @@ -634,7 +674,11 @@ private boolean isShapeType() { } private int firstIndexOfWithOnlyLeadingWs(String s) { - int searchFrom = 0; + 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); @@ -654,7 +698,7 @@ private int firstIndexOfWithOnlyLeadingWs(String s) { } previousSearchFrom = searchFrom; searchFrom = idx + 1; - } while (previousSearchFrom != searchFrom && searchFrom < document.length()); + } while (previousSearchFrom != searchFrom && searchFrom < end); return -1; } diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java index 9f193289..d961bddf 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java @@ -32,6 +32,11 @@ public enum DocumentPositionContext { */ MIXIN, + /** + * Within the target (shape id) of a {@code use} statement. + */ + USE_TARGET, + /** * An unknown or indeterminate position. */ diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java index a099e3f3..2fbb6928 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java @@ -10,6 +10,7 @@ import java.util.Collections; import java.util.List; import java.util.function.BiConsumer; +import java.util.function.Predicate; import java.util.stream.Stream; import org.eclipse.lsp4j.CompletionContext; import org.eclipse.lsp4j.CompletionItem; @@ -20,6 +21,8 @@ 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; @@ -88,12 +91,11 @@ public List handle(CompletionParams params, CancelChecker cc) { return Collections.emptyList(); } - String token = smithyFile.getDocument().copyToken(position); - if (token == null || token.isEmpty()) { + // TODO: Maybe we should only copy the token up to the current character + DocumentId id = smithyFile.getDocument().getDocumentIdAt(position); + if (id == null || id.borrowIdValue().length() == 0) { return Collections.emptyList(); } - String matchToken = token.toLowerCase(); - if (cc.isCanceled()) { return Collections.emptyList(); @@ -110,20 +112,27 @@ public List handle(CompletionParams params, CancelChecker cc) { return Collections.emptyList(); } - return contextualShapes(model, context) - .filter(shape -> shape.getId().getName().toLowerCase().startsWith(matchToken)) + return contextualShapes(model, context, smithyFile) + .filter(contextualMatcher(id, context)) // TODO: Use mapMulti when we upgrade jdk>16 - .collect(ArrayList::new, completionsFactory(context, model, smithyFile), ArrayList::addAll); + .collect(ArrayList::new, completionsFactory(context, model, smithyFile, id), ArrayList::addAll); } private static BiConsumer, Shape> completionsFactory( DocumentPositionContext context, Model model, - SmithyFile smithyFile + SmithyFile smithyFile, + DocumentId id ) { TraitBodyVisitor visitor = new TraitBodyVisitor(model); + boolean useFullId = context == DocumentPositionContext.USE_TARGET + || id.getType() == DocumentId.Type.NAMESPACE + || id.getType() == DocumentId.Type.ABSOLUTE_ID; return (acc, shape) -> { - String shapeName = shape.getId().getName(); + String shapeLabel = useFullId + ? shape.getId().toString() + : shape.getId().getName(); + switch (context) { case TRAIT: String traitBody = shape.accept(visitor); @@ -133,24 +142,21 @@ private static BiConsumer, Shape> completionsFactory( } if (!traitBody.isEmpty()) { - CompletionItem traitWithMembersItem = createCompletion(shapeName + "(" + traitBody + ")"); - addTextEdits(traitWithMembersItem, shape.getId(), smithyFile); + CompletionItem traitWithMembersItem = createCompletion( + shapeLabel + "(" + traitBody + ")", shape.getId(), smithyFile, useFullId, id); acc.add(traitWithMembersItem); } - CompletionItem defaultCompletionItem; - if (shape.isStructureShape() && !shape.members().isEmpty()) { - defaultCompletionItem = createCompletion(shapeName + "()"); - } else { - defaultCompletionItem = createCompletion(shapeName); - } - addTextEdits(defaultCompletionItem, shape.getId(), smithyFile); + String defaultLabel = shape.isStructureShape() && !shape.members().isEmpty() + ? shapeLabel + "()" + : shapeLabel; + CompletionItem defaultCompletionItem = createCompletion( + defaultLabel, shape.getId(), smithyFile, useFullId, id); acc.add(defaultCompletionItem); break; case MEMBER_TARGET: case MIXIN: - CompletionItem item = createCompletion(shapeName); - addTextEdits(item, shape.getId(), smithyFile); - acc.add(item); + case USE_TARGET: + acc.add(createCompletion(shapeLabel, shape.getId(), smithyFile, useFullId, id)); break; case SHAPE_DEF: case OTHER: @@ -193,7 +199,7 @@ private static TextEdit getImportTextEdit(SmithyFile smithyFile, String importId return null; } - private Stream contextualShapes(Model model, DocumentPositionContext context) { + private static Stream contextualShapes(Model model, DocumentPositionContext context, SmithyFile smithyFile) { switch (context) { case TRAIT: return model.getShapesWithTrait(TraitDefinition.class).stream(); @@ -203,6 +209,11 @@ private Stream contextualShapes(Model model, DocumentPositionContext cont .filter(shape -> !shape.hasTrait(TraitDefinition.class)); case MIXIN: return model.getShapesWithTrait(MixinTrait.class).stream(); + case USE_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.getId().getNamespace().contentEquals(smithyFile.getNamespace())) + .filter(shape -> !smithyFile.hasImport(shape.getId().toString())); case SHAPE_DEF: case OTHER: default: @@ -210,9 +221,31 @@ private Stream contextualShapes(Model model, DocumentPositionContext cont } } - private static CompletionItem createCompletion(String label) { + private static Predicate contextualMatcher(DocumentId id, DocumentPositionContext context) { + String matchToken = id.copyIdValue().toLowerCase(); + if (context == DocumentPositionContext.USE_TARGET + || id.getType() == DocumentId.Type.NAMESPACE + || id.getType() == DocumentId.Type.ABSOLUTE_ID) { + return (shape) -> shape.getId().toString().toLowerCase().startsWith(matchToken); + } else { + return (shape) -> shape.getId().getName().toLowerCase().startsWith(matchToken); + } + } + + 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.getRange(), label); + completionItem.setTextEdit(Either.forLeft(textEdit)); + if (!useFullId) { + addTextEdits(completionItem, shapeId, smithyFile); + } return completionItem; } diff --git a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java index 81eab0d0..5f15cb0b 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java @@ -7,11 +7,12 @@ import java.util.Collections; import java.util.List; -import java.util.Set; +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; @@ -43,8 +44,8 @@ public List handle(DefinitionParams params) { } Position position = params.getPosition(); - String token = smithyFile.getDocument().copyId(position); - if (token == null) { + DocumentId id = smithyFile.getDocument().getDocumentIdAt(position); + if (id == null || id.borrowIdValue().length() == 0) { return Collections.emptyList(); } @@ -54,15 +55,10 @@ public List handle(DefinitionParams params) { } Model model = modelResult.getResult().get(); - Set imports = smithyFile.getImports(); - CharSequence namespace = smithyFile.getNamespace(); DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) .determineContext(position); return contextualShapes(model, context) - .filter(shape -> Prelude.isPublicPreludeShape(shape) - || shape.getId().getNamespace().contentEquals(namespace) - || imports.contains(shape.getId().toString())) - .filter(shape -> shape.getId().getName().equals(token)) + .filter(contextualMatcher(smithyFile, id)) .findFirst() .map(Shape::getSourceLocation) .map(LocationAdapter::fromSource) @@ -70,7 +66,19 @@ public List handle(DefinitionParams params) { .orElse(Collections.emptyList()); } - private Stream contextualShapes(Model model, DocumentPositionContext context) { + private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) { + String token = id.copyIdValue(); + if (id.getType() == DocumentId.Type.ABSOLUTE_ID) { + return (shape) -> shape.getId().toString().equals(token); + } else { + return (shape) -> (Prelude.isPublicPreludeShape(shape) + || shape.getId().getNamespace().contentEquals(smithyFile.getNamespace()) + || smithyFile.hasImport(shape.getId().toString())) + && shape.getId().getName().equals(token); + } + } + + private static Stream contextualShapes(Model model, DocumentPositionContext context) { switch (context) { case TRAIT: return model.getShapesWithTrait(TraitDefinition.class).stream(); diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java index 734d4cf5..87de8207 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java @@ -12,13 +12,14 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; 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; @@ -55,9 +56,8 @@ public Hover handle(HoverParams params, Severity minimumSeverity) { } Position position = params.getPosition(); - // TODO: Handle shape id - String token = smithyFile.getDocument().copyToken(position); - if (token == null) { + DocumentId id = smithyFile.getDocument().getDocumentIdAt(position); + if (id == null || id.borrowIdValue().length() == 0) { return hover; } @@ -67,15 +67,10 @@ public Hover handle(HoverParams params, Severity minimumSeverity) { } Model model = modelResult.getResult().get(); - Set imports = smithyFile.getImports(); - CharSequence namespace = smithyFile.getNamespace(); DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) .determineContext(position); Optional matchingShape = contextualShapes(model, context) - .filter(shape -> Prelude.isPublicPreludeShape(shape) - || shape.getId().getNamespace().contentEquals(namespace) - || imports.contains(shape.getId().toString())) - .filter(shape -> shape.getId().getName().equals(token)) + .filter(contextualMatcher(smithyFile, id)) .findFirst(); if (!matchingShape.isPresent()) { @@ -118,7 +113,7 @@ public Hover handle(HoverParams params, Severity minimumSeverity) { } String serializedShape = serialized.get(path) - .substring(15) + .substring(15) // remove '$version: "2.0"' .trim() .replaceAll(quoteReplacement("\n\n"), "\n"); int eol = serializedShape.indexOf('\n'); @@ -140,6 +135,18 @@ public Hover handle(HoverParams params, Severity minimumSeverity) { return hover; } + private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) { + String token = id.copyIdValue(); + if (id.getType() == DocumentId.Type.ABSOLUTE_ID) { + return (shape) -> shape.getId().toString().equals(token); + } else { + return (shape) -> (Prelude.isPublicPreludeShape(shape) + || shape.getId().getNamespace().contentEquals(smithyFile.getNamespace()) + || smithyFile.hasImport(shape.getId().toString())) + && shape.getId().getName().equals(token); + } + } + private Stream contextualShapes(Model model, DocumentPositionContext context) { switch (context) { case TRAIT: diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index c1a2e25c..a47904fa 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1397,6 +1397,7 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { assertThat(server.getProjects().getProject(uri).getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } + // TODO: apparently flaky @Test public void addingDetachedFileWithInvalidSyntax() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); @@ -1553,6 +1554,110 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { assertThat(server.getProjects().getProject(uri).getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } + @Test + public void completionHoverDefinitionWithAbsoluteIds() throws Exception { + String modelText1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "use com.bar#Bar\n" + + "@com.bar#baz\n" + + "structure Foo {\n" + + " bar: com.bar#Bar\n" + + "}\n"; + String modelText2 = "$version: \"2\"\n" + + "namespace com.bar\n" + + "string Bar\n" + + "string Bar2\n" + + "@trait\n" + + "structure baz {}\n"; + 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 = "$version: \"2\"\n" + + "namespace com.foo\n" ; + String modelText2 = "$version: \"2\"\n" + + "namespace com.bar\n" + + "string Bar\n"; + 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(RangeAdapter.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()); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { return initFromWorkspace(workspace, new StubClient()); } 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 9b535d27..edcc19e6 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -408,7 +408,7 @@ public void getsLineOfIndex() { } @Test - public void borrowsId() { + public void borrowsDocumentShapeId() { Document empty = Document.of(""); Document notId = Document.of("?!&"); Document onlyId = Document.of("abc"); @@ -416,37 +416,37 @@ public void borrowsId() { Document technicallyBroken = Document.of("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); Document technicallyValid = Document.of("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); - assertThat(empty.borrowId(new Position(0, 0)), nullValue()); - assertThat(notId.borrowId(new Position(0, 0)), nullValue()); - assertThat(notId.borrowId(new Position(0, 2)), nullValue()); - assertThat(onlyId.borrowId(new Position(0, 0)), string("abc")); - assertThat(onlyId.borrowId(new Position(0, 2)), string("abc")); - assertThat(onlyId.borrowId(new Position(0, 3)), nullValue()); - assertThat(split.borrowId(new Position(0, 0)), string("abc.def")); - assertThat(split.borrowId(new Position(0, 6)), string("abc.def")); - assertThat(split.borrowId(new Position(0, 7)), nullValue()); - assertThat(split.borrowId(new Position(0, 8)), string("hij")); - assertThat(technicallyBroken.borrowId(new Position(0, 0)), string("com.foo#")); - assertThat(technicallyBroken.borrowId(new Position(0, 3)), string("com.foo#")); - assertThat(technicallyBroken.borrowId(new Position(0, 7)), string("com.foo#")); - assertThat(technicallyBroken.borrowId(new Position(0, 9)), string("com.foo$")); - assertThat(technicallyBroken.borrowId(new Position(0, 16)), string("com.foo$")); - assertThat(technicallyBroken.borrowId(new Position(0, 18)), string("com.foo.")); - assertThat(technicallyBroken.borrowId(new Position(0, 25)), string("com.foo.")); - assertThat(technicallyBroken.borrowId(new Position(0, 27)), string("com$foo$bar")); - assertThat(technicallyBroken.borrowId(new Position(0, 30)), string("com$foo$bar")); - assertThat(technicallyBroken.borrowId(new Position(0, 37)), string("com$foo$bar")); - assertThat(technicallyBroken.borrowId(new Position(0, 39)), string("com...foo")); - assertThat(technicallyBroken.borrowId(new Position(0, 43)), string("com...foo")); - assertThat(technicallyBroken.borrowId(new Position(0, 49)), string("$foo")); - assertThat(technicallyBroken.borrowId(new Position(0, 54)), string(".foo")); - assertThat(technicallyBroken.borrowId(new Position(0, 59)), string("#foo")); - assertThat(technicallyValid.borrowId(new Position(0, 0)), string("com.foo#bar")); - assertThat(technicallyValid.borrowId(new Position(0, 12)), string("com.foo#bar$baz")); - assertThat(technicallyValid.borrowId(new Position(0, 28)), string("com.foo")); - assertThat(technicallyValid.borrowId(new Position(0, 36)), string("foo#bar")); - assertThat(technicallyValid.borrowId(new Position(0, 44)), string("foo#bar$baz")); - assertThat(technicallyValid.borrowId(new Position(0, 56)), string("foo$bar")); + assertThat(empty.getDocumentIdAt(new Position(0, 0)), nullValue()); + assertThat(notId.getDocumentIdAt(new Position(0, 0)), nullValue()); + assertThat(notId.getDocumentIdAt(new Position(0, 2)), nullValue()); + assertThat(onlyId.getDocumentIdAt(new Position(0, 0)), documentShapeId("abc", DocumentId.Type.ID)); + assertThat(onlyId.getDocumentIdAt(new Position(0, 2)), documentShapeId("abc", DocumentId.Type.ID)); + assertThat(onlyId.getDocumentIdAt(new Position(0, 3)), nullValue()); + assertThat(split.getDocumentIdAt(new Position(0, 0)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); + assertThat(split.getDocumentIdAt(new Position(0, 6)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); + assertThat(split.getDocumentIdAt(new Position(0, 7)), nullValue()); + assertThat(split.getDocumentIdAt(new Position(0, 8)), documentShapeId("hij", DocumentId.Type.ID)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 0)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 3)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 7)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 9)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 16)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 18)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 25)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 27)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 30)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 37)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 39)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 43)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 49)), documentShapeId("$foo", DocumentId.Type.RELATIVE_WITH_MEMBER)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 54)), documentShapeId(".foo", DocumentId.Type.NAMESPACE)); + assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 59)), documentShapeId("#foo", DocumentId.Type.ABSOLUTE_ID)); + assertThat(technicallyValid.getDocumentIdAt(new Position(0, 0)), documentShapeId("com.foo#bar", DocumentId.Type.ABSOLUTE_ID)); + assertThat(technicallyValid.getDocumentIdAt(new Position(0, 12)), documentShapeId("com.foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); + assertThat(technicallyValid.getDocumentIdAt(new Position(0, 28)), documentShapeId("com.foo", DocumentId.Type.NAMESPACE)); + assertThat(technicallyValid.getDocumentIdAt(new Position(0, 36)), documentShapeId("foo#bar", DocumentId.Type.ABSOLUTE_ID)); + assertThat(technicallyValid.getDocumentIdAt(new Position(0, 44)), documentShapeId("foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); + assertThat(technicallyValid.getDocumentIdAt(new Position(0, 56)), documentShapeId("foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); } public static Matcher string(String other) { @@ -457,4 +457,13 @@ protected boolean matchesSafely(CharSequence item) { } }; } + + public static Matcher documentShapeId(String other, DocumentId.Type type) { + return new CustomTypeSafeMatcher(other + " with type: " + type) { + @Override + protected boolean matchesSafely(DocumentId item) { + return other.equals(item.copyIdValue()) && item.getType() == type; + } + }; + } } From c9c751d6ccba9beefc5715f1523e19d1b6375974 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 13 Jun 2024 09:54:13 -0400 Subject: [PATCH 11/15] Fix Document, UriAdapter, and tests for windows Previously, Document used System.lineSeparator() for figuring out where line starts would be (index of linesep + 1). But if the file was created (and, say, packaged in a jar) on another OS, it would have different lineseps. This change makes use of a simple fact I overlooked in the initial implementation, which was that '\n' is still the last character on each line, so we don't need to break on System.lineSeparator(), just on newline (unless there's still some OS using '\r' only line breaks). UriAdapter was updated to handle windows URIs, which would be made into invalid paths with a leading '/' after removing 'file://'. A bunch of test cases were also updated, which essentially all had one or both of the above problems. --- .github/workflows/ci.yml | 1 + .../amazon/smithy/lsp/document/Document.java | 4 +- .../smithy/lsp/handler/CompletionHandler.java | 2 +- .../smithy/lsp/project/ProjectLoader.java | 1 + .../smithy/lsp/protocol/UriAdapter.java | 13 +- .../amazon/smithy/lsp/LspMatchers.java | 2 +- .../amazon/smithy/lsp/RequestBuilders.java | 3 +- .../smithy/lsp/SmithyLanguageServerTest.java | 215 +++++++++-------- .../lsp/document/DocumentParserTest.java | 102 ++++---- .../smithy/lsp/document/DocumentTest.java | 227 ++++++++++-------- .../lsp/project/ProjectConfigLoaderTest.java | 12 +- .../smithy/lsp/project/ProjectTest.java | 58 +++-- 12 files changed, 350 insertions(+), 290 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0758b7cc..1a32c55d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: runs-on: ${{ matrix.os }} name: Java ${{ matrix.java }} ${{ matrix.os }} strategy: + fail-fast: false matrix: java: [8, 11, 17] os: [ubuntu-latest, windows-latest, macos-latest] 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 4f893cbc..db3577d3 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -561,7 +561,9 @@ private static int[] computeLineIndicies(StringBuilder buffer) { // Have to box sadly, unless there's some IntArray I'm not aware of. Maybe IntBuffer List indicies = new ArrayList<>(); indicies.add(0); - while ((next = buffer.indexOf(System.lineSeparator(), off)) != -1) { + // This works with \r\n line breaks by basically forgetting about the \r, since we don't actually + // care about the content of the line + while ((next = buffer.indexOf("\n", off)) != -1) { indicies.add(next + 1); off = next + 1; ++matchCount; diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java index 2fbb6928..c0c35eab 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java @@ -184,7 +184,7 @@ private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, } private static TextEdit getImportTextEdit(SmithyFile smithyFile, String importId) { - String insertText = "\n" + "use " + 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.getDocumentImports().isPresent()) { Range importsRange = smithyFile.getDocumentImports().get().importsRange(); 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 625a3f77..d3867055 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -66,6 +66,7 @@ private ProjectLoader() { * @return The loaded project */ public static Project loadDetached(String uri, String text) { + LOGGER.info("Loading detached project at " + uri); String asPath = UriAdapter.toPath(uri); ValidatedResult modelResult = Model.assembler() .addUnparsedModel(asPath, text) diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java index bf2b53d3..2b49e821 100644 --- a/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java +++ b/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java @@ -11,6 +11,7 @@ import java.net.URL; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; import java.util.logging.Logger; /** @@ -28,7 +29,7 @@ private UriAdapter() { */ public static String toPath(String uri) { if (uri.startsWith("file://")) { - return uri.replaceFirst("file://", ""); + return Paths.get(URI.create(uri)).toString(); } else if (isSmithyJarFile(uri)) { String decoded = decode(uri); return fixJarScheme(decoded); @@ -39,15 +40,15 @@ public static String toPath(String uri) { /** * @param path Path to convert to LSP URI * @return A URI representation of the given {@code path}, modified to have the - * correct scheme for jars + * correct scheme for our jars */ public static String toUri(String path) { - if (path.startsWith("/")) { - return "file://" + path; - } else if (path.startsWith("jar:file")) { + if (path.startsWith("jar:file")) { return path.replaceFirst("jar:file", "smithyjar"); - } else { + } else if (path.startsWith("smithyjar:")) { return path; + } else { + return Paths.get(path).toUri().toString(); } } diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index c315c9f8..7c508e1a 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -33,7 +33,7 @@ public void describeMismatchSafely(CompletionItem item, Description description) } public static Matcher makesEditedDocument(Document document, String expected) { - return new CustomTypeSafeMatcher("the right edit") { + return new CustomTypeSafeMatcher("makes an edited document " + expected) { @Override protected boolean matchesSafely(TextEdit item) { Document copy = document.copy(); diff --git a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java index 9c4c057c..2a033e5f 100644 --- a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp; import java.net.URI; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -176,7 +177,7 @@ public DidOpen text(String text) { public DidOpenTextDocumentParams build() { if (text == null) { - text = IoUtils.readUtf8File(URI.create(uri).getPath()); + text = IoUtils.readUtf8File(Paths.get(URI.create(uri))); } return new DidOpenTextDocumentParams(new TextDocumentItem(uri, languageId, version, text)); } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index a47904fa..681fa758 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -24,16 +24,18 @@ 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 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; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; 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; @@ -72,10 +74,10 @@ public class SmithyLanguageServerTest { @Test public void runsSelector() throws Exception { - String model = "$version: \"2\"\n" + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" - + "string Foo\n"; + + "string Foo\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -87,7 +89,7 @@ public void runsSelector() throws Exception { @Test public void completion() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + @@ -95,7 +97,7 @@ public void completion() throws Exception { "}\n" + "\n" + "@default(0)\n" + - "integer Bar\n"; + "integer Bar\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -130,15 +132,15 @@ public void completion() throws Exception { @Test public void completionImports() throws Exception { - String model1 = "$version: \"2\"\n" + + String model1 = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + - "}\n"; - String model2 = "$version: \"2\"\n" + + "}\n"); + String model2 = safeString("$version: \"2\"\n" + "namespace com.bar\n" + "\n" + - "string Bar\n"; + "string Bar\n"); TestWorkspace workspace = TestWorkspace.multipleModels(model1, model2); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -159,7 +161,7 @@ public void completionImports() throws Exception { .endLine(3) .endCharacter(15) .build()) - .text("\n bar: Ba") + .text(safeString("\n bar: Ba")) .build(); server.didChange(changeParams); @@ -175,18 +177,18 @@ public void completionImports() throws Exception { Document document = server.getProject().getDocument(uri); // TODO: The server puts the 'use' on the wrong line - assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, "$version: \"2\"\n" + + assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + "namespace com.foo\n" + "use com.bar#Bar\n" + "\n" + "structure Foo {\n" + " bar: Ba\n" + - "}\n"))); + "}\n")))); } @Test public void definition() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@trait\n" + @@ -197,7 +199,7 @@ public void definition() throws Exception { "}\n" + "\n" + "@myTrait(\"\")\n" + - "string Baz\n"; + "string Baz\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -245,7 +247,7 @@ public void definition() throws Exception { @Test public void hover() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@trait\n" + @@ -258,7 +260,7 @@ public void hover() throws Exception { "@myTrait(\"\")\n" + "structure Bar {\n" + " baz: String\n" + - "}\n"; + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); @@ -292,13 +294,13 @@ public void hover() throws Exception { @Test public void hoverWithBrokenModel() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + " bar: Bar\n" + " baz: String\n" + - "}\n"; + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -317,7 +319,7 @@ public void hoverWithBrokenModel() throws Exception { @Test public void documentSymbol() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@trait\n" + @@ -334,7 +336,7 @@ public void documentSymbol() throws Exception { "}\n" + "\n" + "@myTrait(\"abc\")\n" + - "integer Baz\n"; + "integer Baz\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -355,7 +357,7 @@ public void documentSymbol() throws Exception { @Test public void formatting() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo{\n" + @@ -364,7 +366,7 @@ public void formatting() throws Exception { "@tags(\n" + "[\"a\",\n" + " \"b\"])\n" + - "string Baz\n"; + "string Baz\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -375,7 +377,7 @@ public void formatting() throws Exception { List edits = server.formatting(params).get(); Document document = server.getProject().getDocument(uri); - assertThat(edits, (Matcher) containsInAnyOrder(makesEditedDocument(document, "$version: \"2\"\n" + + assertThat(edits, (Matcher) containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" + @@ -384,12 +386,12 @@ public void formatting() throws Exception { "}\n" + "\n" + "@tags([\"a\", \"b\"])\n" + - "string Baz\n"))); + "string Baz\n")))); } @Test public void didChange() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" + @@ -397,7 +399,7 @@ public void didChange() throws Exception { "}\n" + "\n" + "operation GetFoo {\n" + - "}\n"; + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -417,7 +419,7 @@ public void didChange() throws Exception { RequestBuilders.DidChange changeBuilder = new RequestBuilders.DidChange().uri(uri); // Add new line and leading spaces - server.didChange(changeBuilder.range(rangeAdapter.build()).text("\n ").build()); + server.didChange(changeBuilder.range(rangeAdapter.build()).text(safeString("\n ")).build()); // add 'input: G' server.didChange(changeBuilder.range(rangeAdapter.shiftNewLine().shiftRight(4).build()).text("i").build()); server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("n").build()); @@ -431,7 +433,7 @@ public void didChange() throws Exception { server.getLifecycleManager().getTask(uri).get(); // mostly so you can see what it looks like - assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" + @@ -440,7 +442,7 @@ public void didChange() throws Exception { "\n" + "operation GetFoo {\n" + " input: G\n" + - "}\n")); + "}\n"))); // input: G CompletionParams completionParams = new RequestBuilders.PositionRequest() @@ -455,10 +457,10 @@ public void didChange() throws Exception { @Test public void didChangeReloadsModel() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + - "operation Foo {}\n"; + "operation Foo {}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -492,14 +494,14 @@ public void didChangeReloadsModel() throws Exception { @Test public void didChangeThenDefinition() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + " bar: Bar\n" + "}\n" + "\n" + - "string Bar\n"; + "string Bar\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); @@ -523,7 +525,7 @@ public void didChangeThenDefinition() throws Exception { .endLine(5) .endCharacter(1); RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text("\n\n").build()); + 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()); @@ -537,7 +539,7 @@ public void didChangeThenDefinition() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + @@ -546,7 +548,7 @@ public void didChangeThenDefinition() throws Exception { "\n" + "string Baz\n" + "\n" + - "string Bar\n")); + "string Bar\n"))); Location afterChanges = server.definition(definitionParams).get().getLeft().get(0); assertThat(afterChanges.getUri(), equalTo(uri)); @@ -555,7 +557,7 @@ public void didChangeThenDefinition() throws Exception { @Test public void definitionWithApply() throws Exception { - Path root = Paths.get(getClass().getResource("project/apply").getPath()); + 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(); @@ -602,12 +604,12 @@ public void definitionWithApply() throws Exception { @Test public void newShapeMixinCompletion() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@mixin\n" + "structure Foo {}\n" + - "\n"; + "\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); @@ -647,13 +649,13 @@ public void newShapeMixinCompletion() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@mixin\n" + "structure Foo {}\n" + "\n" + - "structure Bar with [F]")); + "structure Bar with [F]"))); Position currentPosition = range.build().getStart(); CompletionParams completionParams = new RequestBuilders.PositionRequest() @@ -670,13 +672,13 @@ public void newShapeMixinCompletion() throws Exception { @Test public void existingShapeMixinCompletion() throws Exception { - String model = "$version: \"2\"\n" + + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@mixin\n" + "structure Foo {}\n" + "\n" + - "structure Bar {}\n"; + "structure Bar {}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); @@ -703,13 +705,13 @@ public void existingShapeMixinCompletion() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getDocument(uri).copyText(), equalTo("$version: \"2\"\n" + + assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "@mixin\n" + "structure Foo {}\n" + "\n" + - "structure Bar with [F] {}\n")); + "structure Bar with [F] {}\n"))); Position currentPosition = range.build().getStart(); CompletionParams completionParams = new RequestBuilders.PositionRequest() @@ -726,12 +728,12 @@ public void existingShapeMixinCompletion() throws Exception { @Test public void diagnosticsOnMemberTarget() { - String model = "$version: \"2\"\n" + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + " bar: Bar\n" - + "}\n"; + + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); @@ -748,13 +750,13 @@ public void diagnosticsOnMemberTarget() { @Test public void diagnosticOnTrait() { - String model = "$version: \"2\"\n" + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" + " @bar\n" + " bar: String\n" - + "}\n"; + + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); @@ -776,12 +778,12 @@ public void diagnosticOnTrait() { @Test public void diagnosticsOnShape() throws Exception { - String model = "$version: \"2\"\n" + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "list Foo {\n" + " \n" - + "}\n"; + + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); StubClient client = new StubClient(); SmithyLanguageServer server = new SmithyLanguageServer(); @@ -820,12 +822,12 @@ public void diagnosticsOnShape() throws Exception { @Test public void insideJar() throws Exception { - String model = "$version: \"2\"\n" + String model = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" + "structure Foo {\n" - + " bar: String\n" - + "}\n"; + + " bar: PrimitiveInteger\n" + + "}\n"); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -847,10 +849,11 @@ public void insideJar() throws Exception { String preludeUri = preludeLocation.getUri(); assertThat(preludeUri, startsWith("smithyjar")); + Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getProject().getDocument(preludeUri).getFullRange()); Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() .uri(preludeUri) - .line(36) + .line(preludeLocation.getRange().getStart().getLine() - 1) // trait applied above 'PrimitiveInteger' .character(1) .buildHover()) .get(); @@ -900,9 +903,9 @@ public void addingWatchedFile() throws Exception { public void removingWatchedFile() { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); String filename = "model/main.smithy"; - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "namespace com.foo\n" - + "string Foo\n"; + + "string Foo\n"); workspace.addModel(filename, modelText); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -928,9 +931,9 @@ public void removingWatchedFile() { public void addingDetachedFile() { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); String filename = "main.smithy"; - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "namespace com.foo\n" - + "string Foo\n"; + + "string Foo\n"); workspace.addModel(filename, modelText); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -971,9 +974,9 @@ public void addingDetachedFile() { public void removingAttachedFile() { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); String filename = "model/main.smithy"; - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "namespace com.foo\n" - + "string Foo\n"; + + "string Foo\n"); workspace.addModel(filename, modelText); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1018,10 +1021,10 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { .sources(Collections.singletonList("./././smithy")) .build(); String filename = "smithy/main.smithy"; - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "\n" - + "string Foo\n"; + + "string Foo\n"); TestWorkspace workspace = TestWorkspace.builder() .withSourceDir(TestWorkspace.dir() .path("./smithy") @@ -1044,7 +1047,7 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { @Test public void reloadingProjectWithArrayMetadataValues() throws Exception { - String modelText1 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + "\n" + "metadata foo = [1]\n" + "metadata foo = [2]\n" @@ -1052,14 +1055,14 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { + "\n" + "namespace com.foo\n" + "\n" - + "string Foo\n"; - String modelText2 = "$version: \"2\"\n" + + "string Foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + "\n" + "metadata foo = [3]\n" + "\n" + "namespace com.foo\n" + "\n" - + "string Bar\n"; + + "string Bar\n"); TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1077,7 +1080,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) .range(RangeAdapter.lineSpan(8, 0, 0)) - .text("\nstring Baz\n") + .text(safeString("\nstring Baz\n")) .build()); server.didSave(RequestBuilders.didSave() .uri(uri) @@ -1108,7 +1111,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { @Test public void changingWatchedFilesWithMetadata() throws Exception { - String modelText1 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + "\n" + "metadata foo = [1]\n" + "metadata foo = [2]\n" @@ -1116,14 +1119,14 @@ public void changingWatchedFilesWithMetadata() throws Exception { + "\n" + "namespace com.foo\n" + "\n" - + "string Foo\n"; - String modelText2 = "$version: \"2\"\n" + + "string Foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + "\n" + "metadata foo = [3]\n" + "\n" + "namespace com.foo\n" + "\n" - + "string Bar\n"; + + "string Bar\n"); TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1154,11 +1157,11 @@ public void changingWatchedFilesWithMetadata() throws Exception { public void addingOpenedDetachedFile() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); String filename = "main.smithy"; - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" - + "string Foo\n"; + + "string Foo\n"); workspace.addModel(filename, modelText); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1180,7 +1183,7 @@ public void addingOpenedDetachedFile() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) .range(RangeAdapter.point(3, 0)) - .text("string Bar\n") + .text(safeString("string Bar\n")) .build()); // Add the already-opened file to the project @@ -1206,9 +1209,9 @@ public void addingOpenedDetachedFile() throws Exception { @Test public void detachingOpenedFile() throws Exception { - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "namespace com.foo\n" - + "string Foo\n"; + + "string Foo\n"); TestWorkspace workspace = TestWorkspace.singleModel(modelText); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1221,7 +1224,7 @@ public void detachingOpenedFile() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) .range(RangeAdapter.point(3, 0)) - .text("string Bar\n") + .text(safeString("string Bar\n")) .build()); workspace.updateConfig(workspace.getConfig() @@ -1247,11 +1250,11 @@ public void detachingOpenedFile() throws Exception { public void movingDetachedFile() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); String filename = "main.smithy"; - String modelText = "$version: \"2\"\n" + String modelText = safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" - + "string Foo\n"; + + "string Foo\n"); workspace.addModel(filename, modelText); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1290,13 +1293,13 @@ public void updatesDiagnosticsAfterReload() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); String filename1 = "model/main.smithy"; - String modelText1 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" + "// using an unknown trait\n" + "@foo\n" - + "string Bar\n"; + + "string Bar\n"); workspace.addModel(filename1, modelText1); StubClient client = new StubClient(); @@ -1318,13 +1321,13 @@ public void updatesDiagnosticsAfterReload() throws Exception { diagnosticWithMessage(containsString("Model.UnresolvedTrait")))); String filename2 = "model/trait.smithy"; - String modelText2 = "$version: \"2\"\n" + String modelText2 = safeString("$version: \"2\"\n" + "\n" + "namespace com.foo\n" + "\n" + "// adding the missing trait\n" + "@trait\n" - + "structure foo {}\n"; + + "structure foo {}\n"); workspace.addModel(filename2, modelText2); String uri2 = workspace.getUri(filename2); @@ -1343,10 +1346,10 @@ public void updatesDiagnosticsAfterReload() throws Exception { @Test public void invalidSyntaxModelPartiallyLoads() { - String modelText1 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + "namespace com.foo\n" - + "string Foo\n"; - String modelText2 = "string Bar\n"; + + "string Foo\n"); + String modelText2 = safeString("string Bar\n"); TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1364,7 +1367,7 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { SmithyLanguageServer server = initFromWorkspace(workspace); String filename = "main.smithy"; - String modelText = "string Foo\n"; + String modelText = safeString("string Foo\n"); workspace.addModel(filename, modelText); String uri = workspace.getUri(filename); @@ -1384,7 +1387,7 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) .range(RangeAdapter.origin()) - .text("$version: \"2\"\nnamespace com.foo\n") + .text(safeString("$version: \"2\"\nnamespace com.foo\n")) .build()); server.getLifecycleManager().waitForAllTasks(); @@ -1432,17 +1435,17 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) - .text("$version: \"2\"\n") + .text(safeString("$version: \"2\"\n")) .range(RangeAdapter.origin()) .build()); server.didChange(RequestBuilders.didChange() .uri(uri) - .text("namespace com.foo\n") + .text(safeString("namespace com.foo\n")) .range(RangeAdapter.point(1, 0)) .build()); server.didChange(RequestBuilders.didChange() .uri(uri) - .text("string Foo\n") + .text(safeString("string Foo\n")) .range(RangeAdapter.point(2, 0)) .build()); @@ -1456,13 +1459,13 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { @Test public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { - String modelText1 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + "namespace com.foo\n" - + "string Foo\n"; - String modelText2 = "$version: \"2\"\n" + + "string Foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "string Bar\n" - + "apply Foo @length(min: 1)\n"; + + "apply Foo @length(min: 1)\n"); TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1494,7 +1497,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri1) .range(RangeAdapter.point(3, 0)) - .text("string Another\n") + .text(safeString("string Another\n")) .build()); server.getLifecycleManager().waitForAllTasks(); @@ -1543,7 +1546,7 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) - .text("$version: \"2\"\nnamespace com.foo\nstring Foo\n") + .text(safeString("$version: \"2\"\nnamespace com.foo\nstring Foo\n")) .range(RangeAdapter.origin()) .build()); server.getLifecycleManager().waitForAllTasks(); @@ -1556,19 +1559,19 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { @Test public void completionHoverDefinitionWithAbsoluteIds() throws Exception { - String modelText1 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + "namespace com.foo\n" + "use com.bar#Bar\n" + "@com.bar#baz\n" + "structure Foo {\n" + " bar: com.bar#Bar\n" - + "}\n"; - String modelText2 = "$version: \"2\"\n" + + "}\n"); + String modelText2 = safeString("$version: \"2\"\n" + "namespace com.bar\n" + "string Bar\n" + "string Bar2\n" + "@trait\n" - + "structure baz {}\n"; + + "structure baz {}\n"); TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); @@ -1627,11 +1630,11 @@ public void completionHoverDefinitionWithAbsoluteIds() throws Exception { @Test public void useCompletionDoesntAutoImport() throws Exception { - String modelText1 = "$version: \"2\"\n" - + "namespace com.foo\n" ; - String modelText2 = "$version: \"2\"\n" + String modelText1 = safeString("$version: \"2\"\n" + + "namespace com.foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + "namespace com.bar\n" - + "string Bar\n"; + + "string Bar\n"); TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); 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 5fccfca3..2bda15b2 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -6,11 +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.CoreMatchers.is; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.junit.jupiter.api.Assertions.assertEquals; +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; @@ -32,7 +34,7 @@ public void jumpsToLines() { "ghi\n" + "\n" + "\n"; - DocumentParser parser = DocumentParser.forDocument(Document.of(text)); + DocumentParser parser = DocumentParser.of(safeString(text)); assertEquals(0, parser.position()); assertEquals(1, parser.line()); assertEquals(1, parser.column()); @@ -43,22 +45,22 @@ public void jumpsToLines() { assertEquals(1, parser.column()); parser.jumpToLine(1); - assertEquals(4, parser.position()); + assertEquals(safeIndex(4, 1), parser.position()); assertEquals(2, parser.line()); assertEquals(1, parser.column()); parser.jumpToLine(2); - assertEquals(8, parser.position()); + assertEquals(safeIndex(8, 2), parser.position()); assertEquals(3, parser.line()); assertEquals(1, parser.column()); parser.jumpToLine(3); - assertEquals(12, parser.position()); + assertEquals(safeIndex(12, 3), parser.position()); assertEquals(4, parser.line()); assertEquals(1, parser.column()); parser.jumpToLine(4); - assertEquals(13, parser.position()); + assertEquals(safeIndex(13, 4), parser.position()); assertEquals(5, parser.line()); assertEquals(1, parser.column()); } @@ -66,7 +68,7 @@ public void jumpsToLines() { @Test public void jumpsToSource() { String text = "abc\ndef\nghi\n"; - DocumentParser parser = DocumentParser.of(text); + DocumentParser parser = DocumentParser.of(safeString(text)); assertThat(parser.position(), is(0)); assertThat(parser.line(), is(1)); assertThat(parser.column(), is(1)); @@ -86,7 +88,7 @@ public void jumpsToSource() { assertThat(parser.column(), is(4)); assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - ok = parser.jumpToSource(new SourceLocation("", 1, 5)); + ok = parser.jumpToSource(new SourceLocation("", 1, 6)); assertThat(ok, is(false)); assertThat(parser.position(), is(3)); assertThat(parser.line(), is(1)); @@ -95,7 +97,7 @@ public void jumpsToSource() { ok = parser.jumpToSource(new SourceLocation("", 2, 1)); assertThat(ok, is(true)); - assertThat(parser.position(), is(4)); + 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))); @@ -105,7 +107,7 @@ public void jumpsToSource() { ok = parser.jumpToSource(new SourceLocation("", 3, 4)); assertThat(ok, is(true)); - assertThat(parser.position(), is(11)); + 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))); @@ -113,17 +115,17 @@ public void jumpsToSource() { @Test public void getsDocumentNamespace() { - DocumentParser noNamespace = DocumentParser.of("abc\ndef\n"); - DocumentParser incompleteNamespace = DocumentParser.of("abc\nnamespac"); - DocumentParser incompleteNamespaceValue = DocumentParser.of("namespace "); - DocumentParser likeNamespace = DocumentParser.of("anamespace com.foo\n"); - DocumentParser otherLikeNamespace = DocumentParser.of("namespacea com.foo"); - DocumentParser namespaceAtEnd = DocumentParser.of("\n\nnamespace com.foo"); - DocumentParser brokenNamespace = DocumentParser.of("\nname space com.foo\n"); - DocumentParser commentedNamespace = DocumentParser.of("abc\n//namespace com.foo\n"); - DocumentParser wsPrefixedNamespace = DocumentParser.of("abc\n namespace com.foo\n"); - DocumentParser notNamespace = DocumentParser.of("namespace !foo"); - DocumentParser trailingComment = DocumentParser.of("namespace com.foo//foo\n"); + DocumentParser noNamespace = DocumentParser.of(safeString("abc\ndef\n")); + DocumentParser incompleteNamespace = DocumentParser.of(safeString("abc\nnamespac")); + DocumentParser incompleteNamespaceValue = DocumentParser.of(safeString("namespace ")); + DocumentParser likeNamespace = DocumentParser.of(safeString("anamespace com.foo\n")); + DocumentParser otherLikeNamespace = DocumentParser.of(safeString("namespacea com.foo")); + DocumentParser namespaceAtEnd = DocumentParser.of(safeString("\n\nnamespace com.foo")); + DocumentParser brokenNamespace = DocumentParser.of(safeString("\nname space com.foo\n")); + DocumentParser commentedNamespace = DocumentParser.of(safeString("abc\n//namespace com.foo\n")); + DocumentParser wsPrefixedNamespace = DocumentParser.of(safeString("abc\n namespace com.foo\n")); + 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()); @@ -143,15 +145,15 @@ public void getsDocumentNamespace() { @Test public void getsDocumentImports() { - DocumentParser noImports = DocumentParser.of("abc\ndef\n"); - DocumentParser incompleteImport = DocumentParser.of("abc\nus"); - DocumentParser incompleteImportValue = DocumentParser.of("use "); - DocumentParser oneImport = DocumentParser.of("use com.foo#bar"); - DocumentParser leadingWsImport = DocumentParser.of(" use com.foo#bar"); - DocumentParser trailingCommentImport = DocumentParser.of("use com.foo#bar//foo"); - DocumentParser commentedImport = DocumentParser.of("//use com.foo#bar"); - DocumentParser multiImports = DocumentParser.of("use com.foo#bar\nuse com.foo#baz"); - DocumentParser notImport = DocumentParser.of("usea com.foo#bar"); + DocumentParser noImports = DocumentParser.of(safeString("abc\ndef\n")); + DocumentParser incompleteImport = DocumentParser.of(safeString("abc\nus")); + DocumentParser incompleteImportValue = DocumentParser.of(safeString("use ")); + DocumentParser oneImport = DocumentParser.of(safeString("use com.foo#bar")); + DocumentParser leadingWsImport = DocumentParser.of(safeString(" use com.foo#bar")); + DocumentParser trailingCommentImport = DocumentParser.of(safeString("use com.foo#bar//foo")); + DocumentParser commentedImport = DocumentParser.of(safeString("//use com.foo#bar")); + 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()); @@ -164,11 +166,11 @@ public void getsDocumentImports() { assertThat(notImport.documentImports(), nullValue()); // Some of these aren't shape ids, but its ok - DocumentParser brokenImport = DocumentParser.of("use com.foo"); - DocumentParser commentSeparatedImports = DocumentParser.of("use com.foo#bar //foo\nuse com.foo#baz\n//abc\nuse com.foo#foo"); - DocumentParser oneBrokenImport = DocumentParser.of("use com.foo\nuse com.foo#bar"); - DocumentParser innerBrokenImport = DocumentParser.of("use com.foo#bar\nuse com.foo\nuse com.foo#baz"); - DocumentParser innerNotImport = DocumentParser.of("use com.foo#bar\nstring Foo\nuse com.foo#baz"); + DocumentParser brokenImport = DocumentParser.of(safeString("use com.foo")); + DocumentParser commentSeparatedImports = DocumentParser.of(safeString("use com.foo#bar //foo\nuse com.foo#baz\n//abc\nuse com.foo#foo")); + DocumentParser oneBrokenImport = DocumentParser.of(safeString("use com.foo\nuse com.foo#bar")); + DocumentParser innerBrokenImport = DocumentParser.of(safeString("use com.foo#bar\nuse com.foo\nuse com.foo#baz")); + DocumentParser innerNotImport = DocumentParser.of(safeString("use com.foo#bar\nstring Foo\nuse com.foo#baz")); assertThat(brokenImport.documentImports().imports(), containsInAnyOrder("com.foo")); assertThat(commentSeparatedImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz", "com.foo#foo")); assertThat(oneBrokenImport.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo")); @@ -178,20 +180,20 @@ public void getsDocumentImports() { @Test public void getsDocumentVersion() { - DocumentParser noVersion = DocumentParser.of("abc\ndef"); - DocumentParser notVersion = DocumentParser.of("$versionNot: \"2\""); - DocumentParser noDollar = DocumentParser.of("version: \"2\""); - DocumentParser noColon = DocumentParser.of("$version \"2\""); - DocumentParser commented = DocumentParser.of("//$version: \"2\""); - DocumentParser leadingWs = DocumentParser.of(" $version: \"2\""); - DocumentParser leadingLines = DocumentParser.of("\n\n//abc\n$version: \"2\""); - DocumentParser notStringNode = DocumentParser.of("$version: 2"); - DocumentParser trailingComment = DocumentParser.of("$version: \"2\"//abc"); - DocumentParser trailingLine = DocumentParser.of("$version: \"2\"\n"); - DocumentParser invalidNode = DocumentParser.of("$version: \"2"); - DocumentParser notFirst = DocumentParser.of("$foo: \"bar\"\n// abc\n$version: \"2\""); - DocumentParser notSecond = DocumentParser.of("$foo: \"bar\"\n$bar: 1\n// abc\n$baz: 2\n $version: \"2\""); - DocumentParser notFirstNoVersion = DocumentParser.of("$foo: \"bar\"\nfoo\n"); + DocumentParser noVersion = DocumentParser.of(safeString("abc\ndef")); + DocumentParser notVersion = DocumentParser.of(safeString("$versionNot: \"2\"")); + DocumentParser noDollar = DocumentParser.of(safeString("version: \"2\"")); + DocumentParser noColon = DocumentParser.of(safeString("$version \"2\"")); + DocumentParser commented = DocumentParser.of(safeString("//$version: \"2\"")); + DocumentParser leadingWs = DocumentParser.of(safeString(" $version: \"2\"")); + DocumentParser leadingLines = DocumentParser.of(safeString("\n\n//abc\n$version: \"2\"")); + DocumentParser notStringNode = DocumentParser.of(safeString("$version: 2")); + DocumentParser trailingComment = DocumentParser.of(safeString("$version: \"2\"//abc")); + DocumentParser trailingLine = DocumentParser.of(safeString("$version: \"2\"\n")); + DocumentParser invalidNode = DocumentParser.of(safeString("$version: \"2")); + DocumentParser notFirst = DocumentParser.of(safeString("$foo: \"bar\"\n// abc\n$version: \"2\"")); + 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()); @@ -255,7 +257,7 @@ public void getsDocumentShapes() { .filter(shape -> shape.getId().getNamespace().equals("com.foo")) .collect(Collectors.toSet()); - DocumentParser parser = DocumentParser.of(text); + DocumentParser parser = DocumentParser.of(safeString(text)); Map documentShapes = parser.documentShapes(shapes); DocumentShape fooDef = documentShapes.get(new Position(2, 7)); 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 edcc19e6..beae1565 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -13,6 +13,7 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.protocol.RangeAdapter; @@ -22,7 +23,7 @@ public class DocumentTest { public void appliesTrailingReplacementEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(1) @@ -34,17 +35,17 @@ public void appliesTrailingReplacementEdit() { document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("abc\n" + - "deg")); + assertThat(document.copyText(), equalTo(safeString("abc\n" + + "deg"))); assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(4)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); } @Test public void appliesAppendingEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(1) @@ -56,17 +57,17 @@ public void appliesAppendingEdit() { document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("abc\n" + - "defg")); - assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(4)); + assertThat(document.copyText(), equalTo(safeString("abc\n" + + "defg"))); + assertThat(document.indexOfLine(0), equalTo(safeIndex(0, 0))); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); } @Test public void appliesLeadingReplacementEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(0) @@ -78,17 +79,17 @@ public void appliesLeadingReplacementEdit() { document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("zbc\n" + - "def")); + assertThat(document.copyText(), equalTo(safeString("zbc\n" + + "def"))); assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(4)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); } @Test public void appliesPrependingEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(0) @@ -100,17 +101,17 @@ public void appliesPrependingEdit() { document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("zabc\n" + - "def")); + assertThat(document.copyText(), equalTo(safeString("zabc\n" + + "def"))); assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(5)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(5, 1))); } @Test public void appliesInnerReplacementEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(0) @@ -118,21 +119,21 @@ public void appliesInnerReplacementEdit() { .endLine(1) .endCharacter(1) .build(); - String editText = "zy\n" + - "x"; + String editText = safeString("zy\n" + + "x"); document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("azy\n" + - "xef")); + assertThat(document.copyText(), equalTo(safeString("azy\n" + + "xef"))); assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(4)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); } @Test public void appliesPrependingAndReplacingEdit() { String s = "abc"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(0) @@ -152,7 +153,7 @@ public void appliesPrependingAndReplacingEdit() { public void appliesInsertionEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(0) @@ -160,24 +161,24 @@ public void appliesInsertionEdit() { .endLine(0) .endCharacter(2) .build(); - String editText = "zx\n" + - "y"; + String editText = safeString("zx\n" + + "y"); document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("abzx\n" + + assertThat(document.copyText(), equalTo(safeString("abzx\n" + "yc\n" + - "def")); + "def"))); assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(5)); - assertThat(document.indexOfLine(2), equalTo(8)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(5, 1))); + assertThat(document.indexOfLine(2), equalTo(safeIndex(8, 2))); } @Test public void appliesDeletionEdit() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Range editRange = new RangeAdapter() .startLine(0) @@ -189,10 +190,10 @@ public void appliesDeletionEdit() { document.applyEdit(editRange, editText); - assertThat(document.copyText(), equalTo("bc\n" + - "def")); + assertThat(document.copyText(), equalTo(safeString("bc\n" + + "def"))); assertThat(document.indexOfLine(0), equalTo(0)); - assertThat(document.indexOfLine(1), equalTo(3)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(3, 1))); } @Test @@ -200,49 +201,49 @@ public void getsIndexOfLine() { String s = "abc\n" + "def\n" + "hij\n"; - Document document = Document.of(s); + Document document = makeDocument(s); assertThat(document.indexOfLine(0), equalTo(0)); assertThat(document.indexOfLine(-1), equalTo(-1)); - assertThat(document.indexOfLine(1), equalTo(4)); - assertThat(document.indexOfLine(2), equalTo(8)); - assertThat(document.indexOfLine(3), equalTo(12)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); + assertThat(document.indexOfLine(2), equalTo(safeIndex(8, 2))); + assertThat(document.indexOfLine(3), equalTo(safeIndex(12, 3))); assertThat(document.indexOfLine(4), equalTo(-1)); } @Test public void getsIndexOfPosition() { - Document document = Document.of("abc\ndef"); + Document document = makeDocument("abc\ndef"); - assertThat(Document.of("").indexOfPosition(new Position(0, 0)), is(-1)); - assertThat(Document.of("").indexOfPosition(new Position(-1, 0)), is(-1)); + assertThat(makeDocument("").indexOfPosition(new Position(0, 0)), is(-1)); + assertThat(makeDocument("").indexOfPosition(new Position(-1, 0)), is(-1)); assertThat(document.indexOfPosition(new Position(0, 0)), is(0)); assertThat(document.indexOfPosition(new Position(0, 3)), is(3)); - assertThat(document.indexOfPosition(new Position(1, 0)), is(4)); - assertThat(document.indexOfPosition(new Position(1, 2)), is(6)); + assertThat(document.indexOfPosition(new Position(1, 0)), is(safeIndex(4, 1))); + assertThat(document.indexOfPosition(new Position(1, 2)), is(safeIndex(6, 1))); assertThat(document.indexOfPosition(new Position(1, 3)), is(-1)); - assertThat(document.indexOfPosition(new Position(0, 4)), is(-1)); + assertThat(document.indexOfPosition(new Position(0, 6)), is(-1)); assertThat(document.indexOfPosition(new Position(2, 0)), is(-1)); } @Test public void getsPositionAtIndex() { - Document document = Document.of("abc\ndef\nhij\n"); + Document document = makeDocument("abc\ndef\nhij\n"); - assertThat(Document.of("").positionAtIndex(0), nullValue()); - assertThat(Document.of("").positionAtIndex(-1), nullValue()); + assertThat(makeDocument("").positionAtIndex(0), nullValue()); + assertThat(makeDocument("").positionAtIndex(-1), nullValue()); assertThat(document.positionAtIndex(0), equalTo(new Position(0, 0))); assertThat(document.positionAtIndex(3), equalTo(new Position(0, 3))); - assertThat(document.positionAtIndex(4), equalTo(new Position(1, 0))); - assertThat(document.positionAtIndex(11), equalTo(new Position(2, 3))); - assertThat(document.positionAtIndex(12), nullValue()); + assertThat(document.positionAtIndex(safeIndex(4, 1)), equalTo(new Position(1, 0))); + assertThat(document.positionAtIndex(safeIndex(11, 2)), equalTo(new Position(2, 3))); + assertThat(document.positionAtIndex(safeIndex(12, 3)), nullValue()); } @Test public void getsEnd() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); Position end = document.end(); @@ -254,7 +255,7 @@ public void getsEnd() { public void borrowsToken() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(0, 2)); @@ -264,7 +265,7 @@ public void borrowsToken() { @Test public void borrowsTokenWithNoWs() { String s = "abc"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(0, 1)); @@ -275,7 +276,7 @@ public void borrowsTokenWithNoWs() { public void borrowsTokenAtStart() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(0, 0)); @@ -286,7 +287,7 @@ public void borrowsTokenAtStart() { public void borrowsTokenAtEnd() { String s = "abc\n" + "def"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(1, 2)); @@ -296,7 +297,7 @@ public void borrowsTokenAtEnd() { @Test public void borrowsTokenAtBoundaryStart() { String s = "a bc d"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(0, 2)); @@ -306,7 +307,7 @@ public void borrowsTokenAtBoundaryStart() { @Test public void borrowsTokenAtBoundaryEnd() { String s = "a bc d"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(0, 3)); @@ -316,7 +317,7 @@ public void borrowsTokenAtBoundaryEnd() { @Test public void doesntBorrowNonToken() { String s = "abc def"; - Document document = Document.of(s); + Document document = makeDocument(s); CharSequence token = document.borrowToken(new Position(0, 3)); @@ -325,11 +326,11 @@ public void doesntBorrowNonToken() { @Test public void borrowsLine() { - Document document = Document.of("abc\n\ndef"); + Document document = makeDocument("abc\n\ndef"); - assertThat(Document.of("").borrowLine(0), string("")); - assertThat(document.borrowLine(0), string("abc\n")); - assertThat(document.borrowLine(1), string("\n")); + 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()); @@ -337,40 +338,40 @@ public void borrowsLine() { @Test public void getsNextIndexOf() { - Document document = Document.of("abc\ndef"); + Document document = makeDocument("abc\ndef"); - assertThat(Document.of("").nextIndexOf("a", 0), is(-1)); + assertThat(makeDocument("").nextIndexOf("a", 0), is(-1)); assertThat(document.nextIndexOf("a", 0), is(0)); assertThat(document.nextIndexOf("a", 1), is(-1)); assertThat(document.nextIndexOf("abc", 0), is(0)); assertThat(document.nextIndexOf("abc", 1), is(-1)); // doesn't match if match goes out of boundary - assertThat(document.nextIndexOf("\n", 3), is(3)); - assertThat(document.nextIndexOf("f", 6), is(6)); - assertThat(document.nextIndexOf("f", 7), is(-1)); // oob + assertThat(document.nextIndexOf(System.lineSeparator(), 3), is(3)); + assertThat(document.nextIndexOf("f", safeIndex(6, 1)), is(safeIndex(6, 1))); + assertThat(document.nextIndexOf("f", safeIndex(7, 1)), is(-1)); // oob } @Test public void getsLastIndexOf() { - Document document = Document.of("abc\ndef"); + Document document = makeDocument("abc\ndef"); - assertThat(Document.of("").lastIndexOf("a", 1), is(-1)); + assertThat(makeDocument("").lastIndexOf("a", 1), is(-1)); assertThat(document.lastIndexOf("a", 0), is(0)); // start assertThat(document.lastIndexOf("a", 1), is(0)); - assertThat(document.lastIndexOf("a", 6), is(0)); - assertThat(document.lastIndexOf("f", 6), is(6)); - assertThat(document.lastIndexOf("f", 7), is(6)); // oob - assertThat(document.lastIndexOf("\n", 3), is(3)); + assertThat(document.lastIndexOf("a", safeIndex(6, 1)), is(0)); + assertThat(document.lastIndexOf("f", safeIndex(6, 1)), is(safeIndex(6, 1))); + assertThat(document.lastIndexOf("f", safeIndex(7, 1)), is(safeIndex(6, 1))); // oob + assertThat(document.lastIndexOf(System.lineSeparator(), 3), is(3)); assertThat(document.lastIndexOf("ab", 1), is(0)); assertThat(document.lastIndexOf("ab", 0), is(0)); // can match even if match goes out of boundary assertThat(document.lastIndexOf("ab", -1), is(-1)); - assertThat(document.lastIndexOf(" ", 8), is(-1)); // not found + assertThat(document.lastIndexOf(" ", safeIndex(8, 1)), is(-1)); // not found } @Test public void borrowsSpan() { - Document empty = Document.of(""); - Document line = Document.of("abc"); - Document multi = Document.of("abc\ndef\n\n"); + 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 @@ -378,43 +379,43 @@ public void borrowsSpan() { 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, 4), string("abc\n")); // with newline - assertThat(multi.borrowSpan(3, 5), string("\nd")); // inner - assertThat(multi.borrowSpan(5, 9), string("ef\n\n")); // up to end + 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 = Document.of(""); - Document single = Document.of("abc"); - Document twoLine = Document.of("abc\ndef"); - Document leadingAndTrailingWs = Document.of("\nabc\n"); - Document threeLine = Document.of("abc\ndef\nhij\n"); + Document empty = makeDocument(""); + Document single = makeDocument("abc"); + Document twoLine = makeDocument("abc\ndef"); + Document leadingAndTrailingWs = makeDocument("\nabc\n"); + Document threeLine = makeDocument("abc\ndef\nhij\n"); assertThat(empty.lineOfIndex(1), is(-1)); // oob assertThat(single.lineOfIndex(0), is(0)); // start assertThat(single.lineOfIndex(2), is(0)); // end assertThat(single.lineOfIndex(3), is(-1)); // oob assertThat(twoLine.lineOfIndex(1), is(0)); // first line - assertThat(twoLine.lineOfIndex(4), is(1)); // second line start + assertThat(twoLine.lineOfIndex(safeIndex(4, 1)), is(1)); // second line start assertThat(twoLine.lineOfIndex(3), is(0)); // new line - assertThat(twoLine.lineOfIndex(6), is(1)); // end - assertThat(twoLine.lineOfIndex(7), is(-1)); // oob + assertThat(twoLine.lineOfIndex(safeIndex(6, 1)), is(1)); // end + assertThat(twoLine.lineOfIndex(safeIndex(7, 1)), is(-1)); // oob assertThat(leadingAndTrailingWs.lineOfIndex(0), is(0)); // new line - assertThat(leadingAndTrailingWs.lineOfIndex(1), is(1)); // start of line - assertThat(leadingAndTrailingWs.lineOfIndex(4), is(1)); // new line - assertThat(threeLine.lineOfIndex(12), is(-1)); - assertThat(threeLine.lineOfIndex(11), is(2)); + assertThat(leadingAndTrailingWs.lineOfIndex(safeIndex(1, 1)), is(1)); // start of line + assertThat(leadingAndTrailingWs.lineOfIndex(safeIndex(4, 1)), is(1)); // new line + assertThat(threeLine.lineOfIndex(safeIndex(12, 3)), is(-1)); + assertThat(threeLine.lineOfIndex(safeIndex(11, 2)), is(2)); } @Test public void borrowsDocumentShapeId() { - Document empty = Document.of(""); - Document notId = Document.of("?!&"); - Document onlyId = Document.of("abc"); - Document split = Document.of("abc.def hij"); - Document technicallyBroken = Document.of("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); - Document technicallyValid = Document.of("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); + 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.getDocumentIdAt(new Position(0, 0)), nullValue()); assertThat(notId.getDocumentIdAt(new Position(0, 0)), nullValue()); @@ -449,11 +450,37 @@ public void borrowsDocumentShapeId() { assertThat(technicallyValid.getDocumentIdAt(new Position(0, 56)), documentShapeId("foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); } + // This is used to convert the character offset in a file that assumes a single character + // line break, and make that same offset safe with multi character line breaks. + // + // This is preferable to simply adjusting how we test Document because bugs in these low-level + // primitive methods will break a lot of stuff, so it's good to be exact. + public static int safeIndex(int standardOffset, int line) { + return standardOffset + (line * (System.lineSeparator().length() - 1)); + } + + // Makes a string literal with '\n' newline characters use the actual OS line separator. + // Don't use this if you didn't manually type out the '\n's. + // TODO: Remove this for textblocks + public static String safeString(String s) { + return s.replace("\n", System.lineSeparator()); + } + + private static Document makeDocument(String s) { + return Document.of(safeString(s)); + } + public static Matcher string(String other) { return new CustomTypeSafeMatcher(other) { @Override protected boolean matchesSafely(CharSequence item) { - return other.equals(item.toString()); + return other.replace("\n", "\\n").replace("\r", "\\r").equals(item.toString().replace("\n", "\\n").replace("\r", "\\r")); + } + @Override + public void describeMismatchSafely(CharSequence item, Description description) { + String o = other.replace("\n", "\\n").replace("\r", "\\r"); + String it = item.toString().replace("\n", "\\n").replace("\r", "\\r"); + equalTo(o).describeMismatch(it, description); } }; } 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 5313beec..42534d74 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java @@ -6,13 +6,13 @@ package software.amazon.smithy.lsp.project; import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.MatcherAssert.assertThat; 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.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; @@ -24,7 +24,7 @@ public class ProjectConfigLoaderTest { @Test public void loadsConfigWithEnvVariable() { System.setProperty("FOO", "bar"); - Path root = Paths.get(getClass().getResource("env-config").getPath()); + Path root = toPath(getClass().getResource("env-config")); Result> result = ProjectConfigLoader.loadFromRoot(root); assertThat(result.isOk(), is(true)); @@ -40,7 +40,7 @@ public void loadsConfigWithEnvVariable() { @Test public void loadsLegacyConfig() { - Path root = Paths.get(getClass().getResource("legacy-config").getPath()); + Path root = toPath(getClass().getResource("legacy-config")); Result> result = ProjectConfigLoader.loadFromRoot(root); assertThat(result.isOk(), is(true)); @@ -55,7 +55,7 @@ public void loadsLegacyConfig() { @Test public void prefersNonLegacyConfig() { - Path root = Paths.get(getClass().getResource("legacy-config-with-conflicts").getPath()); + Path root = toPath(getClass().getResource("legacy-config-with-conflicts")); Result> result = ProjectConfigLoader.loadFromRoot(root); assertThat(result.isOk(), is(true)); @@ -70,7 +70,7 @@ public void prefersNonLegacyConfig() { @Test public void mergesBuildExts() { - Path root = Paths.get(getClass().getResource("build-exts").getPath()); + Path root = toPath(getClass().getResource("build-exts")); Result> result = ProjectConfigLoader.loadFromRoot(root); assertThat(result.isOk(), is(true)); 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 48857821..93d92b07 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -21,9 +21,12 @@ 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.logging.Logger; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.TestWorkspace; @@ -43,7 +46,7 @@ public class ProjectTest { @Test public void loadsFlatProject() { - Path root = Paths.get(getClass().getResource("flat").getPath()); + Path root = toPath(getClass().getResource("flat")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); @@ -56,7 +59,7 @@ public void loadsFlatProject() { @Test public void loadsProjectWithMavenDep() { - Path root = Paths.get(getClass().getResource("maven-dep").getPath()); + Path root = toPath(getClass().getResource("maven-dep")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); @@ -69,7 +72,7 @@ public void loadsProjectWithMavenDep() { @Test public void loadsProjectWithSubdir() { - Path root = Paths.get(getClass().getResource("subdirs").getPath()); + Path root = toPath(getClass().getResource("subdirs")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); @@ -77,10 +80,10 @@ public void loadsProjectWithSubdir() { root.resolve("model"), root.resolve("model2"))); assertThat(project.getSmithyFiles().keySet(), hasItems( - containsString("model/main.smithy"), - containsString("model/subdir/sub.smithy"), - containsString("model2/subdir2/sub2.smithy"), - containsString("model2/subdir2/subsubdir/subsub.smithy"))); + 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.getModelResult().isBroken(), is(false)); assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Bar")); @@ -89,7 +92,7 @@ public void loadsProjectWithSubdir() { @Test public void loadsModelWithUnknownTrait() { - Path root = Paths.get(getClass().getResource("unknown-trait").getPath()); + Path root = toPath(getClass().getResource("unknown-trait")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); @@ -106,7 +109,7 @@ public void loadsModelWithUnknownTrait() { @Test public void loadsWhenModelHasInvalidSyntax() { - Path root = Paths.get(getClass().getResource("invalid-syntax").getPath()); + Path root = toPath(getClass().getResource("invalid-syntax")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); @@ -140,7 +143,7 @@ public void loadsWhenModelHasInvalidSyntax() { @Test public void loadsProjectWithMultipleNamespaces() { - Path root = Paths.get(getClass().getResource("multiple-namespaces").getPath()); + Path root = toPath(getClass().getResource("multiple-namespaces")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getSources(), hasItem(root.resolve("model"))); @@ -176,7 +179,7 @@ public void loadsProjectWithMultipleNamespaces() { @Test public void loadsProjectWithExternalJars() { - Path root = Paths.get(getClass().getResource("external-jars").getPath()); + Path root = toPath(getClass().getResource("external-jars")); Result> result = ProjectLoader.load(root); assertThat(result.isOk(), is(true)); @@ -200,7 +203,7 @@ public void loadsProjectWithExternalJars() { @Test public void failsLoadingInvalidSmithyBuildJson() { - Path root = Paths.get(getClass().getResource("broken/missing-version").getPath()); + Path root = toPath(getClass().getResource("broken/missing-version")); Result> result = ProjectLoader.load(root); assertThat(result.isErr(), is(true)); @@ -208,7 +211,7 @@ public void failsLoadingInvalidSmithyBuildJson() { @Test public void failsLoadingUnparseableSmithyBuildJson() { - Path root = Paths.get(getClass().getResource("broken/parse-failure").getPath()); + Path root = toPath(getClass().getResource("broken/parse-failure")); Result> result = ProjectLoader.load(root); assertThat(result.isErr(), is(true)); @@ -216,7 +219,7 @@ public void failsLoadingUnparseableSmithyBuildJson() { @Test public void doesntFailLoadingProjectWithNonExistingSource() { - Path root = Paths.get(getClass().getResource("broken/source-doesnt-exist").getPath()); + Path root = toPath(getClass().getResource("broken/source-doesnt-exist")); Result> result = ProjectLoader.load(root); assertThat(result.isErr(), is(false)); @@ -226,7 +229,7 @@ public void doesntFailLoadingProjectWithNonExistingSource() { @Test public void failsLoadingUnresolvableMavenDependency() { - Path root = Paths.get(getClass().getResource("broken/unresolvable-maven-dependency").getPath()); + Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); Result> result = ProjectLoader.load(root); assertThat(result.isErr(), is(true)); @@ -234,7 +237,7 @@ public void failsLoadingUnresolvableMavenDependency() { @Test public void failsLoadingUnresolvableProjectDependency() { - Path root = Paths.get(getClass().getResource("broken/unresolvable-maven-dependency").getPath()); + Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); Result> result = ProjectLoader.load(root); assertThat(result.isErr(), is(true)); @@ -242,7 +245,7 @@ public void failsLoadingUnresolvableProjectDependency() { @Test public void loadsProjectWithUnNormalizedDirs() { - Path root = Paths.get(getClass().getResource("unnormalized-dirs").getPath()); + Path root = toPath(getClass().getResource("unnormalized-dirs")); Project project = ProjectLoader.load(root).unwrap(); assertThat(project.getRoot(), equalTo(root)); @@ -255,7 +258,7 @@ public void loadsProjectWithUnNormalizedDirs() { equalTo(root.resolve("model/one.smithy").toString()), equalTo(root.resolve("model2/two.smithy").toString()), equalTo(root.resolve("model3/three.smithy").toString()), - containsString(root.resolve("smithy-test-traits.jar") + "!/META-INF/smithy/smithy.test.json"))); + containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"))); assertThat(project.getDependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); } @@ -520,6 +523,17 @@ public void changingFileWithArrayDependenciesWithDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); + if (document == null) { + String smithyFilesPaths = String.join(System.lineSeparator(), project.getSmithyFiles().keySet()); + String smithyFilesUris = project.getSmithyFiles().keySet().stream() + .map(UriAdapter::toUri) + .collect(Collectors.joining(System.lineSeparator())); + Logger logger = Logger.getLogger(getClass().getName()); + logger.severe("Not found uri: " + uri); + logger.severe("Not found path: " + UriAdapter.toPath(uri)); + logger.severe("PATHS: " + smithyFilesPaths); + logger.severe("URIS: " + smithyFilesUris); + } document.applyEdit(RangeAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -592,4 +606,12 @@ public void removingArrayApply() { assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("bar")); } + + public static Path toPath(URL url) { + try { + return Paths.get(url.toURI()); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } } From 2ae801aa3edea06a5e7408a71beb88f15a1b83a8 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 13 Jun 2024 13:20:40 -0400 Subject: [PATCH 12/15] fix a flaky test --- .../software/amazon/smithy/lsp/SmithyLanguageServerTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 681fa758..6ece0234 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -430,7 +430,7 @@ public void didChange() throws Exception { server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text(" ").build()); server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("G").build()); - server.getLifecycleManager().getTask(uri).get(); + server.getLifecycleManager().waitForAllTasks(); // mostly so you can see what it looks like assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + @@ -451,8 +451,7 @@ public void didChange() throws Exception { .buildCompletion(); List completions = server.completion(completionParams).get().getLeft(); - // TODO: Somehow this has become flaky - assertThat(completions, containsInAnyOrder(hasLabel("GetFoo"), hasLabel("GetFooInput"))); + assertThat(completions, hasItem(hasLabel("GetFooInput"))); } @Test From d46797943cf80e68eb9138cc7522dd96e096607b Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 13 Jun 2024 15:44:27 -0400 Subject: [PATCH 13/15] add log for missing name in DocumentSymbol --- .../amazon/smithy/lsp/SmithyLanguageServer.java | 4 ++++ .../amazon/smithy/lsp/document/DocumentShape.java | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index b4dfc232..78be787b 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -637,6 +637,10 @@ public CompletableFuture resolveCompletionItem(CompletionItem un break; } 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)); diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java index 0913b924..385ee95c 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java @@ -65,6 +65,16 @@ public int hashCode() { return Objects.hash(range, shapeName, kind); } + @Override + public String toString() { + return "DocumentShape{" + + "range=" + range + + ", shapeName=" + shapeName + + ", kind=" + kind + + ", targetReference=" + targetReference + + '}'; + } + public enum Kind { DefinedShape, DefinedMember, From ec70b2813b9f6c0f2d6a2e2046bd6ba86b4cb86c Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 18 Jul 2024 14:38:58 -0400 Subject: [PATCH 14/15] address first round of comments --- .../smithy/lsp/DocumentLifecycleManager.java | 7 +- .../java/software/amazon/smithy/lsp/Main.java | 6 +- .../smithy/lsp/SmithyLanguageClient.java | 2 +- .../smithy/lsp/SmithyLanguageServer.java | 59 +++--- .../smithy/lsp/SmithyProtocolExtensions.java | 4 +- .../lsp/diagnostics/DetachedDiagnostics.java | 24 +-- .../lsp/diagnostics/VersionDiagnostics.java | 58 +++--- .../amazon/smithy/lsp/document/Document.java | 178 +++++++++--------- .../smithy/lsp/document/DocumentId.java | 4 +- .../smithy/lsp/document/DocumentShape.java | 11 ++ .../lsp/ext/serverstatus/OpenProject.java | 4 +- .../lsp/ext/serverstatus/ServerStatus.java | 2 +- .../ext/serverstatus/ServerStatusParams.java | 14 -- .../smithy/lsp/handler/CompletionHandler.java | 56 +++--- .../smithy/lsp/handler/DefinitionHandler.java | 19 +- .../FileWatcherRegistrationHandler.java | 38 ++-- .../smithy/lsp/handler/HoverHandler.java | 13 +- .../amazon/smithy/lsp/project/Project.java | 32 ++-- .../smithy/lsp/project/ProjectConfig.java | 38 +++- .../lsp/project/ProjectConfigLoader.java | 49 ++--- .../smithy/lsp/project/ProjectDependency.java | 4 +- .../project/ProjectDependencyResolver.java | 17 +- .../smithy/lsp/project/ProjectLoader.java | 2 +- .../smithy/lsp/project/ProjectManager.java | 14 +- .../lsp/project/SmithyBuildExtensions.java | 14 +- .../amazon/smithy/lsp/project/SmithyFile.java | 26 +-- .../project/SmithyFileDependenciesIndex.java | 12 +- .../amazon/smithy/lsp/util/Result.java | 12 ++ .../smithy/lsp/util/ThrowingSupplier.java | 17 -- .../smithy/lsp/SmithyLanguageServerTest.java | 76 ++++---- .../amazon/smithy/lsp/UtilMatchers.java | 3 + .../smithy/lsp/document/DocumentTest.java | 64 +++---- .../lsp/project/ProjectConfigLoaderTest.java | 14 +- .../smithy/lsp/project/ProjectTest.java | 176 ++++++++--------- .../project/SmithyBuildExtensionsTest.java | 4 +- 35 files changed, 535 insertions(+), 538 deletions(-) delete mode 100644 src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java index 2d6f1ca6..38126cd9 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -22,15 +22,12 @@ final class DocumentLifecycleManager { private final Map> tasks = new HashMap<>(); private final Set managedDocumentUris = new HashSet<>(); - DocumentLifecycleManager() { - } - - Set getManagedDocuments() { + Set managedDocuments() { return managedDocumentUris; } boolean isManaged(String uri) { - return getManagedDocuments().contains(uri); + return managedDocuments().contains(uri); } CompletableFuture getTask(String uri) { diff --git a/src/main/java/software/amazon/smithy/lsp/Main.java b/src/main/java/software/amazon/smithy/lsp/Main.java index d92bafe0..87add549 100644 --- a/src/main/java/software/amazon/smithy/lsp/Main.java +++ b/src/main/java/software/amazon/smithy/lsp/Main.java @@ -60,14 +60,10 @@ private static InputStream exitOnClose(InputStream delegate) { return new InputStream() { @Override public int read() throws IOException { - return exitIfNegative(delegate.read()); - } - - int exitIfNegative(int result) { + int result = delegate.read(); if (result < 0) { System.exit(0); } - return result; } }; diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java index af8ef575..7280b582 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java @@ -29,7 +29,7 @@ * Wrapper around a delegate {@link LanguageClient} that provides convenience * methods and/or Smithy-specific language client features. */ -public class SmithyLanguageClient implements LanguageClient { +public final class SmithyLanguageClient implements LanguageClient { private final LanguageClient delegate; SmithyLanguageClient(LanguageClient delegate) { diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 78be787b..aa412fad 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -18,6 +18,7 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import com.google.gson.JsonObject; +import java.io.IOException; import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; @@ -96,7 +97,6 @@ import software.amazon.smithy.lsp.ext.LspLog; import software.amazon.smithy.lsp.ext.serverstatus.OpenProject; import software.amazon.smithy.lsp.ext.serverstatus.ServerStatus; -import software.amazon.smithy.lsp.ext.serverstatus.ServerStatusParams; import software.amazon.smithy.lsp.handler.CompletionHandler; import software.amazon.smithy.lsp.handler.DefinitionHandler; import software.amazon.smithy.lsp.handler.FileWatcherRegistrationHandler; @@ -145,7 +145,7 @@ public class SmithyLanguageServer implements private Severity minimumSeverity = Severity.WARNING; private boolean onlyReloadOnSave = false; - public SmithyLanguageServer() { + SmithyLanguageServer() { } SmithyLanguageServer(LanguageClient client, Project project) { @@ -158,7 +158,7 @@ SmithyLanguageClient getClient() { } Project getProject() { - return projects.getMainProject(); + return projects.mainProject(); } ProjectManager getProjects() { @@ -172,14 +172,15 @@ DocumentLifecycleManager getLifecycleManager() { @Override public void connect(LanguageClient client) { LOGGER.info("Connect"); - Properties props = new Properties(); + this.client = new SmithyLanguageClient(client); String message = "smithy-language-server"; try { + Properties props = new Properties(); props.load(SmithyLanguageServer.class.getClassLoader().getResourceAsStream("version.properties")); message += " version " + props.getProperty("version"); - } catch (Exception ignored) { + } catch (IOException e) { + this.client.error("Failed to load smithy-language-server version: " + e); } - this.client = new SmithyLanguageClient(client); this.client.info(message + " started."); } @@ -259,7 +260,7 @@ private void tryInitProject(Path root) { LOGGER.info("Initializing project at " + root); lifecycleManager.cancelAllTasks(); Result> loadResult = ProjectLoader.load( - root, projects, lifecycleManager.getManagedDocuments()); + root, projects, lifecycleManager.managedDocuments()); if (loadResult.isOk()) { Project updatedProject = loadResult.unwrap(); resolveDetachedProjects(updatedProject); @@ -271,7 +272,7 @@ private void 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. - if (projects.getMainProject() == null) { + if (projects.mainProject() == null) { projects.updateMainProject(Project.empty(root)); } @@ -293,8 +294,8 @@ private void resolveDetachedProjects(Project updatedProject) { // This is a project reload, so we need to resolve any added/removed files // that need to be moved to or from detached projects. if (getProject() != null) { - Set currentProjectSmithyPaths = getProject().getSmithyFiles().keySet(); - Set updatedProjectSmithyPaths = updatedProject.getSmithyFiles().keySet(); + Set currentProjectSmithyPaths = getProject().smithyFiles().keySet(); + Set updatedProjectSmithyPaths = updatedProject.smithyFiles().keySet(); Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); addedPaths.removeAll(currentProjectSmithyPaths); @@ -310,7 +311,7 @@ private void resolveDetachedProjects(Project updatedProject) { for (String removedPath : removedPaths) { String removedUri = UriAdapter.toUri(removedPath); // Only move to a detached project if the file is managed - if (lifecycleManager.getManagedDocuments().contains(removedUri)) { + if (lifecycleManager.managedDocuments().contains(removedUri)) { // Note: This should always be non-null, since we essentially got this from the current project Document removedDocument = projects.getDocument(removedUri); // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings @@ -321,7 +322,7 @@ private void resolveDetachedProjects(Project updatedProject) { } private CompletableFuture registerSmithyFileWatchers() { - Project project = projects.getMainProject(); + Project project = projects.mainProject(); List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project); return client.registerCapability(new RegistrationParams(registrations)); } @@ -386,10 +387,10 @@ public CompletableFuture> selectorCommand(SelectorParam return completedFuture(Collections.emptyList()); } - Project project = projects.getMainProject(); + Project project = projects.mainProject(); // TODO: Might also want to tell user if the model isn't loaded // TODO: Use proper location (source is just a point) - return completedFuture(project.getModelResult().getResult() + return completedFuture(project.modelResult().getResult() .map(selector::select) .map(shapes -> shapes.stream() .map(Shape::getSourceLocation) @@ -399,10 +400,10 @@ public CompletableFuture> selectorCommand(SelectorParam } @Override - public CompletableFuture serverStatus(ServerStatusParams params) { + public CompletableFuture serverStatus() { OpenProject openProject = new OpenProject( - UriAdapter.toUri(projects.getMainProject().getRoot().toString()), - projects.getMainProject().getSmithyFiles().keySet().stream() + UriAdapter.toUri(projects.mainProject().root().toString()), + projects.mainProject().smithyFiles().keySet().stream() .map(UriAdapter::toUri) .collect(Collectors.toList()), false); @@ -410,7 +411,7 @@ public CompletableFuture serverStatus(ServerStatusParams params) { List openProjects = new ArrayList<>(); openProjects.add(openProject); - for (Map.Entry entry : projects.getDetachedProjects().entrySet()) { + for (Map.Entry entry : projects.detachedProjects().entrySet()) { openProjects.add(new OpenProject( entry.getKey(), Collections.singletonList(entry.getKey()), @@ -452,13 +453,13 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { if (changedBuildFiles) { client.info("Build files changed, reloading project"); // TODO: Handle more granular updates to build files. - tryInitProject(projects.getMainProject().getRoot()); + tryInitProject(projects.mainProject().root()); } else { client.info("Project files changed, adding files " + createdSmithyFiles + " and removing files " + deletedSmithyFiles); // We get this notification for watched files, which only includes project files, // so we don't need to resolve detached projects. - projects.getMainProject().updateFiles(createdSmithyFiles, deletedSmithyFiles); + projects.mainProject().updateFiles(createdSmithyFiles, deletedSmithyFiles); } // TODO: Update watchers based on specific changes @@ -494,7 +495,7 @@ public void didChange(DidChangeTextDocumentParams params) { if (contentChangeEvent.getRange() != null) { document.applyEdit(contentChangeEvent.getRange(), contentChangeEvent.getText()); } else { - document.applyEdit(document.getFullRange(), contentChangeEvent.getText()); + document.applyEdit(document.fullRange(), contentChangeEvent.getText()); } } @@ -521,7 +522,7 @@ public void didOpen(DidOpenTextDocumentParams params) { String uri = params.getTextDocument().getUri(); lifecycleManager.cancelTask(uri); - lifecycleManager.getManagedDocuments().add(uri); + lifecycleManager.managedDocuments().add(uri); String text = params.getTextDocument().getText(); Document document = projects.getDocument(uri); @@ -539,7 +540,7 @@ public void didClose(DidCloseTextDocumentParams params) { LOGGER.info("DidClose"); String uri = params.getTextDocument().getUri(); - lifecycleManager.getManagedDocuments().remove(uri); + lifecycleManager.managedDocuments().remove(uri); if (projects.isDetached(uri)) { // Only cancel tasks for detached projects, since we're dropping the project @@ -606,7 +607,7 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return Collections.emptyList(); } - Collection documentShapes = smithyFile.getDocumentShapes(); + Collection documentShapes = smithyFile.documentShapes(); if (documentShapes.isEmpty()) { return Collections.emptyList(); } @@ -690,13 +691,13 @@ public CompletableFuture> formatting(DocumentFormatting IdlTokenizer tokenizer = IdlTokenizer.create(uri, document.borrowText()); TokenTree tokenTree = TokenTree.of(tokenizer); String formatted = Formatter.format(tokenTree); - Range range = document.getFullRange(); + Range range = document.fullRange(); TextEdit edit = new TextEdit(range, formatted); return completedFuture(Collections.singletonList(edit)); } private void sendFileDiagnosticsForManagedDocuments() { - for (String managedDocumentUri : lifecycleManager.getManagedDocuments()) { + for (String managedDocumentUri : lifecycleManager.managedDocuments()) { lifecycleManager.putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); } } @@ -725,7 +726,7 @@ List getFileDiagnostics(String uri) { SmithyFile smithyFile = project.getSmithyFile(uri); String path = UriAdapter.toPath(uri); - List diagnostics = project.getModelResult().getValidationEvents().stream() + List diagnostics = project.modelResult().getValidationEvents().stream() .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) .filter(validationEvent -> !UriAdapter.isJarFile(validationEvent.getSourceLocation().getFilename())) .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) @@ -752,7 +753,7 @@ private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFi if (validationEvent.getShapeId().isPresent() && smithyFile != null) { // Event is (probably) on a member target if (validationEvent.containsId("Target")) { - DocumentShape documentShape = smithyFile.getDocumentShapesByStartPosition() + DocumentShape documentShape = smithyFile.documentShapesByStartPosition() .get(PositionAdapter.fromSourceLocation(sourceLocation)); boolean hasMemberTarget = documentShape != null && documentShape.isKind(DocumentShape.Kind.DefinedMember) @@ -762,7 +763,7 @@ private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFi } } else { // Check if the event location is on a trait application - Range traitRange = DocumentParser.forDocument(smithyFile.getDocument()).traitIdRange(sourceLocation); + Range traitRange = DocumentParser.forDocument(smithyFile.document()).traitIdRange(sourceLocation); if (traitRange != null) { range = traitRange; } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java b/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java index e575ef34..3a263914 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java @@ -22,7 +22,6 @@ import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; import org.eclipse.lsp4j.jsonrpc.services.JsonSegment; import software.amazon.smithy.lsp.ext.serverstatus.ServerStatus; -import software.amazon.smithy.lsp.ext.serverstatus.ServerStatusParams; /** * Interface for protocol extensions for Smithy. @@ -39,9 +38,8 @@ public interface SmithyProtocolExtensions { /** * Get a snapshot of the server's status, useful for debugging purposes. * - * @param params Request parameters * @return A future containing the server's status */ @JsonRequest - CompletableFuture serverStatus(ServerStatusParams params); + CompletableFuture serverStatus(); } diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java index 2943f10c..35bab50c 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java @@ -10,11 +10,13 @@ import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.utils.SmithyInternalApi; /** * Diagnostics for when a Smithy file is not connected to a Smithy project via * smithy-build.json or other build file. */ +@SmithyInternalApi public final class DetachedDiagnostics { public static final String DETACHED_FILE = "detached-file"; @@ -28,17 +30,17 @@ private DetachedDiagnostics() { * associated with it) */ public static Diagnostic forSmithyFile(SmithyFile smithyFile) { - if (smithyFile.getDocument() != null) { - int end = smithyFile.getDocument().lineEnd(0); - Range range = RangeAdapter.lineSpan(0, 0, end); - return new Diagnostic( - range, - "This file isn't attached to a project", - DiagnosticSeverity.Warning, - "smithy-language-server", - DETACHED_FILE - ); + if (smithyFile.document() == null) { + return null; } - return null; + int end = smithyFile.document().lineEnd(0); + Range range = RangeAdapter.lineSpan(0, 0, end); + return new Diagnostic( + range, + "This file isn't attached to a project", + DiagnosticSeverity.Warning, + "smithy-language-server", + DETACHED_FILE + ); } } diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java index c4daa192..89599f39 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java @@ -22,7 +22,13 @@ import software.amazon.smithy.lsp.document.DocumentVersion; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.utils.SmithyInternalApi; +/** + * Diagnostics for when a $version control statement hasn't been defined, or when + * it has been defined for IDL 1.0. + */ +@SmithyInternalApi public final class VersionDiagnostics { public static final String SMITHY_UPDATE_VERSION = "migrating-idl-1-to-2"; public static final String SMITHY_DEFINE_VERSION = "define-idl-version"; @@ -49,7 +55,7 @@ private static Diagnostic build(String title, String code, Range range) { * @return Whether the given {@code smithyFile} has a version diagnostic */ public static boolean hasVersionDiagnostic(SmithyFile smithyFile) { - return smithyFile.getDocumentVersion() + return smithyFile.documentVersion() .map(documentVersion -> documentVersion.version().charAt(0) != '2') .orElse(true); } @@ -61,44 +67,24 @@ public static boolean hasVersionDiagnostic(SmithyFile smithyFile) { */ public static Diagnostic forSmithyFile(SmithyFile smithyFile) { // TODO: This can be cached - if (smithyFile.getDocumentVersion().isPresent()) { - DocumentVersion documentVersion = smithyFile.getDocumentVersion().get(); + Diagnostic diagnostic = null; + if (smithyFile.documentVersion().isPresent()) { + DocumentVersion documentVersion = smithyFile.documentVersion().get(); if (!documentVersion.version().toString().startsWith("2")) { - return updateVersion(documentVersion.range()); + diagnostic = build( + "You can upgrade to version 2.", + SMITHY_UPDATE_VERSION, + documentVersion.range()); + diagnostic.setCodeDescription(SMITHY_UPDATE_VERSION_CODE_DIAGNOSTIC); } - } else if (smithyFile.getDocument() != null) { - int end = smithyFile.getDocument().lineEnd(0); + } else if (smithyFile.document() != null) { + int end = smithyFile.document().lineEnd(0); Range range = RangeAdapter.lineSpan(0, 0, end); - return defineVersion(range); + diagnostic = build( + "You should define a version for your Smithy file.", + SMITHY_DEFINE_VERSION, + range); } - return null; - } - - /** - * Build a diagnostic for an outdated Smithy version. - * @param range range where the $version statement is found - * @return a Diagnostic with a code that refer to the codeAction to take - */ - static Diagnostic updateVersion(Range range) { - Diagnostic diag = build( - "You can upgrade to version 2.", - SMITHY_UPDATE_VERSION, - range - ); - diag.setCodeDescription(SMITHY_UPDATE_VERSION_CODE_DIAGNOSTIC); - return diag; - } - - /** - * Build a diagnostic for a missing Smithy version. - * @param range range where the $version is expected to be - * @return a Diagnostic with a code that refer to the codeAction to take - */ - static Diagnostic defineVersion(Range range) { - return build( - "You should define a version for your Smithy file.", - SMITHY_DEFINE_VERSION, - range - ); + return diagnostic; } } 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 db3577d3..2f69f1ba 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -43,8 +43,7 @@ public static Document of(String string) { * @return A copy of this document */ public Document copy() { - // Probably don't need to recompute the line indicies - return Document.of(copyText()); + return new Document(new StringBuilder(copyText()), lineIndices.clone()); } /** @@ -76,7 +75,7 @@ public void applyEdit(Range range, String text) { /** * @return The range of the document, from (0, 0) to {@link #end()} */ - public Range getFullRange() { + public Range fullRange() { return RangeAdapter.offset(end()); } @@ -319,19 +318,103 @@ public CharBuffer borrowToken(Position position) { * within, or {@code null} if the position is not within an id */ public CharBuffer borrowId(Position position) { - DocumentId id = getDocumentIdAt(position); + DocumentId id = copyDocumentId(position); if (id == null) { return null; } return id.borrowIdValue(); } + /** + * @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 + */ + public String copyText() { + return buffer.toString(); + } + + /** + * @param range The range to copy the text of + * @return A copy of the text in this document within the given {@code range} + * or {@code null} if the range is out of bounds + */ + public String copyRange(Range range) { + CharBuffer borrowed = borrowRange(range); + if (borrowed == null) { + return null; + } + + 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 * within, or {@code null} if the position is not within an id */ - public DocumentId getDocumentIdAt(Position position) { + public DocumentId copyDocumentId(Position position) { int idx = indexOfPosition(position); if (idx < 0) { return null; @@ -392,6 +475,7 @@ public DocumentId getDocumentIdAt(Position position) { } } + // 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 @@ -423,90 +507,6 @@ private static boolean isIdChar(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; } - /** - * @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 - */ - public String copyText() { - return buffer.toString(); - } - - /** - * @param range The range to copy the text of - * @return A copy of the text in this document within the given {@code range} - * or {@code null} if the range is out of bounds - */ - public String copyRange(Range range) { - CharBuffer borrowed = borrowRange(range); - if (borrowed == null) { - return null; - } - - 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 line The line to copy * @return A copy of the text in the given line, or {@code null} if the line 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 48841e4f..f2de2fea 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -54,7 +54,7 @@ public enum Type { this.range = range; } - public Type getType() { + public Type type() { return type; } @@ -66,7 +66,7 @@ public CharBuffer borrowIdValue() { return buffer; } - public Range getRange() { + public Range range() { return range; } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java index 385ee95c..d95d32f2 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java @@ -8,6 +8,13 @@ import java.util.Objects; 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 final class DocumentShape { private final Range range; private final CharSequence shapeName; @@ -75,6 +82,10 @@ public String toString() { + '}'; } + /** + * 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, diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java index 8d3c3077..3eaf45df 100644 --- a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java @@ -32,14 +32,14 @@ public OpenProject(@NonNull final String root, @NonNull final List files /** * @return The root directory of the project */ - public String getRoot() { + public String root() { return root; } /** * @return The list of all file URIs tracked by the project */ - public List getFiles() { + public List files() { return files; } diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java index a0a0f101..41372721 100644 --- a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java @@ -23,7 +23,7 @@ public ServerStatus(@NonNull final List openProjects) { /** * @return The open projects tracked by the server */ - public List getOpenProjects() { + public List openProjects() { return openProjects; } } diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java deleted file mode 100644 index 63b44721..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatusParams.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.ext.serverstatus; - -/** - * LSP request parameters for a ServerStatus request. - */ -public class ServerStatusParams { - public ServerStatusParams() { - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java index c0c35eab..3f81cdcb 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java @@ -9,6 +9,7 @@ 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.Predicate; import java.util.stream.Stream; @@ -47,6 +48,9 @@ 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", @@ -70,11 +74,7 @@ public CompletionHandler(Project project) { public List handle(CompletionParams params, CancelChecker cc) { String uri = params.getTextDocument().getUri(); SmithyFile smithyFile = project.getSmithyFile(uri); - if (smithyFile == null) { - return Collections.emptyList(); - } - - if (cc.isCanceled()) { + if (smithyFile == null || cc.isCanceled()) { return Collections.emptyList(); } @@ -92,7 +92,7 @@ public List handle(CompletionParams params, CancelChecker cc) { } // TODO: Maybe we should only copy the token up to the current character - DocumentId id = smithyFile.getDocument().getDocumentIdAt(position); + DocumentId id = smithyFile.document().copyDocumentId(position); if (id == null || id.borrowIdValue().length() == 0) { return Collections.emptyList(); } @@ -101,11 +101,12 @@ public List handle(CompletionParams params, CancelChecker cc) { return Collections.emptyList(); } - if (!project.getModelResult().getResult().isPresent()) { + Optional modelResul = project.modelResult().getResult(); + if (!modelResul.isPresent()) { return Collections.emptyList(); } - Model model = project.getModelResult().getResult().get(); - DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) + Model model = modelResul.get(); + DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) .determineContext(position); if (cc.isCanceled()) { @@ -125,9 +126,7 @@ private static BiConsumer, Shape> completionsFactory( DocumentId id ) { TraitBodyVisitor visitor = new TraitBodyVisitor(model); - boolean useFullId = context == DocumentPositionContext.USE_TARGET - || id.getType() == DocumentId.Type.NAMESPACE - || id.getType() == DocumentId.Type.ABSOLUTE_ID; + boolean useFullId = shouldMatchOnAbsoluteId(id, context); return (acc, shape) -> { String shapeLabel = useFullId ? shape.getId().toString() @@ -146,11 +145,12 @@ private static BiConsumer, Shape> completionsFactory( shapeLabel + "(" + traitBody + ")", shape.getId(), smithyFile, useFullId, id); acc.add(traitWithMembersItem); } - String defaultLabel = shape.isStructureShape() && !shape.members().isEmpty() - ? shapeLabel + "()" - : shapeLabel; + + if (shape.isStructureShape() && !shape.members().isEmpty()) { + shapeLabel += "()"; + } CompletionItem defaultCompletionItem = createCompletion( - defaultLabel, shape.getId(), smithyFile, useFullId, id); + shapeLabel, shape.getId(), smithyFile, useFullId, id); acc.add(defaultCompletionItem); break; case MEMBER_TARGET: @@ -169,7 +169,7 @@ private static BiConsumer, Shape> completionsFactory( private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, SmithyFile smithyFile) { String importId = shapeId.toString(); String importNamespace = shapeId.getNamespace(); - CharSequence currentNamespace = smithyFile.getNamespace(); + CharSequence currentNamespace = smithyFile.namespace(); if (importNamespace.contentEquals(currentNamespace) || Prelude.isPreludeShape(shapeId) @@ -186,12 +186,12 @@ private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, 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.getDocumentImports().isPresent()) { - Range importsRange = smithyFile.getDocumentImports().get().importsRange(); + if (smithyFile.documentImports().isPresent()) { + Range importsRange = smithyFile.documentImports().get().importsRange(); Range editRange = RangeAdapter.point(importsRange.getEnd()); return new TextEdit(editRange, insertText); - } else if (smithyFile.getDocumentNamespace().isPresent()) { - Range namespaceStatementRange = smithyFile.getDocumentNamespace().get().statementRange(); + } else if (smithyFile.documentNamespace().isPresent()) { + Range namespaceStatementRange = smithyFile.documentNamespace().get().statementRange(); Range editRange = RangeAdapter.point(namespaceStatementRange.getEnd()); return new TextEdit(editRange, insertText); } @@ -212,7 +212,7 @@ private static Stream contextualShapes(Model model, DocumentPositionConte case USE_TARGET: return model.shapes() .filter(shape -> !shape.isMemberShape()) - .filter(shape -> !shape.getId().getNamespace().contentEquals(smithyFile.getNamespace())) + .filter(shape -> !shape.getId().getNamespace().contentEquals(smithyFile.namespace())) .filter(shape -> !smithyFile.hasImport(shape.getId().toString())); case SHAPE_DEF: case OTHER: @@ -223,15 +223,19 @@ private static Stream contextualShapes(Model model, DocumentPositionConte private static Predicate contextualMatcher(DocumentId id, DocumentPositionContext context) { String matchToken = id.copyIdValue().toLowerCase(); - if (context == DocumentPositionContext.USE_TARGET - || id.getType() == DocumentId.Type.NAMESPACE - || id.getType() == DocumentId.Type.ABSOLUTE_ID) { + 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, @@ -241,7 +245,7 @@ private static CompletionItem createCompletion( ) { CompletionItem completionItem = new CompletionItem(label); completionItem.setKind(CompletionItemKind.Class); - TextEdit textEdit = new TextEdit(id.getRange(), label); + TextEdit textEdit = new TextEdit(id.range(), label); completionItem.setTextEdit(Either.forLeft(textEdit)); if (!useFullId) { addTextEdits(completionItem, shapeId, smithyFile); diff --git a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java index 5f15cb0b..6de6cfb9 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java @@ -7,6 +7,7 @@ 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; @@ -23,8 +24,10 @@ import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.traits.MixinTrait; import software.amazon.smithy.model.traits.TraitDefinition; -import software.amazon.smithy.model.validation.ValidatedResult; +/** + * Handles go-to-definition requests. + */ public final class DefinitionHandler { private final Project project; @@ -44,18 +47,18 @@ public List handle(DefinitionParams params) { } Position position = params.getPosition(); - DocumentId id = smithyFile.getDocument().getDocumentIdAt(position); + DocumentId id = smithyFile.document().copyDocumentId(position); if (id == null || id.borrowIdValue().length() == 0) { return Collections.emptyList(); } - ValidatedResult modelResult = project.getModelResult(); - if (!modelResult.getResult().isPresent()) { + Optional modelResult = project.modelResult().getResult(); + if (!modelResult.isPresent()) { return Collections.emptyList(); } - Model model = modelResult.getResult().get(); - DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) + Model model = modelResult.get(); + DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) .determineContext(position); return contextualShapes(model, context) .filter(contextualMatcher(smithyFile, id)) @@ -68,11 +71,11 @@ public List handle(DefinitionParams params) { private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) { String token = id.copyIdValue(); - if (id.getType() == DocumentId.Type.ABSOLUTE_ID) { + 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.getNamespace()) + || shape.getId().getNamespace().contentEquals(smithyFile.namespace()) || smithyFile.hasImport(shape.getId().toString())) && shape.getId().getName().equals(token); } diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java index a416c8d9..7da74035 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java @@ -40,25 +40,37 @@ public final class FileWatcherRegistrationHandler { private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles"; private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles"; private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles"; + private static final List BUILD_FILE_WATCHER_REGISTRATIONS; + private static final List SMITHY_FILE_WATCHER_UNREGISTRATIONS; - private FileWatcherRegistrationHandler() { - } - - /** - * @return The registrations to watch for build file changes - */ - public static List getBuildFileWatcherRegistrations() { - List buildFileWatchers = new ArrayList<>(); + static { + // smithy-build.json + .smithy-project.json + build exts + int buildFileWatcherCount = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length; + List buildFileWatchers = new ArrayList<>(buildFileWatcherCount); buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_BUILD))); buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_PROJECT))); for (String ext : ProjectConfigLoader.SMITHY_BUILD_EXTS) { buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ext))); } - return Collections.singletonList(new Registration( + BUILD_FILE_WATCHER_REGISTRATIONS = Collections.singletonList(new Registration( WATCH_BUILD_FILES_ID, WATCH_FILES_METHOD, new DidChangeWatchedFilesRegistrationOptions(buildFileWatchers))); + + SMITHY_FILE_WATCHER_UNREGISTRATIONS = Collections.singletonList(new Unregistration( + WATCH_SMITHY_FILES_ID, + WATCH_FILES_METHOD)); + } + + private FileWatcherRegistrationHandler() { + } + + /** + * @return The registrations to watch for build file changes + */ + public static List getBuildFileWatcherRegistrations() { + return BUILD_FILE_WATCHER_REGISTRATIONS; } /** @@ -66,8 +78,8 @@ public static List getBuildFileWatcherRegistrations() { * @return The registrations to watch for Smithy file changes */ public static List getSmithyFileWatcherRegistrations(Project project) { - List smithyFileWatchers = Stream.concat(project.getSources().stream(), - project.getImports().stream()) + List smithyFileWatchers = Stream.concat(project.sources().stream(), + project.imports().stream()) .map(FileWatcherRegistrationHandler::smithyFileWatcher) .collect(Collectors.toList()); @@ -81,9 +93,7 @@ public static List getSmithyFileWatcherRegistrations(Project proje * @return The unregistrations to stop watching for Smithy file changes */ public static List getSmithyFileWatcherUnregistrations() { - return Collections.singletonList(new Unregistration( - WATCH_SMITHY_FILES_ID, - WATCH_FILES_METHOD)); + return SMITHY_FILE_WATCHER_UNREGISTRATIONS; } private static FileSystemWatcher smithyFileWatcher(Path path) { diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java index 87de8207..06c92a1d 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java @@ -34,6 +34,9 @@ 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; @@ -56,18 +59,18 @@ public Hover handle(HoverParams params, Severity minimumSeverity) { } Position position = params.getPosition(); - DocumentId id = smithyFile.getDocument().getDocumentIdAt(position); + DocumentId id = smithyFile.document().copyDocumentId(position); if (id == null || id.borrowIdValue().length() == 0) { return hover; } - ValidatedResult modelResult = project.getModelResult(); + ValidatedResult modelResult = project.modelResult(); if (!modelResult.getResult().isPresent()) { return hover; } Model model = modelResult.getResult().get(); - DocumentPositionContext context = DocumentParser.forDocument(smithyFile.getDocument()) + DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) .determineContext(position); Optional matchingShape = contextualShapes(model, context) .filter(contextualMatcher(smithyFile, id)) @@ -137,11 +140,11 @@ public Hover handle(HoverParams params, Severity minimumSeverity) { private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) { String token = id.copyIdValue(); - if (id.getType() == DocumentId.Type.ABSOLUTE_ID) { + 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.getNamespace()) + || shape.getId().getNamespace().contentEquals(smithyFile.namespace()) || smithyFile.hasImport(shape.getId().toString())) && shape.getId().getName().equals(token); } 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 01535f7f..b705ea5c 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -72,17 +72,17 @@ public static Project empty(Path root) { /** * @return The path of the root directory of the project */ - public Path getRoot() { + public Path root() { return root; } /** * @return The paths of all Smithy sources specified * in this project's smithy build configuration files, - * normalized and resolved against {@link #getRoot()}. + * normalized and resolved against {@link #root()}. */ - public List getSources() { - return config.getSources().stream() + public List sources() { + return config.sources().stream() .map(root::resolve) .map(Path::normalize) .collect(Collectors.toList()); @@ -91,10 +91,10 @@ public List getSources() { /** * @return The paths of all Smithy imports specified * in this project's smithy build configuration files, - * normalized and resolved against {@link #getRoot()}. + * normalized and resolved against {@link #root()}. */ - public List getImports() { - return config.getImports().stream() + public List imports() { + return config.imports().stream() .map(root::resolve) .map(Path::normalize) .collect(Collectors.toList()); @@ -103,7 +103,7 @@ public List getImports() { /** * @return The paths of all resolved dependencies */ - public List getDependencies() { + public List dependencies() { return dependencies; } @@ -111,14 +111,14 @@ public List getDependencies() { * @return A map of paths to the {@link SmithyFile} at that path, containing * all smithy files loaded in the project. */ - public Map getSmithyFiles() { + public Map smithyFiles() { return this.smithyFiles; } /** * @return The latest result of loading this project */ - public ValidatedResult getModelResult() { + public ValidatedResult modelResult() { return modelResult; } @@ -133,7 +133,7 @@ public Document getDocument(String uri) { if (smithyFile == null) { return null; } - return smithyFile.getDocument(); + return smithyFile.document(); } /** @@ -242,7 +242,7 @@ public void updateFiles(Set addUris, Set removeUris, Set // Only add back stuff we aren't trying to remove. // Only removed paths will have had their SmithyFile removed. if (!removedPaths.contains(visitedPath)) { - assembler.addUnparsedModel(visitedPath, smithyFiles.get(visitedPath).getDocument().copyText()); + assembler.addUnparsedModel(visitedPath, smithyFiles.get(visitedPath).document().copyText()); } } } else { @@ -264,12 +264,12 @@ 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).getShapes()); + 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.getDocument(), updatedShapes).build()); + ProjectLoader.buildSmithyFile(visitedPath, current.document(), updatedShapes).build()); } else { current.setShapes(updatedShapes); } @@ -307,7 +307,7 @@ private void removeFileForReload( visited.add(path); - for (Shape shape : smithyFiles.get(path).getShapes()) { + for (Shape shape : smithyFiles.get(path).shapes()) { builder.removeShape(shape.getId()); // This shape may have traits applied to it in other files, @@ -379,7 +379,7 @@ static final class Builder { private ValidatedResult modelResult; private Supplier assemblerFactory = Model::assembler; private Map> perFileMetadata = new HashMap<>(); - private SmithyFileDependenciesIndex smithyFileDependenciesIndex = SmithyFileDependenciesIndex.EMPTY; + private SmithyFileDependenciesIndex smithyFileDependenciesIndex = new SmithyFileDependenciesIndex(); private Builder() { } 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 11bb77a5..33e5ec21 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -5,10 +5,16 @@ package software.amazon.smithy.lsp.project; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; 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, @@ -40,35 +46,35 @@ static Builder builder() { /** * @return All explicitly configured sources */ - public List getSources() { + public List sources() { return sources; } /** * @return All explicitly configured imports */ - public List getImports() { + public List imports() { return imports; } /** * @return The configured output directory, if one is present */ - public Optional getOutputDirectory() { + public Optional outputDirectory() { return Optional.ofNullable(outputDirectory); } /** * @return All configured external (non-maven) dependencies */ - public List getDependencies() { + public List dependencies() { return dependencies; } /** * @return The Maven configuration, if present */ - public Optional getMaven() { + public Optional maven() { return Optional.ofNullable(mavenConfig); } @@ -82,6 +88,28 @@ static final class Builder { private Builder() { } + static Builder load(Path path) { + String json = IoUtils.readUtf8File(path); + Node node = Node.parseJsonWithComments(json, path.toString()); + 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); 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 ad86747e..c299ecea 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -9,15 +9,12 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; import java.util.logging.Logger; -import java.util.stream.Collectors; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.util.Result; 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.model.node.StringNode; import software.amazon.smithy.utils.IoUtils; /** @@ -70,8 +67,7 @@ public final class ProjectConfigLoader { public static final String SMITHY_PROJECT = ".smithy-project.json"; private static final Logger LOGGER = Logger.getLogger(ProjectConfigLoader.class.getName()); - private static final Supplier DEFAULT_SMITHY_BUILD = () -> - SmithyBuildConfig.builder().version("1").build(); + private static final SmithyBuildConfig DEFAULT_SMITHY_BUILD = SmithyBuildConfig.builder().version("1").build(); private static final NodeMapper NODE_MAPPER = new NodeMapper(); private ProjectConfigLoader() { @@ -92,7 +88,7 @@ static Result> loadFromRoot(Path workspaceRoot) { result.getErr().ifPresent(exceptions::add); } else { LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); - builder.merge(DEFAULT_SMITHY_BUILD.get()); + builder.merge(DEFAULT_SMITHY_BUILD); } SmithyBuildExtensions.Builder extensionsBuilder = SmithyBuildExtensions.builder(); @@ -107,36 +103,17 @@ static Result> loadFromRoot(Path workspaceRoot) { } 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(() -> { - String json = IoUtils.readUtf8File(smithyProjectPath); - Node node = Node.parseJsonWithComments(json, smithyProjectPath.toString()); - 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; - }); - if (result.isOk()) { - finalConfigBuilder = result.unwrap(); - } else { - exceptions.add(result.unwrapErr()); - } - } + 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)); + if (result.isOk()) { + finalConfigBuilder = result.unwrap(); + } else { + exceptions.add(result.unwrapErr()); + } + } if (!exceptions.isEmpty()) { return Result.err(exceptions); diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java index 3b156205..a6e5347a 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java @@ -31,14 +31,14 @@ static ProjectDependency fromNode(Node node) { /** * @return The name of the dependency */ - public String getName() { + public String name() { return name; } /** * @return The path of the dependency */ - public String getPath() { + public String path() { return 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 index 8f7a1f17..e73336f8 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java @@ -46,9 +46,9 @@ static Result, Exception> resolveDependencies(Path root, ProjectConfi .stream() .map(ResolvedArtifact::getPath) .collect(Collectors.toCollection(ArrayList::new)); - config.getDependencies().forEach((projectDependency) -> { + config.dependencies().forEach((projectDependency) -> { // TODO: Not sure if this needs to check for existence - Path path = root.resolve(projectDependency.getPath()).normalize(); + Path path = root.resolve(projectDependency.path()).normalize(); deps.add(path); }); return deps; @@ -57,10 +57,6 @@ static Result, Exception> resolveDependencies(Path root, ProjectConfi // Taken (roughly) from smithy-cli ClasspathAction::resolveDependencies private static DependencyResolver create(ProjectConfig config) { - // DependencyResolver delegate = new MavenDependencyResolver(EnvironmentVariable.SMITHY_MAVEN_CACHE.get()); - // long lastModified = config.getLastModifiedInMillis(); - // DependencyResolver resolver = new FileCacheResolver(getCacheFile(config), lastModified, delegate); - // 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()); @@ -69,7 +65,7 @@ private static DependencyResolver create(ProjectConfig config) { configuredRepositories.forEach(resolver::addRepository); // TODO: Support lock file ? - config.getMaven().ifPresent(maven -> maven.getDependencies().forEach(resolver::addDependency)); + config.maven().ifPresent(maven -> maven.getDependencies().forEach(resolver::addDependency)); return resolver; } @@ -84,7 +80,7 @@ private static File getCacheFile(ProjectConfig config) { // Taken from smithy-cli BuildOptions::resolveOutput private static Path getOutputDirectory(ProjectConfig config) { - return config.getOutputDirectory() + return config.outputDirectory() .map(Paths::get) .orElseGet(SmithyBuild::getDefaultOutputDirectory); } @@ -100,16 +96,13 @@ private static Set getConfiguredMavenRepos(ProjectConfig config } } - Set configuredRepos = config.getMaven() + Set configuredRepos = config.maven() .map(MavenConfig::getRepositories) .orElse(Collections.emptySet()); if (!configuredRepos.isEmpty()) { repositories.addAll(configuredRepos); } else if (envRepos == null) { -// LOGGER.finest(() -> String.format("maven.repositories is not defined in `smithy-build.json` and the %s " -// + "environment variable is not set. Defaulting to Maven Central.", -// EnvironmentVariable.SMITHY_MAVEN_REPOS)); repositories.add(CENTRAL.get()); } return repositories; 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 d3867055..708664b4 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -150,7 +150,7 @@ public static Result> load( // 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.getSources(), config.getImports()); + List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); Result, Exception> loadModelResult = Result.ofFallible(() -> { for (Path path : allSmithyFilePaths) { 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 03986271..c719d0b1 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -28,7 +28,7 @@ public ProjectManager() { * {@link org.eclipse.lsp4j.services.LanguageServer#initialize(InitializeParams)} * is called. If there's no smithy-build.json, this is just an empty project. */ - public Project getMainProject() { + public Project mainProject() { return mainProject; } @@ -45,7 +45,7 @@ public void updateMainProject(Project updated) { * to their own detached projects. These projects contain only the file that * corresponds to the key in the map. */ - public Map getDetachedProjects() { + public Map detachedProjects() { return detached; } @@ -57,7 +57,7 @@ public Project getProject(String uri) { String path = UriAdapter.toPath(uri); if (isDetached(uri)) { return detached.get(uri); - } else if (mainProject.getSmithyFiles().containsKey(path)) { + } else if (mainProject.smithyFiles().containsKey(path)) { return mainProject; } else { // Note: In practice, this shouldn't really happen because the server shouldn't @@ -77,7 +77,7 @@ public boolean isDetached(String uri) { // being placed in a detached project. Removing it here is basically // like removing it lazily, although it does feel a little hacky. String path = UriAdapter.toPath(uri); - if (mainProject.getSmithyFiles().containsKey(path) && detached.containsKey(uri)) { + if (mainProject.smithyFiles().containsKey(path) && detached.containsKey(uri)) { removeDetachedProject(uri); } @@ -110,9 +110,9 @@ public Project removeDetachedProject(String uri) { */ public Document getDocument(String uri) { Project project = getProject(uri); - if (project != null) { - return project.getDocument(uri); + if (project == null) { + return null; } - return null; + return project.getDocument(uri); } } 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 536301d3..35e2a576 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java @@ -46,11 +46,11 @@ private SmithyBuildExtensions(Builder b) { lastModifiedInMillis = b.lastModifiedInMillis; } - public List getImports() { + public List imports() { return imports; } - public MavenConfig getMavenConfig() { + public MavenConfig mavenConfig() { return maven; } @@ -86,8 +86,8 @@ public void mergeMavenFromSmithyBuildConfig(SmithyBuildConfig config) { public SmithyBuildConfig asSmithyBuildConfig() { return SmithyBuildConfig.builder() .version("1") - .imports(getImports()) - .maven(getMavenConfig()) + .imports(imports()) + .maven(mavenConfig()) .lastModifiedInMillis(getLastModifiedInMillis()) .build(); } @@ -115,16 +115,16 @@ public Builder merge(SmithyBuildExtensions other) { List dependencies = new ArrayList<>(maven.getDependencies()); // Merge dependencies from other extension, preferring those defined on MavenConfig. - if (other.getMavenConfig().getDependencies().isEmpty()) { + if (other.mavenConfig().getDependencies().isEmpty()) { dependencies.addAll(other.mavenDependencies); } else { - dependencies.addAll(other.getMavenConfig().getDependencies()); + dependencies.addAll(other.mavenConfig().getDependencies()); } mavenConfigBuilder.dependencies(dependencies); List repositories = new ArrayList<>(maven.getRepositories()); // Merge repositories from other extension, preferring those defined on MavenConfig. - if (other.getMavenConfig().getRepositories().isEmpty()) { + if (other.mavenConfig().getRepositories().isEmpty()) { repositories.addAll(other.mavenRepositories.stream() .map(repo -> MavenRepository.builder().url(repo).build()) .collect(Collectors.toList())); 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 912e9075..ba4374c0 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -48,21 +48,21 @@ private SmithyFile(Builder builder) { /** * @return The path of this Smithy file */ - public String getPath() { + public String path() { return path; } /** * @return The {@link Document} backing this Smithy file */ - public Document getDocument() { + public Document document() { return document; } /** * @return The Shapes defined in this Smithy file */ - public Set getShapes() { + public Set shapes() { return shapes; } @@ -73,15 +73,15 @@ void setShapes(Set shapes) { /** * @return This Smithy file's imports, if they exist */ - public Optional getDocumentImports() { + public Optional documentImports() { return Optional.ofNullable(this.imports); } /** * @return The ids of shapes imported into this Smithy file */ - public Set getImports() { - return getDocumentImports() + public Set imports() { + return documentImports() .map(DocumentImports::imports) .orElse(Collections.emptySet()); } @@ -89,14 +89,14 @@ public Set getImports() { /** * @return This Smithy file's namespace, if one exists */ - public Optional getDocumentNamespace() { + public Optional documentNamespace() { return Optional.ofNullable(namespace); } /** * @return The shapes in this Smithy file, including referenced shapes */ - public Collection getDocumentShapes() { + public Collection documentShapes() { if (documentShapes == null) { return Collections.emptyList(); } @@ -107,7 +107,7 @@ public Collection getDocumentShapes() { * @return A map of {@link Position} to the {@link DocumentShape} they are * the starting position of */ - public Map getDocumentShapesByStartPosition() { + public Map documentShapesByStartPosition() { if (documentShapes == null) { return Collections.emptyMap(); } @@ -117,8 +117,8 @@ public Map getDocumentShapesByStartPosition() { /** * @return The string literal namespace of this Smithy file, or an empty string */ - public CharSequence getNamespace() { - return getDocumentNamespace() + public CharSequence namespace() { + return documentNamespace() .map(DocumentNamespace::namespace) .orElse(""); } @@ -126,7 +126,7 @@ public CharSequence getNamespace() { /** * @return This Smithy file's version, if it exists */ - public Optional getDocumentVersion() { + public Optional documentVersion() { return Optional.ofNullable(documentVersion); } @@ -135,7 +135,7 @@ public Optional getDocumentVersion() { * @return Whether {@code shapeId} is in this SmithyFile's imports */ public boolean hasImport(String shapeId) { - if (imports == null) { + if (imports == null || imports.imports().isEmpty()) { return false; } return imports.imports().contains(shapeId); diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java index 5fea7bb8..818beb9d 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java @@ -41,14 +41,18 @@ * */ final class SmithyFileDependenciesIndex { - static final SmithyFileDependenciesIndex EMPTY = new SmithyFileDependenciesIndex( - new HashMap<>(0), new HashMap<>(0), new HashMap<>(0), new HashMap<>(0)); - 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, @@ -80,7 +84,7 @@ List getTraitsAppliedInOtherFiles(ToShapeId toShapeId) { // TODO: Make this take care of metadata too static SmithyFileDependenciesIndex compute(ValidatedResult modelResult) { if (!modelResult.getResult().isPresent()) { - return EMPTY; + return new SmithyFileDependenciesIndex(); } SmithyFileDependenciesIndex index = new SmithyFileDependenciesIndex( diff --git a/src/main/java/software/amazon/smithy/lsp/util/Result.java b/src/main/java/software/amazon/smithy/lsp/util/Result.java index 631919ee..8ee93e67 100644 --- a/src/main/java/software/amazon/smithy/lsp/util/Result.java +++ b/src/main/java/software/amazon/smithy/lsp/util/Result.java @@ -148,4 +148,16 @@ public Result mapErr(Function mapper) { } 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/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java b/src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java deleted file mode 100644 index 030b0bab..00000000 --- a/src/main/java/software/amazon/smithy/lsp/util/ThrowingSupplier.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.util; - -/** - * 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/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 6ece0234..55848f9f 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -470,7 +470,7 @@ public void didChangeReloadsModel() throws Exception { .text(model) .build(); server.didOpen(openParams); - assertThat(server.getProject().getModelResult().getValidationEvents(), empty()); + assertThat(server.getProject().modelResult().getValidationEvents(), empty()); DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() .uri(uri) @@ -481,13 +481,13 @@ public void didChangeReloadsModel() throws Exception { server.getLifecycleManager().getTask(uri).get(); - assertThat(server.getProject().getModelResult().getValidationEvents(), + assertThat(server.getProject().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); server.didSave(didSaveParams); - assertThat(server.getProject().getModelResult().getValidationEvents(), + assertThat(server.getProject().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); } @@ -848,7 +848,7 @@ public void insideJar() throws Exception { String preludeUri = preludeLocation.getUri(); assertThat(preludeUri, startsWith("smithyjar")); - Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getProject().getDocument(preludeUri).getFullRange()); + Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getProject().getDocument(preludeUri).fullRange()); Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() .uri(preludeUri) @@ -893,9 +893,9 @@ public void addingWatchedFile() throws Exception { assertThat(server.getLifecycleManager().isManaged(uri), is(true)); assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getMainProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getMainProject().getDocument(uri), notNullValue()); - assertThat(server.getProjects().getMainProject().getDocument(uri).copyText(), equalTo("$")); + assertThat(server.getProjects().mainProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProjects().mainProject().getDocument(uri), notNullValue()); + assertThat(server.getProjects().mainProject().getDocument(uri).copyText(), equalTo("$")); } @Test @@ -1065,7 +1065,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getProject().getModelResult().unwrap().getMetadata(); + Map metadataBefore = server.getProject().modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -1087,7 +1087,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { server.getLifecycleManager().getTask(uri).get(); - Map metadataAfter = server.getProject().getModelResult().unwrap().getMetadata(); + Map metadataAfter = server.getProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); @@ -1101,7 +1101,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { server.getLifecycleManager().getTask(uri).get(); - Map metadataAfter2 = server.getProject().getModelResult().unwrap().getMetadata(); + Map metadataAfter2 = server.getProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter2, hasKey("foo")); assertThat(metadataAfter2, hasKey("bar")); assertThat(metadataAfter2.get("foo"), instanceOf(ArrayNode.class)); @@ -1129,7 +1129,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getProject().getModelResult().unwrap().getMetadata(); + Map metadataBefore = server.getProject().modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -1144,7 +1144,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { server.getLifecycleManager().waitForAllTasks(); - Map metadataAfter = server.getProject().getModelResult().unwrap().getMetadata(); + Map metadataAfter = server.getProject().modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); @@ -1166,7 +1166,7 @@ public void addingOpenedDetachedFile() throws Exception { String uri = workspace.getUri("main.smithy"); - assertThat(server.getLifecycleManager().getManagedDocuments(), not(hasItem(uri))); + assertThat(server.getLifecycleManager().managedDocuments(), not(hasItem(uri))); assertThat(server.getProjects().isDetached(uri), is(false)); assertThat(server.getProjects().getProject(uri), nullValue()); @@ -1175,7 +1175,7 @@ public void addingOpenedDetachedFile() throws Exception { .text(modelText) .build()); - assertThat(server.getLifecycleManager().getManagedDocuments(), hasItem(uri)); + assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); assertThat(server.getProjects().isDetached(uri), is(true)); assertThat(server.getProjects().getProject(uri), notNullValue()); @@ -1199,11 +1199,11 @@ public void addingOpenedDetachedFile() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().getManagedDocuments(), hasItem(uri)); + assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); assertThat(server.getProjects().isDetached(uri), is(false)); assertThat(server.getProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProject().getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - assertThat(server.getProject().getModelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + assertThat(server.getProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); } @Test @@ -1237,12 +1237,12 @@ public void detachingOpenedFile() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getLifecycleManager().getManagedDocuments(), hasItem(uri)); + 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).getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); - assertThat(server.getProjects().getProject(uri).getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); } @Test @@ -1355,9 +1355,9 @@ public void invalidSyntaxModelPartiallyLoads() { String uri = workspace.getUri("model-0.smithy"); assertThat(server.getProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProject().getModelResult().isBroken(), is(true)); - assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true)); - assertThat(server.getProject().getModelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getProject().modelResult().isBroken(), is(true)); + assertThat(server.getProject().modelResult().getResult().isPresent(), is(true)); + assertThat(server.getProject().modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); } @Test @@ -1379,9 +1379,9 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { assertThat(server.getProjects().isDetached(uri), is(true)); assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getModelResult().isBroken(), is(true)); - assertThat(server.getProjects().getProject(uri).getModelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).getSmithyFiles().keySet(), hasItem(endsWith(filename))); + 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))); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1393,10 +1393,10 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { assertThat(server.getProjects().isDetached(uri), is(true)); assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getModelResult().isBroken(), is(false)); - assertThat(server.getProjects().getProject(uri).getModelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).getSmithyFiles().keySet(), hasItem(endsWith(filename))); - assertThat(server.getProjects().getProject(uri).getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + 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")); } // TODO: apparently flaky @@ -1451,9 +1451,9 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { server.getLifecycleManager().waitForAllTasks(); assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getDetachedProjects().keySet(), empty()); + assertThat(server.getProjects().detachedProjects().keySet(), empty()); assertThat(server.getProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1482,8 +1482,8 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - Shape foo = server.getProject().getModelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + Shape foo = server.getProject().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))); @@ -1501,9 +1501,9 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { server.getLifecycleManager().waitForAllTasks(); - assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - foo = server.getProject().getModelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); + foo = server.getProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); } @@ -1553,7 +1553,7 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { 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).getModelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java index d5e8a8e0..8c643d5d 100644 --- a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -10,6 +10,9 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; +/** + * Utility hamcrest matchers. + */ public final class UtilMatchers { private UtilMatchers() {} 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 beae1565..9a22c290 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -417,37 +417,37 @@ public void borrowsDocumentShapeId() { 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.getDocumentIdAt(new Position(0, 0)), nullValue()); - assertThat(notId.getDocumentIdAt(new Position(0, 0)), nullValue()); - assertThat(notId.getDocumentIdAt(new Position(0, 2)), nullValue()); - assertThat(onlyId.getDocumentIdAt(new Position(0, 0)), documentShapeId("abc", DocumentId.Type.ID)); - assertThat(onlyId.getDocumentIdAt(new Position(0, 2)), documentShapeId("abc", DocumentId.Type.ID)); - assertThat(onlyId.getDocumentIdAt(new Position(0, 3)), nullValue()); - assertThat(split.getDocumentIdAt(new Position(0, 0)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); - assertThat(split.getDocumentIdAt(new Position(0, 6)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); - assertThat(split.getDocumentIdAt(new Position(0, 7)), nullValue()); - assertThat(split.getDocumentIdAt(new Position(0, 8)), documentShapeId("hij", DocumentId.Type.ID)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 0)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 3)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 7)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 9)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 16)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 18)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 25)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 27)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 30)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 37)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 39)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 43)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 49)), documentShapeId("$foo", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 54)), documentShapeId(".foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.getDocumentIdAt(new Position(0, 59)), documentShapeId("#foo", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.getDocumentIdAt(new Position(0, 0)), documentShapeId("com.foo#bar", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.getDocumentIdAt(new Position(0, 12)), documentShapeId("com.foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); - assertThat(technicallyValid.getDocumentIdAt(new Position(0, 28)), documentShapeId("com.foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyValid.getDocumentIdAt(new Position(0, 36)), documentShapeId("foo#bar", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.getDocumentIdAt(new Position(0, 44)), documentShapeId("foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); - assertThat(technicallyValid.getDocumentIdAt(new Position(0, 56)), documentShapeId("foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); + 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)); } // This is used to convert the character offset in a file that assumes a single character @@ -489,7 +489,7 @@ public static Matcher documentShapeId(String other, DocumentId.Type return new CustomTypeSafeMatcher(other + " with type: " + type) { @Override protected boolean matchesSafely(DocumentId item) { - return other.equals(item.copyIdValue()) && item.getType() == type; + return other.equals(item.copyIdValue()) && item.type() == type; } }; } 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 42534d74..7e0d9f62 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java @@ -29,8 +29,8 @@ public void loadsConfigWithEnvVariable() { assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); - assertThat(config.getMaven().isPresent(), is(true)); - MavenConfig mavenConfig = config.getMaven().get(); + 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")); @@ -45,8 +45,8 @@ public void loadsLegacyConfig() { assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); - assertThat(config.getMaven().isPresent(), is(true)); - MavenConfig mavenConfig = config.getMaven().get(); + assertThat(config.maven().isPresent(), is(true)); + MavenConfig mavenConfig = config.maven().get(); assertThat(mavenConfig.getDependencies(), containsInAnyOrder("baz")); assertThat(mavenConfig.getRepositories().stream() .map(MavenRepository::getUrl) @@ -60,8 +60,8 @@ public void prefersNonLegacyConfig() { assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); - assertThat(config.getMaven().isPresent(), is(true)); - MavenConfig mavenConfig = config.getMaven().get(); + assertThat(config.maven().isPresent(), is(true)); + MavenConfig mavenConfig = config.maven().get(); assertThat(mavenConfig.getDependencies(), containsInAnyOrder("dep1", "dep2")); assertThat(mavenConfig.getRepositories().stream() .map(MavenRepository::getUrl) @@ -75,6 +75,6 @@ public void mergesBuildExts() { assertThat(result.isOk(), is(true)); ProjectConfig config = result.unwrap(); - assertThat(config.getImports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); + assertThat(config.imports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); } } 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 93d92b07..a3b8033e 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -49,12 +49,12 @@ public void loadsFlatProject() { Path root = toPath(getClass().getResource("flat")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); - assertThat(project.getImports(), empty()); - assertThat(project.getDependencies(), empty()); - assertThat(project.getModelResult().isBroken(), is(false)); - assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.imports(), empty()); + assertThat(project.dependencies(), empty()); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } @Test @@ -62,12 +62,12 @@ public void loadsProjectWithMavenDep() { Path root = toPath(getClass().getResource("maven-dep")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); - assertThat(project.getImports(), empty()); - assertThat(project.getDependencies(), hasSize(3)); - assertThat(project.getModelResult().isBroken(), is(false)); - assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.imports(), empty()); + assertThat(project.dependencies(), hasSize(3)); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } @Test @@ -75,19 +75,19 @@ public void loadsProjectWithSubdir() { Path root = toPath(getClass().getResource("subdirs")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItems( + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItems( root.resolve("model"), root.resolve("model2"))); - assertThat(project.getSmithyFiles().keySet(), hasItems( + assertThat(project.smithyFiles().keySet(), 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.getModelResult().isBroken(), is(false)); - assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Bar")); - assertThat(project.getModelResult().unwrap(), hasShapeWithId("com.foo#Baz")); + 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 @@ -95,16 +95,16 @@ public void loadsModelWithUnknownTrait() { Path root = toPath(getClass().getResource("unknown-trait")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); - assertThat(project.getModelResult().isBroken(), is(false)); // unknown traits don't break it + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItem(root.resolve("main.smithy"))); + assertThat(project.modelResult().isBroken(), is(false)); // unknown traits don't break it - List eventIds = project.getModelResult().getValidationEvents().stream() + List eventIds = project.modelResult().getValidationEvents().stream() .map(ValidationEvent::getId) .collect(Collectors.toList()); assertThat(eventIds, hasItem(containsString("UnresolvedTrait"))); - assertThat(project.getModelResult().getResult().isPresent(), is(true)); - assertThat(project.getModelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + assertThat(project.modelResult().getResult().isPresent(), is(true)); + assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); } @Test @@ -112,30 +112,30 @@ public void loadsWhenModelHasInvalidSyntax() { Path root = toPath(getClass().getResource("invalid-syntax")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItem(root.resolve("main.smithy"))); - assertThat(project.getModelResult().isBroken(), is(true)); - List eventIds = project.getModelResult().getValidationEvents().stream() + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItem(root.resolve("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.getSmithyFiles().keySet(), hasItem(containsString("main.smithy"))); + assertThat(project.smithyFiles().keySet(), hasItem(containsString("main.smithy"))); SmithyFile main = project.getSmithyFile(UriAdapter.toUri(root.resolve("main.smithy").toString())); assertThat(main, not(nullValue())); - assertThat(main.getDocument(), not(nullValue())); - assertThat(main.getNamespace(), string("com.foo")); - assertThat(main.getImports(), empty()); + assertThat(main.document(), not(nullValue())); + assertThat(main.namespace(), string("com.foo")); + assertThat(main.imports(), empty()); - assertThat(main.getShapes(), hasSize(2)); - List shapeIds = main.getShapes().stream() + assertThat(main.shapes(), hasSize(2)); + List shapeIds = main.shapes().stream() .map(Shape::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar")); - assertThat(main.getDocumentShapes(), hasSize(3)); - List documentShapeNames = main.getDocumentShapes().stream() + assertThat(main.documentShapes(), hasSize(3)); + List documentShapeNames = main.documentShapes().stream() .map(documentShape -> documentShape.shapeName().toString()) .collect(Collectors.toList()); assertThat(documentShapeNames, hasItems("Foo", "bar", "String")); @@ -146,32 +146,32 @@ public void loadsProjectWithMultipleNamespaces() { Path root = toPath(getClass().getResource("multiple-namespaces")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getSources(), hasItem(root.resolve("model"))); - assertThat(project.getModelResult().getValidationEvents(), empty()); - assertThat(project.getSmithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); + assertThat(project.sources(), hasItem(root.resolve("model"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + assertThat(project.smithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); SmithyFile a = project.getSmithyFile(UriAdapter.toUri(root.resolve("model/a.smithy").toString())); - assertThat(a.getDocument(), not(nullValue())); - assertThat(a.getNamespace(), string("a")); - List aShapeIds = a.getShapes().stream() + assertThat(a.document(), not(nullValue())); + assertThat(a.namespace(), string("a")); + List aShapeIds = a.shapes().stream() .map(Shape::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(aShapeIds, hasItems("a#Hello", "a#HelloInput", "a#HelloOutput")); - List aDocumentShapeNames = a.getDocumentShapes().stream() + List aDocumentShapeNames = a.documentShapes().stream() .map(documentShape -> documentShape.shapeName().toString()) .collect(Collectors.toList()); assertThat(aDocumentShapeNames, hasItems("Hello", "name", "String")); SmithyFile b = project.getSmithyFile(UriAdapter.toUri(root.resolve("model/b.smithy").toString())); - assertThat(b.getDocument(), not(nullValue())); - assertThat(b.getNamespace(), string("b")); - List bShapeIds = b.getShapes().stream() + assertThat(b.document(), not(nullValue())); + assertThat(b.namespace(), string("b")); + List bShapeIds = b.shapes().stream() .map(Shape::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(bShapeIds, hasItems("b#Hello", "b#HelloInput", "b#HelloOutput")); - List bDocumentShapeNames = b.getDocumentShapes().stream() + List bDocumentShapeNames = b.documentShapes().stream() .map(documentShape -> documentShape.shapeName().toString()) .collect(Collectors.toList()); assertThat(bDocumentShapeNames, hasItems("Hello", "name", "String")); @@ -184,18 +184,18 @@ public void loadsProjectWithExternalJars() { assertThat(result.isOk(), is(true)); Project project = result.unwrap(); - assertThat(project.getSources(), containsInAnyOrder(root.resolve("test-traits.smithy"), root.resolve("test-validators.smithy"))); - assertThat(project.getSmithyFiles().keySet(), hasItems( + assertThat(project.sources(), containsInAnyOrder(root.resolve("test-traits.smithy"), root.resolve("test-validators.smithy"))); + assertThat(project.smithyFiles().keySet(), 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.getModelResult().isBroken(), is(true)); - assertThat(project.getModelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); - assertThat(project.getModelResult().getResult().isPresent(), is(true)); - Model model = project.getModelResult().getResult().get(); + 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)); @@ -223,7 +223,7 @@ public void doesntFailLoadingProjectWithNonExistingSource() { Result> result = ProjectLoader.load(root); assertThat(result.isErr(), is(false)); - assertThat(result.unwrap().getSmithyFiles().size(), equalTo(1)); // still have the prelude + assertThat(result.unwrap().smithyFiles().size(), equalTo(1)); // still have the prelude } @@ -248,18 +248,18 @@ public void loadsProjectWithUnNormalizedDirs() { Path root = toPath(getClass().getResource("unnormalized-dirs")); Project project = ProjectLoader.load(root).unwrap(); - assertThat(project.getRoot(), equalTo(root)); - assertThat(project.getSources(), hasItems( + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItems( root.resolve("model"), root.resolve("model2"))); - assertThat(project.getImports(), hasItem(root.resolve("model3"))); - assertThat(project.getSmithyFiles().keySet(), hasItems( + assertThat(project.imports(), hasItem(root.resolve("model3"))); + assertThat(project.smithyFiles().keySet(), 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.getDependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); + assertThat(project.dependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); } @Test @@ -274,7 +274,7 @@ public void changeFileApplyingSimpleTrait() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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))); @@ -284,7 +284,7 @@ public void changeFileApplyingSimpleTrait() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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))); } @@ -301,7 +301,7 @@ public void changeFileApplyingListTrait() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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")); @@ -311,7 +311,7 @@ public void changeFileApplyingListTrait() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); } @@ -332,8 +332,8 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); - Shape baz = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + Shape baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); assertThat(baz.hasTrait("length"), is(true)); @@ -345,8 +345,8 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); - baz = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); assertThat(baz.hasTrait("length"), is(true)); @@ -368,7 +368,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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")); assertThat(bar.hasTrait("length"), is(true)); @@ -380,7 +380,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); assertThat(bar.hasTrait("length"), is(true)); @@ -402,7 +402,7 @@ public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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")); @@ -412,7 +412,7 @@ public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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")); } @@ -429,7 +429,7 @@ public void changingFileWithDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + 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))); @@ -439,7 +439,7 @@ public void changingFileWithDependencies() { project.updateModelWithoutValidating(uri); - foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + 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))); } @@ -456,7 +456,7 @@ public void changingFileWithArrayDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + 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")); @@ -466,7 +466,7 @@ public void changingFileWithArrayDependencies() { project.updateModelWithoutValidating(uri); - foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); } @@ -484,7 +484,7 @@ public void changingFileWithMixedArrayDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + 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")); @@ -494,7 +494,7 @@ public void changingFileWithMixedArrayDependencies() { project.updateModelWithoutValidating(uri); - foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + 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")); } @@ -514,8 +514,8 @@ public void changingFileWithArrayDependenciesWithDependencies() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); assertThat(bar.hasTrait("length"), is(true)); @@ -524,8 +524,8 @@ public void changingFileWithArrayDependenciesWithDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); if (document == null) { - String smithyFilesPaths = String.join(System.lineSeparator(), project.getSmithyFiles().keySet()); - String smithyFilesUris = project.getSmithyFiles().keySet().stream() + String smithyFilesPaths = String.join(System.lineSeparator(), project.smithyFiles().keySet()); + String smithyFilesUris = project.smithyFiles().keySet().stream() .map(UriAdapter::toUri) .collect(Collectors.joining(System.lineSeparator())); Logger logger = Logger.getLogger(getClass().getName()); @@ -538,8 +538,8 @@ public void changingFileWithArrayDependenciesWithDependencies() { project.updateModelWithoutValidating(uri); - foo = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); assertThat(bar.hasTrait("length"), is(true)); @@ -560,7 +560,7 @@ public void removingSimpleApply() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("pattern"), is(true)); assertThat(bar.expectTrait(PatternTrait.class).getPattern().pattern(), equalTo("a")); assertThat(bar.hasTrait("length"), is(true)); @@ -572,7 +572,7 @@ public void removingSimpleApply() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("pattern"), is(true)); assertThat(bar.expectTrait(PatternTrait.class).getPattern().pattern(), equalTo("a")); assertThat(bar.hasTrait("length"), is(false)); @@ -592,7 +592,7 @@ public void removingArrayApply() { TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); - Shape bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + 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")); @@ -602,7 +602,7 @@ public void removingArrayApply() { project.updateModelWithoutValidating(uri); - bar = project.getModelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("bar")); } diff --git a/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java b/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java index 0143c00f..65f90a9c 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java @@ -37,12 +37,12 @@ public void merging() { SmithyBuildExtensions result = builder.mavenDependencies(Arrays.asList("d1", "d2")) .mavenRepositories(Arrays.asList("r1", "r2")).imports(Arrays.asList("i1", "i2")).merge(other).build(); - MavenConfig mavenConfig = result.getMavenConfig(); + MavenConfig mavenConfig = result.mavenConfig(); assertThat(mavenConfig.getDependencies(), containsInAnyOrder("d1", "d2", "hello", "world")); List urls = mavenConfig.getRepositories().stream() .map(MavenRepository::getUrl) .collect(Collectors.toList()); assertThat(urls, containsInAnyOrder("r1", "r2", "hi", "there")); - assertThat(result.getImports(), containsInAnyOrder("i1", "i2", "i3", "i4")); + assertThat(result.imports(), containsInAnyOrder("i1", "i2", "i3", "i4")); } } From 00f772ea6dd56b6765ca9a8d9c73f1bde4b8c232 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Fri, 26 Jul 2024 15:29:34 -0400 Subject: [PATCH 15/15] address rest of comments --- .../smithy/lsp/DocumentLifecycleManager.java | 2 + .../smithy/lsp/SmithyLanguageClient.java | 12 + .../smithy/lsp/SmithyLanguageServer.java | 119 +++++---- .../lsp/codeactions/SmithyCodeActions.java | 6 +- .../lsp/diagnostics/DetachedDiagnostics.java | 46 ---- .../lsp/diagnostics/SmithyDiagnostics.java | 78 ++++++ .../lsp/diagnostics/VersionDiagnostics.java | 90 ------- .../amazon/smithy/lsp/document/Document.java | 4 +- .../smithy/lsp/document/DocumentParser.java | 7 +- .../smithy/lsp/document/DocumentShape.java | 4 + .../smithy/lsp/handler/CompletionHandler.java | 25 +- .../smithy/lsp/handler/DefinitionHandler.java | 14 +- .../FileWatcherRegistrationHandler.java | 4 +- .../smithy/lsp/handler/HoverHandler.java | 22 +- .../amazon/smithy/lsp/package-info.java | 12 + .../amazon/smithy/lsp/project/Project.java | 14 +- .../project/ProjectDependencyResolver.java | 3 + .../smithy/lsp/project/ProjectLoader.java | 16 +- .../smithy/lsp/project/ProjectManager.java | 17 +- .../project/SmithyFileDependenciesIndex.java | 27 +-- .../smithy/lsp/protocol/LocationAdapter.java | 30 --- .../smithy/lsp/protocol/LspAdapter.java | 225 ++++++++++++++++++ .../smithy/lsp/protocol/PositionAdapter.java | 28 --- .../smithy/lsp/protocol/RangeAdapter.java | 178 -------------- .../smithy/lsp/protocol/RangeBuilder.java | 97 ++++++++ .../smithy/lsp/protocol/UriAdapter.java | 112 --------- .../amazon/smithy/lsp/LspMatchers.java | 3 + .../smithy/lsp/SmithyLanguageServerTest.java | 61 ++--- .../amazon/smithy/lsp/SmithyMatchers.java | 3 + .../lsp/SmithyVersionRefactoringTest.java | 22 +- .../amazon/smithy/lsp/TestWorkspace.java | 2 +- .../lsp/document/DocumentParserTest.java | 8 +- .../smithy/lsp/document/DocumentTest.java | 18 +- .../FileWatcherRegistrationHandlerTest.java | 60 +++++ .../lsp/project/ProjectManagerTest.java | 43 ++++ .../smithy/lsp/project/ProjectTest.java | 35 ++- 36 files changed, 771 insertions(+), 676 deletions(-) delete mode 100644 src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java create mode 100644 src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java create mode 100644 src/main/java/software/amazon/smithy/lsp/package-info.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java create mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java create mode 100644 src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java create 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 38126cd9..ba9c33f7 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -39,6 +39,7 @@ void cancelTask(String uri) { CompletableFuture task = tasks.get(uri); if (!task.isDone()) { task.cancel(true); + tasks.remove(uri); } } } @@ -59,6 +60,7 @@ void cancelAllTasks() { for (CompletableFuture task : tasks.values()) { task.cancel(true); } + tasks.clear(); } void waitForAllTasks() throws ExecutionException, InterruptedException { diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java index 7280b582..3f54e013 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java @@ -54,6 +54,18 @@ public void error(String message) { delegate.logMessage(new MessageParams(MessageType.Error, message)); } + /** + * Log a {@link MessageType#Error} message on the client, specifically for + * situations where a file is requested but isn't known to the server. + * + * @param uri LSP URI of the file that was requested. + * @param source Reason for requesting the file. + */ + public void unknownFileError(String uri, String source) { + delegate.logMessage(new MessageParams( + MessageType.Error, "attempted to get file for " + source + " that isn't tracked: " + uri)); + } + @Override public CompletableFuture applyEdit(ApplyWorkspaceEditParams params) { return delegate.applyEdit(params); diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index aa412fad..61b7ff16 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -23,11 +23,13 @@ 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; import java.util.Optional; import java.util.Properties; import java.util.Set; @@ -89,8 +91,7 @@ import org.eclipse.lsp4j.services.TextDocumentService; import org.eclipse.lsp4j.services.WorkspaceService; import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; -import software.amazon.smithy.lsp.diagnostics.DetachedDiagnostics; -import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; +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; @@ -106,10 +107,7 @@ import software.amazon.smithy.lsp.project.ProjectLoader; import software.amazon.smithy.lsp.project.ProjectManager; import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.lsp.protocol.LocationAdapter; -import software.amazon.smithy.lsp.protocol.PositionAdapter; -import software.amazon.smithy.lsp.protocol.RangeAdapter; -import software.amazon.smithy.lsp.protocol.UriAdapter; +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; @@ -176,7 +174,7 @@ public void connect(LanguageClient client) { String message = "smithy-language-server"; try { Properties props = new Properties(); - props.load(SmithyLanguageServer.class.getClassLoader().getResourceAsStream("version.properties")); + props.load(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("version.properties"))); message += " version " + props.getProperty("version"); } catch (IOException e) { this.client.error("Failed to load smithy-language-server version: " + e); @@ -212,7 +210,7 @@ public CompletableFuture initialize(InitializeParams params) { this.minimumSeverity = severity.get(); } else { client.error("Invalid value for 'diagnostics.minimumSeverity': " + configuredMinimumSeverity - + ".\nMust be one of 'NOTE', 'WARNING', 'DANGER', 'ERROR'"); + + ".\nMust be one of " + Arrays.toString(Severity.values())); } } if (jsonObject.has("onlyReloadOnSave")) { @@ -300,7 +298,7 @@ private void resolveDetachedProjects(Project updatedProject) { Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); addedPaths.removeAll(currentProjectSmithyPaths); for (String addedPath : addedPaths) { - String addedUri = UriAdapter.toUri(addedPath); + String addedUri = LspAdapter.toUri(addedPath); if (projects.isDetached(addedUri)) { projects.removeDetachedProject(addedUri); } @@ -309,7 +307,7 @@ private void resolveDetachedProjects(Project updatedProject) { Set removedPaths = new HashSet<>(currentProjectSmithyPaths); removedPaths.removeAll(updatedProjectSmithyPaths); for (String removedPath : removedPaths) { - String removedUri = UriAdapter.toUri(removedPath); + String removedUri = LspAdapter.toUri(removedPath); // Only move to a detached project if the file is managed if (lifecycleManager.managedDocuments().contains(removedUri)) { // Note: This should always be non-null, since we essentially got this from the current project @@ -370,7 +368,7 @@ public CompletableFuture jarFileContents(TextDocumentIdentifier textDocu return completedFuture(document.copyText()); } else { // Technically this can throw if the uri is invalid - return completedFuture(IoUtils.readUtf8Url(UriAdapter.jarUrl(uri))); + return completedFuture(IoUtils.readUtf8Url(LspAdapter.jarUrl(uri))); } } @@ -394,7 +392,7 @@ public CompletableFuture> selectorCommand(SelectorParam .map(selector::select) .map(shapes -> shapes.stream() .map(Shape::getSourceLocation) - .map(LocationAdapter::fromSource) + .map(LspAdapter::toLocation) .collect(Collectors.toList())) .orElse(Collections.emptyList())); } @@ -402,9 +400,9 @@ public CompletableFuture> selectorCommand(SelectorParam @Override public CompletableFuture serverStatus() { OpenProject openProject = new OpenProject( - UriAdapter.toUri(projects.mainProject().root().toString()), + LspAdapter.toUri(projects.mainProject().root().toString()), projects.mainProject().smithyFiles().keySet().stream() - .map(UriAdapter::toUri) + .map(LspAdapter::toUri) .collect(Collectors.toList()), false); @@ -487,7 +485,7 @@ public void didChange(DidChangeTextDocumentParams params) { Document document = projects.getDocument(uri); if (document == null) { - client.error("Attempted to change document the server isn't tracking: " + uri); + client.unknownFileError(uri, "change"); return; } @@ -505,7 +503,7 @@ public void didChange(DidChangeTextDocumentParams params) { // Report any parse/shape/trait loading errors Project project = projects.getProject(uri); if (project == null) { - client.error("Attempted to update a file the server isn't tracking: " + uri); + client.unknownFileError(uri, "change"); return; } CompletableFuture future = CompletableFuture @@ -557,20 +555,19 @@ public void didSave(DidSaveTextDocumentParams params) { 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 + // actually happen in practice + client.unknownFileError(uri, "save"); + return; + } + + Project project = projects.getProject(uri); if (params.getText() != null) { - Project project = projects.getProject(uri); Document document = project.getDocument(uri); - if (document == null) { - // TODO: Could also load a detached project here, but I don't know how this would - // actually happen in practice - client.error("Attempted to save document not tracked by server: " + uri); - return; - } - document.applyEdit(null, params.getText()); } - Project project = projects.getProject(uri); CompletableFuture future = CompletableFuture .runAsync(() -> project.updateAndValidateModel(uri)) .thenCompose(unused -> sendFileDiagnostics(uri)); @@ -580,9 +577,17 @@ public void didSave(DidSaveTextDocumentParams params) { @Override public CompletableFuture, CompletionList>> completion(CompletionParams params) { LOGGER.info("Completion"); - Project project = projects.getProject(params.getTextDocument().getUri()); + + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "completion"); + return completedFuture(Either.forLeft(Collections.emptyList())); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); return CompletableFutures.computeAsync((cc) -> { - CompletionHandler handler = new CompletionHandler(project); + CompletionHandler handler = new CompletionHandler(project, smithyFile); return Either.forLeft(handler.handle(params, cc)); }); } @@ -599,6 +604,11 @@ public CompletableFuture resolveCompletionItem(CompletionItem un documentSymbol(DocumentSymbolParams params) { LOGGER.info("DocumentSymbol"); String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "document symbol"); + return completedFuture(Collections.emptyList()); + } + Project project = projects.getProject(uri); SmithyFile smithyFile = project.getSmithyFile(uri); @@ -655,17 +665,34 @@ public CompletableFuture resolveCompletionItem(CompletionItem un public CompletableFuture, List>> definition(DefinitionParams params) { LOGGER.info("Definition"); - Project project = projects.getProject(params.getTextDocument().getUri()); - List locations = new DefinitionHandler(project).handle(params); + + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "definition"); + return completedFuture(Either.forLeft(Collections.emptyList())); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + List locations = new DefinitionHandler(project, smithyFile).handle(params); return completedFuture(Either.forLeft(locations)); } @Override public CompletableFuture hover(HoverParams params) { LOGGER.info("Hover"); - Project project = projects.getProject(params.getTextDocument().getUri()); + + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "hover"); + return completedFuture(HoverHandler.emptyContents()); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + // TODO: Abstract away passing minimum severity - Hover hover = new HoverHandler(project).handle(params, minimumSeverity); + Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); return completedFuture(hover); } @@ -711,34 +738,33 @@ private CompletableFuture sendFileDiagnostics(String uri) { } List getFileDiagnostics(String uri) { - if (UriAdapter.isJarFile(uri) || UriAdapter.isSmithyJarFile(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(); } - Project project = projects.getProject(uri); - if (project == null) { - client.error("Attempted to get file diagnostics for an untracked file: " + uri); - return Collections.emptyList(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "diagnostics"); } + Project project = projects.getProject(uri); SmithyFile smithyFile = project.getSmithyFile(uri); - String path = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); List diagnostics = project.modelResult().getValidationEvents().stream() .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) - .filter(validationEvent -> !UriAdapter.isJarFile(validationEvent.getSourceLocation().getFilename())) .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) .map(validationEvent -> toDiagnostic(validationEvent, smithyFile)) .collect(Collectors.toCollection(ArrayList::new)); - if (smithyFile != null && VersionDiagnostics.hasVersionDiagnostic(smithyFile)) { - diagnostics.add(VersionDiagnostics.forSmithyFile(smithyFile)); + Diagnostic versionDiagnostic = SmithyDiagnostics.versionDiagnostic(smithyFile); + if (versionDiagnostic != null) { + diagnostics.add(versionDiagnostic); } - if (smithyFile != null && projects.isDetached(uri)) { - diagnostics.add(DetachedDiagnostics.forSmithyFile(smithyFile)); + if (projects.isDetached(uri)) { + diagnostics.add(SmithyDiagnostics.detachedDiagnostic(smithyFile)); } return diagnostics; @@ -749,16 +775,13 @@ private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFi SourceLocation sourceLocation = validationEvent.getSourceLocation(); // TODO: Improve location of diagnostics - Range range = RangeAdapter.lineOffset(PositionAdapter.fromSourceLocation(sourceLocation)); + Range range = LspAdapter.lineOffset(LspAdapter.toPosition(sourceLocation)); if (validationEvent.getShapeId().isPresent() && smithyFile != null) { // Event is (probably) on a member target if (validationEvent.containsId("Target")) { DocumentShape documentShape = smithyFile.documentShapesByStartPosition() - .get(PositionAdapter.fromSourceLocation(sourceLocation)); - boolean hasMemberTarget = documentShape != null - && documentShape.isKind(DocumentShape.Kind.DefinedMember) - && documentShape.targetReference() != null; - if (hasMemberTarget) { + .get(LspAdapter.toPosition(sourceLocation)); + if (documentShape != null && documentShape.hasMemberTarget()) { range = documentShape.targetReference().range(); } } else { diff --git a/src/main/java/software/amazon/smithy/lsp/codeactions/SmithyCodeActions.java b/src/main/java/software/amazon/smithy/lsp/codeactions/SmithyCodeActions.java index 4b370965..3b8197fa 100644 --- a/src/main/java/software/amazon/smithy/lsp/codeactions/SmithyCodeActions.java +++ b/src/main/java/software/amazon/smithy/lsp/codeactions/SmithyCodeActions.java @@ -23,7 +23,7 @@ import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionParams; import org.eclipse.lsp4j.Diagnostic; -import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; +import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; public final class SmithyCodeActions { public static final String SMITHY_UPDATE_VERSION = "smithyUpdateVersion"; @@ -56,12 +56,12 @@ public static List versionCodeActions(CodeActionParams params) { String fileUri = params.getTextDocument().getUri(); boolean defineVersion = params.getContext().getDiagnostics().stream() - .anyMatch(diagnosticCodePredicate(VersionDiagnostics.SMITHY_DEFINE_VERSION)); + .anyMatch(diagnosticCodePredicate(SmithyDiagnostics.DEFINE_VERSION)); if (defineVersion) { actions.add(DefineVersionCodeAction.build(fileUri)); } Optional updateVersionDiagnostic = params.getContext().getDiagnostics().stream() - .filter(diagnosticCodePredicate(VersionDiagnostics.SMITHY_UPDATE_VERSION)).findFirst(); + .filter(diagnosticCodePredicate(SmithyDiagnostics.UPDATE_VERSION)).findFirst(); if (updateVersionDiagnostic.isPresent()) { actions.add( UpdateVersionCodeAction.build(fileUri, updateVersionDiagnostic.get().getRange()) diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java deleted file mode 100644 index 35bab50c..00000000 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/DetachedDiagnostics.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.diagnostics; - -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; -import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.lsp.protocol.RangeAdapter; -import software.amazon.smithy.utils.SmithyInternalApi; - -/** - * Diagnostics for when a Smithy file is not connected to a Smithy project via - * smithy-build.json or other build file. - */ -@SmithyInternalApi -public final class DetachedDiagnostics { - public static final String DETACHED_FILE = "detached-file"; - - private DetachedDiagnostics() { - } - - /** - * @param smithyFile The Smithy file to get a detached diagnostic for - * @return The detached diagnostic associated with the Smithy file, or null - * if one doesn't exist (this occurs if the file doesn't have a document - * associated with it) - */ - public static Diagnostic forSmithyFile(SmithyFile smithyFile) { - if (smithyFile.document() == null) { - return null; - } - int end = smithyFile.document().lineEnd(0); - Range range = RangeAdapter.lineSpan(0, 0, end); - return new Diagnostic( - range, - "This file isn't attached to a project", - DiagnosticSeverity.Warning, - "smithy-language-server", - DETACHED_FILE - ); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java new file mode 100644 index 00000000..2f4452d8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java @@ -0,0 +1,78 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.diagnostics; + +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticCodeDescription; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +/** + * Utility class for creating different kinds of file diagnostics, that aren't + * necessarily connected to model validation events. + */ +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"; + + private static final DiagnosticCodeDescription UPDATE_VERSION_DESCRIPTION = + new DiagnosticCodeDescription("https://smithy.io/2.0/guides/migrating-idl-1-to-2.html"); + + 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 + */ + 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) { + 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) { + 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 Diagnostic createDiagnostic(Range range, String title, String code) { + return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java deleted file mode 100644 index 89599f39..00000000 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.diagnostics; - -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticCodeDescription; -import org.eclipse.lsp4j.DiagnosticSeverity; -import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.document.DocumentVersion; -import software.amazon.smithy.lsp.project.SmithyFile; -import software.amazon.smithy.lsp.protocol.RangeAdapter; -import software.amazon.smithy.utils.SmithyInternalApi; - -/** - * Diagnostics for when a $version control statement hasn't been defined, or when - * it has been defined for IDL 1.0. - */ -@SmithyInternalApi -public final class VersionDiagnostics { - public static final String SMITHY_UPDATE_VERSION = "migrating-idl-1-to-2"; - public static final String SMITHY_DEFINE_VERSION = "define-idl-version"; - - private static final DiagnosticCodeDescription SMITHY_UPDATE_VERSION_CODE_DIAGNOSTIC = - new DiagnosticCodeDescription("https://smithy.io/2.0/guides/migrating-idl-1-to-2.html"); - - private VersionDiagnostics() { - - } - - private static Diagnostic build(String title, String code, Range range) { - return new Diagnostic( - range, - title, - DiagnosticSeverity.Warning, - "Smithy LSP", - code - ); - } - - /** - * @param smithyFile The Smithy file to check for a version diagnostic - * @return Whether the given {@code smithyFile} has a version diagnostic - */ - public static boolean hasVersionDiagnostic(SmithyFile smithyFile) { - return smithyFile.documentVersion() - .map(documentVersion -> documentVersion.version().charAt(0) != '2') - .orElse(true); - } - - /** - * @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 - */ - public static Diagnostic forSmithyFile(SmithyFile smithyFile) { - // TODO: This can be cached - Diagnostic diagnostic = null; - if (smithyFile.documentVersion().isPresent()) { - DocumentVersion documentVersion = smithyFile.documentVersion().get(); - if (!documentVersion.version().toString().startsWith("2")) { - diagnostic = build( - "You can upgrade to version 2.", - SMITHY_UPDATE_VERSION, - documentVersion.range()); - diagnostic.setCodeDescription(SMITHY_UPDATE_VERSION_CODE_DIAGNOSTIC); - } - } else if (smithyFile.document() != null) { - int end = smithyFile.document().lineEnd(0); - Range range = RangeAdapter.lineSpan(0, 0, end); - diagnostic = build( - "You should define a version for your Smithy file.", - SMITHY_DEFINE_VERSION, - range); - } - return diagnostic; - } -} 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 2f69f1ba..8aa90d31 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -10,7 +10,7 @@ import java.util.List; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * In-memory representation of a text document, indexed by line, which can @@ -76,7 +76,7 @@ public void applyEdit(Range range, String text) { * @return The range of the document, from (0, 0) to {@link #end()} */ public Range fullRange() { - return RangeAdapter.offset(end()); + return LspAdapter.offset(end()); } /** 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 01026687..f69d0f19 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -11,8 +11,7 @@ import java.util.Set; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.protocol.PositionAdapter; -import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.ParserUtils; import software.amazon.smithy.model.node.Node; @@ -251,7 +250,7 @@ public DocumentVersion documentVersion() { if (node.isStringNode()) { String version = node.expectStringNode().getValue(); int end = nodeStartCharacter + version.length() + 2; // ? - Range range = RangeAdapter.of(start.getLine(), start.getCharacter(), start.getLine(), end); + Range range = LspAdapter.of(start.getLine(), start.getCharacter(), start.getLine(), end); return new DocumentVersion(range, version); } return null; @@ -281,7 +280,7 @@ public Range traitIdRange(SourceLocation sourceLocation) { skip(); } - return new Range(PositionAdapter.fromSourceLocation(sourceLocation), currentPosition()); + return new Range(LspAdapter.toPosition(sourceLocation), currentPosition()); } /** diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java index d95d32f2..6ea1b4de 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java @@ -55,6 +55,10 @@ public boolean isKind(Kind other) { return this.kind.equals(other); } + public boolean hasMemberTarget() { + return isKind(Kind.DefinedMember) && targetReference() != null; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java index 3f81cdcb..87208401 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java @@ -28,7 +28,7 @@ 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.RangeAdapter; +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; @@ -62,9 +62,11 @@ public final class CompletionHandler { "timestamp", "union", "update", "use", "value", "version"); private final Project project; + private final SmithyFile smithyFile; - public CompletionHandler(Project project) { + public CompletionHandler(Project project, SmithyFile smithyFile) { this.project = project; + this.smithyFile = smithyFile; } /** @@ -72,9 +74,11 @@ public CompletionHandler(Project project) { * @return A list of possible completions */ public List handle(CompletionParams params, CancelChecker cc) { - String uri = params.getTextDocument().getUri(); - SmithyFile smithyFile = project.getSmithyFile(uri); - if (smithyFile == null || cc.isCanceled()) { + // 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(); } @@ -101,11 +105,11 @@ public List handle(CompletionParams params, CancelChecker cc) { return Collections.emptyList(); } - Optional modelResul = project.modelResult().getResult(); - if (!modelResul.isPresent()) { + Optional modelResult = project.modelResult().getResult(); + if (!modelResult.isPresent()) { return Collections.emptyList(); } - Model model = modelResul.get(); + Model model = modelResult.get(); DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document()) .determineContext(position); @@ -188,11 +192,11 @@ private static TextEdit getImportTextEdit(SmithyFile smithyFile, String 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 = RangeAdapter.point(importsRange.getEnd()); + Range editRange = LspAdapter.point(importsRange.getEnd()); return new TextEdit(editRange, insertText); } else if (smithyFile.documentNamespace().isPresent()) { Range namespaceStatementRange = smithyFile.documentNamespace().get().statementRange(); - Range editRange = RangeAdapter.point(namespaceStatementRange.getEnd()); + Range editRange = LspAdapter.point(namespaceStatementRange.getEnd()); return new TextEdit(editRange, insertText); } @@ -309,6 +313,7 @@ public String structureShape(StructureShape shape) { @Override public String timestampShape(TimestampShape shape) { + // TODO: Handle timestampFormat (which could indicate a numeric default) 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 index 6de6cfb9..a3deb370 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java @@ -18,7 +18,7 @@ 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.LocationAdapter; +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; @@ -30,9 +30,11 @@ */ public final class DefinitionHandler { private final Project project; + private final SmithyFile smithyFile; - public DefinitionHandler(Project project) { + public DefinitionHandler(Project project, SmithyFile smithyFile) { this.project = project; + this.smithyFile = smithyFile; } /** @@ -40,12 +42,6 @@ public DefinitionHandler(Project project) { * @return A list of possible definition locations */ public List handle(DefinitionParams params) { - String uri = params.getTextDocument().getUri(); - SmithyFile smithyFile = project.getSmithyFile(uri); - if (smithyFile == null) { - return Collections.emptyList(); - } - Position position = params.getPosition(); DocumentId id = smithyFile.document().copyDocumentId(position); if (id == null || id.borrowIdValue().length() == 0) { @@ -64,7 +60,7 @@ public List handle(DefinitionParams params) { .filter(contextualMatcher(smithyFile, id)) .findFirst() .map(Shape::getSourceLocation) - .map(LocationAdapter::fromSource) + .map(LspAdapter::toLocation) .map(Collections::singletonList) .orElse(Collections.emptyList()); } diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java index 7da74035..08c61ff0 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java @@ -101,9 +101,9 @@ private static FileSystemWatcher smithyFileWatcher(Path path) { if (!glob.endsWith(".smithy") && !glob.endsWith(".json")) { // we have a directory if (glob.endsWith("/")) { - glob = glob + "**"; + glob = glob + "**/*.{smithy,json}"; } else { - glob = glob + "/**"; + glob = glob + "/**/*.{smithy,json}"; } } // Watch the absolute path, either a directory or file diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java index 06c92a1d..fdf4d06d 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java @@ -39,9 +39,20 @@ */ public final class HoverHandler { private final Project project; + private final SmithyFile smithyFile; - public HoverHandler(Project project) { + 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; } /** @@ -50,14 +61,7 @@ public HoverHandler(Project project) { * @return The hover content */ public Hover handle(HoverParams params, Severity minimumSeverity) { - Hover hover = new Hover(); - hover.setContents(new MarkupContent("markdown", "")); - String uri = params.getTextDocument().getUri(); - SmithyFile smithyFile = project.getSmithyFile(uri); - if (smithyFile == null) { - return hover; - } - + Hover hover = emptyContents(); Position position = params.getPosition(); DocumentId id = smithyFile.document().copyDocumentId(position); if (id == null || id.borrowIdValue().length() == 0) { diff --git a/src/main/java/software/amazon/smithy/lsp/package-info.java b/src/main/java/software/amazon/smithy/lsp/package-info.java new file mode 100644 index 00000000..bdee3e45 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/package-info.java @@ -0,0 +1,12 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The Smithy Language Server. + */ +@SmithyInternalApi +package software.amazon.smithy.lsp; + +import software.amazon.smithy.utils.SmithyInternalApi; 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 b705ea5c..bd65d284 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -18,7 +18,7 @@ import java.util.logging.Logger; import java.util.stream.Collectors; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.UriAdapter; +import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.ModelAssembler; @@ -128,7 +128,7 @@ public ValidatedResult modelResult() { * it exists in this project, otherwise {@code null} */ public Document getDocument(String uri) { - String path = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); SmithyFile smithyFile = smithyFiles.get(path); if (smithyFile == null) { return null; @@ -142,7 +142,7 @@ public Document getDocument(String uri) { * it exists in this project, otherwise {@code null} */ public SmithyFile getSmithyFile(String uri) { - String path = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); return smithyFiles.get(path); } @@ -214,7 +214,7 @@ public void updateFiles(Set addUris, Set removeUris, Set Model.Builder builder = prepBuilderForReload(currentModel); for (String uri : removeUris) { - String path = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); removedPaths.add(path); removeFileForReload(assembler, builder, path, visited); @@ -226,7 +226,7 @@ public void updateFiles(Set addUris, Set removeUris, Set } for (String uri : changeUris) { - String path = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); changedPaths.add(path); removeFileForReload(assembler, builder, path, visited); @@ -250,7 +250,7 @@ public void updateFiles(Set addUris, Set removeUris, Set } for (String uri : addUris) { - assembler.addImport(UriAdapter.toPath(uri)); + assembler.addImport(LspAdapter.toPath(uri)); } if (!validate) { @@ -277,7 +277,7 @@ public void updateFiles(Set addUris, Set removeUris, Set } for (String uri : addUris) { - String path = UriAdapter.toPath(uri); + 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) diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java index e73336f8..eca2ecd8 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java @@ -30,6 +30,9 @@ *

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 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 708664b4..b7f23eed 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -32,7 +32,7 @@ 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.UriAdapter; +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; @@ -67,7 +67,7 @@ private ProjectLoader() { */ public static Project loadDetached(String uri, String text) { LOGGER.info("Loading detached project at " + uri); - String asPath = UriAdapter.toPath(uri); + String asPath = LspAdapter.toPath(uri); ValidatedResult modelResult = Model.assembler() .addUnparsedModel(asPath, text) .assemble(); @@ -85,8 +85,8 @@ public static Project loadDetached(String uri, String text) { Map smithyFiles = computeSmithyFiles(sources, modelResult, (filePath) -> { // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but // the model stores jar paths as URIs - if (UriAdapter.isSmithyJarFile(filePath) || UriAdapter.isJarFile(filePath)) { - return Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(filePath))); + 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 { @@ -156,7 +156,7 @@ public static Result> load( for (Path path : allSmithyFilePaths) { if (!managedDocuments.isEmpty()) { String pathString = path.toString(); - String uri = UriAdapter.toUri(pathString); + String uri = LspAdapter.toUri(pathString); if (managedDocuments.contains(uri)) { assembler.addUnparsedModel(pathString, projects.getDocument(uri).copyText()); } else { @@ -190,13 +190,13 @@ public static Result> load( Map smithyFiles = computeSmithyFiles(allSmithyFilePaths, modelResult, (filePath) -> { // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but // the model stores jar paths as URIs - if (UriAdapter.isSmithyJarFile(filePath) || UriAdapter.isJarFile(filePath)) { + if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { // Technically this can throw - return Document.of(IoUtils.readUtf8Url(UriAdapter.jarUrl(filePath))); + 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 = UriAdapter.toUri(filePath); + String uri = LspAdapter.toUri(filePath); if (managedDocuments.contains(uri)) { return projects.getDocument(uri); } 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 c719d0b1..07cfb337 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java @@ -9,7 +9,7 @@ import java.util.Map; import org.eclipse.lsp4j.InitializeParams; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.UriAdapter; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * Manages open projects tracked by the server. @@ -54,7 +54,7 @@ public Map detachedProjects() { * @return The project the given {@code uri} belongs to */ public Project getProject(String uri) { - String path = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); if (isDetached(uri)) { return detached.get(uri); } else if (mainProject.smithyFiles().containsKey(path)) { @@ -67,6 +67,17 @@ public Project getProject(String uri) { } } + /** + * 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 @@ -76,7 +87,7 @@ public boolean isDetached(String uri) { // 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 = UriAdapter.toPath(uri); + String path = LspAdapter.toPath(uri); if (mainProject.smithyFiles().containsKey(path) && detached.containsKey(uri)) { removeDetachedProject(uri); } diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java index 818beb9d..f9c939eb 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java @@ -25,20 +25,19 @@ * shapes, and traits. * *

This is specifically for the following scenarios: - *

    - *
  1. 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.
  2. - *
  3. 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.
  4. - *
  5. Either 1 or 2, but specifically with list traits, which 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. - *
  6. - *
+ *
+ *
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; diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java deleted file mode 100644 index 38658fd5..00000000 --- a/src/main/java/software/amazon/smithy/lsp/protocol/LocationAdapter.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.protocol; - -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.Position; -import software.amazon.smithy.model.SourceLocation; - -/** - * Utility methods for working with LSP's {@link Location}. - */ -public final class LocationAdapter { - private LocationAdapter() { - } - - /** - * 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 - * @return The equivalent Location - */ - public static Location fromSource(SourceLocation sourceLocation) { - return new Location(UriAdapter.toUri(sourceLocation.getFilename()), RangeAdapter.point( - new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java new file mode 100644 index 00000000..51e4ce85 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java @@ -0,0 +1,225 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.logging.Logger; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.model.SourceLocation; + +/** + * Utility methods for converting to and from LSP types {@link Range}, {@link Position}, + * {@link Location} and URI (which is just a string). + * TODO: Using a string internally for URI is pretty brittle. We could wrap it in a custom + * class, or try to use the {@link URI}, which has its own issues because of the + * 'smithyjar:' scheme we use. + */ +public final class LspAdapter { + private static final Logger LOGGER = Logger.getLogger(LspAdapter.class.getName()); + + private LspAdapter() { + } + + /** + * @return Range of (0, 0) - (0, 0) + */ + public static Range origin() { + return new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(0) + .build(); + } + + /** + * @param point Position to create a point range of + * @return Range of (point) - (point) + */ + public static Range point(Position point) { + return new Range(point, point); + } + + /** + * @param line Line of the point + * @param character Character offset on the line + * @return Range of (line, character) - (line, character) + */ + public static Range point(int line, int character) { + return point(new Position(line, character)); + } + + /** + * @param line Line the span is on + * @param startCharacter Start character of the span + * @param endCharacter End character of the span + * @return Range of (line, startCharacter) - (line, endCharacter) + */ + public static Range lineSpan(int line, int startCharacter, int endCharacter) { + return of(line, startCharacter, line, endCharacter); + } + + /** + * @param offset Offset from (0, 0) + * @return Range of (0, 0) - (offset) + */ + public static Range offset(Position offset) { + return new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(offset.getLine()) + .endCharacter(offset.getCharacter()) + .build(); + } + + /** + * @param offset Offset from (offset.line, 0) + * @return Range of (offset.line, 0) - (offset) + */ + public static Range lineOffset(Position offset) { + return new RangeBuilder() + .startLine(offset.getLine()) + .startCharacter(0) + .endLine(offset.getLine()) + .endCharacter(offset.getCharacter()) + .build(); + } + + /** + * @param startLine Range start line + * @param startCharacter Range start character + * @param endLine Range end line + * @param endCharacter Range end character + * @return Range of (startLine, startCharacter) - (endLine, endCharacter) + */ + public static Range of(int startLine, int startCharacter, int endLine, int endCharacter) { + return new RangeBuilder() + .startLine(startLine) + .startCharacter(startCharacter) + .endLine(endLine) + .endCharacter(endCharacter) + .build(); + } + + /** + * Get a {@link Position} from a {@link SourceLocation}, making the line/columns + * 0-indexed. + * + * @param sourceLocation The source location to get the position of + * @return The position + */ + public static Position toPosition(SourceLocation sourceLocation) { + return new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1); + } + + /** + * 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 + * @return The equivalent Location + */ + public static Location toLocation(SourceLocation sourceLocation) { + return new Location(toUri(sourceLocation.getFilename()), point( + new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); + } + + /** + * @param uri LSP URI to convert to a path + * @return A path representation of the {@code uri}, with the scheme removed + */ + public static String toPath(String uri) { + if (uri.startsWith("file://")) { + return Paths.get(URI.create(uri)).toString(); + } else if (isSmithyJarFile(uri)) { + String decoded = decode(uri); + return fixJarScheme(decoded); + } + return uri; + } + + /** + * @param path Path to convert to LSP URI + * @return A URI representation of the given {@code path}, modified to have the + * correct scheme for our jars + */ + public static String toUri(String path) { + if (path.startsWith("jar:file")) { + return path.replaceFirst("jar:file", "smithyjar"); + } else if (path.startsWith("smithyjar:")) { + return path; + } else { + return Paths.get(path).toUri().toString(); + } + } + + /** + * Checks if a given LSP URI is a file in a Smithy jar, which is a Smithy + * Language Server specific file scheme (smithyjar:) used for providing + * contents of Smithy files within Jars. + * + * @param uri LSP URI to check + * @return Returns whether the uri points to a smithy file in a jar + */ + public static boolean isSmithyJarFile(String uri) { + return uri.startsWith("smithyjar:"); + } + + /** + * @param uri LSP URI to check + * @return Returns whether the uri points to a file in jar + */ + public static boolean isJarFile(String uri) { + return uri.startsWith("jar:"); + } + + /** + * Get a {@link URL} for the Jar represented by the given URI or path. + * + * @param uriOrPath LSP URI or regular path + * @return The {@link URL}, or throw if the uri/path cannot be decoded + */ + public static URL jarUrl(String uriOrPath) { + try { + String decodedUri = decode(uriOrPath); + return URI.create(fixJarScheme(decodedUri)).toURL(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String decode(String uriOrPath) { + try { + // Some clients encode parts of the jar, like !/ + return URLDecoder.decode(uriOrPath, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + LOGGER.severe("Failed to decode " + uriOrPath + " : " + e.getMessage()); + return uriOrPath; + } + } + + private static String fixJarScheme(String uriOrPath) { + if (uriOrPath.startsWith("smithyjar:")) { + uriOrPath = uriOrPath.replaceFirst("smithyjar:", ""); + } + if (uriOrPath.startsWith("jar:")) { + return uriOrPath; + } else if (uriOrPath.startsWith("file:")) { + return "jar:" + uriOrPath; + } else { + return "jar:file:" + uriOrPath; + } + } + +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java deleted file mode 100644 index 41b0e354..00000000 --- a/src/main/java/software/amazon/smithy/lsp/protocol/PositionAdapter.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.protocol; - -import org.eclipse.lsp4j.Position; -import software.amazon.smithy.model.SourceLocation; - -/** - * Utility methods for working with LSP's {@link Position}. - */ -public final class PositionAdapter { - private PositionAdapter() { - } - - /** - * Get a {@link Position} from a {@link SourceLocation}, making the line/columns - * 0-indexed. - * - * @param sourceLocation The source location to get the position of - * @return The position - */ - public static Position fromSourceLocation(SourceLocation sourceLocation) { - return new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java deleted file mode 100644 index 34da5348..00000000 --- a/src/main/java/software/amazon/smithy/lsp/protocol/RangeAdapter.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.protocol; - -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; - -/** - * Builder and utility methods for working with LSP's {@link Range}. - */ -public final class RangeAdapter { - private int startLine; - private int startCharacter; - private int endLine; - private int endCharacter; - - /** - * @return Range of (0, 0) - (0, 0) - */ - public static Range origin() { - return new RangeAdapter() - .startLine(0) - .startCharacter(0) - .endLine(0) - .endCharacter(0) - .build(); - } - - /** - * @param point Position to create a point range of - * @return Range of (point) - (point) - */ - public static Range point(Position point) { - return new Range(point, point); - } - - /** - * @param line Line of the point - * @param character Character offset on the line - * @return Range of (line, character) - (line, character) - */ - public static Range point(int line, int character) { - return point(new Position(line, character)); - } - - /** - * @param line Line the span is on - * @param startCharacter Start character of the span - * @param endCharacter End character of the span - * @return Range of (line, startCharacter) - (line, endCharacter) - */ - public static Range lineSpan(int line, int startCharacter, int endCharacter) { - return of(line, startCharacter, line, endCharacter); - } - - /** - * @param offset Offset from (0, 0) - * @return Range of (0, 0) - (offset) - */ - public static Range offset(Position offset) { - return new RangeAdapter() - .startLine(0) - .startCharacter(0) - .endLine(offset.getLine()) - .endCharacter(offset.getCharacter()) - .build(); - } - - /** - * @param offset Offset from (offset.line, 0) - * @return Range of (offset.line, 0) - (offset) - */ - public static Range lineOffset(Position offset) { - return new RangeAdapter() - .startLine(offset.getLine()) - .startCharacter(0) - .endLine(offset.getLine()) - .endCharacter(offset.getCharacter()) - .build(); - } - - /** - * @param startLine Range start line - * @param startCharacter Range start character - * @param endLine Range end line - * @param endCharacter Range end character - * @return Range of (startLine, startCharacter) - (endLine, endCharacter) - */ - public static Range of(int startLine, int startCharacter, int endLine, int endCharacter) { - return new RangeAdapter() - .startLine(startLine) - .startCharacter(startCharacter) - .endLine(endLine) - .endCharacter(endCharacter) - .build(); - } - - /** - * @return This range adapter, with the start/end characters incremented by one - */ - public RangeAdapter shiftRight() { - return this.shiftRight(1); - } - - /** - * @param offset Offset to shift - * @return This range adapter, with the start/end characters incremented by {@code offset} - */ - public RangeAdapter shiftRight(int offset) { - this.startCharacter += offset; - this.endCharacter += offset; - - return this; - } - - /** - * @return This range adapter, with start/end lines incremented by one, and the start/end - * characters span shifted to begin at 0 - */ - public RangeAdapter shiftNewLine() { - this.startLine = this.startLine + 1; - this.endLine = this.endLine + 1; - - int charDiff = this.endCharacter - this.startCharacter; - this.startCharacter = 0; - this.endCharacter = charDiff; - - return this; - } - - /** - * @param startLine The start line for the range - * @return The updated range adapter - */ - public RangeAdapter startLine(int startLine) { - this.startLine = startLine; - return this; - } - - /** - * @param startCharacter The start character for the range - * @return The updated range adapter - */ - public RangeAdapter startCharacter(int startCharacter) { - this.startCharacter = startCharacter; - return this; - } - - /** - * @param endLine The end line for the range - * @return The updated range adapter - */ - public RangeAdapter endLine(int endLine) { - this.endLine = endLine; - return this; - } - - /** - * @param endCharacter The end character for the range - * @return The updated range adapter - */ - public RangeAdapter endCharacter(int endCharacter) { - this.endCharacter = endCharacter; - return this; - } - - /** - * @return The built Range - */ - public Range build() { - return new Range( - new Position(startLine, startCharacter), - new Position(endLine, endCharacter)); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java b/src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java new file mode 100644 index 00000000..0dfb8a47 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +/** + * Builder for constructing LSP's {@link Range}. + */ +public final class RangeBuilder { + private int startLine; + private int startCharacter; + private int endLine; + private int endCharacter; + + /** + * @return This range adapter, with the start/end characters incremented by one + */ + public RangeBuilder shiftRight() { + return this.shiftRight(1); + } + + /** + * @param offset Offset to shift + * @return This range adapter, with the start/end characters incremented by {@code offset} + */ + public RangeBuilder shiftRight(int offset) { + this.startCharacter += offset; + this.endCharacter += offset; + + return this; + } + + /** + * @return This range adapter, with start/end lines incremented by one, and the start/end + * characters span shifted to begin at 0 + */ + public RangeBuilder shiftNewLine() { + this.startLine = this.startLine + 1; + this.endLine = this.endLine + 1; + + int charDiff = this.endCharacter - this.startCharacter; + this.startCharacter = 0; + this.endCharacter = charDiff; + + return this; + } + + /** + * @param startLine The start line for the range + * @return The updated range adapter + */ + public RangeBuilder startLine(int startLine) { + this.startLine = startLine; + return this; + } + + /** + * @param startCharacter The start character for the range + * @return The updated range adapter + */ + public RangeBuilder startCharacter(int startCharacter) { + this.startCharacter = startCharacter; + return this; + } + + /** + * @param endLine The end line for the range + * @return The updated range adapter + */ + public RangeBuilder endLine(int endLine) { + this.endLine = endLine; + return this; + } + + /** + * @param endCharacter The end character for the range + * @return The updated range adapter + */ + public RangeBuilder endCharacter(int endCharacter) { + this.endCharacter = endCharacter; + return this; + } + + /** + * @return The built Range + */ + public Range build() { + return new Range( + new Position(startLine, startCharacter), + new Position(endLine, endCharacter)); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java deleted file mode 100644 index 2b49e821..00000000 --- a/src/main/java/software/amazon/smithy/lsp/protocol/UriAdapter.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.protocol; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URL; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.nio.file.Paths; -import java.util.logging.Logger; - -/** - * Utility methods for working with LSP's URI (which is just a string). - */ -public final class UriAdapter { - private static final Logger LOGGER = Logger.getLogger(UriAdapter.class.getName()); - - private UriAdapter() { - } - - /** - * @param uri LSP URI to convert to a path - * @return A path representation of the {@code uri}, with the scheme removed - */ - public static String toPath(String uri) { - if (uri.startsWith("file://")) { - return Paths.get(URI.create(uri)).toString(); - } else if (isSmithyJarFile(uri)) { - String decoded = decode(uri); - return fixJarScheme(decoded); - } - return uri; - } - - /** - * @param path Path to convert to LSP URI - * @return A URI representation of the given {@code path}, modified to have the - * correct scheme for our jars - */ - public static String toUri(String path) { - if (path.startsWith("jar:file")) { - return path.replaceFirst("jar:file", "smithyjar"); - } else if (path.startsWith("smithyjar:")) { - return path; - } else { - return Paths.get(path).toUri().toString(); - } - } - - /** - * Checks if a given LSP URI is a file in a Smithy jar, which is a Smithy - * Language Server specific file scheme (smithyjar:) used for providing - * contents of Smithy files within Jars. - * - * @param uri LSP URI to check - * @return Returns whether the uri points to a smithy file in a jar - */ - public static boolean isSmithyJarFile(String uri) { - return uri.startsWith("smithyjar:"); - } - - /** - * @param uri LSP URI to check - * @return Returns whether the uri points to a file in jar - */ - public static boolean isJarFile(String uri) { - return uri.startsWith("jar:"); - } - - /** - * Get a {@link URL} for the Jar represented by the given URI or path. - * - * @param uriOrPath LSP URI or regular path - * @return The {@link URL}, or throw if the uri/path cannot be decoded - */ - public static URL jarUrl(String uriOrPath) { - try { - String decodedUri = decode(uriOrPath); - return URI.create(fixJarScheme(decodedUri)).toURL(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static String decode(String uriOrPath) { - try { - // Some clients encode parts of the jar, like !/ - return URLDecoder.decode(uriOrPath, StandardCharsets.UTF_8.name()); - } catch (UnsupportedEncodingException e) { - LOGGER.severe("Failed to decode " + uriOrPath + " : " + e.getMessage()); - return uriOrPath; - } - } - - private static String fixJarScheme(String uriOrPath) { - if (uriOrPath.startsWith("smithyjar:")) { - uriOrPath = uriOrPath.replaceFirst("smithyjar:", ""); - } - if (uriOrPath.startsWith("jar:")) { - return uriOrPath; - } else if (uriOrPath.startsWith("file:")) { - return "jar:" + uriOrPath; - } else { - return "jar:file:" + uriOrPath; - } - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index 7c508e1a..be51d9c5 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -14,6 +14,9 @@ import org.hamcrest.Matcher; import software.amazon.smithy.lsp.document.Document; +/** + * Hamcrest matchers for LSP4J types. + */ public final class LspMatchers { private LspMatchers() {} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 55848f9f..4ec04ab1 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -64,7 +64,8 @@ import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.protocol.RangeBuilder; import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.shapes.Shape; @@ -155,7 +156,7 @@ public void completionImports() throws Exception { DidChangeTextDocumentParams changeParams = new RequestBuilders.DidChange() .uri(uri) .version(2) - .range(new RangeAdapter() + .range(new RangeBuilder() .startLine(3) .startCharacter(15) .endLine(3) @@ -411,7 +412,7 @@ public void didChange() throws Exception { .build(); server.didOpen(openParams); - RangeAdapter rangeAdapter = new RangeAdapter() + RangeBuilder rangeBuilder = new RangeBuilder() .startLine(7) .startCharacter(18) .endLine(7) @@ -419,16 +420,16 @@ public void didChange() throws Exception { RequestBuilders.DidChange changeBuilder = new RequestBuilders.DidChange().uri(uri); // Add new line and leading spaces - server.didChange(changeBuilder.range(rangeAdapter.build()).text(safeString("\n ")).build()); + server.didChange(changeBuilder.range(rangeBuilder.build()).text(safeString("\n ")).build()); // add 'input: G' - server.didChange(changeBuilder.range(rangeAdapter.shiftNewLine().shiftRight(4).build()).text("i").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("n").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("p").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("u").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("t").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text(":").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text(" ").build()); - server.didChange(changeBuilder.range(rangeAdapter.shiftRight().build()).text("G").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftNewLine().shiftRight(4).build()).text("i").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("n").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("p").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("u").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("t").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(":").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(" ").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("G").build()); server.getLifecycleManager().waitForAllTasks(); @@ -447,7 +448,7 @@ public void didChange() throws Exception { // input: G CompletionParams completionParams = new RequestBuilders.PositionRequest() .uri(uri) - .position(rangeAdapter.shiftRight().build().getStart()) + .position(rangeBuilder.shiftRight().build().getStart()) .buildCompletion(); List completions = server.completion(completionParams).get().getLeft(); @@ -475,7 +476,7 @@ public void didChangeReloadsModel() throws Exception { DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() .uri(uri) .text("@http(method:\"\", uri: \"\")\n") - .range(RangeAdapter.point(3, 0)) + .range(LspAdapter.point(3, 0)) .build(); server.didChange(didChangeParams); @@ -518,7 +519,7 @@ public void didChangeThenDefinition() throws Exception { assertThat(initialLocation.getUri(), equalTo(uri)); assertThat(initialLocation.getRange().getStart(), equalTo(new Position(7, 0))); - RangeAdapter range = new RangeAdapter() + RangeBuilder range = new RangeBuilder() .startLine(5) .startCharacter(1) .endLine(5) @@ -618,7 +619,7 @@ public void newShapeMixinCompletion() throws Exception { .text(model) .build()); - RangeAdapter range = new RangeAdapter() + RangeBuilder range = new RangeBuilder() .startLine(6) .startCharacter(0) .endLine(6) @@ -687,7 +688,7 @@ public void existingShapeMixinCompletion() throws Exception { .text(model) .build()); - RangeAdapter range = new RangeAdapter() + RangeBuilder range = new RangeBuilder() .startLine(6) .startCharacter(13) .endLine(6) @@ -882,7 +883,7 @@ public void addingWatchedFile() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.origin()) + .range(LspAdapter.origin()) .text("$") .build()); @@ -1078,7 +1079,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.lineSpan(8, 0, 0)) + .range(LspAdapter.lineSpan(8, 0, 0)) .text(safeString("\nstring Baz\n")) .build()); server.didSave(RequestBuilders.didSave() @@ -1095,7 +1096,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.of(2, 0, 3, 0)) // removing the first 'foo' metadata + .range(LspAdapter.of(2, 0, 3, 0)) // removing the first 'foo' metadata .text("") .build()); @@ -1181,7 +1182,7 @@ public void addingOpenedDetachedFile() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.point(3, 0)) + .range(LspAdapter.point(3, 0)) .text(safeString("string Bar\n")) .build()); @@ -1222,7 +1223,7 @@ public void detachingOpenedFile() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.point(3, 0)) + .range(LspAdapter.point(3, 0)) .text(safeString("string Bar\n")) .build()); @@ -1385,7 +1386,7 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.origin()) + .range(LspAdapter.origin()) .text(safeString("$version: \"2\"\nnamespace com.foo\n")) .build()); @@ -1435,17 +1436,17 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) .text(safeString("$version: \"2\"\n")) - .range(RangeAdapter.origin()) + .range(LspAdapter.origin()) .build()); server.didChange(RequestBuilders.didChange() .uri(uri) .text(safeString("namespace com.foo\n")) - .range(RangeAdapter.point(1, 0)) + .range(LspAdapter.point(1, 0)) .build()); server.didChange(RequestBuilders.didChange() .uri(uri) .text(safeString("string Foo\n")) - .range(RangeAdapter.point(2, 0)) + .range(LspAdapter.point(2, 0)) .build()); server.getLifecycleManager().waitForAllTasks(); @@ -1476,7 +1477,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri2) - .range(RangeAdapter.of(3, 23, 3, 24)) + .range(LspAdapter.of(3, 23, 3, 24)) .text("2") .build()); @@ -1495,7 +1496,7 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri1) - .range(RangeAdapter.point(3, 0)) + .range(LspAdapter.point(3, 0)) .text(safeString("string Another\n")) .build()); @@ -1546,7 +1547,7 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { server.didChange(RequestBuilders.didChange() .uri(uri) .text(safeString("$version: \"2\"\nnamespace com.foo\nstring Foo\n")) - .range(RangeAdapter.origin()) + .range(LspAdapter.origin()) .build()); server.getLifecycleManager().waitForAllTasks(); @@ -1644,7 +1645,7 @@ public void useCompletionDoesntAutoImport() throws Exception { .build()); server.didChange(RequestBuilders.didChange() .uri(uri) - .range(RangeAdapter.point(2, 0)) + .range(LspAdapter.point(2, 0)) .text("use co") .build()); diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java index 72f05d03..6145b423 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -17,6 +17,9 @@ import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.model.validation.ValidationEvent; +/** + * Hamcrest matchers for Smithy library types. + */ public final class SmithyMatchers { private SmithyMatchers() {} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index 11d68cb5..5349b874 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -36,9 +36,9 @@ import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; +import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * This test suite test the generation of the correct {@link CodeAction} given {@link CodeActionParams} @@ -62,11 +62,11 @@ public void noVersionDiagnostic() throws Exception { .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) .collect(Collectors.toList()); - assertThat(codes, hasItem(VersionDiagnostics.SMITHY_DEFINE_VERSION)); + assertThat(codes, hasItem(SmithyDiagnostics.DEFINE_VERSION)); List defineVersionDiagnostics = diagnostics.stream() .filter(d -> d.getCode().isLeft()) - .filter(d -> d.getCode().getLeft().equals(VersionDiagnostics.SMITHY_DEFINE_VERSION)) + .filter(d -> d.getCode().getLeft().equals(SmithyDiagnostics.DEFINE_VERSION)) .collect(Collectors.toList()); assertThat(defineVersionDiagnostics, hasSize(1)); @@ -77,7 +77,7 @@ public void noVersionDiagnostic() throws Exception { context.setTriggerKind(CodeActionTriggerKind.Automatic); CodeActionParams codeActionParams = new CodeActionParams( new TextDocumentIdentifier(uri), - RangeAdapter.point(0, 3), + LspAdapter.point(0, 3), context); List> response = server.codeAction(codeActionParams).get(); assertThat(response, hasSize(1)); @@ -111,11 +111,11 @@ public void oldVersionDiagnostic() throws Exception { .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) .collect(Collectors.toList()); - assertThat(codes, hasItem(VersionDiagnostics.SMITHY_UPDATE_VERSION)); + assertThat(codes, hasItem(SmithyDiagnostics.UPDATE_VERSION)); List updateVersionDiagnostics = diagnostics.stream() .filter(d -> d.getCode().isLeft()) - .filter(d -> d.getCode().getLeft().equals(VersionDiagnostics.SMITHY_UPDATE_VERSION)) + .filter(d -> d.getCode().getLeft().equals(SmithyDiagnostics.UPDATE_VERSION)) .collect(Collectors.toList()); assertThat(updateVersionDiagnostics, hasSize(1)); @@ -126,7 +126,7 @@ public void oldVersionDiagnostic() throws Exception { context.setTriggerKind(CodeActionTriggerKind.Automatic); CodeActionParams codeActionParams = new CodeActionParams( new TextDocumentIdentifier(uri), - RangeAdapter.point(0, 3), + LspAdapter.point(0, 3), context); List> response = server.codeAction(codeActionParams).get(); assertThat(response, hasSize(1)); @@ -157,8 +157,8 @@ public void mostRecentVersion() { List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) - .filter(c -> c.equals(VersionDiagnostics.SMITHY_DEFINE_VERSION) - || c.equals(VersionDiagnostics.SMITHY_UPDATE_VERSION)) + .filter(c -> c.equals(SmithyDiagnostics.DEFINE_VERSION) + || c.equals(SmithyDiagnostics.UPDATE_VERSION)) .collect(Collectors.toList()); assertThat(codes, hasSize(0)); } @@ -177,6 +177,6 @@ public void noShapes() { .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) .collect(Collectors.toList()); - assertThat(codes, containsInAnyOrder(VersionDiagnostics.SMITHY_DEFINE_VERSION)); + assertThat(codes, containsInAnyOrder(SmithyDiagnostics.DEFINE_VERSION)); } } diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index 4ce11c70..3dc37675 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -21,7 +21,7 @@ /** * Sets up a temporary directory containing a Smithy project */ -public class TestWorkspace { +public final class TestWorkspace { private static final NodeMapper MAPPER = new NodeMapper(); private final Path root; private SmithyBuildConfig config; 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 2bda15b2..cdd1c44e 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -21,7 +21,7 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.protocol.RangeAdapter; +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; @@ -133,14 +133,14 @@ public void getsDocumentNamespace() { assertThat(likeNamespace.documentNamespace(), nullValue()); assertThat(otherLikeNamespace.documentNamespace(), nullValue()); assertThat(namespaceAtEnd.documentNamespace().namespace().toString(), equalTo("com.foo")); - assertThat(namespaceAtEnd.documentNamespace().statementRange(), equalTo(RangeAdapter.of(2, 0, 2, 17))); + 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(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(RangeAdapter.of(1, 4, 1, 21))); + 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(RangeAdapter.of(0, 0, 0, 22))); + assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 22))); } @Test 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 9a22c290..b2da248e 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -16,7 +16,7 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.protocol.RangeAdapter; +import software.amazon.smithy.lsp.protocol.RangeBuilder; public class DocumentTest { @Test @@ -25,7 +25,7 @@ public void appliesTrailingReplacementEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(1) .startCharacter(2) .endLine(1) @@ -47,7 +47,7 @@ public void appliesAppendingEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(1) .startCharacter(3) .endLine(1) @@ -69,7 +69,7 @@ public void appliesLeadingReplacementEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(0) .startCharacter(0) .endLine(0) @@ -91,7 +91,7 @@ public void appliesPrependingEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(0) .startCharacter(0) .endLine(0) @@ -113,7 +113,7 @@ public void appliesInnerReplacementEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(0) .startCharacter(1) .endLine(1) @@ -135,7 +135,7 @@ public void appliesPrependingAndReplacingEdit() { String s = "abc"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(0) .startCharacter(0) .endLine(0) @@ -155,7 +155,7 @@ public void appliesInsertionEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(0) .startCharacter(2) .endLine(0) @@ -180,7 +180,7 @@ public void appliesDeletionEdit() { "def"; Document document = makeDocument(s); - Range editRange = new RangeAdapter() + Range editRange = new RangeBuilder() .startLine(0) .startCharacter(0) .endLine(0) diff --git a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java new file mode 100644 index 00000000..2cdf44ee --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.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.handler; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.endsWith; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; +import org.eclipse.lsp4j.Registration; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.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 { + @Test + public void createsCorrectRegistrations() { + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(new TestWorkspace.Dir() + .path("foo") + .withSourceDir(new TestWorkspace.Dir() + .path("bar") + .withSourceFile("bar.smithy", "") + .withSourceFile("baz.smithy", "")) + .withSourceFile("baz.smithy", "")) + .withSourceDir(new TestWorkspace.Dir() + .path("other") + .withSourceFile("other.smithy", "")) + .withSourceFile("abc.smithy", "") + .withConfig(SmithyBuildConfig.builder() + .version("1") + .sources(ListUtils.of("foo", "other/", "abc.smithy")) + .build()) + .build(); + + Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + List watcherPatterns = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project) + .stream() + .map(Registration::getRegisterOptions) + .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) + .flatMap(options -> options.getWatchers().stream()) + .map(watcher -> watcher.getGlobPattern().getLeft()) + .collect(Collectors.toList()); + + assertThat(watcherPatterns, containsInAnyOrder( + endsWith("foo/**/*.{smithy,json}"), + endsWith("other/**/*.{smithy,json}"), + endsWith("abc.smithy"))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java new file mode 100644 index 00000000..1b8bea7f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java @@ -0,0 +1,43 @@ +/* + * 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.updateMainProject(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()); + } +} 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 a3b8033e..d5aad874 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -31,8 +31,7 @@ import org.junit.jupiter.api.Test; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.RangeAdapter; -import software.amazon.smithy.lsp.protocol.UriAdapter; +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; @@ -121,7 +120,7 @@ public void loadsWhenModelHasInvalidSyntax() { assertThat(eventIds, hasItem("Model")); assertThat(project.smithyFiles().keySet(), hasItem(containsString("main.smithy"))); - SmithyFile main = project.getSmithyFile(UriAdapter.toUri(root.resolve("main.smithy").toString())); + SmithyFile main = project.getSmithyFile(LspAdapter.toUri(root.resolve("main.smithy").toString())); assertThat(main, not(nullValue())); assertThat(main.document(), not(nullValue())); assertThat(main.namespace(), string("com.foo")); @@ -150,7 +149,7 @@ public void loadsProjectWithMultipleNamespaces() { assertThat(project.modelResult().getValidationEvents(), empty()); assertThat(project.smithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); - SmithyFile a = project.getSmithyFile(UriAdapter.toUri(root.resolve("model/a.smithy").toString())); + SmithyFile a = 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() @@ -163,7 +162,7 @@ public void loadsProjectWithMultipleNamespaces() { .collect(Collectors.toList()); assertThat(aDocumentShapeNames, hasItems("Hello", "name", "String")); - SmithyFile b = project.getSmithyFile(UriAdapter.toUri(root.resolve("model/b.smithy").toString())); + SmithyFile b = 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() @@ -280,7 +279,7 @@ public void changeFileApplyingSimpleTrait() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -307,7 +306,7 @@ public void changeFileApplyingListTrait() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -341,7 +340,7 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -376,7 +375,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -408,7 +407,7 @@ public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -435,7 +434,7 @@ public void changingFileWithDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -462,7 +461,7 @@ public void changingFileWithArrayDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -490,7 +489,7 @@ public void changingFileWithMixedArrayDependencies() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -526,15 +525,15 @@ public void changingFileWithArrayDependenciesWithDependencies() { if (document == null) { String smithyFilesPaths = String.join(System.lineSeparator(), project.smithyFiles().keySet()); String smithyFilesUris = project.smithyFiles().keySet().stream() - .map(UriAdapter::toUri) + .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: " + UriAdapter.toPath(uri)); + logger.severe("Not found path: " + LspAdapter.toPath(uri)); logger.severe("PATHS: " + smithyFilesPaths); logger.severe("URIS: " + smithyFilesUris); } - document.applyEdit(RangeAdapter.point(document.end()), "\n"); + document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -568,7 +567,7 @@ public void removingSimpleApply() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); + document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); project.updateModelWithoutValidating(uri); @@ -598,7 +597,7 @@ public void removingArrayApply() { String uri = workspace.getUri("model-0.smithy"); Document document = project.getDocument(uri); - document.applyEdit(RangeAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); + document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); project.updateModelWithoutValidating(uri);