diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 25ab2137..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,36 +0,0 @@ ---- - -name: deploy -on: - push: - tags: - - "0*" - -jobs: - deploy: - permissions: - contents: write # needed for the release script - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set variables - run: | - VER=$(cat VERSION) - echo "VERSION=$VER" >> $GITHUB_ENV - echo "Version is $VER" - - - name: Setup JDK - uses: actions/setup-java@v1 - with: - java-version: "11" - - - name: Clean and build - run: ./gradlew clean build distZip - - - name: Release - uses: softprops/action-gh-release@v1 - with: - files: ./build/distributions/smithy-language-server-${{ env.VERSION }}.zip diff --git a/.gitignore b/.gitignore index db17f612..2ec1a66f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,8 @@ project/project .gradle # Ignore Gradle build output directory -build +# Note: Only ignore the top-level build dir, tests use dirs named 'build' which we don't want to ignore +/build bin @@ -27,4 +28,4 @@ bin *.smithy !/src/test/resources/**/*.smithy .ammonite -out/ \ No newline at end of file +out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 30314dea..618ad3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Smithy Language Server Changelog +## 0.7.0 (2025-04-15) + +### Features +* Added standalone runtime images to GitHub release, to run the language server without a local Java installation. ([#159](https://github.com/smithy-lang/smithy-language-server/pull/159)) +* Improved how the language server is launched from the CLI. ([#218](https://github.com/smithy-lang/smithy-language-server/pull/218)) +* Added textDocument/rename support. ([#213](https://github.com/smithy-lang/smithy-language-server/pull/213)) +* Added textDocument/references support. ([#213](https://github.com/smithy-lang/smithy-language-server/pull/213)) +* Made textDocument/documentSymbol return hierarchical symbols. ([#206](https://github.com/smithy-lang/smithy-language-server/pull/206)) + +### Bug fixes +* Fixed possible crash on initialization. ([#216](https://github.com/smithy-lang/smithy-language-server/pull/216)) +* Removed extraneous validation events from hover content. ([#214](https://github.com/smithy-lang/smithy-language-server/pull/214)) + +## 0.6.0 (2025-03-10) + +### Features +* Improved completions, definition, and hover for everything in the IDL. ([#166](https://github.com/smithy-lang/smithy-language-server/pull/166)) +* Diagnostics for smithy-build.json. ([#188](https://github.com/smithy-lang/smithy-language-server/pull/188)) +* Completions for smithy-build.json. ([#193](https://github.com/smithy-lang/smithy-language-server/pull/193)) +* Hover for smithy-build.json. ([#202](https://github.com/smithy-lang/smithy-language-server/pull/202)) +* Folding range for traits and shape blocks. ([#190](https://github.com/smithy-lang/smithy-language-server/pull/190)) +* Inlay hints of the name of inline operation input/output. ([#200](https://github.com/smithy-lang/smithy-language-server/pull/200)) + +### Bug fixes +* Fixed crash when calling setTrace or cancelProgress. ([#183](https://github.com/smithy-lang/smithy-language-server/pull/183)) +* Fixed potential conflicting trait definition when rebuilding. ([#196](https://github.com/smithy-lang/smithy-language-server/pull/196)) + +## 0.5.0 (2024-11-06) + +### Features +* Added support for projects nested in subdirectories of a workspace. The server can now load multiple projects within the same workspace. ([#167](https://github.com/smithy-lang/smithy-language-server/pull/167)) +* Improved location of diagnostics. Diagnostics now appear only on the token, rather than including a bunch of whitespace. ([#179](https://github.com/smithy-lang/smithy-language-server/pull/179)) + +### Bug fixes +* Fixed potential deadlock in `didChangeWorkspaceFolders`. ([#167](https://github.com/smithy-lang/smithy-language-server/pull/167)) + ## 0.4.1 (2024-09-09) ### Features diff --git a/VERSION b/VERSION index 267577d4..faef31a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.7.0 diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 339f1131..00000000 --- a/build.gradle +++ /dev/null @@ -1,269 +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. - */ - -import org.gradle.api.tasks.testing.logging.TestExceptionFormat -import org.gradle.api.tasks.testing.logging.TestLogEvent - - -buildscript { - repositories { - maven { url "https://plugins.gradle.org/m2/" } - mavenLocal() - } -} - - -plugins { - // Apply the java plugin to add support for Java - id "java" - - // Apply the application plugin to add support for building a CLI application. - id "application" - - id "maven-publish" - id "signing" - id "com.palantir.git-version" version "0.12.3" - id "checkstyle" - id "org.jreleaser" version "1.13.0" -} - - -version gitVersion().replaceFirst("v", "") - -// Reusable license copySpec for building JARs -def licenseSpec = copySpec { - from "${project.rootDir}/LICENSE" - from "${project.rootDir}/NOTICE" -} - -// Set up tasks that build source and javadoc jars. -task sourcesJar(type: Jar) { - metaInf.with(licenseSpec) - from { - sourceSets.main.allJava - } - archiveClassifier = "sources" -} - -// Build a javadoc JAR too. -task javadocJar(type: Jar) { - metaInf.with(licenseSpec) - from { - tasks.javadoc - } - archiveClassifier = "javadoc" -} - -ext { - // Load the Smithy Language Server version from VERSION. - libraryVersion = project.file("VERSION").getText('UTF-8').replace(System.lineSeparator(), "") -} - -println "Smithy Language Server version: '${libraryVersion}'" - -def stagingDirectory = rootProject.layout.buildDirectory.dir("staging") - -allprojects { - apply plugin: "java" - apply plugin: "maven-publish" - apply plugin: "signing" - group = "software.amazon.smithy" - version = libraryVersion - description = "Language Server Protocol implementation for Smithy" -} - -repositories { - mavenLocal() - mavenCentral() -} - -publishing { - repositories { - maven { - name = "localStaging" - url = stagingDirectory - } - } - - publications { - mavenJava(MavenPublication) { - groupId = project.group - artifactId = "smithy-language-server" - - from components.java - - jar - - // Ship the source and javadoc jars. - artifact(tasks["sourcesJar"]) - artifact(tasks["javadocJar"]) - - // Include extra information in the POMs. - project.afterEvaluate { - pom { - name.set("Smithy Language Server") - description.set(project.description) - url.set("https://github.com/smithy-lang/smithy-language-server") - licenses { - license { - name.set("Apache License 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("repo") - } - } - developers { - developer { - id.set("smithy") - name.set("Smithy") - organization.set("Amazon Web Services") - organizationUrl.set("https://aws.amazon.com") - roles.add("developer") - } - } - scm { - url.set("https://github.com/smithy-lang/smithy-language-server.git") - } - } - } - } - } -} - - -dependencies { - implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1" - implementation "software.amazon.smithy:smithy-build:[smithyVersion, 2.0[" - implementation "software.amazon.smithy:smithy-cli:[smithyVersion, 2.0[" - implementation "software.amazon.smithy:smithy-model:[smithyVersion, 2.0[" - implementation "software.amazon.smithy:smithy-syntax:[smithyVersion, 2.0[" - - testImplementation "org.junit.jupiter:junit-jupiter:5.10.0" - testImplementation "org.hamcrest:hamcrest:2.2" - - testRuntimeOnly "org.junit.platform:junit-platform-launcher" -} - -tasks.withType(Javadoc).all { - options.addStringOption('Xdoclint:none', '-quiet') -} - -tasks.withType(Test).configureEach { - useJUnitPlatform() - - testLogging { - events TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR - exceptionFormat TestExceptionFormat.FULL - showExceptions true - showCauses true - showStackTraces true - } -} - -tasks.register('createProperties') { - dependsOn processResources - doLast { - new File("$buildDir/resources/main/version.properties").withWriter { w -> - Properties p = new Properties() - p['version'] = project.version.toString() - p.store w, null - } - } -} - -classes { - dependsOn createProperties -} - -application { - // Define the main class for the application. - mainClass = "software.amazon.smithy.lsp.Main" -} - -// ==== CheckStyle ==== -// https://docs.gradle.org/current/userguide/checkstyle_plugin.html -apply plugin: "checkstyle" -tasks.named("checkstyleTest") { - enabled = false -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -jar { - from (configurations.compileClasspath.collect { entry -> zipTree(entry) }) { - exclude "about.html" - exclude "META-INF/LICENSE" - exclude "META-INF/LICENSE.txt" - exclude "META-INF/NOTICE" - exclude "META-INF/MANIFEST.MF" - exclude "META-INF/*.SF" - exclude "META-INF/*.DSA" - exclude "META-INF/*.RSA" - exclude "reflect.properties" - // Included by dependencies in later versions of java, causes duplicate entries in the output jar - exclude "**/module-info.class" - } - manifest { - attributes("Main-Class": "software.amazon.smithy.lsp.Main") - } -} - -jreleaser { - dryrun = false - - // Used for creating a tagged release, uploading files and generating changelog. - // In the future we can set this up to push release tags to GitHub, but for now it's - // set up to do nothing. - // https://jreleaser.org/guide/latest/reference/release/index.html - release { - generic { - enabled = true - skipRelease = true - } - } - - // Used to announce a release to configured announcers. - // https://jreleaser.org/guide/latest/reference/announce/index.html - announce { - active = "NEVER" - } - - // Signing configuration. - // https://jreleaser.org/guide/latest/reference/signing.html - signing { - active = "ALWAYS" - armored = true - } - - // Configuration for deploying to Maven Central. - // https://jreleaser.org/guide/latest/examples/maven/maven-central.html#_gradle - deploy { - maven { - nexus2 { - "maven-central" { - active = "ALWAYS" - url = "https://aws.oss.sonatype.org/service/local" - snapshotUrl = "https://aws.oss.sonatype.org/content/repositories/snapshots" - closeRepository = true - releaseRepository = true - stagingRepository(stagingDirectory.get().toString()) - } - } - } - } -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..fae02d51 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,397 @@ +/* + * 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. + */ + +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.jreleaser.model.Active +import org.jreleaser.model.Distribution.DistributionType +import org.jreleaser.model.Stereotype +import java.util.Properties + +import java.util.regex.Pattern + +buildscript { + repositories { + maven { url = uri("https://plugins.gradle.org/m2/") } + mavenLocal() + } +} + + +plugins { + // Apply the java plugin to add support for Java + id("java") + + // Apply the application plugin to add support for building a CLI application. + id("application") + + id("maven-publish") + id("com.palantir.git-version") version "0.12.3" + id("checkstyle") + id("org.jreleaser") version "1.13.0" + + // Fork of runtime plugin with java 21 support, until https://github.com/beryx/badass-runtime-plugin/issues/153 + // is resolved. + id("com.dua3.gradle.runtime") version "1.13.1-patch-1" +} + + +val gitVersion: groovy.lang.Closure by extra +version = gitVersion().replaceFirst("v", "") + +// Reusable license copySpec for building JARs +val licenseSpec = copySpec { + from("${project.rootDir}/LICENSE") + from("${project.rootDir}/NOTICE") +} + +// Set up tasks that build source and javadoc jars. +tasks.register("sourcesJar") { + metaInf.with(licenseSpec) + from(sourceSets.main.get().allJava) + archiveClassifier = "sources" +} + +// Build a javadoc JAR too. +tasks.register("javadocJar") { + metaInf.with(licenseSpec) + from(tasks.javadoc) + archiveClassifier = "javadoc" +} + +val libraryVersion = project.file("VERSION").readText().trim() +val imageJreVersion = "21" +val correttoRoot = "https://corretto.aws/downloads/latest/amazon-corretto-${imageJreVersion}" + +println("Smithy Language Server version: '${libraryVersion}'") + +val stagingDirectory = rootProject.layout.buildDirectory.dir("staging") + +allprojects { + apply(plugin = "java") + apply(plugin = "maven-publish") + group = "software.amazon.smithy" + version = libraryVersion + description = "Language Server Protocol implementation for Smithy" +} + +repositories { + mavenLocal() + mavenCentral() +} + +publishing { + repositories { + maven { + name = "localStaging" + url = uri(stagingDirectory) + } + } + + publications { + create("mavenJava") { + groupId = project.group.toString() + artifactId = "smithy-language-server" + + from(components["java"]) + + // Ship the source and javadoc jars. + artifact(tasks["sourcesJar"]) + artifact(tasks["javadocJar"]) + + // Include extra information in the POMs. + afterEvaluate { + pom { + name.set("Smithy Language Server") + description.set(project.description) + url.set("https://github.com/smithy-lang/smithy-language-server") + licenses { + license { + name.set("Apache License 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + developers { + developer { + id.set("smithy") + name.set("Smithy") + organization.set("Amazon Web Services") + organizationUrl.set("https://aws.amazon.com") + roles.add("developer") + } + } + scm { + url.set("https://github.com/smithy-lang/smithy-language-server.git") + } + } + } + } + } +} + +checkstyle { + toolVersion = "10.12.4" +} + +dependencies { + implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1") + implementation("software.amazon.smithy:smithy-build:[smithyVersion, 2.0[") + implementation("software.amazon.smithy:smithy-cli:[smithyVersion, 2.0[") + implementation("software.amazon.smithy:smithy-model:[smithyVersion, 2.0[") + implementation("software.amazon.smithy:smithy-syntax:[smithyVersion, 2.0[") + + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + testImplementation("org.hamcrest:hamcrest:2.2") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + checkstyle("com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}") +} + +tasks.withType { + (options as StandardJavadocDocletOptions).addStringOption("Xdoclint:none", "-quiet") +} + +tasks.withType().configureEach { + useJUnitPlatform() + + testLogging { + events = setOf(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR) + exceptionFormat = TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true + } +} + +tasks.register("createProperties") { + dependsOn(tasks.processResources) + doLast { + val file = project.layout.buildDirectory.file("resources/main/version.properties").get().asFile + val properties = Properties() + properties["version"] = project.version.toString() + properties.store(file.writer(), null) + } +} + +tasks.classes { + dependsOn(tasks["createProperties"]) +} + +application { + // Define the main class for the application. + mainClass = "software.amazon.smithy.lsp.Main" +} + +// ==== CheckStyle ==== +// https://docs.gradle.org/current/userguide/checkstyle_plugin.html +apply(plugin = "checkstyle") +tasks.named("checkstyleTest") { + enabled = false +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +tasks.jar { + from (configurations.compileClasspath.get().map { zipTree(it) }) { + exclude("about.html") + exclude("META-INF/LICENSE") + exclude("META-INF/LICENSE.txt") + exclude("META-INF/NOTICE") + exclude("META-INF/MANIFEST.MF") + exclude("META-INF/*.SF") + exclude("META-INF/*.DSA") + exclude("META-INF/*.RSA") + exclude("reflect.properties") + // Included by dependencies in later versions of java, causes duplicate entries in the output jar + exclude("**/module-info.class") + } + manifest { + attributes("Main-Class" to "software.amazon.smithy.lsp.Main") + } +} + + +runtime { + addOptions("--compress", "2", "--strip-debug", "--no-header-files", "--no-man-pages") + addModules("java.logging", "java.naming", "java.xml", "jdk.crypto.ec") + + launcher { + jvmArgs = listOf( + "-XX:-UsePerfData", + "-Xshare:auto", + "-XX:SharedArchiveFile={{BIN_DIR}}/../lib/smithy.jsa" + ) + } + + targetPlatform("linux-x86_64") { + jdkHome = jdkDownload("${correttoRoot}-x64-linux-jdk.tar.gz") + } + + targetPlatform("linux-aarch64") { + jdkHome = jdkDownload("${correttoRoot}-aarch64-linux-jdk.tar.gz") + } + + targetPlatform("darwin-x86_64") { + jdkHome = jdkDownload("${correttoRoot}-x64-macos-jdk.tar.gz") + } + + targetPlatform("darwin-aarch64") { + jdkHome = jdkDownload("${correttoRoot}-aarch64-macos-jdk.tar.gz") + } + + targetPlatform("windows-x64") { + jdkHome = jdkDownload("${correttoRoot}-x64-windows-jdk.zip") + } + + // Because we're using target-platforms, it will use this property as a prefix for each target zip + imageZip = layout.buildDirectory.file("image/smithy-language-server.zip") +} + +tasks["assembleDist"].dependsOn("publish") +tasks["assembleDist"].dependsOn("runtimeZip") + +// Generate a changelog that only includes the changes for the latest version +// which Jreleaser will add to the release notes of the github release. +val releaseChangelogFile = project.layout.buildDirectory.file("resources/RELEASE_CHANGELOG.md").get() +tasks.register("createReleaseChangelog") { + dependsOn(tasks.processResources) + + doLast { + val changelog = project.file("CHANGELOG.md").readText() + // Copy the text in between the first two version headers + val matcher = Pattern.compile("^## \\d+\\.\\d+\\.\\d+", Pattern.MULTILINE).matcher(changelog) + val getIndex = fun(): Int { + matcher.find() + return matcher.start() + } + val result = changelog.substring(getIndex(), getIndex()).trim() + releaseChangelogFile.asFile.writeText(result) + } +} + +tasks.jreleaserRelease.get().dependsOn(tasks.processResources) + +jreleaser { + dryrun = false + + project { + website = "https://smithy.io" + authors = listOf("Smithy") + vendor = "Smithy" + license = "Apache-2.0" + description = "Smithy Language Server - A Language Server Protocol implementation for the Smithy IDL." + copyright = "2019" + } + + release { + github { + overwrite = true + tagName = "{{projectVersion}}" + releaseName = "Smithy Language Server v{{{projectVersion}}}" + changelog { + external = releaseChangelogFile + } + commitAuthor { + name = "smithy-automation" + email = "github-smithy-automation@amazon.com" + } + } + } + + files { + active = Active.ALWAYS + artifact { + // We'll include the VERSION file in the release artifacts so that the version can be easily + // retrieving by hitting the GitHub `releases/latest` url + path = file("VERSION") + extraProperties.put("skipSigning", true) + } + } + + platform { + // These replacements are for the names of files that are released, *not* for names within this build config + replacements = mapOf( + "osx" to "darwin", + "aarch_64" to "aarch64", + "windows_x86_64" to "windows_x64" + ) + } + + distributions { + create("smithy-language-server") { + distributionType = DistributionType.JLINK + stereotype = Stereotype.CLI + + artifact { + path = file("build/image/smithy-language-server-linux-x86_64.zip") + platform = "linux-x86_64" + } + + artifact { + path = file("build/image/smithy-language-server-linux-aarch64.zip") + platform = "linux-aarch_64" + } + + artifact { + path = file("build/image/smithy-language-server-darwin-x86_64.zip") + platform = "osx-x86_64" + } + + artifact { + path = file("build/image/smithy-language-server-darwin-aarch64.zip") + platform = "osx-aarch_64" + } + + artifact { + path = file("build/image/smithy-language-server-windows-x64.zip") + platform = "windows-x86_64" + } + } + } + + checksum { + individual = true + files = false + } + + signing { + active = Active.RELEASE + armored = true + verify = true + } + + // Configuration for deploying to Maven Central. + // https://jreleaser.org/guide/latest/examples/maven/maven-central.html#_gradle + deploy { + maven { + nexus2 { + create("maven-central") { + active = Active.ALWAYS + url = "https://aws.oss.sonatype.org/service/local" + snapshotUrl = "https://aws.oss.sonatype.org/content/repositories/snapshots" + closeRepository = true + releaseRepository = true + stagingRepository(stagingDirectory.get().toString()) + } + } + } + } +} diff --git a/build.sc b/build.sc index dd95130d..d216e069 100644 --- a/build.sc +++ b/build.sc @@ -14,10 +14,10 @@ object lsp extends MavenModule with CiReleaseModule { def ivyDeps = Agg( ivy"org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1", - ivy"software.amazon.smithy:smithy-build:1.50.0", - ivy"software.amazon.smithy:smithy-cli:1.50.0", - ivy"software.amazon.smithy:smithy-model:1.50.0", - ivy"software.amazon.smithy:smithy-syntax:1.50.0" + ivy"software.amazon.smithy:smithy-build:1.56.0", + ivy"software.amazon.smithy:smithy-cli:1.56.0", + ivy"software.amazon.smithy:smithy-model:1.56.0", + ivy"software.amazon.smithy:smithy-syntax:1.56.0" ) def javacOptions = T { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index fa284ede..c6658c32 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -182,7 +182,6 @@ - diff --git a/gradle.properties b/gradle.properties index 23bea553..675832e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -smithyVersion=1.50.0 +smithyVersion=1.56.0 diff --git a/settings.gradle b/settings.gradle.kts similarity index 88% rename from settings.gradle rename to settings.gradle.kts index 5b29b664..f6ca20a3 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -7,4 +7,4 @@ * in the user manual at https://docs.gradle.org/6.6.1/userguide/multi_project_builds.html */ -rootProject.name = 'smithy-language-server' +rootProject.name = "smithy-language-server" diff --git a/src/main/java/software/amazon/smithy/lsp/FilePatterns.java b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java new file mode 100644 index 00000000..536135a8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/FilePatterns.java @@ -0,0 +1,162 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.io.File; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.EnumSet; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import software.amazon.smithy.lsp.project.BuildFileType; +import software.amazon.smithy.lsp.project.Project; + +/** + * Utility methods for computing glob patterns that match against Smithy files + * or build files in Projects and workspaces. + */ +final class FilePatterns { + static final PathMatcher GLOBAL_BUILD_FILES_MATCHER = toPathMatcher(escapeBackslashes( + String.format("**%s{%s}", File.separator, String.join(",", BuildFileType.ALL_FILENAMES)))); + + private FilePatterns() { + } + + private enum SmithyFilePatternOptions { + IS_WATCHER_PATTERN, + IS_PATH_MATCHER_PATTERN; + + private static final EnumSet WATCHER = EnumSet.of(IS_WATCHER_PATTERN); + private static final EnumSet PATH_MATCHER = EnumSet.of(IS_PATH_MATCHER_PATTERN); + private static final EnumSet ALL = EnumSet.allOf(SmithyFilePatternOptions.class); + } + + private enum BuildFilePatternOptions { + IS_WORKSPACE_PATTERN, + IS_PATH_MATCHER_PATTERN; + + private static final EnumSet WORKSPACE = EnumSet.of(IS_WORKSPACE_PATTERN); + private static final EnumSet PATH_MATCHER = EnumSet.of(IS_PATH_MATCHER_PATTERN); + private static final EnumSet ALL = EnumSet.allOf(BuildFilePatternOptions.class); + } + + /** + * @param project The project to get watch patterns for + * @return A list of glob patterns used to watch Smithy files in the given project + */ + static List getSmithyFileWatchPatterns(Project project) { + return Stream.concat(project.sources().stream(), project.imports().stream()) + .map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.WATCHER)) + .toList(); + } + + /** + * @param project The project to get a path matcher for + * @return A path matcher that can check if Smithy files belong to the given project + */ + static PathMatcher getSmithyFilesPathMatcher(Project project) { + String pattern = Stream.concat(project.sources().stream(), project.imports().stream()) + .map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.PATH_MATCHER)) + .collect(Collectors.joining(",")); + return toPathMatcher("{" + pattern + "}"); + } + + /** + * @param project The project to get a path matcher for + * @return A list of path matchers that match watched Smithy files in the given project + */ + static List getSmithyFileWatchPathMatchers(Project project) { + return Stream.concat(project.sources().stream(), project.imports().stream()) + .map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.ALL)) + .map(FilePatterns::toPathMatcher) + .toList(); + } + + /** + * @param root The root to get the watch pattern for + * @return A glob pattern used to watch build files in the given workspace + */ + static String getWorkspaceBuildFilesWatchPattern(Path root) { + return getBuildFilesPattern(root, BuildFilePatternOptions.WORKSPACE); + } + + /** + * @param root The root to get a path matcher for + * @return A path matcher that can check if a file is a build file within the given workspace + */ + static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) { + String pattern = getBuildFilesPattern(root, BuildFilePatternOptions.ALL); + return toPathMatcher(pattern); + } + + /** + * @param project The project to get a path matcher for + * @return A path matcher that can check if a file is a build file belonging to the given project + */ + static PathMatcher getProjectBuildFilesPathMatcher(Project project) { + String pattern = getBuildFilesPattern(project.root(), BuildFilePatternOptions.PATH_MATCHER); + return toPathMatcher(pattern); + } + + private static PathMatcher toPathMatcher(String globPattern) { + return FileSystems.getDefault().getPathMatcher("glob:" + globPattern); + } + + // When computing the pattern used for telling the client which files to watch, we want + // to only watch .smithy/.json files. We don't need it in the PathMatcher pattern because + // we only need to match files, not listen for specific changes (and it is impossible anyway + // because we can't have a nested pattern). + private static String getSmithyFilePattern(Path path, EnumSet options) { + String glob = path.toString(); + if (glob.endsWith(".smithy") || glob.endsWith(".json")) { + return escapeBackslashes(glob); + } + + if (!glob.endsWith(File.separator)) { + glob += File.separator; + } + glob += "**"; + + if (options.contains(SmithyFilePatternOptions.IS_WATCHER_PATTERN)) { + // For some reason, the glob pattern matching works differently on vscode vs + // PathMatcher. See https://github.com/smithy-lang/smithy-language-server/issues/191 + if (options.contains(SmithyFilePatternOptions.IS_PATH_MATCHER_PATTERN)) { + glob += ".{smithy,json}"; + } else { + glob += "/*.{smithy,json}"; + } + } + + return escapeBackslashes(glob); + } + + // Patterns for the workspace need to match on all build files in all subdirectories, + // whereas patterns for projects only look at the top level (because project locations + // are defined by the presence of these build files). + private static String getBuildFilesPattern(Path root, EnumSet options) { + String rootString = root.toString(); + if (!rootString.endsWith(File.separator)) { + rootString += File.separator; + } + + if (options.contains(BuildFilePatternOptions.IS_WORKSPACE_PATTERN)) { + rootString += "**"; + if (!options.contains(BuildFilePatternOptions.IS_PATH_MATCHER_PATTERN)) { + rootString += File.separator; + } + } + + return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}"); + } + + // In glob patterns, '\' is an escape character, so it needs to escaped + // itself to work as a separator (i.e. for windows) + private static String escapeBackslashes(String pattern) { + return pattern.replace("\\", "\\\\"); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/FileTasks.java similarity index 71% rename from src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java rename to src/main/java/software/amazon/smithy/lsp/FileTasks.java index ba9c33f7..945df04f 100644 --- a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java +++ b/src/main/java/software/amazon/smithy/lsp/FileTasks.java @@ -6,29 +6,16 @@ package software.amazon.smithy.lsp; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.logging.Logger; /** - * Tracks asynchronous lifecycle tasks and client-managed documents. - * Allows cancelling of an ongoing task if a new task needs to be started. + * Container for tracking asynchronous tasks by file, allowing for cancellation of an ongoing + * task if a new task needs to be started. */ -final class DocumentLifecycleManager { - private static final Logger LOGGER = Logger.getLogger(DocumentLifecycleManager.class.getName()); +final class FileTasks { private final Map> tasks = new HashMap<>(); - private final Set managedDocumentUris = new HashSet<>(); - - Set managedDocuments() { - return managedDocumentUris; - } - - boolean isManaged(String uri) { - return managedDocuments().contains(uri); - } CompletableFuture getTask(String uri) { return tasks.get(uri); diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java similarity index 62% rename from src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java rename to src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java index 57602501..a52acdfd 100644 --- a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/FileWatcherRegistrations.java @@ -3,8 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.lsp.handler; +package software.amazon.smithy.lsp; +import java.nio.file.Path; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -15,7 +16,6 @@ import org.eclipse.lsp4j.WatchKind; import org.eclipse.lsp4j.jsonrpc.messages.Either; import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectFilePatterns; /** * Handles computing the {@link Registration}s and {@link Unregistration}s for @@ -32,26 +32,33 @@ * 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; +final class FileWatcherRegistrations { + private static final Integer WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles"; private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles"; private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles"; private static final List SMITHY_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration( WATCH_SMITHY_FILES_ID, WATCH_FILES_METHOD)); + private static final List BUILD_FILE_WATCHER_UNREGISTRATIONS = List.of(new Unregistration( + WATCH_BUILD_FILES_ID, + WATCH_FILES_METHOD)); - private FileWatcherRegistrationHandler() { + private FileWatcherRegistrations() { } /** + * Creates registrations to tell the client to watch for new or deleted + * Smithy files, specifically for files that are part of {@link Project}s. + * * @param projects The projects to get registrations for * @return The registrations to watch for Smithy file changes across all projects */ - public static List getSmithyFileWatcherRegistrations(Collection projects) { + static List getSmithyFileWatcherRegistrations(Collection projects) { List smithyFileWatchers = projects.stream() - .flatMap(project -> ProjectFilePatterns.getSmithyFileWatchPatterns(project).stream()) - .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), SMITHY_WATCH_FILE_KIND)) + .filter(project -> project.type() == Project.Type.NORMAL) + .flatMap(project -> FilePatterns.getSmithyFileWatchPatterns(project).stream()) + .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); return Collections.singletonList(new Registration( @@ -63,18 +70,21 @@ public static List getSmithyFileWatcherRegistrations(Collection getSmithyFileWatcherUnregistrations() { + static List getSmithyFileWatcherUnregistrations() { return SMITHY_FILE_WATCHER_UNREGISTRATIONS; } /** - * @param projects The projects to get registrations for - * @return The registrations to watch for build file changes across all projects + * Creates registrations to tell the client to watch for any build file + * creations or deletions, across all workspaces. + * + * @param workspaceRoots The roots of the workspaces to get registrations for + * @return The registrations to watch for build file changes across all workspaces */ - public static List getBuildFileWatcherRegistrations(Collection projects) { - List watchers = projects.stream() - .map(ProjectFilePatterns::getBuildFilesWatchPattern) - .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern))) + static List getBuildFileWatcherRegistrations(Collection workspaceRoots) { + List watchers = workspaceRoots.stream() + .map(FilePatterns::getWorkspaceBuildFilesWatchPattern) + .map(pattern -> new FileSystemWatcher(Either.forLeft(pattern), WATCH_FILE_KIND)) .toList(); return Collections.singletonList(new Registration( @@ -82,4 +92,11 @@ public static List getBuildFileWatcherRegistrations(Collection getBuildFileWatcherUnregistrations() { + return BUILD_FILE_WATCHER_UNREGISTRATIONS; + } } diff --git a/src/main/java/software/amazon/smithy/lsp/Main.java b/src/main/java/software/amazon/smithy/lsp/Main.java index 87add549..d7a7054f 100644 --- a/src/main/java/software/amazon/smithy/lsp/Main.java +++ b/src/main/java/software/amazon/smithy/lsp/Main.java @@ -15,14 +15,13 @@ package software.amazon.smithy.lsp; -import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; -import java.util.Optional; -import org.eclipse.lsp4j.jsonrpc.Launcher; import org.eclipse.lsp4j.launch.LSPLauncher; -import org.eclipse.lsp4j.services.LanguageClient; +import software.amazon.smithy.cli.AnsiColorFormatter; +import software.amazon.smithy.cli.CliPrinter; +import software.amazon.smithy.cli.HelpPrinter; /** * Main launcher for the Language server, started by the editor. @@ -32,90 +31,46 @@ private Main() { } /** - * Launch the LSP and wait for it to terminate. - * - * @param in input stream for communication - * @param out output stream for communication - * @return Empty Optional if service terminated successfully, error otherwise + * Main entry point for the language server. + * @param args Arguments passed to the server. + * @throws Exception If there is an error starting the server. */ - public static Optional launch(InputStream in, OutputStream out) { - SmithyLanguageServer server = new SmithyLanguageServer(); - Launcher launcher = LSPLauncher.createServerLauncher( - server, - exitOnClose(in), - out); - - LanguageClient client = launcher.getRemoteProxy(); - - server.connect(client); - try { - launcher.startListening().get(); - return Optional.empty(); - } catch (Exception e) { - return Optional.of(e); + public static void main(String[] args) throws Exception { + var serverArguments = ServerArguments.create(args); + if (serverArguments.help()) { + printHelp(serverArguments); + System.exit(0); } + + launch(serverArguments); } - private static InputStream exitOnClose(InputStream delegate) { - return new InputStream() { - @Override - public int read() throws IOException { - int result = delegate.read(); - if (result < 0) { - System.exit(0); - } - return result; + private static void launch(ServerArguments serverArguments) throws Exception { + if (serverArguments.useSocket()) { + try (var socket = new Socket("localhost", serverArguments.port())) { + startServer(socket.getInputStream(), socket.getOutputStream()); } - }; + } else { + startServer(System.in, System.out); + } } - /** - * @param args Arguments passed to launch server. First argument must either be - * a port number for socket connection, or 0 to use STDIN and STDOUT - * for communication - */ - public static void main(String[] args) { - - Socket socket = null; - InputStream in; - OutputStream out; + private static void startServer(InputStream in, OutputStream out) throws Exception { + var server = new SmithyLanguageServer(); + var launcher = LSPLauncher.createServerLauncher(server, in, out); - try { - String port = args[0]; - // If port is set to "0", use System.in/System.out. - if (port.equals("0")) { - in = System.in; - out = System.out; - } else { - socket = new Socket("localhost", Integer.parseInt(port)); - in = socket.getInputStream(); - out = socket.getOutputStream(); - } - - Optional launchFailure = launch(in, out); + var client = launcher.getRemoteProxy(); + server.connect(client); - if (launchFailure.isPresent()) { - throw launchFailure.get(); - } else { - System.out.println("Server terminated without errors"); - } - } catch (ArrayIndexOutOfBoundsException e) { - System.out.println("Missing port argument"); - } catch (NumberFormatException e) { - System.out.println("Port number must be a valid integer"); - } catch (Exception e) { - System.out.println(e); + launcher.startListening().get(); + } - e.printStackTrace(); - } finally { - try { - if (socket != null) { - socket.close(); - } - } catch (Exception e) { - System.out.println("Failed to close the socket"); - System.out.println(e); - } - } + private static void printHelp(ServerArguments serverArguments) { + CliPrinter printer = CliPrinter.fromOutputStream(System.out); + HelpPrinter helpPrinter = new HelpPrinter("smithy-language-server"); + serverArguments.registerHelp(helpPrinter); + helpPrinter.summary("Run the Smithy Language Server."); + helpPrinter.print(AnsiColorFormatter.AUTO, printer); + printer.flush(); } } diff --git a/src/main/java/software/amazon/smithy/lsp/ManagedFiles.java b/src/main/java/software/amazon/smithy/lsp/ManagedFiles.java new file mode 100644 index 00000000..d10a17a6 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ManagedFiles.java @@ -0,0 +1,23 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import software.amazon.smithy.lsp.document.Document; + +/** + * Provides access to {@link Document}s managed by the server. + * + *

A document is _managed_ if its state is controlled by the lifecycle methods + * didOpen, didClose, didChange, didSave. In other words, reading from disk _may_ + * not provide the accurate file content. + */ +public interface ManagedFiles { + /** + * @param uri Uri of the document to get + * @return The document if found and it is managed, otherwise {@code null} + */ + Document getManagedDocument(String uri); +} diff --git a/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java new file mode 100644 index 00000000..448e3da0 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ProjectRootVisitor.java @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import software.amazon.smithy.lsp.project.BuildFileType; + +/** + * Finds Project roots based on the location of smithy-build.json and .smithy-project.json. + */ +final class ProjectRootVisitor extends SimpleFileVisitor { + private static final PathMatcher PROJECT_ROOT_MATCHER = FileSystems.getDefault().getPathMatcher( + "glob:{" + BuildFileType.SMITHY_BUILD.filename() + "," + BuildFileType.SMITHY_PROJECT.filename() + "}"); + private static final int MAX_VISIT_DEPTH = 10; + + private final List roots = new ArrayList<>(); + + /** + * Walks through the file tree starting at {@code workspaceRoot}, collecting + * paths of Project roots. + * + * @param workspaceRoot Root of the workspace to find projects in + * @return A list of project roots + * @throws IOException If an I/O error is thrown while walking files + */ + static List findProjectRoots(Path workspaceRoot) throws IOException { + ProjectRootVisitor visitor = new ProjectRootVisitor(); + Files.walkFileTree(workspaceRoot, EnumSet.noneOf(FileVisitOption.class), MAX_VISIT_DEPTH, visitor); + return visitor.roots; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + Path name = file.getFileName(); + if (name != null && PROJECT_ROOT_MATCHER.matches(name)) { + roots.add(file.getParent()); + return FileVisitResult.SKIP_SIBLINGS; + } + return FileVisitResult.CONTINUE; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ServerArguments.java b/src/main/java/software/amazon/smithy/lsp/ServerArguments.java new file mode 100644 index 00000000..345bb3ba --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerArguments.java @@ -0,0 +1,101 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.function.Consumer; +import software.amazon.smithy.cli.ArgumentReceiver; +import software.amazon.smithy.cli.Arguments; +import software.amazon.smithy.cli.CliError; +import software.amazon.smithy.cli.HelpPrinter; + +/** + * Options and Params available for LSP. + */ +final class ServerArguments implements ArgumentReceiver { + + private static final int MIN_PORT = 0; + private static final int MAX_PORT = 65535; + private static final int DEFAULT_PORT = 0; // Default value for unset port number. + private static final String HELP = "--help"; + private static final String HELP_SHORT = "-h"; + private static final String PORT = "--port"; + private static final String PORT_SHORT = "-p"; + private static final String PORT_POSITIONAL = ""; + private int port = DEFAULT_PORT; + private boolean help = false; + + + static ServerArguments create(String[] args) { + Arguments arguments = Arguments.of(args); + var serverArguments = new ServerArguments(); + arguments.addReceiver(serverArguments); + var positional = arguments.getPositional(); + if (!positional.isEmpty()) { + serverArguments.port = serverArguments.validatePortNumber(positional.getFirst()); + } + return serverArguments; + } + + @Override + public void registerHelp(HelpPrinter printer) { + printer.option(HELP, HELP_SHORT, "Print this help output."); + printer.param(PORT, PORT_SHORT, "PORT", + "The port to use for talking to the client. When not specified, or set to 0, " + + "standard in/out is used. Standard in/out is preferred, " + + "so usually this shouldn't be specified."); + printer.option(PORT_POSITIONAL, null, "Deprecated: use --port instead. When not specified, or set to 0, " + + "standard in/out is used. Standard in/out is preferred, so usually this shouldn't be specified."); + } + + @Override + public boolean testOption(String name) { + if (name.equals(HELP) || name.equals(HELP_SHORT)) { + help = true; + return true; + } + return false; + } + + @Override + public Consumer testParameter(String name) { + if (name.equals(PORT_SHORT) || name.equals(PORT)) { + return value -> { + port = validatePortNumber(value); + }; + } + return null; + } + + int port() { + return port; + } + + boolean help() { + return help; + } + + boolean useSocket() { + return port != 0; + } + + private int validatePortNumber(String portStr) { + try { + int portNumber = Integer.parseInt(portStr); + if (portNumber < MIN_PORT || portNumber > MAX_PORT) { + throw invalidPort(portStr); + } else { + return portNumber; + } + } catch (NumberFormatException e) { + throw invalidPort(portStr); + } + } + + private static CliError invalidPort(String portStr) { + return new CliError("Invalid port number: expected an integer between " + + MIN_PORT + " and " + MAX_PORT + ", inclusive. Was: " + portStr); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ServerOptions.java b/src/main/java/software/amazon/smithy/lsp/ServerOptions.java new file mode 100644 index 00000000..7862a65b --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerOptions.java @@ -0,0 +1,86 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import com.google.gson.JsonObject; +import java.util.Arrays; +import java.util.Optional; +import org.eclipse.lsp4j.InitializeParams; +import software.amazon.smithy.model.validation.Severity; + +public final class ServerOptions { + private final Severity minimumSeverity; + private final boolean onlyReloadOnSave; + + private ServerOptions(Builder builder) { + this.minimumSeverity = builder.minimumSeverity; + this.onlyReloadOnSave = builder.onlyReloadOnSave; + } + + public Severity getMinimumSeverity() { + return this.minimumSeverity; + } + + public boolean getOnlyReloadOnSave() { + return this.onlyReloadOnSave; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a ServerOptions instance from the initialization options provided by the client. + * Parses and validates configuration settings from the initialization parameters. + * + * @param params The params passed directly from the client, + * expected to be an InitializeParams object containing server configurations + * @param client The language client used for logging configuration status and errors + * @return A new {@code ServerOptions} instance with parsed configuration values + **/ + public static ServerOptions fromInitializeParams(InitializeParams params, SmithyLanguageClient client) { + // from InitializeParams + Object initializationOptions = params.getInitializationOptions(); + Builder builder = builder(); + if (initializationOptions instanceof JsonObject jsonObject) { + if (jsonObject.has("diagnostics.minimumSeverity")) { + String configuredMinimumSeverity = jsonObject.get("diagnostics.minimumSeverity").getAsString(); + Optional severity = Severity.fromString(configuredMinimumSeverity); + if (severity.isPresent()) { + builder.setMinimumSeverity(severity.get()); + } else { + client.error(String.format(""" + Invalid value for 'diagnostics.minimumSeverity': %s. + Must be one of %s.""", configuredMinimumSeverity, Arrays.toString(Severity.values()))); + } + } + if (jsonObject.has("onlyReloadOnSave")) { + builder.setOnlyReloadOnSave(jsonObject.get("onlyReloadOnSave").getAsBoolean()); + client.info("Configured only reload on save: " + builder.onlyReloadOnSave); + } + } + return builder.build(); + } + + protected static final class Builder { + private Severity minimumSeverity = Severity.WARNING; + private boolean onlyReloadOnSave = false; + + public Builder setMinimumSeverity(Severity minimumSeverity) { + this.minimumSeverity = minimumSeverity; + return this; + } + + public Builder setOnlyReloadOnSave(boolean onlyReloadOnSave) { + this.onlyReloadOnSave = onlyReloadOnSave; + return this; + } + + public ServerOptions build() { + return new ServerOptions(this); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java new file mode 100644 index 00000000..70a70a3e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -0,0 +1,297 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import org.eclipse.lsp4j.FileEvent; +import org.eclipse.lsp4j.WorkspaceFolder; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; +import software.amazon.smithy.lsp.project.ProjectChange; +import software.amazon.smithy.lsp.project.ProjectFile; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +/** + * Keeps track of the state of the server. + */ +public final class ServerState implements ManagedFiles { + private static final Logger LOGGER = Logger.getLogger(ServerState.class.getName()); + + private final Map projects; + private final Set workspacePaths; + private final Set managedUris; + private final FileTasks lifecycleTasks; + + /** + * Create a new, empty server state. + */ + public ServerState() { + this.projects = new HashMap<>(); + this.workspacePaths = new HashSet<>(); + this.managedUris = new HashSet<>(); + this.lifecycleTasks = new FileTasks(); + } + + /** + * @return All projects tracked by the server. + */ + public Collection getAllProjects() { + return projects.values(); + } + + /** + * @return All files managed by the server, including their projects. + */ + public Collection getAllManaged() { + List allManaged = new ArrayList<>(managedUris.size()); + for (String uri : managedUris) { + allManaged.add(findManaged(uri)); + } + return allManaged; + } + + /** + * @return All workspace paths tracked by the server. + */ + public Set workspacePaths() { + return workspacePaths; + } + + @Override + public Document getManagedDocument(String uri) { + if (managedUris.contains(uri)) { + ProjectAndFile projectAndFile = findProjectAndFile(uri); + if (projectAndFile != null) { + return projectAndFile.file().document(); + } + } + + return null; + } + + FileTasks lifecycleTasks() { + return lifecycleTasks; + } + + Project findProjectByRoot(String root) { + return projects.get(root); + } + + ProjectAndFile findProjectAndFile(String uri) { + for (Project project : projects.values()) { + ProjectFile projectFile = project.getProjectFile(uri); + if (projectFile != null) { + return new ProjectAndFile(uri, project, projectFile); + } + } + + return null; + } + + ProjectAndFile findManaged(String uri) { + if (managedUris.contains(uri)) { + return findProjectAndFile(uri); + } + return null; + } + + ProjectAndFile open(String uri, String text) { + managedUris.add(uri); + + ProjectAndFile projectAndFile = findProjectAndFile(uri); + if (projectAndFile != null) { + projectAndFile.file().document().applyEdit(null, text); + } else { + // A newly created build file or smithy file may be opened before we receive the + // `didChangeWatchedFiles` notification, so we either need to load an unresolved + // project (build file), or load a detached project (smithy file). When we receive + // `didChangeWatchedFiles` we will move the file into a regular project, if applicable. + Path path = Path.of(LspAdapter.toPath(uri)); + if (FilePatterns.GLOBAL_BUILD_FILES_MATCHER.matches(path)) { + Project project = ProjectLoader.loadUnresolved(path, text); + projects.put(uri, project); + return findProjectAndFile(uri); + } + + createDetachedProject(uri, text); + projectAndFile = findProjectAndFile(uri); // Note: This will always be present + } + + return projectAndFile; + } + + void close(String uri) { + managedUris.remove(uri); + + ProjectAndFile projectAndFile = findProjectAndFile(uri); + if (projectAndFile != null && shouldDropOnClose(projectAndFile.project())) { + lifecycleTasks.cancelTask(uri); + projects.remove(uri); + } + } + + private static boolean shouldDropOnClose(Project project) { + return project.type() == Project.Type.DETACHED || project.type() == Project.Type.UNRESOLVED; + } + + List tryInitProject(Path root) { + LOGGER.finest("Initializing project at " + root); + lifecycleTasks.cancelAllTasks(); + + String projectName = root.toString(); + try { + Project updatedProject = ProjectLoader.load(root, this); + + if (updatedProject.type() == Project.Type.EMPTY) { + removeProjectAndResolve(projectName); + } else { + resolveProjects(projects.get(projectName), updatedProject); + projects.put(projectName, updatedProject); + } + + LOGGER.finest("Initialized project at " + root); + return List.of(); + } catch (Exception e) { + LOGGER.severe("Failed to load project at " + 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. + projects.computeIfAbsent(projectName, ignored -> Project.empty(root)); + + return List.of(e); + } + } + + void loadWorkspace(WorkspaceFolder workspaceFolder) { + Path workspaceRoot = Paths.get(URI.create(workspaceFolder.getUri())); + workspacePaths.add(workspaceRoot); + try { + List projectRoots = ProjectRootVisitor.findProjectRoots(workspaceRoot); + for (Path root : projectRoots) { + tryInitProject(root); + } + } catch (IOException e) { + LOGGER.severe(e.getMessage()); + } + } + + void removeWorkspace(WorkspaceFolder folder) { + Path workspaceRoot = Paths.get(URI.create(folder.getUri())); + workspacePaths.remove(workspaceRoot); + + // Have to do the removal separately, so we don't modify project.attachedProjects() + // while iterating through it + List projectsToRemove = new ArrayList<>(); + for (var entry : projects.entrySet()) { + if (entry.getValue().type() == Project.Type.NORMAL && entry.getValue().root().startsWith(workspaceRoot)) { + projectsToRemove.add(entry.getKey()); + } + } + + for (String projectName : projectsToRemove) { + removeProjectAndResolve(projectName); + } + } + + List applyFileEvents(List events) { + List errors = new ArrayList<>(); + + var changes = WorkspaceChanges.computeWorkspaceChanges(events, this); + + for (var entry : changes.byProject().entrySet()) { + String projectRoot = entry.getKey(); + ProjectChange projectChange = entry.getValue(); + + Project project = findProjectByRoot(projectRoot); + + if (!projectChange.changedBuildFileUris().isEmpty()) { + // Note: this will take care of removing projects when build files are + // deleted + errors.addAll(tryInitProject(project.root())); + } else { + Set createdUris = projectChange.createdSmithyFileUris(); + Set deletedUris = projectChange.deletedSmithyFileUris(); + + project.updateFiles(createdUris, deletedUris); + + // If any file was previously opened and created a detached project, remove them + for (String createdUri : createdUris) { + projects.remove(createdUri); + } + } + } + + for (var newProjectRoot : changes.newProjectRoots()) { + errors.addAll(tryInitProject(newProjectRoot)); + } + + return errors; + } + + private void removeProjectAndResolve(String projectName) { + Project removedProject = projects.remove(projectName); + if (removedProject != null) { + resolveProjects(removedProject, Project.empty(removedProject.root())); + } + } + + private void resolveProjects(Project oldProject, Project updatedProject) { + // There may be unresolved projects that have been resolved by the updated project, so + // we need to remove them here. + removeDetachedOrUnresolvedProjects(updatedProject.getAllBuildFilePaths()); + + // This is a project reload, so we need to resolve any added/removed files + // that need to be moved to or from detachedProjects projects. + if (oldProject != null) { + Set currentProjectSmithyPaths = oldProject.getAllSmithyFilePaths(); + Set updatedProjectSmithyPaths = updatedProject.getAllSmithyFilePaths(); + + Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); + addedPaths.removeAll(currentProjectSmithyPaths); + removeDetachedOrUnresolvedProjects(addedPaths); + + Set removedPaths = new HashSet<>(currentProjectSmithyPaths); + removedPaths.removeAll(updatedProjectSmithyPaths); + for (String removedPath : removedPaths) { + String removedUri = LspAdapter.toUri(removedPath); + // Only move to a detached project if the file is managed + if (managedUris.contains(removedUri)) { + Document removedDocument = oldProject.getProjectFile(removedUri).document(); + createDetachedProject(removedUri, removedDocument.copyText()); + } + } + } else { + // This is a new project, so there may be detached projects that are resolved by + // this new project. + removeDetachedOrUnresolvedProjects(updatedProject.getAllSmithyFilePaths()); + } + } + + private void removeDetachedOrUnresolvedProjects(Set filePaths) { + for (String filePath : filePaths) { + String uri = LspAdapter.toUri(filePath); + projects.remove(uri); + } + } + + private void createDetachedProject(String uri, String text) { + Project project = ProjectLoader.loadDetached(uri, text); + projects.put(uri, project); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 436c3f44..5d2f0247 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -16,29 +16,20 @@ package software.amazon.smithy.lsp; import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.CompletableFuture.supplyAsync; +import static org.eclipse.lsp4j.jsonrpc.CompletableFutures.computeAsync; -import com.google.gson.JsonObject; import java.io.IOException; -import java.net.URI; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.Collections; -import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Properties; -import java.util.Set; -import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.logging.Logger; import java.util.stream.Collectors; -import java.util.stream.Stream; +import org.eclipse.lsp4j.ClientCapabilities; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionOptions; import org.eclipse.lsp4j.CodeActionParams; @@ -49,7 +40,6 @@ import org.eclipse.lsp4j.CompletionParams; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; import org.eclipse.lsp4j.DidChangeConfigurationParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidChangeWatchedFilesParams; @@ -57,40 +47,55 @@ import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.DocumentFilter; import org.eclipse.lsp4j.DocumentFormattingParams; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.DynamicRegistrationCapabilities; +import org.eclipse.lsp4j.FoldingRange; +import org.eclipse.lsp4j.FoldingRangeRequestParams; import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.InlayHintParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.PrepareRenameResult; import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceParams; import org.eclipse.lsp4j.Registration; import org.eclipse.lsp4j.RegistrationParams; +import org.eclipse.lsp4j.RenameOptions; +import org.eclipse.lsp4j.RenameParams; import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SetTraceParams; import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.SymbolKind; +import org.eclipse.lsp4j.TextDocumentChangeRegistrationOptions; +import org.eclipse.lsp4j.TextDocumentClientCapabilities; import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentRegistrationOptions; +import org.eclipse.lsp4j.TextDocumentSaveRegistrationOptions; import org.eclipse.lsp4j.TextDocumentSyncKind; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.Unregistration; import org.eclipse.lsp4j.UnregistrationParams; import org.eclipse.lsp4j.WorkDoneProgressBegin; -import org.eclipse.lsp4j.WorkDoneProgressCreateParams; +import org.eclipse.lsp4j.WorkDoneProgressCancelParams; import org.eclipse.lsp4j.WorkDoneProgressEnd; +import org.eclipse.lsp4j.WorkspaceEdit; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; import org.eclipse.lsp4j.WorkspaceServerCapabilities; -import org.eclipse.lsp4j.jsonrpc.CompletableFutures; import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.jsonrpc.messages.Either3; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; import org.eclipse.lsp4j.services.LanguageServer; @@ -99,29 +104,31 @@ import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentShape; import software.amazon.smithy.lsp.ext.OpenProject; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.ext.ServerStatus; import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; -import software.amazon.smithy.lsp.handler.CompletionHandler; -import software.amazon.smithy.lsp.handler.DefinitionHandler; -import software.amazon.smithy.lsp.handler.FileWatcherRegistrationHandler; -import software.amazon.smithy.lsp.handler.HoverHandler; +import software.amazon.smithy.lsp.language.BuildCompletionHandler; +import software.amazon.smithy.lsp.language.BuildHoverHandler; +import software.amazon.smithy.lsp.language.CompletionHandler; +import software.amazon.smithy.lsp.language.DefinitionHandler; +import software.amazon.smithy.lsp.language.DocumentSymbolHandler; +import software.amazon.smithy.lsp.language.FoldingRangeHandler; +import software.amazon.smithy.lsp.language.HoverHandler; +import software.amazon.smithy.lsp.language.InlayHintHandler; +import software.amazon.smithy.lsp.language.ReferencesHandler; +import software.amazon.smithy.lsp.language.RenameHandler; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectChanges; -import software.amazon.smithy.lsp.project.ProjectLoader; -import software.amazon.smithy.lsp.project.ProjectManager; +import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; -import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.loader.IdlTokenizer; import software.amazon.smithy.model.selector.Selector; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.syntax.Formatter; import software.amazon.smithy.syntax.TokenTree; import software.amazon.smithy.utils.IoUtils; @@ -133,7 +140,6 @@ public class SmithyLanguageServer implements static { ServerCapabilities capabilities = new ServerCapabilities(); - capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental); capabilities.setCodeActionProvider(new CodeActionOptions(SmithyCodeActions.all())); capabilities.setDefinitionProvider(true); capabilities.setDeclarationProvider(true); @@ -141,6 +147,10 @@ public class SmithyLanguageServer implements capabilities.setHoverProvider(true); capabilities.setDocumentFormattingProvider(true); capabilities.setDocumentSymbolProvider(true); + capabilities.setFoldingRangeProvider(true); + capabilities.setInlayHintProvider(true); + capabilities.setReferencesProvider(true); + capabilities.setRenameProvider(new RenameOptions(true)); WorkspaceFoldersOptions workspaceFoldersOptions = new WorkspaceFoldersOptions(); workspaceFoldersOptions.setSupported(true); @@ -150,28 +160,19 @@ public class SmithyLanguageServer implements } private SmithyLanguageClient client; - private final ProjectManager projects = new ProjectManager(); - private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); - private Severity minimumSeverity = Severity.WARNING; - private boolean onlyReloadOnSave = false; + private final ServerState state = new ServerState(); + private ClientCapabilities clientCapabilities; + private ServerOptions serverOptions; SmithyLanguageServer() { } - SmithyLanguageClient getClient() { - return this.client; + ServerState getState() { + return state; } - Project getFirstProject() { - return projects.attachedProjects().values().stream().findFirst().orElse(null); - } - - ProjectManager getProjects() { - return projects; - } - - DocumentLifecycleManager getLifecycleManager() { - return this.lifecycleManager; + Severity getMinimumSeverity() { + return this.serverOptions.getMinimumSeverity(); } @Override @@ -197,26 +198,8 @@ public CompletableFuture initialize(InitializeParams params) { .flatMap(ProcessHandle::of) .ifPresent(processHandle -> processHandle.onExit().thenRun(this::exit)); + this.serverOptions = ServerOptions.fromInitializeParams(params, client); // TODO: Replace with a Gson Type Adapter if more config options are added beyond `logToFile`. - Object initializationOptions = params.getInitializationOptions(); - if (initializationOptions instanceof JsonObject jsonObject) { - if (jsonObject.has("diagnostics.minimumSeverity")) { - String configuredMinimumSeverity = jsonObject.get("diagnostics.minimumSeverity").getAsString(); - Optional severity = Severity.fromString(configuredMinimumSeverity); - if (severity.isPresent()) { - this.minimumSeverity = severity.get(); - } else { - client.error(String.format(""" - Invalid value for 'diagnostics.minimumSeverity': %s. - Must be one of %s.""", configuredMinimumSeverity, Arrays.toString(Severity.values()))); - } - } - if (jsonObject.has("onlyReloadOnSave")) { - this.onlyReloadOnSave = jsonObject.get("onlyReloadOnSave").getAsBoolean(); - client.info("Configured only reload on save: " + this.onlyReloadOnSave); - } - } - if (params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { Either workDoneProgressToken = params.getWorkDoneToken(); @@ -227,8 +210,7 @@ public CompletableFuture initialize(InitializeParams params) { } for (WorkspaceFolder workspaceFolder : params.getWorkspaceFolders()) { - Path root = Paths.get(URI.create(workspaceFolder.getUri())); - tryInitProject(workspaceFolder.getName(), root); + state.loadWorkspace(workspaceFolder); } if (workDoneProgressToken != null) { @@ -237,93 +219,110 @@ public CompletableFuture initialize(InitializeParams params) { } } + this.clientCapabilities = params.getCapabilities(); + + // We register for this capability dynamically otherwise + if (!isDynamicSyncRegistrationSupported()) { + CAPABILITIES.setTextDocumentSync(TextDocumentSyncKind.Incremental); + } + LOGGER.finest("Done initialize"); return completedFuture(new InitializeResult(CAPABILITIES)); } - private void tryInitProject(String name, Path root) { - LOGGER.finest("Initializing project at " + root); - lifecycleManager.cancelAllTasks(); - Result> loadResult = ProjectLoader.load( - root, projects, lifecycleManager.managedDocuments()); - if (loadResult.isOk()) { - Project updatedProject = loadResult.unwrap(); - resolveDetachedProjects(this.projects.getProjectByName(name), updatedProject); - this.projects.updateProjectByName(name, updatedProject); - LOGGER.finest("Initialized project at " + root); - } else { - LOGGER.severe("Init project failed"); - // TODO: Maybe we just start with this anyways by default, and then add to it - // if we find a smithy-build.json, etc. - // If we overwrite an existing project with an empty one, we lose track of the state of tracked - // files. Instead, we will just keep the original project before the reload failure. - if (projects.getProjectByName(name) == null) { - projects.updateProjectByName(name, Project.empty(root)); - } - - String baseMessage = "Failed to load Smithy project " + name + " at " + root; - StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); - for (Exception error : loadResult.unwrapErr()) { + private void reportProjectLoadErrors(List errors) { + if (!errors.isEmpty()) { + StringBuilder errorMessage = new StringBuilder("Failed to load Smithy projects").append(":"); + for (Exception error : errors) { errorMessage.append(System.lineSeparator()); errorMessage.append('\t'); errorMessage.append(error.getMessage()); } client.error(errorMessage.toString()); - - String showMessage = baseMessage + ". Check server logs to find out what went wrong."; - client.showMessage(new MessageParams(MessageType.Error, showMessage)); } } - private void resolveDetachedProjects(Project oldProject, Project updatedProject) { - // This is a project reload, so we need to resolve any added/removed files - // that need to be moved to or from detached projects. - if (oldProject != null) { - Set currentProjectSmithyPaths = oldProject.smithyFiles().keySet(); - Set updatedProjectSmithyPaths = updatedProject.smithyFiles().keySet(); - - Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); - addedPaths.removeAll(currentProjectSmithyPaths); - for (String addedPath : addedPaths) { - String addedUri = LspAdapter.toUri(addedPath); - if (projects.isDetached(addedUri)) { - projects.removeDetachedProject(addedUri); - } - } + private CompletableFuture registerSmithyFileWatchers() { + List registrations = FileWatcherRegistrations.getSmithyFileWatcherRegistrations( + state.getAllProjects()); + return client.registerCapability(new RegistrationParams(registrations)); + } - Set removedPaths = new HashSet<>(currentProjectSmithyPaths); - removedPaths.removeAll(updatedProjectSmithyPaths); - for (String removedPath : removedPaths) { - String removedUri = LspAdapter.toUri(removedPath); - // Only move to a detached project if the file is managed - if (lifecycleManager.managedDocuments().contains(removedUri)) { - Document removedDocument = oldProject.getDocument(removedUri); - // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings - projects.createDetachedProject(removedUri, removedDocument.copyText()); - } - } - } + private CompletableFuture unregisterSmithyFileWatchers() { + List unregistrations = FileWatcherRegistrations.getSmithyFileWatcherUnregistrations(); + return client.unregisterCapability(new UnregistrationParams(unregistrations)); } - private CompletableFuture registerSmithyFileWatchers() { - List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations( - projects.attachedProjects().values()); + private CompletableFuture registerWorkspaceBuildFileWatchers() { + var registrations = FileWatcherRegistrations.getBuildFileWatcherRegistrations(state.workspacePaths()); return client.registerCapability(new RegistrationParams(registrations)); } - private CompletableFuture unregisterSmithyFileWatchers() { - List unregistrations = FileWatcherRegistrationHandler.getSmithyFileWatcherUnregistrations(); + private CompletableFuture unregisterWorkspaceBuildFileWatchers() { + var unregistrations = FileWatcherRegistrations.getBuildFileWatcherUnregistrations(); return client.unregisterCapability(new UnregistrationParams(unregistrations)); } @Override public void initialized(InitializedParams params) { - List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations( - projects.attachedProjects().values()); - client.registerCapability(new RegistrationParams(registrations)); + // We have to do this in `initialized` because we can't send dynamic registrations in `initialize`. + if (isDynamicSyncRegistrationSupported()) { + registerDocumentSynchronization(); + } + + registerWorkspaceBuildFileWatchers(); registerSmithyFileWatchers(); } + private boolean isDynamicSyncRegistrationSupported() { + return Optional.ofNullable(clientCapabilities) + .map(ClientCapabilities::getTextDocument) + .map(TextDocumentClientCapabilities::getSynchronization) + .map(DynamicRegistrationCapabilities::getDynamicRegistration) + .orElse(false); + } + + private void registerDocumentSynchronization() { + List buildDocumentSelector = List.of( + new DocumentFilter("json", "file", "**/{smithy-build,.smithy-project}.json")); + + var openCloseBuildOpts = new TextDocumentRegistrationOptions(buildDocumentSelector); + var changeBuildOpts = new TextDocumentChangeRegistrationOptions(TextDocumentSyncKind.Incremental); + changeBuildOpts.setDocumentSelector(buildDocumentSelector); + var saveBuildOpts = new TextDocumentSaveRegistrationOptions(); + saveBuildOpts.setDocumentSelector(buildDocumentSelector); + saveBuildOpts.setIncludeText(true); + + client.registerCapability(new RegistrationParams(List.of( + new Registration("SyncSmithyBuildFiles/Open", "textDocument/didOpen", openCloseBuildOpts), + new Registration("SyncSmithyBuildFiles/Close", "textDocument/didClose", openCloseBuildOpts), + new Registration("SyncSmithyBuildFiles/Change", "textDocument/didChange", changeBuildOpts), + new Registration("SyncSmithyBuildFiles/Save", "textDocument/didSave", saveBuildOpts)))); + + DocumentFilter smithyFilter = new DocumentFilter(); + smithyFilter.setLanguage("smithy"); + smithyFilter.setScheme("file"); + + DocumentFilter smithyJarFilter = new DocumentFilter(); + smithyJarFilter.setLanguage("smithy"); + smithyJarFilter.setScheme("smithyjar"); + + List smithyDocumentSelector = List.of(smithyFilter); + + var openCloseSmithyOpts = new TextDocumentRegistrationOptions(List.of(smithyFilter, smithyJarFilter)); + var changeSmithyOpts = new TextDocumentChangeRegistrationOptions(TextDocumentSyncKind.Incremental); + changeSmithyOpts.setDocumentSelector(smithyDocumentSelector); + var saveSmithyOpts = new TextDocumentSaveRegistrationOptions(); + saveSmithyOpts.setDocumentSelector(smithyDocumentSelector); + saveSmithyOpts.setIncludeText(true); + + client.registerCapability(new RegistrationParams(List.of( + new Registration("SyncSmithyFiles/Open", "textDocument/didOpen", openCloseSmithyOpts), + new Registration("SyncSmithyFiles/Close", "textDocument/didClose", openCloseSmithyOpts), + new Registration("SyncSmithyFiles/Change", "textDocument/didChange", changeSmithyOpts), + new Registration("SyncSmithyFiles/Save", "textDocument/didSave", saveSmithyOpts)))); + } + @Override public WorkspaceService getWorkspaceService() { return this; @@ -345,14 +344,29 @@ public void exit() { System.exit(0); } + @Override + public void cancelProgress(WorkDoneProgressCancelParams params) { + // TODO: Right now this stub just avoids a possible runtime error from the default + // impl in lsp4j. If we start using work done tokens, we will want to support canceling + // them here. + LOGGER.warning("window/workDoneProgress/cancel not implemented"); + } + + @Override + public void setTrace(SetTraceParams params) { + // TODO: Eventually when we set up better logging, maybe there's something to do here. + // For now, this stub just avoids a runtime error from the default impl in lsp4j. + LOGGER.warning("$/setTrace not implemented"); + } + @Override public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { LOGGER.finest("JarFileContents"); + String uri = textDocumentIdentifier.getUri(); - Project project = projects.getProject(uri); - Document document = project.getDocument(uri); - if (document != null) { - return completedFuture(document.copyText()); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile != null) { + return completedFuture(projectAndFile.file().document().copyText()); } else { // Technically this can throw if the uri is invalid return completedFuture(IoUtils.readUtf8Url(LspAdapter.jarUrl(uri))); @@ -371,11 +385,7 @@ public CompletableFuture> selectorCommand(SelectorParam return completedFuture(Collections.emptyList()); } - // Select from all available projects - Collection detached = projects.detachedProjects().values(); - Collection nonDetached = projects.attachedProjects().values(); - - return completedFuture(Stream.concat(detached.stream(), nonDetached.stream()) + return completedFuture(state.getAllProjects().stream() .flatMap(project -> project.modelResult().getResult().stream()) .map(selector::select) .flatMap(shapes -> shapes.stream() @@ -388,52 +398,27 @@ public CompletableFuture> selectorCommand(SelectorParam @Override public CompletableFuture serverStatus() { List openProjects = new ArrayList<>(); - for (Project project : projects.attachedProjects().values()) { + for (Project project : state.getAllProjects()) { openProjects.add(new OpenProject( LspAdapter.toUri(project.root().toString()), - project.smithyFiles().keySet().stream() + project.getAllSmithyFilePaths().stream() .map(LspAdapter::toUri) .toList(), - false)); + project.type() == Project.Type.DETACHED)); } - - for (Map.Entry entry : projects.detachedProjects().entrySet()) { - openProjects.add(new OpenProject( - entry.getKey(), - Collections.singletonList(entry.getKey()), - true)); - } - return completedFuture(new ServerStatus(openProjects)); } @Override public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { LOGGER.finest("DidChangeWatchedFiles"); + // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), - // or the smithy-build.json itself was changed - - Map changesByProject = projects.computeProjectChanges(params.getChanges()); - - changesByProject.forEach((projectName, projectChanges) -> { - Project project = projects.getProjectByName(projectName); - if (projectChanges.hasChangedBuildFiles()) { - client.info("Build files changed, reloading project"); - // TODO: Handle more granular updates to build files. - tryInitProject(projectName, project.root()); - } else if (projectChanges.hasChangedSmithyFiles()) { - Set createdUris = projectChanges.createdSmithyFileUris(); - Set deletedUris = projectChanges.deletedSmithyFileUris(); - client.info("Project files changed, adding files " - + createdUris + " and removing files " + deletedUris); - - // We get this notification for watched files, which only includes project files, - // so we don't need to resolve detached projects. - project.updateFiles(createdUris, deletedUris); - } - }); + // the smithy-build.json itself was changed, added, or deleted. + reportProjectLoadErrors(state.applyFileEvents(params.getChanges())); // TODO: Update watchers based on specific changes + // Note: We don't update build file watchers here - only on workspace changes unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); sendFileDiagnosticsForManagedDocuments(); @@ -443,41 +428,17 @@ public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { LOGGER.finest("DidChangeWorkspaceFolders"); - Either progressToken = Either.forLeft(UUID.randomUUID().toString()); - try { - client.createProgress(new WorkDoneProgressCreateParams(progressToken)).get(); - } catch (ExecutionException | InterruptedException e) { - client.error(String.format("Unable to create work done progress token: %s", e.getMessage())); - progressToken = null; - } - - if (progressToken != null) { - WorkDoneProgressBegin begin = new WorkDoneProgressBegin(); - begin.setTitle("Updating workspace"); - client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(begin))); - } - for (WorkspaceFolder folder : params.getEvent().getAdded()) { - Path root = Paths.get(URI.create(folder.getUri())); - tryInitProject(folder.getName(), root); + state.loadWorkspace(folder); } for (WorkspaceFolder folder : params.getEvent().getRemoved()) { - Project removedProject = projects.removeProjectByName(folder.getName()); - if (removedProject == null) { - continue; - } - - resolveDetachedProjects(removedProject, Project.empty(removedProject.root())); + state.removeWorkspace(folder); } unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + unregisterWorkspaceBuildFileWatchers().thenRun(this::registerWorkspaceBuildFileWatchers); sendFileDiagnosticsForManagedDocuments(); - - if (progressToken != null) { - WorkDoneProgressEnd end = new WorkDoneProgressEnd(); - client.notifyProgress(new ProgressParams(progressToken, Either.forLeft(end))); - } } @Override @@ -495,14 +456,15 @@ public void didChange(DidChangeTextDocumentParams params) { String uri = params.getTextDocument().getUri(); - lifecycleManager.cancelTask(uri); + state.lifecycleTasks().cancelTask(uri); - Document document = projects.getDocument(uri); - if (document == null) { + ProjectAndFile projectAndFile = state.findManaged(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "change"); return; } + Document document = projectAndFile.file().document(); for (TextDocumentContentChangeEvent contentChangeEvent : params.getContentChanges()) { if (contentChangeEvent.getRange() != null) { document.applyEdit(contentChangeEvent.getRange(), contentChangeEvent.getText()); @@ -511,20 +473,31 @@ public void didChange(DidChangeTextDocumentParams params) { } } - if (!onlyReloadOnSave) { - Project project = projects.getProject(uri); - if (project == null) { - client.unknownFileError(uri, "change"); - return; + projectAndFile.file().reparse(); + + Project project = projectAndFile.project(); + switch (projectAndFile.file()) { + case SmithyFile ignored -> { + if (this.serverOptions.getOnlyReloadOnSave()) { + return; + } + + // TODO: A consequence of this is that any existing validation events are cleared, which + // is kinda annoying. + // Report any parse/shape/trait loading errors + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateModelWithoutValidating(uri)) + .thenRunAsync(() -> sendFileDiagnostics(projectAndFile)); + + state.lifecycleTasks().putTask(uri, future); } + case BuildFile ignored -> { + CompletableFuture future = CompletableFuture + .runAsync(project::validateConfig) + .thenRunAsync(() -> sendFileDiagnostics(projectAndFile)); - // TODO: A consequence of this is that any existing validation events are cleared, which - // is kinda annoying. - // Report any parse/shape/trait loading errors - CompletableFuture future = CompletableFuture - .runAsync(() -> project.updateModelWithoutValidating(uri)) - .thenComposeAsync(unused -> sendFileDiagnostics(uri)); - lifecycleManager.putTask(uri, future); + state.lifecycleTasks().putTask(uri, future); + } } } @@ -534,18 +507,11 @@ public void didOpen(DidOpenTextDocumentParams params) { String uri = params.getTextDocument().getUri(); - lifecycleManager.cancelTask(uri); - lifecycleManager.managedDocuments().add(uri); + state.lifecycleTasks().cancelTask(uri); - String text = params.getTextDocument().getText(); - Document document = projects.getDocument(uri); - if (document != null) { - document.applyEdit(null, text); - } else { - projects.createDetachedProject(uri, text); - } + ProjectAndFile projectAndFile = state.open(uri, params.getTextDocument().getText()); - lifecycleManager.putTask(uri, sendFileDiagnostics(uri)); + state.lifecycleTasks().putTask(uri, sendFileDiagnostics(projectAndFile)); } @Override @@ -553,15 +519,7 @@ public void didClose(DidCloseTextDocumentParams params) { LOGGER.finest("DidClose"); String uri = params.getTextDocument().getUri(); - lifecycleManager.managedDocuments().remove(uri); - - 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 + state.close(uri); } @Override @@ -569,24 +527,29 @@ public void didSave(DidSaveTextDocumentParams params) { LOGGER.finest("DidSave"); String uri = params.getTextDocument().getUri(); - lifecycleManager.cancelTask(uri); - if (!projects.isTracked(uri)) { - // TODO: Could also load a detached project here, but I don't know how this would - // actually happen in practice + state.lifecycleTasks().cancelTask(uri); + + ProjectAndFile projectAndFile = state.findManaged(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "save"); return; } - Project project = projects.getProject(uri); if (params.getText() != null) { - Document document = project.getDocument(uri); - document.applyEdit(null, params.getText()); + projectAndFile.file().document().applyEdit(null, params.getText()); } - CompletableFuture future = CompletableFuture - .runAsync(() -> project.updateAndValidateModel(uri)) - .thenCompose(unused -> sendFileDiagnostics(uri)); - lifecycleManager.putTask(uri, future); + Project project = projectAndFile.project(); + if (projectAndFile.file() instanceof BuildFile) { + reportProjectLoadErrors(state.tryInitProject(project.root())); + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + sendFileDiagnosticsForManagedDocuments(); + } else { + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateAndValidateModel(uri)) + .thenRunAsync(() -> sendFileDiagnostics(projectAndFile)); + state.lifecycleTasks().putTask(uri, future); + } } @Override @@ -594,17 +557,24 @@ public CompletableFuture, CompletionList>> completio LOGGER.finest("Completion"); String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "completion"); return completedFuture(Either.forLeft(Collections.emptyList())); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); - return CompletableFutures.computeAsync((cc) -> { - CompletionHandler handler = new CompletionHandler(project, smithyFile); - return Either.forLeft(handler.handle(params, cc)); - }); + Project project = projectAndFile.project(); + return switch (projectAndFile.file()) { + case IdlFile idlFile -> { + var handler = new CompletionHandler(project, idlFile); + yield computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); + } + case BuildFile buildFile -> { + var handler = new BuildCompletionHandler(project, buildFile); + yield supplyAsync(() -> Either.forLeft(handler.handle(params))); + } + default -> completedFuture(Either.forLeft(List.of())); + }; } @Override @@ -618,59 +588,61 @@ public CompletableFuture resolveCompletionItem(CompletionItem un public CompletableFuture>> documentSymbol(DocumentSymbolParams params) { LOGGER.finest("DocumentSymbol"); + String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "document symbol"); return completedFuture(Collections.emptyList()); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(List.of()); + } - return CompletableFutures.computeAsync((cc) -> { - Collection documentShapes = smithyFile.documentShapes(); - if (documentShapes.isEmpty()) { - return Collections.emptyList(); - } + List statements = idlFile.getParse().statements(); + var handler = new DocumentSymbolHandler(idlFile.document(), statements); + return CompletableFuture.supplyAsync(handler::handle); + } - if (cc.isCanceled()) { - return Collections.emptyList(); - } + @Override + public CompletableFuture> foldingRange(FoldingRangeRequestParams params) { + LOGGER.finest("FoldingRange"); - List> documentSymbols = new ArrayList<>(documentShapes.size()); - for (DocumentShape documentShape : documentShapes) { - SymbolKind symbolKind; - switch (documentShape.kind()) { - case Inline: - // No shape name in the document text, so no symbol - continue; - case DefinedMember: - case Elided: - symbolKind = SymbolKind.Property; - break; - case DefinedShape: - case Targeted: - default: - symbolKind = SymbolKind.Class; - break; - } + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "folding range"); + return completedFuture(Collections.emptyList()); + } - // Check before copying shapeName, which is actually a reference to the underlying document, and may - // be changed. - cc.checkCanceled(); + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(List.of()); + } - 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)); - } + List statements = idlFile.getParse().statements(); + var handler = new FoldingRangeHandler(idlFile.document(), idlFile.getParse().imports(), statements); + return CompletableFuture.supplyAsync(handler::handle); + } - return documentSymbols; - }); + @Override + public CompletableFuture> inlayHint(InlayHintParams params) { + LOGGER.finest("InlayHint"); + + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "inlay hint"); + return completedFuture(Collections.emptyList()); + } + + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(List.of()); + } + + List statements = idlFile.getParse().statements(); + var handler = new InlayHintHandler(idlFile.document(), statements, params.getRange()); + return CompletableFuture.supplyAsync(handler::handle); } @Override @@ -679,15 +651,19 @@ public CompletableFuture resolveCompletionItem(CompletionItem un LOGGER.finest("Definition"); String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "definition"); - return completedFuture(Either.forLeft(Collections.emptyList())); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { + return completedFuture(null); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); - List locations = new DefinitionHandler(project, smithyFile).handle(params); - return completedFuture(Either.forLeft(locations)); + Project project = projectAndFile.project(); + var handler = new DefinitionHandler(project, smithyFile); + return CompletableFuture.supplyAsync(() -> Either.forLeft(handler.handle(params))); } @Override @@ -695,17 +671,25 @@ public CompletableFuture hover(HoverParams params) { LOGGER.finest("Hover"); String uri = params.getTextDocument().getUri(); - if (!projects.isTracked(uri)) { + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { client.unknownFileError(uri, "hover"); - return completedFuture(HoverHandler.emptyContents()); + return completedFuture(null); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); + return switch (projectAndFile.file()) { + case IdlFile idlFile -> { + Project project = projectAndFile.project(); - // TODO: Abstract away passing minimum severity - Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); - return completedFuture(hover); + var handler = new HoverHandler(project, idlFile); + yield CompletableFuture.supplyAsync(() -> handler.handle(params)); + } + case BuildFile buildFile -> { + var handler = new BuildHoverHandler(buildFile); + yield CompletableFuture.supplyAsync(() -> handler.handle(params)); + } + default -> completedFuture(null); + }; } @Override @@ -720,13 +704,20 @@ public CompletableFuture>> codeAction(CodeActio @Override public CompletableFuture> formatting(DocumentFormattingParams params) { LOGGER.finest("Formatting"); + String uri = params.getTextDocument().getUri(); - Project project = projects.getProject(uri); - Document document = project.getDocument(uri); - if (document == null) { - return completedFuture(Collections.emptyList()); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "format"); + return completedFuture(null); + } + + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return completedFuture(null); } + Document document = smithyFile.document(); + IdlTokenizer tokenizer = IdlTokenizer.create(uri, document.borrowText()); TokenTree tokenTree = TokenTree.of(tokenizer); String formatted = Formatter.format(tokenTree); @@ -735,86 +726,73 @@ public CompletableFuture> formatting(DocumentFormatting return completedFuture(Collections.singletonList(edit)); } - private void sendFileDiagnosticsForManagedDocuments() { - for (String managedDocumentUri : lifecycleManager.managedDocuments()) { - lifecycleManager.putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); + @Override + public CompletableFuture> references(ReferenceParams params) { + LOGGER.finest("References"); + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "references"); + return completedFuture(null); } - } - private CompletableFuture sendFileDiagnostics(String uri) { - return CompletableFuture.runAsync(() -> { - List diagnostics = getFileDiagnostics(uri); - PublishDiagnosticsParams publishDiagnosticsParams = new PublishDiagnosticsParams(uri, diagnostics); - client.publishDiagnostics(publishDiagnosticsParams); - }); + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(null); + } + + var handler = new ReferencesHandler(projectAndFile.project(), idlFile); + return supplyAsync(() -> handler.handle(params)); } - List getFileDiagnostics(String uri) { - if (LspAdapter.isJarFile(uri) || LspAdapter.isSmithyJarFile(uri)) { - // Don't send diagnostics to jar files since they can't be edited - // and diagnostics could be misleading. - return Collections.emptyList(); + @Override + public CompletableFuture rename(RenameParams params) { + LOGGER.finest("Rename"); + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "rename"); + return completedFuture(null); } - if (!projects.isTracked(uri)) { - client.unknownFileError(uri, "diagnostics"); + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(null); } - Project project = projects.getProject(uri); - SmithyFile smithyFile = project.getSmithyFile(uri); - String path = LspAdapter.toPath(uri); - - List diagnostics = project.modelResult().getValidationEvents().stream() - .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) - .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) - .map(validationEvent -> toDiagnostic(validationEvent, smithyFile)) - .collect(Collectors.toCollection(ArrayList::new)); + var handler = new RenameHandler(projectAndFile.project(), idlFile); + return supplyAsync(() -> handler.handle(params)); + } - Diagnostic versionDiagnostic = SmithyDiagnostics.versionDiagnostic(smithyFile); - if (versionDiagnostic != null) { - diagnostics.add(versionDiagnostic); + @Override + public CompletableFuture> + prepareRename(PrepareRenameParams params) { + LOGGER.finest("PrepareRename"); + String uri = params.getTextDocument().getUri(); + ProjectAndFile projectAndFile = state.findProjectAndFile(uri); + if (projectAndFile == null) { + client.unknownFileError(uri, "prepareRename"); + return completedFuture(null); } - if (projects.isDetached(uri)) { - diagnostics.add(SmithyDiagnostics.detachedDiagnostic(smithyFile)); + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { + return completedFuture(null); } - return diagnostics; + var handler = new RenameHandler(projectAndFile.project(), idlFile); + return supplyAsync(() -> Either3.forFirst(handler.prepare(params))); } - private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { - DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); - SourceLocation sourceLocation = validationEvent.getSourceLocation(); - - // TODO: Improve location of diagnostics - 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(LspAdapter.toPosition(sourceLocation)); - if (documentShape != null && documentShape.hasMemberTarget()) { - range = documentShape.targetReference().range(); - } - } else { - // Check if the event location is on a trait application - Range traitRange = DocumentParser.forDocument(smithyFile.document()).traitIdRange(sourceLocation); - if (traitRange != null) { - range = traitRange; - } - } + private void sendFileDiagnosticsForManagedDocuments() { + for (ProjectAndFile managed : state.getAllManaged()) { + state.lifecycleTasks().putOrComposeTask(managed.uri(), sendFileDiagnostics(managed)); } - - String message = validationEvent.getId() + ": " + validationEvent.getMessage(); - return new Diagnostic(range, message, severity, "Smithy"); } - private static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { - return switch (severity) { - case ERROR, DANGER -> DiagnosticSeverity.Error; - case WARNING -> DiagnosticSeverity.Warning; - case NOTE -> DiagnosticSeverity.Information; - default -> DiagnosticSeverity.Hint; - }; + private CompletableFuture sendFileDiagnostics(ProjectAndFile projectAndFile) { + return CompletableFuture.runAsync(() -> { + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + projectAndFile, this.serverOptions.getMinimumSeverity()); + var publishDiagnosticsParams = new PublishDiagnosticsParams(projectAndFile.uri(), diagnostics); + client.publishDiagnostics(publishDiagnosticsParams); + }); } } diff --git a/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java new file mode 100644 index 00000000..8bc72487 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/WorkspaceChanges.java @@ -0,0 +1,119 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectChange; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +/** + * Aggregates changes to the workspace, including existing project changes and + * new project additions. + */ +final class WorkspaceChanges { + // smithy-build.json + .smithy-project.json + exts + private final Map byProject = new HashMap<>(); + private final List newProjectRoots = new ArrayList<>(); + + private WorkspaceChanges() { + } + + static WorkspaceChanges computeWorkspaceChanges(List events, ServerState state) { + WorkspaceChanges changes = new WorkspaceChanges(); + + List projectFileMatchers = new ArrayList<>(); + state.getAllProjects().forEach(project -> { + if (project.type() == Project.Type.NORMAL) { + projectFileMatchers.add(createProjectFileMatcher(project.root().toString(), project)); + } + }); + + List workspaceBuildFileMatchers = new ArrayList<>(state.workspacePaths().size()); + state.workspacePaths().forEach(workspacePath -> + workspaceBuildFileMatchers.add(FilePatterns.getWorkspaceBuildFilesPathMatcher(workspacePath))); + + for (FileEvent event : events) { + changes.addEvent(event, projectFileMatchers, workspaceBuildFileMatchers); + } + + return changes; + } + + Map byProject() { + return byProject; + } + + List newProjectRoots() { + return newProjectRoots; + } + + private void addEvent( + FileEvent event, + List projectFileMatchers, + List workspaceBuildFileMatchers + ) { + String changedUri = event.getUri(); + Path changedPath = Path.of(LspAdapter.toPath(changedUri)); + for (ProjectFileMatcher projectFileMatcher : projectFileMatchers) { + if (projectFileMatcher.smithyFileMatcher().matches(changedPath)) { + addSmithyFileChange(event.getType(), changedUri, projectFileMatcher.projectName()); + return; + } else if (projectFileMatcher.buildFileMatcher().matches(changedPath)) { + addBuildFileChange(changedUri, projectFileMatcher.projectName()); + return; + } + } + + // If no project matched, maybe there's an added project. + if (event.getType() == FileChangeType.Created) { + for (PathMatcher workspaceBuildFileMatcher : workspaceBuildFileMatchers) { + if (workspaceBuildFileMatcher.matches(changedPath)) { + Path newProjectRoot = changedPath.getParent(); + this.newProjectRoots.add(newProjectRoot); + return; + } + } + } + } + + private void addSmithyFileChange(FileChangeType changeType, String changedUri, String projectName) { + ProjectChange projectChange = byProject.computeIfAbsent( + projectName, ignored -> ProjectChange.empty()); + + switch (changeType) { + case Created -> projectChange.createdSmithyFileUris().add(changedUri); + case Deleted -> projectChange.deletedSmithyFileUris().add(changedUri); + default -> { + } + } + } + + private void addBuildFileChange(String changedUri, String projectName) { + ProjectChange projectChange = byProject.computeIfAbsent( + projectName, ignored -> ProjectChange.empty()); + + projectChange.changedBuildFileUris().add(changedUri); + } + + private record ProjectFileMatcher(String projectName, PathMatcher smithyFileMatcher, PathMatcher buildFileMatcher) { + } + + private static ProjectFileMatcher createProjectFileMatcher(String projectName, Project project) { + PathMatcher smithyFileMatcher = FilePatterns.getSmithyFilesPathMatcher(project); + + PathMatcher buildFileMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); + return new ProjectFileMatcher(projectName, smithyFileMatcher, buildFileMatcher); + } +} + diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java index 2f4452d8..ce9c7652 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java @@ -5,74 +5,241 @@ package software.amazon.smithy.lsp.diagnostics; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DiagnosticCodeDescription; import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.lsp.document.DocumentParser; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; /** - * Utility class for creating different kinds of file diagnostics, that aren't - * necessarily connected to model validation events. + * Creates diagnostics for Smithy files. */ public final class SmithyDiagnostics { public static final String UPDATE_VERSION = "migrating-idl-1-to-2"; public static final String DEFINE_VERSION = "define-idl-version"; public static final String DETACHED_FILE = "detached-file"; + public static final String USE_SMITHY_BUILD = "use-smithy-build"; private static final DiagnosticCodeDescription UPDATE_VERSION_DESCRIPTION = new DiagnosticCodeDescription("https://smithy.io/2.0/guides/migrating-idl-1-to-2.html"); + private static final DiagnosticCodeDescription USE_SMITHY_BUILD_DESCRIPTION = + new DiagnosticCodeDescription("https://smithy.io/2.0/guides/smithy-build-json.html#using-smithy-build-json"); private SmithyDiagnostics() { } /** - * Creates a diagnostic for when a $version control statement hasn't been defined, - * or when it has been defined for IDL 1.0. - * - * @param smithyFile The Smithy file to get a version diagnostic for - * @return The version diagnostic associated with the Smithy file, or null - * if one doesn't exist + * @param projectAndFile Project and file to get diagnostics for + * @param minimumSeverity Minimum severity of validation events to diagnose + * @return A list of diagnostics for the given project and file */ - public static Diagnostic versionDiagnostic(SmithyFile smithyFile) { - if (smithyFile.documentVersion().isPresent()) { - DocumentVersion documentVersion = smithyFile.documentVersion().get(); - if (!documentVersion.version().startsWith("2")) { - Diagnostic diagnostic = createDiagnostic( - documentVersion.range(), "You can upgrade to idl version 2.", UPDATE_VERSION); + public static List getFileDiagnostics(ProjectAndFile projectAndFile, Severity minimumSeverity) { + if (LspAdapter.isJarFile(projectAndFile.uri()) || LspAdapter.isSmithyJarFile(projectAndFile.uri())) { + // Don't send diagnostics to jar files since they can't be edited + // and diagnostics could be misleading. + return List.of(); + } + + Diagnose diagnose = switch (projectAndFile.file()) { + case SmithyFile smithyFile -> new DiagnoseSmithy(smithyFile, projectAndFile.project()); + case BuildFile buildFile -> new DiagnoseBuild(buildFile, projectAndFile.project()); + }; + + String path = projectAndFile.file().path(); + EventToDiagnostic eventToDiagnostic = diagnose.getEventToDiagnostic(); + + List diagnostics = diagnose.getValidationEvents().stream() + .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0 + && event.getSourceLocation().getFilename().equals(path)) + .map(eventToDiagnostic::toDiagnostic) + .collect(Collectors.toCollection(ArrayList::new)); + + diagnose.addExtraDiagnostics(diagnostics); + + return diagnostics; + } + + private sealed interface Diagnose { + List getValidationEvents(); + + EventToDiagnostic getEventToDiagnostic(); + + void addExtraDiagnostics(List diagnostics); + } + + private record DiagnoseSmithy(SmithyFile smithyFile, Project project) implements Diagnose { + @Override + public List getValidationEvents() { + return project.modelResult().getValidationEvents(); + } + + @Override + public EventToDiagnostic getEventToDiagnostic() { + if (!(smithyFile instanceof IdlFile idlFile)) { + return new Simple(); + } + + var idlParse = idlFile.getParse(); + var view = StatementView.createAtStart(idlParse).orElse(null); + if (view == null) { + return new Simple(); + } else { + var documentParser = DocumentParser.forStatements( + smithyFile.document(), view.parseResult().statements()); + return new Idl(view, documentParser); + } + } + + @Override + public void addExtraDiagnostics(List diagnostics) { + Diagnostic versionDiagnostic = versionDiagnostic(smithyFile); + if (versionDiagnostic != null) { + diagnostics.add(versionDiagnostic); + } + + if (project.type() == Project.Type.DETACHED) { + diagnostics.add(detachedDiagnostic(smithyFile)); + } + } + + + private static Diagnostic versionDiagnostic(SmithyFile smithyFile) { + if (!(smithyFile instanceof IdlFile idlFile)) { + return null; + } + + Syntax.IdlParseResult syntaxInfo = idlFile.getParse(); + if (syntaxInfo.version().version().startsWith("2")) { + return null; + } else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) { + var diagnostic = createDiagnostic( + syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION); diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); return diagnostic; + } else { + int end = smithyFile.document().lineEnd(0); + Range range = LspAdapter.lineSpan(0, 0, end); + return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION); } - } 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; + + private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { + Range range; + if (smithyFile.document() == null) { + range = LspAdapter.origin(); + } else { + int end = smithyFile.document().lineEnd(0); + range = LspAdapter.lineSpan(0, 0, end); + } + + return createDiagnostic(range, "This file isn't attached to a project", DETACHED_FILE); + } } - /** - * 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); + private record DiagnoseBuild(BuildFile buildFile, Project project) implements Diagnose { + @Override + public List getValidationEvents() { + return project().configEvents(); } - return createDiagnostic(range, "This file isn't attached to a project", DETACHED_FILE); + @Override + public EventToDiagnostic getEventToDiagnostic() { + return new Simple(); + } + + @Override + public void addExtraDiagnostics(List diagnostics) { + switch (buildFile.type()) { + case SMITHY_BUILD_EXT_0, SMITHY_BUILD_EXT_1 -> diagnostics.add(useSmithyBuild()); + default -> { + } + } + } + + private Diagnostic useSmithyBuild() { + Range range = LspAdapter.origin(); + Diagnostic diagnostic = createDiagnostic( + range, + String.format(""" + You should use smithy-build.json as your build configuration file for Smithy. + The %s file is not supported by Smithy, and support from the language server + will be removed in a later version. + """, buildFile.type().filename()), + USE_SMITHY_BUILD + ); + diagnostic.setCodeDescription(USE_SMITHY_BUILD_DESCRIPTION); + return diagnostic; + } } private static Diagnostic createDiagnostic(Range range, String title, String code) { return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code); } + + private sealed interface EventToDiagnostic { + default Range getDiagnosticRange(ValidationEvent event) { + var start = LspAdapter.toPosition(event.getSourceLocation()); + var end = LspAdapter.toPosition(event.getSourceLocation()); + end.setCharacter(end.getCharacter() + 1); // Range is exclusive + + return new Range(start, end); + } + + default Diagnostic toDiagnostic(ValidationEvent event) { + var diagnosticSeverity = switch (event.getSeverity()) { + case ERROR, DANGER -> DiagnosticSeverity.Error; + case WARNING -> DiagnosticSeverity.Warning; + case NOTE -> DiagnosticSeverity.Information; + default -> DiagnosticSeverity.Hint; + }; + var diagnosticRange = getDiagnosticRange(event); + var message = event.getId() + ": " + event.getMessage(); + return new Diagnostic(diagnosticRange, message, diagnosticSeverity, "Smithy"); + } + } + + private record Simple() implements EventToDiagnostic {} + + private record Idl(StatementView view, DocumentParser parser) implements EventToDiagnostic { + @Override + public Range getDiagnosticRange(ValidationEvent event) { + Position eventStart = LspAdapter.toPosition(event.getSourceLocation()); + final Range defaultRange = EventToDiagnostic.super.getDiagnosticRange(event); + + if (event.getShapeId().isPresent()) { + int eventStartIndex = parser.getDocument().indexOfPosition(eventStart); + Syntax.Statement statement = view.getStatementAt(eventStartIndex).orElse(null); + + if (statement instanceof Syntax.Statement.MemberDef def + && event.containsId("Target") + && def.target() != null) { + Range targetRange = LspAdapter.identRange(def.target(), parser.getDocument()); + return Objects.requireNonNullElse(targetRange, defaultRange); + } else if (statement instanceof Syntax.Statement.TraitApplication app) { + Range traitIdRange = LspAdapter.identRange(app.id(), parser.getDocument()); + if (traitIdRange != null) { + traitIdRange.getStart().setCharacter(traitIdRange.getStart().getCharacter() - 1); // include @ + } + return Objects.requireNonNullElse(traitIdRange, defaultRange); + } + } + + return Objects.requireNonNullElse(parser.findContiguousRange(event.getSourceLocation()), defaultRange); + } + } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java index 75ee0e15..671e6c4c 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -11,6 +11,7 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; /** * In-memory representation of a text document, indexed by line, which can @@ -97,20 +98,31 @@ public int indexOfLine(int line) { * doesn't exist */ public int lineOfIndex(int idx) { - // TODO: Use binary search or similar - if (idx >= length() || idx < 0) { - return -1; - } - - for (int line = 0; line <= lastLine() - 1; line++) { - int currentLineIdx = indexOfLine(line); - int nextLineIdx = indexOfLine(line + 1); - if (idx >= currentLineIdx && idx < nextLineIdx) { - return line; + int low = 0; + int up = lastLine(); + + while (low <= up) { + int mid = (low + up) / 2; + int midLineIdx = lineIndices[mid]; + int midLineEndIdx = lineEndUnchecked(mid); + if (idx >= midLineIdx && idx <= midLineEndIdx) { + return mid; + } else if (idx < midLineIdx) { + up = mid - 1; + } else { + low = mid + 1; } } - return lastLine(); + return -1; + } + + private int lineEndUnchecked(int line) { + if (line == lastLine()) { + return length() - 1; + } else { + return lineIndices[line + 1] - 1; + } } /** @@ -135,7 +147,6 @@ public int indexOfPosition(int line, int character) { return -1; } - int idx = startLineIdx + character; if (line == lastLine()) { if (idx >= buffer.length()) { @@ -167,6 +178,61 @@ public Position positionAtIndex(int index) { return new Position(line, character); } + /** + * @param start The start character offset + * @param end The end character offset + * @return The range between the two given offsets + */ + public Range rangeBetween(int start, int end) { + if (end < start || start < 0) { + return null; + } + + // The start is inclusive, so it should be within the bounds of the document + Position startPos = positionAtIndex(start); + if (startPos == null) { + return null; + } + + Position endPos; + if (end == length()) { + endPos = end(); + } else { + endPos = positionAtIndex(end); + } + + return new Range(startPos, endPos); + } + + /** + * @param item The item to get the range of + * @return The range of the item + */ + public Range rangeOf(Syntax.Item item) { + return rangeBetween(item.start(), item.end()); + } + + /** + * @param token The token to get the range of + * @return The range of the token, excluding enclosing "" + */ + public Range rangeOfValue(Syntax.Node.Str token) { + int lineStart = indexOfLine(token.lineNumber()); + if (lineStart < 0) { + return null; + } + + int startChar = token.start() - lineStart; + int endChar = token.end() - lineStart; + + if (token.type() == Syntax.Node.Type.Str) { + startChar += 1; + endChar -= 1; + } + + return new Range(new Position(token.lineNumber(), startChar), new Position(token.lineNumber(), endChar)); + } + /** * @param line The line to find the end of * @return The index of the end of the given line, or {@code -1} if the @@ -195,9 +261,7 @@ public int lastLine() { * @return The end position of this document */ public Position end() { - return new Position( - lineIndices.length - 1, - buffer.length() - lineIndices[lineIndices.length - 1]); + return new Position(lastLine(), lastColExclusive()); } /** @@ -220,23 +284,6 @@ public int lastIndexOf(String s, int before) { return buffer.lastIndexOf(s, before); } - /** - * @param c The character to find the last index of - * @param before The index to stop the search at - * @param line The line to search within - * @return The index of the last occurrence of {@code c} before {@code before} - * on the line {@code line} or {@code -1} if one doesn't exist - */ - int lastIndexOfOnLine(char c, int before, int line) { - int lineIdx = indexOfLine(line); - for (int i = before; i >= lineIdx; i--) { - if (buffer.charAt(i) == c) { - return i; - } - } - return -1; - } - /** * @return A reference to the text in this document */ @@ -244,124 +291,6 @@ public CharSequence borrowText() { return buffer; } - /** - * @param range The range to borrow the text of - * @return A reference to the text in this document within the given {@code range} - * or {@code null} if the range is out of bounds - */ - public CharBuffer borrowRange(Range range) { - int startLine = range.getStart().getLine(); - int startChar = range.getStart().getCharacter(); - int endLine = range.getEnd().getLine(); - int endChar = range.getEnd().getCharacter(); - - // TODO: Maybe make this return the whole thing, thing up to an index, or thing after an - // index if one of the indicies is out of bounds. - int startLineIdx = indexOfLine(startLine); - int endLineIdx = indexOfLine(endLine); - if (startLineIdx < 0 || endLineIdx < 0) { - return null; - } - - int startIdx = startLineIdx + startChar; - int endIdx = endLineIdx + endChar; - if (startIdx > buffer.length() || endIdx > buffer.length()) { - return null; - } - - return CharBuffer.wrap(buffer, startIdx, endIdx); - } - - /** - * @param position The position within the token to borrow - * @return A reference to the token that the given {@code position} is - * within, or {@code null} if the position is not within a token - */ - public CharBuffer borrowToken(Position position) { - int idx = indexOfPosition(position); - if (idx < 0) { - return null; - } - - char atIdx = buffer.charAt(idx); - // Not a token - if (!Character.isLetterOrDigit(atIdx) && atIdx != '_') { - return null; - } - - int startIdx = idx; - while (startIdx >= 0) { - char c = buffer.charAt(startIdx); - if (Character.isLetterOrDigit(c) || c == '_') { - startIdx--; - } else { - break; - } - } - - int endIdx = idx; - while (endIdx < buffer.length()) { - char c = buffer.charAt(endIdx); - if (Character.isLetterOrDigit(c) || c == '_') { - endIdx++; - } else { - break; - } - } - - return CharBuffer.wrap(buffer, startIdx + 1, endIdx); - } - - /** - * @param position The position within the id to borrow - * @return A reference to the id that the given {@code position} is - * within, or {@code null} if the position is not within an id - */ - public CharBuffer borrowId(Position position) { - DocumentId id = copyDocumentId(position); - if (id == null) { - return null; - } - return id.idSlice(); - } - - /** - * @param line The line to borrow - * @return A reference to the text in the given line, or {@code null} if - * 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 */ @@ -375,38 +304,20 @@ public String copyText() { * or {@code null} if the range is out of bounds */ public String copyRange(Range range) { - CharBuffer borrowed = borrowRange(range); - if (borrowed == null) { - return null; - } - - return borrowed.toString(); - } + int start = indexOfPosition(range.getStart()); - /** - * @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; + int end; + Position endPosition = range.getEnd(); + if (endPosition.getLine() == lastLine() && endPosition.getCharacter() == lastColExclusive()) { + end = length(); + } else { + end = indexOfPosition(range.getEnd()); } - return token.toString(); + return copySpan(start, end); } - /** - * @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(); + private int lastColExclusive() { + return length() - lineIndices[lastLine()]; } /** @@ -425,81 +336,48 @@ public DocumentId copyDocumentId(Position position) { return null; } - boolean hasHash = false; - boolean hasDollar = false; - boolean hasDot = false; + boolean isMember = false; int startIdx = idx; while (startIdx >= 0) { char c = buffer.charAt(startIdx); - if (isIdChar(c)) { - switch (c) { - case '#': - hasHash = true; - break; - case '$': - hasDollar = true; - break; - case '.': - hasDot = true; - break; - default: - break; - } - startIdx -= 1; - } else { + if (!isIdChar(c)) { break; } + + if (c == '$') { + isMember = true; + } + + startIdx -= 1; } int endIdx = idx; while (endIdx < buffer.length()) { char c = buffer.charAt(endIdx); - if (isIdChar(c)) { - switch (c) { - case '#': - hasHash = true; - break; - case '$': - hasDollar = true; - break; - case '.': - hasDot = true; - break; - default: - break; - } + if (!isIdChar(c)) { + break; + } - endIdx += 1; - } else { + if (!isMember && c == '$') { break; } - } + endIdx += 1; + } - // TODO: This can be improved to do some extra validation, like if - // there's more than 1 hash or $, its invalid. Additionally, we - // should only give a type of *WITH_MEMBER if the position is on - // the member itself. We will probably need to add some logic or - // keep track of the member itself in order to properly match the - // RELATIVE_WITH_MEMBER type in handlers. DocumentId.Type type; - if (hasHash && hasDollar) { - type = DocumentId.Type.ABSOLUTE_WITH_MEMBER; - } else if (hasHash) { - type = DocumentId.Type.ABSOLUTE_ID; - } else if (hasDollar) { - type = DocumentId.Type.RELATIVE_WITH_MEMBER; - } else if (hasDot) { - type = DocumentId.Type.NAMESPACE; + if (isMember) { + type = DocumentId.Type.MEMBER; } else { - type = DocumentId.Type.ID; + type = DocumentId.Type.ROOT; } - 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); + // Add one since we went past the start when breaking from the loop. + // Not necessary for endIdx, because we want it to be one past the last + // character. + int startCharIdx = startIdx + 1; + CharBuffer wrapped = CharBuffer.wrap(buffer, startCharIdx, endIdx); + Range range = rangeBetween(startCharIdx, endIdx); return new DocumentId(type, wrapped, range); } @@ -507,19 +385,6 @@ private static boolean isIdChar(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; } - /** - * @param line The line to copy - * @return A copy of the text in the given line, or {@code null} if the line - * doesn't exist - */ - public String copyLine(int line) { - CharBuffer borrowed = borrowLine(line); - if (borrowed == null) { - return null; - } - return borrowed.toString(); - } - /** * @param start The index of the start of the span to copy * @param end The index of the end of the span to copy @@ -527,11 +392,16 @@ public String copyLine(int line) { * {@code end}, or {@code null} if the span is out of bounds or start > end */ public String copySpan(int start, int end) { - CharBuffer borrowed = borrowSpan(start, end); - if (borrowed == null) { + if (start < 0 || end < 0) { + return null; + } + + // end is exclusive + if (end > buffer.length() || start > end) { return null; } - return borrowed.toString(); + + return CharBuffer.wrap(buffer, start, end).toString(); } /** @@ -541,18 +411,6 @@ public int length() { return buffer.length(); } - /** - * @param index The index to get the character at - * @return The character at the given index, or {@code \u0000} if one - * doesn't exist - */ - char charAt(int index) { - if (index < 0 || index >= length()) { - return '\u0000'; - } - return buffer.charAt(index); - } - // Adapted from String::split private static int[] computeLineIndicies(StringBuilder buffer) { int matchCount = 0; diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java index ec7c5f39..9571805a 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -23,32 +23,31 @@ public record DocumentId(Type type, CharBuffer idSlice, Range range) { */ public enum Type { /** - * Just a shape name, no namespace or member. + * A root shape id, i.e. without trailing '$member'. May or may not + * have a leading namespace. */ - ID, + ROOT, /** - * Same as {@link Type#ID}, but with a namespace. + * A shape id with a member, i.e. with trailing '$member'. May or may + * not have a leading namespace. May or may not have a root shape name + * before the '$member'. */ - ABSOLUTE_ID, - - /** - * Just a namespace - will have one or more {@code .}. - */ - NAMESPACE, - - /** - * Same as {@link Type#ABSOLUTE_ID}, but with a member - will have a {@code $}. - */ - ABSOLUTE_WITH_MEMBER, - - /** - * Same as {@link Type#ID}, but with a member - will have a {@code $}. - */ - RELATIVE_WITH_MEMBER + MEMBER } public String copyIdValue() { return idSlice.toString(); } + + /** + * @return The value of the id without a leading '$' + */ + public String copyIdValueForElidedMember() { + String idValue = copyIdValue(); + if (idValue.startsWith("$")) { + return idValue.substring(1); + } + return idValue; + } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java index 0c5d9c60..47eefd7e 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java @@ -7,6 +7,7 @@ import java.util.Set; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The imports of a document, including the range they occupy. @@ -14,4 +15,6 @@ * @param importsRange The range of the imports * @param imports The set of imported shape ids. They are not guaranteed to be valid shape ids */ -public record DocumentImports(Range importsRange, Set imports) {} +public record DocumentImports(Range importsRange, Set imports) { + static final DocumentImports EMPTY = new DocumentImports(LspAdapter.origin(), Set.of()); +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java index 94c8b79b..d6e6ce39 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp.document; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The namespace of the document, including the range it occupies. @@ -13,4 +14,6 @@ * @param statementRange The range of the statement, including {@code namespace} * @param namespace The namespace of the document. Not guaranteed to be a valid namespace */ -public record DocumentNamespace(Range statementRange, CharSequence namespace) {} +public record DocumentNamespace(Range statementRange, String namespace) { + static final DocumentNamespace NONE = new DocumentNamespace(LspAdapter.origin(), ""); +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java index 3982447b..6b322ee6 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -5,303 +5,112 @@ package software.amazon.smithy.lsp.document; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; +import java.util.List; import java.util.Set; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.loader.ParserUtils; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.node.StringNode; -import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.utils.SimpleParser; /** - * 'Parser' that uses the line-indexed property of the underlying {@link Document} - * to jump around the document, parsing small pieces without needing to start at - * the beginning. - * - *

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

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

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

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

+ * + *

See the Smithy model for docs on the specific shapes.

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

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

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

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

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

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

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

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

+ * + * @implNote One of the ideas behind this is that you should not have to pay for + * computing the member target unless necessary. + */ +sealed interface DynamicMemberTarget { + /** + * @param parent The parent node containing the member. + * @param model The model being traversed. + * @return The target of the member shape at the cursor's current position. + */ + Shape getTarget(Syntax.Node parent, Model model); + + static Map forTrait(Shape traitShape, IdlPosition.TraitValue traitValue) { + Syntax.IdlParseResult syntaxInfo = traitValue.view().parseResult(); + return switch (traitShape.getId().toString()) { + case "smithy.test#smokeTests" -> Map.of( + ShapeId.from("smithy.test#SmokeTestCase$params"), + new OperationInput(traitValue), + ShapeId.from("smithy.test#SmokeTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); + + case "smithy.api#examples" -> Map.of( + ShapeId.from("smithy.api#Example$input"), + new OperationInput(traitValue), + ShapeId.from("smithy.api#Example$output"), + new OperationOutput(traitValue)); + + case "smithy.test#httpRequestTests" -> Map.of( + ShapeId.from("smithy.test#HttpRequestTestCase$params"), + new OperationInput(traitValue), + ShapeId.from("smithy.test#HttpRequestTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); + + case "smithy.test#httpResponseTests" -> Map.of( + ShapeId.from("smithy.test#HttpResponseTestCase$params"), + new OperationOutput(traitValue), + ShapeId.from("smithy.test#HttpResponseTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); + + default -> null; + }; + } + + static Map forMetadata(String metadataKey) { + return switch (metadataKey) { + case "validators" -> Map.of( + ShapeId.from("smithy.lang.server#Validator$configuration"), new MappedDependent( + "name", + Builtins.VALIDATOR_CONFIG_MAPPING)); + default -> null; + }; + } + + /** + * Computes the input shape of the operation targeted by {@code traitValue}, + * to use as the member target. + * + * @param traitValue The position, in the applied trait value. + */ + record OperationInput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget { + @Override + public Shape getTarget(Syntax.Node parent, Model model) { + return ShapeSearch.findTraitTarget(traitValue, model) + .flatMap(Shape::asOperationShape) + .flatMap(operationShape -> model.getShape(operationShape.getInputShape())) + .orElse(null); + } + } + + /** + * Computes the output shape of the operation targeted by {@code traitValue}, + * to use as the member target. + * + * @param traitValue The position, in the applied trait value. + */ + record OperationOutput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget { + @Override + public Shape getTarget(Syntax.Node parent, Model model) { + return ShapeSearch.findTraitTarget(traitValue, model) + .flatMap(Shape::asOperationShape) + .flatMap(operationShape -> model.getShape(operationShape.getOutputShape())) + .orElse(null); + } + } + + /** + * Computes the value of another member in the node, {@code memberName}, + * using that as the id of the target shape. + * + * @param memberName The name of the other member to compute the value of. + * @param parseResult The parse result of the file the node is within. + */ + record ShapeIdDependent(String memberName, Syntax.IdlParseResult parseResult) implements DynamicMemberTarget { + @Override + public Shape getTarget(Syntax.Node parent, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, parent); + if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) { + String id = str.stringValue(); + return ShapeSearch.findShape(parseResult, id, model).orElse(null); + } + return null; + } + } + + /** + * Computes the value of another member in the node, {@code memberName}, + * and looks up the id of the target shape from {@code mapping} using that + * value. + * + * @param memberName The name of the member to compute the value of. + * @param mapping A mapping of {@code memberName} values to corresponding + * member target ids. + */ + record MappedDependent(String memberName, Map mapping) implements DynamicMemberTarget { + @Override + public Shape getTarget(Syntax.Node parent, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, parent); + if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) { + String value = str.stringValue(); + ShapeId targetId = mapping.get(value); + if (targetId != null) { + return model.getShape(targetId).orElse(null); + } + } + return null; + } + } + + // Note: This is suboptimal in isolation, but it should be called rarely in + // comparison to parsing or NodeCursor construction, which are optimized for + // speed and memory usage (instead of key lookup), and the number of keys + // is assumed to be low in most cases. + private static Syntax.Node.Kvp findMatchingKvp(String keyName, Syntax.Node parent) { + // This will be called after skipping a ValueForKey, so that will be previous + if (parent instanceof Syntax.Node.Kvps kvps) { + for (Syntax.Node.Kvp kvp : kvps.kvps()) { + String key = kvp.key().stringValue(); + if (!keyName.equals(key)) { + continue; + } + + return kvp; + } + } + return null; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java b/src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java new file mode 100644 index 00000000..62653db5 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/FoldingRangeHandler.java @@ -0,0 +1,140 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import org.eclipse.lsp4j.FoldingRange; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.document.DocumentImports; +import software.amazon.smithy.lsp.syntax.Syntax; + + +public record FoldingRangeHandler(Document document, DocumentImports documentImports, + List statements) { + /** + * Main public handle function in the handler class. + * + * @return A list of FoldingRange + */ + public List handle() { + return generateFoldingRanges(); + } + + private boolean isFoldable(int startLine, int endLine) { + // If the statement or node takes up at least two lines, it is foldable + return endLine > startLine; + } + + private void addFoldingRange(List foldingRanges, int startIndex, int endIndex) { + int startLine = document.lineOfIndex(startIndex); + int endLine = document.lineOfIndex(endIndex); + if (isFoldable(startLine, endLine)) { + foldingRanges.add(new FoldingRange(startLine, endLine)); + } + } + + private void addFoldingRangeForImports(List foldingRanges) { + Range range = documentImports.importsRange(); + if (range != null && isFoldable(range.getStart().getLine(), range.getEnd().getLine())) { + foldingRanges.add(new FoldingRange(range.getStart().getLine(), range.getEnd().getLine())); + } + } + + private List generateFoldingRanges() { + List foldingRanges = new ArrayList<>(); + + addFoldingRangeForImports(foldingRanges); + + ListIterator iterator = statements.listIterator(); + + while (iterator.hasNext()) { + var statement = iterator.next(); + switch (statement) { + case Syntax.Statement.TraitApplication trait -> + processFoldingRangeForTraitApplication(foldingRanges, trait, iterator); + + case Syntax.Statement.Metadata metadata -> + processFoldingRangeForNode(foldingRanges, metadata.value()); + + case Syntax.Statement.Block blk -> + processFoldingRangeForBlock(foldingRanges, blk); + + case Syntax.Statement.NodeMemberDef nodeMember -> + processFoldingRangeForNode(foldingRanges, nodeMember.value()); + + case Syntax.Statement.InlineMemberDef inlineMember -> + addFoldingRange(foldingRanges, inlineMember.start(), inlineMember.end()); + // Skip the statements don't need to be folded. + default -> { + } + } + } + return foldingRanges; + } + + private void processFoldingRangeForBlock(List foldingRanges, Syntax.Statement.Block blk) { + // If the block is empty, the last statement index will not be set. + if (blk.lastStatementIndex() == blk.statementIndex()) { + return; + } + addFoldingRange(foldingRanges, blk.start(), blk.end()); + } + + private void processFoldingRangeForTraitApplication(List foldingRanges, + Syntax.Statement.TraitApplication trait, + ListIterator iterator) { + int traitBlockStart = trait.start(); + int traitBlockEnd = -1; + // Create folding range for the start trait statement. + processFoldingRangeForNode(foldingRanges, trait.value()); + // Find next non-trait statement and create folding range for the statement traversed. + while (iterator.hasNext()) { + var nextStatement = iterator.next(); + + if (nextStatement instanceof Syntax.Statement.TraitApplication nextTrait) { + traitBlockEnd = nextTrait.value() == null ? nextTrait.end() : nextTrait.value().end(); + processFoldingRangeForNode(foldingRanges, nextTrait.value()); + } else { + iterator.previous(); + break; + } + } + //Single nested trait is handled by processFoldingRangeForNode. + if (traitBlockEnd != -1) { + addFoldingRange(foldingRanges, traitBlockStart, traitBlockEnd); + } + } + + private void processFoldingRangeForNode(List foldingRanges, Syntax.Node node) { + if (node == null) { + return; + } + + switch (node) { + case Syntax.Node.Kvps kvps -> { + if (!kvps.kvps().isEmpty()) { + addFoldingRange(foldingRanges, kvps.start(), kvps.end()); + kvps.kvps().forEach(kvp -> processFoldingRangeForNode(foldingRanges, kvp.value())); + } + } + // Obj only contains kvps, will use kvps as its folding range + case Syntax.Node.Obj obj -> processFoldingRangeForNode(foldingRanges, obj.kvps()); + + case Syntax.Node.Arr arr -> { + if (!arr.elements().isEmpty()) { + addFoldingRange(foldingRanges, arr.start(), arr.end()); + arr.elements().forEach(element -> processFoldingRangeForNode(foldingRanges, element)); + } + } + // Skip the Nodes don't need to be folded + default -> { + } + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java new file mode 100644 index 00000000..1be5be6b --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -0,0 +1,302 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.DocumentationTrait; +import software.amazon.smithy.model.traits.ExternalDocumentationTrait; +import software.amazon.smithy.model.validation.ValidatedResult; + +/** + * Handles hover requests for the Smithy IDL. + */ +public final class HoverHandler { + /** + * Empty markdown hover content. + */ + public static final Hover EMPTY = new Hover(new MarkupContent("markdown", "")); + + private final Project project; + private final IdlFile smithyFile; + + /** + * @param project Project the hover is in + * @param smithyFile Smithy file the hover is in + */ + public HoverHandler(Project project, IdlFile smithyFile) { + this.project = project; + this.smithyFile = smithyFile; + } + + /** + * @param params The request params + * @return The hover content + */ + public Hover handle(HoverParams params) { + Position position = params.getPosition(); + DocumentId id = smithyFile.document().copyDocumentId(position); + if (id == null || id.idSlice().isEmpty()) { + return EMPTY; + } + + Syntax.IdlParseResult parseResult = smithyFile.getParse(); + int documentIndex = smithyFile.document().indexOfPosition(position); + IdlPosition idlPosition = StatementView.createAt(parseResult, documentIndex) + .map(IdlPosition::of) + .orElse(null); + + return switch (idlPosition) { + case IdlPosition.ControlKey ignored -> Builtins.CONTROL.getMember(id.copyIdValueForElidedMember()) + .flatMap(HoverHandler::withBuiltinShapeDocs) + .orElse(EMPTY); + + case IdlPosition.MetadataKey ignored -> Builtins.METADATA.getMember(id.copyIdValue()) + .flatMap(HoverHandler::withBuiltinShapeDocs) + .orElse(EMPTY); + + case IdlPosition.MetadataValue metadataValue -> takeShapeReference( + ShapeSearch.searchMetadataValue(metadataValue)) + .flatMap(HoverHandler::withBuiltinShapeDocs) + .orElse(EMPTY); + + case IdlPosition.StatementKeyword ignored -> Builtins.SHAPE_MEMBER_TARGETS.getMember(id.copyIdValue()) + .or(() -> Builtins.NON_SHAPE_KEYWORDS.getMember(id.copyIdValue())) + .flatMap(HoverHandler::withBuiltinShapeDocs) + .orElse(EMPTY); + + case IdlPosition.MemberName memberName -> getBuiltinMember(memberName) + .flatMap(HoverHandler::withBuiltinShapeDocs) + // Fall back to user model hover, since we didn't find a matching builtin shape with docs + .orElseGet(() -> modelSensitiveHover(id, memberName)); + + case null -> EMPTY; + + default -> modelSensitiveHover(id, idlPosition); + }; + } + + private static Optional takeShapeReference(NodeSearch.Result result) { + return switch (result) { + case NodeSearch.Result.TerminalShape terminalShape + when terminalShape.isIdRef() -> Optional.of(terminalShape.shape()); + + case NodeSearch.Result.ObjectKey(NodeCursor.Key key, Shape containerShape, var ignored) + when !containerShape.isMapShape() -> containerShape.getMember(key.name()); + + default -> Optional.empty(); + }; + } + + private static Optional getBuiltinMember(IdlPosition.MemberName memberName) { + var shapeDef = memberName.view().nearestShapeDefBefore(); + if (shapeDef == null) { + return Optional.empty(); + } + + String shapeType = shapeDef.shapeType().stringValue(); + StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType); + if (shapeMembersDef == null) { + return Optional.empty(); + } + + return shapeMembersDef.getMember(memberName.name()); + } + + private Hover modelSensitiveHover(DocumentId id, IdlPosition idlPosition) { + ValidatedResult validatedModel = project.modelResult(); + if (validatedModel.getResult().isEmpty()) { + return EMPTY; + } + + Model model = validatedModel.getResult().get(); + Optional matchingShape = switch (idlPosition) { + // TODO: Handle resource ids and properties. This only works for mixins right now. + case IdlPosition.ElidedMember elidedMember -> + ShapeSearch.findElidedMemberParent(elidedMember, id, model) + .flatMap(shape -> shape.getMember(id.copyIdValueForElidedMember())); + + default -> ShapeSearch.findShapeDefinition(idlPosition, id, model); + }; + + if (matchingShape.isEmpty()) { + return EMPTY; + } + + return withShape(matchingShape.get(), model); + } + + private Hover withShape(Shape shape, Model model) { + String serializedShape = switch (shape) { + case MemberShape memberShape -> serializeMember(memberShape); + default -> serializeShape(model, shape); + }; + + if (serializedShape == null) { + return EMPTY; + } + + String hoverContent = String.format(""" + ```smithy + %s + ``` + """, serializedShape); + + // TODO: Add docs to a separate section of the hover content + // if (shapeToSerialize.hasTrait(DocumentationTrait.class)) { + // String docs = shapeToSerialize.expectTrait(DocumentationTrait.class).getValue(); + // hoverContent.append("\n---\n").append(docs); + // } + + return withMarkupContents(hoverContent); + } + + // Note: This isn't used for user-defined shapes because we include docs + // in the serialized hover content. + static Optional withBuiltinShapeDocs(Shape shape) { + StringBuilder builder = new StringBuilder(); + + var builtinShapeDocs = BuiltinShapeDocs.forShape(shape); + + if (!builtinShapeDocs.shapeDocs.isEmpty()) { + builder.append(builtinShapeDocs.shapeDocs); + + if (!builtinShapeDocs.externalDocs.isEmpty()) { + // Space out regular docs and external docs so they're easier to read. + builder.append(System.lineSeparator()).append(System.lineSeparator()); + } + } + + builtinShapeDocs.externalDocs + .forEach((url, doc) -> builder.append(String.format("[%s](%s)%n", url, doc))); + + if (builder.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(new Hover(new MarkupContent("markdown", builder.toString()))); + } + + private record BuiltinShapeDocs(String shapeDocs, Map externalDocs) { + private static BuiltinShapeDocs forShape(Shape shape) { + var shapeDocs = shape.getTrait(DocumentationTrait.class) + .map(DocumentationTrait::getValue) + .orElse(""); + + Map externalDocs = new HashMap<>(); + + shape.getTrait(ExternalDocumentationTrait.class) + .map(ExternalDocumentationTrait::getUrls) + .ifPresent(externalDocs::putAll); + + // The builtins model defines some external docs on root shapes, which are meant to be + // included in the hover content for all members so we can always provide a link to + // Smithy's docs, even if the member itself doesn't have a specific link that would + // make sense. + shape.asMemberShape() + .map(MemberShape::getContainer) + .flatMap(Builtins.MODEL::getShape) + .flatMap(container -> container.getTrait(ExternalDocumentationTrait.class)) + .map(ExternalDocumentationTrait::getUrls) + .ifPresent(externalDocs::putAll); + + return new BuiltinShapeDocs(shapeDocs, externalDocs); + } + } + + private static Hover withMarkupContents(String text) { + return new Hover(new MarkupContent("markdown", text)); + } + + private static String serializeMember(MemberShape memberShape) { + StringBuilder contents = new StringBuilder(); + contents.append("namespace") + .append(" ") + .append(memberShape.getId().getNamespace()) + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + memberShape.getTrait(DocumentationTrait.class) + .map(DocumentationTrait::getValue) + .ifPresent(docs -> addMemberDocs(contents, docs)); + + for (var trait : memberShape.getAllTraits().values()) { + if (trait.toShapeId().equals(DocumentationTrait.ID)) { + continue; + } + + contents.append("@") + .append(trait.toShapeId().getName()) + .append("(") + .append(Node.printJson(trait.toNode())) + .append(")") + .append(System.lineSeparator()); + } + + contents.append(memberShape.getMemberName()) + .append(": ") + .append(memberShape.getTarget().getName()) + .append(System.lineSeparator()); + return contents.toString(); + } + + private static void addMemberDocs(StringBuilder builder, String docs) { + builder.append("/// ") + // Replace newline literals in the doc string with actual newlines, and /// so we can render + // an IDL doc comment. + .append(docs.replaceAll( + Matcher.quoteReplacement(System.lineSeparator()), System.lineSeparator() + "/// ") + .trim()) + .append(System.lineSeparator()); + + } + + private static String serializeShape(Model model, Shape shape) { + SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder() + .metadataFilter(key -> false) + .shapeFilter(s -> s.getId().equals(shape.getId())) + // TODO: If we remove the documentation trait in the serializer, + // it also gets removed from members. This causes weird behavior if + // there are applied traits (such as through mixins), where you get + // an empty apply because the documentation trait was removed + // .traitFilter(trait -> !trait.toShapeId().equals(DocumentationTrait.ID)) + .serializePrelude() + .build(); + Map serialized = serializer.serialize(model); + Path path = Paths.get(shape.getId().getNamespace() + ".smithy"); + if (!serialized.containsKey(path)) { + return null; + } + + String serializedShape = serialized.get(path) + .substring(15) // remove '$version: "2.0"' + .trim() + .replaceAll(Matcher.quoteReplacement( + // Replace newline literals with actual newlines + System.lineSeparator() + System.lineSeparator()), System.lineSeparator()); + return serializedShape; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java new file mode 100644 index 00000000..00b5f7e8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java @@ -0,0 +1,133 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; + +/** + * Represents different kinds of positions within an IDL file. + */ +sealed interface IdlPosition { + /** + * @return Whether the token at this position is definitely a reference + * to a root/top-level shape. + */ + default boolean isRootShapeReference() { + return switch (this) { + case TraitId ignored -> true; + case MemberTarget ignored -> true; + case ShapeDef ignored -> true; + case ForResource ignored -> true; + case Mixin ignored -> true; + case UseTarget ignored -> true; + case ApplyTarget ignored -> true; + default -> false; + }; + } + + /** + * @return The view this position is within. + */ + StatementView view(); + + record TraitId(StatementView view) implements IdlPosition {} + + record MemberTarget(StatementView view) implements IdlPosition {} + + record ShapeDef(StatementView view) implements IdlPosition {} + + record Mixin(StatementView view) implements IdlPosition {} + + record ApplyTarget(StatementView view) implements IdlPosition {} + + record UseTarget(StatementView view) implements IdlPosition {} + + record Namespace(StatementView view) implements IdlPosition {} + + record TraitValue(StatementView view, Syntax.Statement.TraitApplication application) implements IdlPosition {} + + record NodeMemberTarget(StatementView view, Syntax.Statement.NodeMemberDef nodeMember) implements IdlPosition {} + + record ControlKey(StatementView view) implements IdlPosition {} + + record MetadataKey(StatementView view) implements IdlPosition {} + + record MetadataValue(StatementView view, Syntax.Statement.Metadata metadata) implements IdlPosition {} + + record StatementKeyword(StatementView view) implements IdlPosition {} + + record MemberName(StatementView view, String name) implements IdlPosition {} + + record ElidedMember(StatementView view) implements IdlPosition {} + + record ForResource(StatementView view) implements IdlPosition {} + + record Unknown(StatementView view) implements IdlPosition {} + + static IdlPosition of(StatementView view) { + int documentIndex = view.documentIndex(); + + if (view.getStatement().isInKeyword(documentIndex)) { + return new StatementKeyword(view); + } + + return switch (view.getStatement()) { + case Syntax.Statement.Incomplete incomplete + when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); + + case Syntax.Statement.Apply apply + when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(view); + + case Syntax.Statement.Metadata m + when m.key().isIn(documentIndex) -> new IdlPosition.MetadataKey(view); + + case Syntax.Statement.Metadata m + when m.value() != null && m.value().isIn(documentIndex) -> new IdlPosition.MetadataValue(view, m); + + case Syntax.Statement.Control c + when c.key().isIn(documentIndex) -> new IdlPosition.ControlKey(view); + + case Syntax.Statement.TraitApplication t + when t.id().isEmpty() || t.id().isIn(documentIndex) -> new IdlPosition.TraitId(view); + + case Syntax.Statement.Use u + when u.use().isIn(documentIndex) -> new IdlPosition.UseTarget(view); + + case Syntax.Statement.MemberDef m + when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(view); + + case Syntax.Statement.MemberDef m + when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view, m.name().stringValue()); + + case Syntax.Statement.NodeMemberDef m + when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget(view, m); + + case Syntax.Statement.Namespace n + when n.namespace().isIn(documentIndex) -> new IdlPosition.Namespace(view); + + case Syntax.Statement.TraitApplication t + when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue(view, t); + + case Syntax.Statement.InlineMemberDef m + when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view, m.name().stringValue()); + + case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember(view); + + case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(view); + + case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(view); + + case Syntax.Statement.NodeMemberDef m -> new IdlPosition.MemberName(view, m.name().stringValue()); + + case Syntax.Statement.Block ignored -> new IdlPosition.MemberName(view, ""); + + case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(view); + + default -> new IdlPosition.Unknown(view); + }; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java b/src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java new file mode 100644 index 00000000..4610831c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/InlayHintHandler.java @@ -0,0 +1,111 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; + +public record InlayHintHandler(Document document, + List statements, + Range hintRange) { + + private static final String OPERATION_TYPE = "operation"; + private static final String INPUT_TYPE = "input"; + private static final String OUTPUT_TYPE = "output"; + private static final String DEFAULT_INPUT_SUFFIX = "Input"; + private static final String DEFAULT_OUTPUT_SUFFIX = "Output"; + private static final String OPERATION_INPUT_SUFFIX = "operationInputSuffix"; + private static final String OPERATION_OUTPUT_SUFFIX = "operationOutputSuffix"; + + /** + * Main public handle function in the handler class. + * + * @return A list of Inlay hints + */ + public List handle() { + return processInlayHints(); + } + + private IOSuffix getIOSuffix(ListIterator iterator) { + // Default value for IO Suffix + String inputSuffix = DEFAULT_INPUT_SUFFIX; + String outputSuffix = DEFAULT_OUTPUT_SUFFIX; + + while (iterator.hasNext()) { + var statement = iterator.next(); + // Pattern match used for the following two statement to cast them to ideal Statement or Node type. + if (statement instanceof Syntax.Statement.Control control) { + if (control.value() instanceof Syntax.Node.Str str) { + String key = control.key().stringValue(); + String suffix = str.stringValue(); + if (key.equals(OPERATION_INPUT_SUFFIX)) { + inputSuffix = suffix; + } else if (key.equals(OPERATION_OUTPUT_SUFFIX)) { + outputSuffix = suffix; + } + } + } else if (statement instanceof Syntax.Statement.ShapeDef) { + // Customized suffix can only appear at the head of file. Once hit the shapedef statement, we can break. + iterator.previous(); + break; + } + } + return new IOSuffix(inputSuffix, outputSuffix); + } + + private boolean coveredByRange(Syntax.Statement statement, int rangeStart, int rangeEnd) { + // Check if the statement is totally or partially covered by range. + return statement.end() >= rangeStart && statement.start() <= rangeEnd; + } + + private List processInlayHints() { + List inlayHints = new ArrayList<>(); + ListIterator iterator = statements.listIterator(); + IOSuffix ioSuffix = getIOSuffix(iterator); + // Convert the window range into document character index. + int rangeStartIndex = document.indexOfPosition(hintRange.getStart()); + int rangeEndIndex = document.indexOfPosition(hintRange.getEnd()); + String lastOperationName = ""; + while (iterator.hasNext()) { + var statement = iterator.next(); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef + && shapeDef.shapeType().stringValue().equals(OPERATION_TYPE)) { + lastOperationName = shapeDef.shapeName().stringValue(); + continue; + } + if (statement instanceof Syntax.Statement.InlineMemberDef inlineMemberDef) { + if (!coveredByRange(statement, rangeStartIndex, rangeEndIndex)) { + continue; + } + + String inlayHintLabel = switch (inlineMemberDef.name().stringValue()) { + case INPUT_TYPE -> lastOperationName + ioSuffix.inputSuffix(); + case OUTPUT_TYPE -> lastOperationName + ioSuffix.outputSuffix(); + default -> null; + }; + + if (inlayHintLabel == null) { + continue; + } + + Position position = document.positionAtIndex(inlineMemberDef.end()); + InlayHint inlayHint = new InlayHint(position, Either.forLeft(inlayHintLabel)); + inlayHints.add(inlayHint); + } + } + return inlayHints; + } + + private record IOSuffix(String inputSuffix, String outputSuffix) { + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java new file mode 100644 index 00000000..686a8c6c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java @@ -0,0 +1,251 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.IdRefTrait; + +/** + * Searches models along the path of {@link NodeCursor}s, with support for + * dynamically computing member targets via {@link DynamicMemberTarget}. + */ +final class NodeSearch { + private NodeSearch() { + } + + /** + * @param cursor The cursor to search along. + * @param model The model to search within. + * @param startingShape The shape to start the search at. + * @return The search result. + */ + static Result search(NodeCursor cursor, Model model, Shape startingShape) { + return new DefaultSearch(model).search(cursor, startingShape); + } + + /** + * @param cursor The cursor to search along. + * @param model The model to search within. + * @param startingShape The shape to start the search at. + * @param dynamicMemberTargets A map of member shape id to dynamic member + * targets to use for the search. + * @return The search result. + */ + static Result search( + NodeCursor cursor, + Model model, + Shape startingShape, + Map dynamicMemberTargets + ) { + if (dynamicMemberTargets == null || dynamicMemberTargets.isEmpty()) { + return search(cursor, model, startingShape); + } + + return new SearchWithDynamicMemberTargets(model, dynamicMemberTargets).search(cursor, startingShape); + } + + /** + * The different types of results of a search. The result will be {@link None} + * if at any point the cursor doesn't line up with the model (i.e. if the + * cursor was an array edge, but in the model we were at a structure shape). + * + * @apiNote Each result type, besides {@link None}, also includes the model, + * because it may be necessary to interpret the results (i.e. if you need + * member targets). This is done so that other APIs can wrap {@link NodeSearch} + * and callers don't have to know about which model was used in the search + * under the hood, or to allow switching the model if necessary during a search. + */ + sealed interface Result { + None NONE = new None(); + + /** + * @return The string values of other keys in {@link ObjectKey} and {@link ObjectShape}, + * or an empty set. + */ + default Set getOtherPresentKeys() { + Syntax.Node.Kvps terminalContainer; + NodeCursor.Key terminalKey; + switch (this) { + case NodeSearch.Result.ObjectShape obj -> { + terminalContainer = obj.node(); + terminalKey = null; + } + case NodeSearch.Result.ObjectKey key -> { + terminalContainer = key.key().parent(); + terminalKey = key.key(); + } + default -> { + return Set.of(); + } + } + + Set otherPresentKeys = new HashSet<>(); + for (var kvp : terminalContainer.kvps()) { + otherPresentKeys.add(kvp.key().stringValue()); + } + + if (terminalKey != null) { + otherPresentKeys.remove(terminalKey.name()); + } + + return otherPresentKeys; + } + + /** + * No result - the path is invalid in the model. + */ + record None() implements Result {} + + /** + * The path ended on a shape. + * + * @param shape The shape at the end of the path. + * @param targetOf The nullable member {@code shape} is the target of. + * @param model The model {@code shape} is within. + */ + record TerminalShape(Shape shape, MemberShape targetOf, Model model) implements Result { + /** + * @return Whether the shape at the end of the path, or the member + * it was targeted by, is an idRef. + */ + boolean isIdRef() { + return shape.hasTrait(IdRefTrait.class) || (targetOf != null && targetOf.hasTrait(IdRefTrait.class)); + } + } + + /** + * The path ended on a key or member name of an object-like shape. + * + * @param key The key node the path ended at. + * @param containerShape The shape containing the key. + * @param model The model {@code containerShape} is within. + */ + record ObjectKey(NodeCursor.Key key, Shape containerShape, Model model) implements Result {} + + /** + * The path ended on an object-like shape. + * + * @param node The node the path ended at. + * @param shape The shape at the end of the path. + * @param model The model {@code shape} is within. + */ + record ObjectShape(Syntax.Node.Kvps node, Shape shape, Model model) implements Result {} + + /** + * The path ended on an array-like shape. + * + * @param node The node the path ended at. + * @param shape The shape at the end of the path. + * @param model The model {@code shape} is within. + */ + record ArrayShape(Syntax.Node.Arr node, ListShape shape, Model model) implements Result {} + } + + private static sealed class DefaultSearch { + protected final Model model; + + private DefaultSearch(Model model) { + this.model = model; + } + + Result search(NodeCursor cursor, Shape shape) { + return search(cursor, shape, null); + } + + protected final Result search(NodeCursor cursor, Shape shape, MemberShape targetOf) { + if (!cursor.hasNext() || shape == null) { + return Result.NONE; + } + + NodeCursor.Edge edge = cursor.next(); + return switch (edge) { + case NodeCursor.Obj obj + when ShapeSearch.isObjectShape(shape) -> searchObj(cursor, obj, shape); + + case NodeCursor.Arr arr + when shape instanceof ListShape list -> searchArr(cursor, arr, list); + + case NodeCursor.Terminal ignored -> new Result.TerminalShape(shape, targetOf, model); + + default -> Result.NONE; + }; + } + + private Result searchObj(NodeCursor cursor, NodeCursor.Obj obj, Shape shape) { + if (!cursor.hasNext()) { + return new Result.ObjectShape(obj.node(), shape, model); + } + + return switch (cursor.next()) { + case NodeCursor.Terminal ignored -> new Result.ObjectShape(obj.node(), shape, model); + + case NodeCursor.Key key -> new Result.ObjectKey(key, shape, model); + + case NodeCursor.ValueForKey value + when shape instanceof MapShape map -> searchTarget(cursor, value.parent(), map.getValue()); + + case NodeCursor.ValueForKey value -> shape.getMember(value.keyName()) + .map(member -> searchTarget(cursor, value.parent(), member)) + .orElse(Result.NONE); + + default -> Result.NONE; + }; + } + + private Result searchArr(NodeCursor cursor, NodeCursor.Arr arr, ListShape shape) { + if (!cursor.hasNext()) { + return new Result.ArrayShape(arr.node(), shape, model); + } + + return switch (cursor.next()) { + case NodeCursor.Terminal ignored -> new Result.ArrayShape(arr.node(), shape, model); + + case NodeCursor.Elem elem -> searchTarget(cursor, elem.parent(), shape.getMember()); + + default -> Result.NONE; + }; + } + + protected Result searchTarget(NodeCursor cursor, Syntax.Node parent, MemberShape memberShape) { + return search(cursor, model.getShape(memberShape.getTarget()).orElse(null), memberShape); + } + } + + private static final class SearchWithDynamicMemberTargets extends DefaultSearch { + private final Map dynamicMemberTargets; + + private SearchWithDynamicMemberTargets( + Model model, + Map dynamicMemberTargets + ) { + super(model); + this.dynamicMemberTargets = dynamicMemberTargets; + } + + @Override + protected Result searchTarget(NodeCursor cursor, Syntax.Node parent, MemberShape memberShape) { + DynamicMemberTarget dynamicMemberTarget = dynamicMemberTargets.get(memberShape.getId()); + if (dynamicMemberTarget != null) { + Shape target = dynamicMemberTarget.getTarget(parent, model); + if (target != null) { + return search(cursor, target, memberShape); + } + } + + return super.searchTarget(cursor, parent, memberShape); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/References.java b/src/main/java/software/amazon/smithy/lsp/language/References.java new file mode 100644 index 00000000..bc90dd75 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/References.java @@ -0,0 +1,371 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.IdRefTrait; + +/** + * Collects references to a shape across a project or within a specific file. + */ +final class References { + private final Model model; + private final Shape shape; + private final ShapeReferencesNodeWalker traitValueWalker; + private final ShapeReferencesNodeWalker nodeMemberWalker; + private final List fileReferences = new ArrayList<>(); + private final List definitionReferences = new ArrayList<>(); + private List pendingUseRefs = new ArrayList<>(); + private List pendingRefs = new ArrayList<>(); + private IdlFile currentFile; + private Syntax.IdlParseResult currentParseResult; + + private References(Model model, Shape shape) { + this.model = model; + this.shape = shape; + this.traitValueWalker = new ShapeReferencesNodeWalker(model, this); + this.nodeMemberWalker = new ShapeReferencesNodeWalker(Builtins.MODEL, this); + } + + /** + * References to a shape in a specific file, excluding the shape's definition. + * + * @param idlFile The file the references are in + * @param useRefs The references in use statements + * @param refs All other references in the file + */ + record FileReferences(IdlFile idlFile, List useRefs, List refs) {} + + /** + * The definition of a shape. + * + * @param idlFile The file the shape is defined in + * @param ref The shape's name token + */ + record DefinitionReference(IdlFile idlFile, Syntax.Node.Str ref) {} + + /** + * Finds all references to {@code shape} across all files in the given {@code project}. + * + * @param model The model the shape is in + * @param shape The shape to find references to + * @param project The project to find references in + * @return All found references, including the shape's definition + */ + static References findReferences(Model model, Shape shape, Project project) { + var references = new References(model, shape); + references.findReferences(project); + return references; + } + + /** + * Finds all references to {@code shape} in the given {@code idlFile}. + * + * @param model The model the shape is in + * @param shape The shape to find references to + * @param idlFile The file to find references in + * @return All found references, not including the shape's definition + */ + static References findReferences(Model model, Shape shape, IdlFile idlFile) { + var references = new References(model, shape); + references.findReferences(idlFile); + return references; + } + + /** + * @return A list of all found references + */ + List fileReferences() { + return fileReferences; + } + + /** + * @return A list of all found definitions + */ + List definitionReferences() { + return definitionReferences; + } + + private void addPendingReferences() { + // Don't create a new FileReferences when there weren't any refs in + // the file. + if (!pendingUseRefs.isEmpty() || !pendingRefs.isEmpty()) { + fileReferences.add(new FileReferences( + currentFile, + pendingUseRefs, + pendingRefs + )); + + // Create a fresh pending list so subsequent modifications don't mutate a + // list in the FileReferences we just made. + pendingUseRefs = new ArrayList<>(); + pendingRefs = new ArrayList<>(); + } + } + + private void findReferences(Project project) { + for (SmithyFile smithyFile : project.getAllSmithyFiles()) { + if (!(smithyFile instanceof IdlFile idlFile)) { + continue; + } + + findReferences(idlFile); + } + + // Include the shape's definition, which won't be collected otherwise. + // Note: This doesn't add the definition of an inline shape, because it + // doesn't have an identifier to ref. + addDefinitionReference(project); + } + + private void findReferences(IdlFile idlFile) { + currentFile = idlFile; + currentParseResult = idlFile.getParse(); + + for (Syntax.Statement statement : currentParseResult.statements()) { + collect(statement); + } + + addPendingReferences(); + } + + private void collect(Syntax.Statement statement) { + switch (statement) { + case Syntax.Statement.Use use -> { + if (use.use().stringValue().equals(shape.getId().toString())) { + pendingUseRefs.add(use); + } + } + + case Syntax.Statement.Mixins mixins -> { + for (var mixin : mixins.mixins()) { + addShapeReference(mixin); + } + } + + case Syntax.Statement.ForResource forResource -> addShapeReference(forResource.resource()); + + case Syntax.Statement.MemberDef memberDef -> { + if (memberDef.target() != null) { + addShapeReference(memberDef.target()); + } + } + + case Syntax.Statement.TraitApplication traitApplication -> collectTrait(traitApplication); + + case Syntax.Statement.NodeMemberDef nodeMemberDef -> collectNodeMember(nodeMemberDef); + + default -> { + } + } + } + + private void collectTrait(Syntax.Statement.TraitApplication traitApplication) { + findShape(traitApplication.id().stringValue()).ifPresent(traitShape -> { + if (traitShape.getId().equals(shape.getId())) { + pendingRefs.add(traitApplication.id()); + } + traitValueWalker.walk(traitApplication.value(), traitShape); + }); + } + + private void collectNodeMember(Syntax.Statement.NodeMemberDef nodeMemberDef) { + createView(nodeMemberDef) + .map(StatementView::nearestShapeDefBefore) + .map(shapeDef -> Builtins.getMemberTargetForShapeType( + shapeDef.shapeType().stringValue(), + nodeMemberDef.name().stringValue())) + .ifPresent(memberTarget -> nodeMemberWalker.walk(nodeMemberDef.value(), memberTarget)); + } + + private boolean startsWithId(Shape s) { + return s.getId().getNamespace().equals(shape.getId().getNamespace()) + && s.getId().getName().equals(shape.getId().getName()); + } + + private void addShapeReference(Syntax.Node.Str token) { + if (findShape(token.stringValue()) + .filter(s -> s.getId().equals(shape.getId())) + .isPresent()) { + pendingRefs.add(token); + } + } + + private Optional findShape(String nameOrId) { + return ShapeSearch.findShape(currentParseResult, nameOrId, model); + } + + private Optional createView(Syntax.Statement statement) { + return StatementView.createAt(currentParseResult, statement); + } + + private void addDefinitionReference(Project project) { + var sourceLocation = shape.getSourceLocation(); + var projectFile = project.getProjectFile(LspAdapter.toUri(sourceLocation.getFilename())); + if (!(projectFile instanceof IdlFile idl)) { + return; + } + + var parseResult = idl.getParse(); + int documentIndex = idl.document().indexOfPosition(LspAdapter.toPosition(sourceLocation)); + var statement = StatementView.createAt(parseResult, documentIndex) + .map(StatementView::getStatement) + .orElse(null); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + definitionReferences.add(new DefinitionReference(idl, shapeDef.shapeName())); + } + } + + private void addIdRef(Syntax.Node.Str id) { + if (findShape(id.stringValue()) + .filter(this::startsWithId) + .isPresent()) { + pendingRefs.add(id); + } + } + + /** + * Walks a {@link Syntax.Node}, whose structure is defined by a {@link Shape}, + * to find all references to shapes in that node. + * + * @param model The model with the shape defining the node's structure + * @param references The references to consume found shape references + * + * @implNote This is very similar to {@link NodeSearch}, but walks all children + * instead of along a specific path, and only looks for shapes with {@code idRef}. + * It also doesn't use {@link DynamicMemberTarget}s, as right now there aren't + * any cases where a dynamic member target could provide shape references. + */ + private record ShapeReferencesNodeWalker(Model model, References references) { + private void walk(Syntax.Node node, Shape nodeShape) { + if (nodeShape == null) { + return; + } + + switch (node) { + case Syntax.Node.Obj obj -> walk(obj.kvps(), nodeShape); + + case Syntax.Node.Kvps kvps -> walkKvps(kvps, nodeShape); + + case Syntax.Node.Arr arr -> walkArr(arr, nodeShape); + + case Syntax.Node.Str str -> { + if (nodeShape.hasTrait(IdRefTrait.class)) { + references.addIdRef(str); + } + } + + case null, default -> { + } + } + } + + private void walkArr(Syntax.Node.Arr arr, Shape nodeShape) { + if (!(nodeShape instanceof ListShape listShape)) { + return; + } + + if (listShape.getMember().hasTrait(IdRefTrait.ID)) { + for (var elem : arr.elements()) { + walkIdRef(elem); + } + } else { + var target = getTarget(listShape.getMember()); + if (target == null) { + return; + } + for (var elem : arr.elements()) { + walk(elem, target); + } + } + } + + private void walkKvps(Syntax.Node.Kvps kvps, Shape nodeShape) { + if (!ShapeSearch.isObjectShape(nodeShape)) { + return; + } + + if (nodeShape instanceof MapShape mapShape) { + walkMap(kvps, mapShape); + } else { + walkAggregate(kvps, nodeShape); + } + } + + private void walkMap(Syntax.Node.Kvps kvps, MapShape mapShape) { + var keyMember = mapShape.getKey(); + var valueMember = mapShape.getValue(); + + boolean keyHasIdRef = keyMember.hasTrait(IdRefTrait.ID); + boolean valueHasIdRef = valueMember.hasTrait(IdRefTrait.ID); + + if (keyHasIdRef && valueHasIdRef) { + for (var kvp : kvps.kvps()) { + walkIdRef(kvp.key()); + walkIdRef(kvp.value()); + } + } else if (keyHasIdRef) { + var valueTarget = model.getShape(mapShape.getValue().getTarget()).orElse(null); + for (var kvp : kvps.kvps()) { + walkIdRef(kvp.key()); + walk(kvp.value(), valueTarget); + } + } else if (valueHasIdRef) { + var keyTarget = model.getShape(mapShape.getKey().getTarget()).orElse(null); + for (var kvp : kvps.kvps()) { + walk(kvp.key(), keyTarget); + walkIdRef(kvp.value()); + } + } else { + var keyTarget = getTarget(keyMember); + var valueTarget = getTarget(valueMember); + for (var kvp : kvps.kvps()) { + walk(kvp.key(), keyTarget); + walk(kvp.value(), valueTarget); + } + } + } + + private void walkAggregate(Syntax.Node.Kvps kvps, Shape nodeShape) { + for (var kvp : kvps.kvps()) { + var member = nodeShape.getMember(kvp.key().stringValue()).orElse(null); + if (member == null) { + continue; + } + + if (member.hasTrait(IdRefTrait.ID)) { + walkIdRef(kvp.value()); + } else { + var target = getTarget(member); + walk(kvp.value(), target); + } + } + } + + private void walkIdRef(Syntax.Node node) { + if (node instanceof Syntax.Node.Str str) { + references.addIdRef(str); + } + } + + private Shape getTarget(MemberShape member) { + return model.getShape(member.getTarget()).orElse(null); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java b/src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java new file mode 100644 index 00000000..32a46e91 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ReferencesHandler.java @@ -0,0 +1,115 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceParams; +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.Shape; + +public record ReferencesHandler(Project project, IdlFile idlFile) { + /** + * @param params The request params + * @return A list of locations of the found refs + */ + public List handle(ReferenceParams params) { + var config = Config.create(project, idlFile, params.getPosition()); + var references = References.findReferences(config.model(), config.shape(), project); + return toLocations(references); + } + + record Config(DocumentId id, Shape shape, Model model, IdlFile definitionFile) { + static Config create(Project project, IdlFile idlFile, Position position) { + DocumentId id = idlFile.document().copyDocumentId(position); + if (id == null || id.idSlice().isEmpty()) { + throw notSupported(); + } + + var parseResult = idlFile.getParse(); + int documentIndex = idlFile.document().indexOfPosition(position); + var idlPosition = StatementView.createAt(parseResult, documentIndex) + .map(IdlPosition::of) + .orElse(null); + if (idlPosition == null) { + throw notSupported(); + } + + var model = project.modelResult().getResult().orElse(null); + if (model == null) { + throw noModel(); + } + + var shapeReference = ShapeSearch.getShapeReference(idlPosition, id, model); + if (shapeReference.isEmpty()) { + throw notSupported(); + } + + var shape = shapeReference.get(); + var definitionFile = project.getDefinitionFile(shape); + + IdlFile idlDefinitionFile = null; + if (definitionFile instanceof IdlFile idl) { + idlDefinitionFile = idl; + } + + return new Config(id, shape, model, idlDefinitionFile); + } + + private static ResponseErrorException notSupported() { + var error = new ResponseError(); + error.setCode(ResponseErrorCode.RequestFailed); + error.setMessage("Finding references not supported here."); + return new ResponseErrorException(error); + } + + private static ResponseErrorException noModel() { + var error = new ResponseError(); + error.setCode(ResponseErrorCode.RequestFailed); + error.setMessage("Model is too broken to find references."); + return new ResponseErrorException(error); + } + } + + private List toLocations(References references) { + List locations = new ArrayList<>(); + for (var fileReferences : references.fileReferences()) { + String uri = LspAdapter.toUri(fileReferences.idlFile().path()); + + for (var ref : fileReferences.refs()) { + addLocation(locations, uri, fileReferences.idlFile().document().rangeOfValue(ref)); + } + + for (var use : fileReferences.useRefs()) { + addLocation(locations, uri, fileReferences.idlFile().document().rangeOfValue(use.use())); + } + } + + for (var definitionRef : references.definitionReferences()) { + String uri = LspAdapter.toUri(definitionRef.idlFile().path()); + addLocation(locations, uri, definitionRef.idlFile().document().rangeOfValue(definitionRef.ref())); + } + + return locations; + } + + private void addLocation(List locations, String uri, Range range) { + if (range != null) { + locations.add(new Location(uri, range)); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java b/src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java new file mode 100644 index 00000000..827988fd --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/RenameHandler.java @@ -0,0 +1,259 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.RenameParams; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ShapeIdSyntaxException; + +public record RenameHandler(Project project, IdlFile idlFile) { + /** + * @param params The request params + * @return The range of the identifier to rename + */ + public Range prepare(PrepareRenameParams params) { + var config = ReferencesHandler.Config.create(project, idlFile, params.getPosition()); + return getRenameRange(config.id().range(), config.id().copyIdValue()); + } + + /** + * @param params The request params + * @return A workspace edit that applies the rename + */ + public WorkspaceEdit handle(RenameParams params) { + var config = ReferencesHandler.Config.create(project, idlFile, params.getPosition()); + var edits = getEdits(config, params.getNewName()); + return new WorkspaceEdit(edits); + } + + private Map> getEdits(ReferencesHandler.Config config, String newName) { + String namespace = config.shape().getId().getNamespace(); + + ShapeId renamedId; + try { + renamedId = ShapeId.fromRelative(namespace, newName); + } catch (ShapeIdSyntaxException e) { + throw invalidShapeId(e); + } + + var projectEdits = new ProjectEdits(config, newName, renamedId, new HashMap<>()); + projectEdits.collect(project); + + return projectEdits.edits; + } + + private record ProjectEdits( + ReferencesHandler.Config config, + String newName, + ShapeId renamedShapeId, + Map> edits + ) { + private enum FileEditType { + CONFLICT, + SIMPLE + } + + private void collect(Project project) { + var references = References.findReferences(config.model(), config.shape(), project); + + addEdits(references); + deconflictDefinition(); + } + + private void addEdits(References allReferences) { + for (var fileReferences : allReferences.fileReferences()) { + FileEditType fileEditType = getEditType(fileReferences.idlFile()); + if (fileEditType == FileEditType.CONFLICT) { + addConflictRenames(fileReferences, renamedShapeId.toString()); + } else { + addSimpleRenames(fileReferences); + } + } + + for (var definitionReference : allReferences.definitionReferences()) { + var uri = checkIfJar(definitionReference.idlFile()); + + addSimpleRename(uri, definitionReference.idlFile(), definitionReference.ref()); + } + } + + private void deconflictDefinition() { + var sourceFile = config.definitionFile(); + if (sourceFile == null) { + return; + } + + String conflictingId = getConflictingImport(sourceFile, newName); + if (conflictingId == null) { + return; + } + + var conflictingShape = ShapeSearch.findShape(sourceFile.getParse(), conflictingId, config.model()); + if (conflictingShape.isEmpty()) { + return; + } + + var references = References.findReferences(config.model(), conflictingShape.get(), sourceFile); + for (var fileReferences : references.fileReferences()) { + addConflictRenames(fileReferences, conflictingId); + } + // Note: No deconflict needed for the definition (plus it won't be picked up by allReferences) + } + + private void addConflictRenames(References.FileReferences fileReferences, String conflictingId) { + var uri = checkIfJar(fileReferences.idlFile()); + + for (var ref : fileReferences.refs()) { + var range = fileReferences.idlFile().document().rangeOfValue(ref); + if (range == null) { + continue; + } + + var referenceId = ref.stringValue(); + var renamedId = conflictingId; + if (referenceId.contains("$")) { + renamedId = conflictingId + "$" + referenceId.split("\\$")[1]; + } + + add(uri, range, renamedId); + } + + for (var use : fileReferences.useRefs()) { + var range = fileReferences.idlFile().document().rangeOf(use); + if (range == null) { + continue; + } + + add(uri, range, ""); + } + } + + private void addSimpleRenames(References.FileReferences fileReferences) { + var uri = checkIfJar(fileReferences.idlFile()); + + for (var ref : fileReferences.refs()) { + addSimpleRename(uri, fileReferences.idlFile(), ref); + } + + for (var use : fileReferences.useRefs()) { + var range = fileReferences.idlFile().document().rangeOfValue(use.use()); + if (range == null) { + continue; + } + + var referenceId = use.use().stringValue(); + var renameRange = getRenameRange(range, referenceId); + add(uri, renameRange, newName); + } + } + + private void addSimpleRename(String uri, IdlFile idlFile, Syntax.Node.Str ref) { + var range = idlFile.document().rangeOfValue(ref); + if (range == null) { + return; + } + + var referenceId = ref.stringValue(); + var renameRange = getRenameRange(range, referenceId); + add(uri, renameRange, newName); + } + + private String checkIfJar(IdlFile idlFile) { + String uri = LspAdapter.toUri(idlFile.path()); + if (!LspAdapter.isJarFile(uri) && !LspAdapter.isSmithyJarFile(uri)) { + return uri; + } + + throw referencedInJar(uri); + } + + private FileEditType getEditType(IdlFile idlFile) { + if (isDefinitionFile(idlFile) || !conflicts(idlFile)) { + return FileEditType.SIMPLE; + } else { + return FileEditType.CONFLICT; + } + } + + private boolean isDefinitionFile(IdlFile idlFile) { + return config.definitionFile() != null && config.definitionFile().path().equals(idlFile.path()); + } + + private boolean conflicts(IdlFile idlFile) { + if (renamedShapeId == null) { + return false; + } + + String fileNamespace = idlFile.getParse().namespace().namespace(); + if (!renamedShapeId.getNamespace().equals(fileNamespace)) { + ShapeId renamedCurrentScope = ShapeId.fromRelative(fileNamespace, newName); + if (config.model().getShape(renamedCurrentScope).isPresent()) { + return true; + } + } + + return getConflictingImport(idlFile, newName) != null; + } + + private void add(String uri, Range renamedRange, String renamed) { + var edit = new TextEdit(renamedRange, renamed); + edits.computeIfAbsent(uri, k -> new ArrayList<>()).add(edit); + } + } + + private static ResponseErrorException invalidShapeId(ShapeIdSyntaxException e) { + var responseError = new ResponseError(); + responseError.setCode(ResponseErrorCode.RequestFailed); + responseError.setMessage("Renamed shape id would be invalid: " + e.getMessage()); + return new ResponseErrorException(responseError); + } + + private static ResponseErrorException referencedInJar(String uri) { + var error = new ResponseError(); + error.setCode(ResponseErrorCode.RequestFailed); + error.setMessage("Can't rename shape referenced in jar: " + uri); + return new ResponseErrorException(error); + } + + private static Range getRenameRange(Range range, String idString) { + int originalStartCharacter = range.getStart().getCharacter(); + int hashIdx = idString.indexOf('#'); + if (hashIdx >= 0) { + int currentCharacter = range.getStart().getCharacter(); + range.getStart().setCharacter(currentCharacter + hashIdx + 1); + } + int dollarIdx = idString.indexOf('$'); + if (dollarIdx >= 0) { + range.getEnd().setCharacter(originalStartCharacter + dollarIdx); + } + return range; + } + + private static String getConflictingImport(IdlFile idlFile, String newName) { + String matcher = "#" + newName; + for (String imported : idlFile.getParse().imports().imports()) { + if (imported.endsWith(matcher)) { + return imported; + } + } + return null; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java new file mode 100644 index 00000000..1544cf17 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java @@ -0,0 +1,261 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionItemLabelDetails; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.document.DocumentNamespace; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ShapeVisitor; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.ErrorTrait; +import software.amazon.smithy.model.traits.MixinTrait; +import software.amazon.smithy.model.traits.PrivateTrait; +import software.amazon.smithy.model.traits.RequiredTrait; +import software.amazon.smithy.model.traits.TraitDefinition; + +/** + * Maps {@link CompletionCandidates.Shapes} to {@link CompletionItem}s. + * + * @param idlPosition The position of the cursor in the IDL file. + * @param model The model to get shape completions from. + * @param context The context for creating completions. + */ +record ShapeCompleter(IdlPosition idlPosition, Model model, CompleterContext context) { + List getCompletionItems(CompletionCandidates.Shapes candidates) { + AddItems addItems; + if (idlPosition instanceof IdlPosition.TraitId) { + addItems = new AddDeepTraitBodyItem(model); + } else { + addItems = AddItems.NOOP; + } + + ToLabel toLabel; + ModifyItems modifyItems; + boolean shouldMatchFullId = idlPosition instanceof IdlPosition.UseTarget + || context.matchToken().contains("#") + || context.matchToken().contains("."); + if (shouldMatchFullId) { + toLabel = (shape) -> shape.getId().toString(); + modifyItems = ModifyItems.NOOP; + } else { + toLabel = (shape) -> shape.getId().getName(); + modifyItems = new AddImportTextEdits(idlPosition.view().parseResult()); + } + + Matcher matcher = new Matcher(context.matchToken(), toLabel, idlPosition.view().parseResult().namespace()); + Mapper mapper = new Mapper(context.insertRange(), toLabel, addItems, modifyItems); + return streamCandidates(candidates) + .filter(matcher::test) + .mapMulti(mapper::accept) + .toList(); + } + + private Stream streamCandidates(CompletionCandidates.Shapes candidates) { + return switch (candidates) { + case ANY_SHAPE -> model.shapes(); + case STRING_SHAPES -> model.getStringShapes().stream(); + case RESOURCE_SHAPES -> model.getResourceShapes().stream(); + case OPERATION_SHAPES -> model.getOperationShapes().stream(); + case ERROR_SHAPES -> model.getShapesWithTrait(ErrorTrait.class).stream(); + case TRAITS -> model.getShapesWithTrait(TraitDefinition.class).stream(); + case MIXINS -> model.getShapesWithTrait(MixinTrait.class).stream(); + case MEMBER_TARGETABLE -> model.shapes() + .filter(shape -> !shape.isMemberShape() + && !shape.hasTrait(TraitDefinition.ID) + && !shape.hasTrait(MixinTrait.ID)); + case USE_TARGET -> model.shapes().filter(this::shouldImport); + }; + } + + private boolean shouldImport(Shape shape) { + return !shape.isMemberShape() + && !shape.getId().getNamespace().equals(idlPosition.view().parseResult().namespace().namespace()) + && !idlPosition.view().parseResult().imports().imports().contains(shape.getId().toString()) + && !shape.hasTrait(PrivateTrait.ID); + } + + /** + * Filters shape candidates based on whether they are accessible and match + * the match token. + * + * @param matchToken The token to match shapes against, i.e. the token + * being typed. + * @param toLabel The way to get the label to match against from a shape. + * @param namespace The namespace of the current Smithy file. + */ + private record Matcher(String matchToken, ToLabel toLabel, DocumentNamespace namespace) { + boolean test(Shape shape) { + return toLabel.toLabel(shape).toLowerCase().startsWith(matchToken) + && (shape.getId().getNamespace().equals(namespace.namespace()) || !shape.hasTrait(PrivateTrait.ID)); + } + } + + /** + * Maps matching shape candidates to {@link CompletionItem}. + * + * @param insertRange Range the completion text will be inserted into. + * @param toLabel The way to get the label to show in the completion item. + * @param addItems Adds extra completion items for a shape. + * @param modifyItems Modifies created completion items for a shape. + */ + private record Mapper(Range insertRange, ToLabel toLabel, AddItems addItems, ModifyItems modifyItems) { + void accept(Shape shape, Consumer completionItemConsumer) { + String shapeLabel = toLabel.toLabel(shape); + CompletionItem defaultItem = shapeCompletion(shapeLabel, shape); + completionItemConsumer.accept(defaultItem); + addItems.add(this, shapeLabel, shape, completionItemConsumer); + } + + private CompletionItem shapeCompletion(String shapeLabel, Shape shape) { + var completionItem = new CompletionItem(shapeLabel); + completionItem.setKind(CompletionItemKind.Class); + completionItem.setDetail(shape.getType().toString()); + + var labelDetails = new CompletionItemLabelDetails(); + labelDetails.setDescription(shape.getId().toString()); + completionItem.setLabelDetails(labelDetails); + + TextEdit edit = new TextEdit(insertRange, shapeLabel); + completionItem.setTextEdit(Either.forLeft(edit)); + + modifyItems.modify(this, shapeLabel, shape, completionItem); + return completionItem; + } + } + + /** + * Strategy to get the completion label from {@link Shape}s used for + * matching and constructing the completion item. + */ + private interface ToLabel { + String toLabel(Shape shape); + } + + /** + * A customization point for adding extra completions items for a given + * shape. + */ + private interface AddItems { + AddItems NOOP = new AddItems() { + }; + + default void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) { + } + } + + /** + * Adds a completion item that fills out required member names. + * + * TODO: Need to check what happens for recursive traits. The model won't + * be valid, but it may still be loaded and could blow this up. + */ + private static final class AddDeepTraitBodyItem extends ShapeVisitor.Default implements AddItems { + private final Model model; + + AddDeepTraitBodyItem(Model model) { + this.model = model; + } + + @Override + public void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) { + String traitBody = shape.accept(this); + // Strip outside pair of brackets from any structure traits. + if (!traitBody.isEmpty() && traitBody.charAt(0) == '{') { + traitBody = traitBody.substring(1, traitBody.length() - 1); + } + + if (!traitBody.isEmpty()) { + String label = String.format("%s(%s)", shapeLabel, traitBody); + var traitWithMembersItem = mapper.shapeCompletion(label, shape); + consumer.accept(traitWithMembersItem); + } + } + + @Override + protected String getDefault(Shape shape) { + return CompletionCandidates.defaultCandidates(shape).value(); + } + + @Override + public String structureShape(StructureShape shape) { + List entries = new ArrayList<>(); + for (MemberShape memberShape : shape.members()) { + if (memberShape.hasTrait(RequiredTrait.class)) { + entries.add(memberShape.getMemberName() + ": " + memberShape.accept(this)); + } + } + return "{" + String.join(", ", entries) + "}"; + } + + @Override + public String memberShape(MemberShape shape) { + return model.getShape(shape.getTarget()) + .map(target -> target.accept(this)) + .orElse(""); + } + } + + /** + * A customization point for modifying created completion items, adding + * context, additional text edits, etc. + */ + private interface ModifyItems { + ModifyItems NOOP = new ModifyItems() { + }; + + default void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { + } + } + + /** + * Adds text edits for use statements for shapes that need to be imported. + * + * @param syntaxInfo Syntax info of the current Smithy file. + */ + private record AddImportTextEdits(Syntax.IdlParseResult syntaxInfo) implements ModifyItems { + @Override + public void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { + if (inScope(shape.getId())) { + return; + } + + // We can only know where to put the import if there's already use statements, or a namespace + if (!syntaxInfo.imports().imports().isEmpty()) { + addEdit(completionItem, syntaxInfo.imports().importsRange(), shape); + } else if (!syntaxInfo.namespace().namespace().isEmpty()) { + addEdit(completionItem, syntaxInfo.namespace().statementRange(), shape); + } + } + + private boolean inScope(ShapeId shapeId) { + return Prelude.isPublicPreludeShape(shapeId) + || shapeId.getNamespace().equals(syntaxInfo.namespace().namespace()) + || syntaxInfo.imports().imports().contains(shapeId.toString()); + } + + private void addEdit(CompletionItem completionItem, Range range, Shape shape) { + Range editRange = LspAdapter.point(range.getEnd()); + String insertText = System.lineSeparator() + "use " + shape.getId().toString(); + TextEdit importEdit = new TextEdit(editRange, insertText); + completionItem.setAdditionalTextEdits(List.of(importEdit)); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java new file mode 100644 index 00000000..5ddf02fe --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -0,0 +1,428 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.document.DocumentImports; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.ResourceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ShapeIdSyntaxException; +import software.amazon.smithy.model.traits.IdRefTrait; + +/** + * Provides methods to search for shapes, using context and syntax specific + * information, like the current {@link SmithyFile} or {@link IdlPosition}. + */ +final class ShapeSearch { + private ShapeSearch() { + } + + /** + * Attempts to find a shape using a token, {@code nameOrId}. + * + *

When {@code nameOrId} does not contain a '#', this searches for shapes + * either in {@code idlParse}'s imports, in {@code idlParse}'s namespace, or + * the prelude, in that order. When {@code nameOrId} does contain a '#', it + * is assumed to be a full shape id and is searched for directly. + * + * @param parseResult The parse result of the file {@code nameOrId} is within. + * @param nameOrId The name or shape id of the shape to find. + * @param model The model to search. + * @return The shape, if found. + */ + static Optional findShape(Syntax.IdlParseResult parseResult, String nameOrId, Model model) { + return switch (nameOrId) { + case null -> Optional.empty(); + + case String s when s.isEmpty() -> Optional.empty(); + + case String s when s.contains("#") -> tryFrom(s, model); + + default -> fromImports(parseResult.imports(), nameOrId, model) + .or(() -> tryFromRelative(parseResult.namespace().namespace(), nameOrId, model)) + .or(() -> tryFromRelative(Prelude.NAMESPACE, nameOrId, model)); + }; + } + + private static Optional fromImports(DocumentImports imports, String nameOrId, Model model) { + if (imports.imports().isEmpty()) { + return Optional.empty(); + } + + if (nameOrId.contains("$")) { + // Relative member id, so it could be a member of an imported shape + String[] split = nameOrId.split("\\$"); + String containerName = split[0]; + String memberName = split[1]; + String matchString = "#" + containerName; + for (String fileImport : imports.imports()) { + if (fileImport.endsWith(matchString)) { + return tryWithMember(fileImport, memberName, model); + } + } + } else { + String matchString = "#" + nameOrId; + for (String fileImport : imports.imports()) { + if (fileImport.endsWith(matchString)) { + return tryFrom(fileImport, model); + } + } + } + + return Optional.empty(); + } + + private static Optional tryFrom(String id, Model model) { + try { + ShapeId shapeId = ShapeId.from(id); + return model.getShape(shapeId); + } catch (ShapeIdSyntaxException e) { + return Optional.empty(); + } + } + + private static Optional tryWithMember(String rootId, String memberName, Model model) { + try { + ShapeId shapeId = ShapeId.from(rootId).withMember(memberName); + return model.getShape(shapeId); + } catch (ShapeIdSyntaxException e) { + return Optional.empty(); + } + } + + private static Optional tryFromRelative(String namespace, String name, Model model) { + try { + ShapeId shapeId = ShapeId.fromRelative(namespace, name); + return model.getShape(shapeId); + } catch (ShapeIdSyntaxException e) { + return Optional.empty(); + } + } + + /** + * Attempts to find the shape referenced by {@code id} at {@code idlPosition} in {@code model}. + * + * @param idlPosition The position of the potential shape reference. + * @param id The identifier at {@code idlPosition}. + * @param model The model to search for shapes in. + * @return The shape, if found. + */ + static Optional findShapeDefinition(IdlPosition idlPosition, DocumentId id, Model model) { + return switch (idlPosition) { + case IdlPosition.TraitValue traitValue -> findShapeDefinitionInTrait(traitValue, id, model); + + case IdlPosition.NodeMemberTarget nodeMemberTarget -> + findShapeDefinitionInNodeMemberTarget(nodeMemberTarget, id, model); + + // Note: This could be made more specific, at least for mixins + case IdlPosition.ElidedMember elidedMember -> + findElidedMemberParent(elidedMember, id, model); + + case IdlPosition.MemberName memberName -> { + var parentDef = memberName.view().nearestShapeDefBefore(); + if (parentDef == null) { + yield Optional.empty(); + } + var relativeId = parentDef.shapeName().stringValue() + "$" + memberName.name(); + yield findShape(memberName.view().parseResult(), relativeId, model); + } + + case IdlPosition pos when pos.isRootShapeReference() -> + findShape(pos.view().parseResult(), id.copyIdValue(), model); + + default -> Optional.empty(); + }; + } + + private static Optional findShapeDefinitionInTrait( + IdlPosition.TraitValue traitValue, + DocumentId id, + Model model + ) { + var result = searchTraitValue(traitValue, model); + return switch (result) { + case NodeSearch.Result.TerminalShape terminal when terminal.isIdRef() -> + findShape(traitValue.view().parseResult(), id.copyIdValue(), model); + + case NodeSearch.Result.ObjectKey objectKey when !objectKey.containerShape().isMapShape() -> + objectKey.containerShape().getMember(objectKey.key().name()); + + default -> Optional.empty(); + }; + } + + private static Optional findShapeDefinitionInNodeMemberTarget( + IdlPosition.NodeMemberTarget nodeMemberTarget, + DocumentId id, + Model model + ) { + var result = searchNodeMemberTarget(nodeMemberTarget); + if (result instanceof NodeSearch.Result.TerminalShape terminal && terminal.isIdRef()) { + return findShape(nodeMemberTarget.view().parseResult(), id.copyIdValue(), model); + } + return Optional.empty(); + } + + static Optional getShapeReference(IdlPosition idlPosition, DocumentId id, Model model) { + Optional shape = switch (idlPosition) { + case IdlPosition.TraitValue traitValue -> traitValueReference(traitValue, id, model); + + case IdlPosition.NodeMemberTarget nodeMemberTarget -> + nodeMemberTargetReference(nodeMemberTarget, id, model); + + case IdlPosition pos when pos.isRootShapeReference() -> { + String nameOrId = id.copyIdValue(); + yield findShape(pos.view().parseResult(), nameOrId, model); + } + + default -> Optional.empty(); + }; + + return shape.filter(s -> !s.isMemberShape()); + } + + private static Optional traitValueReference(IdlPosition.TraitValue traitValue, DocumentId id, Model model) { + // Find the shape corresponding to the given traitValue position. + var searchResult = ShapeSearch.searchTraitValue(traitValue, model); + + // We only care about results that could be shape refs, so trait members + // or idRefs. + return switch (searchResult) { + case NodeSearch.Result.TerminalShape terminal when terminal.isIdRef() -> { + String nameOrId = id.copyIdValue(); + yield findShape(traitValue.view().parseResult(), nameOrId, model); + } + + case NodeSearch.Result.ObjectKey objectKey -> { + if (objectKey.containerShape() instanceof MapShape mapShape) { + if (mapShape.getKey().getMemberTrait(model, IdRefTrait.class).isPresent()) { + String nameOrId = id.copyIdValue(); + yield findShape(traitValue.view().parseResult(), nameOrId, model); + } + } + yield Optional.empty(); + } + + default -> Optional.empty(); + }; + } + + private static Optional nodeMemberTargetReference( + IdlPosition.NodeMemberTarget target, + DocumentId id, + Model model + ) { + var searchResult = ShapeSearch.searchNodeMemberTarget(target); + return switch (searchResult) { + // The cursor is on some node value nested within a member of a service, resource, or operation + // shape. When this value is supposed to represent a shape id, provide refs for that id. + case NodeSearch.Result.TerminalShape terminal when terminal.isIdRef() -> { + String nameOrId = id.copyIdValue(); + yield findShape(target.view().parseResult(), nameOrId, model); + } + + // The cursor is on some key of a node nested within a member of a service or resource shape. + // We want to provide refs when the key is a service closure shape rename. + case NodeSearch.Result.ObjectKey objectKey -> { + var containerId = objectKey.containerShape().getId(); + if (Builtins.SERVICE_RENAME_ID.equals(containerId)) { + yield findShape(target.view().parseResult(), objectKey.key().name(), model); + } else { + yield Optional.empty(); + } + } + default -> Optional.empty(); + }; + } + + /** + * @param forResource The nullable for-resource statement. + * @param view A statement view containing the for-resource statement. + * @param model The model to search in. + * @return A resource shape matching the given for-resource statement, if found. + */ + static Optional findResource( + Syntax.Statement.ForResource forResource, + StatementView view, + Model model + ) { + if (forResource != null) { + String resourceNameOrId = forResource.resource().stringValue(); + return findShape(view.parseResult(), resourceNameOrId, model) + .flatMap(Shape::asResourceShape); + } + return Optional.empty(); + } + + /** + * @param mixins The nullable mixins statement. + * @param view The statement view containing the mixins statement. + * @param model The model to search in. + * @return A list of the mixin shapes matching those in the mixin statement. + */ + static List findMixins(Syntax.Statement.Mixins mixins, StatementView view, Model model) { + if (mixins != null) { + List mixinShapes = new ArrayList<>(mixins.mixins().size()); + for (Syntax.Ident ident : mixins.mixins()) { + String mixinNameOrId = ident.stringValue(); + findShape(view.parseResult(), mixinNameOrId, model).ifPresent(mixinShapes::add); + } + return mixinShapes; + } + return List.of(); + } + + /** + * @param elidedMember The elided member position + * @param id The identifier of the elided member + * @param model The model to search in + * @return The shape the elided member comes from, if found. + */ + static Optional findElidedMemberParent( + IdlPosition.ElidedMember elidedMember, + DocumentId id, + Model model + ) { + var view = elidedMember.view(); + var forResourceAndMixins = view.nearestForResourceAndMixinsBefore(); + + String searchToken = id.copyIdValueForElidedMember(); + + // TODO: Handle ambiguity + Optional foundResource = findResource(forResourceAndMixins.forResource(), view, model) + .filter(shape -> shape.getIdentifiers().containsKey(searchToken) + || shape.getProperties().containsKey(searchToken)); + if (foundResource.isPresent()) { + return foundResource; + } + + return findMixins(forResourceAndMixins.mixins(), view, model) + .stream() + .filter(shape -> shape.getAllMembers().containsKey(searchToken)) + .findFirst(); + } + + /** + * @param traitValue The trait value position + * @param model The model to search in + * @return The shape that {@code traitValue} is being applied to, if found. + */ + static Optional findTraitTarget(IdlPosition.TraitValue traitValue, Model model) { + Syntax.Statement.ShapeDef shapeDef = traitValue.view().nearestShapeDefAfter(); + + if (shapeDef == null) { + return Optional.empty(); + } + + String shapeName = shapeDef.shapeName().stringValue(); + return findShape(traitValue.view().parseResult(), shapeName, model); + } + + /** + * @param shape The shape to check + * @return Whether {@code shape} is represented as an object in a + * {@link software.amazon.smithy.lsp.syntax.Syntax.Node}. + */ + static boolean isObjectShape(Shape shape) { + return switch (shape.getType()) { + case STRUCTURE, UNION, MAP -> true; + default -> false; + }; + } + + /** + * @param metadataValue The metadata value position + * @return The result of searching from the given metadata value within the + * {@link Builtins} model. + */ + static NodeSearch.Result searchMetadataValue(IdlPosition.MetadataValue metadataValue) { + String metadataKey = metadataValue.metadata().key().stringValue(); + Shape metadataValueShapeDef = Builtins.getMetadataValue(metadataKey); + if (metadataValueShapeDef == null) { + return NodeSearch.Result.NONE; + } + + NodeCursor cursor = NodeCursor.create( + metadataValue.metadata().value(), + metadataValue.view().documentIndex()); + var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey); + return NodeSearch.search(cursor, Builtins.MODEL, metadataValueShapeDef, dynamicTargets); + } + + /** + * @param nodeMemberTarget The node member target position + * @return The result of searching from the given node member target value + * within the {@link Builtins} model. + */ + static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nodeMemberTarget) { + Syntax.Statement.ShapeDef shapeDef = nodeMemberTarget.view().nearestShapeDefBefore(); + + if (shapeDef == null) { + return NodeSearch.Result.NONE; + } + + String shapeType = shapeDef.shapeType().stringValue(); + String memberName = nodeMemberTarget.nodeMember().name().stringValue(); + Shape memberShapeDef = Builtins.getMemberTargetForShapeType(shapeType, memberName); + + if (memberShapeDef == null) { + return NodeSearch.Result.NONE; + } + + // This is a workaround for the case when you just have 'operations: '. + // Alternatively, we could add an 'empty' Node value, if this situation comes up + // elsewhere. + // + // TODO: Note that searchTraitValue has to do a similar thing, but parsing + // trait values always yields at least an empty Kvps, so it is kind of the same. + if (nodeMemberTarget.nodeMember().value() == null) { + return new NodeSearch.Result.TerminalShape(memberShapeDef, null, Builtins.MODEL); + } + + NodeCursor cursor = NodeCursor.create( + nodeMemberTarget.nodeMember().value(), + nodeMemberTarget.view().documentIndex()); + return NodeSearch.search(cursor, Builtins.MODEL, memberShapeDef); + } + + /** + * @param traitValue The trait value position + * @param model The model to search + * @return The result of searching from {@code traitValue} within {@code model}. + */ + static NodeSearch.Result searchTraitValue(IdlPosition.TraitValue traitValue, Model model) { + String traitName = traitValue.application().id().stringValue(); + Optional maybeTraitShape = findShape(traitValue.view().parseResult(), traitName, model); + if (maybeTraitShape.isEmpty()) { + return NodeSearch.Result.NONE; + } + + Shape traitShape = maybeTraitShape.get(); + NodeCursor cursor = NodeCursor.create( + traitValue.application().value(), + traitValue.view().documentIndex()); + if (cursor.isTerminal() && isObjectShape(traitShape)) { + // In this case, we've just started to type '@myTrait(foo)', which to the parser looks like 'foo' is just + // an identifier. But this would mean you don't get member completions when typing the first trait value + // member, so we can modify the node path to make it _look_ like it's actually a key + cursor.edges().addFirst(new NodeCursor.Obj(new Syntax.Node.Kvps())); + } + + var dynamicTargets = DynamicMemberTarget.forTrait(traitShape, traitValue); + return NodeSearch.search(cursor, model, traitShape, dynamicTargets); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java new file mode 100644 index 00000000..dc19b712 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java @@ -0,0 +1,219 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.util.StreamUtils; + +/** + * Maps simple {@link CompletionCandidates} to {@link CompletionItem}s. + * + * @param context The context for creating completions. + * @param mapper The mapper used to map candidates to completion items. + * Defaults to {@link Mapper} + * + * @see ShapeCompleter for what maps {@link CompletionCandidates.Shapes}. + */ +record SimpleCompleter(CompleterContext context, Mapper mapper) { + SimpleCompleter(CompleterContext context) { + this(context, new Mapper(context)); + } + + List getCompletionItems(CompletionCandidates candidates) { + Matcher matcher; + if (context.exclude().isEmpty()) { + matcher = new DefaultMatcher(context.matchToken()); + } else { + matcher = new ExcludingMatcher(context.matchToken(), context.exclude()); + } + + return getCompletionItems(candidates, matcher); + } + + private List getCompletionItems(CompletionCandidates candidates, Matcher matcher) { + return switch (candidates) { + case CompletionCandidates.Constant(var value) + when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value)); + + case CompletionCandidates.Literals(var literals) -> literals.stream() + .filter(matcher::testLiteral) + .map(mapper::literal) + .toList(); + + case CompletionCandidates.Labeled(var labeled) -> labeled.entrySet().stream() + .filter(matcher::testLabeled) + .map(mapper::labeled) + .toList(); + + case CompletionCandidates.Members(var members) -> members.entrySet().stream() + .filter(matcher::testMember) + .map(mapper::member) + .toList(); + + case CompletionCandidates.ElidedMembers(var memberNames) -> memberNames.stream() + .filter(matcher::testElided) + .map(mapper::elided) + .toList(); + + case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher); + + case CompletionCandidates.And(var one, var two) -> { + List oneItems = getCompletionItems(one, matcher); + List twoItems = getCompletionItems(two, matcher); + List completionItems = new ArrayList<>(oneItems.size() + twoItems.size()); + completionItems.addAll(oneItems); + completionItems.addAll(twoItems); + yield completionItems; + } + + default -> List.of(); + }; + } + + private CompletionCandidates customCandidates(CompletionCandidates.Custom custom) { + return switch (custom) { + case NAMESPACE_FILTER -> new CompletionCandidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces()) + .collect(StreamUtils.toWrappedMap())); + + case VALIDATOR_NAME -> CompletionCandidates.VALIDATOR_NAMES; + + case PROJECT_NAMESPACES -> new CompletionCandidates.Literals(streamNamespaces().toList()); + }; + } + + private Stream streamNamespaces() { + return context().project().getAllSmithyFiles().stream() + .map(smithyFile -> switch (smithyFile) { + case IdlFile idlFile -> idlFile.getParse().namespace().namespace(); + default -> ""; + }) + .filter(namespace -> !namespace.isEmpty()); + } + + /** + * Matches different kinds of completion candidates against the text of + * whatever triggered the completion, used to filter out candidates. + * + * @apiNote LSP has support for client-side matching/filtering, but only when + * the completion items don't have text edits. We use text edits to have more + * control over the range the completion text will occupy, so we need to do + * matching/filtering server-side. + * + * @see LSP Completion Docs + */ + private sealed interface Matcher { + String matchToken(); + + default boolean testConstant(String constant) { + return test(constant); + } + + default boolean testLiteral(String literal) { + return test(literal); + } + + default boolean testLabeled(Map.Entry labeled) { + return test(labeled.getKey()) || test(labeled.getValue()); + } + + default boolean testMember(Map.Entry member) { + return test(member.getKey()); + } + + default boolean testElided(String memberName) { + return test(memberName) || test("$" + memberName); + } + + default boolean test(String s) { + return s.toLowerCase().startsWith(matchToken()); + } + } + + private record DefaultMatcher(String matchToken) implements Matcher {} + + private record ExcludingMatcher(String matchToken, Set exclude) implements Matcher { + @Override + public boolean testElided(String memberName) { + // Exclusion set doesn't contain member names with leading '$', so we don't + // want to delegate to the regular `test` method + return !exclude.contains(memberName) + && (Matcher.super.test(memberName) || Matcher.super.test("$" + memberName)); + } + + @Override + public boolean test(String s) { + return !exclude.contains(s) && Matcher.super.test(s); + } + } + + /** + * Maps different kinds of completion candidates to {@link CompletionItem}s. + */ + static class Mapper { + private final Range insertRange; + private final CompletionItemKind literalKind; + + Mapper(CompleterContext context) { + this.insertRange = context.insertRange(); + this.literalKind = context.literalKind(); + } + + CompletionItem constant(String value) { + return textEditCompletion(value, CompletionItemKind.Constant); + } + + CompletionItem literal(String value) { + return textEditCompletion(value, literalKind); + } + + CompletionItem labeled(Map.Entry entry) { + return textEditCompletion(entry.getKey(), CompletionItemKind.EnumMember, entry.getValue()); + } + + CompletionItem member(Map.Entry entry) { + String value = entry.getKey() + ": " + entry.getValue().value(); + return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value); + } + + CompletionItem elided(String memberName) { + return textEditCompletion("$" + memberName, CompletionItemKind.Field); + } + + protected CompletionItem textEditCompletion(String label, CompletionItemKind kind) { + return textEditCompletion(label, kind, label); + } + + protected CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) { + CompletionItem item = new CompletionItem(label); + item.setKind(kind); + TextEdit textEdit = new TextEdit(insertRange, insertText); + item.setTextEdit(Either.forLeft(textEdit)); + return item; + } + } + + static final class BuildFileMapper extends Mapper { + BuildFileMapper(CompleterContext context) { + super(context); + } + + @Override + CompletionItem member(Map.Entry entry) { + String value = "\"" + entry.getKey() + "\": " + entry.getValue().value(); + return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java new file mode 100644 index 00000000..97d55555 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFile.java @@ -0,0 +1,82 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.util.concurrent.locks.ReentrantLock; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; + +/** + * The language server's representation of a smithy-build.json + * .smithy-project.json file. + */ +public final class BuildFile implements ProjectFile { + private final String path; + private final Document document; + private final BuildFileType type; + private final ReentrantLock parseLock = new ReentrantLock(); + private Syntax.NodeParseResult parseResult; + + private BuildFile( + String path, + Document document, + BuildFileType type, + Syntax.NodeParseResult parseResult + ) { + this.path = path; + this.document = document; + this.type = type; + this.parseResult = parseResult; + } + + static BuildFile create(String path, Document document, BuildFileType type) { + Syntax.NodeParseResult parseResult = Syntax.parseNode(document); + return new BuildFile(path, document, type, parseResult); + } + + @Override + public String path() { + return path; + } + + @Override + public Document document() { + return document; + } + + @Override + public void reparse() { + Syntax.NodeParseResult updatedParse = Syntax.parseNode(document()); + + parseLock.lock(); + try { + this.parseResult = updatedParse; + } finally { + parseLock.unlock(); + } + } + + /** + * @return The type of this build file + */ + public BuildFileType type() { + return type; + } + + /** + * @return The latest computed {@link Syntax.NodeParseResult} of this build file + * @apiNote Don't call this method over and over. {@link Syntax.NodeParseResult} + * is immutable so just call this once and use the returned value. + */ + public Syntax.NodeParseResult getParse() { + parseLock.lock(); + try { + return parseResult; + } finally { + parseLock.unlock(); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java new file mode 100644 index 00000000..3e2d5082 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFileType.java @@ -0,0 +1,79 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +/** + * The type of build file. + * + *

The language server supports loading project config from multiple kinds + * of files. + */ +public enum BuildFileType { + /** + * Primary smithy-build configuration file used by most projects. + * + * @see software.amazon.smithy.build.model.SmithyBuildConfig + * @see smithy-build.json + */ + SMITHY_BUILD("smithy-build.json"), + + /** + * A config file used specifically for the language server from before + * maven deps from smithy-build.json were supported. + * + * @see SmithyBuildExtensions + */ + SMITHY_BUILD_EXT_0("build" + File.separator + "smithy-dependencies.json"), + + /** + * A config file used specifically for the language server from before + * maven deps from smithy-build.json were supported. + * + * @see SmithyBuildExtensions + */ + SMITHY_BUILD_EXT_1(".smithy.json"), + + /** + * A config file used specifically for the language server to specify + * project config for a project that isn't specifying sources and + * dependencies in smithy-build.json, typically some external build + * system is being used. + * + * @see SmithyProjectJson + */ + SMITHY_PROJECT(".smithy-project.json"),; + + /** + * The filenames of all {@link BuildFileType}s. + */ + public static final List ALL_FILENAMES = Arrays.stream(BuildFileType.values()) + .map(BuildFileType::filename) + .toList(); + + private final String filename; + + BuildFileType(String filename) { + this.filename = filename; + } + + /** + * @return The filename that denotes this {@link BuildFileType}. + */ + public String filename() { + return filename; + } + + boolean supportsMavenConfiguration() { + return switch (this) { + case SMITHY_BUILD, SMITHY_BUILD_EXT_0, SMITHY_BUILD_EXT_1 -> true; + default -> false; + }; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java b/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java new file mode 100644 index 00000000..b1d2b07a --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java @@ -0,0 +1,109 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.lsp.ManagedFiles; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.utils.IoUtils; + +/** + * Immutable container for multiple {@link BuildFile}s, with accessors by path + * and {@link BuildFileType}. + */ +final class BuildFiles implements Iterable { + private final Map buildFiles; + + private BuildFiles(Map buildFiles) { + this.buildFiles = buildFiles; + } + + @Override + public Iterator iterator() { + return buildFiles.values().iterator(); + } + + BuildFile getByPath(String path) { + return buildFiles.get(path); + } + + BuildFile getByType(BuildFileType type) { + for (BuildFile buildFile : buildFiles.values()) { + if (buildFile.type() == type) { + return buildFile; + } + } + return null; + } + + boolean isEmpty() { + return buildFiles.isEmpty(); + } + + Set getAllPaths() { + return buildFiles.keySet(); + } + + static BuildFiles of(Collection buildFiles) { + Map buildFileMap = new HashMap<>(buildFiles.size()); + for (BuildFile buildFile : buildFiles) { + buildFileMap.put(buildFile.path(), buildFile); + } + return new BuildFiles(buildFileMap); + } + + static BuildFiles of(Path path, Document document) { + for (BuildFileType type : BuildFileType.values()) { + if (path.endsWith(type.filename())) { + String pathString = path.toString(); + BuildFile buildFile = BuildFile.create(pathString, document, type); + return new BuildFiles(Map.of(pathString, buildFile)); + } + } + + return BuildFiles.of(List.of()); + } + + static BuildFiles load(Path root, ManagedFiles managedFiles) { + Map buildFiles = new HashMap<>(BuildFileType.values().length); + for (BuildFileType type : BuildFileType.values()) { + BuildFile buildFile = readBuildFile(type, root, managedFiles); + if (buildFile != null) { + buildFiles.put(buildFile.path(), buildFile); + } + } + return new BuildFiles(buildFiles); + } + + private static BuildFile readBuildFile( + BuildFileType type, + Path workspaceRoot, + ManagedFiles managedFiles + ) { + Path buildFilePath = workspaceRoot.resolve(type.filename()); + if (!Files.isRegularFile(buildFilePath)) { + return null; + } + + String pathString = buildFilePath.toString(); + String uri = LspAdapter.toUri(pathString); + Document document = managedFiles.getManagedDocument(uri); + if (document == null) { + // Note: This shouldn't fail since we checked for the file's existence + document = Document.of(IoUtils.readUtf8File(pathString)); + } + + return BuildFile.create(pathString, document, type); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java b/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java new file mode 100644 index 00000000..f171fbc6 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java @@ -0,0 +1,46 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.util.concurrent.locks.ReentrantLock; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; + +public final class IdlFile extends SmithyFile { + private final ReentrantLock idlParseLock = new ReentrantLock(); + private Syntax.IdlParseResult parseResult; + + IdlFile(String path, Document document, Syntax.IdlParseResult parseResult) { + super(path, document); + this.parseResult = parseResult; + } + + @Override + public void reparse() { + Syntax.IdlParseResult parse = Syntax.parseIdl(document()); + + idlParseLock.lock(); + try { + this.parseResult = parse; + } finally { + idlParseLock.unlock(); + } + } + + /** + * @return The latest computed {@link Syntax.IdlParseResult} of this Smithy file + * @apiNote Don't call this method over and over. {@link Syntax.IdlParseResult} is + * immutable so just call this once and use the returned value. + */ + public Syntax.IdlParseResult getParse() { + idlParseLock.lock(); + try { + return parseResult; + } finally { + idlParseLock.unlock(); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/Project.java b/src/main/java/software/amazon/smithy/lsp/project/Project.java index 42c9135e..0c00a391 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/Project.java +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -7,12 +7,12 @@ 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; 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; @@ -22,11 +22,15 @@ import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.ModelAssembler; +import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.shapes.AbstractShapeBuilder; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.utils.IoUtils; /** @@ -35,25 +39,67 @@ */ public final class Project { private static final Logger LOGGER = Logger.getLogger(Project.class.getName()); + private final Path root; private final ProjectConfig config; - private final List dependencies; + private final BuildFiles buildFiles; private final Map smithyFiles; private final Supplier assemblerFactory; - private ValidatedResult modelResult; - // TODO: Move this into SmithyFileDependenciesIndex - private Map> perFileMetadata; - private SmithyFileDependenciesIndex smithyFileDependenciesIndex; - - private Project(Builder builder) { - this.root = Objects.requireNonNull(builder.root); - this.config = builder.config; - this.dependencies = builder.dependencies; - this.smithyFiles = builder.smithyFiles; - this.modelResult = builder.modelResult; - this.assemblerFactory = builder.assemblerFactory; - this.perFileMetadata = builder.perFileMetadata; - this.smithyFileDependenciesIndex = builder.smithyFileDependenciesIndex; + private final Type type; + private volatile ValidatedResult modelResult; + private volatile RebuildIndex rebuildIndex; + private volatile List configEvents; + + Project( + Path root, + ProjectConfig config, + BuildFiles buildFiles, + Map smithyFiles, + Supplier assemblerFactory, + Type type, + ValidatedResult modelResult, + RebuildIndex rebuildIndex, + List configEvents + ) { + this.root = root; + this.config = config; + this.buildFiles = buildFiles; + this.smithyFiles = smithyFiles; + this.assemblerFactory = assemblerFactory; + this.type = type; + this.modelResult = modelResult; + this.rebuildIndex = rebuildIndex; + this.configEvents = configEvents; + } + + /** + * The type of project, which depends on how it was loaded. + */ + public enum Type { + /** + * A project loaded using some build configuration files, i.e. smithy-build.json. + */ + NORMAL, + + /** + * A project loaded from a single source file, without any build configuration files. + */ + DETACHED, + + /** + * A project loaded from a single build file. + * + *

This occurs when a newly created build file is opened before we + * receive its `didChangeWatchedFiles` notification, which takes care + * of both adding new build files to an existing project, and creating + * a new project in a new root. + */ + UNRESOLVED, + + /** + * A project loaded with no source or build configuration files. + */ + EMPTY; } /** @@ -63,10 +109,15 @@ private Project(Builder builder) { * @return The empty project */ public static Project empty(Path root) { - return builder() - .root(root) - .modelResult(ValidatedResult.empty()) - .build(); + return new Project(root, + ProjectConfig.empty(), + BuildFiles.of(List.of()), + new HashMap<>(), + Model::assembler, + Type.EMPTY, + ValidatedResult.empty(), + new RebuildIndex(), + List.of()); } /** @@ -76,6 +127,14 @@ public Path root() { return root; } + ProjectConfig config() { + return config; + } + + public List configEvents() { + return configEvents; + } + /** * @return The paths of all Smithy sources specified * in this project's smithy build configuration files, @@ -101,18 +160,25 @@ public List imports() { } /** - * @return The paths of all resolved dependencies + * @return The paths of all Smithy files loaded in the project. */ - public List dependencies() { - return dependencies; + public Set getAllSmithyFilePaths() { + return this.smithyFiles.keySet(); + } + + public Set getAllBuildFilePaths() { + return this.buildFiles.getAllPaths(); } /** - * @return A map of paths to the {@link SmithyFile} at that path, containing - * all smithy files loaded in the project. + * @return All the Smithy files loaded in the project. */ - public Map smithyFiles() { - return this.smithyFiles; + public Collection getAllSmithyFiles() { + return this.smithyFiles.values(); + } + + public Type type() { + return type; } /** @@ -123,27 +189,31 @@ public ValidatedResult modelResult() { } /** - * @param uri The URI of the {@link Document} to get - * @return The {@link Document} corresponding to the given {@code uri} if - * it exists in this project, otherwise {@code null} + * @param uri The uri of the {@link ProjectFile} to get + * @return The {@link ProjectFile} corresponding to {@code path} if + * it exists in this project, otherwise {@code null}. */ - public Document getDocument(String uri) { + public ProjectFile getProjectFile(String uri) { String path = LspAdapter.toPath(uri); SmithyFile smithyFile = smithyFiles.get(path); - if (smithyFile == null) { - return null; + if (smithyFile != null) { + return smithyFile; } - return smithyFile.document(); + + return buildFiles.getByPath(path); } /** - * @param uri The URI of the {@link SmithyFile} to get - * @return The {@link SmithyFile} corresponding to the given {@code uri} if - * it exists in this project, otherwise {@code null} + * @param shape The shape to get the definition file of + * @return The file the shape is defined in, or {@code null} if the file + * isn't in this project */ - public SmithyFile getSmithyFile(String uri) { - String path = LspAdapter.toPath(uri); - return smithyFiles.get(path); + public SmithyFile getDefinitionFile(Shape shape) { + return smithyFiles.get(shape.getSourceLocation().getFilename()); + } + + public synchronized void validateConfig() { + this.configEvents = ProjectConfigLoader.validateBuildFiles(buildFiles); } /** @@ -174,6 +244,8 @@ public void updateAndValidateModel(String uri) { */ public void updateFiles(Set addUris, Set removeUris) { updateFiles(addUris, removeUris, Collections.emptySet(), true); + // Config has to be re-validated because it may be reporting missing files + validateConfig(); } /** @@ -186,7 +258,7 @@ public void updateFiles(Set addUris, Set removeUris) { * @param changeUris URIs of files that changed * @param validate Whether to run model validation. */ - public void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) { + private void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) { if (modelResult.getResult().isEmpty()) { // TODO: If there's no model, we didn't collect the smithy files (so no document), so I'm thinking // maybe we do nothing here. But we could also still update the document, and @@ -206,7 +278,6 @@ public void updateFiles(Set addUris, Set removeUris, Set // So we don't have to recompute the paths later Set removedPaths = new HashSet<>(removeUris.size()); - Set changedPaths = new HashSet<>(changeUris.size()); Set visited = new HashSet<>(); @@ -227,7 +298,6 @@ public void updateFiles(Set addUris, Set removeUris, Set for (String uri : changeUris) { String path = LspAdapter.toPath(uri); - changedPaths.add(path); removeFileForReload(assembler, builder, path, visited); removeDependentsForReload(assembler, builder, path, visited); @@ -250,7 +320,15 @@ public void updateFiles(Set addUris, Set removeUris, Set } for (String uri : addUris) { - assembler.addImport(LspAdapter.toPath(uri)); + String path = LspAdapter.toPath(uri); + String text = IoUtils.readUtf8File(path); + + // TODO: Inefficient ? + Document document = Document.of(text); + SmithyFile smithyFile = SmithyFile.create(path, document); + this.smithyFiles.put(path, smithyFile); + + assembler.addUnparsedModel(path, text); } if (!validate) { @@ -258,31 +336,7 @@ public void updateFiles(Set addUris, Set removeUris, Set } this.modelResult = assembler.assemble(); - this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); - this.smithyFileDependenciesIndex = SmithyFileDependenciesIndex.compute(this.modelResult); - - for (String visitedPath : visited) { - if (!removedPaths.contains(visitedPath)) { - SmithyFile current = smithyFiles.get(visitedPath); - Set updatedShapes = getFileShapes(visitedPath, smithyFiles.get(visitedPath).shapes()); - // Only recompute the rest of the smithy file if it changed - if (changedPaths.contains(visitedPath)) { - // TODO: Could cache validation events - this.smithyFiles.put(visitedPath, - ProjectLoader.buildSmithyFile(visitedPath, current.document(), updatedShapes).build()); - } else { - current.setShapes(updatedShapes); - } - } - } - - for (String uri : addUris) { - String path = LspAdapter.toPath(uri); - Set fileShapes = getFileShapes(path, Collections.emptySet()); - Document document = Document.of(IoUtils.readUtf8File(path)); - SmithyFile smithyFile = ProjectLoader.buildSmithyFile(path, document, fileShapes).build(); - smithyFiles.put(path, smithyFile); - } + this.rebuildIndex = this.rebuildIndex.recompute(this.modelResult); } // This mainly exists to explain why we remove the metadata @@ -306,21 +360,23 @@ private void removeFileForReload( visited.add(path); - for (Shape shape : smithyFiles.get(path).shapes()) { - builder.removeShape(shape.getId()); + for (ToShapeId toShapeId : this.rebuildIndex.getDefinedShapes(path)) { + builder.removeShape(toShapeId.toShapeId()); // This shape may have traits applied to it in other files, // so simply removing the shape loses the information about // those traits. // This shape's dependencies files will be removed and re-loaded - smithyFileDependenciesIndex.getDependenciesFiles(shape).forEach((depPath) -> - removeFileForReload(assembler, builder, depPath, visited)); + for (String depPath : this.rebuildIndex.getDependenciesFiles(toShapeId)) { + removeFileForReload(assembler, builder, depPath, visited); + } // Traits applied in other files are re-added to the assembler so if/when the shape // is reloaded, it will have those traits - smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(shape).forEach((trait) -> - assembler.addTrait(shape.getId(), trait)); + for (Trait trait : this.rebuildIndex.getTraitsAppliedInOtherFiles(toShapeId)) { + assembler.addTrait(toShapeId.toShapeId(), trait); + } } } @@ -332,11 +388,11 @@ private void removeDependentsForReload( ) { // This file may apply traits to shapes in other files. Normally, letting the assembler simply reparse // the file would be fine because it would ignore the duplicated trait application coming from the same - // source location. But if the apply statement is changed/removed, the old application isn't removed, so we - // could get a duplicate trait, or a merged array trait. - smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) -> + // source location. But if the apply statement is changed/removed, the old trait isn't removed, so we + // could get a duplicate application, or a merged array application. + this.rebuildIndex.getDependentFiles(path).forEach((depPath) -> removeFileForReload(assembler, builder, depPath, visited)); - smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { + this.rebuildIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { Shape shape = builder.getCurrentShapes().get(shapeId); if (shape != null) { builder.removeShape(shapeId); @@ -350,87 +406,164 @@ private void removeDependentsForReload( } private void addRemainingMetadataForReload(Model.Builder builder, Set filesToSkip) { - for (Map.Entry> e : this.perFileMetadata.entrySet()) { + for (Map.Entry> e : this.rebuildIndex.filesToMetadata().entrySet()) { if (!filesToSkip.contains(e.getKey())) { e.getValue().forEach(builder::putMetadataProperty); } } } - private Set getFileShapes(String path, Set orDefault) { - return this.modelResult.getResult() - .map(model -> model.shapes() - .filter(shape -> shape.getSourceLocation().getFilename().equals(path)) - .collect(Collectors.toSet())) - .orElse(orDefault); - } - - static Builder builder() { - return new Builder(); - } - - static final class Builder { - private Path root; - private ProjectConfig config = ProjectConfig.empty(); - private final List dependencies = new ArrayList<>(); - private final Map smithyFiles = new HashMap<>(); - private ValidatedResult modelResult; - private Supplier assemblerFactory = Model::assembler; - private Map> perFileMetadata = new HashMap<>(); - private SmithyFileDependenciesIndex smithyFileDependenciesIndex = new SmithyFileDependenciesIndex(); - - private Builder() { + /** + * An index that caches rebuild dependency relationships between Smithy files, + * shapes, traits, and metadata. + * + *

This is specifically for the following scenarios: + *

+ *
A file applies traits to shapes in other files
+ *
If that file changes, the applied traits need to be removed before the + * file is reloaded, so there aren't duplicate traits.
+ *
A file has shapes with traits applied in other files
+ *
If that file changes, the traits need to be re-applied when the model is + * re-assembled, so they aren't lost.
+ *
Either 1 or 2, but specifically with list traits
+ *
List traits are merged via + * trait conflict resolution . For these traits, all files that contain + * parts of the list trait must be fully reloaded, since we can only remove + * the whole trait, not parts of it.
+ *
A file has metadata
+ *
Metadata for a specific file has to be removed before reloading that + * file, but since array nodes are merged, we also need to keep track of + * other files' metadata that may also need to be reloaded.
+ *
+ */ + record RebuildIndex( + Map> filesToDependentFiles, + Map> shapeIdsToDependenciesFiles, + Map>> filesToTraitsTheyApply, + Map> shapesToAppliedTraitsInOtherFiles, + Map> filesToMetadata, + Map> filesToDefinedShapes + ) { + private RebuildIndex() { + this( + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0), + new HashMap<>(0) + ); } - public Builder root(Path root) { - this.root = root; - return this; + static RebuildIndex create(ValidatedResult modelResult) { + return new RebuildIndex().recompute(modelResult); } - public Builder config(ProjectConfig config) { - this.config = config; - return this; + Set getDependentFiles(String path) { + return filesToDependentFiles.getOrDefault(path, Collections.emptySet()); } - public Builder dependencies(List paths) { - this.dependencies.clear(); - this.dependencies.addAll(paths); - return this; + Set getDependenciesFiles(ToShapeId toShapeId) { + return shapeIdsToDependenciesFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptySet()); } - public Builder addDependency(Path path) { - this.dependencies.add(path); - return this; + Map> getAppliedTraitsInFile(String path) { + return filesToTraitsTheyApply.getOrDefault(path, Collections.emptyMap()); } - public Builder smithyFiles(Map smithyFiles) { - this.smithyFiles.clear(); - this.smithyFiles.putAll(smithyFiles); - return this; + List getTraitsAppliedInOtherFiles(ToShapeId toShapeId) { + return shapesToAppliedTraitsInOtherFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptyList()); } - public Builder modelResult(ValidatedResult modelResult) { - this.modelResult = modelResult; - return this; + Set getDefinedShapes(String path) { + return filesToDefinedShapes.getOrDefault(path, Collections.emptySet()); } - public Builder assemblerFactory(Supplier assemblerFactory) { - this.assemblerFactory = assemblerFactory; - return this; - } + RebuildIndex recompute(ValidatedResult modelResult) { + var newIndex = new RebuildIndex( + new HashMap<>(filesToDependentFiles.size()), + new HashMap<>(shapeIdsToDependenciesFiles.size()), + new HashMap<>(filesToTraitsTheyApply.size()), + new HashMap<>(shapesToAppliedTraitsInOtherFiles.size()), + new HashMap<>(filesToMetadata.size()), + new HashMap<>(filesToDefinedShapes.size()) + ); + + if (modelResult.getResult().isEmpty()) { + return newIndex; + } - public Builder perFileMetadata(Map> perFileMetadata) { - this.perFileMetadata = perFileMetadata; - return this; - } + Model model = modelResult.getResult().get(); + + // This is gross, but necessary to deal with the way that array metadata gets merged. + // When we try to reload a single file, we need to make sure we remove the metadata for + // that file. But if there's array metadata, a single key contains merged elements from + // other files. This splits up the metadata by source file, creating an artificial array + // node for elements that are merged. + for (var metadataEntry : model.getMetadata().entrySet()) { + if (metadataEntry.getValue().isArrayNode()) { + Map arrayByFile = new HashMap<>(); + for (Node node : metadataEntry.getValue().expectArrayNode()) { + String filename = node.getSourceLocation().getFilename(); + arrayByFile.computeIfAbsent(filename, (f) -> ArrayNode.builder()).withValue(node); + } + for (var arrayByFileEntry : arrayByFile.entrySet()) { + newIndex.filesToMetadata.computeIfAbsent(arrayByFileEntry.getKey(), (f) -> new HashMap<>()) + .put(metadataEntry.getKey(), arrayByFileEntry.getValue().build()); + } + } else { + String filename = metadataEntry.getValue().getSourceLocation().getFilename(); + newIndex.filesToMetadata.computeIfAbsent(filename, (f) -> new HashMap<>()) + .put(metadataEntry.getKey(), metadataEntry.getValue()); + } + } + + for (Shape shape : model.toSet()) { + String shapeSourceFilename = shape.getSourceLocation().getFilename(); + newIndex.filesToDefinedShapes.computeIfAbsent(shapeSourceFilename, (f) -> new HashSet<>()) + .add(shape); + + for (Trait traitApplication : shape.getAllTraits().values()) { + // We only care about trait applications in the source files + if (traitApplication.isSynthetic()) { + continue; + } + + Node traitNode = traitApplication.toNode(); + if (traitNode.isArrayNode()) { + for (Node element : traitNode.expectArrayNode()) { + SourceLocation elementSourceLocation = element.getSourceLocation(); + String elementSourceFilename = elementSourceLocation.getFilename(); + if (!isNone(elementSourceLocation) && !elementSourceFilename.equals(shapeSourceFilename)) { + newIndex.filesToDependentFiles + .computeIfAbsent(elementSourceFilename, (f) -> new HashSet<>()) + .add(shapeSourceFilename); + newIndex.shapeIdsToDependenciesFiles + .computeIfAbsent(shape.getId(), (i) -> new HashSet<>()) + .add(elementSourceFilename); + } + } + } else { + SourceLocation traitSourceLocation = traitNode.getSourceLocation(); + String traitSourceFilename = traitSourceLocation.getFilename(); + if (!isNone(traitSourceLocation) && !traitSourceFilename.equals(shapeSourceFilename)) { + newIndex.shapesToAppliedTraitsInOtherFiles + .computeIfAbsent(shape.getId(), (i) -> new ArrayList<>()) + .add(traitApplication); + newIndex.filesToTraitsTheyApply + .computeIfAbsent(traitSourceFilename, (f) -> new HashMap<>()) + .computeIfAbsent(shape.getId(), (i) -> new ArrayList<>()) + .add(traitApplication); + } + } + } + } - public Builder smithyFileDependenciesIndex(SmithyFileDependenciesIndex smithyFileDependenciesIndex) { - this.smithyFileDependenciesIndex = smithyFileDependenciesIndex; - return this; + return newIndex; } - public Project build() { - return new Project(this); + private static boolean isNone(SourceLocation sourceLocation) { + return sourceLocation.getFilename().equals(SourceLocation.NONE.getFilename()); } } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java new file mode 100644 index 00000000..7790acab --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.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.project; + +/** + * Simple wrapper for a project and a file in that project, which many + * server functions act upon. + * + * @param uri The uri of the file + * @param project The project, non-nullable + * @param file The file within {@code project}, non-nullable + */ +public record ProjectAndFile(String uri, Project project, ProjectFile file) { +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java similarity index 59% rename from src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java rename to src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java index 0da219e9..2e85742a 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectChanges.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectChange.java @@ -5,6 +5,7 @@ package software.amazon.smithy.lsp.project; +import java.util.HashSet; import java.util.Set; /** @@ -14,22 +15,18 @@ * @param createdSmithyFileUris The uris of created Smithy files * @param deletedSmithyFileUris The uris of deleted Smithy files */ -public record ProjectChanges( +public record ProjectChange( Set changedBuildFileUris, Set createdSmithyFileUris, Set deletedSmithyFileUris ) { /** - * @return Whether there are any changed build files + * @return An empty and mutable set of project changes */ - public boolean hasChangedBuildFiles() { - return !changedBuildFileUris.isEmpty(); - } - - /** - * @return Whether there are any changed Smithy files - */ - public boolean hasChangedSmithyFiles() { - return !createdSmithyFileUris.isEmpty() || !deletedSmithyFileUris.isEmpty(); + public static ProjectChange empty() { + return new ProjectChange( + new HashSet<>(), + new HashSet<>(), + new HashSet<>()); } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java index 33e5ec21..3f05e0a0 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -5,151 +5,78 @@ package software.amazon.smithy.lsp.project; +import java.net.URL; 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, * merged from all configuration sources. */ final class ProjectConfig { + private static final MavenConfig DEFAULT_MAVEN = MavenConfig.builder().build(); + private final List sources; private final List imports; - private final String outputDirectory; - private final List dependencies; - private final MavenConfig mavenConfig; + private final List projectDependencies; + private final MavenConfig maven; + private final List modelPaths; + private final List resolvedDependencies; + + ProjectConfig( + List sources, + List imports, + List projectDependencies, + MavenConfig maven, + List modelPaths, + List resolvedDependencies + ) { + this.sources = sources; + this.imports = imports; + this.projectDependencies = projectDependencies; + this.maven = maven == null ? DEFAULT_MAVEN : maven; + this.modelPaths = modelPaths; + this.resolvedDependencies = resolvedDependencies; + } + + private ProjectConfig() { + this(List.of(), List.of(), List.of(), DEFAULT_MAVEN, List.of(), List.of()); + } - private ProjectConfig(Builder builder) { - this.sources = builder.sources; - this.imports = builder.imports; - this.outputDirectory = builder.outputDirectory; - this.dependencies = builder.dependencies; - this.mavenConfig = builder.mavenConfig; + private ProjectConfig(Path modelPath) { + this(List.of(), List.of(), List.of(), DEFAULT_MAVEN, List.of(modelPath), List.of()); } static ProjectConfig empty() { - return builder().build(); + return new ProjectConfig(); } - static Builder builder() { - return new Builder(); + static ProjectConfig detachedConfig(Path modelPath) { + return new ProjectConfig(modelPath); } - /** - * @return All explicitly configured sources - */ - public List sources() { + List sources() { return sources; } - /** - * @return All explicitly configured imports - */ - public List imports() { + List imports() { return imports; } - /** - * @return The configured output directory, if one is present - */ - public Optional outputDirectory() { - return Optional.ofNullable(outputDirectory); + List projectDependencies() { + return projectDependencies; } - /** - * @return All configured external (non-maven) dependencies - */ - public List dependencies() { - return dependencies; + MavenConfig maven() { + return maven; } - /** - * @return The Maven configuration, if present - */ - public Optional maven() { - return Optional.ofNullable(mavenConfig); + List modelPaths() { + return modelPaths; } - 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() { - } - - 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); - 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); - } + List resolvedDependencies() { + return resolvedDependencies; } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java index c299ecea..c99897e1 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java @@ -5,136 +5,611 @@ package software.amazon.smithy.lsp.project; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; -import java.util.logging.Logger; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Stream; +import software.amazon.smithy.build.model.MavenConfig; +import software.amazon.smithy.build.model.MavenRepository; import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.util.Result; +import software.amazon.smithy.cli.EnvironmentVariable; +import software.amazon.smithy.cli.dependencies.DependencyResolver; +import software.amazon.smithy.cli.dependencies.DependencyResolverException; +import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; +import software.amazon.smithy.cli.dependencies.ResolvedArtifact; +import software.amazon.smithy.model.SourceException; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.ModelDiscovery; +import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.NodeMapper; import software.amazon.smithy.model.node.ObjectNode; -import software.amazon.smithy.utils.IoUtils; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; /** - * Loads {@link ProjectConfig}s from a given root directory - * - *

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

Aggregation is done as follows: - *

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

Resolving a {@link ProjectDependency} is as simple as getting its path - * relative to the project root, but is done here in order to be loaded the - * same way as Maven dependencies. - * TODO: There are some things in here taken from smithy-cli. Should figure out - * if we can expose them through smithy-cli instead of duplicating them here to - * avoid drift. - */ -final class ProjectDependencyResolver { - // Taken from smithy-cli ConfigurationUtils - private static final Supplier CENTRAL = () -> MavenRepository.builder() - .url("https://repo.maven.apache.org/maven2") - .build(); - - private ProjectDependencyResolver() { - } - - static Result, Exception> resolveDependencies(Path root, ProjectConfig config) { - return Result.ofFallible(() -> { - List deps = ProjectDependencyResolver.create(config).resolve() - .stream() - .map(ResolvedArtifact::getPath) - .collect(Collectors.toCollection(ArrayList::new)); - config.dependencies().forEach((projectDependency) -> { - // TODO: Not sure if this needs to check for existence - Path path = root.resolve(projectDependency.path()).normalize(); - deps.add(path); - }); - return deps; - }); - } - - // Taken (roughly) from smithy-cli ClasspathAction::resolveDependencies - private static DependencyResolver create(ProjectConfig config) { - // TODO: Seeing what happens if we just don't use the file cache. When we do, at least for testing, the - // server writes a classpath.json to build/smithy/ which is used by all tests, messing everything up. - DependencyResolver resolver = new MavenDependencyResolver(EnvironmentVariable.SMITHY_MAVEN_CACHE.get()); - - Set configuredRepositories = getConfiguredMavenRepos(config); - configuredRepositories.forEach(resolver::addRepository); - - // TODO: Support lock file ? - config.maven().ifPresent(maven -> maven.getDependencies().forEach(resolver::addDependency)); - - return resolver; - } - - // TODO: If this cache file is necessary for the server's use cases, we may - // want to keep an in memory version of it so we don't write stuff to - // people's build dirs. Right now, we just don't use it at all. - // Taken (roughly) from smithy-cli ClasspathAction::getCacheFile - private static File getCacheFile(ProjectConfig config) { - return getOutputDirectory(config).resolve("classpath.json").toFile(); - } - - // Taken from smithy-cli BuildOptions::resolveOutput - private static Path getOutputDirectory(ProjectConfig config) { - return config.outputDirectory() - .map(Paths::get) - .orElseGet(SmithyBuild::getDefaultOutputDirectory); - } - - // Taken from smithy-cli ConfigurationUtils::getConfiguredMavenRepos - private static Set getConfiguredMavenRepos(ProjectConfig config) { - Set repositories = new LinkedHashSet<>(); - - String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get(); - if (envRepos != null) { - for (String repo : envRepos.split("\\|")) { - repositories.add(MavenRepository.builder().url(repo.trim()).build()); - } - } - - Set configuredRepos = config.maven() - .map(MavenConfig::getRepositories) - .orElse(Collections.emptySet()); - - if (!configuredRepos.isEmpty()) { - repositories.addAll(configuredRepos); - } else if (envRepos == null) { - repositories.add(CENTRAL.get()); - } - return repositories; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.java new file mode 100644 index 00000000..0ddfe67f --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectFile.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.project; + +import software.amazon.smithy.lsp.document.Document; + +/** + * A file belonging to a Smithy project that the language server understands + * and tracks. + */ +public sealed interface ProjectFile permits SmithyFile, BuildFile { + /** + * @return The absolute path of the file + */ + String path(); + + /** + * @return The underlying document of the file + */ + Document document(); + + /** + * Reparse the underlying document. + */ + void reparse(); +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java deleted file mode 100644 index f4ba000c..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectFilePatterns.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import java.io.File; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Utility methods for creating file patterns corresponding to meaningful - * paths of a {@link Project}, such as sources and build files. - */ -public final class ProjectFilePatterns { - private static final int BUILD_FILE_COUNT = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length; - - private ProjectFilePatterns() { - } - - /** - * @param project The project to get watch patterns for - * @return A list of glob patterns used to watch Smithy files in the given project - */ - public static List getSmithyFileWatchPatterns(Project project) { - return Stream.concat(project.sources().stream(), project.imports().stream()) - .map(path -> getSmithyFilePattern(path, true)) - .toList(); - } - - /** - * @param project The project to get a path matcher for - * @return A path matcher that can check if Smithy files belong to the given project - */ - public static PathMatcher getSmithyFilesPathMatcher(Project project) { - String pattern = Stream.concat(project.sources().stream(), project.imports().stream()) - .map(path -> getSmithyFilePattern(path, false)) - .collect(Collectors.joining(",")); - return FileSystems.getDefault().getPathMatcher("glob:{" + pattern + "}"); - } - - /** - * @param project The project to get the watch pattern for - * @return A glob pattern used to watch build files in the given project - */ - public static String getBuildFilesWatchPattern(Project project) { - Path root = project.root(); - String buildJsonPattern = escapeBackslashes(root.resolve(ProjectConfigLoader.SMITHY_BUILD).toString()); - String projectJsonPattern = escapeBackslashes(root.resolve(ProjectConfigLoader.SMITHY_PROJECT).toString()); - - List patterns = new ArrayList<>(BUILD_FILE_COUNT); - patterns.add(buildJsonPattern); - patterns.add(projectJsonPattern); - for (String buildExt : ProjectConfigLoader.SMITHY_BUILD_EXTS) { - patterns.add(escapeBackslashes(root.resolve(buildExt).toString())); - } - - return "{" + String.join(",", patterns) + "}"; - } - - /** - * @param project The project to get a path matcher for - * @return A path matcher that can check if a file is a build file belonging to the given project - */ - public static PathMatcher getBuildFilesPathMatcher(Project project) { - // Watch pattern is the same as the pattern used for matching - String pattern = getBuildFilesWatchPattern(project); - return FileSystems.getDefault().getPathMatcher("glob:" + pattern); - } - - // When computing the pattern used for telling the client which files to watch, we want - // to only watch .smithy/.json files. We don't need in the PathMatcher pattern (and it - // is impossible anyway because we can't have a nested pattern). - private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) { - String glob = path.toString(); - if (glob.endsWith(".smithy") || glob.endsWith(".json")) { - return escapeBackslashes(glob); - } - - if (!glob.endsWith(File.separator)) { - glob += File.separator; - } - glob += "**"; - - if (isWatcherPattern) { - glob += ".{smithy,json}"; - } - - return escapeBackslashes(glob); - } - - // In glob patterns, '\' is an escape character, so it needs to escaped - // itself to work as a separator (i.e. for windows) - private static String escapeBackslashes(String pattern) { - return pattern.replace("\\", "\\\\"); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java index cc4f8c6d..1c5025a0 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -5,59 +5,35 @@ 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.HashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.function.Supplier; -import java.util.logging.Logger; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.ManagedFiles; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.document.DocumentImports; -import software.amazon.smithy.lsp.document.DocumentNamespace; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentShape; -import software.amazon.smithy.lsp.document.DocumentVersion; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; 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; +import software.amazon.smithy.utils.TriConsumer; /** * Loads {@link Project}s. - * - * TODO: There's a lot of duplicated logic and redundant code here to refactor. */ public final class ProjectLoader { - private static final Logger LOGGER = Logger.getLogger(ProjectLoader.class.getName()); - private ProjectLoader() { } /** - * Loads a detached (single-file) {@link Project} with the given file. + * Loads a detachedProjects (single-file) {@link Project} with the given file. * - *

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

Unlike {@link #load(Path, ManagedFiles)}, this method isn't * fallible since it doesn't do any IO that we would want to recover an * error from. * @@ -66,42 +42,60 @@ private ProjectLoader() { * @return The loaded project */ public static Project loadDetached(String uri, String text) { - LOGGER.info("Loading detached project at " + uri); - String asPath = LspAdapter.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.getParent()) - .config(ProjectConfig.builder() - .sources(Collections.singletonList(asPath)) - .build()) - .modelResult(modelResult); - - 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 (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { - return Document.of(IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath))); - } else if (filePath.equals(asPath)) { - Document document = Document.of(text); + Document document = Document.of(text); + ManagedFiles managedFiles = (fileUri) -> { + if (uri.equals(fileUri)) { return document; - } else { - // 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."); } - }); + return null; + }; + + Path path = Paths.get(LspAdapter.toPath(uri)); + ProjectConfig config = ProjectConfig.detachedConfig(path); + LoadModelResult result = doLoad(managedFiles, config); + + return new Project( + path, + config, + BuildFiles.of(List.of()), + result.smithyFiles(), + result.assemblerFactory(), + Project.Type.DETACHED, + result.modelResult(), + result.rebuildIndex(), + List.of() + ); + } - return builder.smithyFiles(smithyFiles) - .perFileMetadata(computePerFileMetadata(modelResult)) - .build(); + /** + * Loads an unresolved (single config file) {@link Project} with the given file. + * + * @param path Path of the file to load into a project + * @param text Text of the file to load into a project + * @return The loaded project + */ + public static Project loadUnresolved(Path path, String text) { + Document document = Document.of(text); + BuildFiles buildFiles = BuildFiles.of(path, document); + + // An unresolved project is meant to be resolved at a later point, so we don't + // even try loading its configuration from the build file. + ProjectConfig config = ProjectConfig.empty(); + + // We aren't loading any smithy files in this project, so use a no-op ManagedFiles. + LoadModelResult result = doLoad((fileUri) -> null, config); + + return new Project( + path, + config, + buildFiles, + result.smithyFiles(), + result.assemblerFactory(), + Project.Type.UNRESOLVED, + result.modelResult(), + result.rebuildIndex(), + List.of() + ); } /** @@ -117,297 +111,141 @@ public static Project loadDetached(String uri, String text) { * reason about how the project was structured. * * @param root Path of the project root - * @param projects Currently loaded projects, for getting content of managed documents - * @param managedDocuments URIs of documents managed by the client + * @param managedFiles Files managed by the server * @return Result of loading the project */ - public static Result> load( - Path root, - ProjectManager projects, - Set managedDocuments - ) { - 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())); - } + public static Project load(Path root, ManagedFiles managedFiles) throws Exception { + var buildFiles = BuildFiles.load(root, managedFiles); + if (buildFiles.isEmpty()) { + return Project.empty(root); + } + + ProjectConfigLoader.Result configResult = ProjectConfigLoader.load(root, buildFiles); + LoadModelResult result = doLoad(managedFiles, configResult.config()); + + return new Project( + root, + configResult.config(), + buildFiles, + result.smithyFiles(), + result.assemblerFactory(), + Project.Type.NORMAL, + result.modelResult(), + result.rebuildIndex(), + configResult.events() + ); + } - List dependencies = resolveResult.unwrap(); + private record LoadModelResult( + Supplier assemblerFactory, + ValidatedResult modelResult, + Map smithyFiles, + Project.RebuildIndex rebuildIndex + ) { + } + private static LoadModelResult doLoad(ManagedFiles managedFiles, ProjectConfig config) { // The model assembler factory is used to get assemblers that already have the correct // dependencies resolved for future loads - Result, Exception> assemblerFactoryResult = createModelAssemblerFactory(dependencies); - if (assemblerFactoryResult.isErr()) { - return Result.err(Collections.singletonList(assemblerFactoryResult.unwrapErr())); - } + Supplier assemblerFactory = createModelAssemblerFactory(config.resolvedDependencies()); - Supplier assemblerFactory = assemblerFactoryResult.unwrap(); - ModelAssembler assembler = assemblerFactory.get(); - - // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential - // here for inconsistent behavior. - List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); + Map smithyFiles = new HashMap<>(config.modelPaths().size()); - Result, Exception> loadModelResult = Result.ofFallible(() -> { - for (Path path : allSmithyFilePaths) { - if (!managedDocuments.isEmpty()) { - String pathString = path.toString(); - String uri = LspAdapter.toUri(pathString); - if (managedDocuments.contains(uri)) { - assembler.addUnparsedModel(pathString, projects.getDocument(uri).copyText()); - } else { - assembler.addImport(path); - } - } 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 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(); - - Project.Builder projectBuilder = Project.builder() - .root(root) - .config(config) - .dependencies(dependencies) - .modelResult(modelResult) - .assemblerFactory(assemblerFactory); - - 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 (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { - // Technically this can throw - return Document.of(IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath))); - } - // TODO: We recompute uri from path and vice-versa very frequently, - // maybe we can cache it. - String uri = LspAdapter.toUri(filePath); - if (managedDocuments.contains(uri)) { - return projects.getDocument(uri); - } - // There may be a more efficient way of reading this - return Document.of(IoUtils.readUtf8File(filePath)); - }); + ModelAssembler assembler = assemblerFactory.get(); + ValidatedResult modelResult = loadModel(managedFiles, config.modelPaths(), assembler, smithyFiles); - return Result.ok(projectBuilder.smithyFiles(smithyFiles) - .perFileMetadata(computePerFileMetadata(modelResult)) - .smithyFileDependenciesIndex(SmithyFileDependenciesIndex.compute(modelResult)) - .build()); - } + Project.RebuildIndex rebuildIndex = Project.RebuildIndex.create(modelResult); + addDependencySmithyFiles(managedFiles, rebuildIndex.filesToDefinedShapes().keySet(), smithyFiles); - static Result> load(Path root) { - return load(root, new ProjectManager(), new HashSet<>(0)); + return new LoadModelResult( + assemblerFactory, + modelResult, + smithyFiles, + rebuildIndex + ); } - private static Map computeSmithyFiles( + private static ValidatedResult loadModel( + ManagedFiles managedFiles, List allSmithyFilePaths, - ValidatedResult modelResult, - Function documentProvider + ModelAssembler assembler, + Map smithyFiles ) { - Map> shapesByFile; - if (modelResult.getResult().isPresent()) { - Model model = modelResult.getResult().get(); - shapesByFile = model.shapes().collect(Collectors.groupingByConcurrent( - shape -> shape.getSourceLocation().getFilename(), Collectors.toSet())); - } else { - shapesByFile = new HashMap<>(allSmithyFilePaths.size()); - } - - // There may be smithy files part of the project that aren't part of the model - for (Path smithyFilePath : allSmithyFilePaths) { - String pathString = smithyFilePath.toString(); - if (!shapesByFile.containsKey(pathString)) { - shapesByFile.put(pathString, Collections.emptySet()); - } - } - - 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 smithyFiles; - } - - /** - * Computes extra information about what is in the Smithy file and where, - * such as the namespace, imports, version number, and shapes. - * - * @param path Path of the Smithy file - * @param document The document backing the Smithy file - * @param shapes The shapes defined in the Smithy file - * @return A builder for the Smithy file - */ - public static SmithyFile.Builder buildSmithyFile(String path, Document document, Set shapes) { - DocumentParser documentParser = DocumentParser.forDocument(document); - DocumentNamespace namespace = documentParser.documentNamespace(); - DocumentImports imports = documentParser.documentImports(); - Map documentShapes = documentParser.documentShapes(shapes); - DocumentVersion documentVersion = documentParser.documentVersion(); - return SmithyFile.builder() - .path(path) - .document(document) - .shapes(shapes) - .namespace(namespace) - .imports(imports) - .documentShapes(documentShapes) - .documentVersion(documentVersion); - } + TriConsumer consumer = (filePath, text, document) -> { + assembler.addUnparsedModel(filePath, text.toString()); + smithyFiles.put(filePath, SmithyFile.create(filePath, document)); + }; - // 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()); - } + for (Path path : allSmithyFilePaths) { + String pathString = path.toString(); + findOrReadDocument(managedFiles, pathString, consumer); } - 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 - // 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); - }); + 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(); + // Smithy files in jars were loaded by the model assembler via model discovery, so we need to collect those. + private static void addDependencySmithyFiles( + ManagedFiles managedFiles, + Set loadedSmithyFilePaths, + Map smithyFiles + ) { + TriConsumer consumer = (filePath, text, document) -> { + SmithyFile smithyFile = SmithyFile.create(filePath, document); + smithyFiles.put(filePath, smithyFile); + }; + + for (String loadedPath : loadedSmithyFilePaths) { + if (!smithyFiles.containsKey(loadedPath)) { + findOrReadDocument(managedFiles, loadedPath, consumer); } - return 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).normalize(); - collectDirectory(paths, root, path); - } - for (String file : imports) { - Path path = root.resolve(file).normalize(); - collectDirectory(paths, root, path); + private static void findOrReadDocument( + ManagedFiles managedFiles, + String filePath, + TriConsumer consumer + ) { + // NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but + // the model stores jar paths as URIs + if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) { + // Technically this can throw + String text = IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath)); + Document document = Document.of(text); + consumer.accept(filePath, text, document); + return; } - return 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 + // TODO: We recompute uri from path and vice-versa very frequently, + // maybe we can cache it. + String uri = LspAdapter.toUri(filePath); + Document managed = managedFiles.getManagedDocument(uri); + if (managed != null) { + CharSequence text = managed.borrowText(); + consumer.accept(filePath, text, managed); + return; } - } - private static void collectJar(List accumulator, String jarRoot, Path jarPath) { - URL manifestUrl = ModelDiscovery.createSmithyJarManifestUrl(jarPath.toString()); - - String prefix = computeJarFilePrefix(jarRoot, jarPath); - for (URL model : ModelDiscovery.findModels(manifestUrl)) { - String name = ModelDiscovery.getSmithyModelPathFromJarUrl(model); - Path target = Paths.get(prefix + name); - collectFile(accumulator, target); - } + // There may be a more efficient way of reading this + String text = IoUtils.readUtf8File(filePath); + Document document = Document.of(text); + consumer.accept(filePath, text, document); } - private static String computeJarFilePrefix(String jarRoot, Path jarPath) { - Path jarFilenamePath = jarPath.getFileName(); + private static Supplier createModelAssemblerFactory(List dependencies) { + // We don't want the model to be broken when there are unknown traits, + // because that will essentially disable language server features, so + // we need to allow unknown traits for each factory. - if (jarFilenamePath == null) { - return jarRoot; + if (dependencies.isEmpty()) { + return () -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); } - 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); - } + URL[] urls = dependencies.toArray(new URL[0]); + URLClassLoader classLoader = new URLClassLoader(urls); + return () -> Model.assembler(classLoader) + .discoverModels(classLoader) + .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java deleted file mode 100644 index de6927e2..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.logging.Logger; -import org.eclipse.lsp4j.FileChangeType; -import org.eclipse.lsp4j.FileEvent; -import org.eclipse.lsp4j.WorkspaceFolder; -import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.protocol.LspAdapter; - -/** - * Manages open projects tracked by the server. - */ -public final class ProjectManager { - private static final Logger LOGGER = Logger.getLogger(ProjectManager.class.getName()); - - private final Map detached = new HashMap<>(); - private final Map attached = new HashMap<>(); - - public ProjectManager() { - } - - /** - * @param name Name of the project, usually comes from {@link WorkspaceFolder#getName()} - * @return The project with the given name, if it exists - */ - public Project getProjectByName(String name) { - return this.attached.get(name); - } - - /** - * @param name Name of the project to update - * @param updated Project to update - */ - public void updateProjectByName(String name, Project updated) { - this.attached.put(name, updated); - } - - /** - * @param name Name of the project to remove - * @return The removed project, if it exists - */ - public Project removeProjectByName(String name) { - return this.attached.remove(name); - } - - /** - * @return A map of URIs of open files that aren't attached to a tracked project - * to their own detached projects. These projects contain only the file that - * corresponds to the key in the map. - */ - public Map detachedProjects() { - return detached; - } - - /** - * @return A map of project names to projects tracked by the server - */ - public Map attachedProjects() { - return attached; - } - - /** - * @param uri The URI of the file belonging to the project to get - * @return The project the given {@code uri} belongs to - */ - public Project getProject(String uri) { - String path = LspAdapter.toPath(uri); - if (isDetached(uri)) { - return detached.get(uri); - } else { - for (Project project : attached.values()) { - if (project.smithyFiles().containsKey(path)) { - return project; - } - } - - LOGGER.warning(() -> "Tried getting project for unknown file: " + uri); - - return null; - } - } - - /** - * Note: This is equivalent to {@code getProject(uri) == null}. If this is true, - * there is also a corresponding {@link SmithyFile} in {@link Project#getSmithyFile(String)}. - * - * @param uri The URI of the file to check - * @return True if the given URI corresponds to a file tracked by the server - */ - public boolean isTracked(String uri) { - return getProject(uri) != null; - } - - /** - * @param uri The URI of the file to check - * @return Whether the given {@code uri} is of a file in a detached project - */ - public boolean isDetached(String uri) { - // We might be in a state where a file was added to a tracked project, - // but was opened before the project loaded. This would result in it - // being placed in a detached project. Removing it here is basically - // like removing it lazily, although it does feel a little hacky. - String path = LspAdapter.toPath(uri); - Project nonDetached = getNonDetached(path); - if (nonDetached != null && detached.containsKey(uri)) { - removeDetachedProject(uri); - } - - return detached.containsKey(uri); - } - - private Project getNonDetached(String path) { - for (Project project : attached.values()) { - if (project.smithyFiles().containsKey(path)) { - return project; - } - } - return null; - } - - /** - * @param uri The URI of the file to create a detached project for - * @param text The text of the file to create a detached project for - * @return A new detached project of the given {@code uri} and {@code text} - */ - public Project createDetachedProject(String uri, String text) { - Project project = ProjectLoader.loadDetached(uri, text); - detached.put(uri, project); - return project; - } - - /** - * @param uri The URI of the file to remove a detached project for - * @return The removed project, or null if none existed - */ - public Project removeDetachedProject(String uri) { - return detached.remove(uri); - } - - /** - * @param uri The URI of the file to get the document of - * @return The {@link Document} corresponding to the given {@code uri}, if - * it exists in any projects, otherwise {@code null}. - */ - public Document getDocument(String uri) { - Project project = getProject(uri); - if (project == null) { - return null; - } - return project.getDocument(uri); - } - - /** - * Computes per-project file changes from the given file events. - * - *

>Note: if you have lots of projects, this will create a bunch of - * garbage because most times you aren't getting multiple sets of large - * updates to a project. Project changes are relatively rare, so this - * shouldn't have a huge impact. - * - * @param events The file events to compute per-project file changes from - * @return A map of project name to the corresponding project's changes - */ - public Map computeProjectChanges(List events) { - // Note: we could eagerly compute these and store them, but project changes are relatively rare, - // and doing it this way means we don't need to manage the state. - Map projectSmithyFileMatchers = new HashMap<>(attachedProjects().size()); - Map projectBuildFileMatchers = new HashMap<>(attachedProjects().size()); - - Map changes = new HashMap<>(attachedProjects().size()); - - attachedProjects().forEach((projectName, project) -> { - projectSmithyFileMatchers.put(projectName, ProjectFilePatterns.getSmithyFilesPathMatcher(project)); - projectBuildFileMatchers.put(projectName, ProjectFilePatterns.getBuildFilesPathMatcher(project)); - - // Need these to be hash sets so they are mutable - changes.put(projectName, new ProjectChanges(new HashSet<>(), new HashSet<>(), new HashSet<>())); - }); - - for (FileEvent event : events) { - String changedUri = event.getUri(); - Path changedPath = Path.of(LspAdapter.toPath(changedUri)); - if (changedUri.endsWith(".smithy")) { - projectSmithyFileMatchers.forEach((projectName, matcher) -> { - if (matcher.matches(changedPath)) { - if (event.getType() == FileChangeType.Created) { - changes.get(projectName).createdSmithyFileUris().add(changedUri); - } else if (event.getType() == FileChangeType.Deleted) { - changes.get(projectName).deletedSmithyFileUris().add(changedUri); - } - } - }); - } else { - projectBuildFileMatchers.forEach((projectName, matcher) -> { - if (matcher.matches(changedPath)) { - changes.get(projectName).changedBuildFileUris().add(changedUri); - } - }); - } - } - - return changes; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java index 83af09b8..cc561fc7 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java @@ -159,8 +159,6 @@ public Builder mavenRepositories(Collection mavenRepositories) { .map(repo -> MavenRepository.builder().url(repo).build()) .collect(Collectors.toList())) .build(); - LOGGER.warning("Read deprecated `mavenRepositories` in smithy-build.json. Update smithy-build.json to " - + "{\"maven\": {\"repositories\": [{\"url\": \"repo url\"}]}}"); } this.maven = config; @@ -183,8 +181,6 @@ public Builder mavenDependencies(Collection mavenDependencies) { config = config.toBuilder() .dependencies(mavenDependencies) .build(); - LOGGER.warning("Read deprecated `mavenDependencies` in smithy-build.json. Update smithy-build.json to " - + "{\"maven\": {\"dependencies\": [\"dependencyA\", \"dependencyB\"]}}"); } this.maven = config; return this; diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java index ba4374c0..a3251e11 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -5,198 +5,45 @@ package software.amazon.smithy.lsp.project; -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import org.eclipse.lsp4j.Position; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.document.DocumentImports; -import software.amazon.smithy.lsp.document.DocumentNamespace; -import software.amazon.smithy.lsp.document.DocumentShape; -import software.amazon.smithy.lsp.document.DocumentVersion; -import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.lsp.syntax.Syntax; /** * The language server's representation of a Smithy file. - * - *

Note: This currently is only ever a .smithy file, but could represent - * a .json file in the future. */ -public final class SmithyFile { +public sealed class SmithyFile implements ProjectFile permits IdlFile { private final String path; private final Document document; - // TODO: If we have more complex use-cases for partially updating SmithyFile, we - // could use a toBuilder() - private Set shapes; - private final DocumentNamespace namespace; - private final DocumentImports imports; - private final Map documentShapes; - private final DocumentVersion documentVersion; - private SmithyFile(Builder builder) { - this.path = builder.path; - this.document = builder.document; - this.shapes = builder.shapes; - this.namespace = builder.namespace; - this.imports = builder.imports; - this.documentShapes = builder.documentShapes; - this.documentVersion = builder.documentVersion; + SmithyFile(String path, Document document) { + this.path = path; + this.document = document; } - /** - * @return The path of this Smithy file - */ + static SmithyFile create(String path, Document document) { + // TODO: Make a better abstraction for loading an arbitrary project file + if (path.endsWith(".smithy")) { + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + return new IdlFile(path, document, parse); + } else { + return new SmithyFile(path, document); + } + } + + @Override public String path() { return path; } - /** - * @return The {@link Document} backing this Smithy file - */ + @Override public Document document() { return document; } /** - * @return The Shapes defined in this Smithy file - */ - public Set shapes() { - return shapes; - } - - void setShapes(Set shapes) { - this.shapes = shapes; - } - - /** - * @return This Smithy file's imports, if they exist - */ - public Optional documentImports() { - return Optional.ofNullable(this.imports); - } - - /** - * @return The ids of shapes imported into this Smithy file - */ - public Set imports() { - return documentImports() - .map(DocumentImports::imports) - .orElse(Collections.emptySet()); - } - - /** - * @return This Smithy file's namespace, if one exists - */ - public Optional documentNamespace() { - return Optional.ofNullable(namespace); - } - - /** - * @return The shapes in this Smithy file, including referenced shapes - */ - public Collection documentShapes() { - if (documentShapes == null) { - return Collections.emptyList(); - } - return documentShapes.values(); - } - - /** - * @return A map of {@link Position} to the {@link DocumentShape} they are - * the starting position of - */ - public Map documentShapesByStartPosition() { - if (documentShapes == null) { - return Collections.emptyMap(); - } - return documentShapes; - } - - /** - * @return The string literal namespace of this Smithy file, or an empty string - */ - public CharSequence namespace() { - return documentNamespace() - .map(DocumentNamespace::namespace) - .orElse(""); - } - - /** - * @return This Smithy file's version, if it exists - */ - public Optional documentVersion() { - return Optional.ofNullable(documentVersion); - } - - /** - * @param shapeId The shape id to check - * @return Whether {@code shapeId} is in this SmithyFile's imports + * Reparse the underlying {@link #document()}. */ - public boolean hasImport(String shapeId) { - if (imports == null || imports.imports().isEmpty()) { - return false; - } - return imports.imports().contains(shapeId); - } - - /** - * @return A {@link SmithyFile} builder - */ - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - private String path; - private Document document; - private Set shapes; - private DocumentNamespace namespace; - private DocumentImports imports; - private Map documentShapes; - private DocumentVersion documentVersion; - - private Builder() { - } - - public Builder path(String path) { - this.path = path; - return this; - } - - public Builder document(Document document) { - this.document = document; - return this; - } - - public Builder shapes(Set shapes) { - this.shapes = shapes; - return this; - } - - public Builder namespace(DocumentNamespace namespace) { - this.namespace = namespace; - return this; - } - - public Builder imports(DocumentImports imports) { - this.imports = imports; - return this; - } - - public Builder documentShapes(Map documentShapes) { - this.documentShapes = documentShapes; - return this; - } - - public Builder documentVersion(DocumentVersion documentVersion) { - this.documentVersion = documentVersion; - return this; - } - - public SmithyFile build() { - return new SmithyFile(this); - } + public void reparse() { + // Don't parse JSON files, at least for now } } diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java deleted file mode 100644 index f6652c16..00000000 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.ToShapeId; -import software.amazon.smithy.model.traits.Trait; -import software.amazon.smithy.model.validation.ValidatedResult; - -/** - * An index that caches rebuild dependency relationships between Smithy files, - * shapes, and traits. - * - *

This is specifically for the following scenarios: - *

- *
A file applies traits to shapes in other files
- *
If that file changes, the applied traits need to be removed before the - * file is reloaded, so there aren't duplicate traits.
- *
A file has shapes with traits applied in other files
- *
If that file changes, the traits need to be re-applied when the model is - * re-assembled, so they aren't lost.
- *
Either 1 or 2, but specifically with list traits
- *
List traits are merged via - * trait conflict resolution . For these traits, all files that contain - * parts of the list trait must be fully reloaded, since we can only remove - * the whole trait, not parts of it.
- *
- */ -final class SmithyFileDependenciesIndex { - private final Map> filesToDependentFiles; - private final Map> shapeIdsToDependenciesFiles; - private final Map>> filesToTraitsTheyApply; - private final Map> shapesToAppliedTraitsInOtherFiles; - - SmithyFileDependenciesIndex() { - this.filesToDependentFiles = new HashMap<>(0); - this.shapeIdsToDependenciesFiles = new HashMap<>(0); - this.filesToTraitsTheyApply = new HashMap<>(0); - this.shapesToAppliedTraitsInOtherFiles = new HashMap<>(0); - } - - private SmithyFileDependenciesIndex( - Map> filesToDependentFiles, - Map> shapeIdsToDependenciesFiles, - Map>> filesToTraitsTheyApply, - Map> shapesToAppliedTraitsInOtherFiles - ) { - this.filesToDependentFiles = filesToDependentFiles; - this.shapeIdsToDependenciesFiles = shapeIdsToDependenciesFiles; - this.filesToTraitsTheyApply = filesToTraitsTheyApply; - this.shapesToAppliedTraitsInOtherFiles = shapesToAppliedTraitsInOtherFiles; - } - - Set getDependentFiles(String path) { - return filesToDependentFiles.getOrDefault(path, Collections.emptySet()); - } - - Set getDependenciesFiles(ToShapeId toShapeId) { - return shapeIdsToDependenciesFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptySet()); - } - - Map> getAppliedTraitsInFile(String path) { - return filesToTraitsTheyApply.getOrDefault(path, Collections.emptyMap()); - } - - List getTraitsAppliedInOtherFiles(ToShapeId toShapeId) { - return shapesToAppliedTraitsInOtherFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptyList()); - } - - // TODO: Make this take care of metadata too - static SmithyFileDependenciesIndex compute(ValidatedResult modelResult) { - if (modelResult.getResult().isEmpty()) { - return new SmithyFileDependenciesIndex(); - } - - SmithyFileDependenciesIndex index = new SmithyFileDependenciesIndex( - new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>()); - - Model model = modelResult.getResult().get(); - for (Shape shape : model.toSet()) { - String shapeSourceFilename = shape.getSourceLocation().getFilename(); - for (Trait traitApplication : shape.getAllTraits().values()) { - // We only care about trait applications in the source files - if (traitApplication.isSynthetic()) { - continue; - } - - Node traitNode = traitApplication.toNode(); - if (traitNode.isArrayNode()) { - for (Node element : traitNode.expectArrayNode()) { - String elementSourceFilename = element.getSourceLocation().getFilename(); - if (!elementSourceFilename.equals(shapeSourceFilename)) { - index.filesToDependentFiles.computeIfAbsent(elementSourceFilename, (k) -> new HashSet<>()) - .add(shapeSourceFilename); - index.shapeIdsToDependenciesFiles.computeIfAbsent(shape.getId(), (k) -> new HashSet<>()) - .add(elementSourceFilename); - } - } - } else { - String traitSourceFilename = traitApplication.getSourceLocation().getFilename(); - if (!traitSourceFilename.equals(shapeSourceFilename)) { - index.shapesToAppliedTraitsInOtherFiles.computeIfAbsent(shape.getId(), (k) -> new ArrayList<>()) - .add(traitApplication); - index.filesToTraitsTheyApply.computeIfAbsent(traitSourceFilename, (k) -> new HashMap<>()) - .computeIfAbsent(shape.getId(), (k) -> new ArrayList<>()) - .add(traitApplication); - } - } - } - } - - return index; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java new file mode 100644 index 00000000..3474f7b8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyProjectJson.java @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.util.List; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; + +record SmithyProjectJson( + List sources, + List imports, + List dependencies, + String outputDirectory +) { + static SmithyProjectJson empty() { + return new SmithyProjectJson(List.of(), List.of(), List.of(), null); + } + + static SmithyProjectJson fromNode(Node node) { + ObjectNode objectNode = node.expectObjectNode(); + + List sources = objectNode.getArrayMember("sources") + .map(arrayNode -> arrayNode.getElementsAs(StringNode.class).stream() + .map(StringNode::getValue) + .toList()) + .orElse(List.of()); + + List imports = objectNode.getArrayMember("imports") + .map(arrayNode -> arrayNode.getElementsAs(StringNode.class).stream() + .map(StringNode::getValue) + .toList()) + .orElse(List.of()); + + List dependencies = objectNode.getArrayMember("dependencies") + .map(arrayNode -> arrayNode.getElements().stream() + .map(ProjectDependency::fromNode) + .toList()) + .orElse(List.of()); + + String outputDirectory = objectNode.getStringMemberOrDefault("outputDirectory", null); + + return new SmithyProjectJson(sources, imports, dependencies, outputDirectory); + } + + /** + * An arbitrary project dependency, used to specify non-maven projectDependencies + * that exist locally. + * + * @param name The name of the dependency + * @param path The path of the dependency + */ + record ProjectDependency(String name, String path) { + static ProjectDependency fromNode(Node node) { + ObjectNode objectNode = node.expectObjectNode(); + String name = objectNode.expectStringMember("name").getValue(); + String path = objectNode.expectStringMember("path").getValue(); + return new ProjectDependency(name, path); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java b/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java new file mode 100644 index 00000000..865a959e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ToSmithyNode.java @@ -0,0 +1,114 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.util.List; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.ModelSyntaxException; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.BooleanNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NullNode; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; + +/** + * Converts a {@link BuildFile#getParse()} into a Smithy {@link Node}, + * and turning any parse errors into {@link ValidationEvent}s. + * + *

Since the language server's parser is much more lenient than the regular + * {@link Node} parser, the converted {@link Node} will contain only + * the parts of the original text that make up valid {@link Node}s. + */ +final class ToSmithyNode { + private final String path; + private final Document document; + + private ToSmithyNode(String path, Document document) { + this.path = path; + this.document = document; + } + + static ValidatedResult toSmithyNode(BuildFile buildFile) { + var toSmithyNode = new ToSmithyNode(buildFile.path(), buildFile.document()); + + var smithyNode = toSmithyNode.toSmithyNode(buildFile.getParse().value()); + var events = toSmithyNode.getValidationEvents(); + + return new ValidatedResult<>(smithyNode, events); + } + + private List getValidationEvents() { + // The language server's parser isn't going to produce the same errors + // because of its leniency. Reparsing like this does incur a cost, but + // I think it's ok for now considering we get the added benefit of + // having the same errors Smithy itself would produce. + try { + Node.parseJsonWithComments(document.copyText(), path); + return List.of(); + } catch (ModelSyntaxException e) { + return List.of(ValidationEvent.fromSourceException(e)); + } + } + + private Node toSmithyNode(Syntax.Node syntaxNode) { + if (syntaxNode == null) { + return null; + } + + SourceLocation sourceLocation = nodeStartSourceLocation(syntaxNode); + return switch (syntaxNode) { + case Syntax.Node.Obj obj -> { + ObjectNode.Builder builder = ObjectNode.builder().sourceLocation(sourceLocation); + for (Syntax.Node.Kvp kvp : obj.kvps().kvps()) { + String keyValue = kvp.key().stringValue(); + StringNode key = new StringNode(keyValue, nodeStartSourceLocation(kvp.key())); + Node value = toSmithyNode(kvp.value()); + if (value != null) { + builder.withMember(key, value); + } + } + yield builder.build(); + } + case Syntax.Node.Arr arr -> { + ArrayNode.Builder builder = ArrayNode.builder().sourceLocation(sourceLocation); + for (Syntax.Node elem : arr.elements()) { + Node elemNode = toSmithyNode(elem); + if (elemNode != null) { + builder.withValue(elemNode); + } + } + yield builder.build(); + } + case Syntax.Ident ident -> { + String value = ident.stringValue(); + yield switch (value) { + case "true", "false" -> new BooleanNode(Boolean.parseBoolean(value), sourceLocation); + case "null" -> new NullNode(sourceLocation); + default -> null; + }; + } + case Syntax.Node.Str str -> new StringNode(str.stringValue(), sourceLocation); + case Syntax.Node.Num num -> new NumberNode(num.value(), sourceLocation); + default -> null; + }; + } + + private SourceLocation nodeStartSourceLocation(Syntax.Node node) { + Range range = document.rangeBetween(node.start(), node.end()); + if (range == null) { + range = LspAdapter.origin(); + } + return LspAdapter.toSourceLocation(path, range); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java index 59e62ead..41fca697 100644 --- a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java @@ -15,6 +15,9 @@ import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.FromSourceLocation; import software.amazon.smithy.model.SourceLocation; /** @@ -111,6 +114,23 @@ public static Range of(int startLine, int startCharacter, int endLine, int endCh .build(); } + /** + * @param ident Identifier to get the range of + * @param document Document the identifier is in + * @return The range of the identifier in the given document + */ + public static Range identRange(Syntax.Ident ident, Document document) { + return document.rangeOfValue(ident); + } + + /** + * @param range The range to check + * @return Whether the range's start is equal to it's end + */ + public static boolean isEmpty(Range range) { + return range.getStart().equals(range.getEnd()); + } + /** * Get a {@link Position} from a {@link SourceLocation}, making the line/columns * 0-indexed. @@ -126,12 +146,39 @@ public static Position toPosition(SourceLocation sourceLocation) { * Get a {@link Location} from a {@link SourceLocation}, with the filename * transformed to a URI, and the line/column made 0-indexed. * - * @param sourceLocation The source location to get a Location from + * @param fromSourceLocation The source location to get a Location from * @return The equivalent Location */ - public static Location toLocation(SourceLocation sourceLocation) { - return new Location(toUri(sourceLocation.getFilename()), point( - new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); + public static Location toLocation(FromSourceLocation fromSourceLocation) { + SourceLocation sourceLocation = fromSourceLocation.getSourceLocation(); + return new Location(toUri(sourceLocation.getFilename()), point(toPosition(sourceLocation))); + } + + /** + * Get a {@link SourceLocation} with the given path, at the start of the given + * range. + * + * @param path The path of the source location + * @param range The range of the source location + * @return The source location + */ + public static SourceLocation toSourceLocation(String path, Range range) { + return toSourceLocation(path, range.getStart()); + } + + /** + * Get a {@link SourceLocation} with the given path, at the given position. + * + * @param path The path of the source location + * @param position The position of the source location + * @return The source location + */ + public static SourceLocation toSourceLocation(String path, Position position) { + return new SourceLocation( + path, + position.getLine() + 1, + position.getCharacter() + 1 + ); } /** diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java new file mode 100644 index 00000000..9546a06e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java @@ -0,0 +1,188 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import java.util.ArrayList; +import java.util.List; + +/** + * A moveable index into a path from the root of a {@link Syntax.Node} to a + * position somewhere within that node. The path supports iteration forward + * only. + */ +public final class NodeCursor { + private final List edges; + private int pos = 0; + + NodeCursor(List edges) { + this.edges = edges; + } + + /** + * @param value The node value to create the cursor for + * @param documentIndex The index within the document to create the cursor for + * @return A node cursor from the start of {@code value} to {@code documentIndex} + * within {@code document}. + */ + public static NodeCursor create(Syntax.Node value, int documentIndex) { + List edges = new ArrayList<>(); + NodeCursor cursor = new NodeCursor(edges); + + if (value == null || documentIndex < 0) { + return cursor; + } + + Syntax.Node next = value; + while (true) { + iteration: switch (next) { + case Syntax.Node.Kvps kvps -> { + edges.add(new NodeCursor.Obj(kvps)); + Syntax.Node.Kvp lastKvp = null; + for (Syntax.Node.Kvp kvp : kvps.kvps()) { + if (kvp.key.isIn(documentIndex)) { + String key = kvp.key.stringValue(); + edges.add(new NodeCursor.Key(key, kvps)); + edges.add(new NodeCursor.Terminal(kvp)); + return cursor; + } else if (kvp.inValue(documentIndex)) { + if (kvp.value == null) { + lastKvp = kvp; + break; + } + String key = kvp.key.stringValue(); + edges.add(new NodeCursor.ValueForKey(key, kvps)); + next = kvp.value; + break iteration; + } else { + lastKvp = kvp; + } + } + if (lastKvp != null && lastKvp.value == null) { + edges.add(new NodeCursor.ValueForKey(lastKvp.key.stringValue(), kvps)); + edges.add(new NodeCursor.Terminal(lastKvp)); + return cursor; + } + return cursor; + } + case Syntax.Node.Obj obj -> { + next = obj.kvps; + } + case Syntax.Node.Arr arr -> { + edges.add(new NodeCursor.Arr(arr)); + for (int i = 0; i < arr.elements.size(); i++) { + Syntax.Node elem = arr.elements.get(i); + if (elem.isIn(documentIndex)) { + edges.add(new NodeCursor.Elem(i, arr)); + next = elem; + break iteration; + } + } + return cursor; + } + case null -> { + edges.add(new NodeCursor.Terminal(null)); + return cursor; + } + default -> { + edges.add(new NodeCursor.Terminal(next)); + return cursor; + } + } + } + } + + public List edges() { + return edges; + } + + /** + * @return Whether the cursor is not at the end of the path. A return value + * of {@code true} means {@link #next()} may be called safely. + */ + public boolean hasNext() { + return pos < edges.size(); + } + + /** + * @return The next edge along the path. Also moves the cursor forward. + */ + public Edge next() { + Edge edge = edges.get(pos); + pos++; + return edge; + } + + /** + * @return Whether the path consists of a single, terminal, node. + */ + public boolean isTerminal() { + return edges.size() == 1 && edges.getFirst() instanceof Terminal; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (Edge edge : edges) { + switch (edge) { + case Obj ignored -> builder.append("Obj,"); + case Arr ignored -> builder.append("Arr,"); + case Terminal ignored -> builder.append("Terminal,"); + case Elem elem -> builder.append("Elem(").append(elem.index).append("),"); + case Key key -> builder.append("Key(").append(key.name).append("),"); + case ValueForKey valueForKey -> builder.append("ValueForKey(").append(valueForKey.keyName).append("),"); + } + } + return builder.toString(); + } + + /** + * An edge along a path within a {@link Syntax.Node}. Edges are fine-grained + * structurally, so there is a distinction between e.g. a path into an object, + * an object key, and a value for an object key, but there is no distinction + * between e.g. a path into a string value vs a numeric value. Each edge stores + * a reference to the underlying node, or a reference to the parent node. + */ + public sealed interface Edge {} + + /** + * Within an object, i.e. within the braces: '{}'. + * @param node The value of the underlying node at this edge. + */ + public record Obj(Syntax.Node.Kvps node) implements Edge {} + + /** + * Within an array/list, i.e. within the brackets: '[]'. + * @param node The value of the underlying node at this edge. + */ + public record Arr(Syntax.Node.Arr node) implements Edge {} + + /** + * The end of a path. Will always be present at the end of any non-empty path. + * @param node The value of the underlying node at this edge. + */ + public record Terminal(Syntax.Node node) implements Edge {} + + /** + * Within a key of an object, i.e. '{"here": null}' + * @param name The name of the key. + * @param parent The object node the key is within. + */ + public record Key(String name, Syntax.Node.Kvps parent) implements Edge {} + + /** + * Within a value corresponding to a key of an object, i.e. '{"key": "here"}' + * @param keyName The name of the key. + * @param parent The object node the value is within. + */ + public record ValueForKey(String keyName, Syntax.Node.Kvps parent) implements Edge {} + + /** + * Within an element of an array/list, i.e. '["here"]'. + * @param index The index of the element. + * @param parent The array node the element is within. + */ + public record Elem(int index, Syntax.Node.Arr parent) implements Edge {} +} diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java new file mode 100644 index 00000000..8330b831 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -0,0 +1,1069 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.utils.SimpleParser; + +/** + * Parser for {@link Syntax.Node} and {@link Syntax.Statement}. See + * {@link Syntax} for more details on the design of the parser. + * + *

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

+ *

IDL Syntax

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

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

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

+ *

Node Syntax

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

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

+ */ + abstract static sealed class MemberStatement extends Statement { + final Block parent; + + protected MemberStatement(Block parent) { + this.parent = parent; + } + + /** + * @return The possibly null block enclosing this statement. + */ + public Block parent() { + return parent; + } + } + + /** + * A trait application, i.e. `@` followed by an identifier. + */ + public static final class TraitApplication extends MemberStatement { + final Ident id; + Node value; + + TraitApplication(Block parent, Ident id) { + super(parent); + this.id = id; + } + + public Ident id() { + return id; + } + + public Node value() { + return value; + } + } + + /** + * A member definition, i.e. identifier `:` identifier. Only appears + * in {@link Block}s. + */ + public static final class MemberDef extends MemberStatement { + final Ident name; + int colonPos = -1; + Ident target; + + MemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + + public Ident target() { + return target; + } + + /** + * @param pos The character offset to check + * @return Whether the given offset is within this member's target + */ + public boolean inTarget(int pos) { + if (colonPos < 0) { + return false; + } else if (target == null || target.isEmpty()) { + return pos > colonPos; + } else { + return target.isIn(pos); + } + } + } + + /** + * An enum member definition, i.e. an identifier followed by an optional + * value assignment. Only appears in {@link Block}s. + */ + public static final class EnumMemberDef extends MemberStatement { + final Ident name; + Node value; + + EnumMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + } + + /** + * An elided member definition, i.e. `$` followed by an identifier. Only + * appears in {@link Block}s. + */ + public static final class ElidedMemberDef extends MemberStatement { + final Ident name; + + ElidedMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + } + + /** + * An inline member definition, i.e. an identifier followed by `:=`. Only + * appears in {@link Block}s, and doesn't include the actual definition, + * just the member name. + */ + public static final class InlineMemberDef extends MemberStatement { + final Ident name; + + InlineMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + } + + /** + * A member definition with a node value, i.e. identifier `:` node value. + * Only appears in {@link Block}s. + */ + public static final class NodeMemberDef extends MemberStatement { + final Ident name; + int colonPos = -1; + Node value; + + NodeMemberDef(Block parent, Ident name) { + super(parent); + this.name = name; + } + + public Ident name() { + return name; + } + + public Node value() { + return value; + } + + /** + * @param pos The character offset to check + * @return Whether the given {@code pos} is within this member's value + */ + public boolean inValue(int pos) { + return (value != null && value.isIn(pos)) + || (colonPos >= 0 && pos > colonPos); + } + } + + /** + * Used to indicate the start of a block, i.e. {}. + */ + public static final class Block extends MemberStatement { + final int statementIndex; + int lastStatementIndex; + + Block(Block parent, int lastStatementIndex) { + super(parent); + this.statementIndex = lastStatementIndex; + this.lastStatementIndex = lastStatementIndex; + } + + public int statementIndex() { + return statementIndex; + } + + public int lastStatementIndex() { + return lastStatementIndex; + } + } + + /** + * An error that occurred during IDL parsing. This is distinct from + * {@link Node.Err} primarily because {@link Node.Err} is an actual + * value a {@link Node} can have. + */ + public static final class Err extends Statement implements Syntax.Err { + final String message; + + Err(String message) { + this.message = message; + } + + @Override + public String message() { + return message; + } + } + } + + /** + * An identifier in a {@link Node} or {@link Statement}. Starts with any + * alpha or `_` character, followed by any sequence of Shape ID characters + * (i.e. `.`, `#`, `$`, `_` digits, alphas). + */ + public static final class Ident extends Node.Str { + static final Ident EMPTY = new Ident(-1, -1, -1, ""); + + Ident(int lineNumber, int start, int end, String value) { + super(lineNumber, start, end, value); + } + + public boolean isEmpty() { + return (start - end) == 0; + } + } + + /** + * Represents any syntax error, either {@link Node} or {@link Statement}. + */ + public sealed interface Err { + /** + * @return The start index of the error. + */ + int start(); + + /** + * @return The end index of the error. + */ + int end(); + + /** + * @return The error message. + */ + String message(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/util/Result.java b/src/main/java/software/amazon/smithy/lsp/util/Result.java deleted file mode 100644 index d4c4112c..00000000 --- a/src/main/java/software/amazon/smithy/lsp/util/Result.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.util; - -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Type representing the result of an operation that could be successful - * or fail. - * - * @param Type of successful result - * @param Type of failed result - */ -public final class Result { - private final T value; - private final E error; - - private Result(T value, E error) { - this.value = value; - this.error = error; - } - - /** - * @param value The success value - * @param Type of successful result - * @param Type of failed result - * @return The successful result - */ - public static Result ok(T value) { - return new Result<>(value, null); - } - - /** - * @param error The failed value - * @param Type of successful result - * @param Type of failed result - * @return The failed result - */ - public static Result err(E error) { - return new Result<>(null, error); - } - - /** - * @param fallible A function that may fail - * @param Type of successful result - * @return A result containing the result of calling {@code fallible} - */ - public static Result ofFallible(Supplier fallible) { - try { - return Result.ok(fallible.get()); - } catch (Exception e) { - return Result.err(e); - } - } - - /** - * @param throwing A function that may throw - * @param Type of successful result - * @return A result containing the result of calling {@code throwing} - */ - public static Result ofThrowing(ThrowingSupplier throwing) { - try { - return Result.ok(throwing.get()); - } catch (Exception e) { - return Result.err(e); - } - } - - /** - * @return Whether this result is successful - */ - public boolean isOk() { - return this.value != null; - } - - /** - * @return Whether this result is failed - */ - public boolean isErr() { - return this.error != null; - } - - /** - * @return The successful value, or throw an exception if this Result is failed - */ - public T unwrap() { - if (get().isEmpty()) { - throw new RuntimeException("Called unwrap on an Err Result: " + getErr().get()); - } - return get().get(); - } - - /** - * @return The failed value, or throw an exception if this Result is successful - */ - public E unwrapErr() { - if (getErr().isEmpty()) { - throw new RuntimeException("Called unwrapErr on an Ok Result: " + get().get()); - } - return getErr().get(); - } - - /** - * @return Get the successful value if present - */ - public Optional get() { - return Optional.ofNullable(value); - } - - /** - * @return Get the failed value if present - */ - public Optional getErr() { - return Optional.ofNullable(error); - } - - /** - * Transforms the successful value of this Result, if present. - * - * @param mapper Function to apply to the successful value of this result - * @param The type to map to - * @return A new result with {@code mapper} applied, if this result is a - * successful one - */ - public Result map(Function mapper) { - if (isOk()) { - return Result.ok(mapper.apply(unwrap())); - } - return Result.err(unwrapErr()); - } - - /** - * Transforms the failed value of this Result, if present. - * - * @param mapper Function to apply to the failed value of this result - * @param The type to map to - * @return A new result with {@code mapper} applied, if this result is a - * failed one - */ - public Result mapErr(Function mapper) { - if (isErr()) { - return Result.err(mapper.apply(unwrapErr())); - } - return Result.ok(unwrap()); - } - - - /** - * A supplier that throws a checked exception. - * - * @param The output of the supplier - * @param The exception type that can be thrown - */ - @FunctionalInterface - public interface ThrowingSupplier { - T get() throws E; - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java new file mode 100644 index 00000000..55187018 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.util; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +public final class StreamUtils { + private StreamUtils() { + } + + public static Collector> toWrappedMap() { + return Collectors.toMap(s -> s, s -> "\"" + s + "\""); + } + + public static Collector, ?, Map> mappingValue(Function valueMapper) { + return Collectors.toMap(Map.Entry::getKey, entry -> valueMapper.apply(entry.getValue())); + } +} diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep deleted file mode 100644 index 445d5757..00000000 --- a/src/main/resources/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Delete this file as soon as actual an actual resources is added to this directory. \ No newline at end of file diff --git a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy new file mode 100644 index 00000000..d16f6e09 --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy @@ -0,0 +1,195 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure SmithyProjectJson { + sources: Strings + imports: Strings + outputDirectory: String + dependencies: ProjectDependencies +} + +list ProjectDependencies { + member: ProjectDependency +} + +structure ProjectDependency { + name: String + + @required + path: String +} + +@externalDocumentation("Smithy Build Reference": "https://smithy.io/2.0/guides/smithy-build-json.html") +structure SmithyBuildJson { + /// Defines the version of smithy-build. Set to 1.0. + @required + version: SmithyBuildVersion + + /// The location where projections are written. Each projection will create a + /// subdirectory named after the projection, and the artifacts from the projection, + /// including a model.json file, will be placed in the directory. + outputDirectory: String + + /// Provides a list of relative files or directories that contain the models + /// that are considered the source models of the build. When a directory is + /// encountered, all files in the entire directory tree are added as sources. + /// Sources are relative to the configuration file. + sources: Strings + + /// Provides a list of model files and directories to load when validating and + /// building the model. Imports are a local dependency: they are not considered + /// part of model package being built, but are required to build the model package. + /// Models added through imports are not present in the output of the built-in + /// sources plugin. + /// When a directory is encountered, all files in the entire directory tree are + /// imported. Imports defined at the top-level are used in every projection. + /// Imports are relative to the configuration file. + imports: Strings + + /// A map of projection names to projection configurations. + @externalDocumentation( + "Projections Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#projections" + ) + projections: Projections + + /// Defines the plugins to apply to the model when building every projection. + /// Plugins are a mapping of plugin IDs to plugin-specific configuration objects. + @externalDocumentation( + "Plugins Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#plugins" + ) + plugins: Plugins + + /// If a plugin can't be found, Smithy will by default fail the build. This setting + /// can be set to true to allow the build to progress even if a plugin can't be + /// found on the classpath. + ignoreMissingPlugins: Boolean + + /// Defines Java Maven dependencies needed to build the model. Dependencies are + /// used to bring in model imports, build plugins, validators, transforms, and + /// other extensions. + @externalDocumentation( + "Maven Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-configuration" + ) + maven: Maven +} + +@default("1.0") +string SmithyBuildVersion + +map Projections { + key: String + value: Projection +} + +@externalDocumentation( + "Projections Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#projections" +) +structure Projection { + /// Defines the projection as a placeholder that other projections apply. Smithy + /// will not build artifacts for abstract projections. Abstract projections must + /// not define imports or plugins. + abstract: Boolean + + /// Provides a list of relative imports to include when building this specific + /// projection (in addition to any imports defined at the top-level). When a + /// directory is encountered, all files in the directory tree are imported. + /// Note: imports are relative to the configuration file. + imports: Strings + + /// Defines the transformations to apply to the projection. Transformations are + /// used to remove shapes, remove traits, modify trait contents, and any other + /// kind of transformation necessary for the projection. Transforms are applied + /// in the order defined. + @externalDocumentation( + "Transforms Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#transforms" + ) + transforms: Transforms + + /// Defines the plugins to apply to the model when building this projection. + /// plugins is a mapping of a plugin IDs to plugin-specific configuration objects. + /// smithy-build will attempt to resolve plugin names using Java SPI to locate + /// an instance of software.amazon.smithy.build.SmithyBuildPlugin that returns a + /// matching name when calling getName. smithy-build will emit a warning when a + /// plugin cannot be resolved. + @externalDocumentation( + "Plugins Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#plugins" + ) + plugins: Plugins +} + +map Plugins { + key: String + value: Document +} + +list Transforms { + member: Transform +} + +@externalDocumentation( + "Transforms Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#transforms" +) +structure Transform { + /// The required name of the transform. + @required + name: String + + /// A structure that contains configuration key-value pairs. + args: TransformArgs +} + +structure TransformArgs { +} + +@externalDocumentation( + "Maven Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-configuration" +) +structure Maven { + /// A list of Maven dependency coordinates in the form of groupId:artifactId:version. + /// The Smithy CLI will search each registered Maven repository for the dependency. + dependencies: Strings + + /// A list of Maven repositories to search for dependencies. If no repositories + /// are defined and the SMITHY_MAVEN_REPOS environment variable is not defined, + /// then this value defaults to Maven Central. + @externalDocumentation( + "Maven Repositories Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-repositories" + ) + repositories: MavenRepositories +} + +list MavenRepositories { + member: MavenRepository +} + +@externalDocumentation( + "Maven Repositories Reference": "https://smithy.io/2.0/guides/smithy-build-json.html#maven-repositories" +) +structure MavenRepository { + /// The URL of the repository (for example, https://repo.maven.apache.org/maven2). + @required + url: String + + /// HTTP basic or digest credentials to use with the repository. Credentials are + /// provided in the form of "username:password". + /// + /// **WARNING** Credentials SHOULD NOT be defined statically in a smithy-build.json + /// file. Instead, use environment variables to keep credentials out of source control. + httpCredentials: String + + /// The URL of the proxy to configure for this repository (for example, + /// http://proxy.maven.apache.org:8080). + proxyHost: String + + /// HTTP credentials to use with the proxy for the repository. Credentials are + /// provided in the form of "username:password". + /// + /// **WARNING** Credentials SHOULD NOT be defined statically in a smithy-build.json + /// file. Instead, use environment variables to keep credentials out of source control. + proxyCredentials: String +} + +list Strings { + member: String +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy b/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy new file mode 100644 index 00000000..238cd0f5 --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy @@ -0,0 +1,37 @@ +$version: "2.0" + +namespace smithy.lang.server + +string SmithyIdlVersion + +string AnyNamespace + +string ValidatorName + +structure ValidatorConfig {} + +string Selector + +@idRef +string AnyShape + +@idRef +string AnyTrait + +@idRef +string AnyMixin + +@idRef +string AnyString + +@idRef +string AnyError + +@idRef +string AnyOperation + +@idRef +string AnyResource + +@idRef +string AnyMemberTarget diff --git a/src/main/resources/software/amazon/smithy/lsp/language/control.smithy b/src/main/resources/software/amazon/smithy/lsp/language/control.smithy new file mode 100644 index 00000000..eb0fdd5e --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/control.smithy @@ -0,0 +1,17 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure BuiltinControl { + /// Defines the [version](https://smithy.io/2.0/spec/idl.html#smithy-version) + /// of the smithy idl used in this model file. + version: SmithyIdlVersion = "2.0" + + /// Defines the suffix used when generating names for + /// [inline operation input](https://smithy.io/2.0/spec/idl.html#idl-inline-input-output). + operationInputSuffix: String = "Input" + + /// Defines the suffix used when generating names for + /// [inline operation output](https://smithy.io/2.0/spec/idl.html#idl-inline-input-output). + operationOutputSuffix: String = "Output" +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy b/src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy new file mode 100644 index 00000000..5705629c --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/keywords.smithy @@ -0,0 +1,49 @@ +$version: "2.0" + +namespace smithy.lang.server + +union NonShapeKeywords { + /// Metadata is a schema-less extensibility mechanism used to associate metadata to + /// an entire model. + @externalDocumentation( + "Metadata Reference": "https://smithy.io/2.0/spec/model.html#model-metadata" + ) + metadata: Unit + + /// A namespace is a mechanism for logically grouping shapes in a way that makes them + /// reusable alongside other models without naming conflicts. + @externalDocumentation( + "Namespace Statement Reference": "https://smithy.io/2.0/spec/idl.html#namespaces" + "Shape ID Reference": "https://smithy.io/2.0/spec/model.html#shape-id" + ) + namespace: Unit + + /// The use section of the IDL is used to import shapes into the current namespace so + /// that they can be referred to using a relative shape ID. + @externalDocumentation( + "Use Statement Reference": "https://smithy.io/2.0/spec/idl.html#referring-to-shapes" + ) + use: Unit + + /// Applies a trait to a shape outside of the shape's definition + @externalDocumentation( + "Apply Statement Reference": "https://smithy.io/2.0/spec/idl.html#apply-statement" + "Applying Traits Reference": "https://smithy.io/2.0/spec/model.html#applying-traits" + ) + apply: Unit + + /// Allows referencing a resource's identifiers and properties in members to create + /// resource bindings using target elision syntax. + @externalDocumentation( + "Identifier Bindings Reference": "https://smithy.io/2.0/spec/service-types.html#binding-identifiers-to-operations" + "Property Bindings Reference": "https://smithy.io/2.0/spec/service-types.html#binding-members-to-properties" + "Target Elision Syntax Reference": "https://smithy.io/2.0/spec/idl.html#idl-target-elision" + ) + for: Unit + + /// Mixes in a list of mixins to a shape. + @externalDocumentation( + "Mixins Reference": "https://smithy.io/2.0/spec/idl.html#mixins" + ) + with: Unit +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/members.smithy b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy new file mode 100644 index 00000000..ab2304f7 --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy @@ -0,0 +1,262 @@ +$version: "2.0" + +namespace smithy.lang.server + +union ShapeMemberTargets { + /// A service is the entry point of an API that aggregates resources and operations together. + @externalDocumentation("Service Reference": "https://smithy.io/2.0/spec/service-types.html#service") + service: ServiceShape + + /// The operation type represents the input, output, and possible errors of an API operation. + @externalDocumentation("Operation Reference": "https://smithy.io/2.0/spec/service-types.html#operation") + operation: OperationShape + + /// Smithy defines a resource as an entity with an identity that has a set of operations. + @externalDocumentation("Resource Reference": "https://smithy.io/2.0/spec/service-types.html#resource") + resource: ResourceShape + + /// The list type represents an ordered homogeneous collection of values. + @externalDocumentation("List Reference": "https://smithy.io/2.0/spec/aggregate-types.html#list") + list: ListShape + + /// The map type represents a map data structure that maps string keys to homogeneous values. + @externalDocumentation("Map Reference": "https://smithy.io/2.0/spec/aggregate-types.html#map") + map: MapShape + + /// The structure type represents a fixed set of named, unordered, heterogeneous values. + @externalDocumentation("Structure Reference": "https://smithy.io/2.0/spec/aggregate-types.html#structure") + structure: Unit + + /// The union type represents a tagged union data structure that can take on several different, but fixed, types. + @externalDocumentation("Union Reference": "https://smithy.io/2.0/spec/aggregate-types.html#union") + union: Unit + + /// A blob is uninterpreted binary data. + blob: Unit + + /// A boolean is a Boolean value type. + boolean: Unit + + /// A string is a UTF-8 encoded string. + string: Unit + + /// A byte is an 8-bit signed integer ranging from -128 to 127 (inclusive). + byte: Unit + + /// A short is a 16-bit signed integer ranging from -32,768 to 32,767 (inclusive). + short: Unit + + /// An integer is a 32-bit signed integer ranging from -2^31 to (2^31)-1 (inclusive). + integer: Unit + + /// A long is a 64-bit signed integer ranging from -2^63 to (2^63)-1 (inclusive). + long: Unit + + /// A float is a single precision IEEE-754 floating point number. + float: Unit + + /// A double is a double precision IEEE-754 floating point number. + double: Unit + + /// A bigInteger is an arbitrarily large signed integer. + bigInteger: Unit + + /// A bigDecimal is an arbitrary precision signed decimal number. + bigDecimal: Unit + + /// A timestamp represents an instant in time in the proleptic Gregorian calendar, + /// independent of local times or timezones. Timestamps support an allowable date + /// range between midnight January 1, 0001 CE to 23:59:59.999 on December 31, 9999 CE, + /// with a temporal resolution of 1 millisecond. + @externalDocumentation("Timestamp Reference": "https://smithy.io/2.0/spec/simple-types.html#timestamp") + timestamp: Unit + + /// A document represents protocol-agnostic open content that functions as a kind of + /// "any" type. Document types are represented by a JSON-like data model and can + /// contain UTF-8 strings, arbitrary precision numbers, booleans, nulls, a list of + /// these values, and a map of UTF-8 strings to these values. + @externalDocumentation("Document Reference": "https://smithy.io/2.0/spec/simple-types.html#document") + document: Unit + + /// The enum shape is used to represent a fixed set of one or more string values. + @externalDocumentation("Enum Reference": "https://smithy.io/2.0/spec/simple-types.html#enum") + enum: Unit + + /// An intEnum is used to represent an enumerated set of one or more integer values. + @externalDocumentation("IntEnum Reference": "https://smithy.io/2.0/spec/simple-types.html#intenum") + intEnum: Unit +} + +@externalDocumentation("Service Reference": "https://smithy.io/2.0/spec/service-types.html#service") +structure ServiceShape { + /// Defines the optional version of the service. The version can be provided in any + /// format (e.g., 2017-02-11, 2.0, etc). + version: String + + /// Binds a set of operation shapes to the service. Each element in the given list + /// MUST be a valid shape ID that targets an operation shape. + @externalDocumentation( + "Operations Reference": "https://smithy.io/2.0/spec/service-types.html#service-operations" + ) + operations: Operations + + /// Binds a set of resource shapes to the service. Each element in the given list MUST + /// be a valid shape ID that targets a resource shape. + @externalDocumentation( + "Resources Reference": "https://smithy.io/2.0/spec/service-types.html#service-resources" + ) + resources: Resources + + /// Defines a list of common errors that every operation bound within the closure of + /// the service can return. Each provided shape ID MUST target a structure shape that + /// is marked with the error trait. + errors: Errors + + /// Disambiguates shape name conflicts in the service closure. Map keys are shape IDs + /// contained in the service, and map values are the disambiguated shape names to use + /// in the context of the service. Each given shape ID MUST reference a shape contained + /// in the closure of the service. Each given map value MUST match the smithy:Identifier + /// production used for shape IDs. Renaming a shape does not give the shape a new shape ID. + /// - No renamed shape name can case-insensitively match any other renamed shape name + /// or the name of a non-renamed shape contained in the service. + /// - Member shapes MAY NOT be renamed. + /// - Resource and operation shapes MAY NOT be renamed. Renaming shapes is intended for + /// incidental naming conflicts, not for renaming the fundamental concepts of a service. + /// - Shapes from other namespaces marked as private MAY be renamed. + /// - A rename MUST use a name that is case-sensitively different from the original shape ID name. + rename: Rename +} + +list Operations { + member: AnyOperation +} + +list Resources { + member: AnyResource +} + +list Errors { + member: AnyError +} + +map Rename { + key: AnyShape + value: String +} + +@externalDocumentation("Operation Reference": "https://smithy.io/2.0/spec/service-types.html#operation") +structure OperationShape { + /// The input of the operation defined using a shape ID that MUST target a structure. + /// - Every operation SHOULD define a dedicated input shape marked with the + /// input trait. Creating a dedicated input shape ensures that input members + /// can be added in the future if needed. + /// - Input defaults to smithy.api#Unit if no input is defined, indicating that + /// the operation has no meaningful input. + input: AnyMemberTarget + + /// The output of the operation defined using a shape ID that MUST target a structure. + /// - Every operation SHOULD define a dedicated output shape marked with the + /// output trait. Creating a dedicated output shape ensures that output members + /// can be added in the future if needed. + /// - Output defaults to smithy.api#Unit if no output is defined, indicating that + /// the operation has no meaningful output. + output: AnyMemberTarget + + /// The errors that an operation can return. Each string in the list is a shape ID that + /// MUST target a structure shape marked with the error trait. + errors: Errors +} + +@externalDocumentation("Resource Reference": "https://smithy.io/2.0/spec/service-types.html#resource") +structure ResourceShape { + /// Defines a map of identifier string names to Shape IDs used to identify the resource. + /// Each shape ID MUST target a string shape. + @externalDocumentation( + "Identifiers Reference": "https://smithy.io/2.0/spec/service-types.html#resource-identifiers" + ) + identifiers: Identifiers + + /// Defines a map of property string names to Shape IDs that enumerate the properties + /// of the resource. + @externalDocumentation( + "Properties Reference": "https://smithy.io/2.0/spec/service-types.html#resource-properties" + ) + properties: Properties + + /// Defines the lifecycle operation used to create a resource using one or more + /// identifiers created by the service. The value MUST be a valid Shape ID that targets an operation shape. + @externalDocumentation( + "Create Reference": "https://smithy.io/2.0/spec/service-types.html#create-lifecycle" + ) + create: AnyOperation + + /// Defines an idempotent lifecycle operation used to create a resource using identifiers + /// provided by the client. The value MUST be a valid Shape ID that targets an operation shape. + @externalDocumentation( + "Put Reference": "https://smithy.io/2.0/spec/service-types.html#put-lifecycle" + ) + put: AnyOperation + + /// Defines the lifecycle operation used to retrieve the resource. The value MUST be + /// a valid Shape ID that targets an operation shape. + @externalDocumentation( + "Read Reference": "https://smithy.io/2.0/spec/service-types.html#read-lifecycle" + ) + read: AnyOperation + + /// Defines the lifecycle operation used to update the resource. The value MUST be a + /// valid Shape ID that targets an operation shape. + @externalDocumentation( + "Update Reference": "https://smithy.io/2.0/spec/service-types.html#update-lifecycle" + ) + update: AnyOperation + + /// Defines the lifecycle operation used to delete the resource. The value MUST be a + /// valid Shape ID that targets an operation shape. + @externalDocumentation( + "Delete Reference": "https://smithy.io/2.0/spec/service-types.html#delete-lifecycle" + ) + delete: AnyOperation + + /// Defines the lifecycle operation used to list resources of this type. The value + /// MUST be a valid Shape ID that targets an operation shape. + @externalDocumentation( + "List Reference": "https://smithy.io/2.0/spec/service-types.html#list-lifecycle" + ) + list: AnyOperation + + /// Binds a list of non-lifecycle instance operations to the resource. Each value in + /// the list MUST be a valid Shape ID that targets an operation shape. + operations: Operations + + /// Binds a list of non-lifecycle collection operations to the resource. Each value in + /// the list MUST be a valid Shape ID that targets an operation shape. + collectionOperations: Operations + + /// Binds a list of resources to this resource as a child resource, forming a containment + /// relationship. Each value in the list MUST be a valid Shape ID that targets a resource. + /// The resources MUST NOT have a cyclical containment hierarchy, and a resource can not + /// be bound more than once in the entire closure of a resource or service. + resources: Resources +} + +map Identifiers { + key: String + value: AnyString +} + +map Properties { + key: String + value: AnyMemberTarget +} + +// Note: No builtin docs for list/map members, because they could clobber user-defined docs. +// We could add some logic to merge them, but I don't think it is worth it. +structure ListShape { + member: AnyMemberTarget +} + +structure MapShape { + key: AnyString + value: AnyMemberTarget +} diff --git a/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy new file mode 100644 index 00000000..6322b5a5 --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy @@ -0,0 +1,154 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure BuiltinMetadata { + /// Suppressions are used to suppress specific validation events. + @externalDocumentation("Suppressions Reference": "https://smithy.io/2.0/spec/model-validation.html#suppressions") + suppressions: Suppressions + + /// An array of validator objects used to constrain the model. + @externalDocumentation("Validators Reference": "https://smithy.io/2.0/spec/model-validation.html#validators") + validators: Validators + + /// An array of severity override objects used to raise the severity of non-suppressed validation events. + @externalDocumentation("Severity Overrides Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-overrides") + severityOverrides: SeverityOverrides +} + +list Suppressions { + member: Suppression +} + +list Validators { + member: Validator +} + +list SeverityOverrides { + member: SeverityOverride +} + +@externalDocumentation("Suppressions Reference": "https://smithy.io/2.0/spec/model-validation.html#suppressions") +structure Suppression { + /// The hierarchical validation event ID to suppress. + id: String + + /// The validation event is only suppressed if it matches the supplied namespace. + /// A value of * can be provided to match any namespace. + /// * is useful for suppressing validation events that are not bound to any specific shape. + namespace: AnyNamespace + + /// Provides an optional reason for the suppression. + reason: String +} + +@externalDocumentation("Validators Reference": "https://smithy.io/2.0/spec/model-validation.html#validators") +structure Validator { + /// The name of the validator to apply. This name is used in implementations to find and configure + /// the appropriate validator implementation. Validators only take effect if a Smithy processor + /// implements the validator. + name: ValidatorName + + /// Defines a custom identifier for the validator. + /// Multiple instances of a single validator can be configured for a model. Providing + /// an `id` allows suppressions to suppress a specific instance of a validator. + /// If `id` is not specified, it will default to the name property of the validator definition. + /// IDs that contain dots (.) are hierarchical. For example, the ID "Foo.Bar" contains + /// the ID "Foo". Event ID hierarchies can be leveraged to group validation events and + /// allow more granular suppressions. + id: String + + /// Provides a custom message to use when emitting validation events. The special `{super}` + /// string can be added to a custom message to inject the original error message of + /// the validation event into the custom message. + message: String + + /// Provides a custom severity level to use when a validation event occurs. If no severity + /// is provided, then the default severity of the validator is used. + /// + /// **Note** The severity of user-defined validators cannot be set to `ERROR`. + @externalDocumentation("Severity Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-definition") + severity: ValidatorSeverity + + /// Provides a list of the namespaces that are targeted by the validator. The validator + /// will ignore any validation events encountered that are not specific to the given namespaces. + namespaces: AnyNamespaces + + /// A valid selector that causes the validator to only validate shapes that match the + /// selector. The validator will ignore any validation events encountered that do not + /// satisfy the selector. + @externalDocumentation("Selector Reference": "https://smithy.io/2.0/spec/selectors.html#selectors") + selector: String + + /// Object that provides validator configuration. The available properties are defined + /// by each validator. Validators MAY require that specific configuration properties are provided. + configuration: ValidatorConfig +} + +enum ValidatorSeverity { + NOTE = "NOTE" + WARNING = "WARNING" + DANGER = "DANGER" +} + +list AnyNamespaces { + member: AnyNamespace +} + +@externalDocumentation("Severity Overrides Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-overrides") +structure SeverityOverride { + /// The hierarchical validation event ID to elevate. + id: String + + /// The validation event is only elevated if it matches the supplied namespace. + /// A value of `*` can be provided to match any namespace. + namespace: AnyNamespace + + /// Defines the severity to elevate matching events to. This value can only be set + /// to `WARNING` or `DANGER`. + @externalDocumentation("Severity Reference": "https://smithy.io/2.0/spec/model-validation.html#severity-definition") + severity: SeverityOverrideSeverity +} + +enum SeverityOverrideSeverity { + WARNING = "WARNING" + DANGER = "DANGER" +} + +structure BuiltinValidators { + /// Emits a validation event for each shape that matches the given selector. + @externalDocumentation("EmitEachSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emiteachselector") + EmitEachSelector: EmitEachSelectorConfig + + /// Emits a validation event if no shape in the model matches the given selector. + @externalDocumentation("EmitNoneSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emitnoneselector") + EmitNoneSelector: EmitNoneSelectorConfig + + UnreferencedShapes: UnreferencedShapesConfig +} + +@externalDocumentation("EmitEachSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emiteachselector") +structure EmitEachSelectorConfig { + /// A valid selector. A validation event is emitted for each shape in the model that matches the selector. + @required + selector: Selector + + /// An optional string that MUST be a valid shape ID that targets a trait definition. + /// A validation event is only emitted for shapes that have this trait. + bindToTrait: AnyTrait + + /// A custom template that is expanded for each matching shape and assigned as the message + /// for the emitted validation event. + messageTemplate: String +} + +@externalDocumentation("EmitNoneSelector Reference": "https://smithy.io/2.0/spec/model-validation.html#emitnoneselector") +structure EmitNoneSelectorConfig { + /// A valid selector. If no shape in the model is returned by the selector, then a validation event is emitted. + @required + selector: Selector +} + +structure UnreferencedShapesConfig { + selector: Selector = "service" +} diff --git a/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java new file mode 100644 index 00000000..d6d2cf53 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/FilePatternsTest.java @@ -0,0 +1,121 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static software.amazon.smithy.lsp.UtilMatchers.canMatchPath; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.utils.ListUtils; + +public class FilePatternsTest { + @Test + public void createsProjectPathMatchers() { + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(new TestWorkspace.Dir() + .withPath("foo") + .withSourceDir(new TestWorkspace.Dir() + .withPath("bar") + .withSourceFile("bar.smithy", "") + .withSourceFile("baz.smithy", "")) + .withSourceFile("baz.smithy", "")) + .withSourceDir(new TestWorkspace.Dir() + .withPath("other") + .withSourceFile("other.smithy", "")) + .withSourceFile("abc.smithy", "") + .withConfig(SmithyBuildConfig.builder() + .version("1") + .sources(ListUtils.of("foo", "other/", "abc.smithy")) + .build()) + .build(); + + Project project = ProjectTest.load(workspace.getRoot()); + PathMatcher smithyMatcher = FilePatterns.getSmithyFilesPathMatcher(project); + PathMatcher buildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(project); + + Path root = project.root(); + assertThat(smithyMatcher, canMatchPath(root.resolve("abc.smithy"))); + assertThat(smithyMatcher, canMatchPath(root.resolve("foo/bar/baz.smithy"))); + assertThat(smithyMatcher, canMatchPath(root.resolve("other/bar.smithy"))); + assertThat(buildMatcher, canMatchPath(root.resolve("smithy-build.json"))); + assertThat(buildMatcher, canMatchPath(root.resolve(".smithy-project.json"))); + } + + @Test + public void createsWorkspacePathMatchers() throws IOException { + Path workspaceRoot = Files.createTempDirectory("test"); + workspaceRoot.toFile().deleteOnExit(); + + TestWorkspace fooWorkspace = TestWorkspace.builder() + .withRoot(workspaceRoot) + .withPath("foo") + .build(); + + // Set up a project outside the 'foo' root. + workspaceRoot.resolve("bar").toFile().mkdir(); + workspaceRoot.resolve("bar/smithy-build.json").toFile().createNewFile(); + + Project fooProject = ProjectTest.load(fooWorkspace.getRoot()); + + PathMatcher fooBuildMatcher = FilePatterns.getProjectBuildFilesPathMatcher(fooProject); + PathMatcher workspaceBuildMatcher = FilePatterns.getWorkspaceBuildFilesPathMatcher(workspaceRoot); + + assertThat(fooBuildMatcher, canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(fooBuildMatcher, not(canMatchPath(workspaceRoot.resolve("bar/smithy-build.json")))); + assertThat(workspaceBuildMatcher, canMatchPath(workspaceRoot.resolve("foo/smithy-build.json"))); + assertThat(workspaceBuildMatcher, canMatchPath(workspaceRoot.resolve("bar/smithy-build.json"))); + } + + @Test + public void smithyFileWatchPatternsMatchCorrectSmithyFiles() { + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(new TestWorkspace.Dir() + .withPath("foo") + .withSourceDir(new TestWorkspace.Dir() + .withPath("bar") + .withSourceFile("bar.smithy", "") + .withSourceFile("baz.smithy", "")) + .withSourceFile("baz.smithy", "")) + .withSourceDir(new TestWorkspace.Dir() + .withPath("other") + .withSourceFile("other.smithy", "")) + .withSourceFile("abc.smithy", "") + .withConfig(SmithyBuildConfig.builder() + .version("1") + .sources(ListUtils.of("foo", "other/", "abc.smithy")) + .build()) + .build(); + + Project project = ProjectTest.load(workspace.getRoot()); + List matchers = FilePatterns.getSmithyFileWatchPathMatchers(project); + + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("foo/abc.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("foo/foo/abc/def.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("other/abc.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("other/foo/abc.smithy")))); + assertThat(matchers, hasItem(canMatchPath(workspace.getRoot().resolve("abc.smithy")))); + } + + @Test + public void matchingAnyBuildFile() { + PathMatcher global = FilePatterns.GLOBAL_BUILD_FILES_MATCHER; + + assertThat(global, canMatchPath(Path.of("/smithy-build.json"))); + assertThat(global, canMatchPath(Path.of("foo/bar/smithy-build.json"))); + assertThat(global, canMatchPath(Path.of("/foo/bar/smithy-build.json"))); + assertThat(global, not(canMatchPath(Path.of("/foo/bar/foo-smithy-build.json")))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index 4048b749..9dc5476f 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -5,8 +5,12 @@ package software.amazon.smithy.lsp; +import java.util.Collection; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; import org.hamcrest.CustomTypeSafeMatcher; @@ -35,6 +39,16 @@ public void describeMismatchSafely(CompletionItem item, Description description) }; } + public static Matcher hasLabelAndEditText(String label, String editText) { + return new CustomTypeSafeMatcher<>("label " + label + " editText " + editText) { + @Override + protected boolean matchesSafely(CompletionItem item) { + return label.equals(item.getLabel()) + && editText.trim().equals(item.getTextEdit().getLeft().getNewText().trim()); + } + }; + } + public static Matcher makesEditedDocument(Document document, String expected) { return new CustomTypeSafeMatcher<>("makes an edited document " + expected) { @Override @@ -59,24 +73,49 @@ public void describeMismatchSafely(TextEdit textEdit, Description description) { }; } + public static Matcher> togetherMakeEditedDocument(Document document, String expected) { + return new CustomTypeSafeMatcher<>("make edited document " + expected) { + @Override + protected boolean matchesSafely(Collection item) { + Document copy = document.copy(); + for (TextEdit edit : item) { + copy.applyEdit(edit.getRange(), edit.getNewText()); + } + return copy.copyText().equals(expected); + } + + @Override + public void describeMismatchSafely(Collection item, Description description) { + Document copy = document.copy(); + for (TextEdit edit : item) { + copy.applyEdit(edit.getRange(), edit.getNewText()); + } + String actual = copy.copyText(); + description.appendText(String.format(""" + expected: + '%s' + but was: + '%s' + """, expected, actual)); + } + }; + } + public static Matcher hasText(Document document, Matcher expected) { - return new CustomTypeSafeMatcher<>("text in range") { + return new CustomTypeSafeMatcher<>("text in range " + expected.toString()) { @Override protected boolean matchesSafely(Range item) { - CharSequence borrowed = document.borrowRange(item); - if (borrowed == null) { - return false; - } - return expected.matches(borrowed.toString()); + String actual = document.copyRange(item); + return expected.matches(actual); } @Override public void describeMismatchSafely(Range range, Description description) { - if (document.borrowRange(range) == null) { + if (document.copyRange(range) == null) { description.appendText("text was null"); } else { description.appendDescriptionOf(expected) - .appendText("was " + document.borrowRange(range).toString()); + .appendText("was " + document.copyRange(range)); } } }; @@ -95,4 +134,57 @@ public void describeMismatchSafely(Diagnostic event, Description description) { } }; } + + public static Matcher inlayHint(String label, Position position) { + return new CustomTypeSafeMatcher<>("Inlay Hint label " + label + " position " + + position.getLine() + "," + position.getCharacter()) { + @Override + protected boolean matchesSafely(InlayHint item) { + return item.getLabel().getLeft().equals(label) && position.equals(item.getPosition()); + } + @Override + public void describeMismatchSafely(InlayHint item, Description description) { + if (!item.getLabel().getLeft().equals(label)) { + description.appendText("Expected inlay hint item with label '" + + label + "' but was '" + item.getLabel().getLeft() + "'"); + } + if (!position.equals(item.getPosition())) { + description.appendText("Expected inlay hint item with position '" + + position.getLine() + "," + position.getCharacter() + + "' but was '" + item.getPosition().getLine() + + "," + item.getPosition().getCharacter()+ "'"); + } + + } + }; + } + + public static Matcher isLocationIncluding(String uri, Position position) { + return new CustomTypeSafeMatcher<>("a location in " + uri + " on the same line of, and including " + position) { + @Override + protected boolean matchesSafely(Location item) { + return rangeMatches(item.getRange()) && item.getUri().equals(uri); + } + + private boolean rangeMatches(Range range) { + var start = range.getStart(); + var end = range.getEnd(); + return start.getLine() == position.getLine() + && end.getLine() == position.getLine() + && start.getCharacter() <= position.getCharacter() + && end.getCharacter() > position.getCharacter(); + } + + @Override + protected void describeMismatchSafely(Location item, Description mismatchDescription) { + if (!item.getUri().equals(uri)) { + mismatchDescription.appendText("uri was " + item.getUri()); + } + + if (!rangeMatches(item.getRange())) { + mismatchDescription.appendText("range was " + item.getRange()); + } + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java b/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java new file mode 100644 index 00000000..d8f9b241 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ProjectRootVisitorTest.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static software.amazon.smithy.lsp.project.ProjectTest.toPath; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class ProjectRootVisitorTest { + @Test + public void findsNestedRoot() throws Exception { + Path root = toPath(getClass().getResource("project/nested")); + List found = ProjectRootVisitor.findProjectRoots(root); + assertThat(found, contains(UtilMatchers.endsWith(Path.of("nested/nested")))); + } + + @Test + public void findsMultiNestedRoots() throws Exception { + Path root = toPath(getClass().getResource("project/multi-nested")); + List found = ProjectRootVisitor.findProjectRoots(root); + assertThat(found, containsInAnyOrder( + UtilMatchers.endsWith(Path.of("multi-nested/nested-a")), + UtilMatchers.endsWith(Path.of("multi-nested/nested-b")))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java index e0f2bc9e..055228c8 100644 --- a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -26,7 +26,11 @@ import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PrepareRenameParams; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceContext; +import org.eclipse.lsp4j.ReferenceParams; +import org.eclipse.lsp4j.RenameParams; import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextDocumentItem; @@ -242,6 +246,29 @@ public CompletionParams buildCompletion() { new Position(line, character), new CompletionContext(CompletionTriggerKind.Invoked)); } + + public ReferenceParams buildReference() { + return new ReferenceParams( + new TextDocumentIdentifier(uri), + new Position(line, character), + new ReferenceContext(true) + ); + } + + public RenameParams buildRename(String newName) { + return new RenameParams( + new TextDocumentIdentifier(uri), + new Position(line, character), + newName + ); + } + + public PrepareRenameParams buildPrepareRename() { + return new PrepareRenameParams( + new TextDocumentIdentifier(uri), + new Position(line, character) + ); + } } public static final class DidChangeWatchedFiles { diff --git a/src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java b/src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java new file mode 100644 index 00000000..048b83a1 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/ServerArgumentsTest.java @@ -0,0 +1,111 @@ +package software.amazon.smithy.lsp; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.cli.CliError; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ServerArgumentsTest { + @Test + void validPositionalPortNumber() { + String[] args = {"1"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(1, serverArguments.port()); + assertFalse(serverArguments.help()); + assertTrue(serverArguments.useSocket()); + } + + @Test + void invalidPositionalPortNumber() { + String[] args = {"65536"}; + assertThrows(CliError.class,()-> {ServerArguments.create(args);}); + + } + + @Test + void invalidFlagPortNumber() { + String[] args = {"-p","65536"}; + assertThrows(CliError.class,()-> {ServerArguments.create(args);}); + } + + @Test + void validFlagPortNumberShort() { + String[] args = {"-p","100"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(100, serverArguments.port()); + assertFalse(serverArguments.help()); + assertTrue(serverArguments.useSocket()); + } + + @Test + void defaultPortNumber() { + String[] args = {}; + ServerArguments serverArguments = ServerArguments.create(args); + + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void defaultPortNumberInArg() { + String[] args = {"0"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void defaultPortNumberWithFlag() { + String[] args = {"--port","0"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void defaultPortNumberWithShotFlag() { + String[] args = {"-p","0"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(0, serverArguments.port()); + assertFalse(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void validFlagPortNumber() { + String[] args = {"--port","200"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertEquals(200, serverArguments.port()); + } + + @Test + void invalidFlag() { + String[] args = {"--foo"}; + assertThrows(CliError.class,()-> {ServerArguments.create(args);}); + } + + @Test + void validHelpShort() { + String[] args = {"-h"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertTrue(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } + + @Test + void validHelp() { + String[] args = {"--help"}; + ServerArguments serverArguments = ServerArguments.create(args); + assertTrue(serverArguments.help()); + assertFalse(serverArguments.useSocket()); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index 22ea3400..aad25ceb 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1,7 +1,8 @@ package software.amazon.smithy.lsp; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; @@ -16,7 +17,6 @@ import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static software.amazon.smithy.lsp.LspMatchers.diagnosticWithMessage; import static software.amazon.smithy.lsp.LspMatchers.hasLabel; import static software.amazon.smithy.lsp.LspMatchers.hasText; @@ -26,46 +26,43 @@ 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.Files; import java.nio.file.Path; 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; -import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; import org.eclipse.lsp4j.DocumentFormattingParams; -import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; import org.eclipse.lsp4j.FileChangeType; import org.eclipse.lsp4j.FormattingOptions; import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.SymbolInformation; +import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextEdit; -import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.model.MavenConfig; import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.protocol.RangeBuilder; import software.amazon.smithy.model.node.ArrayNode; @@ -73,6 +70,7 @@ import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.traits.LengthTrait; +import software.amazon.smithy.model.validation.Severity; public class SmithyLanguageServerTest { @Test @@ -92,296 +90,6 @@ public void runsSelector() throws Exception { assertThat(locations, not(empty())); } - @Test - public void completion() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: String - } - - @default(0) - integer Bar - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - // String - CompletionParams memberTargetParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(4) - .character(10) - .buildCompletion(); - // @default - CompletionParams traitParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(7) - .character(2) - .buildCompletion(); - CompletionParams wsParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(2) - .character(1) - .buildCompletion(); - - List memberTargetCompletions = server.completion(memberTargetParams).get().getLeft(); - List traitCompletions = server.completion(traitParams).get().getLeft(); - List wsCompletions = server.completion(wsParams).get().getLeft(); - - assertThat(memberTargetCompletions, containsInAnyOrder(hasLabel("String"))); - assertThat(traitCompletions, containsInAnyOrder(hasLabel("default"))); - assertThat(wsCompletions, empty()); - } - - @Test - public void completionImports() throws Exception { - String model1 = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - } - """); - String model2 = safeString(""" - $version: "2" - namespace com.bar - - string Bar - """); - TestWorkspace workspace = TestWorkspace.multipleModels(model1, model2); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("model-0.smithy"); - - DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() - .uri(uri) - .text(model1) - .build(); - server.didOpen(openParams); - - DidChangeTextDocumentParams changeParams = new RequestBuilders.DidChange() - .uri(uri) - .version(2) - .range(new RangeBuilder() - .startLine(3) - .startCharacter(15) - .endLine(3) - .endCharacter(15) - .build()) - .text(safeString("\n bar: Ba")) - .build(); - server.didChange(changeParams); - - // bar: Ba - CompletionParams completionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(4) - .character(10) - .buildCompletion(); - List completions = server.completion(completionParams).get().getLeft(); - - assertThat(completions, containsInAnyOrder(hasLabel("Bar"))); - - Document document = server.getFirstProject().getDocument(uri); - // TODO: The server puts the 'use' on the wrong line - assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, safeString(""" - $version: "2" - namespace com.foo - use com.bar#Bar - - structure Foo { - bar: Ba - } - """)))); - } - - @Test - public void definition() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @trait - string myTrait - - structure Foo { - bar: Baz - } - - @myTrait("") - string Baz - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - // bar: Baz - DefinitionParams memberTargetParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(7) - .character(9) - .buildDefinition(); - // @myTrait - DefinitionParams traitParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(10) - .character(1) - .buildDefinition(); - DefinitionParams wsParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(2) - .character(0) - .buildDefinition(); - - List memberTargetLocations = server.definition(memberTargetParams).get().getLeft(); - List traitLocations = server.definition(traitParams).get().getLeft(); - List wsLocations = server.definition(wsParams).get().getLeft(); - - Document document = server.getFirstProject().getDocument(uri); - assertNotNull(document); - - assertThat(memberTargetLocations, hasSize(1)); - Location memberTargetLocation = memberTargetLocations.get(0); - assertThat(memberTargetLocation.getUri(), equalTo(uri)); - assertThat(memberTargetLocation.getRange().getStart(), equalTo(new Position(11, 0))); - // TODO - // assertThat(document.borrowRange(memberTargetLocation.getRange()), equalTo("")); - - assertThat(traitLocations, hasSize(1)); - Location traitLocation = traitLocations.get(0); - assertThat(traitLocation.getUri(), equalTo(uri)); - assertThat(traitLocation.getRange().getStart(), equalTo(new Position(4, 0))); - - assertThat(wsLocations, empty()); - } - - @Test - public void hover() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @trait - string myTrait - - structure Foo { - bar: Bar - } - - @myTrait("") - structure Bar { - baz: String - } - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("main.smithy"); - - // bar: Bar - HoverParams memberParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(7) - .character(9) - .buildHover(); - // @myTrait("") - HoverParams traitParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(10) - .character(1) - .buildHover(); - HoverParams wsParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(2) - .character(0) - .buildHover(); - - Hover memberHover = server.hover(memberParams).get(); - Hover traitHover = server.hover(traitParams).get(); - Hover wsHover = server.hover(wsParams).get(); - - assertThat(memberHover.getContents().getRight().getValue(), containsString("structure Bar")); - assertThat(traitHover.getContents().getRight().getValue(), containsString("string myTrait")); - assertThat(wsHover.getContents().getRight().getValue(), equalTo("")); - } - - @Test - public void hoverWithBrokenModel() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: Bar - baz: String - } - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - // baz: String - HoverParams params = new RequestBuilders.PositionRequest() - .uri(uri) - .line(5) - .character(9) - .buildHover(); - Hover hover = server.hover(params).get(); - - assertThat(hover.getContents().getRight().getValue(), containsString("string String")); - } - - @Test - public void documentSymbol() throws Exception { - String model = safeString(""" - $version: "2" - namespace com.foo - - @trait - string myTrait - - structure Foo { - @required - bar: Bar - } - - structure Bar { - @myTrait("foo") - baz: Baz - } - - @myTrait("abc") - integer Baz - """); - TestWorkspace workspace = TestWorkspace.singleModel(model); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("main.smithy"); - - server.didOpen(RequestBuilders.didOpen() - .uri(uri) - .build()); - - server.getLifecycleManager().waitForAllTasks(); - - DocumentSymbolParams params = new DocumentSymbolParams(new TextDocumentIdentifier(uri)); - List> response = server.documentSymbol(params).get(); - List documentSymbols = response.stream().map(Either::getRight).toList(); - List names = documentSymbols.stream().map(DocumentSymbol::getName).collect(Collectors.toList()); - - assertThat(names, hasItem("myTrait")); - assertThat(names, hasItem("Foo")); - assertThat(names, hasItem("bar")); - assertThat(names, hasItem("Bar")); - assertThat(names, hasItem("baz")); - assertThat(names, hasItem("Baz")); - } - @Test public void formatting() throws Exception { String model = safeString(""" @@ -401,11 +109,16 @@ public void formatting() throws Exception { String uri = workspace.getUri("main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(model) + .build()); + TextDocumentIdentifier id = new TextDocumentIdentifier(uri); DocumentFormattingParams params = new DocumentFormattingParams(id, new FormattingOptions()); List edits = server.formatting(params).get(); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().getManagedDocument(uri); assertThat(edits, containsInAnyOrder(makesEditedDocument(document, safeString(""" $version: "2" @@ -463,10 +176,10 @@ public void didChange() throws Exception { server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(" ").build()); server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("G").build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); // mostly so you can see what it looks like - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(safeString(""" $version: "2" namespace com.foo @@ -507,7 +220,7 @@ public void didChangeReloadsModel() throws Exception { .text(model) .build(); server.didOpen(openParams); - assertThat(server.getFirstProject().modelResult().getValidationEvents(), empty()); + assertThat(server.getState().findProjectAndFile(uri).project().modelResult().getValidationEvents(), empty()); DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() .uri(uri) @@ -516,20 +229,20 @@ public void didChangeReloadsModel() throws Exception { .build(); server.didChange(didChangeParams); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleTasks().getTask(uri).get(); - assertThat(server.getFirstProject().modelResult().getValidationEvents(), + assertThat(server.getState().findProjectAndFile(uri).project().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); server.didSave(didSaveParams); - assertThat(server.getFirstProject().modelResult().getValidationEvents(), + assertThat(server.getState().findProjectAndFile(uri).project().modelResult().getValidationEvents(), containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); } @Test - public void didChangeThenDefinition() throws Exception { + public void diagnosticsOnMemberTarget() { String model = safeString(""" $version: "2" namespace com.foo @@ -537,298 +250,106 @@ public void didChangeThenDefinition() throws Exception { structure Foo { bar: Bar } - - string Bar """); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - server.didOpen(new RequestBuilders.DidOpen() - .uri(uri) - .text(model) - .build()); - DefinitionParams definitionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .line(4) - .character(9) - .buildDefinition(); - Location initialLocation = server.definition(definitionParams).get().getLeft().get(0); - assertThat(initialLocation.getUri(), equalTo(uri)); - assertThat(initialLocation.getRange().getStart(), equalTo(new Position(7, 0))); - - RangeBuilder range = new RangeBuilder() - .startLine(5) - .startCharacter(1) - .endLine(5) - .endCharacter(1); - RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text(safeString("\n\n")).build()); - server.didChange(change.range(range.shiftNewLine().shiftNewLine().build()).text("s").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text("i").build()); - server.didChange(change.range(range.shiftRight().build()).text("n").build()); - server.didChange(change.range(range.shiftRight().build()).text("g").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("B").build()); - server.didChange(change.range(range.shiftRight().build()).text("a").build()); - server.didChange(change.range(range.shiftRight().build()).text("z").build()); - - server.getLifecycleManager().getTask(uri).get(); - - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - bar: Bar - } - - string Baz - - string Bar - """))); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); - Location afterChanges = server.definition(definitionParams).get().getLeft().get(0); - assertThat(afterChanges.getUri(), equalTo(uri)); - assertThat(afterChanges.getRange().getStart(), equalTo(new Position(9, 0))); - } - - @Test - public void definitionWithApply() throws Exception { - Path root = toPath(getClass().getResource("project/apply")); - SmithyLanguageServer server = initFromRoot(root); - String foo = root.resolve("model/foo.smithy").toUri().toString(); - String bar = root.resolve("model/bar.smithy").toUri().toString(); + assertThat(diagnostics, hasSize(1)); + Diagnostic diagnostic = diagnostics.get(0); + assertThat(diagnostic.getMessage(), startsWith("Target.UnresolvedShape")); - 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"))); + Document document = server.getState().findProjectAndFile(uri).file().document(); + assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); } @Test - public void newShapeMixinCompletion() throws Exception { + public void diagnosticsOnInvalidStructureMember() { String model = safeString(""" $version: "2" namespace com.foo - - @mixin - structure Foo {} - + + structure Foo { + abc + } """); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - server.didOpen(new RequestBuilders.DidOpen() - .uri(uri) - .text(model) - .build()); - - RangeBuilder range = new RangeBuilder() - .startLine(6) - .startCharacter(0) - .endLine(6) - .endCharacter(0); - RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text("s").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text("u").build()); - server.didChange(change.range(range.shiftRight().build()).text("c").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("u").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text("e").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("B").build()); - server.didChange(change.range(range.shiftRight().build()).text("a").build()); - server.didChange(change.range(range.shiftRight().build()).text("r").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("w").build()); - server.didChange(change.range(range.shiftRight().build()).text("i").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("h").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("[]").build()); - server.didChange(change.range(range.shiftRight().build()).text("F").build()); - - server.getLifecycleManager().getTask(uri).get(); - - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" - $version: "2" - namespace com.foo - - @mixin - structure Foo {} - - structure Bar with [F]"""))); - - Position currentPosition = range.build().getStart(); - CompletionParams completionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .position(range.shiftRight().build().getStart()) - .buildCompletion(); - - assertThat(server.getFirstProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); + assertThat(diagnostics, hasSize(1)); - List completions = server.completion(completionParams).get().getLeft(); + Diagnostic diagnostic = diagnostics.getFirst(); - assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); + assertThat(diagnostic.getRange(), equalTo( + new Range( + new Position(4, 7), + new Position(4, 8) + ) + ) + ); } @Test - public void existingShapeMixinCompletion() throws Exception { + public void diagnosticsOnUse() { String model = safeString(""" $version: "2" namespace com.foo - - @mixin - structure Foo {} - - structure Bar {} + + use mything#SomeUnknownThing """); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - server.didOpen(new RequestBuilders.DidOpen() - .uri(uri) - .text(model) - .build()); - - RangeBuilder range = new RangeBuilder() - .startLine(6) - .startCharacter(13) - .endLine(6) - .endCharacter(13); - RequestBuilders.DidChange change = new RequestBuilders.DidChange().uri(uri); - server.didChange(change.range(range.build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("w").build()); - server.didChange(change.range(range.shiftRight().build()).text("i").build()); - server.didChange(change.range(range.shiftRight().build()).text("t").build()); - server.didChange(change.range(range.shiftRight().build()).text("h").build()); - server.didChange(change.range(range.shiftRight().build()).text(" ").build()); - server.didChange(change.range(range.shiftRight().build()).text("[]").build()); - server.didChange(change.range(range.shiftRight().build()).text("F").build()); - - server.getLifecycleManager().getTask(uri).get(); - - assertThat(server.getFirstProject().getDocument(uri).copyText(), equalTo(safeString(""" - $version: "2" - namespace com.foo - - @mixin - structure Foo {} - - structure Bar with [F] {} - """))); - - Position currentPosition = range.build().getStart(); - CompletionParams completionParams = new RequestBuilders.PositionRequest() - .uri(uri) - .position(range.shiftRight().build().getStart()) - .buildCompletion(); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); - assertThat(server.getFirstProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + Diagnostic diagnostic = diagnostics.getFirst(); + Document document = server.getState().findProjectAndFile(uri).file().document(); - List completions = server.completion(completionParams).get().getLeft(); + assertThat(diagnostic.getRange(), hasText(document, equalTo("mything#SomeUnknownThing"))); - assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); } @Test - public void diagnosticsOnMemberTarget() { + public void diagnosticOnTrait() { String model = safeString(""" $version: "2" namespace com.foo structure Foo { - bar: Bar + @bar + bar: String } """); TestWorkspace workspace = TestWorkspace.singleModel(model); SmithyLanguageServer server = initFromWorkspace(workspace); String uri = workspace.getUri("main.smithy"); - List diagnostics = server.getFileDiagnostics(uri); + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); - assertThat(diagnostic.getMessage(), startsWith("Target.UnresolvedShape")); + assertThat(diagnostic.getMessage(), startsWith("Model.UnresolvedTrait")); - Document document = server.getFirstProject().getDocument(uri); - assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); + Document document = server.getState().findProjectAndFile(uri).file().document(); + assertThat(diagnostic.getRange(), hasText(document, equalTo("@bar"))); } @Test - public void diagnosticOnTrait() { - String model = safeString(""" - $version: "2" - namespace com.foo - - structure Foo { - @bar - bar: String - } - """); - 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.getFirstProject().getDocument(uri); - assertThat(diagnostic.getRange(), hasText(document, equalTo("@bar"))); - } - - @Test - public void diagnosticsOnShape() throws Exception { + public void diagnosticsOnShape() throws Exception { String model = safeString(""" $version: "2" namespace com.foo @@ -861,12 +382,13 @@ public void diagnosticsOnShape() throws Exception { .uri(uri) .build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); assertThat(diagnostics, hasSize(1)); Diagnostic diagnostic = diagnostics.get(0); assertThat(diagnostic.getMessage(), containsString("Missing required member")); - // TODO: In this case, the event is attached to the shape, but the shape isn't in the model + // TODO: In this case, the event is attachedProjects to the shape, but the shape isn't in the model // because it could not be successfully created. So we can't know the actual position of // the shape, because determining it depends on where its defined in the model. // assertThat(diagnostic.getRange().getStart(), equalTo(new Position(3, 5))); @@ -904,7 +426,6 @@ public void insideJar() throws Exception { String preludeUri = preludeLocation.getUri(); assertThat(preludeUri, startsWith("smithyjar")); - Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getFirstProject().getDocument(preludeUri).fullRange()); Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() .uri(preludeUri) @@ -943,15 +464,12 @@ public void addingWatchedFile() throws Exception { .build()); // Make sure the task is running, then wait for it - CompletableFuture future = server.getLifecycleManager().getTask(uri); + CompletableFuture future = server.getState().lifecycleTasks().getTask(uri); assertThat(future, notNullValue()); future.get(); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri), notNullValue()); - assertThat(server.getProjects().getDocument(uri).copyText(), equalTo("$")); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().findManaged(uri).file().document().copyText(), equalTo("$")); } @Test @@ -980,8 +498,7 @@ public void removingWatchedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().getDocument(uri), nullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); } @Test @@ -1003,9 +520,7 @@ 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()); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); String movedFilename = "model/main.smithy"; workspace.moveModel(filename, movedFilename); @@ -1021,12 +536,8 @@ public void addingDetachedFile() { .event(movedUri, FileChangeType.Created) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().isDetached(movedUri), is(false)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); + assertManagedMatches(server, movedUri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -1048,9 +559,7 @@ public void removingAttachedFile() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); String movedFilename = "main.smithy"; workspace.moveModel(filename, movedFilename); @@ -1067,12 +576,8 @@ public void removingAttachedFile() { .event(uri, FileChangeType.Deleted) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().isDetached(movedUri), is(true)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); + assertManagedMatches(server, movedUri, Project.Type.DETACHED, movedUri); } @Test @@ -1103,9 +608,7 @@ public void loadsProjectWithUnNormalizedSourcesDirs() { .text(modelText) .build()); - assertThat(server.getLifecycleManager().isManaged(uri), is(true)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -1133,7 +636,7 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataBefore = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -1153,9 +656,9 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .uri(uri) .build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleTasks().getTask(uri).get(); - Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataAfter = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); @@ -1167,9 +670,9 @@ public void reloadingProjectWithArrayMetadataValues() throws Exception { .text("") .build()); - server.getLifecycleManager().getTask(uri).get(); + server.getState().lifecycleTasks().getTask(uri).get(); - Map metadataAfter2 = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataAfter2 = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataAfter2, hasKey("foo")); assertThat(metadataAfter2, hasKey("bar")); assertThat(metadataAfter2.get("foo"), instanceOf(ArrayNode.class)); @@ -1201,7 +704,7 @@ public void changingWatchedFilesWithMetadata() throws Exception { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - Map metadataBefore = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataBefore = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataBefore, hasKey("foo")); assertThat(metadataBefore, hasKey("bar")); assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); @@ -1214,16 +717,15 @@ public void changingWatchedFilesWithMetadata() throws Exception { .event(uri, FileChangeType.Deleted) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - Map metadataAfter = server.getFirstProject().modelResult().unwrap().getMetadata(); + Map metadataAfter = server.getState().findProjectByRoot(workspace.getRoot().toString()).modelResult().unwrap().getMetadata(); assertThat(metadataAfter, hasKey("foo")); assertThat(metadataAfter, hasKey("bar")); assertThat(metadataAfter.get("foo"), instanceOf(ArrayNode.class)); assertThat(metadataAfter.get("foo").expectArrayNode().size(), equalTo(2)); } - // TODO: Somehow this is flaky @Test public void addingOpenedDetachedFile() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); @@ -1240,18 +742,14 @@ public void addingOpenedDetachedFile() throws Exception { String uri = workspace.getUri("main.smithy"); - assertThat(server.getLifecycleManager().managedDocuments(), not(hasItem(uri))); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); + assertThat(server.getState().findManaged(uri), nullValue()); server.didOpen(RequestBuilders.didOpen() .uri(uri) .text(modelText) .build()); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1271,13 +769,13 @@ public void addingOpenedDetachedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - assertThat(server.getFirstProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().findManaged(uri).project().modelResult().unwrap(), allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Bar") + )); } @Test @@ -1311,14 +809,13 @@ public void detachingOpenedFile() throws Exception { .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); - assertThat(server.getProjects().getProject(uri).modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); + assertThat(server.getState().findManaged(uri).project().modelResult(), hasValue(allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Bar") + ))); } @Test @@ -1342,7 +839,7 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - // Moving to an also detached file - the server doesn't send DidChangeWatchedFiles + // Moving to an also detachedProjects file - the server doesn't send DidChangeWatchedFiles String movedFilename = "main-2.smithy"; workspace.moveModel(filename, movedFilename); String movedUri = workspace.getUri(movedFilename); @@ -1355,14 +852,10 @@ public void movingDetachedFile() throws Exception { .text(modelText) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getLifecycleManager().isManaged(uri), is(false)); - assertThat(server.getProjects().getProject(uri), nullValue()); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); - assertThat(server.getProjects().getProject(movedUri), notNullValue()); - assertThat(server.getProjects().isDetached(movedUri), is(true)); + assertThat(server.getState().findManaged(uri), nullValue()); + assertManagedMatches(server, movedUri, Project.Type.DETACHED, movedUri); } @Test @@ -1391,7 +884,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .text(modelText1) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); List publishedDiagnostics1 = client.diagnostics; assertThat(publishedDiagnostics1, hasSize(1)); @@ -1417,7 +910,7 @@ public void updatesDiagnosticsAfterReload() throws Exception { .event(uri2, FileChangeType.Created) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); List publishedDiagnostics2 = client.diagnostics; assertThat(publishedDiagnostics2, hasSize(2)); // sent more diagnostics @@ -1436,12 +929,19 @@ public void invalidSyntaxModelPartiallyLoads() { TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); SmithyLanguageServer server = initFromWorkspace(workspace); - String uri = workspace.getUri("model-0.smithy"); + Project project = server.getState().findProjectByRoot(workspace.getRoot().toString()); + assertThat(project, notNullValue()); + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getResult().isPresent(), is(true)); + assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + + String uri = workspace.getUri("model-1.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText2) + .build()); - assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getFirstProject().modelResult().isBroken(), is(true)); - assertThat(server.getFirstProject().modelResult().getResult().isPresent(), is(true)); - assertThat(server.getFirstProject().modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); } @Test @@ -1459,13 +959,14 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { .text(modelText) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(true)); - assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult().isBroken(), is(true)); + assertThat(projectAndFile.project().modelResult().getResult().isPresent(), is(true)); + assertThat(projectAndFile.project().getAllSmithyFilePaths(), hasItem(endsWith(filename))); server.didChange(RequestBuilders.didChange() .uri(uri) @@ -1476,17 +977,17 @@ public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { """)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(false)); - assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); - assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); - assertThat(server.getProjects().getProject(uri).modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); + ProjectAndFile projectAndFile1 = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile1, notNullValue()); + assertThat(projectAndFile1.project().modelResult().isBroken(), is(false)); + assertThat(projectAndFile1.project().modelResult().getResult().isPresent(), is(true)); + assertThat(projectAndFile1.project().getAllSmithyFilePaths(), hasItem(endsWith(filename))); + assertThat(projectAndFile1.project().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); } - // TODO: apparently flaky @Test public void addingDetachedFileWithInvalidSyntax() throws Exception { TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); @@ -1502,11 +1003,9 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .text("") .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(true)); - assertThat(server.getProjects().getProject(uri), notNullValue()); - assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); + assertManagedMatches(server, uri, Project.Type.DETACHED, uri); List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); updatedSources.add(filename); @@ -1535,12 +1034,10 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception { .range(LspAdapter.point(2, 0)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getProjects().isDetached(uri), is(false)); - assertThat(server.getProjects().detachedProjects().keySet(), empty()); - assertThat(server.getFirstProject().getSmithyFile(uri), notNullValue()); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().findManaged(uri).project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test @@ -1571,10 +1068,14 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text("2") .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - Shape foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri2); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + + Shape foo = projectAndFile.project().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); @@ -1590,11 +1091,15 @@ public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { .text(safeString("string Another\n")) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); + + projectAndFile = server.getState().findProjectAndFile(uri1); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); - assertThat(server.getFirstProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); - foo = server.getFirstProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + foo = projectAndFile.project().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); } @@ -1614,14 +1119,25 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { .event(uri, FileChangeType.Created) .build()); + String buildJson = workspace.readFile("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + String invalidDependency = "software.amazon.smithy:smithy-smoke-test-traits:[1.0, 2.0["; workspace.updateConfig(workspace.getConfig().toBuilder() .maven(MavenConfig.builder() .dependencies(Collections.singletonList(invalidDependency)) .build()) .build()); - server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + buildJson = workspace.readFile("smithy-build.json"); + server.didChange(RequestBuilders.didChange() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(workspace.getUri("smithy-build.json")) .build()); String fixed = "software.amazon.smithy:smithy-smoke-test-traits:1.49.0"; @@ -1630,8 +1146,13 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { .dependencies(Collections.singletonList(fixed)) .build()) .build()); - server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() - .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + buildJson = workspace.readFile("smithy-build.json"); + server.didChange(RequestBuilders.didChange() + .uri(workspace.getUri("smithy-build.json")) + .text(buildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(workspace.getUri("smithy-build.json")) .build()); server.didChange(RequestBuilders.didChange() @@ -1643,130 +1164,16 @@ public void brokenBuildFileEventuallyConsistent() throws Exception { """)) .range(LspAdapter.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).modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); - } - - @Test - public void completionHoverDefinitionWithAbsoluteIds() throws Exception { - String modelText1 = safeString(""" - $version: "2" - namespace com.foo - use com.bar#Bar - @com.bar#baz - structure Foo { - bar: com.bar#Bar - } - """); - String modelText2 = safeString(""" - $version: "2" - namespace com.bar - string Bar - string Bar2 - @trait - structure baz {} - """); - TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("model-0.smithy"); - - // use com.b - RequestBuilders.PositionRequest useTarget = RequestBuilders.positionRequest() - .uri(uri) - .line(2) - .character(8); - // @com.b - RequestBuilders.PositionRequest trait = RequestBuilders.positionRequest() - .uri(uri) - .line(3) - .character(2); - // bar: com.ba - RequestBuilders.PositionRequest memberTarget = RequestBuilders.positionRequest() - .uri(uri) - .line(5) - .character(14); - - List useTargetCompletions = server.completion(useTarget.buildCompletion()).get().getLeft(); - List traitCompletions = server.completion(trait.buildCompletion()).get().getLeft(); - List memberTargetCompletions = server.completion(memberTarget.buildCompletion()).get().getLeft(); - - assertThat(useTargetCompletions, containsInAnyOrder(hasLabel("com.bar#Bar2"))); // won't match 'Bar' because its already imported - assertThat(traitCompletions, containsInAnyOrder(hasLabel("com.bar#baz"))); - assertThat(memberTargetCompletions, containsInAnyOrder(hasLabel("com.bar#Bar"), hasLabel("com.bar#Bar2"))); - - List useTargetLocations = server.definition(useTarget.buildDefinition()).get().getLeft(); - List traitLocations = server.definition(trait.buildDefinition()).get().getLeft(); - List memberTargetLocations = server.definition(memberTarget.buildDefinition()).get().getLeft(); - - String uri1 = workspace.getUri("model-1.smithy"); - - assertThat(useTargetLocations, hasSize(1)); - assertThat(useTargetLocations.get(0).getUri(), equalTo(uri1)); - assertThat(useTargetLocations.get(0).getRange().getStart(), equalTo(new Position(2, 0))); - - assertThat(traitLocations, hasSize(1)); - assertThat(traitLocations.get(0).getUri(), equalTo(uri1)); - assertThat(traitLocations.get(0).getRange().getStart(), equalTo(new Position(5, 0))); - - assertThat(memberTargetLocations, hasSize(1)); - assertThat(memberTargetLocations.get(0).getUri(), equalTo(uri1)); - assertThat(memberTargetLocations.get(0).getRange().getStart(), equalTo(new Position(2, 0))); - - Hover useTargetHover = server.hover(useTarget.buildHover()).get(); - Hover traitHover = server.hover(trait.buildHover()).get(); - Hover memberTargetHover = server.hover(memberTarget.buildHover()).get(); - - assertThat(useTargetHover.getContents().getRight().getValue(), containsString("string Bar")); - assertThat(traitHover.getContents().getRight().getValue(), containsString("structure baz {}")); - assertThat(memberTargetHover.getContents().getRight().getValue(), containsString("string Bar")); - } - - @Test - public void useCompletionDoesntAutoImport() throws Exception { - String modelText1 = safeString(""" - $version: "2" - namespace com.foo - """); - String modelText2 = safeString(""" - $version: "2" - namespace com.bar - string Bar - """); - TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); - SmithyLanguageServer server = initFromWorkspace(workspace); - - String uri = workspace.getUri("model-0.smithy"); - server.didOpen(RequestBuilders.didOpen() - .uri(uri) - .text(modelText1) - .build()); - server.didChange(RequestBuilders.didChange() - .uri(uri) - .range(LspAdapter.point(2, 0)) - .text("use co") - .build()); - - List completions = server.completion(RequestBuilders.positionRequest() - .uri(uri) - .line(2) - .character(5) - .buildCompletion()) - .get() - .getLeft(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(completions, containsInAnyOrder(hasLabel("com.bar#Bar"))); - assertThat(completions.get(0).getAdditionalTextEdits(), nullValue()); + ProjectAndFile projectAndFile = server.getState().findProjectAndFile(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); } @Test public void loadsMultipleRoots() { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", """ $version: "2" @@ -1776,7 +1183,6 @@ public void loadsMultipleRoots() { .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", """ $version: "2" @@ -1787,17 +1193,14 @@ public void loadsMultipleRoots() { SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), hasKey("bar")); - - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + assertThat(projectFoo, notNullValue()); + assertThat(projectBar, notNullValue()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("foo.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("bar.smithy"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); @@ -1806,7 +1209,6 @@ public void loadsMultipleRoots() { @Test public void multiRootLifecycleManagement() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", """ $version: "2" @@ -1816,7 +1218,6 @@ public void multiRootLifecycleManagement() throws Exception { .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", """ $version: "2" @@ -1840,12 +1241,12 @@ public void multiRootLifecycleManagement() throws Exception { server.didChange(RequestBuilders.didChange() .uri(fooUri) .text("\nstructure Bar {}") - .range(LspAdapter.point(server.getProjects().getDocument(fooUri).end())) + .range(LspAdapter.point(server.getState().findProjectAndFile(fooUri).file().document().end())) .build()); server.didChange(RequestBuilders.didChange() .uri(barUri) .text("\nstructure Foo {}") - .range(LspAdapter.point(server.getProjects().getDocument(barUri).end())) + .range(LspAdapter.point(server.getState().findProjectAndFile(barUri).file().document().end())) .build()); server.didSave(RequestBuilders.didSave() @@ -1855,10 +1256,10 @@ public void multiRootLifecycleManagement() throws Exception { .uri(barUri) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1870,14 +1271,12 @@ public void multiRootLifecycleManagement() throws Exception { @Test public void multiRootAddingWatchedFile() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceDir(new TestWorkspace.Dir() .withPath("model") .withSourceFile("main.smithy", "")) .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceDir(new TestWorkspace.Dir() .withPath("model") @@ -1928,14 +1327,14 @@ public void multiRootAddingWatchedFile() throws Exception { .range(LspAdapter.point(3, 0)) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("other.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("other.smithy"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Bar"))); @@ -1945,14 +1344,12 @@ public void multiRootAddingWatchedFile() throws Exception { @Test public void multiRootChangingBuildFile() throws Exception { TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceDir(new TestWorkspace.Dir() .withPath("model") .withSourceFile("main.smithy", "")) .build(); TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceDir(new TestWorkspace.Dir() .withPath("model") @@ -1998,6 +1395,9 @@ public void multiRootChangingBuildFile() throws Exception { .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Changed) .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(workspaceBar.getUri("model/main.smithy")) + .build()); server.didChange(RequestBuilders.didChange() .uri(workspaceBar.getUri("model/main.smithy")) .text(""" @@ -2010,24 +1410,23 @@ public void multiRootChangingBuildFile() throws Exception { .range(LspAdapter.origin()) .build()); - server.getLifecycleManager().waitForAllTasks(); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getProjects().detachedProjects(), anEmptyMap()); - assertThat(server.getProjects().getProject(newUri), notNullValue()); - assertThat(server.getProjects().getProject(workspaceBar.getUri("model/main.smithy")), notNullValue()); - assertThat(server.getProjects().getProject(workspaceFoo.getUri("model/main.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(newUri), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceBar.getUri("model/main.smithy")), notNullValue()); + assertThat(server.getState().findProjectAndFile(workspaceFoo.getUri("model/main.smithy")), notNullValue()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("main.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("other.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("main.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("other.smithy"))); - assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar$other"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.other#Other"))); + assertThat(projectFoo.modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.bar#Bar"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.bar#Bar$other"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.other#Other"))); } @Test @@ -2038,7 +1437,6 @@ public void addingWorkspaceFolder() throws Exception { structure Foo {} """; TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", fooModel) .build(); @@ -2051,7 +1449,6 @@ public void addingWorkspaceFolder() throws Exception { structure Bar {} """; TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", barModel) .build(); @@ -2070,19 +1467,16 @@ public void addingWorkspaceFolder() throws Exception { .text(barModel) .build()); - server.getLifecycleManager().waitForAllTasks(); - - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), hasKey("bar")); + server.getState().lifecycleTasks().waitForAllTasks(); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + assertManagedMatches(server, workspaceFoo.getUri("foo.smithy"), Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, workspaceBar.getUri("bar.smithy"), Project.Type.NORMAL, workspaceBar.getRoot()); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProjectByName("bar"); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(workspaceBar.getName()); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("foo.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("bar.smithy"))); assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); @@ -2096,7 +1490,6 @@ public void removingWorkspaceFolder() { structure Foo {} """; TestWorkspace workspaceFoo = TestWorkspace.builder() - .withName("foo") .withPath("foo") .withSourceFile("foo.smithy", fooModel) .build(); @@ -2107,20 +1500,21 @@ public void removingWorkspaceFolder() { structure Bar {} """; TestWorkspace workspaceBar = TestWorkspace.builder() - .withName("bar") .withPath("bar") .withSourceFile("bar.smithy", barModel) .build(); SmithyLanguageServer server = initFromWorkspaces(workspaceFoo, workspaceBar); + String fooUri = workspaceFoo.getUri("foo.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspaceFoo.getUri("foo.smithy")) + .uri(fooUri) .text(fooModel) .build()); + String barUri = workspaceBar.getUri("bar.smithy"); server.didOpen(RequestBuilders.didOpen() - .uri(workspaceBar.getUri("bar.smithy")) + .uri(barUri) .text(barModel) .build()); @@ -2128,23 +1522,729 @@ public void removingWorkspaceFolder() { .removed(workspaceBar.getRoot().toUri().toString(), "bar") .build()); - assertThat(server.getProjects().attachedProjects(), hasKey("foo")); - assertThat(server.getProjects().attachedProjects(), not(hasKey("bar"))); - assertThat(server.getProjects().detachedProjects(), hasKey(endsWith("bar.smithy"))); - assertThat(server.getProjects().isDetached(workspaceBar.getUri("bar.smithy")), is(true)); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); - assertThat(server.getProjects().getDocument(workspaceFoo.getUri("foo.smithy")), notNullValue()); - assertThat(server.getProjects().getDocument(workspaceBar.getUri("bar.smithy")), notNullValue()); + Project projectFoo = server.getState().findProjectByRoot(workspaceFoo.getName()); + Project projectBar = server.getState().findProjectByRoot(barUri); - Project projectFoo = server.getProjects().getProjectByName("foo"); - Project projectBar = server.getProjects().getProject(workspaceBar.getUri("bar.smithy")); + assertThat(projectFoo.getAllSmithyFilePaths(), hasItem(endsWith("foo.smithy"))); + assertThat(projectBar.getAllSmithyFilePaths(), hasItem(endsWith("bar.smithy"))); - assertThat(projectFoo.smithyFiles(), hasKey(endsWith("foo.smithy"))); - assertThat(projectBar.smithyFiles(), hasKey(endsWith("bar.smithy"))); + assertThat(projectFoo.modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + assertThat(projectBar.modelResult(), hasValue(hasShapeWithId("com.bar#Bar"))); + } - assertThat(projectFoo.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); - assertThat(projectBar.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.bar#Bar"))); + @Test + public void singleWorkspaceMultiRoot() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), notNullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); + assertThat(server.getState().workspacePaths(), contains(root)); + } + + @Test + public void addingRootsToWorkspace() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + SmithyLanguageServer server = initFromRoot(root); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Created) + .event(workspaceBar.getUri("smithy-build.json"), FileChangeType.Created) + .build()); + + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), notNullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); + } + + @Test + public void removingRootsFromWorkspace() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), notNullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); + + workspaceFoo.deleteModel("smithy-build.json"); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri("smithy-build.json"), FileChangeType.Deleted) + .build()); + + assertThat(server.getState().workspacePaths(), contains(root)); + assertThat(server.getState().findProjectByRoot(workspaceFoo.getName()), nullValue()); + assertThat(server.getState().findProjectByRoot(workspaceBar.getName()), notNullValue()); + } + + @Test + public void addingConfigFile() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + String bazUri = workspaceFoo.getUri("baz.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(bazUri) + .text(bazModel) + .build()); + + assertThat(server.getState().workspacePaths(), contains(root)); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); + + workspaceFoo.addModel(".smithy-project.json", """ + { + "sources": ["baz.smithy"] + }"""); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.NORMAL, workspaceFoo.getRoot()); } + + @Test + public void removingConfigFile() throws Exception { + Path root = Files.createTempDirectory("test"); + root.toFile().deleteOnExit(); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(root) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + workspaceFoo.addModel(".smithy-project.json", """ + { + "sources": ["baz.smithy"] + }"""); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(root) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + + SmithyLanguageServer server = initFromRoot(root); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String bazUri = workspaceFoo.getUri("baz.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(bazUri) + .text(bazModel) + .build()); + + assertThat(server.getState().workspacePaths(), contains(root)); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + + workspaceFoo.deleteModel(".smithy-project.json"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspaceFoo.getUri(".smithy-project.json"), FileChangeType.Deleted) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); + } + + @Test + public void tracksJsonFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + workspace.addModel("model/main.json",""" + { + "smithy": "2.0", + "shapes": { + "com.foo#Foo": { + "type": "structure" + } + } + } + """); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + Project project = server.getState().findProjectByRoot(workspace.getName()); + assertThat(project.modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + } + + @Test + public void tracksBuildFileChanges() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + String smithyBuildJson = workspace.readFile("smithy-build.json"); + String uri = workspace.getUri("smithy-build.json"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(smithyBuildJson) + .build()); + + assertManagedMatches(server, uri, Project.Type.NORMAL, workspace.getRoot()); + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(smithyBuildJson)); + + String updatedSmithyBuildJson = """ + { + "version": "1.0", + "sources": ["foo.smithy"] + } + """; + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(updatedSmithyBuildJson) + .build()); + assertThat(server.getState().getManagedDocument(uri).copyText(), equalTo(updatedSmithyBuildJson)); + + server.didSave(RequestBuilders.didSave() + .uri(uri) + .build()); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + + assertThat(server.getState().findManaged(uri), nullValue()); + } + + @Test + public void reloadsProjectOnBuildFileSave() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspaces(workspace); + + String buildJson = workspace.readFile("smithy-build.json"); + String buildJsonUri = workspace.getUri("smithy-build.json"); + + server.didOpen(RequestBuilders.didOpen() + .uri(buildJsonUri) + .text(buildJson) + .build()); + + String model = """ + namespace com.foo + string Foo + """; + workspace.addModel("foo.smithy", model); + String fooUri = workspace.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(model) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.DETACHED, fooUri); + + String updatedBuildJson = """ + { + "version": "1.0", + "sources": ["foo.smithy"] + } + """; + server.didChange(RequestBuilders.didChange() + .uri(buildJsonUri) + .text(updatedBuildJson) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(buildJsonUri) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspace.getRoot()); + } + + @Test + public void testCustomServerOptions() { + ServerOptions options = ServerOptions.builder() + .setMinimumSeverity(Severity.NOTE) + .setOnlyReloadOnSave(true) + .build(); + + assertThat(options.getMinimumSeverity(), equalTo(Severity.NOTE)); + assertThat(options.getOnlyReloadOnSave(), equalTo(true)); + } + + @Test + public void testFromInitializeParamsWithValidOptions() { + StubClient client = new StubClient(); + // Create initialization options + JsonObject opts = new JsonObject(); + opts.add("diagnostics.minimumSeverity", new JsonPrimitive("ERROR")); + opts.add("onlyReloadOnSave", new JsonPrimitive(true)); + + // Create InitializeParams with the options + InitializeParams params = new InitializeParams(); + params.setInitializationOptions(opts); + + // Call the method being tested + ServerOptions options = ServerOptions.fromInitializeParams(params, new SmithyLanguageClient(client)); + + assertThat(options.getMinimumSeverity(), equalTo(Severity.ERROR)); + assertThat(options.getOnlyReloadOnSave(), equalTo(true)); + } + + @Test + public void testFromInitializeParamsWithPartialOptions() { + StubClient client = new StubClient(); + JsonObject opts = new JsonObject(); + opts.add("onlyReloadOnSave", new JsonPrimitive(true)); + // Not setting minimumSeverity + + // Create InitializeParams with the options + InitializeParams params = new InitializeParams(); + params.setInitializationOptions(opts); + + ServerOptions options = ServerOptions.fromInitializeParams(params, new SmithyLanguageClient(client)); + + assertThat(options.getMinimumSeverity(), equalTo(Severity.WARNING)); // Default value + assertThat(options.getOnlyReloadOnSave(), equalTo(true)); // Explicitly set value + } + + @Test + public void openingNewBuildFileInExistingProjectBeforeDidChangeWatchedFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithNoConfig("test"); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(workspace.getRoot()) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + SmithyLanguageServer server = initFromRoot(workspace.getRoot()); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String bazModel = """ + $version: "2" + namespace com.baz + structure Baz {} + """; + workspaceFoo.addModel("baz.smithy", bazModel); + String bazUri = workspaceFoo.getUri("baz.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(bazUri) + .text(bazModel) + .build()); + + assertThat(server.getState().workspacePaths(), contains(workspace.getRoot())); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); + + String smithyProjectJson = """ + { + "sources": ["baz.smithy"] + }"""; + workspaceFoo.addModel(".smithy-project.json", smithyProjectJson); + String smithyProjectJsonUri = workspaceFoo.getUri(".smithy-project.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(smithyProjectJsonUri) + .text(smithyProjectJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.DETACHED, bazUri); + assertManagedMatches(server, smithyProjectJsonUri, Project.Type.UNRESOLVED, smithyProjectJsonUri); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(smithyProjectJsonUri, FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, bazUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, smithyProjectJsonUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertThat(server.getState().getAllProjects().size(), is(1)); + } + + @Test + public void openingNewBuildFileInNewProjectBeforeDidChangeWatchedFiles() { + TestWorkspace workspace = TestWorkspace.emptyWithNoConfig("test"); + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.builder() + .withRoot(workspace.getRoot()) + .withPath("foo") + .withSourceFile("foo.smithy", fooModel) + .build(); + + SmithyLanguageServer server = initFromRoot(workspace.getRoot()); + + String fooUri = workspaceFoo.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + TestWorkspace workspaceBar = TestWorkspace.builder() + .withRoot(workspace.getRoot()) + .withPath("bar") + .withSourceFile("bar.smithy", barModel) + .build(); + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + assertThat(server.getState().workspacePaths(), contains(workspace.getRoot())); + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + + String barSmithyBuildJson = """ + { + "version": "1", + "sources": ["bar.smithy"] + }"""; + workspaceBar.addModel("smithy-build.json", barSmithyBuildJson); + String barSmithyBuildUri = workspaceBar.getUri("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(barSmithyBuildUri) + .text(barSmithyBuildJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.UNRESOLVED, barSmithyBuildUri); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(barSmithyBuildUri, FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.NORMAL, workspaceBar.getRoot()); + } + + @Test + public void openingConfigFileInEmptyWorkspaceBeforeDidChangeWatchedFiles() { + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.singleModel(fooModel); + + SmithyLanguageServer server = initFromWorkspace(workspaceFoo); + + TestWorkspace workspaceBar = TestWorkspace.emptyWithNoConfig("bar"); + server.didChangeWorkspaceFolders(RequestBuilders.didChangeWorkspaceFolders() + .added(workspaceBar.getRoot().toUri().toString(), "bar") + .build()); + + assertThat(server.getState().workspacePaths(), containsInAnyOrder( + workspaceFoo.getRoot(), + workspaceBar.getRoot())); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + workspaceBar.addModel("bar.smithy", barModel); + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String fooUri = workspaceFoo.getUri("main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + + String barSmithyBuildJson = """ + { + "version": "1", + "sources": ["bar.smithy"] + }"""; + workspaceBar.addModel("smithy-build.json", barSmithyBuildJson); + String barSmithyBuildUri = workspaceBar.getUri("smithy-build.json"); + server.didOpen(RequestBuilders.didOpen() + .uri(barSmithyBuildUri) + .text(barSmithyBuildJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.UNRESOLVED, barSmithyBuildUri); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(barSmithyBuildUri, FileChangeType.Created) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.NORMAL, workspaceBar.getRoot()); + } + + @Test + public void openingConfigFileInEmptyWorkspaceAfterDidChangeWatchedFiles() { + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + TestWorkspace workspaceFoo = TestWorkspace.singleModel(fooModel); + + SmithyLanguageServer server = initFromWorkspace(workspaceFoo); + + TestWorkspace workspaceBar = TestWorkspace.emptyWithNoConfig("bar"); + server.didChangeWorkspaceFolders(RequestBuilders.didChangeWorkspaceFolders() + .added(workspaceBar.getRoot().toUri().toString(), "bar") + .build()); + + assertThat(server.getState().workspacePaths(), containsInAnyOrder( + workspaceFoo.getRoot(), + workspaceBar.getRoot())); + + String barModel = """ + $version: "2" + namespace com.bar + structure Bar {} + """; + workspaceBar.addModel("bar.smithy", barModel); + String barUri = workspaceBar.getUri("bar.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(barUri) + .text(barModel) + .build()); + + String fooUri = workspaceFoo.getUri("main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.DETACHED, barUri); + + String barSmithyBuildJson = """ + { + "version": "1", + "sources": ["bar.smithy"] + }"""; + workspaceBar.addModel("smithy-build.json", barSmithyBuildJson); + String barSmithyBuildUri = workspaceBar.getUri("smithy-build.json"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(barSmithyBuildUri, FileChangeType.Created) + .build()); + + server.didOpen(RequestBuilders.didOpen() + .uri(barSmithyBuildUri) + .text(barSmithyBuildJson) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.NORMAL, workspaceFoo.getRoot()); + assertManagedMatches(server, barUri, Project.Type.NORMAL, workspaceBar.getRoot()); + assertManagedMatches(server, barSmithyBuildUri, Project.Type.NORMAL, workspaceBar.getRoot()); + } + + @Test + public void foo() { + TestWorkspace workspace = TestWorkspace.emptyWithNoConfig("foo"); + SmithyLanguageServer server = initFromRoot(workspace.getRoot()); + + String fooModel = """ + $version: "2" + namespace com.foo + structure Foo {} + """; + workspace.addModel("foo.smithy", fooModel); + String fooUri = workspace.getUri("foo.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(fooUri) + .text(fooModel) + .build()); + + assertManagedMatches(server, fooUri, Project.Type.DETACHED, fooUri); + } + + private void assertManagedMatches( + SmithyLanguageServer server, + String uri, + Project.Type expectedType, + String expectedRootUri + ) { + ProjectAndFile projectAndFile = server.getState().findManaged(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().type(), equalTo(expectedType)); + assertThat(projectAndFile.project().root().toString(), equalTo(LspAdapter.toPath(expectedRootUri))); + } + + private void assertManagedMatches( + SmithyLanguageServer server, + String uri, + Project.Type expectedType, + Path expectedRootPath + ) { + ProjectAndFile projectAndFile = server.getState().findManaged(uri); + assertThat(projectAndFile, notNullValue()); + assertThat(projectAndFile.project().type(), equalTo(expectedType)); + assertThat(projectAndFile.project().root(), equalTo(expectedRootPath)); + } + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { 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 f5796236..cb60abc7 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -11,6 +11,7 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.loader.Prelude; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; @@ -42,7 +43,7 @@ public void describeMismatchSafely(ValidatedResult item, Description descript } public static Matcher hasShapeWithId(String id) { - return new CustomTypeSafeMatcher<>("a model with the shape id `" + id + "`") { + return new CustomTypeSafeMatcher<>("has shape id `" + id + "`") { @Override protected boolean matchesSafely(Model item) { return item.getShape(ShapeId.from(id)).isPresent(); @@ -59,7 +60,7 @@ public void describeMismatchSafely(Model model, Description description) { } public static Matcher eventWithMessage(Matcher message) { - return new CustomTypeSafeMatcher<>("has matching message") { + return new CustomTypeSafeMatcher<>("has message matching " + message.toString()) { @Override protected boolean matchesSafely(ValidationEvent item) { return message.matches(item.getMessage()); @@ -71,4 +72,22 @@ public void describeMismatchSafely(ValidationEvent event, Description descriptio } }; } + + public static Matcher eventWithSourceLocation(Matcher sourceLocationMatcher) { + return new CustomTypeSafeMatcher<>("has source location " + sourceLocationMatcher.toString()) { + @Override + protected boolean matchesSafely(ValidationEvent item) { + return sourceLocationMatcher.matches(item.getSourceLocation()); + } + }; + } + + public static Matcher eventWithId(Matcher id) { + return new CustomTypeSafeMatcher<>("has id matching " + id.toString()) { + @Override + protected boolean matchesSafely(ValidationEvent item) { + return id.matches(item.getId()); + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index f7337b54..9af70e0c 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -60,7 +60,8 @@ public void noVersionDiagnostic() throws Exception { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) @@ -86,7 +87,7 @@ public void noVersionDiagnostic() throws Exception { List edits = action.getEdit().getChanges().get(uri); assertThat(edits, hasSize(1)); TextEdit edit = edits.get(0); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); document.applyEdit(edit.getRange(), edit.getNewText()); assertThat(document.copyText(), equalTo(safeString(""" $version: "1" @@ -110,7 +111,8 @@ public void oldVersionDiagnostic() throws Exception { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) @@ -139,7 +141,7 @@ public void oldVersionDiagnostic() throws Exception { List edits = action.getEdit().getChanges().get(uri); assertThat(edits, hasSize(1)); TextEdit edit = edits.get(0); - Document document = server.getFirstProject().getDocument(uri); + Document document = server.getState().findProjectAndFile(uri).file().document(); document.applyEdit(edit.getRange(), edit.getNewText()); assertThat(document.copyText(), equalTo(""" $version: "2" @@ -161,7 +163,8 @@ public void mostRecentVersion() { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) @@ -180,7 +183,8 @@ public void noShapes() { server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); - List diagnostics = server.getFileDiagnostics(uri); + List diagnostics = SmithyDiagnostics.getFileDiagnostics( + server.getState().findProjectAndFile(uri), server.getMinimumSeverity()); List codes = diagnostics.stream() .filter(d -> d.getCode().isLeft()) .map(d -> d.getCode().getLeft()) diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java index f95c0fe3..0e362add 100644 --- a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -25,10 +25,10 @@ public final class TestWorkspace { private SmithyBuildConfig config; private final String name; - private TestWorkspace(Path root, SmithyBuildConfig config, String name) { + private TestWorkspace(Path root, SmithyBuildConfig config) { this.root = root; this.config = config; - this.name = name; + this.name = root.toString(); } /** @@ -87,6 +87,14 @@ public void updateConfig(SmithyBuildConfig newConfig) { this.config = newConfig; } + public String readFile(String relativePath) { + try { + return Files.readString(root.resolve(relativePath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + /** * @param model String of the model to create in the workspace * @return A workspace with a single model, "main.smithy", with the given contents, and @@ -120,6 +128,16 @@ public static TestWorkspace multipleModels(String... models) { return builder.build(); } + public static TestWorkspace emptyWithNoConfig(String prefix) { + Path root; + try { + root = Files.createTempDirectory(prefix); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new TestWorkspace(root, null); + } + public static Builder builder() { return new Builder(); } @@ -183,7 +201,7 @@ private static void writeModels(Path toDir, Map models) throws E public static final class Builder extends Dir { private SmithyBuildConfig config = null; - private String name = ""; + private Path root = null; private Builder() {} @@ -222,8 +240,8 @@ public Builder withConfig(SmithyBuildConfig config) { return this; } - public Builder withName(String name) { - this.name = name; + public Builder withRoot(Path root) { + this.root = root; return this; } @@ -232,8 +250,13 @@ public TestWorkspace build() { if (path == null) { path = "test"; } - Path root = Files.createTempDirectory(path); - root.toFile().deleteOnExit(); + Path projectRoot; + if (this.root != null) { + projectRoot = Files.createDirectory(this.root.resolve(path)); + } else { + projectRoot = Files.createTempDirectory(path); + projectRoot.toFile().deleteOnExit(); + } List sources = new ArrayList<>(); sources.addAll(sourceModels.keySet()); @@ -250,11 +273,11 @@ public TestWorkspace build() { .imports(imports) .build(); } - writeConfig(root, config); + writeConfig(projectRoot, config); - writeModels(root); + writeModels(projectRoot); - return new TestWorkspace(root, config, name); + return new TestWorkspace(projectRoot, config); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java new file mode 100644 index 00000000..2d81afae --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java @@ -0,0 +1,66 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.Document; + +/** + * Wraps some text and positions within that text for easier testing of features + * that operate on cursor positions within a text document. + * + * @param text The underlying text + * @param positions The positions within {@code text} + */ +public record TextWithPositions(String text, Position... positions) { + private static final String POSITION_MARKER = "%"; + + /** + * A convenience method for constructing {@link TextWithPositions} without + * manually specifying the positions, which are error-prone and hard to + * read. + * + *

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

+ * + * @param raw The raw string with position markers + * @return {@link TextWithPositions} with positions where the markers were, + * and those markers removed. + */ + public static TextWithPositions from(String raw) { + Document document = Document.of(safeString(raw)); + List positions = new ArrayList<>(); + + int lastLine = -1; + int lineMarkerCount = 0; + int i = 0; + while (true) { + int next = document.nextIndexOf(POSITION_MARKER, i); + if (next < 0) { + break; + } + Position position = document.positionAtIndex(next); + + // If there's two or more markers on the same line, any markers after the + // first will be off by one when we do the replacement. + if (position.getLine() != lastLine) { + lastLine = position.getLine(); + lineMarkerCount = 1; + } else { + position.setCharacter(position.getCharacter() - lineMarkerCount); + lineMarkerCount++; + } + positions.add(position); + i = next + 1; + } + String text = document.copyText().replace(POSITION_MARKER, ""); + return new TextWithPositions(text, positions.toArray(new Position[0])); + }} diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java index f4dc1103..f2af0163 100644 --- a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -5,6 +5,8 @@ package software.amazon.smithy.lsp; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.Optional; @@ -53,4 +55,46 @@ protected void describeMismatchSafely(PathMatcher item, Description mismatchDesc } }; } + + public static Matcher endsWith(Path path) { + return new CustomTypeSafeMatcher("A path that ends with " + path.toString()) { + @Override + protected boolean matchesSafely(Path item) { + return item.endsWith(path); + } + + @Override + protected void describeMismatchSafely(Path item, Description mismatchDescription) { + mismatchDescription.appendText(item.toString() + " did not end with " + path.toString()); + } + }; + } + + public static Matcher stringEquals(String expected) { + return new CustomTypeSafeMatcher<>(expected) { + @Override + protected boolean matchesSafely(String item) { + return safeString(expected).equals(item); + } + + @Override + protected void describeMismatchSafely(String item, Description mismatchDescription) { + mismatchDescription.appendText("was: " + item); + } + }; + } + + public static Matcher throwsWithMessage(Matcher message) { + return new CustomTypeSafeMatcher<>("Throws " + message) { + @Override + protected boolean matchesSafely(Runnable item) { + try { + item.run(); + return false; + } catch (Exception e) { + return message.matches(e.getMessage()); + } + } + }; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java index db29102a..814882e5 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -6,115 +6,23 @@ package software.amazon.smithy.lsp.document; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.junit.jupiter.api.Assertions.assertEquals; -import static software.amazon.smithy.lsp.document.DocumentTest.safeIndex; +import static org.junit.jupiter.api.Assertions.assertNull; import static software.amazon.smithy.lsp.document.DocumentTest.safeString; -import static software.amazon.smithy.lsp.document.DocumentTest.string; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.shapes.Shape; public class DocumentParserTest { - @Test - public void jumpsToLines() { - String text = """ - abc - def - ghi - - - """; - DocumentParser parser = DocumentParser.of(safeString(text)); - assertEquals(0, parser.position()); - assertEquals(1, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(0); - assertEquals(0, parser.position()); - assertEquals(1, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(1); - assertEquals(safeIndex(4, 1), parser.position()); - assertEquals(2, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(2); - assertEquals(safeIndex(8, 2), parser.position()); - assertEquals(3, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(3); - assertEquals(safeIndex(12, 3), parser.position()); - assertEquals(4, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(4); - assertEquals(safeIndex(13, 4), parser.position()); - assertEquals(5, parser.line()); - assertEquals(1, parser.column()); - } - - @Test - public void jumpsToSource() { - String text = "abc\ndef\nghi\n"; - DocumentParser parser = DocumentParser.of(safeString(text)); - assertThat(parser.position(), is(0)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(1)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 0))); - - boolean ok = parser.jumpToSource(new SourceLocation("", 1, 2)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(1)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(2)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 1))); - - ok = parser.jumpToSource(new SourceLocation("", 1, 4)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(3)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - - ok = parser.jumpToSource(new SourceLocation("", 1, 6)); - assertThat(ok, is(false)); - assertThat(parser.position(), is(3)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - - ok = parser.jumpToSource(new SourceLocation("", 2, 1)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(safeIndex(4, 1))); - assertThat(parser.line(), is(2)); - assertThat(parser.column(), is(1)); - assertThat(parser.currentPosition(), equalTo(new Position(1, 0))); - - ok = parser.jumpToSource(new SourceLocation("", 4, 1)); - assertThat(ok, is(false)); - - ok = parser.jumpToSource(new SourceLocation("", 3, 4)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(safeIndex(11, 2))); - assertThat(parser.line(), is(3)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(2, 3))); - } - @Test public void getsDocumentNamespace() { DocumentParser noNamespace = DocumentParser.of(safeString("abc\ndef\n")); @@ -129,20 +37,20 @@ public void getsDocumentNamespace() { DocumentParser notNamespace = DocumentParser.of(safeString("namespace !foo")); DocumentParser trailingComment = DocumentParser.of(safeString("namespace com.foo//foo\n")); - assertThat(noNamespace.documentNamespace(), nullValue()); - assertThat(incompleteNamespace.documentNamespace(), nullValue()); - assertThat(incompleteNamespaceValue.documentNamespace(), nullValue()); - assertThat(likeNamespace.documentNamespace(), nullValue()); - assertThat(otherLikeNamespace.documentNamespace(), nullValue()); - assertThat(namespaceAtEnd.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(noNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteNamespaceValue.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(likeNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(otherLikeNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(namespaceAtEnd.documentNamespace().namespace(), equalTo("com.foo")); assertThat(namespaceAtEnd.documentNamespace().statementRange(), equalTo(LspAdapter.of(2, 0, 2, 17))); - assertThat(brokenNamespace.documentNamespace(), nullValue()); - assertThat(commentedNamespace.documentNamespace(), nullValue()); - assertThat(wsPrefixedNamespace.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(brokenNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(commentedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(wsPrefixedNamespace.documentNamespace().namespace(), equalTo("com.foo")); assertThat(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.of(1, 4, 1, 21))); - assertThat(notNamespace.documentNamespace(), nullValue()); - assertThat(trailingComment.documentNamespace().namespace().toString(), equalTo("com.foo")); - assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 22))); + assertThat(notNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(trailingComment.documentNamespace().namespace(), equalTo("com.foo")); + assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 17))); } @Test @@ -157,15 +65,15 @@ public void getsDocumentImports() { DocumentParser multiImports = DocumentParser.of(safeString("use com.foo#bar\nuse com.foo#baz")); DocumentParser notImport = DocumentParser.of(safeString("usea com.foo#bar")); - assertThat(noImports.documentImports(), nullValue()); - assertThat(incompleteImport.documentImports(), nullValue()); - assertThat(incompleteImportValue.documentImports(), nullValue()); + assertThat(noImports.documentImports().importsRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteImportValue.documentImports().importsRange(), equalTo(LspAdapter.origin())); assertThat(oneImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); assertThat(leadingWsImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); assertThat(trailingCommentImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); - assertThat(commentedImport.documentImports(), nullValue()); + assertThat(commentedImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); assertThat(multiImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz")); - assertThat(notImport.documentImports(), nullValue()); + assertThat(notImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); // Some of these aren't shape ids, but its ok DocumentParser brokenImport = DocumentParser.of(safeString("use com.foo")); @@ -197,20 +105,20 @@ public void getsDocumentVersion() { DocumentParser notSecond = DocumentParser.of(safeString("$foo: \"bar\"\n$bar: 1\n// abc\n$baz: 2\n $version: \"2\"")); DocumentParser notFirstNoVersion = DocumentParser.of(safeString("$foo: \"bar\"\nfoo\n")); - assertThat(noVersion.documentVersion(), nullValue()); - assertThat(notVersion.documentVersion(), nullValue()); - assertThat(noDollar.documentVersion(), nullValue()); - assertThat(noColon.documentVersion(), nullValue()); - assertThat(commented.documentVersion(), nullValue()); + assertThat(noVersion.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(notVersion.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(noDollar.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(noColon.documentVersion().version(), equalTo("2")); + assertThat(commented.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(leadingWs.documentVersion().version(), equalTo("2")); assertThat(leadingLines.documentVersion().version(), equalTo("2")); - assertThat(notStringNode.documentVersion(), nullValue()); + assertThat(notStringNode.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(trailingComment.documentVersion().version(), equalTo("2")); assertThat(trailingLine.documentVersion().version(), equalTo("2")); - assertThat(invalidNode.documentVersion(), nullValue()); + assertThat(invalidNode.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(notFirst.documentVersion().version(), equalTo("2")); assertThat(notSecond.documentVersion().version(), equalTo("2")); - assertThat(notFirstNoVersion.documentVersion(), nullValue()); + assertThat(notFirstNoVersion.documentVersion().range(), equalTo(LspAdapter.origin())); Range leadingWsRange = leadingWs.documentVersion().range(); Range trailingCommentRange = trailingComment.documentVersion().range(); @@ -224,99 +132,77 @@ public void getsDocumentVersion() { assertThat(notSecond.getDocument().copyRange(notSecondRange), equalTo("$version: \"2\"")); } - @Test - public void getsDocumentShapes() { + @ParameterizedTest + @MethodSource("contiguousRangeTestCases") + public void findsContiguousRange(SourceLocation input, Range expected) { String text = """ - $version: "2" - namespace com.foo - string Foo - structure Bar { - bar: Foo - } - enum Baz { - ONE - TWO - } - intEnum Biz { - ONE = 1 - } - @mixin - structure Boz { - elided: String - } - structure Mixed with [Boz] { - $elided - } - operation Get { - input := { - a: Integer - } - } - """; - Set shapes = Model.assembler() - .addUnparsedModel("main.smithy", text) - .assemble() - .unwrap() - .shapes() - .filter(shape -> shape.getId().getNamespace().equals("com.foo")) - .collect(Collectors.toSet()); - + abc def + ghi jkl + mno pqr + """; DocumentParser parser = DocumentParser.of(safeString(text)); - Map documentShapes = parser.documentShapes(shapes); - DocumentShape fooDef = documentShapes.get(new Position(2, 7)); - DocumentShape barDef = documentShapes.get(new Position(3, 10)); - DocumentShape barMemberDef = documentShapes.get(new Position(4, 4)); - DocumentShape targetFoo = documentShapes.get(new Position(4, 9)); - DocumentShape bazDef = documentShapes.get(new Position(6, 5)); - DocumentShape bazOneDef = documentShapes.get(new Position(7, 4)); - DocumentShape bazTwoDef = documentShapes.get(new Position(8, 4)); - DocumentShape bizDef = documentShapes.get(new Position(10, 8)); - DocumentShape bizOneDef = documentShapes.get(new Position(11, 4)); - DocumentShape bozDef = documentShapes.get(new Position(14, 10)); - DocumentShape elidedDef = documentShapes.get(new Position(15, 4)); - DocumentShape targetString = documentShapes.get(new Position(15, 12)); - DocumentShape mixedDef = documentShapes.get(new Position(17, 10)); - DocumentShape elided = documentShapes.get(new Position(18, 4)); - DocumentShape get = documentShapes.get(new Position(20, 10)); - DocumentShape getInput = documentShapes.get(new Position(21, 13)); - DocumentShape getInputA = documentShapes.get(new Position(22, 8)); + Range actual = parser.findContiguousRange(input); + + if (expected == null) { + assertNull(actual); + } else { + assertEquals(expected, actual); + } + } - 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")); + private static Stream contiguousRangeTestCases() { + return Stream.of( + // Middle of a word + Arguments.of( + new SourceLocation("test.smithy", 1, 2), + new Range(new Position(0, 0), new Position(0, 3)) + ), + // Start of a word + Arguments.of( + new SourceLocation("test.smithy", 1, 5), + new Range(new Position(0, 4), new Position(0, 7)) + ), + // End of a word + Arguments.of( + new SourceLocation("test.smithy", 1, 7), + new Range(new Position(0, 4), new Position(0, 7)) + ), + // Start of line + Arguments.of( + new SourceLocation("test.smithy", 3, 1), + new Range(new Position(2, 0), new Position(2, 3)) + ), + // End of line + Arguments.of( + new SourceLocation("test.smithy", 3, 6), + new Range(new Position(2, 5), new Position(2, 8)) + ), + // Invalid location + Arguments.of( + new SourceLocation("test.smithy", 10, 1), + null + ), + // At whitespace between words + Arguments.of( + new SourceLocation("test.smithy", 1, 4), + new Range(new Position(0, 3), new Position(0, 4)) + ), + // At start of file + Arguments.of( + new SourceLocation("test.smithy", 1, 1), + new Range(new Position(0, 0), new Position(0, 3)) + ), + // At end of file - last character + Arguments.of( + new SourceLocation("test.smithy", 3, 8), + new Range(new Position(2, 5), new Position(2, 8)) + ), + // At end of file - after last character + Arguments.of( + new SourceLocation("test.smithy", 3, 9), + new Range(new Position(2, 8), new Position(2, 9)) + ) + ); } } diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java index b3acf480..80ca1788 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -16,6 +16,7 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.TextWithPositions; import software.amazon.smithy.lsp.protocol.RangeBuilder; public class DocumentTest { @@ -254,91 +255,6 @@ public void getsEnd() { assertThat(end.getCharacter(), equalTo(3)); } - @Test - public void borrowsToken() { - String s = "abc\n" + - "def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 2)); - - assertThat(token, string("abc")); - } - - @Test - public void borrowsTokenWithNoWs() { - String s = "abc"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 1)); - - assertThat(token, string("abc")); - } - - @Test - public void borrowsTokenAtStart() { - String s = "abc\n" + - "def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 0)); - - assertThat(token, string("abc")); - } - - @Test - public void borrowsTokenAtEnd() { - String s = "abc\n" + - "def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(1, 2)); - - assertThat(token, string("def")); - } - - @Test - public void borrowsTokenAtBoundaryStart() { - String s = "a bc d"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 2)); - - assertThat(token, string("bc")); - } - - @Test - public void borrowsTokenAtBoundaryEnd() { - String s = "a bc d"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 3)); - - assertThat(token, string("bc")); - } - - @Test - public void doesntBorrowNonToken() { - String s = "abc def"; - Document document = makeDocument(s); - - CharSequence token = document.borrowToken(new Position(0, 3)); - - assertThat(token, nullValue()); - } - - @Test - public void borrowsLine() { - Document document = makeDocument("abc\n\ndef"); - - assertThat(makeDocument("").borrowLine(0), string("")); - assertThat(document.borrowLine(0), string(safeString("abc\n"))); - assertThat(document.borrowLine(1), string(safeString("\n"))); - assertThat(document.borrowLine(2), string("def")); - assertThat(document.borrowLine(-1), nullValue()); - assertThat(document.borrowLine(3), nullValue()); - } - @Test public void getsNextIndexOf() { Document document = makeDocument("abc\ndef"); @@ -370,23 +286,6 @@ public void getsLastIndexOf() { assertThat(document.lastIndexOf(" ", safeIndex(8, 1)), is(-1)); // not found } - @Test - public void borrowsSpan() { - Document empty = makeDocument(""); - Document line = makeDocument("abc"); - Document multi = makeDocument("abc\ndef\n\n"); - - assertThat(empty.borrowSpan(0, 1), nullValue()); // empty - assertThat(line.borrowSpan(-1, 1), nullValue()); // negative - assertThat(line.borrowSpan(0, 0), string("")); // empty - assertThat(line.borrowSpan(0, 1), string("a")); // one - assertThat(line.borrowSpan(0, 3), string("abc")); // all - assertThat(line.borrowSpan(0, 4), nullValue()); // oob - assertThat(multi.borrowSpan(0, safeIndex(4, 1)), string(safeString("abc\n"))); // with newline - assertThat(multi.borrowSpan(3, safeIndex(5, 1)), string(safeString("\nd"))); // inner - assertThat(multi.borrowSpan(safeIndex(5, 1), safeIndex(9, 3)), string(safeString("ef\n\n"))); // up to end - } - @Test public void getsLineOfIndex() { Document empty = makeDocument(""); @@ -395,6 +294,7 @@ public void getsLineOfIndex() { Document leadingAndTrailingWs = makeDocument("\nabc\n"); Document threeLine = makeDocument("abc\ndef\nhij\n"); + assertThat(empty.lineOfIndex(0), is(-1)); // empty has no lines, so oob assertThat(empty.lineOfIndex(1), is(-1)); // oob assertThat(single.lineOfIndex(0), is(0)); // start assertThat(single.lineOfIndex(2), is(0)); // end @@ -412,45 +312,47 @@ public void getsLineOfIndex() { } @Test - public void borrowsDocumentShapeId() { - Document empty = makeDocument(""); - Document notId = makeDocument("?!&"); - Document onlyId = makeDocument("abc"); - Document split = makeDocument("abc.def hij"); - Document technicallyBroken = makeDocument("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); - Document technicallyValid = makeDocument("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); - - assertThat(empty.copyDocumentId(new Position(0, 0)), nullValue()); - assertThat(notId.copyDocumentId(new Position(0, 0)), nullValue()); - assertThat(notId.copyDocumentId(new Position(0, 2)), nullValue()); - assertThat(onlyId.copyDocumentId(new Position(0, 0)), documentShapeId("abc", DocumentId.Type.ID)); - assertThat(onlyId.copyDocumentId(new Position(0, 2)), documentShapeId("abc", DocumentId.Type.ID)); - assertThat(onlyId.copyDocumentId(new Position(0, 3)), nullValue()); - assertThat(split.copyDocumentId(new Position(0, 0)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); - assertThat(split.copyDocumentId(new Position(0, 6)), documentShapeId("abc.def", DocumentId.Type.NAMESPACE)); - assertThat(split.copyDocumentId(new Position(0, 7)), nullValue()); - assertThat(split.copyDocumentId(new Position(0, 8)), documentShapeId("hij", DocumentId.Type.ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 0)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 3)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 7)), documentShapeId("com.foo#", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 9)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 16)), documentShapeId("com.foo$", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 18)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 25)), documentShapeId("com.foo.", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 27)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 30)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 37)), documentShapeId("com$foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 39)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 43)), documentShapeId("com...foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 49)), documentShapeId("$foo", DocumentId.Type.RELATIVE_WITH_MEMBER)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 54)), documentShapeId(".foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyBroken.copyDocumentId(new Position(0, 59)), documentShapeId("#foo", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 0)), documentShapeId("com.foo#bar", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 12)), documentShapeId("com.foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 28)), documentShapeId("com.foo", DocumentId.Type.NAMESPACE)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 36)), documentShapeId("foo#bar", DocumentId.Type.ABSOLUTE_ID)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 44)), documentShapeId("foo#bar$baz", DocumentId.Type.ABSOLUTE_WITH_MEMBER)); - assertThat(technicallyValid.copyDocumentId(new Position(0, 56)), documentShapeId("foo$bar", DocumentId.Type.RELATIVE_WITH_MEMBER)); + public void copiesDocumentIds() { + assertThat("%", isDocumentShapeId(nullValue())); + + assertThat("%?!&", isDocumentShapeId(nullValue())); + assertThat("?!&%", isDocumentShapeId(nullValue())); + + assertThat("%abc.def hij", isDocumentShapeId(withValueAndType("abc.def", DocumentId.Type.ROOT))); + assertThat("abc.def% hij", isDocumentShapeId(nullValue())); + assertThat("abc.def %hij", isDocumentShapeId(withValueAndType("hij", DocumentId.Type.ROOT))); + assertThat("abc.def hij%", isDocumentShapeId(nullValue())); + + makeDocument("com.foo# com.foo$ com.foo. com$foo$bar com...foo $foo .foo #foo"); + makeDocument("com.foo#bar com.foo#bar$baz com.foo foo#bar foo#bar$baz foo$bar"); + + assertThat("%com.foo#bar", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + assertThat("com.foo#%bar", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + + assertThat("%com.foo#bar$baz", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + assertThat("com.foo#%bar$baz", isDocumentShapeId(withValueAndType("com.foo#bar", DocumentId.Type.ROOT))); + assertThat("com.foo#bar$%baz", isDocumentShapeId(withValueAndType("com.foo#bar$baz", DocumentId.Type.MEMBER))); + assertThat("com.foo#bar%$baz", isDocumentShapeId(withValueAndType("com.foo#bar$baz", DocumentId.Type.MEMBER))); + + assertThat("%foo$bar", isDocumentShapeId(withValueAndType("foo", DocumentId.Type.ROOT))); + assertThat("fo%o$bar", isDocumentShapeId(withValueAndType("foo", DocumentId.Type.ROOT))); + + assertThat("foo%$bar", isDocumentShapeId(withValueAndType("foo$bar", DocumentId.Type.MEMBER))); + assertThat("foo$%bar", isDocumentShapeId(withValueAndType("foo$bar", DocumentId.Type.MEMBER))); + + assertThat("%$foo", isDocumentShapeId(withValueAndType("$foo", DocumentId.Type.MEMBER))); + } + + public static Matcher isDocumentShapeId(Matcher matcher) { + return new CustomTypeSafeMatcher<>("a DocumentShapeId matching " + matcher) { + @Override + protected boolean matchesSafely(String item) { + var twp = TextWithPositions.from(item); + var document = Document.of(twp.text()); + var id = document.copyDocumentId(twp.positions()[0]); + return matcher.matches(id); + } + }; } // This is used to convert the character offset in a file that assumes a single character @@ -467,7 +369,7 @@ public static int safeIndex(int standardOffset, int line) { public static String safeString(String s) { return s.replace("\n", System.lineSeparator()); } - + private static Document makeDocument(String s) { return Document.of(safeString(s)); } @@ -488,11 +390,11 @@ public void describeMismatchSafely(CharSequence item, Description description) { }; } - public static Matcher documentShapeId(String other, DocumentId.Type type) { + public static Matcher withValueAndType(String other, DocumentId.Type type) { return new CustomTypeSafeMatcher<>(other + " with type: " + type) { @Override protected boolean matchesSafely(DocumentId item) { - return other.equals(item.copyIdValue()) && item.type() == type; + return item != null && other.equals(item.copyIdValue()) && item.type() == type; } }; } diff --git a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java deleted file mode 100644 index 8b9df90a..00000000 --- a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.handler; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasItem; - -import java.nio.file.FileSystems; -import java.nio.file.PathMatcher; -import java.util.HashSet; -import java.util.List; -import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; -import org.eclipse.lsp4j.Registration; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.UtilMatchers; -import software.amazon.smithy.lsp.project.Project; -import software.amazon.smithy.lsp.project.ProjectLoader; -import software.amazon.smithy.lsp.project.ProjectManager; -import software.amazon.smithy.utils.ListUtils; - -public class FileWatcherRegistrationHandlerTest { - @Test - public void createsCorrectRegistrations() { - TestWorkspace workspace = TestWorkspace.builder() - .withSourceDir(new TestWorkspace.Dir() - .withPath("foo") - .withSourceDir(new TestWorkspace.Dir() - .withPath("bar") - .withSourceFile("bar.smithy", "") - .withSourceFile("baz.smithy", "")) - .withSourceFile("baz.smithy", "")) - .withSourceDir(new TestWorkspace.Dir() - .withPath("other") - .withSourceFile("other.smithy", "")) - .withSourceFile("abc.smithy", "") - .withConfig(SmithyBuildConfig.builder() - .version("1") - .sources(ListUtils.of("foo", "other/", "abc.smithy")) - .build()) - .build(); - - Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - List matchers = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(List.of(project)) - .stream() - .map(Registration::getRegisterOptions) - .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) - .flatMap(options -> options.getWatchers().stream()) - .map(watcher -> watcher.getGlobPattern().getLeft()) - // The watcher glob patterns will look different between windows/unix, so turning - // them into path matchers lets us do platform-agnostic assertions. - .map(pattern -> FileSystems.getDefault().getPathMatcher("glob:" + pattern)) - .toList(); - - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("foo/abc.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("foo/foo/abc/def.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("other/abc.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("other/foo/abc.smithy")))); - assertThat(matchers, hasItem(UtilMatchers.canMatchPath(workspace.getRoot().resolve("abc.smithy")))); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java new file mode 100644 index 00000000..664a8b73 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java @@ -0,0 +1,308 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static software.amazon.smithy.lsp.LspMatchers.hasLabelAndEditText; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.BuildFileType; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class BuildCompletionHandlerTest { + @Test + public void completesSmithyBuildJsonTopLevel() { + var text = TextWithPositions.from(""" + { + % + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1.0" + """), + hasLabelAndEditText("outputDirectory", """ + "outputDirectory": "" + """), + hasLabelAndEditText("sources", """ + "sources": [] + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("projections", """ + "projections": {} + """), + hasLabelAndEditText("plugins", """ + "plugins": {} + """), + hasLabelAndEditText("ignoreMissingPlugins", """ + "ignoreMissingPlugins": + """), + hasLabelAndEditText("maven", """ + "maven": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonProjectionMembers() { + var text = TextWithPositions.from(""" + { + "projections": { + "foo": { + % + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("abstract", """ + "abstract": + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("transforms", """ + "transforms": [] + """), + hasLabelAndEditText("plugins", """ + "plugins": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonTransformMembers() { + var text = TextWithPositions.from(""" + { + "projections": { + "foo": { + "transforms": [ + { + % + } + ] + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("name", """ + "name": "" + """), + hasLabelAndEditText("args", """ + "args": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonMavenMembers() { + var text = TextWithPositions.from(""" + { + "maven": { + % + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("dependencies", """ + "dependencies": [] + """), + hasLabelAndEditText("repositories", """ + "repositories": [] + """) + )); + } + + @Test + public void completesSmithyBuildJsonMavenRepoMembers() { + var text = TextWithPositions.from(""" + { + "maven": { + "repositories": [ + { + % + } + ] + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("url", """ + "url": "" + """), + hasLabelAndEditText("httpCredentials", """ + "httpCredentials": "" + """), + hasLabelAndEditText("proxyHost", """ + "proxyHost": "" + """), + hasLabelAndEditText("proxyCredentials", """ + "proxyCredentials": "" + """) + )); + } + + @Test + public void completesSmithyProjectJsonTopLevel() { + var text = TextWithPositions.from(""" + { + % + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_PROJECT); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("sources", """ + "sources": [] + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("outputDirectory", """ + "outputDirectory": "" + """), + hasLabelAndEditText("dependencies", """ + "dependencies": [] + """) + )); + } + + @Test + public void completesSmithyProjectJsonDependencyMembers() { + var text = TextWithPositions.from(""" + { + "dependencies": [ + { + % + } + ] + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_PROJECT); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("name", """ + "name": "" + """), + hasLabelAndEditText("path", """ + "path": "" + """) + )); + } + + @Test + public void matchesStringKeys() { + var text = TextWithPositions.from(""" + { + "v%" + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1.0" + """) + )); + } + + @Test + public void matchesNonStringKeys() { + var text = TextWithPositions.from(""" + { + v% + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1.0" + """) + )); + } + + @Test + public void completesKeyValues() { + var text = TextWithPositions.from(""" + { + "version": %, + "projections": { + "a": { + "abstract": % + }, + "b": { + "imports": % + }, + "c": { + "plugins": % + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("\"1.0\"", """ + "1.0" + """), + hasLabelAndEditText("false", "false"), + hasLabelAndEditText("true", "true"), + hasLabelAndEditText("[]", "[]"), + hasLabelAndEditText("{}", "{}") + )); + } + + private static List getCompItems(TextWithPositions twp, BuildFileType type) { + try { + Path root = Files.createTempDirectory("test"); + Path path = root.resolve(type.filename()); + Files.writeString(path, twp.text()); + Project project = ProjectTest.load(root); + String uri = LspAdapter.toUri(path.toString()); + BuildFile buildFile = (BuildFile) project.getProjectFile(uri); + List completionItems = new ArrayList<>(); + BuildCompletionHandler handler = new BuildCompletionHandler(project, buildFile); + for (Position position : twp.positions()) { + CompletionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildCompletion(); + completionItems.addAll(handler.handle(params)); + } + return completionItems; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java new file mode 100644 index 00000000..3ffc76df --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildHoverHandlerTest.java @@ -0,0 +1,113 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.BuildFileType; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; + +public class BuildHoverHandlerTest { + @Test + public void includesDocs() { + var twp = TextWithPositions.from(""" + { + %"version": "1.0" + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("version"))); + } + + @Test + public void includesExternalDocs() { + var twp = TextWithPositions.from(""" + { + %"projections": {} + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("https://smithy.io"))); + } + + @Test + public void nested() { + var twp = TextWithPositions.from(""" + { + "maven": { + %"dependencies": [] + } + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("coordinates"))); + } + + @Test + public void noHoverForValues() { + var twp = TextWithPositions.from(""" + %{ + "version": %"1.0", + "sources": %[] + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, empty()); + } + + @Test + public void membersIncludeInheritedDocs() { + var twp = TextWithPositions.from(""" + %{ + %"version": "1.0" + } + """); + var hovers = getHovers(BuildFileType.SMITHY_BUILD, twp); + + assertThat(hovers, contains(containsString("Smithy Build Reference"))); + } + + private static List getHovers(BuildFileType buildFileType, TextWithPositions twp) { + var workspace = TestWorkspace.emptyWithNoConfig("test"); + workspace.addModel(buildFileType.filename(), twp.text()); + + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri(buildFileType.filename()); + BuildFile buildFile = (BuildFile) project.getProjectFile(uri); + + List hover = new ArrayList<>(); + BuildHoverHandler handler = new BuildHoverHandler(buildFile); + for (Position position : twp.positions()) { + HoverParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildHover(); + Hover result = handler.handle(params); + if (result != null) { + hover.add(result.getContents().getRight().getValue()); + } + } + + return hover; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java new file mode 100644 index 00000000..53c2df7e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -0,0 +1,1138 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.LspMatchers; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.project.SmithyFile; + +public class CompletionHandlerTest { + @Test + public void getsCompletions() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar", "baz")); + } + + @Test + public void completesTraitMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(bar: %) + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"")); + } + + @Test + public void completesMetadataMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace:% + }]"""); + List comps = getCompLabels(text); + + assertThat(comps, not(empty())); + } + + @Test + public void doesntDuplicateTraitBodyMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: String + } + + @foo(bar: "", ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void doesntDuplicateMetadataMembers() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace: "foo" + %}] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "reason")); + } + + @Test + public void doesntDuplicateListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + list L { + member: String + %} + map M { + key: String + %} + """); + List comps = getCompLabels(text); + + + assertThat(comps, contains("value")); + } + + @Test + public void doesntDuplicateOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + operation O { + input := {} + %} + """); + List comps = getCompLabels(text); + assertThat(comps, containsInAnyOrder("output", "errors")); + } + + @Test + public void doesntDuplicateServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + service S { + version: "2024-08-31" + operations: [] + %} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("rename", "resources", "errors")); + } + + @Test + public void doesntDuplicateResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + resource R { + identifiers: {} + properties: {} + read: Op + create: Op + %} + + operation Op {} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "list", "put", "delete", "update", "collectionOperations", "operations", "resources")); + } + + @Test + public void completesEnumTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + enum foo { + ONE + TWO + THREE + } + + @foo(T%) + """); + List comps = getCompItems(text.text(), text.positions()); + + List labels = comps.stream().map(CompletionItem::getLabel).toList(); + List editText = comps.stream() + .map(completionItem -> { + if (completionItem.getTextEdit() != null) { + return completionItem.getTextEdit().getLeft().getNewText(); + } else { + return completionItem.getInsertText(); + } + }).toList(); + + assertThat(labels, containsInAnyOrder("TWO", "THREE")); + assertThat(editText, containsInAnyOrder("\"TWO\"", "\"THREE\"")); + // TODO: Fix this issue where the string is inserted within the enclosing "" + } + + @Test + public void completesFromSingleCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @http(m%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method")); + } + + @Test + public void completesBuiltinControlKeys() { + TextWithPositions text = TextWithPositions.from(""" + $ver% + $ope%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + startsWith("$version: \"2.0\""), + startsWith("$operationInputSuffix: \"Input\""), + startsWith("$operationOutputSuffix: \"Output\""))); + } + + @Test + public void completesBuiltinMetadataKeys() { + TextWithPositions text = TextWithPositions.from(""" + metadata su% + metadata va%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("suppressions = []", "validators = []")); + } + + @Test + public void completesStatementKeywords() { + TextWithPositions text = TextWithPositions.from(""" + us% + ma% + met% + nam% + blo% + boo% + str% + byt% + sho% + int% + lon% + flo% + dou% + big% + tim% + doc% + enu% + lis% + uni% + ser% + res% + ope% + app%"""); + List comps = getCompLabels(text); + + String[] keywords = CompletionCandidates.KEYWORD.literals().toArray(new String[0]); + assertThat(comps, containsInAnyOrder(keywords)); + } + + @Test + public void completesServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + service One { + ver% + ope% + res% + err% + ren% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("version", "operations", "resources", "errors", "rename")); + } + + @Test + public void completesResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + resource A { + ide% + pro% + cre% + pu% + rea% + upd% + del% + lis% + ope% + coll% + res% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "identifiers", + "properties", + "create", + "put", + "read", + "update", + "delete", + "list", + "operations", + "collectionOperations", + "resources")); + } + + @Test + public void completesOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + operation Op { + inp% + out% + err% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("input", "output", "errors")); + } + + @Test + public void completesListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + map M { + k% + v% + } + list L { + m% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("key", "value", "member")); + } + + @Test + public void completesMetadataValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata validators = [{ nam% }] + metadata suppressions = [{ rea% }] + metadata severityOverrides = [{ sev% }] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("namespaces", "name", "reason", "severity")); + } + + @Test + public void completesMetadataValueWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "namespace", "reason")); + } + + @Test + public void completesTraitValueWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @http(% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method", "uri", "code")); + } + + @Test + public void completesShapeMemberNameWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + list Foo { + % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("member")); + } + + // TODO: These next two shouldn't need the space after ':' + @Test + public void completesMemberTargetsWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + structure Foo { + bar: % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("String", "Integer", "Float")); + } + + @Test + public void completesOperationMemberTargetsWithoutStartingCharacters() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + structure Foo {} + operation Bar { + input: % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("Foo")); + } + + @Test + public void completesTraitsWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + @%"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("http")); + } + + @Test + public void completesOperationErrors() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + @error("client") + structure MyError {} + + operation Foo { + errors: [% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("MyError")); + } + + @Test + public void completesServiceMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + service Foo { + operations: [%] + resources: [%] + errors: [%] + } + operation MyOp {} + resource MyResource {} + @error("client") + structure MyError {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("MyOp", "MyResource", "MyError")); + } + + @Test + public void completesResourceMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + resource Foo { + create: M% + operations: [O%] + resources: [%] + } + operation MyOp {} + operation OtherOp {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("MyOp", "OtherOp", "Foo")); + } + + @Test + public void insertionTextHasCorrectRange() { + TextWithPositions text = TextWithPositions.from("metadata suppressions = [%]"); + + var comps = getCompItems(text.text(), text.positions()); + var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList(); + + assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "metadata suppressions = [{}]")); + } + + @Test + public void replacementTextHasCorrectRange() { + TextWithPositions text = TextWithPositions.from("strin%"); + + var comps = getCompItems(text.text(), text.positions()); + var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList(); + + assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "string")); + } + + @Test + public void completesNamespace() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo%"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("com.foo")); + } + + // TODO: This shouldn't need the space after the ':' + @Test + public void completesInlineOpMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + operation Op { + input := + @tags([]) + { + foo: % + } + } + """); + List comps = getCompLabels(text); + + + assertThat(comps, hasItem("String")); + } + + @Test + public void completesNamespacesInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata suppressions = [{ + id: "foo" + namespace:% + }] + namespace com.foo + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("*")); + } + + @Test + public void completesSeverityInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata severityOverrides = [{ + id: "foo" + severity:% + }] + namespace com.foo + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("WARNING", "DANGER")); + } + + @Test + public void completesValidatorNamesInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata validators = [{ + id: "foo" + name:% + }] + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("EmitEachSelector", "EmitNoneSelector")); + } + + @Test + public void completesValidatorConfigInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata validators = [{ + id: "foo" + name: "EmitNoneSelector" + configuration: {%} + }] + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("selector")); + } + + @Test + public void doesntCompleteTraitsAfterClosingParen() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @error("client")% + structure Foo {} + """); + List comps = getCompLabels(text); + + assertThat(comps, empty()); + } + + @Test + public void doesntCompleteTraitsAfterClosingParen2() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bool: Boolean + } + + @foo(bool: true)% + structure Foo {} + """); + List comps = getCompLabels(text); + + assertThat(comps, empty()); + } + + @Test + public void recursiveTraitDef() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + bar: Bar + } + + @foo(bar: { bar: { b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar")); + } + + @Test + public void recursiveTraitDef2() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + one: Baz + } + + structure Baz { + two: Bar + } + + @foo(bar: { one: { two: { o% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("one")); + } + + @Test + public void recursiveTraitDef3() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + list Bar { + member: Baz + } + + structure Baz { + bar: Bar + } + + @foo(bar: [{bar: [{b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar")); + } + + @Test + public void recursiveTraitDef4() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + baz: Baz + } + + list Baz { + member: Bar + } + + @foo(bar: {baz:[{baz:[{b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void recursiveTraitDef5() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + baz: Baz + } + + map Baz { + key: String + value: Bar + } + + @foo(bar: {baz: {key: {baz: {key: {b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void completesInlineForResource() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + resource MyResource { + } + + operation Foo { + input := for % + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("MyResource")); + } + + @Test + public void completesElidedMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + resource MyResource { + identifiers: { one: String } + properties: { abc: String } + } + + resource MyResource2 { + identifiers: { two: String } + properties: { def: String } + } + + @mixin + structure MyMixin { + foo: String + } + + @mixin + structure MyMixin2 { + bar: String + } + + structure One for MyResource { + $% + } + + structure Two with [MyMixin] { + $% + } + + operation MyOp { + input := for MyResource2 { + $% + } + output := with [MyMixin2] { + $% + } + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("$one", "$foo", "$two", "$bar", "$abc", "$def")); + } + + @Test + public void traitsWithMaps() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + myMap: MyMap + } + + map MyMap { + key: String + value: String + } + + @foo(myMap: %) + structure A {} + + @foo(myMap: {%}) + structure B {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("{}")); + } + + @Test + public void applyTarget() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string Zzz + + apply Z% + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("Zzz")); + } + + @Test + public void enumMapKeys() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + enum Keys { + FOO = "foo" + BAR = "bar" + } + + @trait + map mapTrait { + key: Keys + value: String + } + + @mapTrait(%) + string Foo + + @mapTrait({%}) + string Bar + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("FOO", "BAR", "FOO", "BAR")); + } + + @Test + public void dynamicTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace smithy.test + + @trait + list smokeTests { + member: SmokeTestCase + } + + structure SmokeTestCase { + params: Document + vendorParams: Document + vendorParamsShape: ShapeId + } + + @idRef + string ShapeId + + @smokeTests([ + { + params: {%} + vendorParamsShape: MyVendorParams + vendorParams: {%} + } + ]) + operation Foo { + input := { + bar: String + } + } + + structure MyVendorParams { + abc: String + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("bar", "abc")); + } + + @Test + public void doesntDuplicateElidedMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + ade: String + } + + structure Bar with [Foo] { + $abc + $% + } + + structure Baz with [Foo] { + abc: String + $% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$ade", "$ade")); + } + + @Test + public void knownMemberNamesWithElided() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + map Foo { + key: String + value: String + } + + map Bar with [Foo] { + key: String + % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("value", "$value")); + } + + @Test + public void unknownMemberNamesWithElided() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + def: String + } + + structure Bar with [Foo] { + $abc + % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$def")); + } + + @Test + public void completesElidedMembersWithoutLeadingDollar() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + } + + structure Bar with [Foo] { + ab% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$abc")); + } + + @Test + public void completesNodeMemberTargetStart() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + service A { + version: % + } + service B { + operations: % + } + resource C { + identifiers: % + } + operation D { + errors: % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"", "[]", "{}", "[]")); + } + + @Test + public void completesAbsoluteShapeIds() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: smithy.% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("smithy.api#String")); + } + + @Test + public void completesUseTarget() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + use smithy.api#Strin% + """); + List comps = getCompItems(text.text(), text.positions()); + + assertThat(comps, hasSize(1)); + CompletionItem item = comps.get(0); + assertThat(item.getTextEdit().getLeft().getNewText(), equalTo("smithy.api#String")); + assertThat(item.getAdditionalTextEdits(), nullValue()); + } + + @Test + public void completesIdRefs() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @trait + structure myId { + ref: ShapeId + } + @idRef + string ShapeId + @myId(ref: %) + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("myId", "ShapeId")); + } + + @Test + public void completesIdRefsOnMember() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @trait + structure myId { + @idRef + ref: String + } + @myId(ref: %) + """); + List comps = getCompLabels(text); + assertThat(comps, hasItems("myId", "String")); + } + + private static List getCompLabels(TextWithPositions textWithPositions) { + return getCompLabels(textWithPositions.text(), textWithPositions.positions()); + } + + private static List getCompLabels(String text, Position... positions) { + return getCompItems(text, positions).stream().map(CompletionItem::getLabel).toList(); + } + + private static List getCompItems(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile smithyFile = (IdlFile) (SmithyFile) project.getProjectFile(uri); + + List completionItems = new ArrayList<>(); + CompletionHandler handler = new CompletionHandler(project, smithyFile); + for (Position position : positions) { + CompletionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildCompletion(); + completionItems.addAll(handler.handle(params, () -> {})); + } + + return completionItems; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java new file mode 100644 index 00000000..e117f8e4 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -0,0 +1,405 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; + +public class DefinitionHandlerTest { + @Test + public void getsPreludeTraitIdLocations() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @tags([]) + string Foo + """); + GetLocationsResult onAt = getLocations(text, new Position(3, 0)); + GetLocationsResult ok = getLocations(text, new Position(3, 1)); + GetLocationsResult atEnd = getLocations(text, new Position(3, 5)); + + assertThat(onAt.locations, empty()); + + assertThat(ok.locations, hasSize(1)); + assertThat(ok.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(ok, ok.locations.getFirst(), "list tags"); + + assertThat(atEnd.locations, empty()); + } + + @Test + public void getsTraitIdsLocationsInCurrentFile() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + string foo + + @foo + string Bar + """); + GetLocationsResult result = getLocations(text, new Position(6, 1)); + + assertThat(result.locations, hasSize(1)); + Location location = result.locations.getFirst(); + assertThat(location.getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, location, "string foo"); + } + + @Test + public void shapeDefs() { + String text = safeString(""" + $version: "2" + namespace com.foo + + structure Foo {} + + structure Bar { + foo: Foo + } + """); + GetLocationsResult onShapeDef = getLocations(text, new Position(3, 10)); + assertThat(onShapeDef.locations, hasSize(1)); + assertThat(onShapeDef.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(onShapeDef, onShapeDef.locations.getFirst(), "structure Foo"); + + GetLocationsResult memberTarget = getLocations(text, new Position(6, 9)); + assertThat(memberTarget.locations, hasSize(1)); + assertThat(memberTarget.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(memberTarget, memberTarget.locations.getFirst(), "structure Foo"); + } + + @Test + public void forResource() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo {} + + structure Bar for Foo {} + """); + GetLocationsResult result = getLocations(text, new Position(5, 18)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "resource Foo"); + } + + @Test + public void mixin() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo {} + + structure Bar with [Foo] {} + """); + GetLocationsResult result = getLocations(text, new Position(6, 20)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void useTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + use smithy.api#tags + """); + GetLocationsResult result = getLocations(text, new Position(2, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "list tags"); + } + + @Test + public void applyTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + structure Foo {} + + apply Foo @tags([]) + """); + GetLocationsResult result = getLocations(text, new Position(5, 6)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void nodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + service Foo { + version: "0" + operations: [Bar] + } + + operation Bar {} + """); + GetLocationsResult result = getLocations(text, new Position(5, 17)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "operation Bar"); + } + + @Test + public void nestedNodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo { + identifiers: { + foo: String + } + } + """); + GetLocationsResult result = getLocations(text, new Position(5, 13)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string String"); + } + + @Test + public void traitValueTopLevelKey() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + } + + @foo(bar: "") + string Baz + """); + GetLocationsResult result = getLocations(text, new Position(8, 7)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "bar: String"); + } + + @Test + public void traitValueNestedKey() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: BarList + } + + list BarList { + member: Bar + } + + structure Bar { + baz: String + } + + @foo(bar: [{ baz: "one" }, { baz: "two" }]) + string S + """); + GetLocationsResult result = getLocations(text, new Position(16, 29)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "baz: String"); + } + + @Test + public void elidedMixinMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + bar: String + } + + structure Bar with [Foo] { + $bar + } + """); + GetLocationsResult result = getLocations(text, new Position(9, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void elidedResourceMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo { + identifiers: { + bar: String + } + } + + structure Bar for Foo { + $bar + } + """); + GetLocationsResult result = getLocations(text, new Position(10, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "resource Foo"); + } + + @Test + public void idRefTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @idRef + string ShapeId + + @trait + structure foo { + id: ShapeId + } + + string Bar + + @foo(id: %Bar) + structure Baz {} + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string Bar"); + } + + @Test + public void idRefMemberTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + @idRef + id: String + } + + @foo(id: %Bar) + string Bar + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string Bar"); + } + + @Test + public void absoluteShapeId() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: %smithy.api#String + } + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string String"); + } + + private static void assertIsShapeDef( + GetLocationsResult result, + Location location, + String expected + ) { + String uri = location.getUri(); + SmithyFile smithyFile = (SmithyFile) result.handler.project.getProjectFile(uri); + assertThat(smithyFile, notNullValue()); + + int documentIndex = smithyFile.document().indexOfPosition(location.getRange().getStart()); + assertThat(documentIndex, greaterThanOrEqualTo(0)); + + StatementView view = StatementView.createAt(((IdlFile) smithyFile).getParse(), documentIndex).orElse(null); + assertThat(view, notNullValue()); + assertThat(view.statementIndex(), greaterThanOrEqualTo(0)); + + var statement = view.getStatement(); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + String shapeType = shapeDef.shapeType().stringValue(); + String shapeName = shapeDef.shapeName().stringValue(); + assertThat(shapeType + " " + shapeName, equalTo(expected)); + } else if (statement instanceof Syntax.Statement.MemberDef memberDef) { + String memberName = memberDef.name().stringValue(); + String memberTarget = memberDef.target().stringValue(); + assertThat(memberName + ": " + memberTarget, equalTo(expected)); + } else { + fail("Expected shape or member def, but was " + statement.getClass().getName()); + } + } + + record GetLocationsResult(DefinitionHandler handler, List locations) {} + + private static GetLocationsResult getLocations(TextWithPositions textWithPositions) { + return getLocations(textWithPositions.text(), textWithPositions.positions()); + } + + private static GetLocationsResult getLocations(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); + + List locations = new ArrayList<>(); + DefinitionHandler handler = new DefinitionHandler(project, (IdlFile) smithyFile); + for (Position position : positions) { + DefinitionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildDefinition(); + locations.addAll(handler.handle(params)); + } + + return new GetLocationsResult(handler, locations); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java new file mode 100644 index 00000000..a2cef703 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java @@ -0,0 +1,340 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static software.amazon.smithy.lsp.LspMatchers.hasText; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.SymbolKind; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.syntax.Syntax; + +public class DocumentSymbolTest { + @Test + public void documentSymbols() { + var document = Document.of(""" + $version: "2" + namespace com.foo + + @trait + string myTrait + + structure Foo { + @required + bar: Bar + + baz: Baz + } + + operation MyOp { + input := { + foo: String + } + + output: MyOpOutput + } + + resource MyResource { + identifiers: { + myId: String + myOtherId: String + } + properties: { + myProperty: Foo + myOtherProperty: String + } + get: MyOp + operations: [ + MyOp + ] + } + """); + var symbols = getDocumentSymbols(document); + + assertThat(symbols, hasSize(5)); + + var nsSymbol = symbols.get(0); + assertThat(nsSymbol.getName(), equalTo("com.foo")); + assertThat(nsSymbol.getKind(), equalTo(SymbolKind.Namespace)); + assertThat(nsSymbol.getRange(), hasText(document, equalTo("namespace com.foo"))); + assertThat(nsSymbol.getSelectionRange(), hasText(document, equalTo("com.foo"))); + assertThat(nsSymbol.getDetail(), nullValue()); + assertThat(nsSymbol.getChildren(), nullValue()); + + var myTraitSymbol = symbols.get(1); + assertThat(myTraitSymbol.getName(), equalTo("myTrait")); + assertThat(myTraitSymbol.getKind(), equalTo(SymbolKind.Class)); + assertThat(myTraitSymbol.getRange(), hasText(document, equalTo("string myTrait"))); + assertThat(myTraitSymbol.getSelectionRange(), hasText(document, equalTo("myTrait"))); + assertThat(myTraitSymbol.getDetail(), nullValue()); + assertThat(myTraitSymbol.getChildren(), nullValue()); + + var fooSymbol = symbols.get(2); + assertThat(fooSymbol.getName(), equalTo("Foo")); + assertThat(fooSymbol.getKind(), equalTo(SymbolKind.Class)); + assertThat(fooSymbol.getRange(), hasText(document, allOf( + containsString("structure Foo"), + containsString("bar: Bar"), + containsString("baz: Baz") + ))); + assertThat(fooSymbol.getSelectionRange(), hasText(document, equalTo("Foo"))); + assertThat(fooSymbol.getDetail(), nullValue()); + assertThat(fooSymbol.getChildren(), hasSize(2)); + + var barMemberSymbol = fooSymbol.getChildren().get(0); + assertThat(barMemberSymbol.getName(), equalTo("bar")); + assertThat(barMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(barMemberSymbol.getRange(), hasText(document, equalTo("bar: Bar"))); + assertThat(barMemberSymbol.getSelectionRange(), hasText(document, equalTo("bar"))); + assertThat(barMemberSymbol.getDetail(), equalTo("Bar")); + assertThat(barMemberSymbol.getChildren(), nullValue()); + + var myOpSymbol = symbols.get(3); + assertThat(myOpSymbol.getName(), equalTo("MyOp")); + assertThat(myOpSymbol.getKind(), equalTo(SymbolKind.Interface)); + assertThat(myOpSymbol.getRange(), hasText(document, allOf( + containsString("operation MyOp"), + containsString("input :="), + containsString("output: MyOpOutput") + ))); + assertThat(myOpSymbol.getSelectionRange(), hasText(document, equalTo("MyOp"))); + assertThat(myOpSymbol.getChildren(), hasSize(2)); + + var myOpInputSymbol = myOpSymbol.getChildren().get(0); + assertThat(myOpInputSymbol.getName(), equalTo("input")); + assertThat(myOpInputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpInputSymbol.getRange(), hasText(document, allOf( + containsString("input :="), + containsString("foo: String") + ))); + assertThat(myOpInputSymbol.getSelectionRange(), hasText(document, equalTo("input"))); + assertThat(myOpInputSymbol.getDetail(), nullValue()); + assertThat(myOpInputSymbol.getChildren(), hasSize(1)); + + var myOpInputFooMemberSymbol = myOpInputSymbol.getChildren().get(0); + assertThat(myOpInputFooMemberSymbol.getName(), equalTo("foo")); + assertThat(myOpInputFooMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(myOpInputFooMemberSymbol.getRange(), hasText(document, equalTo("foo: String"))); + assertThat(myOpInputFooMemberSymbol.getSelectionRange(), hasText(document, equalTo("foo"))); + assertThat(myOpInputFooMemberSymbol.getDetail(), equalTo("String")); + assertThat(myOpInputFooMemberSymbol.getChildren(), nullValue()); + + var myOpOutputSymbol = myOpSymbol.getChildren().get(1); + assertThat(myOpOutputSymbol.getName(), equalTo("output")); + assertThat(myOpOutputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpOutputSymbol.getRange(), hasText(document, equalTo("output: MyOpOutput"))); + assertThat(myOpOutputSymbol.getSelectionRange(), hasText(document, equalTo("output"))); + assertThat(myOpOutputSymbol.getDetail(), equalTo("MyOpOutput")); + assertThat(myOpOutputSymbol.getChildren(), nullValue()); + + var myResourceSymbol = symbols.get(4); + assertThat(myResourceSymbol.getName(), equalTo("MyResource")); + assertThat(myResourceSymbol.getKind(), equalTo(SymbolKind.Interface)); + assertThat(myResourceSymbol.getRange(), hasText(document, allOf( + containsString("resource MyResource"), + containsString("myId: String"), + containsString("get: MyOp") + ))); + assertThat(myResourceSymbol.getSelectionRange(), hasText(document, equalTo("MyResource"))); + assertThat(myResourceSymbol.getDetail(), nullValue()); + assertThat(myResourceSymbol.getChildren(), hasSize(4)); + + var myResourceIdentifiersSymbol = myResourceSymbol.getChildren().get(0); + assertThat(myResourceIdentifiersSymbol.getName(), equalTo("identifiers")); + assertThat(myResourceIdentifiersSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourceIdentifiersSymbol.getRange(), hasText(document, allOf( + containsString("identifiers:"), + containsString("myId: String"), + containsString("myOtherId: String") + ))); + assertThat(myResourceIdentifiersSymbol.getSelectionRange(), hasText(document, equalTo("identifiers"))); + assertThat(myResourceIdentifiersSymbol.getDetail(), nullValue()); + assertThat(myResourceIdentifiersSymbol.getChildren(), nullValue()); + var myResourcePropertiesSymbol = myResourceSymbol.getChildren().get(1); + assertThat(myResourcePropertiesSymbol.getName(), equalTo("properties")); + assertThat(myResourcePropertiesSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourcePropertiesSymbol.getRange(), hasText(document, allOf( + containsString("properties:"), + containsString("myProperty: Foo"), + containsString("myOtherProperty: String") + ))); + assertThat(myResourcePropertiesSymbol.getSelectionRange(), hasText(document, equalTo("properties"))); + assertThat(myResourcePropertiesSymbol.getDetail(), nullValue()); + assertThat(myResourcePropertiesSymbol.getChildren(), nullValue()); + var myResourceGetSymbol = myResourceSymbol.getChildren().get(2); + assertThat(myResourceGetSymbol.getName(), equalTo("get")); + assertThat(myResourceGetSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourceGetSymbol.getRange(), hasText(document, equalTo("get: MyOp"))); + assertThat(myResourceGetSymbol.getSelectionRange(), hasText(document, equalTo("get"))); + assertThat(myResourceGetSymbol.getDetail(), equalTo("MyOp")); + assertThat(myResourceGetSymbol.getChildren(), nullValue()); + var myResourceOperationsSymbol = myResourceSymbol.getChildren().get(3); + assertThat(myResourceOperationsSymbol.getName(), equalTo("operations")); + assertThat(myResourceOperationsSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myResourceOperationsSymbol.getRange(), hasText(document, allOf( + containsString("operations: ["), + containsString("MyOp") + ))); + assertThat(myResourceOperationsSymbol.getSelectionRange(), hasText(document, equalTo("operations"))); + assertThat(myResourceOperationsSymbol.getDetail(), nullValue()); + assertThat(myResourceOperationsSymbol.getChildren(), nullValue()); + } + + @Test + public void handlesForResourceAndMixins() { + var document = Document.of(""" + operation MyOp for MyResource with [MyMixin] { + input: MyOpInput + output := for MyResource with [MyMixin] { + foo: String + $bar + } + } + """); + var symbols = getDocumentSymbols(document); + assertThat(symbols.size(), equalTo(1)); + + var myOpSymbol = symbols.get(0); + assertThat(myOpSymbol.getName(), equalTo("MyOp")); + assertThat(myOpSymbol.getKind(), equalTo(SymbolKind.Interface)); + assertThat(myOpSymbol.getRange(), hasText(document, allOf( + containsString("operation MyOp for MyResource with [MyMixin]"), + containsString("input: MyOpInput"), + containsString("output :="), + containsString("$bar") + ))); + assertThat(myOpSymbol.getSelectionRange(), hasText(document, equalTo("MyOp"))); + assertThat(myOpSymbol.getDetail(), nullValue()); + assertThat(myOpSymbol.getChildren(), hasSize(2)); + + var myOpInputSymbol = myOpSymbol.getChildren().get(0); + assertThat(myOpInputSymbol.getName(), equalTo("input")); + assertThat(myOpInputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpInputSymbol.getRange(), hasText(document, equalTo("input: MyOpInput"))); + assertThat(myOpInputSymbol.getSelectionRange(), hasText(document, equalTo("input"))); + assertThat(myOpInputSymbol.getDetail(), equalTo("MyOpInput")); + assertThat(myOpInputSymbol.getChildren(), nullValue()); + + var myOpOutputSymbol = myOpSymbol.getChildren().get(1); + assertThat(myOpOutputSymbol.getName(), equalTo("output")); + assertThat(myOpOutputSymbol.getKind(), equalTo(SymbolKind.Property)); + assertThat(myOpOutputSymbol.getRange(), hasText(document, allOf( + containsString("output := for MyResource with [MyMixin]"), + containsString("foo: String"), + containsString("$bar") + ))); + assertThat(myOpOutputSymbol.getSelectionRange(), hasText(document, equalTo("output"))); + assertThat(myOpOutputSymbol.getDetail(), nullValue()); + assertThat(myOpOutputSymbol.getChildren(), hasSize(2)); + + var fooMemberSymbol = myOpOutputSymbol.getChildren().get(0); + assertThat(fooMemberSymbol.getName(), equalTo("foo")); + assertThat(fooMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(fooMemberSymbol.getRange(), hasText(document, equalTo("foo: String"))); + assertThat(fooMemberSymbol.getSelectionRange(), hasText(document, equalTo("foo"))); + assertThat(fooMemberSymbol.getDetail(), equalTo("String")); + assertThat(fooMemberSymbol.getChildren(), nullValue()); + + var barMemberSymbol = myOpOutputSymbol.getChildren().get(1); + assertThat(barMemberSymbol.getName(), equalTo("$bar")); + assertThat(barMemberSymbol.getKind(), equalTo(SymbolKind.Field)); + assertThat(barMemberSymbol.getRange(), hasText(document, equalTo("$bar"))); + assertThat(barMemberSymbol.getSelectionRange(), hasText(document, equalTo("$bar"))); + assertThat(barMemberSymbol.getDetail(), nullValue()); + assertThat(barMemberSymbol.getChildren(), nullValue()); + } + + @Test + public void enums() { + var document = Document.of(""" + enum MyEnum { + FOO + BAR = "bar" + } + + intEnum MyIntEnum { + FOO + BAR = 1 + } + """); + var symbols = getDocumentSymbols(document); + + assertThat(symbols.size(), equalTo(2)); + var myEnumSymbol = symbols.get(0); + assertThat(myEnumSymbol.getName(), equalTo("MyEnum")); + assertThat(myEnumSymbol.getKind(), equalTo(SymbolKind.Enum)); + assertThat(myEnumSymbol.getRange(), hasText(document, allOf( + containsString("enum MyEnum"), + containsString("BAR = \"bar\"") + ))); + assertThat(myEnumSymbol.getSelectionRange(), hasText(document, equalTo("MyEnum"))); + assertThat(myEnumSymbol.getDetail(), nullValue()); + assertThat(myEnumSymbol.getChildren(), hasSize(2)); + + var myEnumFooSymbol = myEnumSymbol.getChildren().get(0); + assertThat(myEnumFooSymbol.getName(), equalTo("FOO")); + assertThat(myEnumFooSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myEnumFooSymbol.getRange(), hasText(document, equalTo("FOO"))); + assertThat(myEnumFooSymbol.getSelectionRange(), hasText(document, equalTo("FOO"))); + assertThat(myEnumFooSymbol.getDetail(), nullValue()); + assertThat(myEnumFooSymbol.getChildren(), nullValue()); + + var myEnumBarSymbol = myEnumSymbol.getChildren().get(1); + assertThat(myEnumBarSymbol.getName(), equalTo("BAR")); + assertThat(myEnumBarSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myEnumBarSymbol.getRange(), hasText(document, equalTo("BAR = \"bar\""))); + assertThat(myEnumBarSymbol.getSelectionRange(), hasText(document, equalTo("BAR"))); + assertThat(myEnumBarSymbol.getDetail(), nullValue()); + assertThat(myEnumBarSymbol.getChildren(), nullValue()); + + var myIntEnumSymbol = symbols.get(1); + assertThat(myIntEnumSymbol.getName(), equalTo("MyIntEnum")); + assertThat(myIntEnumSymbol.getKind(), equalTo(SymbolKind.Enum)); + assertThat(myIntEnumSymbol.getRange(), hasText(document, allOf( + containsString("intEnum MyIntEnum"), + containsString("BAR = 1") + ))); + assertThat(myIntEnumSymbol.getSelectionRange(), hasText(document, equalTo("MyIntEnum"))); + assertThat(myIntEnumSymbol.getDetail(), nullValue()); + assertThat(myIntEnumSymbol.getChildren(), hasSize(2)); + + var myIntEnumFooSymbol = myIntEnumSymbol.getChildren().get(0); + assertThat(myIntEnumFooSymbol.getName(), equalTo("FOO")); + assertThat(myIntEnumFooSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myIntEnumFooSymbol.getRange(), hasText(document, equalTo("FOO"))); + assertThat(myIntEnumFooSymbol.getSelectionRange(), hasText(document, equalTo("FOO"))); + assertThat(myIntEnumFooSymbol.getDetail(), nullValue()); + assertThat(myIntEnumFooSymbol.getChildren(), nullValue()); + + var myIntEnumBarSymbol = myIntEnumSymbol.getChildren().get(1); + assertThat(myIntEnumBarSymbol.getName(), equalTo("BAR")); + assertThat(myIntEnumBarSymbol.getKind(), equalTo(SymbolKind.EnumMember)); + assertThat(myIntEnumBarSymbol.getRange(), hasText(document, equalTo("BAR = 1"))); + assertThat(myIntEnumBarSymbol.getSelectionRange(), hasText(document, equalTo("BAR"))); + assertThat(myIntEnumBarSymbol.getDetail(), nullValue()); + assertThat(myIntEnumBarSymbol.getChildren(), nullValue()); + } + + private static List getDocumentSymbols(Document document) { + Syntax.IdlParseResult parseResult = Syntax.parseIdl(document); + + List symbols = new ArrayList<>(); + var handler = new DocumentSymbolHandler(document, parseResult.statements()); + for (var sym : handler.handle()) { + symbols.add(sym.getRight()); + } + return symbols; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java new file mode 100644 index 00000000..d6b05ba7 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/FoldingRangeHandlerTest.java @@ -0,0 +1,456 @@ +package software.amazon.smithy.lsp.language; + + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectTest; + + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +public class FoldingRangeHandlerTest { + @Test + public void foldingRangeForMultipleImports() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use example.test1% + use example.test2 + use example.test3% + + structure foo {% + bar: String + }% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(2)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + + } + + @Test + public void foldingRangeForSingleStructure() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure foo { + bar: String + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(1)); + assertArrayEquals(new int[]{3, 5}, ranges.getFirst()); + } + + @Test + public void foldingRangeForSingleEmptyShape() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure foo { + + } + resource foo { + + } + operation foo { + + } + union foo{ + + + } + service foo{ + + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(0)); + + } + + @Test + public void foldingRangeForNestedEmptyShape() { + String model = safeString(""" + resource foo { + bar:{ + + } + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(1)); + + } + + @Test + public void foldingRangeForMultipleAdjacentBlocks() { + String model = safeString(""" + $version: "2" + namespace com.foo + + structure First { + a: String + } + structure Second { + b: String + } + structure Third { + c: String + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(3)); + + } + + @Test + public void foldingRangeForStructureWithComment() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + // Comment before + structure WithComments {% + // Comment inside + field1: String, + field2: Integer // Inline comment + // Comment between fields + field3: Boolean + }% // Comment after + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(1)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + } + + @Test + public void foldingRangeForNestedStructures() { + String model = safeString(""" + $version: "2" + namespace com.foo + + resource Person { + name: { + firstName: String, + lastName: String + }, + address: { + street: String, + city: String, + country: String + } + operations: [GetName, + GetAddress, + GetCountry, + GetStreet, + GetCity] + } + """); + + List ranges = getFoldingRanges(model); + + assertThat(ranges, hasSize(4)); + assertArrayEquals(new int[]{3, 18}, ranges.get(0)); + assertArrayEquals(new int[]{4, 7}, ranges.get(1)); + assertArrayEquals(new int[]{8, 12}, ranges.get(2)); + assertArrayEquals(new int[]{13, 17}, ranges.get(3)); + } + + @Test + public void foldingRangeForMultipleAndNestedTraits() { + TextWithPositions model = TextWithPositions.from(""" + @restJson1% + @title("") + @cors() + @sigv4() + @aws.api#service(% + foo: "bar", + foo2: "bar" + )% + @documentation("foo bar")% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(2)); + assertEquals(ranges.get(0)[0], model.positions()[1].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + } + + @Test + public void foldingRangeTest() { + TextWithPositions model = TextWithPositions.from(""" + @required + """); + + List ranges = getFoldingRanges(model.text()); + + } + + @Test + public void foldingRangeForTraitsBlockContainNewline() { + TextWithPositions model = TextWithPositions.from(""" + @restJson1% + @title("") + @cors() + + @sigv4() + + @aws.api#service(% + foo: "bar", + foo2: "bar" + )% + @documentation("foo bar")% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(2)); + assertEquals(ranges.get(0)[0], model.positions()[1].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + } + + @Test + public void foldingRangeForMultipleTraitsBlocks() { + TextWithPositions model = TextWithPositions.from(""" + @restJson1% + @title("") + @cors()% + structure foo{% + bar: String + }% + + @restJson1% + @title("") + @cors()% + + structure foo2{% + bar: String + }% + + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(4)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[3].getLine()); + assertEquals(ranges.get(2)[0], model.positions()[4].getLine()); + assertEquals(ranges.get(2)[1], model.positions()[5].getLine()); + assertEquals(ranges.get(3)[0], model.positions()[6].getLine()); + assertEquals(ranges.get(3)[1], model.positions()[7].getLine()); + } + + @Test + public void foldingRangeForTraitWithNestedMembers() { + TextWithPositions model = TextWithPositions.from(""" + @integration(% + requestTemplates: {% + "application/json": {% + "field1": "value1", + }% + }% + )% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(3)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[5].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[4].getLine()); + assertEquals(ranges.get(2)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(2)[1], model.positions()[3].getLine()); + } + + @Test + public void foldingRangeForNestedTraitsWithOperation() { + TextWithPositions model = TextWithPositions.from(""" + @integration(% + requestParameters: {% + "param1": "a", + "param2": "b", + "param3": "c", + },% + requestTemplates: {% + "application/json": "{}" + }% + )% + + @http(% + uri: "a/b/c" + method: "POST" + code: 200 + )% + @documentation("foo bar")% + operation CreateBeer {% + input: Beer + output: Beer + errors: [% + fooException + ]% + }% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(7)); + } + + @Test + public void foldingRangeForMixedStructuresAndTraits() { + TextWithPositions model = TextWithPositions.from(""" + @deprecated% + @documentation("Additional docs")% + structure DocumentedStruct {% + @required% + @range(min: 1, max: 100)% + field: Integer + }% + """); + + List ranges = getFoldingRanges(model.text()); + + assertThat(ranges, hasSize(3)); + assertEquals(ranges.get(0)[0], model.positions()[0].getLine()); + assertEquals(ranges.get(0)[1], model.positions()[1].getLine()); + assertEquals(ranges.get(1)[0], model.positions()[2].getLine()); + assertEquals(ranges.get(1)[1], model.positions()[5].getLine()); + assertEquals(ranges.get(2)[0], model.positions()[3].getLine()); + assertEquals(ranges.get(2)[1], model.positions()[4].getLine()); + } + + @Test + public void foldingRangeForMetaData() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + metadata test = [ + { + id: "foo" + namespace: "m1" + }, + { + id: "foo2" + namespace: "m2" + } + ] + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(3)); + } + + @Test + public void foldingRangeForInlineDefinition() { + TextWithPositions model = TextWithPositions.from(""" + operation foo { + input := { + string: String + } + output := { + string: String + } + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(3)); + } + + @Test + public void foldingRangeForEnumWithSimpleMember() { + TextWithPositions model = TextWithPositions.from(""" + enum Stage { + ABC + DEF + GHI + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(1)); + } + + @Test + public void foldingRangeForEnumWithAssignedMember() { + TextWithPositions model = TextWithPositions.from(""" + enum Stage { + ABC = 1 + DEF = 2 + GHI = 3 + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(1)); + } + + @Test + public void foldingRangeForListAndMap() { + TextWithPositions model = TextWithPositions.from(""" + list StringList{ + @required + @length(min: 1, max: 10) + @documentation("Member docs") + member: String + } + map StringMap { + key: String, + value: String + } + """); + List ranges = getFoldingRanges(model.text()); + assertThat(ranges, hasSize(3)); + } + + private static List getFoldingRanges(String text) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + List ranges = new ArrayList<>(); + var handler = new FoldingRangeHandler(idlFile.document(), idlFile.getParse().imports(), idlFile.getParse().statements()); + + for (var range : handler.handle()) { + ranges.add(new int[]{range.getStartLine(), range.getEndLine()}); + } + + return ranges; + } +} + diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java new file mode 100644 index 00000000..2c7ac313 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -0,0 +1,385 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.project.SmithyFile; + +public class HoverHandlerTest { + @Test + public void controlKey() { + String text = safeString(""" + $version: "2" + """); + List hovers = getHovers(text, new Position(0, 1)); + + assertThat(hovers, contains(containsString("version"))); + } + + @Test + public void metadataKey() { + String text = safeString(""" + metadata suppressions = [] + """); + List hovers = getHovers(text, new Position(0, 9)); + + assertThat(hovers, contains(containsString("Suppressions"))); + } + + @Test + public void metadataValue() { + String text = safeString(""" + metadata suppressions = [{id: "foo"}] + """); + List hovers = getHovers(text, new Position(0, 26)); + + assertThat(hovers, contains(containsString("id"))); + } + + @Test + public void traitValue() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @http(method: "GET", uri: "/") + operation Foo {} + """); + List hovers = getHovers(text, new Position(3, 7)); + + assertThat(hovers, contains(containsString("method: NonEmptyString"))); + } + + @Test + public void elidedMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + bar: String + } + + structure Bar with [Foo] { + $bar + } + """); + List hovers = getHovers(text, new Position(9, 5)); + + assertThat(hovers, contains(containsString("bar: String"))); + } + + @Test + public void nodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + service Foo { + version: "0" + operations: [Bar] + } + + operation Bar {} + """); + List hovers = getHovers(text, new Position(5, 17)); + + assertThat(hovers, contains(containsString("operation Bar"))); + } + + @Test + public void absoluteShapeId() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: %smithy.api#String + } + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("string String"))); + } + + @Test + public void selfShapeDefinition() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure %Foo {} + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("structure Foo"))); + } + + @Test + public void selfMemberDefinition() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + %bar: String + } + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("bar: String"))); + } + + @Test + public void shapeKeywordHover() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + %service MyService {} + %operation MyOperation {} + %resource MyResource {} + %list MyList {} + %map MyMap {} + %structure MyStructure {} + %union MyUnion {} + %blob MyBlob + %timestamp MyTimestamp + %document MyDocument + %enum MyEnum {} + %intEnum MyIntEnum {} + """); + var hovers = getHovers(twp); + assertThat(hovers, contains( + containsString("Service Reference"), + containsString("Operation Reference"), + containsString("Resource Reference"), + containsString("List Reference"), + containsString("Map Reference"), + containsString("Structure Reference"), + containsString("Union Reference"), + containsString("binary data"), + containsString("Timestamp Reference"), + containsString("Document Reference"), + containsString("Enum Reference"), + containsString("IntEnum Reference") + )); + } + + @Test + public void builtinMemberHover() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + service MyService { + %version: "" + %operations: [] + %resources: [] + %errors: [] + %rename: {} + } + + operation MyOperation { + %input := {} + %output := {} + %errors: [] + } + + operation NoInlineOperation { + %input: Foo + %output: Foo + %errors: [] + } + + resource MyResource { + %identifiers: {} + %properties: {} + %create: {} + %put: {} + %read: {} + %update: {} + %delete: {} + %list: {} + %operations: [] + %collectionOperations: [] + %resources: [] + } + """); + var hovers = getHovers(twp); + assertThat(hovers, contains( + containsString("optional version"), + containsString("operation shapes"), + containsString("resource shapes"), + containsString("common errors"), + containsString("Disambiguates"), + containsString("input of the operation"), + containsString("output of the operation"), + containsString("errors that an operation"), + containsString("input of the operation"), + containsString("output of the operation"), + containsString("errors that an operation"), + containsString("map of identifier"), + containsString("map of property"), + containsString("create a resource"), + containsString("idempotent"), + containsString("retrieve the resource"), + containsString("update the resource"), + containsString("delete the resource"), + containsString("list resources"), + containsString("instance operations"), + containsString("collection operations"), + containsString("child resource") + )); + } + + @Test + public void nonShapeKeywordHover() { + var twp = TextWithPositions.from(""" + $version: "2" + + %metadata foo = "foo" + + %namespace com.foo + + %use com.foo#Foo + + %apply Foo @bar + + structure Foo %for Bar {} + + structure Baz %with [Foo] {} + """); + var hovers = getHovers(twp); + + assertThat(hovers, contains( + containsString("schema-less"), + containsString("A namespace is"), + containsString("The use section"), + containsString("Applies a trait"), + containsString("Allows referencing"), + containsString("Mixes in") + )); + } + + @Test + public void builtinsHoverIncludeInheritedDocs() { + var twp = TextWithPositions.from(""" + $version: "2" + + metadata validators = [ + { + %name: "" + } + ] + + namespace com.foo + + operation MyOperation { + %input := {} + } + """); + var hovers = getHovers(twp); + + assertThat(hovers, contains( + containsString("Validators Reference"), + containsString("Operation Reference") + )); + } + + @Test + public void builtinHoverDoesntClobberUserDocs() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + list Foo { + /// One + %member: String + } + + map Bar { + /// Two + %key: String + + /// Three + %value: String + } + + structure Baz { + /// Four + /// Five + %baz: String + } + """); + var hovers = getHovers(twp); + + assertThat(hovers, contains( + containsString("One"), + containsString("Two"), + containsString("Three"), + containsString("Four") + )); + } + + @Test + public void idRefMemberTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + @idRef + id: String + } + + @foo(id: %Bar) + string Bar + """); + var hovers = getHovers(text); + + assertThat(hovers, containsInAnyOrder( + containsString("string Bar") + )); + } + + private static List getHovers(TextWithPositions text) { + return getHovers(text.text(), text.positions()); + } + + private static List getHovers(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + SmithyFile smithyFile = (SmithyFile) project.getProjectFile(uri); + + List hover = new ArrayList<>(); + HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile); + for (Position position : positions) { + HoverParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildHover(); + hover.add(handler.handle(params).getContents().getRight().getValue()); + } + + return hover; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java new file mode 100644 index 00000000..7d2d3d21 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/InlayHintHandlerTest.java @@ -0,0 +1,407 @@ +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.LspMatchers; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectTest; + + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class InlayHintHandlerTest { + @Test + public void inlayHintForInlineOperationWithCustomizedSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + $operationInputSuffix: "Request" + $operationOutputSuffix: "Response" + + namespace smithy.example + + operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(2)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserRequest", positions[0]), + LspMatchers.inlayHint("GetUserResponse", positions[1]) + )); + + } + + @Test + public void inlayHintForInlineOperationWithoutCustomizedSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + + operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(2)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[0]), + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + @Test + public void inlayHintForInputInlineOperation() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + + operation GetUser { + input :=% { + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[0]) + )); + } + + @Test + public void inlayHintForInputInlineOperationWithMismatchedSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + $operationOutputSuffix: "Response" + + operation GetUser { + input :=% { + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[0]) + )); + } + + @Test + public void inlayHintForOperationWithoutInlineMemberDef() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + namespace smithy.example + $operationOutputSuffix: "Response" + + operation GetUser { + } + % + """); + var positions = model.positions(); + Position startPosition = new Position(0,0); + Position endPosition = positions[0]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(0)); + } + + @Test + public void inlayHintForInlineOperationOffRangeSuffix() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + $operationInputSuffix: "Request" + $operationOutputSuffix: "Response" + + namespace smithy.example + + %operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[3]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(2)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserRequest", positions[1]), + LspMatchers.inlayHint("GetUserResponse", positions[2]) + )); + } + + @Test + public void inlayHintForInlineOperationPartiallyInRange() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + $operationInputSuffix: "Request" + $operationOutputSuffix: "Response" + + namespace smithy.example + + %operation GetUser { + input :=% { + userId: String + } + % + output := { + username: String + userId: String + } + } + """); + var positions = model.positions(); + Position startPosition =positions[0]; + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserRequest", positions[1]) + )); + } + + @Test + public void inlayHintForInlineOperationNotInRange() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + operation GetUser { + input := { + userId: String + } + + % output :=% { + username: String + userId: String + } + } + % + """); + var positions = model.positions(); + Position startPosition =positions[0]; + Position endPosition =positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + @Test + public void inlayHintForInlineOperationInOneLine() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + operation GetUser { + input := { + userId: String + } + + % output :=% { + username: String + userId: String + } + } + + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + @Test + public void inlayHintForMultipleInlineOperations() { + TextWithPositions model = TextWithPositions.from(""" + %$version: "2" + + operation GetUser { + input :=% { + userId: String + } + + output :=% { + username: String + userId: String + } + } + + operation GetAddress { + input :=% { + userId: String + } + + output :=% { + address: String + } + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[5]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(4)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserInput", positions[1]), + LspMatchers.inlayHint("GetUserOutput", positions[2]), + LspMatchers.inlayHint("GetAddressInput", positions[3]), + LspMatchers.inlayHint("GetAddressOutput", positions[4]) + )); + } + + @Test + public void inlayHintForMultipleInlineOperationWithLimitedRange() { + TextWithPositions model = TextWithPositions.from(""" + $version: "2" + + operation GetUser { + input := { + userId: String + } + + output := { + username: String + userId: String + } + } + structure foo{ + id: String + } + + %operation GetAddress { + input :=% { + userId: String + } + + output := { + address: String + } + } + + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetAddressInput", positions[1]) + )); + } + + @Test + public void inlayHintsForInlineOperationWithMixinAndFor() { + TextWithPositions model = TextWithPositions.from(""" + %$version: "2" + + operation GetUser { + output :=% for foo with [bar] { + @required + check: Boolean + } + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[2]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertEquals("GetUserOutput", hints.get(0).getLabel().getLeft()); + assertEquals(positions[1], hints.get(0).getPosition()); + + } + + @Test + public void inlayHintsForInvalidInlineOperation() { + TextWithPositions model = TextWithPositions.from(""" + %$version: "2" + + operation GetUser { + output :=% test + } + % + """); + var positions = model.positions(); + Position startPosition = positions[0]; + Position endPosition = positions[1]; + Listhints = getInlayHints(model.text(), startPosition, endPosition); + assertThat(hints, hasSize(1)); + assertThat(hints, contains( + LspMatchers.inlayHint("GetUserOutput", positions[1]) + )); + } + + private static List getInlayHints(String text, Position startPosition, Position endPosition) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + var handler = new InlayHintHandler(idlFile.document(), + idlFile.getParse().statements(), + new Range(startPosition, endPosition)); + return handler.handle(); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java new file mode 100644 index 00000000..670a6014 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/ReferencesHandlerTest.java @@ -0,0 +1,329 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.everyItem; +import static software.amazon.smithy.lsp.LspMatchers.isLocationIncluding; +import static software.amazon.smithy.lsp.UtilMatchers.throwsWithMessage; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.ReferenceParams; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; + +public class ReferencesHandlerTest { + @Test + public void shapeDef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string %Foo + + structure Bar { + foo: %Foo + } + + resource Baz { + identifiers: { + foo: %Foo + } + properties: { + foo: %Foo + } + put: %Foo + } + + service Bux { + operations: [%Foo] + rename: { + "%com.foo#Foo": "Renamed" + } + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void traitId() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure %myTrait { + ref: ShapeId + } + + @idRef + string ShapeId + + @%myTrait + string Foo + + @%myTrait(ref: %myTrait) + string Bar + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void idRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + ref: %ShapeId + } + + @idRef + string %ShapeId + + @myTrait(ref: %ShapeId) + string Foo + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void stringIdRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: "%com.foo#Foo") + string %Foo + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void mapIdRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + map myTrait { + @idRef + key: String + + @idRef + value: String + } + + @myTrait( + "%com.foo#Foo": %Foo + "%com.foo#Foo$foo": %Foo$foo + ) + structure %Foo { + foo: %Foo + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void rootShapeReferencesIncludeIdsWithMembers() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %Foo$foo) + structure %Foo { + foo: String + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void inlineIoReferences() { + // No refs on the actual inline shape def. It isn't named in the text, so + // don't consider it a ref. + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %OpInput) + operation Op { + input := {} + } + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void serviceRenameReferences() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + service Foo { + version: "1" + rename: { + "%com.foo#Bar": "Baz" + } + } + + @myTrait(ref: %Bar) + structure %Bar {} + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void referencesInNodeMembers() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: %Bar + } + + resource Foo { + identifiers: { + id: %Bar + } + } + + @myTrait(ref: %Bar) + string %Bar + """); + var result = getLocations(twp); + + assertHasAllLocations(result, twp.positions()); + } + + @Test + public void unsupportedReferences() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @mixin + @myTrait(ref: Foo$%foo) + structure Foo { + %foo: String + } + + operation Op { + %input := { + %foo: String + } + %output: OpOutput + %errors: [] + } + + structure OpOutput with [Foo] { + %$foo + } + """); + var workspace = TestWorkspace.singleModel(twp.text()); + var project = ProjectTest.load(workspace.getRoot()); + var uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + for (var position : twp.positions()) { + assertThat(() -> ReferencesHandler.Config.create(project, idlFile, position), + throwsWithMessage(containsString("not supported"))); + } + } + + private static void assertHasAllLocations(GetLocationsResult result, Position... positions) { + String uri = result.workspace.getUri("main.smithy"); + List> matchers = new ArrayList<>(); + for (Position position : positions) { + matchers.add(isLocationIncluding(uri, position)); + } + assertThat(result.locations, everyItem(containsInAnyOrder(matchers))); + } + + private record GetLocationsResult(TestWorkspace workspace, List> locations) {} + + private static GetLocationsResult getLocations(TextWithPositions twp) { + TestWorkspace workspace = TestWorkspace.singleModel(twp.text()); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + + List> locations = new ArrayList<>(); + ReferencesHandler handler = new ReferencesHandler(project, idlFile); + for (Position position : twp.positions()) { + ReferenceParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildReference(); + var result = handler.handle(params); + locations.add(new ArrayList<>(result)); + } + + return new GetLocationsResult(workspace, locations); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java new file mode 100644 index 00000000..d665299b --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/RenameHandlerTest.java @@ -0,0 +1,1016 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static software.amazon.smithy.lsp.UtilMatchers.stringEquals; +import static software.amazon.smithy.lsp.UtilMatchers.throwsWithMessage; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; + +public class RenameHandlerTest { + @Test + public void renamesRootShapesInTheSameFile() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string %Foo + + @myTrait(ref: %Foo) + structure Bar { + foo: %Foo + } + """); + var result = getEdits("Baz", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string Baz + + @myTrait(ref: Baz) + structure Bar { + foo: Baz + } + """); + } + + @Test + public void renamesAbsoluteIds() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string %Foo + + @myTrait(ref: com.foo#%Foo) + structure Bar { + foo: com.foo#%Foo + } + """); + var result = getEdits("Baz", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + string Baz + + @myTrait(ref: com.foo#Baz) + structure Bar { + foo: com.foo#Baz + } + """); + } + + @Test + public void renamesRootShapeAbsoluteIdsWithMember() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %com.foo#Foo$foo) + structure %Foo { + foo: String + } + """); + var result = getEdits("Bar", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: com.foo#Bar$foo) + structure Bar { + foo: String + } + """); + } + + @Test + public void multiFileSameNamespace() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @trait + structure myTrait { + @idRef + ref: String + } + + string %Foo + + @myTrait(ref: com.foo#%Foo) + structure Bar { + foo: %Foo + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @myTrait(ref: com.foo#%Foo) + structure Abc { + foo: %Foo + } + """); + var result = getEdits("Baz", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edited0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edited0, stringEquals(""" + $version: "2" + namespace com.foo + @trait + structure myTrait { + @idRef + ref: String + } + + string Baz + + @myTrait(ref: com.foo#Baz) + structure Bar { + foo: Baz + } + """)); + var edited1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edited1, stringEquals(""" + $version: "2" + namespace com.foo + + @myTrait(ref: com.foo#Baz) + structure Abc { + foo: Baz + } + """)); + } + } + + @Test + public void differentNamespaces() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + + structure Bar { + foo: %Foo + } + """); + var result = getEdits("Baz", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edited0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edited0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Baz {} + """)); + + var edited1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edited1, stringEquals(""" + $version: "2" + namespace com.bar + + use com.foo#Baz + + structure Bar { + foo: Baz + } + """)); + } + } + + @Test + public void localConflicts() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + + structure Bar { + foo: %Foo + } + + string Baz + """); + var result = getEdits("Baz", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edited0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edited0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Baz {} + """)); + + var edited1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + // Note: Formatter can take care of cleaning this up. + assertThat(edited1, stringEquals(""" + $version: "2" + namespace com.bar + + + + structure Bar { + foo: com.foo#Baz + } + + string Baz + """)); + } + } + + @Test + public void importConflicts() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + use com.baz#Baz + + structure Bar { + foo: %Foo + baz: Baz + } + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.baz + + structure Baz {} + """); + var result = getEdits("Baz", twp1, twp2, twp3); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Baz {} + """)); + var edit1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edit1, stringEquals(""" + $version: "2" + namespace com.bar + + + use com.baz#Baz + + structure Bar { + foo: com.foo#Baz + baz: Baz + } + """)); + + String uri = result.workspace.getUri("model-2.smithy"); + var unrelatedEdit = workspaceEdit.getChanges().get(uri); + assertThat(unrelatedEdit, nullValue()); + } + } + + @Test + public void importConflictsInDefinitionFile() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure %Foo { + foo: %Foo + bar: Bar + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + structure Bar {} + """); + var result = getEdits("Bar", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + structure Bar { + foo: Bar + bar: com.bar#Bar + } + """)); + + String uri = result.workspace.getUri("model-1.smithy"); + var unrelatedEdit = workspaceEdit.getChanges().get(uri); + assertThat(unrelatedEdit, nullValue()); + } + } + + @Test + public void importConflictsInSameNamespaceFile() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + structure Bar {} + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure Baz { + foo: %Foo + bar: Bar + } + """); + var result = getEdits("Bar", twp1, twp2, twp3); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + structure Bar {} + """)); + + String uri = result.workspace.getUri("model-1.smithy"); + var unrelatedEdit = workspaceEdit.getChanges().get(uri); + assertThat(unrelatedEdit, nullValue()); + + var edit2 = getEditedText(result.workspace, workspaceEdit, "model-2.smithy"); + assertThat(edit2, stringEquals(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure Baz { + foo: com.foo#Bar + bar: Bar + } + """)); + } + } + + @Test + public void importConflictsAcrossFiles() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure %Foo {} + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#%Foo + + structure Bar { + foo: %Foo + } + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure A { + foo: %Foo + bar: Bar + } + """); + var twp4 = TextWithPositions.from(""" + $version: "2" + namespace com.baz + + use com.foo#%Foo + use com.bar#Bar + + structure B { + foo: %Foo + bar: Bar + } + """); + var result = getEdits("Bar", twp1, twp2, twp3, twp4); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3, twp4); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + structure Bar {} + """)); + + var edit1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edit1, stringEquals(""" + $version: "2" + namespace com.bar + + + + structure Bar { + foo: com.foo#Bar + } + """)); + + var edit2 = getEditedText(result.workspace, workspaceEdit, "model-2.smithy"); + assertThat(edit2, stringEquals(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + structure A { + foo: com.foo#Bar + bar: Bar + } + """)); + + var edit3 = getEditedText(result.workspace, workspaceEdit, "model-3.smithy"); + assertThat(edit3, stringEquals(""" + $version: "2" + namespace com.baz + + + use com.bar#Bar + + structure B { + foo: com.foo#Bar + bar: Bar + } + """)); + } + } + + @Test + public void importConflictsInTraitsSimple() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + @trait + structure myTrait { + @idRef + ref: String + } + + structure %Foo { + @myTrait(ref: Bar$bar) + bar: Bar + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#myTrait + + structure Bar { + bar: Bar + } + """); + var result = getEdits("Bar", twp1, twp2); + + assertHasEditsForAllPositions(result, twp1, twp2); + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + @trait + structure myTrait { + @idRef + ref: String + } + + structure Bar { + @myTrait(ref: com.bar#Bar$bar) + bar: com.bar#Bar + } + """)); + } + } + + @Test + public void importConflictsInTraits() { + var twp1 = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + use com.bar#Bar + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: Bar) + structure %Foo { + @myTrait(ref: com.bar#Bar) + bar: Bar + + @myTrait(ref: com.bar#Bar$bar) + bar2: com.bar#Bar + + @myTrait(ref: Bar$bar) + foo: %Foo + + @myTrait(ref: %Foo$bar) + foo2: com.foo#%Foo + + @myTrait(ref: com.foo#%Foo$bar) + foo3: %Foo + } + """); + var twp2 = TextWithPositions.from(""" + $version: "2" + namespace com.bar + + use com.foo#myTrait + use com.foo#%Foo + + @myTrait(ref: %Foo) + structure Bar { + @myTrait(ref: com.foo#%Foo) + foo: %Foo + + @myTrait(ref: com.foo#%Foo$bar) + foo2: com.foo#%Foo + + @myTrait(ref: Foo$bar) + foo3: %Foo + + bar: Bar + } + """); + var twp3 = TextWithPositions.from(""" + $version: "2" + namespace com.baz + + use com.foo#myTrait + use com.foo#%Foo + use com.bar#Bar + + @myTrait(ref: %Foo) + structure Baz { + @myTrait(ref: com.foo#%Foo) + foo: %Foo + + @myTrait(ref: com.foo#%Foo$bar) + bar: Bar + + @myTrait(ref: %Foo$bar) + foo2: com.foo#%Foo + } + """); + var result = getEdits("Bar", twp1, twp2, twp3); + + assertHasEditsForAllPositions(result, twp1, twp2, twp3); + + for (var workspaceEdit : result.edits) { + var edit0 = getEditedText(result.workspace, workspaceEdit, "model-0.smithy"); + assertThat(edit0, stringEquals(""" + $version: "2" + namespace com.foo + + + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: com.bar#Bar) + structure Bar { + @myTrait(ref: com.bar#Bar) + bar: com.bar#Bar + + @myTrait(ref: com.bar#Bar$bar) + bar2: com.bar#Bar + + @myTrait(ref: com.bar#Bar$bar) + foo: Bar + + @myTrait(ref: Bar$bar) + foo2: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + foo3: Bar + } + """)); + + var edit1 = getEditedText(result.workspace, workspaceEdit, "model-1.smithy"); + assertThat(edit1, stringEquals(""" + $version: "2" + namespace com.bar + + use com.foo#myTrait + + + @myTrait(ref: com.foo#Bar) + structure Bar { + @myTrait(ref: com.foo#Bar) + foo: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + foo2: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + foo3: com.foo#Bar + + bar: Bar + } + """)); + + var edit2 = getEditedText(result.workspace, workspaceEdit, "model-2.smithy"); + assertThat(edit2, stringEquals(""" + $version: "2" + namespace com.baz + + use com.foo#myTrait + + use com.bar#Bar + + @myTrait(ref: com.foo#Bar) + structure Baz { + @myTrait(ref: com.foo#Bar) + foo: com.foo#Bar + + @myTrait(ref: com.foo#Bar$bar) + bar: Bar + + @myTrait(ref: com.foo#Bar$bar) + foo2: com.foo#Bar + } + """)); + } + } + + @Test + public void multipleEditsOnSameLine() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + + @idRef + ref2: String + } + + @myTrait(ref: %Foo, ref2: %Foo) + structure %Foo { + foo: %Foo + } + """); + var result = getEdits("A", twp); + + assertHasEditsForAllPositions(result, twp); + + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + + @idRef + ref2: String + } + + @myTrait(ref: A, ref2: A) + structure A { + foo: A + } + """); + } + + @Test + public void stringIdRef() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: "%com.foo#Foo") + structure %Foo { + @myTrait(ref: "%com.foo#Foo$foo") + foo: String + } + """); + var result = getEdits("Bar", twp); + + assertHasEditsForAllPositions(result, twp); + assertAllEditsMake(result, """ + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: "com.foo#Bar") + structure Bar { + @myTrait(ref: "com.foo#Bar$foo") + foo: String + } + """); + } + + @Test + public void prepare() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure myTrait { + @idRef + ref: String + } + + @myTrait(ref: %com.foo#%Foo%$foo) + structure %Foo% { + @myTrait(ref: %Foo%$foo) + foo: %Foo% + } + + @myTrait(ref: %Foo%) + string Bar + """); + var onNs = twp.positions()[0]; + var onAbsNameWithMember = twp.positions()[1]; + var onAbsNameWithMemberEnd = twp.positions()[2]; + var onDef = twp.positions()[3]; + var onDefEnd = twp.positions()[4]; + var onRelNameWithMember = twp.positions()[5]; + var onRelNameWithMemberEnd = twp.positions()[6]; + var onTarget = twp.positions()[7]; + var onTargetEnd = twp.positions()[8]; + var onRelName = twp.positions()[9]; + var onRelNameEnd = twp.positions()[10]; + + TestWorkspace workspace = TestWorkspace.singleModel(twp.text()); + Project project = ProjectTest.load(workspace.getRoot()); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + RenameHandler handler = new RenameHandler(project, idlFile); + + var rangeOnNs = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onNs).buildPrepareRename()); + var rangeOnAbsNameWithMember = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onAbsNameWithMember).buildPrepareRename()); + var rangeOnDef = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onDef).buildPrepareRename()); + var rangeOnRelNameWithMember = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onRelNameWithMember).buildPrepareRename()); + var rangeOnTarget = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onTarget).buildPrepareRename()); + var rangeOnRelName = handler.prepare(RequestBuilders.positionRequest() + .uri(uri).position(onRelName).buildPrepareRename()); + + assertThat(rangeOnNs, equalTo(new Range(onAbsNameWithMember, onAbsNameWithMemberEnd))); + assertThat(rangeOnAbsNameWithMember, equalTo(new Range(onAbsNameWithMember, onAbsNameWithMemberEnd))); + assertThat(rangeOnDef, equalTo(new Range(onDef, onDefEnd))); + assertThat(rangeOnRelNameWithMember, equalTo(new Range(onRelNameWithMember, onRelNameWithMemberEnd))); + assertThat(rangeOnTarget, equalTo(new Range(onTarget, onTargetEnd))); + assertThat(rangeOnRelName, equalTo(new Range(onRelName, onRelNameEnd))); + } + + @Test + public void invalidShapeId() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string %Foo + """); + + assertThat(() -> getEdits("123", twp), throwsWithMessage(containsString("id would be invalid"))); + } + + @Test + public void referenceInJar() { + var twp = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + structure Foo { + foo: %String + } + """); + + assertThat(() -> getEdits("Bar", twp), throwsWithMessage(containsString("jar"))); + } + + private static void assertHasEditsForAllPositions(GetEditsResult result, TextWithPositions... twps) { + int sum = 0; + for (TextWithPositions twp : twps) { + sum += twp.positions().length; + } + assertThat(result.edits, hasSize(sum)); + } + + private static void assertAllEditsMake(GetEditsResult result, String expected) { + assertThat(result.edits, not(empty())); + + for (var edit : result.edits) { + var editedText = getEditedText(result.workspace, edit, "model-0.smithy"); + assertThat(editedText, stringEquals(expected)); + } + } + + private static String getEditedText(TestWorkspace workspace, WorkspaceEdit edit, String filename) { + String uri = workspace.getUri(filename); + var textEdits = edit.getChanges().get(uri); + assertThat(textEdits, notNullValue()); + assertThat(textEdits, not(empty())); + + String text = workspace.readFile(filename); + var document = Document.of(text); + // Edits have to be applied in reverse order so that an edit earlier in the + // file doesn't clobber the range a later edit would occupy. + textEdits.sort((l, r) -> { + int lIdx = document.indexOfPosition(l.getRange().getStart()); + int rIdx = document.indexOfPosition(r.getRange().getStart()); + return Integer.compare(rIdx, lIdx); + }); + + for (var textEdit : textEdits) { + var s = document.indexOfPosition(textEdit.getRange().getStart()); + var e = document.indexOfPosition(textEdit.getRange().getEnd()); + var span = document.copySpan(s, e); + document.applyEdit(textEdit.getRange(), textEdit.getNewText()); + var tmp = document.copyText(); + System.out.println(); + } + return document.copyText(); + } + + private record GetEditsResult(TestWorkspace workspace, List edits) {} + + private static GetEditsResult getEdits(String newName, TextWithPositions... twps) { + var files = Arrays.stream(twps).map(TextWithPositions::text).toArray(String[]::new); + TestWorkspace workspace = TestWorkspace.multipleModels(files); + Project project = ProjectTest.load(workspace.getRoot()); + List edits = new ArrayList<>(); + for (int i = 0; i < twps.length; i++) { + String uri = workspace.getUri("model-" + i + ".smithy"); + IdlFile idlFile = (IdlFile) project.getProjectFile(uri); + RenameHandler handler = new RenameHandler(project, idlFile); + for (Position position : twps[i].positions()) { + var params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildRename(newName); + var edit = handler.handle(params); + edits.add(edit); + } + } + return new GetEditsResult(workspace, edits); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java deleted file mode 100644 index 7e0d9f62..00000000 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.hasSize; -import static software.amazon.smithy.lsp.project.ProjectTest.toPath; - -import java.nio.file.Path; -import java.util.List; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.build.model.MavenConfig; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.lsp.util.Result; - -public class ProjectConfigLoaderTest { - @Test - public void loadsConfigWithEnvVariable() { - System.setProperty("FOO", "bar"); - Path root = toPath(getClass().getResource("env-config")); - Result> result = ProjectConfigLoader.loadFromRoot(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.maven().isPresent(), is(true)); - MavenConfig mavenConfig = config.maven().get(); - assertThat(mavenConfig.getRepositories(), hasSize(1)); - MavenRepository repository = mavenConfig.getRepositories().stream().findFirst().get(); - assertThat(repository.getUrl(), containsString("example.com/maven/my_repo")); - assertThat(repository.getHttpCredentials().isPresent(), is(true)); - assertThat(repository.getHttpCredentials().get(), containsString("my_user:bar")); - } - - @Test - public void loadsLegacyConfig() { - Path root = toPath(getClass().getResource("legacy-config")); - Result> result = ProjectConfigLoader.loadFromRoot(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.maven().isPresent(), is(true)); - MavenConfig mavenConfig = config.maven().get(); - assertThat(mavenConfig.getDependencies(), containsInAnyOrder("baz")); - assertThat(mavenConfig.getRepositories().stream() - .map(MavenRepository::getUrl) - .collect(Collectors.toList()), containsInAnyOrder("foo", "bar")); - } - - @Test - public void prefersNonLegacyConfig() { - Path root = toPath(getClass().getResource("legacy-config-with-conflicts")); - Result> result = ProjectConfigLoader.loadFromRoot(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.maven().isPresent(), is(true)); - MavenConfig mavenConfig = config.maven().get(); - assertThat(mavenConfig.getDependencies(), containsInAnyOrder("dep1", "dep2")); - assertThat(mavenConfig.getRepositories().stream() - .map(MavenRepository::getUrl) - .collect(Collectors.toList()), containsInAnyOrder("url1", "url2")); - } - - @Test - public void mergesBuildExts() { - Path root = toPath(getClass().getResource("build-exts")); - Result> result = ProjectConfigLoader.loadFromRoot(root); - - assertThat(result.isOk(), is(true)); - ProjectConfig config = result.unwrap(); - assertThat(config.imports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java new file mode 100644 index 00000000..2ff26946 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigTest.java @@ -0,0 +1,294 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithSourceLocation; +import static software.amazon.smithy.lsp.project.ProjectTest.toPath; +import static software.amazon.smithy.lsp.protocol.LspAdapter.toSourceLocation; + +import java.nio.file.Path; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.MavenRepository; +import software.amazon.smithy.cli.dependencies.DependencyResolver; +import software.amazon.smithy.cli.dependencies.DependencyResolverException; +import software.amazon.smithy.cli.dependencies.ResolvedArtifact; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class ProjectConfigTest { + @Test + public void loadsConfigWithEnvVariable() { + System.setProperty("FOO", "bar"); + Path root = toPath(getClass().getResource("env-config")); + ProjectConfig config = load(root).config(); + + assertThat(config.maven().getRepositories(), hasSize(1)); + MavenRepository repository = config.maven().getRepositories().stream().findFirst().get(); + assertThat(repository.getUrl(), containsString("example.com/maven/my_repo")); + assertThat(repository.getHttpCredentials().isPresent(), is(true)); + assertThat(repository.getHttpCredentials().get(), containsString("my_user:bar")); + } + + @Test + public void loadsLegacyConfig() { + Path root = toPath(getClass().getResource("legacy-config")); + ProjectConfig config = load(root).config(); + + assertThat(config.maven().getDependencies(), containsInAnyOrder("baz")); + assertThat(config.maven().getRepositories().stream() + .map(MavenRepository::getUrl) + .collect(Collectors.toList()), containsInAnyOrder("foo", "bar")); + } + + @Test + public void prefersNonLegacyConfig() { + Path root = toPath(getClass().getResource("legacy-config-with-conflicts")); + ProjectConfig config = load(root).config(); + + assertThat(config.maven().getDependencies(), containsInAnyOrder("dep1", "dep2")); + assertThat(config.maven().getRepositories().stream() + .map(MavenRepository::getUrl) + .collect(Collectors.toList()), containsInAnyOrder("url1", "url2")); + } + + @Test + public void mergesBuildExts() { + Path root = toPath(getClass().getResource("build-exts")); + ProjectConfig config = load(root).config(); + + assertThat(config.imports(), containsInAnyOrder(containsString("main.smithy"), containsString("other.smithy"))); + assertThat(config.maven().getDependencies(), containsInAnyOrder("foo")); + } + + @Test + public void handlesEmptyFiles() { + var root = Path.of("foo"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, ""); + var result = load(root, buildFiles); + + var smithyBuild = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(smithyBuild, notNullValue()); + assertThat(result.events(), containsInAnyOrder(allOf( + eventWithId(equalTo("Model")), + eventWithMessage(containsString("Error parsing JSON")), + eventWithSourceLocation(equalTo(LspAdapter.toSourceLocation(smithyBuild.path(), new Position(0, 0)))) + ))); + } + + @Test + public void validatesSmithyBuildJson() { + var text = TextWithPositions.from(""" + { + "version" : %1, // Should be a string + "sources": ["foo"] + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + )); + assertThat(result.config().sources(), empty()); + } + + @Test + public void validatesSmithyProjectJson() { + var text = TextWithPositions.from(""" + { + "sources": ["foo"], + "dependencies": [ + %"foo" // Should be an object + ] + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_PROJECT, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_PROJECT); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + )); + assertThat(result.config().sources(), empty()); + } + + @Test + public void validatesMavenConfig() { + // "httpCredentials" is invalid, but we don't get the source location in the exception + var text = TextWithPositions.from(""" + %{ + "version" : "1", + "sources": ["foo"], + "maven": { + "repositories": [ + { + "url": "foo", + "httpCredentials": "bar" + } + ] + } + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder(allOf( + eventWithMessage(containsString("httpCredentials")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + ))); + assertThat(result.config().sources(), empty()); + } + + @Test + public void resolveValidatesFilesExist() { + var text = TextWithPositions.from(""" + { + "sources": [%"foo"], + "imports": [%"bar"], + "dependencies": [ + { + "name": "baz", + "path": %"baz" + } + ] + } + """); + var notFoundSourcePosition = text.positions()[0]; + var notFoundImportPosition = text.positions()[1]; + var notFoundDepPosition = text.positions()[2]; + var root = Path.of("test"); + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_PROJECT, text.text()); + var result = load(root, buildFiles); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_PROJECT); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + allOf( + eventWithId(equalTo("FileNotFound")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), notFoundSourcePosition))) + ), + allOf( + eventWithId(equalTo("FileNotFound")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), notFoundImportPosition))) + ), + allOf( + eventWithId(equalTo("FileNotFound")), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), notFoundDepPosition))) + ) + )); + assertThat(result.config().sources(), containsInAnyOrder(equalTo("foo"))); + assertThat(result.config().imports(), containsInAnyOrder(equalTo("bar"))); + assertThat(result.config().projectDependencies().getFirst().path(), equalTo("baz")); + } + + @Test + public void resolveValidatesMavenDependencies() { + var text = TextWithPositions.from(""" + { + "version": "1", + %"maven": { + "dependencies": ["foo"], + "repositories": [ + { + "url": "bar" + } + ] + } + } + """); + var eventPosition = text.positions()[0]; + var root = Path.of("test"); + Supplier resolverFactory = () -> new DependencyResolver() { + @Override + public void addRepository(MavenRepository mavenRepository) { + throw new DependencyResolverException("repo " + mavenRepository.getUrl()); + } + + @Override + public void addDependency(String s) { + throw new DependencyResolverException("dep " + s); + } + + @Override + public List resolve() { + throw new DependencyResolverException("call resolve"); + } + }; + var buildFiles = createBuildFiles(root, BuildFileType.SMITHY_BUILD, text.text()); + var result = ProjectConfigLoader.load(root, buildFiles, resolverFactory); + + var buildFile = buildFiles.getByType(BuildFileType.SMITHY_BUILD); + assertThat(buildFile, notNullValue()); + assertThat(result.events(), containsInAnyOrder( + allOf( + eventWithId(equalTo("DependencyResolver")), + eventWithMessage(allOf( + containsString("repo bar"), + containsString("dep foo"), + containsString("call resolve") + )), + eventWithSourceLocation(equalTo(toSourceLocation(buildFile.path(), eventPosition))) + ) + )); + } + + private record NoOpResolver() implements DependencyResolver { + @Override + public void addRepository(MavenRepository mavenRepository) { + } + + @Override + public void addDependency(String s) { + } + + @Override + public List resolve() { + return List.of(); + } + } + + private static BuildFiles createBuildFiles(Path root, BuildFileType type, String content) { + var buildFile = BuildFile.create(root.resolve(type.filename()).toString(), Document.of(content), type); + return BuildFiles.of(List.of(buildFile)); + } + + private static ProjectConfigLoader.Result load(Path root, BuildFiles buildFiles) { + return ProjectConfigLoader.load(root, buildFiles, NoOpResolver::new); + } + + private static ProjectConfigLoader.Result load(Path root) { + var buildFiles = BuildFiles.load(root, new ServerState()); + return ProjectConfigLoader.load(root, buildFiles, NoOpResolver::new); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java deleted file mode 100644 index fe9a2c50..00000000 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectFilePatternsTest.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import static org.hamcrest.MatcherAssert.assertThat; - -import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.HashSet; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.build.model.SmithyBuildConfig; -import software.amazon.smithy.lsp.TestWorkspace; -import software.amazon.smithy.lsp.UtilMatchers; -import software.amazon.smithy.utils.ListUtils; - -public class ProjectFilePatternsTest { - @Test - public void createsPathMatchers() { - TestWorkspace workspace = TestWorkspace.builder() - .withSourceDir(new TestWorkspace.Dir() - .withPath("foo") - .withSourceDir(new TestWorkspace.Dir() - .withPath("bar") - .withSourceFile("bar.smithy", "") - .withSourceFile("baz.smithy", "")) - .withSourceFile("baz.smithy", "")) - .withSourceDir(new TestWorkspace.Dir() - .withPath("other") - .withSourceFile("other.smithy", "")) - .withSourceFile("abc.smithy", "") - .withConfig(SmithyBuildConfig.builder() - .version("1") - .sources(ListUtils.of("foo", "other/", "abc.smithy")) - .build()) - .build(); - - Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); - PathMatcher smithyMatcher = ProjectFilePatterns.getSmithyFilesPathMatcher(project); - PathMatcher buildMatcher = ProjectFilePatterns.getBuildFilesPathMatcher(project); - - Path root = project.root(); - assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("abc.smithy"))); - assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("foo/bar/baz.smithy"))); - assertThat(smithyMatcher, UtilMatchers.canMatchPath(root.resolve("other/bar.smithy"))); - assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve("smithy-build.json"))); - assertThat(buildMatcher, UtilMatchers.canMatchPath(root.resolve(".smithy-project.json"))); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java new file mode 100644 index 00000000..0e240790 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectLoaderTest.java @@ -0,0 +1,230 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.validation.Severity; + +public class ProjectLoaderTest { + @Test + public void loadsFlatProject() { + Path root = ProjectTest.toPath(getClass().getResource("flat")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.config().imports(), empty()); + assertThat(project.config().resolvedDependencies(), empty()); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsProjectWithMavenDep() { + Path root = ProjectTest.toPath(getClass().getResource("maven-dep")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.config().imports(), empty()); + assertThat(project.config().resolvedDependencies(), hasSize(3)); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsProjectWithSubdir() { + Path root = ProjectTest.toPath(getClass().getResource("subdirs")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItems( + endsWith("model"), + endsWith("model2"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( + equalTo(root.resolve("model/main.smithy").toString()), + equalTo(root.resolve("model/subdir/sub.smithy").toString()), + equalTo(root.resolve("model2/subdir2/sub2.smithy").toString()), + equalTo(root.resolve("model2/subdir2/subsubdir/subsub.smithy").toString()))); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Baz")); + } + + @Test + public void loadsModelWithUnknownTrait() { + Path root = ProjectTest.toPath(getClass().getResource("unknown-trait")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.modelResult().isBroken(), is(false)); // unknown traits don't break it + assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(containsString("UnresolvedTrait")))); + assertThat(project.modelResult().getResult().isPresent(), is(true)); + assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void loadsWhenModelHasInvalidSyntax() { + Path root = ProjectTest.toPath(getClass().getResource("invalid-syntax")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.config().sources(), hasItem(endsWith("main.smithy"))); + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getValidationEvents(), hasItem(eventWithId(equalTo("Model")))); + assertThat(project.modelResult(), hasValue(allOf( + hasShapeWithId("com.foo#Foo"), + hasShapeWithId("com.foo#Foo$bar")))); + assertThat(project.getAllSmithyFilePaths(), hasItem(containsString("main.smithy"))); + } + + @Test + public void loadsProjectWithMultipleNamespaces() { + Path root = ProjectTest.toPath(getClass().getResource("multiple-namespaces")); + Project project = ProjectTest.load(root); + + assertThat(project.config().sources(), hasItem(endsWith("model"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + assertThat(project.getAllSmithyFilePaths(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); + + assertThat(project.modelResult(), hasValue(allOf( + hasShapeWithId("a#Hello"), + hasShapeWithId("a#HelloInput"), + hasShapeWithId("a#HelloOutput"), + hasShapeWithId("b#Hello"), + hasShapeWithId("b#HelloInput"), + hasShapeWithId("b#HelloOutput")))); + } + + @Test + public void loadsProjectWithExternalJars() { + Path root = ProjectTest.toPath(getClass().getResource("external-jars")); + Project project = ProjectTest.load(root); + + assertThat(project.config().sources(), containsInAnyOrder( + endsWith("test-traits.smithy"), + endsWith("test-validators.smithy"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( + containsString("test-traits.smithy"), + containsString("test-validators.smithy"), + // Note: Depending on the order of how jar dependencies are added to the model assembler, + // this may or may not be present. This is because we're relying on the shapes loaded in + // the model to determine all Smithy files, and this file re-defines a shape, so the shape + // definition is super-seeded. + // containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"), + containsString("alloy-core.jar!/META-INF/smithy/uuid.smithy"))); + + assertThat(project.modelResult().isBroken(), is(true)); + assertThat(project.modelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); + + assertThat(project.modelResult().getResult().isPresent(), is(true)); + Model model = project.modelResult().getResult().get(); + assertThat(model, hasShapeWithId("smithy.test#test")); + assertThat(model, hasShapeWithId("ns.test#Weather")); + assertThat(model.expectShape(ShapeId.from("ns.test#Weather")).hasTrait("smithy.test#test"), is(true)); + } + + @Test + public void loadsProjectWithInvalidSmithyBuildJson() { + Path root = ProjectTest.toPath(getClass().getResource("broken/missing-version")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents(), hasItem(eventWithMessage(containsString("version")))); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithUnparseableSmithyBuildJson() { + Path root = ProjectTest.toPath(getClass().getResource("broken/parse-failure")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents().isEmpty(), is(false)); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithNonExistingSource() { + Path root = ProjectTest.toPath(getClass().getResource("broken/source-doesnt-exist")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents(), hasItem(eventWithId(equalTo("FileNotFound")))); + assertThat(project.modelResult().isBroken(), is(false)); + assertThat(project.getAllSmithyFiles().size(), equalTo(1)); // still have the prelude + } + + @Test + public void loadsProjectWithUnresolvableMavenDependency() { + Path root = ProjectTest.toPath(getClass().getResource("broken/unresolvable-maven-dependency")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_BUILD); + assertThat(project.configEvents(), hasItem(eventWithId(equalTo("DependencyResolver")))); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithUnresolvableProjectDependency() { + Path root = ProjectTest.toPath(getClass().getResource("broken/unresolvable-project-dependency")); + Project project = ProjectTest.load(root); + + assertHasBuildFile(project, BuildFileType.SMITHY_PROJECT); + assertThat(project.configEvents(), hasItem(eventWithId(equalTo("FileNotFound")))); + assertThat(project.modelResult().isBroken(), is(false)); + } + + @Test + public void loadsProjectWithUnNormalizedDirs() throws Exception { + Path root = ProjectTest.toPath(getClass().getResource("unnormalized-dirs")); + Project project = ProjectTest.load(root); + + assertThat(project.root(), equalTo(root)); + assertThat(project.sources(), hasItems( + root.resolve("model"), + root.resolve("model2"))); + assertThat(project.imports(), hasItem(root.resolve("model3"))); + assertThat(project.getAllSmithyFilePaths(), hasItems( + equalTo(root.resolve("model/test-traits.smithy").toString()), + equalTo(root.resolve("model/one.smithy").toString()), + equalTo(root.resolve("model2/two.smithy").toString()), + equalTo(root.resolve("model3/three.smithy").toString()), + containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"))); + assertThat(project.config().resolvedDependencies(), hasItem( + root.resolve("smithy-test-traits.jar").toUri().toURL())); + } + + private static void assertHasBuildFile(Project project, BuildFileType expectedType) { + String uri = LspAdapter.toUri(project.root().resolve(expectedType.filename()).toString()); + var file = project.getProjectFile(uri); + assertThat(file, instanceOf(BuildFile.class)); + assertThat(((BuildFile) file).type(), is(expectedType)); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java deleted file mode 100644 index 4446ea5b..00000000 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.lsp.project; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -import java.nio.file.Path; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.lsp.protocol.LspAdapter; - -public class ProjectManagerTest { - @Test - public void canCheckIfAFileIsTracked() { - Path attachedRoot = ProjectTest.toPath(getClass().getResource("flat")); - Project mainProject = ProjectLoader.load(attachedRoot).unwrap(); - - ProjectManager manager = new ProjectManager(); - manager.updateProjectByName("main", mainProject); - - String detachedUri = LspAdapter.toUri("/foo/bar"); - manager.createDetachedProject(detachedUri, ""); - - String mainUri = LspAdapter.toUri(attachedRoot.resolve("main.smithy").toString()); - - assertThat(manager.isTracked(mainUri), is(true)); - assertThat(manager.getProject(mainUri), notNullValue()); - assertThat(manager.getProject(mainUri).getSmithyFile(mainUri), notNullValue()); - - assertThat(manager.isTracked(detachedUri), is(true)); - assertThat(manager.getProject(detachedUri), notNullValue()); - assertThat(manager.getProject(detachedUri).getSmithyFile(detachedUri), notNullValue()); - - String untrackedUri = LspAdapter.toUri("/bar/baz.smithy"); - assertThat(manager.isTracked(untrackedUri), is(false)); - assertThat(manager.getProject(untrackedUri), nullValue()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java index 21790ba4..80fb2b4a 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -5,262 +5,38 @@ package software.amazon.smithy.lsp.project; -import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.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.UtilMatchers.anOptionalOf; -import static software.amazon.smithy.lsp.document.DocumentTest.string; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashMap; import java.util.List; -import java.util.logging.Logger; -import java.util.stream.Collectors; +import java.util.Map; import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.SmithyMatchers; import software.amazon.smithy.lsp.TestWorkspace; import software.amazon.smithy.lsp.document.Document; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StringShape; import software.amazon.smithy.model.traits.LengthTrait; import software.amazon.smithy.model.traits.PatternTrait; import software.amazon.smithy.model.traits.TagsTrait; -import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; +import software.amazon.smithy.model.validation.ValidatedResult; public class ProjectTest { - @Test - public void loadsFlatProject() { - Path root = toPath(getClass().getResource("flat")); - Project project = ProjectLoader.load(root).unwrap(); - - 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 - public void loadsProjectWithMavenDep() { - Path root = toPath(getClass().getResource("maven-dep")); - Project project = ProjectLoader.load(root).unwrap(); - - 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 - public void loadsProjectWithSubdir() { - Path root = toPath(getClass().getResource("subdirs")); - Project project = ProjectLoader.load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItems( - root.resolve("model"), - root.resolve("model2"))); - 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.modelResult().isBroken(), is(false)); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); - assertThat(project.modelResult().unwrap(), hasShapeWithId("com.foo#Baz")); - } - - @Test - public void loadsModelWithUnknownTrait() { - Path root = toPath(getClass().getResource("unknown-trait")); - Project project = ProjectLoader.load(root).unwrap(); - - 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.modelResult().getValidationEvents().stream() - .map(ValidationEvent::getId) - .collect(Collectors.toList()); - assertThat(eventIds, hasItem(containsString("UnresolvedTrait"))); - assertThat(project.modelResult().getResult().isPresent(), is(true)); - assertThat(project.modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); - } - - @Test - public void loadsWhenModelHasInvalidSyntax() { - Path root = toPath(getClass().getResource("invalid-syntax")); - Project project = ProjectLoader.load(root).unwrap(); - - 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.smithyFiles().keySet(), hasItem(containsString("main.smithy"))); - 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")); - assertThat(main.imports(), empty()); - - 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.documentShapes(), hasSize(3)); - List documentShapeNames = main.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(documentShapeNames, hasItems("Foo", "bar", "String")); - } - - @Test - public void loadsProjectWithMultipleNamespaces() { - Path root = toPath(getClass().getResource("multiple-namespaces")); - Project project = ProjectLoader.load(root).unwrap(); - - 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(LspAdapter.toUri(root.resolve("model/a.smithy").toString())); - assertThat(a.document(), not(nullValue())); - assertThat(a.namespace(), string("a")); - List aShapeIds = a.shapes().stream() - .map(Shape::toShapeId) - .map(ShapeId::toString) - .collect(Collectors.toList()); - assertThat(aShapeIds, hasItems("a#Hello", "a#HelloInput", "a#HelloOutput")); - List aDocumentShapeNames = a.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(aDocumentShapeNames, hasItems("Hello", "name", "String")); - - SmithyFile b = project.getSmithyFile(LspAdapter.toUri(root.resolve("model/b.smithy").toString())); - 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.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(bDocumentShapeNames, hasItems("Hello", "name", "String")); - } - - @Test - public void loadsProjectWithExternalJars() { - Path root = toPath(getClass().getResource("external-jars")); - Result> result = ProjectLoader.load(root); - - assertThat(result.isOk(), is(true)); - Project project = result.unwrap(); - assertThat(project.sources(), containsInAnyOrder(root.resolve("test-traits.smithy"), root.resolve("test-validators.smithy"))); - assertThat(project.smithyFiles().keySet(), hasItems( - containsString("test-traits.smithy"), - containsString("test-validators.smithy"), - containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"), - containsString("alloy-core.jar!/META-INF/smithy/uuid.smithy"))); - - assertThat(project.modelResult().isBroken(), is(true)); - assertThat(project.modelResult().getValidationEvents(Severity.ERROR), hasItem(eventWithMessage(containsString("Proto index 1")))); - - assertThat(project.modelResult().getResult().isPresent(), is(true)); - Model model = project.modelResult().getResult().get(); - assertThat(model, hasShapeWithId("smithy.test#test")); - assertThat(model, hasShapeWithId("ns.test#Weather")); - assertThat(model.expectShape(ShapeId.from("ns.test#Weather")).hasTrait("smithy.test#test"), is(true)); - } - - @Test - public void failsLoadingInvalidSmithyBuildJson() { - Path root = toPath(getClass().getResource("broken/missing-version")); - Result> result = ProjectLoader.load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void failsLoadingUnparseableSmithyBuildJson() { - Path root = toPath(getClass().getResource("broken/parse-failure")); - Result> result = ProjectLoader.load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void doesntFailLoadingProjectWithNonExistingSource() { - Path root = toPath(getClass().getResource("broken/source-doesnt-exist")); - Result> result = ProjectLoader.load(root); - - assertThat(result.isErr(), is(false)); - assertThat(result.unwrap().smithyFiles().size(), equalTo(1)); // still have the prelude - } - - - @Test - public void failsLoadingUnresolvableMavenDependency() { - Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); - Result> result = ProjectLoader.load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void failsLoadingUnresolvableProjectDependency() { - Path root = toPath(getClass().getResource("broken/unresolvable-maven-dependency")); - Result> result = ProjectLoader.load(root); - - assertThat(result.isErr(), is(true)); - } - - @Test - public void loadsProjectWithUnNormalizedDirs() { - Path root = toPath(getClass().getResource("unnormalized-dirs")); - Project project = ProjectLoader.load(root).unwrap(); - - assertThat(project.root(), equalTo(root)); - assertThat(project.sources(), hasItems( - root.resolve("model"), - root.resolve("model2"))); - assertThat(project.imports(), hasItem(root.resolve("model3"))); - assertThat(project.smithyFiles().keySet(), hasItems( - equalTo(root.resolve("model/test-traits.smithy").toString()), - equalTo(root.resolve("model/one.smithy").toString()), - equalTo(root.resolve("model2/two.smithy").toString()), - equalTo(root.resolve("model3/three.smithy").toString()), - containsString("smithy-test-traits.jar!/META-INF/smithy/smithy.test.json"))); - assertThat(project.dependencies(), hasItem(root.resolve("smithy-test-traits.jar"))); - } - @Test public void changeFileApplyingSimpleTrait() { String m1 = """ @@ -275,14 +51,14 @@ public void changeFileApplyingSimpleTrait() { string Bar """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("length"), is(true)); assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -306,14 +82,14 @@ public void changeFileApplyingListTrait() { string Bar """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -343,7 +119,7 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { apply Baz @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); Shape baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); @@ -353,7 +129,7 @@ public void changeFileApplyingListTraitWithUnrelatedDependencies() { assertThat(baz.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -385,7 +161,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { apply Bar @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); @@ -394,7 +170,7 @@ public void changingFileApplyingListTraitWithRelatedDependencies() { assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -425,14 +201,14 @@ public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { apply Bar @tags(["bar"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "bar")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -456,14 +232,14 @@ public void changingFileWithDependencies() { apply Foo @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("length"), is(true)); assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -487,14 +263,14 @@ public void changingFileWithArrayDependencies() { apply Foo @tags(["foo"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -519,14 +295,14 @@ public void changingFileWithMixedArrayDependencies() { apply Foo @tags(["foo"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); assertThat(foo.hasTrait("tags"), is(true)); assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "foo")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -555,7 +331,7 @@ public void changingFileWithArrayDependenciesWithDependencies() { apply Bar @length(min: 1) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); @@ -565,18 +341,7 @@ public void changingFileWithArrayDependenciesWithDependencies() { assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); - if (document == null) { - String smithyFilesPaths = String.join(System.lineSeparator(), project.smithyFiles().keySet()); - String smithyFilesUris = project.smithyFiles().keySet().stream() - .map(LspAdapter::toUri) - .collect(Collectors.joining(System.lineSeparator())); - Logger logger = Logger.getLogger(getClass().getName()); - logger.severe("Not found uri: " + uri); - logger.severe("Not found path: " + LspAdapter.toPath(uri)); - logger.severe("PATHS: " + smithyFilesPaths); - logger.severe("URIS: " + smithyFilesUris); - } + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.point(document.end()), "\n"); project.updateModelWithoutValidating(uri); @@ -607,7 +372,7 @@ public void removingSimpleApply() { apply Bar @pattern("a") """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("pattern"), is(true)); @@ -616,7 +381,7 @@ public void removingSimpleApply() { assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); project.updateModelWithoutValidating(uri); @@ -645,14 +410,14 @@ public void removingArrayApply() { apply Bar @tags(["bar"]) """; TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); - Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + Project project = load(workspace.getRoot()); Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); assertThat(bar.hasTrait("tags"), is(true)); assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo", "bar")); String uri = workspace.getUri("model-0.smithy"); - Document document = project.getDocument(uri); + Document document = project.getProjectFile(uri).document(); document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); project.updateModelWithoutValidating(uri); @@ -662,6 +427,72 @@ public void removingArrayApply() { assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("bar")); } + @Test + public void loadsEmptyProjectWhenThereAreNoConfigFiles() throws Exception { + Path root = Files.createTempDirectory("foo"); + Project project = load(root); + + assertThat(project.type(), equalTo(Project.Type.EMPTY)); + } + + @Test + public void changingTraitWithSourceLocationNone() { + // Manually construct a Project with a model containing a trait with SourceLocation.NONE, + // since this test can't rely on any specific trait always having SourceLocation.NONE, as + // it may be fixed upstream. + Path root = Path.of("foo").toAbsolutePath(); + String fooPath = root.resolve("foo.smithy").toString(); + SmithyFile fooSmithyFile = SmithyFile.create(fooPath, Document.of(""" + $version: "2" + namespace com.foo + @length(max: 10) + string Foo + """)); + Map smithyFiles = new HashMap<>(); + smithyFiles.put(fooPath, fooSmithyFile); + Model model = Model.builder() + .addShape(StringShape.builder() + .id("com.foo#Foo") + .source(fooPath, 4, 1) + .addTrait(LengthTrait.builder() + .sourceLocation(SourceLocation.NONE) + .min(1L) + .build()) + .build()) + .build(); + ValidatedResult modelResult = ValidatedResult.fromValue(model); + Project.RebuildIndex rebuildIndex = Project.RebuildIndex.create(modelResult); + + Project project = new Project( + root, + ProjectConfig.empty(), + BuildFiles.of(List.of()), + smithyFiles, + Model::assembler, + Project.Type.DETACHED, + modelResult, + rebuildIndex, + List.of() + ); + + assertThat(project.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + + String fooUri = LspAdapter.toUri(fooPath); + project.updateModelWithoutValidating(fooUri); + + assertThat(project.modelResult(), SmithyMatchers.hasValue(SmithyMatchers.hasShapeWithId("com.foo#Foo"))); + assertThat(project.modelResult().getValidationEvents(), empty()); + } + + public static Project load(Path root) { + try { + return ProjectLoader.load(root, new ServerState()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + public static Path toPath(URL url) { try { return Paths.get(url.toURI()); diff --git a/src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java b/src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java new file mode 100644 index 00000000..e9bdf20e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ToSmithyNodeTest.java @@ -0,0 +1,105 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithSourceLocation; +import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; + +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeType; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.validation.ValidatedResult; + +public class ToSmithyNodeTest { + @ParameterizedTest + @MethodSource("differentNodeTypesProvider") + public void convertsDifferentNodeTypes(String text, NodeType expectedNodeType) { + BuildFile buildFile = BuildFile.create("foo", Document.of(text), BuildFileType.SMITHY_BUILD); + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + + assertThat(nodeResult.getResult().map(Node::getType), anOptionalOf(equalTo(expectedNodeType))); + } + + private static Stream differentNodeTypesProvider() { + return Stream.of( + Arguments.of("null", NodeType.NULL), + Arguments.of("true", NodeType.BOOLEAN), + Arguments.of("false", NodeType.BOOLEAN), + Arguments.of("0", NodeType.NUMBER), + Arguments.of("\"foo\"", NodeType.STRING), + Arguments.of("[]", NodeType.ARRAY), + Arguments.of("{}", NodeType.OBJECT) + ); + } + + @Test + public void skipsMissingElements() { + var text = """ + { + "version": , + "imports": [ + , + "foo" + ], + "projections": { + , + "bar": {} + } + } + """; + BuildFile buildFile = BuildFile.create("foo", Document.of(text), BuildFileType.SMITHY_BUILD); + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + + ObjectNode node = nodeResult.getResult().get().expectObjectNode(); + assertThat(node.getStringMap().keySet(), containsInAnyOrder("imports", "projections")); + + List imports = node.expectArrayMember("imports") + .getElementsAs(elem -> elem.expectStringNode().getValue()); + assertThat(imports, containsInAnyOrder(equalTo("foo"))); + + Set projections = node.expectObjectMember("projections") + .getStringMap() + .keySet(); + assertThat(projections, containsInAnyOrder("bar")); + } + + @Test + public void emitsValidationEventsForParseErrors() { + var twp = TextWithPositions.from(""" + { + "version": %, + "imports": [] + } + """); + Position eventPosition = twp.positions()[0]; + BuildFile buildFile = BuildFile.create("foo", Document.of(twp.text()), BuildFileType.SMITHY_BUILD); + ValidatedResult nodeResult = ToSmithyNode.toSmithyNode(buildFile); + + assertThat(nodeResult.getValidationEvents(), containsInAnyOrder(allOf( + eventWithId(equalTo("Model")), + eventWithMessage(containsString("Error parsing JSON")), + eventWithSourceLocation(equalTo(LspAdapter.toSourceLocation("foo", eventPosition))) + ))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java new file mode 100644 index 00000000..edec5b57 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -0,0 +1,1118 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; + +public class IdlParserTest { + @Test + public void parses() { + String text = """ + string Foo + @tags(["foo"]) + structure Bar { + baz: String + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef); + } + + @Test + public void parsesStatements() { + String text = """ + $version: "2" + metadata foo = [{ bar: 2 }] + namespace com.foo + + use com.bar#baz + + @baz + structure Foo { + @baz + bar: String + } + + enum Bar { + BAZ = "BAZ" + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Metadata, + Syntax.Statement.Type.Namespace, + Syntax.Statement.Type.Use, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.EnumMemberDef); + } + + @Test + public void parsesMixinsAndForResource() { + String text = """ + structure Foo with [Mix] {} + structure Bar for Resource {} + structure Baz for Resource with [Mix] {} + structure Bux with [One, Two, Three] {} + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins); + } + + @Test + public void parsesOp() { + String text = """ + operation One {} + operation Two { + input: Input + } + operation Three { + input: Input + output: Output + } + operation Four { + input: Input + errors: [Err] + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void parsesOpInline() { + String text = """ + operation One { + input := { + foo: String + } + output := { + @foo + foo: String + } + } + operation Two { + input := for Foo { + foo: String + } + output := with [Bar] { + bar: String + } + } + operation Three { + input := for Foo with [Bar, Baz] {} + } + operation Four { + input := @foo {} + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication); + } + + @Test + public void parsesOpInlineWithTraits() { + String text = safeString(""" + operation Op { + input := @foo { + foo: Foo + } + output := {} + }"""); + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef); + } + + @Test + public void parsesServiceAndResource() { + String text = """ + service Foo { + version: "2024-08-15 + operations: [ + Op1 + Op2 + ] + errors: [ + Err1 + Err2 + ] + } + resource Bar { + identifiers: { id: String } + properties: { prop: String } + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void ignoresComments() { + String text = """ + // one + $version: "2" // two + + namespace com.foo // three + // four + use com.bar#baz // five + + // six + @baz // seven + structure Foo // eight + { // nine + // ten + bar: String // eleven + } // twelve + + enum Bar // thirteen + { // fourteen + // fifteen + BAR // sixteen + } // seventeen + service Baz // eighteen + { // nineteen + // twenty + version: "" // twenty one + } // twenty two + """; + + assertTypesEqual(text, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Namespace, + Syntax.Statement.Type.Use, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.EnumMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void defaultAssignments() { + String text = """ + structure Foo { + one: One = "" + two: Two = 2 + three: Three = false + four: Four = [] + five: Five = {} + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef); + } + + @Test + public void stringKeysInTraits() { + String text = """ + @foo( + "bar": "baz" + ) + """; + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + assertThat(parse.statements(), hasSize(1)); + assertThat(parse.statements().get(0), instanceOf(Syntax.Statement.TraitApplication.class)); + + Syntax.Statement.TraitApplication traitApplication = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var nodeTypes = NodeParserTest.getNodeTypes(traitApplication.value()); + + assertThat(nodeTypes, contains( + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str)); + } + + @Test + public void goodControlWithEmptyString() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + %$operationInputSuffix: ""% + %$operationOutputSuffix: " "% + """); + Document document = Document.of(text.text()); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + var positions = text.positions(); + assertThat(statements, hasSize(3)); + + assertTypesEqual(text.text(), + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control + ); + assertThat(document.copySpan(statements.get(1).start, statements.get(1).end), equalTo( + "$operationInputSuffix: \"\"".trim())); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo( + "$operationOutputSuffix: \" \"".trim())); + } + + @Test + public void badControlWithoutColon() { + String text = """ + $version 2 + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(1)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("$version 2")); + } + + @Test + public void goodTraitWithNodeDef() { + String text = """ + @integration( + requestParameters: { + "param1": "a" + } + ) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + var trait = (Syntax.Statement.TraitApplication) statements.get(0); + assertThat(document.copySpan(trait.start, trait.end), equalTo( + (""" + @integration( + requestParameters: { + "param1": "a" + } + ) + """).trim())); + var value = (Syntax.Node.Kvps)trait.value(); + assertThat(document.copySpan(value.start, value.end).trim(), equalTo( + (""" + ( + requestParameters: { + "param1": "a" + } + """).trim())); + } + + @Test + public void goodTraitWithEmptyDef() { + String text ="@integration()"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var value = (Syntax.Node.Kvps)trait.value(); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@integration()")); + assertThat(document.copySpan(value.start, value.end), equalTo("(")); + } + + @Test + public void goodTraitWithStringKeyAndKvpsValue() { + String text = """ + @integration( + "foo" :{ + "param1": "a" + } + ) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var kvps = (Syntax.Node.Kvps)trait.value(); + assertThat(parse.statements(), hasSize(1)); + assertThat(document.copySpan(trait.start, trait.end), equalTo((""" + @integration( + "foo" :{ + "param1": "a" + } + ) + """).trim())); + assertThat(document.copySpan(kvps.start, kvps.end), equalTo((""" + ( + "foo" :{ + "param1": "a" + } + """))); + } + + @Test + public void goodTraitWithStrOnly() { + String text = "@integration(\"foo bar\")"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var str = (Syntax.Node.Str)trait.value(); + assertThat(parse.statements(), hasSize(1)); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@integration(\"foo bar\")")); + assertThat(document.copySpan(str.start, str.end), equalTo("(\"foo bar\"")); + } + + @Test + public void goodTraitWithNestedKvps() { + String text = """ + @integration({ + "abc": { + "abc": { + "abc": "abc" + }, + "def": "def" + } + }) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var trait = (Syntax.Statement.TraitApplication) parse.statements().get(0); + Syntax.Node.Obj firstObj = (Syntax.Node.Obj)trait.value(); + Syntax.Node.Kvps firstKvps = firstObj.kvps(); + assertThat(document.copySpan(firstKvps.start, firstKvps.end), equalTo((""" + { + "abc": { + "abc": { + "abc": "abc" + }, + "def": "def" + } + } + """).trim())); + Syntax.Node.Obj secondObj = (Syntax.Node.Obj)firstKvps.kvps().get(0).value(); + Syntax.Node.Kvps secondKvps = secondObj.kvps(); + assertThat(document.copySpan(secondKvps.start, secondKvps.end), equalTo((""" + { + "abc": { + "abc": "abc" + }, + "def": "def" + } + """).trim())); + Syntax.Node.Obj thirdObj = (Syntax.Node.Obj)secondKvps.kvps().get(0).value(); + Syntax.Node.Kvps thirdKvps = thirdObj.kvps(); + assertThat(document.copySpan(thirdKvps.start, thirdKvps.end), equalTo((""" + { + "abc": "abc" + } + """).trim())); + } + + @Test + public void goodTraitWithNum() { + String text = """ + @a(1) + @b(-2) + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication firstTrait = (Syntax.Statement.TraitApplication) statements.get(0); + Syntax.Statement.TraitApplication secondTrait = (Syntax.Statement.TraitApplication) statements.get(1); + var firstTraitValue = firstTrait.value(); + var secondTraitValue = secondTrait.value(); + assertThat(document.copySpan(firstTrait.start, firstTrait.end), + equalTo("@a(1)")); + assertThat(document.copySpan(firstTraitValue.start, firstTraitValue.end), + equalTo("(1")); + assertThat(document.copySpan(secondTrait.start, secondTrait.end), + equalTo("@b(-2)")); + assertThat(document.copySpan(secondTraitValue.start, secondTraitValue.end), + equalTo("(-2")); + } + + @Test + public void badInlineMemberHitEof() { + String text = """ + operation foo{ + input:= + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var errors = parse.errors(); + assertThat(errors.get(0).message(), equalTo("expected {")); + } + + @Test + public void goodTraitWithIdent() { + String text = "@a(b)"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + var traitValue = trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a(b)")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(b")); + } + + @Test + public void goodTraitWithEmptyValue() { + String text = "@a()"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + var traitValue = trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a()")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(")); + } + + @Test + public void badTraitWithInvalidNode() { + String text = "@a(?)"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + Syntax.Node.Err traitValue = (Syntax.Node.Err) trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a(?)")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(?")); + assertThat((traitValue.message), equalTo("unexpected token ?")); + } + + @Test + public void badTraitWithInvalidNodeAndUnclosed() { + String text = "@a(?"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) statements.get(0); + Syntax.Node.Err traitValue = (Syntax.Node.Err) trait.value(); + assertThat(document.copySpan(trait.start, trait.end), + equalTo("@a(?")); + assertThat(document.copySpan(traitValue.start, traitValue.end), + equalTo("(?")); + assertThat((traitValue.message), equalTo("unexpected eof")); + } + + @Test + public void badTraitWithUnclosedTextBlock() { + String text = "@a(\"\"\")"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication)statements.get(0); + Syntax.Node value = trait.value(); + assertThat(value, instanceOf(Syntax.Node.Err.class)); + } + + @Test + public void badTraitWithTextBlockKey() { + String text = "@a(\"\"\"b\"\"\":1)"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication)statements.get(0); + Syntax.Node node = trait.value(); + assertThat(node, instanceOf(Syntax.Node.Kvps.class)); + Syntax.Node.Kvps kvps = (Syntax.Node.Kvps)node; + Syntax.Node.Kvp kvp = kvps.kvps().get(0); + assertThat(kvp.key().stringValue(), equalTo("b")); + } + + @Test + public void badTraitWithNestedUnclosedKvps() { + String text = """ + @test({ + key1: { + key2: { + key3: { + key4: { + key5: { + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + Syntax.Statement.TraitApplication traitApplication = (Syntax.Statement.TraitApplication)statements.get(0); + Syntax.Node.Obj obj = (Syntax.Node.Obj) traitApplication.value(); + for (int i = 1; i <= 5; i++){ + Syntax.Node.Kvps kvps = obj.kvps(); + assertThat(kvps.kvps().get(0).key().stringValue(), equalTo("key" + i)); + obj = (Syntax.Node.Obj) kvps.kvps().get(0).value(); + } + } + + @Test + public void badMetadataWithUnclosedArr() { + String text = "metadata foo = [a,b,c"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + Syntax.Statement.Metadata metadata = (Syntax.Statement.Metadata ) parse.statements().get(0); + assertThat(document.copySpan(metadata.start, metadata.end), equalTo("metadata foo = [a,b,c")); + assertThat(document.copySpan(metadata.value.start, metadata.value.end), equalTo("[a,b,c")); + } + + @Test + public void badMetadataWithoutEqual() { + String text = """ + metadata a + metadata b + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + Syntax.Statement.Metadata metadataOne = (Syntax.Statement.Metadata ) parse.statements().get(0); + Syntax.Statement.Metadata metadataTwo = (Syntax.Statement.Metadata) parse.statements().get(1); + assertThat(document.copySpan(metadataOne.start, metadataOne.end), equalTo("metadata a")); + assertThat(document.copySpan(metadataTwo.start, metadataTwo.end), equalTo("metadata b")); + } + + @Test + public void goodApplyWithSingularTrait() { + String text = "apply foo @examples "; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + Syntax.Statement.Apply apply = (Syntax.Statement.Apply) parse.statements().get(0); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) parse.statements().get(1); + assertThat(document.copySpan(apply.start, apply.end), equalTo("apply foo")); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@examples")); + } + + @Test + public void badApplyWithMissingTraitMark() { + String text = "apply foo{bar,@buz}"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + + Syntax.Statement.Apply apply = (Syntax.Statement.Apply) parse.statements().get(0); + Syntax.Statement.TraitApplication trait = (Syntax.Statement.TraitApplication) parse.statements().get(3); + assertThat(document.copySpan(apply.start, apply.end), equalTo("apply foo")); + assertThat(document.copySpan(trait.start, trait.end), equalTo("@buz")); + } + + @Test + public void goodEnumShapeDef() { + String text = """ + enum foo { + } + intEnum bar { + @test + a = 1 + } + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(6)); + assertThat(statements.get(0), instanceOf(Syntax.Statement.ShapeDef.class)); + assertThat(statements.get(1), instanceOf(Syntax.Statement.Block.class)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("enum foo")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("intEnum bar")); + assertThat(document.copySpan(statements.get(3).start, statements.get(3).end), equalTo(""" + { + @test + a = 1 + }""")); + assertThat(document.copySpan(statements.get(4).start, statements.get(4).end), equalTo("@test")); + assertThat(document.copySpan(statements.get(5).start, statements.get(5).end), equalTo("a = 1")); + } + + @Test + public void goodStructListMapUnion() { + String text = """ + structure a { + } + list b { + } + map c { + } + union d { + } + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(8)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("structure a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("list b")); + assertThat(document.copySpan(statements.get(4).start, statements.get(4).end), equalTo("map c")); + assertThat(document.copySpan(statements.get(6).start, statements.get(6).end), equalTo("union d")); + } + + @Test + public void goodResourceService() { + String text = """ + resource a { + } + service b { + } + """; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(4)); + assertThat(statements.get(0), instanceOf(Syntax.Statement.ShapeDef.class)); + assertThat(statements.get(1), instanceOf(Syntax.Statement.Block.class)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("resource a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("service b")); + } + + @Test + public void goodElideMember() { + String text = "structure a {foo:$bar}"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(statements, hasSize(4)); + assertThat(statements.get(0), instanceOf(Syntax.Statement.ShapeDef.class)); + assertThat(statements.get(1), instanceOf(Syntax.Statement.Block.class)); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("structure a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("foo:")); + assertThat(document.copySpan(statements.get(3).start, statements.get(3).end), equalTo("$bar")); + } + + @Test + public void badTraitWithArrWithoutLeftBrace() { + String text = "@test(a,b,c])"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("@test(a,")); + assertThat(document.copySpan(statements.get(1).start, statements.get(1).end), equalTo("b")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("c")); + } + + @Test + public void badStructureWithUseStatement() { + String text = "structure a {use abc}"; + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + var statements = parse.statements(); + assertThat(document.copySpan(statements.get(0).start, statements.get(0).end), equalTo("structure a")); + assertThat(document.copySpan(statements.get(2).start, statements.get(2).end), equalTo("use abc")); + } + + @Test + public void traitApplicationsDontContainTrailingWhitespace() { + var twp = TextWithPositions.from(""" + %@foo(foo: "")% + structure Foo { + foo: Foo + } + """); + Document document = Document.of(twp.text()); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + + assertThat(getTypes(parse), contains( + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef)); + + Syntax.Statement traitApplication = parse.statements().get(0); + assertThat(document.positionAtIndex(traitApplication.start()), equalTo(twp.positions()[0])); + assertThat(document.positionAtIndex(traitApplication.end()), equalTo(twp.positions()[1])); + } + + @ParameterizedTest + @MethodSource("brokenProvider") + public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); + List types = parse.statements().stream() + .map(Syntax.Statement::type) + .toList(); + + assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); + assertThat(desc, types, equalTo(expectedTypes)); + } + + record InvalidSyntaxTestCase( + String description, + String text, + List expectedErrorMessages, + List expectedTypes + ) {} + + private static final List INVALID_SYNTAX_TEST_CASES = List.of( + new InvalidSyntaxTestCase( + "empty", + "", + List.of(), + List.of() + ), + new InvalidSyntaxTestCase( + "just shape type", + "structure", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "missing resource", + "string Foo for", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.ForResource) + ), + new InvalidSyntaxTestCase( + "unexpected line break", + "string \nstring Foo", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete, Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "unexpected token", + "string [", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "unexpected token 2", + "string Foo [", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "enum missing {", + "enum Foo\nBAR}", + List.of("expected {"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.EnumMemberDef) + ), + new InvalidSyntaxTestCase( + "enum missing }", + "enum Foo {BAR", + List.of("expected }"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.EnumMemberDef) + ), + new InvalidSyntaxTestCase( + "enum using invalid value", + "enum Foo {?}", + List.of("unexpected token ? expected trait or member", "expected member or trait"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "regular shape missing {", + "structure Foo\nbar: String}", + List.of("expected {"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing }", + "structure Foo {bar: String", + List.of("expected }"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing :", + "structure Foo {bar String}", + List.of("expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing assignment", + """ + structure Foo { + foo + bar + } + """, + List.of("expected :","expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.MemberDef, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape with invalid member", + "structure Foo {?}", + List.of("unexpected token ? expected trait or member", "expected member or trait"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "op with inline missing {", + "operation Foo\ninput := {}}", + List.of("expected {"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "op with inline missing }", + "operation Foo{input:={}", + List.of("expected }"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "node shape with missing {", + "resource Foo\nidentifiers:{}}", + List.of("expected {"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing }", + "service Foo{operations:[]", + List.of("expected }"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing :", + "service Foo{operations {}}", + List.of("expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing node", + "service Foo{bar:}", + List.of("expected node"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape missing assignment", + """ + service Foo{ + a + b + } + """, + List.of("expected :", "expected :"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.Block, Syntax.Statement.Type.NodeMemberDef, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "apply missing @", + "apply Foo", + List.of("expected trait or block"), + List.of(Syntax.Statement.Type.Apply) + ), + new InvalidSyntaxTestCase( + "apply missing trait in block", + "apply Foo {@bar,buz}", + List.of("expected trait", "expected identifier"), + List.of(Syntax.Statement.Type.Apply, Syntax.Statement.Type.Block, Syntax.Statement.Type.TraitApplication, Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "apply missing }", + "apply Foo {@bar", + List.of("expected }"), + List.of( + Syntax.Statement.Type.Apply, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.TraitApplication) + ), + new InvalidSyntaxTestCase( + "trait missing member value", + "@foo(bar: )\nstring Foo", + List.of("expected value"), + List.of(Syntax.Statement.Type.TraitApplication, Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "inline with member missing target", + """ + operation Op { + input := + @tags([]) + { + foo:\s + } + } + """, + List.of("expected identifier"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "invalid mixin identifier", + """ + structure Foo with [123] {} + """, + List.of("expected identifier"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "mixin missing []", + """ + structure Foo with abc {} + """, + List.of("expected [", "expected ]"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "invalid mixin identifier missing []", + """ + structure Foo with 123, abc {} + """, + List.of("expected [", "expected identifier", "expected ]"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.Block) + ), + new InvalidSyntaxTestCase( + "operation using member value without :", + """ + operation Op { + input + output + } + """, + List.of("expected :", "expected :"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "trait use unexpected key", + """ + @integration(String: + """, + List.of("unexpected token "), + List.of( + Syntax.Statement.Type.TraitApplication) + ), + new InvalidSyntaxTestCase( + "control without value", + """ + $version + $operationInputSuffix "Request" + $operationInputSuffix: "Request" + """, + List.of("expected :", "expected :"), + List.of( + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Control) + ), + new InvalidSyntaxTestCase( + "metadata without equal", + """ + metadata bar + structure foo{} + """, + List.of("expected ="), + List.of( + Syntax.Statement.Type.Metadata, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Block) + ) + ); + + private static Stream brokenProvider() { + return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of( + invalidSyntaxTestCase.description, + invalidSyntaxTestCase.text, + invalidSyntaxTestCase.expectedErrorMessages, + invalidSyntaxTestCase.expectedTypes)); + } + + private static void assertTypesEqual(String text, Syntax.Statement.Type... types) { + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + var actualTypes = getTypes(parse); + assertThat(actualTypes, contains(types)); + } + + private static List getTypes(Syntax.IdlParseResult parse) { + return parse.statements().stream() + .map(Syntax.Statement::type) + .filter(type -> type != Syntax.Statement.Type.Block) + .toList(); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java new file mode 100644 index 00000000..9fd2bb1f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeCursorTest.java @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.document.Document; + +public class NodeCursorTest { + @Test + public void findsNodeCursor() { + String text = safeString(""" + { + "foo": "bar" + }"""); + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); + + assertCursorMatches(cursor, new NodeCursor(List.of( + new NodeCursor.Obj(null), + new NodeCursor.Key("foo", null), + new NodeCursor.Terminal(null) + ))); + } + + @Test + public void findsNodeCursorWhenBroken() { + String text = safeString(""" + { + "foo" + }"""); + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); + + assertCursorMatches(cursor, new NodeCursor(List.of( + new NodeCursor.Obj(null), + new NodeCursor.Key("foo", null), + new NodeCursor.Terminal(null) + ))); + } + + private static void assertCursorMatches(NodeCursor actual, NodeCursor expected) { + if (!actual.toString().equals(expected.toString())) { + fail("Expected cursor to match:\n" + expected + "\nbut was:\n" + actual); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java new file mode 100644 index 00000000..70641049 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java @@ -0,0 +1,499 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.syntax; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.fail; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.document.Document; + +public class NodeParserTest { + @Test + public void goodEmptyObj() { + String text = "{}"; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps); + } + + @Test + public void goodEmptyObjWithWs() { + String text = "{ }"; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps); + } + + @Test + public void goodObjSingleKey() { + String text = """ + {"abc": "def"}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodObjMultiKey() { + String text = """ + {"abc": "def", "ghi": "jkl"}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodNestedObjs() { + String text = """ + {"abc": {"abc": {"abc": "abc"}, "def": "def"}}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + @Test + public void goodObjSingleKeyWithTrailingComma() { + String text = """ + {"a":{"abc": "def"} , }"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodEmptyArr() { + String text = "[]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr); + } + + @Test + public void goodEmptyArrWithWs() { + String text = "[ ]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr); + } + + @Test + public void goodSingleElemArr() { + String text = "[1]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num); + } + + @Test + public void goodMultiElemArr() { + String text = """ + [1, 2, "3"]"""; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str); + } + + @Test + public void goodNestedArr() { + String text = """ + [[1, [1, 2], []] 3]"""; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num); + } + + @ParameterizedTest + @MethodSource("goodStringsProvider") + public void goodStrings(String text, String expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + if (value instanceof Syntax.Node.Str s) { + String actualValue = s.stringValue(); + if (!expectedValue.equals(actualValue)) { + fail(String.format("expected text of %s to be parsed as a string with value %s, but was %s", + text, expectedValue, actualValue)); + } + } else { + fail(String.format("expected text of %s to be parsed as a string, but was %s", + text, value.type())); + } + } + + private static Stream goodStringsProvider() { + return Stream.of( + Arguments.of("\"foo\"", "foo"), + Arguments.of("\"\"", "") + ); + } + + @ParameterizedTest + @MethodSource("goodIdentsProvider") + public void goodIdents(String text, String expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + if (value instanceof Syntax.Ident ident) { + String actualValue = ident.stringValue(); + if (!expectedValue.equals(actualValue)) { + fail(String.format("expected text of %s to be parsed as an ident with value %s, but was %s", + text, expectedValue, actualValue)); + } + } else { + fail(String.format("expected text of %s to be parsed as an ident, but was %s", + text, value.type())); + } + } + + private static Stream goodIdentsProvider() { + return Stream.of( + Arguments.of("true", "true"), + Arguments.of("false", "false"), + Arguments.of("null", "null") + ); + } + + @ParameterizedTest + @MethodSource("goodNumbersProvider") + public void goodNumbers(String text, BigDecimal expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + + if (value instanceof Syntax.Node.Num num) { + if (!expectedValue.equals(num.value)) { + fail(String.format("Expected text of %s to be parsed as a number with value %s, but was %s", + text, expectedValue, num.value)); + } + } else { + fail(String.format("Expected text of %s to be parsed as a number but was %s", + text, value.type())); + } + } + + private static Stream goodNumbersProvider() { + return Stream.of( + Arguments.of("-10", BigDecimal.valueOf(-10)), + Arguments.of("0", BigDecimal.valueOf(0)), + Arguments.of("123", BigDecimal.valueOf(123)) + ); + } + + @ParameterizedTest + @MethodSource("brokenProvider") + public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { + Syntax.NodeParseResult parse = Syntax.parseNode(Document.of(text)); + List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); + List types = getNodeTypes(parse.value()); + + assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); + assertThat(desc, types, equalTo(expectedTypes)); + } + + record InvalidSyntaxTestCase( + String description, + String text, + List expectedErrorMessages, + List expectedTypes + ) {} + + private static final List INVALID_SYNTAX_TEST_CASES = List.of( + new InvalidSyntaxTestCase( + "invalid element token", + "[1, 2}]", + List.of("unexpected token }"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed empty", + "[", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr) + ), + new InvalidSyntaxTestCase( + "unclosed", + "[1,", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed with sp", + "[1, ", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem", + "[1,a", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem and sp", + "[1,a ", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem no ,", + "[a 2", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Ident, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed in member", + "{foo: [1, 2}", + List.of("unexpected token }", "missing ]", "missing }"), + List.of( + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Ident, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "Non-string key with no value", + "{1}", + List.of("unexpected Num", "expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Non-string key with : but no value", + "{1:}", + List.of("unexpected Num", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "String key with no value", + "{\"1\"}", + List.of("expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no value", + "{\"1\":}", + List.of("expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with no value but a trailing ,", + "{\"1\",}", + List.of("expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no value and a trailing ,", + "{\"1\":,}", + List.of("expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no }", + "{\"1\": abc, \"2\": def", + List.of("missing }"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, Syntax.Node.Type.Ident, Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "Invalid key", + "{\"abc}", + List.of("unexpected eof", "missing }"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Missing :", + "{\"abc\" 1}", + List.of("expected :"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "Missing key in obj", + "{,}", + List.of(), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Missing colon and unexpected value in obj", + "{foo ?}", + List.of("expected :","unexpected token ?"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "Unclosed text block", + """ + \"\"\"abc + """, + List.of(), + List.of(Syntax.Node.Type.Err) + ), + new InvalidSyntaxTestCase( + "Invalid number", + """ + 123? + """, + List.of(), + List.of(Syntax.Node.Type.Err) + ) + ); + + private static Stream brokenProvider() { + return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of( + invalidSyntaxTestCase.description, + invalidSyntaxTestCase.text, + invalidSyntaxTestCase.expectedErrorMessages, + invalidSyntaxTestCase.expectedTypes)); + } + + @Test + public void parsesStringsWithEscapes() { + String text = """ + "a\\"b" + """; + assertTypesEqual(text, + Syntax.Node.Type.Str); + } + + @Test + public void parsesTextBlocks() { + String text = "[\"\"\"foo\"\"\", 2, \"bar\", 3, \"\", 4, \"\"\"\"\"\"]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str); + } + + @Test + public void stringValues() { + Syntax.Node node = Syntax.parseNode(Document.of(""" + [ + "abc", + "", + \""" + foo + \""" + ] + """)).value(); + + assertThat(node, instanceOf(Syntax.Node.Arr.class)); + Syntax.Node.Arr arr = (Syntax.Node.Arr) node; + assertThat(arr.elements(), hasSize(3)); + + Syntax.Node first = arr.elements().get(0); + assertThat(first, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) first).stringValue(), equalTo("abc")); + + Syntax.Node second = arr.elements().get(1); + assertThat(second, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) second).stringValue(), equalTo("")); + + Syntax.Node third = arr.elements().get(2); + assertThat(third, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) third).stringValue().trim(), equalTo("foo")); + } + + @Test + public void badKvpWithTrailingIncompleteKvp() { + String text = "{\"foo\":bar, \"buz\"}"; + Document document = Document.of(text); + Syntax.Node.Obj node = (Syntax.Node.Obj)Syntax.parseNode(document).value(); + Syntax.Node.Kvps kvps = node.kvps(); + assertThat(document.copySpan(kvps.start, kvps.end), equalTo("{\"foo\":bar, \"buz\"}")); + Syntax.Node.Kvp first = kvps.kvps().get(0); + assertThat(document.copySpan(first.start, first.end), equalTo("\"foo\":bar")); + Syntax.Node.Kvp second = kvps.kvps().get(1); + assertThat(document.copySpan(second.start, second.end), equalTo("\"buz\"")); + } + + @Test + public void badKvpWithLeadingComma() { + String text = "{,\"foo\":bar}"; + Document document = Document.of(text); + Syntax.Node.Obj node = (Syntax.Node.Obj)Syntax.parseNode(document).value(); + Syntax.Node.Kvps kvps = node.kvps(); + assertThat(document.copySpan(kvps.start, kvps.end), equalTo("{,\"foo\":bar}")); + Syntax.Node.Kvp first = kvps.kvps().get(0); + assertThat(document.copySpan(first.start, first.end), equalTo("\"foo\":bar")); + } + + @Test + public void badArrWithLeadingComma() { + String text = "[,a,"; + Document document = Document.of(text); + Syntax.Node.Arr arr = (Syntax.Node.Arr)Syntax.parseNode(document).value(); + assertThat(arr.elements(), hasSize(1)); + assertThat(document.copySpan(arr.elements.get(0).start, arr.elements.get(0).end), equalTo("a")); + } + + @Test + public void badArrWithInvalidNum() { + String text = "[456?,123]"; + Document document = Document.of(text); + Syntax.Node.Arr arr = (Syntax.Node.Arr)Syntax.parseNode(document).value(); + assertThat(arr.elements(), hasSize(1)); + assertThat(document.copySpan(arr.elements.get(0).start, arr.elements.get(0).end), equalTo("123")); + } + + private static void assertTypesEqual(String text, Syntax.Node.Type... types) { + assertThat(getNodeTypes(Syntax.parseNode(Document.of(text)).value()), contains(types)); + } + + static List getNodeTypes(Syntax.Node value) { + List types = new ArrayList<>(); + value.consume(v -> types.add(v.type())); + return types; + } +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json new file mode 100644 index 00000000..166f52e0 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/build/smithy-dependencies.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "mavenDependencies": ["foo"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-a/smithy-build.json new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/multi-nested/nested-b/.smithy-project.json new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/nested/nested/smithy-build.json new file mode 100644 index 00000000..e69de29b