diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0758b7cc..1a32c55d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: runs-on: ${{ matrix.os }} name: Java ${{ matrix.java }} ${{ matrix.os }} strategy: + fail-fast: false matrix: java: [8, 11, 17] os: [ubuntu-latest, windows-latest, macos-latest] diff --git a/VERSION b/VERSION index abd41058..0d91a54c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.4 +0.3.0 diff --git a/build.gradle b/build.gradle index 9965cfdc..927cdb28 100644 --- a/build.gradle +++ b/build.gradle @@ -156,21 +156,27 @@ publishing { dependencies { - implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.14.0" + implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.20.0" implementation "software.amazon.smithy:smithy-build:[smithyVersion, 2.0[" implementation "software.amazon.smithy:smithy-cli:[smithyVersion, 2.0[" implementation "software.amazon.smithy:smithy-model:[smithyVersion, 2.0[" - implementation 'com.disneystreaming.smithy:smithytranslate-formatter-jvm-java-api:0.3.10' + implementation "software.amazon.smithy:smithy-syntax:[smithyVersion, 2.0[" - // Use JUnit test framework - testImplementation "junit:junit:4.13" + + testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.0" + testImplementation "org.junit.jupiter:junit-jupiter-params:5.10.0" + testImplementation "org.hamcrest:hamcrest:2.1" + + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0" } tasks.withType(Javadoc).all { options.addStringOption('Xdoclint:none', '-quiet') } -tasks.withType(Test) { +tasks.withType(Test).configureEach { + useJUnitPlatform() + testLogging { events TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR exceptionFormat TestExceptionFormat.FULL @@ -180,7 +186,8 @@ tasks.withType(Test) { } } -task createProperties(dependsOn: processResources) { +tasks.register('createProperties') { + dependsOn processResources doLast { new File("$buildDir/resources/main/version.properties").withWriter { w -> Properties p = new Properties() @@ -202,7 +209,9 @@ application { // ==== CheckStyle ==== // https://docs.gradle.org/current/userguide/checkstyle_plugin.html apply plugin: "checkstyle" -tasks["checkstyleTest"].enabled = false +tasks.named("checkstyleTest") { + enabled = false +} java { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -213,6 +222,7 @@ jar { from (configurations.compileClasspath.collect { entry -> zipTree(entry) }) { exclude "about.html" exclude "META-INF/LICENSE" + exclude "META-INF/LICENSE.txt" exclude "META-INF/NOTICE" exclude "META-INF/MANIFEST.MF" exclude "META-INF/*.SF" diff --git a/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java new file mode 100644 index 00000000..ba9c33f7 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/DocumentLifecycleManager.java @@ -0,0 +1,73 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.HashMap; +import java.util.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. + */ +final class DocumentLifecycleManager { + private static final Logger LOGGER = Logger.getLogger(DocumentLifecycleManager.class.getName()); + private final Map> tasks = new HashMap<>(); + private final Set managedDocumentUris = new HashSet<>(); + + Set managedDocuments() { + return managedDocumentUris; + } + + boolean isManaged(String uri) { + return managedDocuments().contains(uri); + } + + CompletableFuture getTask(String uri) { + return tasks.get(uri); + } + + void cancelTask(String uri) { + if (tasks.containsKey(uri)) { + CompletableFuture task = tasks.get(uri); + if (!task.isDone()) { + task.cancel(true); + tasks.remove(uri); + } + } + } + + void putTask(String uri, CompletableFuture future) { + tasks.put(uri, future); + } + + void putOrComposeTask(String uri, CompletableFuture future) { + if (tasks.containsKey(uri)) { + tasks.computeIfPresent(uri, (k, v) -> v.thenCompose((unused) -> future)); + } else { + tasks.put(uri, future); + } + } + + void cancelAllTasks() { + for (CompletableFuture task : tasks.values()) { + task.cancel(true); + } + tasks.clear(); + } + + void waitForAllTasks() throws ExecutionException, InterruptedException { + for (CompletableFuture task : tasks.values()) { + if (!task.isDone()) { + task.get(); + } + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/Main.java b/src/main/java/software/amazon/smithy/lsp/Main.java index 5a7bf9f4..87add549 100644 --- a/src/main/java/software/amazon/smithy/lsp/Main.java +++ b/src/main/java/software/amazon/smithy/lsp/Main.java @@ -15,6 +15,7 @@ package software.amazon.smithy.lsp; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; @@ -39,7 +40,11 @@ private Main() { */ public static Optional launch(InputStream in, OutputStream out) { SmithyLanguageServer server = new SmithyLanguageServer(); - Launcher launcher = LSPLauncher.createServerLauncher(server, in, out); + Launcher launcher = LSPLauncher.createServerLauncher( + server, + exitOnClose(in), + out); + LanguageClient client = launcher.getRemoteProxy(); server.connect(client); @@ -51,6 +56,19 @@ public static Optional launch(InputStream in, OutputStream out) { } } + 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; + } + }; + } + /** * @param args Arguments passed to launch server. First argument must either be * a port number for socket connection, or 0 to use STDIN and STDOUT diff --git a/src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java b/src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java deleted file mode 100644 index fc738b12..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ProtocolAdapter.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import java.util.Optional; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.SymbolKind; -import software.amazon.smithy.model.shapes.ShapeType; -import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; - -public final class ProtocolAdapter { - private ProtocolAdapter() { - - } - - /** - * @param event ValidationEvent to be converted to a Diagnostic. - * @return Returns a Diagnostic from a ValidationEvent. - */ - public static Diagnostic toDiagnostic(ValidationEvent event) { - int line = event.getSourceLocation().getLine() - 1; - int col = event.getSourceLocation().getColumn() - 1; - - DiagnosticSeverity severity = toDiagnosticSeverity(event.getSeverity()); - - Range range = new Range(new Position(line, 0), new Position(line, col)); - - final String message = event.getId() + ": " + event.getMessage(); - - return new Diagnostic(range, message, severity, "Smithy LSP"); - } - - /** - * @param severity Severity to be converted to a DiagnosticSeverity. - * @return Returns a DiagnosticSeverity from a Severity. - */ - public static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { - if (severity == Severity.DANGER) { - return DiagnosticSeverity.Error; - } else if (severity == Severity.ERROR) { - return DiagnosticSeverity.Error; - } else if (severity == Severity.WARNING) { - return DiagnosticSeverity.Warning; - } else if (severity == Severity.NOTE) { - return DiagnosticSeverity.Information; - } else { - return DiagnosticSeverity.Hint; - } - } - - /** - * @param shapeType The type to be converted to a SymbolKind - * @param parentType An optional type of the shape's enclosing definition - * @return An lsp4j SymbolKind - */ - public static SymbolKind toSymbolKind(ShapeType shapeType, Optional parentType) { - switch (shapeType) { - case BYTE: - case BIG_INTEGER: - case DOUBLE: - case BIG_DECIMAL: - case FLOAT: - case LONG: - case INTEGER: - case SHORT: - return SymbolKind.Number; - case BLOB: - // technically a sequence of bytes, so due to the lack of a better alternative, an array - case LIST: - case SET: - return SymbolKind.Array; - case BOOLEAN: - return SymbolKind.Boolean; - case STRING: - return SymbolKind.String; - case TIMESTAMP: - case UNION: - return SymbolKind.Interface; - - case DOCUMENT: - return SymbolKind.Class; - case ENUM: - case INT_ENUM: - return SymbolKind.Enum; - case MAP: - return SymbolKind.Object; - case STRUCTURE: - return SymbolKind.Struct; - case MEMBER: - if (!parentType.isPresent()) { - return SymbolKind.Field; - } - switch (parentType.get()) { - case ENUM: - return SymbolKind.EnumMember; - case UNION: - return SymbolKind.Class; - default: return SymbolKind.Field; - } - case SERVICE: - case RESOURCE: - return SymbolKind.Module; - case OPERATION: - return SymbolKind.Method; - default: - // This case shouldn't be reachable - return SymbolKind.Key; - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java b/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java deleted file mode 100644 index bafe93dc..00000000 --- a/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import java.io.File; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; -import java.util.Collection; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.lsp.ext.LspLog; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.loader.ModelAssembler; -import software.amazon.smithy.model.validation.ValidatedResult; - -public final class SmithyInterface { - - private SmithyInterface() { - - } - - /** - * Reads the model in a specified file, adding external jars to model builder. - * - * @param files list of smithy files - * @param externalJars set of external jars - * @return either an exception encountered during model building, or the result - * of model building - */ - public static Either> readModel(Collection files, - Collection externalJars) { - try { - URL[] urls = externalJars.stream().map(SmithyInterface::fileToUrl).toArray(URL[]::new); - URLClassLoader urlClassLoader = new URLClassLoader(urls); - ModelAssembler assembler = Model.assembler(urlClassLoader) - .discoverModels(urlClassLoader) - // We don't want the model to be broken when there are unknown traits, - // because that will essentially disable language server features. - .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); - - for (File file : files) { - assembler.addImport(file.getAbsolutePath()); - } - - return Either.forRight(assembler.assemble()); - } catch (Exception e) { - LspLog.println(e); - return Either.forLeft(e); - } - } - - private static URL fileToUrl(File file) { - try { - return file.toURI().toURL(); - } catch (MalformedURLException e) { - throw new RuntimeException("Failed to get file's URL", e); - } - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java new file mode 100644 index 00000000..3f54e013 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageClient.java @@ -0,0 +1,163 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.eclipse.lsp4j.ApplyWorkspaceEditParams; +import org.eclipse.lsp4j.ApplyWorkspaceEditResponse; +import org.eclipse.lsp4j.ConfigurationParams; +import org.eclipse.lsp4j.LogTraceParams; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.ProgressParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.RegistrationParams; +import org.eclipse.lsp4j.ShowDocumentParams; +import org.eclipse.lsp4j.ShowDocumentResult; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.WorkDoneProgressCreateParams; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.services.LanguageClient; + +/** + * Wrapper around a delegate {@link LanguageClient} that provides convenience + * methods and/or Smithy-specific language client features. + */ +public final class SmithyLanguageClient implements LanguageClient { + private final LanguageClient delegate; + + SmithyLanguageClient(LanguageClient delegate) { + this.delegate = delegate; + } + + /** + * Log a {@link MessageType#Info} message on the client. + * + * @param message Message to log + */ + public void info(String message) { + delegate.logMessage(new MessageParams(MessageType.Info, message)); + } + + /** + * Log a {@link MessageType#Error} message on the client. + * + * @param message Message to log + */ + public void error(String message) { + delegate.logMessage(new MessageParams(MessageType.Error, message)); + } + + /** + * Log a {@link MessageType#Error} message on the client, specifically for + * situations where a file is requested but isn't known to the server. + * + * @param uri LSP URI of the file that was requested. + * @param source Reason for requesting the file. + */ + public void unknownFileError(String uri, String source) { + delegate.logMessage(new MessageParams( + MessageType.Error, "attempted to get file for " + source + " that isn't tracked: " + uri)); + } + + @Override + public CompletableFuture applyEdit(ApplyWorkspaceEditParams params) { + return delegate.applyEdit(params); + } + + @Override + public CompletableFuture registerCapability(RegistrationParams params) { + return delegate.registerCapability(params); + } + + @Override + public CompletableFuture unregisterCapability(UnregistrationParams params) { + return delegate.unregisterCapability(params); + } + + @Override + public void telemetryEvent(Object object) { + delegate.telemetryEvent(object); + } + + @Override + public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { + delegate.publishDiagnostics(diagnostics); + } + + @Override + public void showMessage(MessageParams messageParams) { + delegate.showMessage(messageParams); + } + + @Override + public CompletableFuture showMessageRequest(ShowMessageRequestParams requestParams) { + return delegate.showMessageRequest(requestParams); + } + + @Override + public CompletableFuture showDocument(ShowDocumentParams params) { + return delegate.showDocument(params); + } + + @Override + public void logMessage(MessageParams message) { + delegate.logMessage(message); + } + + @Override + public CompletableFuture> workspaceFolders() { + return delegate.workspaceFolders(); + } + + @Override + public CompletableFuture> configuration(ConfigurationParams configurationParams) { + return delegate.configuration(configurationParams); + } + + @Override + public CompletableFuture createProgress(WorkDoneProgressCreateParams params) { + return delegate.createProgress(params); + } + + @Override + public void notifyProgress(ProgressParams params) { + delegate.notifyProgress(params); + } + + @Override + public void logTrace(LogTraceParams params) { + delegate.logTrace(params); + } + + @Override + public CompletableFuture refreshSemanticTokens() { + return delegate.refreshSemanticTokens(); + } + + @Override + public CompletableFuture refreshCodeLenses() { + return delegate.refreshCodeLenses(); + } + + @Override + public CompletableFuture refreshInlayHints() { + return delegate.refreshInlayHints(); + } + + @Override + public CompletableFuture refreshInlineValues() { + return delegate.refreshInlineValues(); + } + + @Override + public CompletableFuture refreshDiagnostics() { + return delegate.refreshDiagnostics(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 9af1b029..61b7ff16 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -15,28 +15,75 @@ package software.amazon.smithy.lsp; +import static java.util.concurrent.CompletableFuture.completedFuture; + import com.google.gson.JsonObject; -import java.io.File; import java.io.IOException; import java.net.URI; -import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.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.concurrent.CompletableFuture; +import java.util.logging.Logger; import java.util.stream.Collectors; +import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionOptions; +import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; import org.eclipse.lsp4j.CompletionOptions; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.DidChangeConfigurationParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.DocumentFormattingParams; +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.InitializedParams; import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.LocationLink; import org.eclipse.lsp4j.MessageParams; import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.ProgressParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.Registration; +import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SymbolInformation; +import org.eclipse.lsp4j.SymbolKind; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.TextDocumentSyncKind; -import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.Unregistration; +import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.WorkDoneProgressBegin; +import org.eclipse.lsp4j.WorkDoneProgressEnd; +import org.eclipse.lsp4j.jsonrpc.CompletableFutures; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; @@ -44,156 +91,723 @@ import org.eclipse.lsp4j.services.TextDocumentService; import org.eclipse.lsp4j.services.WorkspaceService; import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; +import software.amazon.smithy.lsp.diagnostics.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.LspLog; -import software.amazon.smithy.utils.ListUtils; - -public class SmithyLanguageServer implements LanguageServer, LanguageClientAware, SmithyProtocolExtensions { - File tempWorkspaceRoot; - private final Optional client = Optional.empty(); - private File workspaceRoot; - private Optional tds = Optional.empty(); - - @Override - public CompletableFuture shutdown() { - return Utils.completableFuture(new Object()); - } - - private void loadSmithyBuild(File root) { - this.tds.ifPresent(tds -> tds.createProject(root)); - } - - @Override - public CompletableFuture initialize(InitializeParams params) { - if (params.getRootUri() != null) { - try { - workspaceRoot = new File(new URI(params.getRootUri())); - loadSmithyBuild(workspaceRoot); - } catch (Exception e) { - LspLog.println("Failure trying to load extensions from workspace root: " + workspaceRoot.getAbsolutePath()); - e.printStackTrace(); - } - } else { - LspLog.println("Workspace root was null"); - } - - if (params.getWorkspaceFolders() == null) { - try { - tempWorkspaceRoot = Files.createTempDirectory("smithy-lsp-workspace").toFile(); - LspLog.println("Created temporary workspace root: " + tempWorkspaceRoot); - tempWorkspaceRoot.deleteOnExit(); - WorkspaceFolder workspaceFolder = new WorkspaceFolder(tempWorkspaceRoot.toURI().toString()); - params.setWorkspaceFolders(ListUtils.of(workspaceFolder)); - } catch (IOException e) { - e.printStackTrace(); - } - } - - // TODO: Replace with a Gson Type Adapter if more config options are added beyond `logToFile`. - Object initializationOptions = params.getInitializationOptions(); - if (initializationOptions instanceof JsonObject) { - JsonObject jsonObject = (JsonObject) initializationOptions; - if (jsonObject.has("logToFile")) { - String setting = jsonObject.get("logToFile").getAsString(); - if (setting.equals("enabled")) { - LspLog.enable(); - } - } - } - - // TODO: This will break on multi-root workspaces - for (WorkspaceFolder ws : params.getWorkspaceFolders()) { - try { - File root = new File(new URI(ws.getUri())); - LspLog.setWorkspaceFolder(root); - loadSmithyBuild(root); - } catch (Exception e) { - LspLog.println("Error when loading workspace folder " + ws.toString() + ": " + e); - e.printStackTrace(); - } - } - - ServerCapabilities capabilities = new ServerCapabilities(); - capabilities.setTextDocumentSync(TextDocumentSyncKind.Full); - capabilities.setCodeActionProvider(new CodeActionOptions(SmithyCodeActions.all())); - capabilities.setDefinitionProvider(true); - capabilities.setDeclarationProvider(true); - capabilities.setCompletionProvider(new CompletionOptions(true, null)); - capabilities.setHoverProvider(true); - capabilities.setDocumentFormattingProvider(true); - capabilities.setDocumentSymbolProvider(true); - - return Utils.completableFuture(new InitializeResult(capabilities)); - } - - @Override - public void exit() { - System.exit(0); - } - - @Override - public WorkspaceService getWorkspaceService() { - return new SmithyWorkspaceService(this.tds); - } - - @Override - public TextDocumentService getTextDocumentService() { - File temp = null; - try { - temp = Files.createTempDirectory("smithy-lsp").toFile(); - LspLog.println("Created a temporary folder for file contents " + temp); - temp.deleteOnExit(); - } catch (IOException e) { - LspLog.println("Failed to create a temporary folder " + e); - } - SmithyTextDocumentService local = new SmithyTextDocumentService(this.client, temp); - tds = Optional.of(local); - return local; - } - - @Override - public void connect(LanguageClient client) { - Properties props = new Properties(); - String message = "Hello from smithy-language-server!"; - try { - props.load(SmithyLanguageServer.class.getClassLoader().getResourceAsStream("version.properties")); - message = "Hello from smithy-language-server " + props.getProperty("version") + "!"; - } catch (Exception e) { - LspLog.println("Could not read Language Server version: " + e); - } - tds.ifPresent(tds -> tds.setClient(client)); - client.showMessage(new MessageParams(MessageType.Info, message)); - } - - @Override - public CompletableFuture jarFileContents(TextDocumentIdentifier documentUri) { - String uri = documentUri.getUri(); - - try { - LspLog.println("Trying to resolve " + uri); - List lines = Utils.jarFileContents(uri); - String contents = lines.stream().collect(Collectors.joining(System.lineSeparator())); - return CompletableFuture.completedFuture(contents); - } catch (IOException e) { - LspLog.println("Failed to resolve " + uri + " error: " + e); - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(e); - return future; - } - } - - @Override - public CompletableFuture> selectorCommand(SelectorParams selectorParams) { - LspLog.println("Received selector Command: " + selectorParams.getExpression()); - if (this.tds.isPresent()) { - Either> result = this.tds.get().runSelector(selectorParams.getExpression()); - if (result.isRight()) { - List locations = result.getRight(); - LspLog.println(String.format("Selector command found %s matching shapes.", locations.size())); - return CompletableFuture.completedFuture(locations); - } else { - LspLog.println("Resolve model validation errors and re-run selector command: " + result.getLeft()); - } - } - return CompletableFuture.completedFuture(Collections.emptyList()); - } +import software.amazon.smithy.lsp.ext.serverstatus.OpenProject; +import software.amazon.smithy.lsp.ext.serverstatus.ServerStatus; +import software.amazon.smithy.lsp.handler.CompletionHandler; +import software.amazon.smithy.lsp.handler.DefinitionHandler; +import software.amazon.smithy.lsp.handler.FileWatcherRegistrationHandler; +import software.amazon.smithy.lsp.handler.HoverHandler; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectConfigLoader; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectManager; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.util.Result; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.IdlTokenizer; +import software.amazon.smithy.model.selector.Selector; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; +import software.amazon.smithy.syntax.Formatter; +import software.amazon.smithy.syntax.TokenTree; +import software.amazon.smithy.utils.IoUtils; + +public class SmithyLanguageServer implements + LanguageServer, LanguageClientAware, SmithyProtocolExtensions, WorkspaceService, TextDocumentService { + private static final Logger LOGGER = Logger.getLogger(SmithyLanguageServer.class.getName()); + private static final ServerCapabilities CAPABILITIES; + + static { + ServerCapabilities capabilities = new ServerCapabilities(); + capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental); + capabilities.setCodeActionProvider(new CodeActionOptions(SmithyCodeActions.all())); + capabilities.setDefinitionProvider(true); + capabilities.setDeclarationProvider(true); + capabilities.setCompletionProvider(new CompletionOptions(true, null)); + capabilities.setHoverProvider(true); + capabilities.setDocumentFormattingProvider(true); + capabilities.setDocumentSymbolProvider(true); + CAPABILITIES = capabilities; + } + + private SmithyLanguageClient client; + private final ProjectManager projects = new ProjectManager(); + private final DocumentLifecycleManager lifecycleManager = new DocumentLifecycleManager(); + private Severity minimumSeverity = Severity.WARNING; + private boolean onlyReloadOnSave = false; + + SmithyLanguageServer() { + } + + SmithyLanguageServer(LanguageClient client, Project project) { + this.client = new SmithyLanguageClient(client); + this.projects.updateMainProject(project); + } + + SmithyLanguageClient getClient() { + return this.client; + } + + Project getProject() { + return projects.mainProject(); + } + + ProjectManager getProjects() { + return projects; + } + + DocumentLifecycleManager getLifecycleManager() { + return this.lifecycleManager; + } + + @Override + public void connect(LanguageClient client) { + LOGGER.info("Connect"); + this.client = new SmithyLanguageClient(client); + String message = "smithy-language-server"; + try { + Properties props = new Properties(); + props.load(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("version.properties"))); + message += " version " + props.getProperty("version"); + } catch (IOException e) { + this.client.error("Failed to load smithy-language-server version: " + e); + } + this.client.info(message + " started."); + } + + @Override + public CompletableFuture initialize(InitializeParams params) { + LOGGER.info("Initialize"); + + // TODO: Use this to manage shutdown if the parent process exits, after upgrading jdk + // Optional.ofNullable(params.getProcessId()) + // .flatMap(ProcessHandle::of) + // .ifPresent(processHandle -> { + // processHandle.onExit().thenRun(this::exit); + // }); + + // TODO: Replace with a Gson Type Adapter if more config options are added beyond `logToFile`. + Object initializationOptions = params.getInitializationOptions(); + if (initializationOptions instanceof JsonObject) { + JsonObject jsonObject = (JsonObject) initializationOptions; + if (jsonObject.has("logToFile")) { + String setting = jsonObject.get("logToFile").getAsString(); + if (setting.equals("enabled")) { + LspLog.enable(); + } + } + if (jsonObject.has("diagnostics.minimumSeverity")) { + String configuredMinimumSeverity = jsonObject.get("diagnostics.minimumSeverity").getAsString(); + Optional severity = Severity.fromString(configuredMinimumSeverity); + if (severity.isPresent()) { + this.minimumSeverity = severity.get(); + } else { + client.error("Invalid value for 'diagnostics.minimumSeverity': " + configuredMinimumSeverity + + ".\nMust be one of " + Arrays.toString(Severity.values())); + } + } + if (jsonObject.has("onlyReloadOnSave")) { + this.onlyReloadOnSave = jsonObject.get("onlyReloadOnSave").getAsBoolean(); + client.info("Configured only reload on save: " + this.onlyReloadOnSave); + } + } + + Path root = null; + // TODO: Handle multiple workspaces + if (params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { + String uri = params.getWorkspaceFolders().get(0).getUri(); + root = Paths.get(URI.create(uri)); + } else if (params.getRootUri() != null) { + String uri = params.getRootUri(); + root = Paths.get(URI.create(uri)); + } else if (params.getRootPath() != null) { + String uri = params.getRootPath(); + root = Paths.get(URI.create(uri)); + } + + if (root != null) { + // TODO: Support this for other tasks. Need to create a progress token with the client + // through createProgress. + Either workDoneProgressToken = params.getWorkDoneToken(); + if (workDoneProgressToken != null) { + WorkDoneProgressBegin notification = new WorkDoneProgressBegin(); + notification.setTitle("Initializing"); + client.notifyProgress(new ProgressParams(workDoneProgressToken, Either.forLeft(notification))); + } + + tryInitProject(root); + + if (workDoneProgressToken != null) { + WorkDoneProgressEnd notification = new WorkDoneProgressEnd(); + client.notifyProgress(new ProgressParams(workDoneProgressToken, Either.forLeft(notification))); + } + } + + LOGGER.info("Done initialize"); + return completedFuture(new InitializeResult(CAPABILITIES)); + } + + private void tryInitProject(Path root) { + LOGGER.info("Initializing project at " + root); + lifecycleManager.cancelAllTasks(); + Result> loadResult = ProjectLoader.load( + root, projects, lifecycleManager.managedDocuments()); + if (loadResult.isOk()) { + Project updatedProject = loadResult.unwrap(); + resolveDetachedProjects(updatedProject); + projects.updateMainProject(loadResult.unwrap()); + LOGGER.info("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.mainProject() == null) { + projects.updateMainProject(Project.empty(root)); + } + + String baseMessage = "Failed to load Smithy project at " + root; + StringBuilder errorMessage = new StringBuilder(baseMessage).append(":"); + for (Exception error : loadResult.unwrapErr()) { + errorMessage.append(System.lineSeparator()); + errorMessage.append('\t'); + errorMessage.append(error.getMessage()); + } + client.error(errorMessage.toString()); + + String showMessage = baseMessage + ". Check server logs to find out what went wrong."; + client.showMessage(new MessageParams(MessageType.Error, showMessage)); + } + } + + private void resolveDetachedProjects(Project updatedProject) { + // This is a project reload, so we need to resolve any added/removed files + // that need to be moved to or from detached projects. + if (getProject() != null) { + Set currentProjectSmithyPaths = getProject().smithyFiles().keySet(); + Set updatedProjectSmithyPaths = updatedProject.smithyFiles().keySet(); + + Set addedPaths = new HashSet<>(updatedProjectSmithyPaths); + addedPaths.removeAll(currentProjectSmithyPaths); + for (String addedPath : addedPaths) { + String addedUri = LspAdapter.toUri(addedPath); + if (projects.isDetached(addedUri)) { + projects.removeDetachedProject(addedUri); + } + } + + Set removedPaths = new HashSet<>(currentProjectSmithyPaths); + removedPaths.removeAll(updatedProjectSmithyPaths); + for (String removedPath : removedPaths) { + String removedUri = LspAdapter.toUri(removedPath); + // Only move to a detached project if the file is managed + if (lifecycleManager.managedDocuments().contains(removedUri)) { + // Note: This should always be non-null, since we essentially got this from the current project + Document removedDocument = projects.getDocument(removedUri); + // The copy here is technically unnecessary, if we make ModelAssembler support borrowed strings + projects.createDetachedProject(removedUri, removedDocument.copyText()); + } + } + } + } + + private CompletableFuture registerSmithyFileWatchers() { + Project project = projects.mainProject(); + List registrations = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project); + return client.registerCapability(new RegistrationParams(registrations)); + } + + private CompletableFuture unregisterSmithyFileWatchers() { + List unregistrations = FileWatcherRegistrationHandler.getSmithyFileWatcherUnregistrations(); + return client.unregisterCapability(new UnregistrationParams(unregistrations)); + } + + @Override + public void initialized(InitializedParams params) { + List registrations = FileWatcherRegistrationHandler.getBuildFileWatcherRegistrations(); + client.registerCapability(new RegistrationParams(registrations)); + registerSmithyFileWatchers(); + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public CompletableFuture shutdown() { + // TODO: Cancel all in-progress requests + return completedFuture(new Object()); + } + + @Override + public void exit() { + System.exit(0); + } + + @Override + public CompletableFuture jarFileContents(TextDocumentIdentifier textDocumentIdentifier) { + LOGGER.info("JarFileContents"); + String uri = textDocumentIdentifier.getUri(); + Project project = projects.getProject(uri); + Document document = project.getDocument(uri); + if (document != null) { + return completedFuture(document.copyText()); + } else { + // Technically this can throw if the uri is invalid + return completedFuture(IoUtils.readUtf8Url(LspAdapter.jarUrl(uri))); + } + } + + // TODO: This doesn't really work for multiple projects + @Override + public CompletableFuture> selectorCommand(SelectorParams selectorParams) { + LOGGER.info("SelectorCommand"); + Selector selector; + try { + selector = Selector.parse(selectorParams.getExpression()); + } catch (Exception e) { + LOGGER.info("Invalid selector"); + // TODO: Respond with error somehow + return completedFuture(Collections.emptyList()); + } + + Project project = projects.mainProject(); + // TODO: Might also want to tell user if the model isn't loaded + // TODO: Use proper location (source is just a point) + return completedFuture(project.modelResult().getResult() + .map(selector::select) + .map(shapes -> shapes.stream() + .map(Shape::getSourceLocation) + .map(LspAdapter::toLocation) + .collect(Collectors.toList())) + .orElse(Collections.emptyList())); + } + + @Override + public CompletableFuture serverStatus() { + OpenProject openProject = new OpenProject( + LspAdapter.toUri(projects.mainProject().root().toString()), + projects.mainProject().smithyFiles().keySet().stream() + .map(LspAdapter::toUri) + .collect(Collectors.toList()), + false); + + List openProjects = new ArrayList<>(); + openProjects.add(openProject); + + 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.info("DidChangeWatchedFiles"); + // Smithy files were added or deleted to watched sources/imports (specified by smithy-build.json), + // or the smithy-build.json itself was changed + Set createdSmithyFiles = new HashSet<>(params.getChanges().size()); + Set deletedSmithyFiles = new HashSet<>(params.getChanges().size()); + boolean changedBuildFiles = false; + for (FileEvent event : params.getChanges()) { + String changedUri = event.getUri(); + if (changedUri.endsWith(".smithy")) { + if (event.getType().equals(FileChangeType.Created)) { + createdSmithyFiles.add(changedUri); + } else if (event.getType().equals(FileChangeType.Deleted)) { + deletedSmithyFiles.add(changedUri); + } + } else if (changedUri.endsWith(ProjectConfigLoader.SMITHY_BUILD) + || changedUri.endsWith(ProjectConfigLoader.SMITHY_PROJECT)) { + changedBuildFiles = true; + } else { + for (String extFile : ProjectConfigLoader.SMITHY_BUILD_EXTS) { + if (changedUri.endsWith(extFile)) { + changedBuildFiles = true; + break; + } + } + } + } + + if (changedBuildFiles) { + client.info("Build files changed, reloading project"); + // TODO: Handle more granular updates to build files. + tryInitProject(projects.mainProject().root()); + } else { + client.info("Project files changed, adding files " + + createdSmithyFiles + " and removing files " + deletedSmithyFiles); + // We get this notification for watched files, which only includes project files, + // so we don't need to resolve detached projects. + projects.mainProject().updateFiles(createdSmithyFiles, deletedSmithyFiles); + } + + // TODO: Update watchers based on specific changes + unregisterSmithyFileWatchers().thenRun(this::registerSmithyFileWatchers); + + sendFileDiagnosticsForManagedDocuments(); + } + + @Override + public void didChangeConfiguration(DidChangeConfigurationParams params) { + } + + @Override + public void didChange(DidChangeTextDocumentParams params) { + LOGGER.info("DidChange"); + + if (params.getContentChanges().isEmpty()) { + LOGGER.info("Received empty DidChange"); + return; + } + + String uri = params.getTextDocument().getUri(); + + lifecycleManager.cancelTask(uri); + + Document document = projects.getDocument(uri); + if (document == null) { + client.unknownFileError(uri, "change"); + return; + } + + for (TextDocumentContentChangeEvent contentChangeEvent : params.getContentChanges()) { + if (contentChangeEvent.getRange() != null) { + document.applyEdit(contentChangeEvent.getRange(), contentChangeEvent.getText()); + } else { + document.applyEdit(document.fullRange(), contentChangeEvent.getText()); + } + } + + if (!onlyReloadOnSave) { + // TODO: A consequence of this is that any existing validation events are cleared, which + // is kinda annoying. + // Report any parse/shape/trait loading errors + Project project = projects.getProject(uri); + if (project == null) { + client.unknownFileError(uri, "change"); + return; + } + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateModelWithoutValidating(uri)) + .thenComposeAsync(unused -> sendFileDiagnostics(uri)); + lifecycleManager.putTask(uri, future); + } + } + + @Override + public void didOpen(DidOpenTextDocumentParams params) { + LOGGER.info("DidOpen"); + + String uri = params.getTextDocument().getUri(); + + lifecycleManager.cancelTask(uri); + lifecycleManager.managedDocuments().add(uri); + + String text = params.getTextDocument().getText(); + Document document = projects.getDocument(uri); + if (document != null) { + document.applyEdit(null, text); + } else { + projects.createDetachedProject(uri, text); + } + + lifecycleManager.putTask(uri, sendFileDiagnostics(uri)); + } + + @Override + public void didClose(DidCloseTextDocumentParams params) { + LOGGER.info("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 + } + + @Override + public void didSave(DidSaveTextDocumentParams params) { + LOGGER.info("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 + client.unknownFileError(uri, "save"); + return; + } + + Project project = projects.getProject(uri); + if (params.getText() != null) { + Document document = project.getDocument(uri); + document.applyEdit(null, params.getText()); + } + + CompletableFuture future = CompletableFuture + .runAsync(() -> project.updateAndValidateModel(uri)) + .thenCompose(unused -> sendFileDiagnostics(uri)); + lifecycleManager.putTask(uri, future); + } + + @Override + public CompletableFuture, CompletionList>> completion(CompletionParams params) { + LOGGER.info("Completion"); + + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "completion"); + return completedFuture(Either.forLeft(Collections.emptyList())); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + return CompletableFutures.computeAsync((cc) -> { + CompletionHandler handler = new CompletionHandler(project, smithyFile); + return Either.forLeft(handler.handle(params, cc)); + }); + } + + @Override + public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { + LOGGER.info("ResolveCompletion"); + // TODO: Use this to add the import when a completion item is selected, if its expensive + return completedFuture(unresolved); + } + + @Override + public CompletableFuture>> + documentSymbol(DocumentSymbolParams params) { + LOGGER.info("DocumentSymbol"); + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "document symbol"); + return completedFuture(Collections.emptyList()); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + + return CompletableFutures.computeAsync((cc) -> { + if (smithyFile == null) { + return Collections.emptyList(); + } + + Collection documentShapes = smithyFile.documentShapes(); + if (documentShapes.isEmpty()) { + return Collections.emptyList(); + } + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + List> documentSymbols = new ArrayList<>(documentShapes.size()); + for (DocumentShape documentShape : documentShapes) { + if (cc.isCanceled()) { + client.info("canceled document symbols"); + return Collections.emptyList(); + } + SymbolKind symbolKind; + switch (documentShape.kind()) { + case Inline: + // No shape name in the document text, so no symbol + continue; + case DefinedMember: + case Elided: + symbolKind = SymbolKind.Property; + break; + case DefinedShape: + case Targeted: + default: + symbolKind = SymbolKind.Class; + break; + } + String symbolName = documentShape.shapeName().toString(); + if (symbolName.isEmpty()) { + LOGGER.warning("[DocumentSymbols] Empty shape name for " + documentShape); + continue; + } + Range symbolRange = documentShape.range(); + DocumentSymbol symbol = new DocumentSymbol(symbolName, symbolKind, symbolRange, symbolRange); + documentSymbols.add(Either.forRight(symbol)); + } + + return documentSymbols; + }); + } + + @Override + public CompletableFuture, List>> + definition(DefinitionParams params) { + LOGGER.info("Definition"); + + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "definition"); + return completedFuture(Either.forLeft(Collections.emptyList())); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + List locations = new DefinitionHandler(project, smithyFile).handle(params); + return completedFuture(Either.forLeft(locations)); + } + + @Override + public CompletableFuture hover(HoverParams params) { + LOGGER.info("Hover"); + + String uri = params.getTextDocument().getUri(); + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "hover"); + return completedFuture(HoverHandler.emptyContents()); + } + + Project project = projects.getProject(uri); + SmithyFile smithyFile = project.getSmithyFile(uri); + + // TODO: Abstract away passing minimum severity + Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); + return completedFuture(hover); + } + + @Override + public CompletableFuture>> codeAction(CodeActionParams params) { + List> versionCodeActions = + SmithyCodeActions.versionCodeActions(params).stream() + .map(Either::forRight) + .collect(Collectors.toList()); + return completedFuture(versionCodeActions); + } + + @Override + public CompletableFuture> formatting(DocumentFormattingParams params) { + LOGGER.info("Formatting"); + String uri = params.getTextDocument().getUri(); + Project project = projects.getProject(uri); + Document document = project.getDocument(uri); + if (document == null) { + return completedFuture(Collections.emptyList()); + } + + IdlTokenizer tokenizer = IdlTokenizer.create(uri, document.borrowText()); + TokenTree tokenTree = TokenTree.of(tokenizer); + String formatted = Formatter.format(tokenTree); + Range range = document.fullRange(); + TextEdit edit = new TextEdit(range, formatted); + return completedFuture(Collections.singletonList(edit)); + } + + private void sendFileDiagnosticsForManagedDocuments() { + for (String managedDocumentUri : lifecycleManager.managedDocuments()) { + lifecycleManager.putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); + } + } + + private CompletableFuture sendFileDiagnostics(String uri) { + return CompletableFuture.runAsync(() -> { + List diagnostics = getFileDiagnostics(uri); + PublishDiagnosticsParams publishDiagnosticsParams = new PublishDiagnosticsParams(uri, diagnostics); + client.publishDiagnostics(publishDiagnosticsParams); + }); + } + + List getFileDiagnostics(String uri) { + 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(); + } + + if (!projects.isTracked(uri)) { + client.unknownFileError(uri, "diagnostics"); + } + + 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)); + + Diagnostic versionDiagnostic = SmithyDiagnostics.versionDiagnostic(smithyFile); + if (versionDiagnostic != null) { + diagnostics.add(versionDiagnostic); + } + + if (projects.isDetached(uri)) { + diagnostics.add(SmithyDiagnostics.detachedDiagnostic(smithyFile)); + } + + return diagnostics; + } + + private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { + DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); + SourceLocation sourceLocation = validationEvent.getSourceLocation(); + + // 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; + } + } + } + + String message = validationEvent.getId() + ": " + validationEvent.getMessage(); + return new Diagnostic(range, message, severity, "Smithy"); + } + + private static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { + switch (severity) { + case ERROR: + case DANGER: + return DiagnosticSeverity.Error; + case WARNING: + return DiagnosticSeverity.Warning; + case NOTE: + return DiagnosticSeverity.Information; + default: + return DiagnosticSeverity.Hint; + } + } } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java b/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java index 63b1c608..3a263914 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyProtocolExtensions.java @@ -21,6 +21,7 @@ import org.eclipse.lsp4j.TextDocumentIdentifier; import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; import org.eclipse.lsp4j.jsonrpc.services.JsonSegment; +import software.amazon.smithy.lsp.ext.serverstatus.ServerStatus; /** * Interface for protocol extensions for Smithy. @@ -33,4 +34,12 @@ public interface SmithyProtocolExtensions { @JsonRequest CompletableFuture> selectorCommand(SelectorParams selectorParams); + + /** + * Get a snapshot of the server's status, useful for debugging purposes. + * + * @return A future containing the server's status + */ + @JsonRequest + CompletableFuture serverStatus(); } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java b/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java deleted file mode 100644 index ebb34e3a..00000000 --- a/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java +++ /dev/null @@ -1,894 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import com.google.common.hash.HashFunction; -import com.google.common.hash.Hashing; -import java.io.File; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import org.eclipse.lsp4j.CodeAction; -import org.eclipse.lsp4j.CodeActionParams; -import org.eclipse.lsp4j.Command; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionList; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DefinitionParams; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidCloseTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.DidSaveTextDocumentParams; -import org.eclipse.lsp4j.DocumentFormattingParams; -import org.eclipse.lsp4j.DocumentSymbol; -import org.eclipse.lsp4j.DocumentSymbolParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.LocationLink; -import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.MessageType; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.SymbolKind; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TextDocumentItem; -import org.eclipse.lsp4j.TextEdit; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.eclipse.lsp4j.services.LanguageClient; -import org.eclipse.lsp4j.services.TextDocumentService; -import smithyfmt.Formatter; -import smithyfmt.Result; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; -import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; -import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; -import software.amazon.smithy.lsp.editor.SmartInput; -import software.amazon.smithy.lsp.ext.Completions; -import software.amazon.smithy.lsp.ext.Constants; -import software.amazon.smithy.lsp.ext.Document; -import software.amazon.smithy.lsp.ext.DocumentPreamble; -import software.amazon.smithy.lsp.ext.LspLog; -import software.amazon.smithy.lsp.ext.SmithyBuildLoader; -import software.amazon.smithy.lsp.ext.SmithyProject; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.knowledge.NeighborProviderIndex; -import software.amazon.smithy.model.loader.ParserUtils; -import software.amazon.smithy.model.neighbor.Walker; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.ShapeType; -import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidationEvent; -import software.amazon.smithy.utils.SimpleParser; - -public class SmithyTextDocumentService implements TextDocumentService { - - private final List baseCompletions = new ArrayList<>(); - private Optional client; - private final List noLocations = Collections.emptyList(); - @Nullable - private SmithyProject project; - private final File temporaryFolder; - - // when files are edited, their contents will be persisted in memory and removed - // on didSave or didClose - private final Map temporaryContents = new ConcurrentHashMap<>(); - - // We use this function to hash filepaths to the same location in temporary - // folder - private final HashFunction hash = Hashing.murmur3_128(); - - /** - * @param client Language Client to be used by text document service. - * @param tempFile Temporary File to be used by text document service. - */ - public SmithyTextDocumentService(Optional client, File tempFile) { - this.client = client; - this.temporaryFolder = tempFile; - } - - public void setClient(LanguageClient client) { - this.client = Optional.of(client); - } - - public Optional getRoot() { - return Optional.ofNullable(project).map(SmithyProject::getRoot); - } - - /** - * Processes extensions. - *

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

Methods on this class will often return {@code -1} or {@code null} for + * failure cases to reduce allocations, since these methods may be called + * frequently. + */ +public final class Document { + private final StringBuilder buffer; + private int[] lineIndices; + + private Document(StringBuilder buffer, int[] lineIndices) { + this.buffer = buffer; + this.lineIndices = lineIndices; + } + + /** + * @param string String to create a document for + * @return The created document + */ + public static Document of(String string) { + StringBuilder buffer = new StringBuilder(string); + int[] lineIndicies = computeLineIndicies(buffer); + return new Document(buffer, lineIndicies); + } + + /** + * @return A copy of this document + */ + public Document copy() { + return new Document(new StringBuilder(copyText()), lineIndices.clone()); + } + + /** + * @param range The range to apply the edit to. Providing {@code null} will + * replace the text in the document + * @param text The text of the edit to apply + */ + public void applyEdit(Range range, String text) { + if (range == null) { + buffer.replace(0, buffer.length(), text); + } else { + Position start = range.getStart(); + Position end = range.getEnd(); + if (start.getLine() >= lineIndices.length) { + buffer.append(text); + } else { + int startIndex = lineIndices[start.getLine()] + start.getCharacter(); + if (end.getLine() >= lineIndices.length) { + buffer.replace(startIndex, buffer.length(), text); + } else { + int endIndex = lineIndices[end.getLine()] + end.getCharacter(); + buffer.replace(startIndex, endIndex, text); + } + } + } + this.lineIndices = computeLineIndicies(buffer); + } + + /** + * @return The range of the document, from (0, 0) to {@link #end()} + */ + public Range fullRange() { + return LspAdapter.offset(end()); + } + + /** + * @param line The line to find the index of + * @return The index of the start of the given {@code line}, or {@code -1} + * if the line doesn't exist + */ + public int indexOfLine(int line) { + if (line >= lineIndices.length || line < 0) { + return -1; + } + return lineIndices[line]; + } + + /** + * @param idx Index to find the line of + * @return The line that {@code idx} is within or {@code -1} if the line + * doesn't exist + */ + public int lineOfIndex(int idx) { + // TODO: Use binary search or similar + if (idx >= length() || idx < 0) { + return -1; + } + + for (int line = 0; line <= lastLine() - 1; line++) { + int currentLineIdx = indexOfLine(line); + int nextLineIdx = indexOfLine(line + 1); + if (idx >= currentLineIdx && idx < nextLineIdx) { + return line; + } + } + + return lastLine(); + } + + /** + * @param position The position to find the index of + * @return The index of the position in this document, or {@code -1} if the + * position is out of bounds + */ + public int indexOfPosition(Position position) { + return indexOfPosition(position.getLine(), position.getCharacter()); + } + + /** + * @param line The line of the index to find + * @param character The character offset in the line + * @return The index of the position in this document, or {@code -1} if the + * position is out of bounds + */ + public int indexOfPosition(int line, int character) { + int startLineIdx = indexOfLine(line); + if (startLineIdx < 0) { + // line is oob + return -1; + } + + + int idx = startLineIdx + character; + if (line == lastLine()) { + if (idx >= buffer.length()) { + // index is oob + return -1; + } + } else { + if (idx >= indexOfLine(line + 1)) { + // index is onto next line + return -1; + } + } + + return idx; + } + + /** + * @param index The index to find the position of + * @return The position of the index in this document, or {@code null} if + * the index is out of bounds + */ + public Position positionAtIndex(int index) { + int line = lineOfIndex(index); + if (line < 0) { + return null; + } + int lineStart = indexOfLine(line); + int character = index - lineStart; + return new Position(line, character); + } + + /** + * @param line The line to find the end of + * @return The index of the end of the given line, or {@code -1} if the + * line is out of bounds + */ + public int lineEnd(int line) { + if (line > lastLine() || line < 0) { + return -1; + } + + if (line == lastLine()) { + return length() - 1; + } else { + return indexOfLine(line + 1) - 1; + } + } + + /** + * @return The line number of the last line in this document + */ + public int lastLine() { + return lineIndices.length - 1; + } + + /** + * @return The end position of this document + */ + public Position end() { + return new Position( + lineIndices.length - 1, + buffer.length() - lineIndices[lineIndices.length - 1]); + } + + /** + * @param s The string to find the next index of + * @param after The index to start the search at + * @return The index of the next occurrence of {@code s} after {@code after} + * or {@code -1} if one doesn't exist + */ + public int nextIndexOf(String s, int after) { + return buffer.indexOf(s, after); + } + + /** + * @param s The string to find the last index of + * @param before The index to end the search at + * @return The index of the last occurrence of {@code s} before {@code before} + * or {@code -1} if one doesn't exist + */ + public int lastIndexOf(String s, int before) { + return buffer.lastIndexOf(s, before); + } + + /** + * @param c The character to find the last index of + * @param before The index to stop the search at + * @param line The line to search within + * @return The index of the last occurrence of {@code c} before {@code before} + * on the line {@code line} or {@code -1} if one doesn't exist + */ + int lastIndexOfOnLine(char c, int before, int line) { + int lineIdx = indexOfLine(line); + for (int i = before; i >= lineIdx; i--) { + if (buffer.charAt(i) == c) { + return i; + } + } + return -1; + } + + /** + * @return A reference to the text in this document + */ + public CharSequence borrowText() { + return buffer; + } + + /** + * @param range The range to borrow the text of + * @return A reference to the text in this document within the given {@code range} + * or {@code null} if the range is out of bounds + */ + public CharBuffer borrowRange(Range range) { + int startLine = range.getStart().getLine(); + int startChar = range.getStart().getCharacter(); + int endLine = range.getEnd().getLine(); + int endChar = range.getEnd().getCharacter(); + + // TODO: Maybe make this return the whole thing, thing up to an index, or thing after an + // index if one of the indicies is out of bounds. + int startLineIdx = indexOfLine(startLine); + int endLineIdx = indexOfLine(endLine); + if (startLineIdx < 0 || endLineIdx < 0) { + return null; + } + + int startIdx = startLineIdx + startChar; + int endIdx = endLineIdx + endChar; + if (startIdx > buffer.length() || endIdx > buffer.length()) { + return null; + } + + return CharBuffer.wrap(buffer, startIdx, endIdx); + } + + /** + * @param position The position within the token to borrow + * @return A reference to the token that the given {@code position} is + * within, or {@code null} if the position is not within a token + */ + public CharBuffer borrowToken(Position position) { + int idx = indexOfPosition(position); + if (idx < 0) { + return null; + } + + char atIdx = buffer.charAt(idx); + // Not a token + if (!Character.isLetterOrDigit(atIdx) && atIdx != '_') { + return null; + } + + int startIdx = idx; + while (startIdx >= 0) { + char c = buffer.charAt(startIdx); + if (Character.isLetterOrDigit(c) || c == '_') { + startIdx--; + } else { + break; + } + } + + int endIdx = idx; + while (endIdx < buffer.length()) { + char c = buffer.charAt(endIdx); + if (Character.isLetterOrDigit(c) || c == '_') { + endIdx++; + } else { + break; + } + } + + return CharBuffer.wrap(buffer, startIdx + 1, endIdx); + } + + /** + * @param position The position within the id to borrow + * @return A reference to the id that the given {@code position} is + * within, or {@code null} if the position is not within an id + */ + public CharBuffer borrowId(Position position) { + DocumentId id = copyDocumentId(position); + if (id == null) { + return null; + } + return id.borrowIdValue(); + } + + /** + * @param line The line to borrow + * @return A reference to the text in the given line, or {@code null} if + * the line doesn't exist + */ + public CharBuffer borrowLine(int line) { + if (line >= lineIndices.length || line < 0) { + return null; + } + + int lineStart = indexOfLine(line); + if (line + 1 >= lineIndices.length) { + return CharBuffer.wrap(buffer, lineStart, buffer.length()); + } + + return CharBuffer.wrap(buffer, lineStart, indexOfLine(line + 1)); + } + + /** + * @param start The index of the start of the span to borrow + * @param end The end of the index of the span to borrow (exclusive) + * @return A reference to the text within the indicies {@code start} and + * {@code end}, or {@code null} if the span is out of bounds or start > end + */ + public CharBuffer borrowSpan(int start, int end) { + if (start < 0 || end < 0) { + return null; + } + + // end is exclusive + if (end > buffer.length() || start > end) { + return null; + } + + return CharBuffer.wrap(buffer, start, end); + } + + /** + * @return A copy of the text of this document + */ + public String copyText() { + return buffer.toString(); + } + + /** + * @param range The range to copy the text of + * @return A copy of the text in this document within the given {@code range} + * or {@code null} if the range is out of bounds + */ + public String copyRange(Range range) { + CharBuffer borrowed = borrowRange(range); + if (borrowed == null) { + return null; + } + + return borrowed.toString(); + } + + /** + * @param position The position within the token to copy + * @return A copy of the token that the given {@code position} is within, + * or {@code null} if the position is not within a token + */ + public String copyToken(Position position) { + CharSequence token = borrowToken(position); + if (token == null) { + return null; + } + return token.toString(); + } + + /** + * @param position The position within the id to copy + * @return A copy of the id that the given {@code position} is + * within, or {@code null} if the position is not within an id + */ + public String copyId(Position position) { + CharBuffer id = borrowId(position); + if (id == null) { + return null; + } + return id.toString(); + } + + /** + * @param position The position within the id to get + * @return A new id that the given {@code position} is + * within, or {@code null} if the position is not within an id + */ + public DocumentId copyDocumentId(Position position) { + int idx = indexOfPosition(position); + if (idx < 0) { + return null; + } + + char atIdx = buffer.charAt(idx); + if (!isIdChar(atIdx)) { + return null; + } + + boolean hasHash = false; + boolean hasDollar = false; + boolean hasDot = 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 { + break; + } + } + + 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; + } + + endIdx += 1; + } else { + break; + } + } + + + // TODO: This can be improved to do some extra validation, like if + // there's more than 1 hash or $, its invalid. Additionally, we + // should only give a type of *WITH_MEMBER if the position is on + // the member itself. We will probably need to add some logic or + // keep track of the member itself in order to properly match the + // RELATIVE_WITH_MEMBER type in handlers. + DocumentId.Type type; + if (hasHash && hasDollar) { + type = DocumentId.Type.ABSOLUTE_WITH_MEMBER; + } else if (hasHash) { + type = DocumentId.Type.ABSOLUTE_ID; + } else if (hasDollar) { + type = DocumentId.Type.RELATIVE_WITH_MEMBER; + } else if (hasDot) { + type = DocumentId.Type.NAMESPACE; + } else { + type = DocumentId.Type.ID; + } + + int actualStartIdx = startIdx + 1; // because we go past the actual start in the loop + CharBuffer wrapped = CharBuffer.wrap(buffer, actualStartIdx, endIdx); // endIdx here is non-inclusive + Position start = positionAtIndex(actualStartIdx); + Position end = positionAtIndex(endIdx - 1); // because we go pas the actual end in the loop + Range range = new Range(start, end); + return new DocumentId(type, wrapped, range); + } + + private static boolean isIdChar(char c) { + 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 + * @return A copy of the text within the indicies {@code start} and + * {@code end}, or {@code null} if the span is out of bounds or start > end + */ + public String copySpan(int start, int end) { + CharBuffer borrowed = borrowSpan(start, end); + if (borrowed == null) { + return null; + } + return borrowed.toString(); + } + + /** + * @return The length of the document + */ + public int length() { + return buffer.length(); + } + + /** + * @param index The index to get the character at + * @return The character at the given index, or {@code \u0000} if one + * doesn't exist + */ + char charAt(int index) { + if (index < 0 || index >= length()) { + return '\u0000'; + } + return buffer.charAt(index); + } + + // Adapted from String::split + private static int[] computeLineIndicies(StringBuilder buffer) { + int matchCount = 0; + int off = 0; + int next; + // Have to box sadly, unless there's some IntArray I'm not aware of. Maybe IntBuffer + List indicies = new ArrayList<>(); + indicies.add(0); + // This works with \r\n line breaks by basically forgetting about the \r, since we don't actually + // care about the content of the line + while ((next = buffer.indexOf("\n", off)) != -1) { + indicies.add(next + 1); + off = next + 1; + ++matchCount; + } + return indicies.stream().mapToInt(Integer::intValue).toArray(); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java new file mode 100644 index 00000000..f2de2fea --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -0,0 +1,72 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.nio.CharBuffer; +import org.eclipse.lsp4j.Range; + +/** + * An inaccurate representation of an identifier within a model. It is + * inaccurate in the sense that the string value it references isn't + * necessarily a valid identifier, it just looks like an identifier. + */ +public final class DocumentId { + /** + * Represents the different kinds of identifiers that can be used to match. + */ + public enum Type { + /** + * Just a shape name, no namespace or member. + */ + ID, + + /** + * Same as {@link Type#ID}, but with a namespace. + */ + ABSOLUTE_ID, + + /** + * Just a namespace - will have one or more {@code .}. + */ + NAMESPACE, + + /** + * Same as {@link Type#ABSOLUTE_ID}, but with a member - will have a {@code $}. + */ + ABSOLUTE_WITH_MEMBER, + + /** + * Same as {@link Type#ID}, but with a member - will have a {@code $}. + */ + RELATIVE_WITH_MEMBER; + } + + private final Type type; + private final CharBuffer buffer; + private final Range range; + + DocumentId(Type type, CharBuffer buffer, Range range) { + this.type = type; + this.buffer = buffer; + this.range = range; + } + + public Type type() { + return type; + } + + public String copyIdValue() { + return buffer.toString(); + } + + public CharBuffer borrowIdValue() { + return buffer; + } + + public Range range() { + return range; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java new file mode 100644 index 00000000..057aaa50 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.util.Set; +import org.eclipse.lsp4j.Range; + +/** + * The imports of a document, including the range they occupy. + */ +public final class DocumentImports { + private final Range importsRange; + private final Set imports; + + DocumentImports(Range importsRange, Set imports) { + this.importsRange = importsRange; + this.imports = imports; + } + + /** + * @return The range of the imports + */ + public Range importsRange() { + return importsRange; + } + + /** + * @return The set of imported shape ids. They are not guaranteed + * to be valid shape ids + */ + public Set imports() { + return imports; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java new file mode 100644 index 00000000..52181a6e --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java @@ -0,0 +1,36 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import org.eclipse.lsp4j.Range; + +/** + * The namespace of the document, including the range it occupies. + */ +public final class DocumentNamespace { + private final Range statementRange; + private final CharSequence namespace; + + DocumentNamespace(Range statementRange, CharSequence namespace) { + this.statementRange = statementRange; + this.namespace = namespace; + } + + /** + * @return The range of the statement, including {@code namespace} + */ + public Range statementRange() { + return statementRange; + } + + /** + * @return The namespace of the document. Not guaranteed to be + * a valid namespace + */ + public CharSequence namespace() { + return namespace; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java new file mode 100644 index 00000000..f69d0f19 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -0,0 +1,727 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.ParserUtils; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.utils.SimpleParser; + +/** + * 'Parser' that uses the line-indexed property of the underlying {@link Document} + * to jump around the document, parsing small pieces without needing to start at + * the beginning. + * + *

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

Methods on this class often return {@code -1} or {@code null} for failure + * cases to reduce allocations, since these methods may be called frequently. + */ +public final class DocumentParser extends SimpleParser { + private final Document document; + + private DocumentParser(Document document) { + super(document.borrowText()); + this.document = document; + } + + static DocumentParser of(String text) { + return DocumentParser.forDocument(Document.of(text)); + } + + /** + * @param document Document to create a parser for + * @return A parser for the given document + */ + public static DocumentParser forDocument(Document document) { + return new DocumentParser(document); + } + + /** + * @return The {@link DocumentNamespace} for the underlying document, or + * {@code null} if it couldn't be found + */ + public DocumentNamespace documentNamespace() { + int namespaceStartIdx = firstIndexOfWithOnlyLeadingWs("namespace"); + if (namespaceStartIdx < 0) { + return null; + } + + Position namespaceStatementStartPosition = document.positionAtIndex(namespaceStartIdx); + if (namespaceStatementStartPosition == null) { + // Shouldn't happen on account of the previous check + return null; + } + jumpToPosition(namespaceStatementStartPosition); + skip(); // n + skip(); // a + skip(); // m + skip(); // e + skip(); // s + skip(); // p + skip(); // a + skip(); // c + skip(); // e + + if (!isSp()) { + return null; + } + + sp(); + + if (!isNamespaceChar()) { + return null; + } + + int start = position(); + while (isNamespaceChar()) { + skip(); + } + int end = position(); + CharSequence namespace = document.borrowSpan(start, end); + + consumeRemainingCharactersOnLine(); + Position namespaceStatementEnd = currentPosition(); + + return new DocumentNamespace(new Range(namespaceStatementStartPosition, namespaceStatementEnd), namespace); + } + + /** + * @return The {@link DocumentImports} for the underlying document, or + * {@code null} if they couldn't be found + */ + public DocumentImports documentImports() { + // TODO: What if its 'uses', not just 'use'? + // Should we look for another? + int firstUseStartIdx = firstIndexOfWithOnlyLeadingWs("use"); + if (firstUseStartIdx < 0) { + // No use + return null; + } + + Position firstUsePosition = document.positionAtIndex(firstUseStartIdx); + if (firstUsePosition == null) { + // Shouldn't happen on account of the previous check + return null; + } + rewind(firstUseStartIdx, firstUsePosition.getLine() + 1, firstUsePosition.getCharacter() + 1); + + Set imports = new HashSet<>(); + Position lastUseEnd; // At this point we know there's at least one + do { + skip(); // u + skip(); // s + skip(); // e + + String id = getImport(); // handles skipping the ws + if (id != null) { + imports.add(id); + } + consumeRemainingCharactersOnLine(); + lastUseEnd = currentPosition(); + nextNonWsNonComment(); + } while (isUse()); + + if (imports.isEmpty()) { + return null; + } + + return new DocumentImports(new Range(firstUsePosition, lastUseEnd), imports); + } + + /** + * @param shapes The shapes defined in the underlying document + * @return A map of the starting positions of shapes defined or referenced + * in the underlying document to their corresponding {@link DocumentShape} + */ + public Map documentShapes(Set shapes) { + Map documentShapes = new HashMap<>(shapes.size()); + for (Shape shape : shapes) { + if (!jumpToSource(shape.getSourceLocation())) { + continue; + } + + if (shape.isMemberShape()) { + DocumentShape.Kind kind = DocumentShape.Kind.DefinedMember; + if (is('$')) { + kind = DocumentShape.Kind.Elided; + } + DocumentShape member = documentShape(kind); + documentShapes.put(member.range().getStart(), member); + sp(); + if (peek() == ':') { + skip(); + // get target + sp(); + DocumentShape target = documentShape(DocumentShape.Kind.Targeted); + documentShapes.put(target.range().getStart(), target); + member.setTargetReference(target); + } + } else { + skipAlpha(); // shape type + sp(); + DocumentShape shapeDef = documentShape(DocumentShape.Kind.DefinedShape); + if (shapeDef.shapeName().length() == 0) { + // Not sure if we should set the shape name here + shapeDef.setKind(DocumentShape.Kind.Inline); + } + documentShapes.put(shapeDef.range().getStart(), shapeDef); + } + } + return documentShapes; + } + + private DocumentShape documentShape(DocumentShape.Kind kind) { + Position start = currentPosition(); + int startIdx = position(); + if (kind == DocumentShape.Kind.Elided) { + skip(); // '$' + startIdx = position(); // so the name doesn't contain '$' - we need to match it later + } + skipIdentifier(); // shape name + Position end = currentPosition(); + int endIdx = position(); + Range range = new Range(start, end); + CharSequence shapeName = document.borrowSpan(startIdx, endIdx); + return new DocumentShape(range, shapeName, kind); + } + + /** + * @return The {@link DocumentVersion} for the underlying document, or + * {@code null} if it couldn't be found + */ + public DocumentVersion documentVersion() { + firstIndexOfNonWsNonComment(); + if (!is('$')) { + return null; + } + while (is('$') && !isVersion()) { + // Skip this line + if (!jumpToLine(line())) { + return null; + } + // Skip any ws and docs + nextNonWsNonComment(); + } + + // Found a non-control statement before version. + if (!is('$')) { + return null; + } + + Position start = currentPosition(); + skip(); // $ + skipAlpha(); // version + sp(); + if (!is(':')) { + return null; + } + skip(); // ':' + sp(); + int nodeStartCharacter = column() - 1; + CharSequence span = document.borrowSpan(position(), document.lineEnd(line() - 1) + 1); + if (span == null) { + return null; + } + + // TODO: Ew + Node node; + try { + node = StringNode.parseJsonWithComments(span.toString()); + } catch (Exception e) { + return null; + } + + if (node.isStringNode()) { + String version = node.expectStringNode().getValue(); + int end = nodeStartCharacter + version.length() + 2; // ? + Range range = 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; + } + + /** + * Jumps the parser location to the given {@code source}. + * + * @param source The location to jump to. The filename must be the same as + * the underlying document's filename (this is not checked) + * @return Whether the parser successfully jumped + */ + public boolean jumpToSource(SourceLocation source) { + int idx = this.document.indexOfPosition(source.getLine() - 1, source.getColumn() - 1); + if (idx < 0) { + return false; + } + this.rewind(idx, source.getLine(), source.getColumn()); + return true; + } + + /** + * @return The current position of the parser + */ + public Position currentPosition() { + return new Position(line() - 1, column() - 1); + } + + /** + * @return The underlying document + */ + public Document getDocument() { + return this.document; + } + + /** + * @param position The position in the document to check + * @return The context at that position + */ + public DocumentPositionContext determineContext(Position position) { + // TODO: Support additional contexts + // Also can compute these in one pass probably. + if (isTrait(position)) { + return DocumentPositionContext.TRAIT; + } else if (isMemberTarget(position)) { + return DocumentPositionContext.MEMBER_TARGET; + } else if (isShapeDef(position)) { + return DocumentPositionContext.SHAPE_DEF; + } else if (isMixin(position)) { + return DocumentPositionContext.MIXIN; + } else 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; + } + + private boolean isWs() { + return isNl() || isSp(); + } + + private boolean isNl() { + return is('\n') || is('\r'); + } + + private boolean isSp() { + return is(' ') || is('\t'); + } + + private boolean isWs(int offset) { + char peeked = peek(offset); + switch (peeked) { + case '\n': + case '\r': + case ' ': + case '\t': + return true; + default: + return false; + } + } + + private boolean isEof() { + return is(EOF); + } + + private boolean isShapeIdChar() { + return isIdentifierChar() || is('#') || is('.') || is('$'); + } + + private void skipShapeId() { + while (isShapeIdChar()) { + skip(); + } + } + + private boolean isShapeIdChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; + } + + private boolean isNamespaceChar() { + return isIdentifierChar() || is('.'); + } + + private boolean isShapeType() { + CharSequence token = document.borrowToken(currentPosition()); + if (token == null) { + return false; + } + + switch (token.toString()) { + case "structure": + case "operation": + case "string": + case "integer": + case "list": + case "map": + case "boolean": + case "enum": + case "union": + case "blob": + case "byte": + case "short": + case "long": + case "float": + case "double": + case "timestamp": + case "intEnum": + case "document": + case "service": + case "resource": + case "bigDecimal": + case "bigInteger": + return true; + default: + return false; + } + } + + private int firstIndexOfWithOnlyLeadingWs(String s) { + return nextIndexOfWithOnlyLeadingWs(s, 0, document.length()); + } + + private int nextIndexOfWithOnlyLeadingWs(String s, int start, int end) { + int searchFrom = start; + int previousSearchFrom; + do { + int idx = document.nextIndexOf(s, searchFrom); + if (idx < 0) { + return -1; + } + int lineStart = document.lastIndexOf(System.lineSeparator(), idx) + 1; + if (idx == lineStart) { + return idx; + } + CharSequence before = document.borrowSpan(lineStart, idx); + if (before == null) { + return -1; + } + if (before.chars().allMatch(Character::isWhitespace)) { + return idx; + } + previousSearchFrom = searchFrom; + searchFrom = idx + 1; + } while (previousSearchFrom != searchFrom && searchFrom < end); + return -1; + } + + private int firstIndexOfNonWsNonComment() { + reset(); + do { + ws(); + if (is('/')) { + consumeRemainingCharactersOnLine(); + } + } while (isWs()); + return position(); + } + + private void nextNonWsNonComment() { + do { + ws(); + if (is('/')) { + consumeRemainingCharactersOnLine(); + } + } while (isWs()); + } + + private void reset() { + rewind(0, 1, 1); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java new file mode 100644 index 00000000..d961bddf --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.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 new file mode 100644 index 00000000..6ea1b4de --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java @@ -0,0 +1,100 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import java.util.Objects; +import org.eclipse.lsp4j.Range; + +/** + * A Shape definition OR reference within a document, including the range it occupies. + * + *

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

- * This version of the method above is used when the - * file is in ephemeral storage (temporary location when file is being changed) - * - * @param changed file which may or may not be already tracked by this project. - * @param exclude file to exclude from being recompiled. - * @return either an error, or a loaded project. - */ - public Either recompile(File changed, File exclude) { - HashSet fileSet = new HashSet<>(); - - for (File existing : onlyExistingFiles(this.smithyFiles)) { - if (exclude != null && !existing.equals(exclude)) { - fileSet.add(existing); - } - } - - if (changed.isFile()) { - fileSet.add(changed); - } - - return load(this.imports, new ArrayList<>(fileSet), this.externalJars, this.root); - } - - public ValidatedResult getModel() { - return this.model; - } - - public List getExternalJars() { - return this.externalJars; - } - - public List getSmithyFiles() { - return this.smithyFiles; - } - - public List getCompletions(String token, boolean isTrait, Optional target) { - return this.model.getResult().map(model -> Completions.find(model, token, isTrait, target)) - .orElse(Collections.emptyList()); - } - - public Map getLocations() { - return this.locations; - } - - /** - * Load the project using a SmithyBuildExtensions configuration and workspace - * root. - * - * @param config configuration. - * @param root workspace root. - * @return either an error or a loaded project. - */ - public static Either load(SmithyBuildExtensions config, File root, - DependencyResolver resolver) { - List imports = config.getImports().stream().map(p -> Paths.get(root.getAbsolutePath(), p).normalize()) - .collect(Collectors.toList()); - - if (imports.isEmpty()) { - imports.add(root.toPath()); - } - - LspLog.println("Imports from config: " + imports + " will be resolved against root " + root); - - List smithyFiles = discoverSmithyFiles(imports, root); - LspLog.println("Discovered smithy files: " + smithyFiles); - - List externalJars = downloadExternalDependencies(config, resolver); - LspLog.println("Downloaded external jars: " + externalJars); - - return load(imports, smithyFiles, externalJars, root); - - } - - /** - * Run a selector expression against the loaded model in the workspace. - * @param expression the selector expression. - * @return list of locations of shapes that match expression. - */ - public Either> runSelector(String expression) { - try { - Selector selector = Selector.parse(expression); - Set shapes = selector.select(this.model.unwrap()); - return Either.forRight(shapes.stream() - .map(shape -> this.locations.get(shape.getId())) - .collect(Collectors.toList())); - } catch (ValidatedResultException e) { - return Either.forLeft(e); - } - } - - private static Either load( - List imports, - List smithyFiles, - List externalJars, - File root - ) { - Either> model = createModel(smithyFiles, externalJars); - - if (model.isLeft()) { - return Either.forLeft(model.getLeft()); - } else { - model.getRight().getValidationEvents().forEach(LspLog::println); - - try { - SmithyProject p = new SmithyProject(imports, smithyFiles, externalJars, root, model.getRight()); - return Either.forRight(p); - } catch (Exception e) { - return Either.forLeft(e); - } - } - } - - private static Either> createModel( - List discoveredFiles, - List externalJars - ) { - return SmithyInterface.readModel(discoveredFiles, externalJars); - } - - public File getRoot() { - return this.root; - } - - private static Map collectLocations(Model model) { - ShapeLocationCollector collector = new FileCachingCollector(); - return collector.collectDefinitionLocations(model); - } - - /** - * Returns the shapeId of the shape that corresponds to the file uri and position within the model. - * - * @param uri String uri of model file. - * @param position Cursor position within model file. - * @return ShapeId of corresponding shape defined at location. - */ - public Optional getShapeIdFromLocation(String uri, Position position) { - Comparator> rangeSize = Comparator.comparing(entry -> - entry.getValue().getRange().getEnd().getLine() - entry.getValue().getRange().getStart().getLine()); - return locations.entrySet().stream() - .filter(entry -> entry.getValue().getUri().endsWith(Paths.get(uri).toString())) - .filter(entry -> isPositionInRange(entry.getValue().getRange(), position)) - // Since the position is in each of the overlapping shapes, return the location with the smallest range. - .sorted(rangeSize) - .map(Map.Entry::getKey) - .findFirst(); - } - - private boolean isPositionInRange(Range range, Position position) { - if (range.getStart().getLine() > position.getLine()) { - return false; - } - if (range.getEnd().getLine() < position.getLine()) { - return false; - } - // For single-line ranges, make sure position is between start and end chars. - if (range.getStart().getLine() == position.getLine() - && range.getEnd().getLine() == position.getLine()) { - return (range.getStart().getCharacter() <= position.getCharacter() - && range.getEnd().getCharacter() >= position.getCharacter()); - } else if (range.getStart().getLine() == position.getLine()) { - return range.getStart().getCharacter() <= position.getCharacter(); - } else if (range.getEnd().getLine() == position.getLine()) { - return range.getEnd().getCharacter() >= position.getCharacter(); - } - return true; - } - - private static Boolean isValidSmithyFile(Path file) { - String fName = file.getFileName().toString(); - return fName.endsWith(Constants.SMITHY_EXTENSION); - } - - private static List walkSmithyFolder(Path path, File root) { - - try (Stream walk = Files.walk(path)) { - return walk.filter(Files::isRegularFile).filter(SmithyProject::isValidSmithyFile).map(Path::toFile) - .collect(Collectors.toList()); - } catch (IOException e) { - LspLog.println("Failed to walk import '" + path + "' from root " + root + ": " + e); - return new ArrayList<>(); - } - } - - private static List discoverSmithyFiles(List imports, File root) { - List smithyFiles = new ArrayList<>(); - - imports.forEach(path -> { - if (Files.isDirectory(path)) { - smithyFiles.addAll(walkSmithyFolder(path, root)); - } else if (isValidSmithyFile(path)) { - smithyFiles.add(path.resolve(root.toPath()).toFile()); - } - }); - return smithyFiles; - } - - private static List downloadExternalDependencies(SmithyBuildExtensions extensions, - DependencyResolver resolver) { - LspLog.println("Downloading external dependencies for " + extensions); - try { - addConfiguredMavenRepos(extensions, resolver); - extensions.getMavenConfig().getDependencies().forEach(resolver::addDependency); - - return resolver.resolve().stream() - .map(artifact -> artifact.getPath().toFile()).collect(Collectors.toList()); - } catch (Exception e) { - LspLog.println("Failed to download external jars for " + extensions + ": " + e); - return Collections.emptyList(); - } - } - - private static void addConfiguredMavenRepos(SmithyBuildExtensions extensions, DependencyResolver resolver) { - // Environment variables take precedence over config files. - String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get(); - if (envRepos != null) { - for (String repo : envRepos.split("\\|")) { - resolver.addRepository(MavenRepository.builder().url(repo.trim()).build()); - } - } - - Set configuredRepos = extensions.getMavenConfig().getRepositories(); - - if (!configuredRepos.isEmpty()) { - configuredRepos.forEach(resolver::addRepository); - } else if (envRepos == null) { - LspLog.println(String.format("maven.repositories is not defined in smithy-build.json and the %s " - + "environment variable is not set. Defaulting to Maven Central.", - EnvironmentVariable.SMITHY_MAVEN_REPOS)); - resolver.addRepository(CENTRAL); - } - } - - private static List onlyExistingFiles(Collection files) { - return files.stream().filter(File::isFile).collect(Collectors.toList()); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java b/src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java deleted file mode 100644 index 98e82441..00000000 --- a/src/main/java/software/amazon/smithy/lsp/ext/ValidationException.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -/** - * General exception thrown during loading of Smithy build files. - */ -public class ValidationException extends Exception { - public ValidationException(String msg) { - super(msg); - } -} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java new file mode 100644 index 00000000..3eaf45df --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/OpenProject.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.ext.serverstatus; + +import java.util.List; +import org.eclipse.lsp4j.jsonrpc.validation.NonNull; + +/** + * A snapshot of a project the server has open. + */ +public class OpenProject { + @NonNull + private final String root; + @NonNull + private final List files; + private final boolean isDetached; + + /** + * @param root The root URI of the project + * @param files The list of all file URIs tracked by the project + * @param isDetached Whether the project is detached + */ + public OpenProject(@NonNull final String root, @NonNull final List files, boolean isDetached) { + this.root = root; + this.files = files; + this.isDetached = isDetached; + } + + /** + * @return The root directory of the project + */ + public String root() { + return root; + } + + /** + * @return The list of all file URIs tracked by the project + */ + public List files() { + return files; + } + + /** + * @return Whether the project is detached - tracking just a single open file + */ + public boolean isDetached() { + return isDetached; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java new file mode 100644 index 00000000..41372721 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/ext/serverstatus/ServerStatus.java @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.ext.serverstatus; + +import java.util.List; +import org.eclipse.lsp4j.jsonrpc.validation.NonNull; + +/** + * A snapshot of the server status, containing the projects it has open. + * We can add more here later as we see fit. + */ +public class ServerStatus { + @NonNull + private final List openProjects; + + public ServerStatus(@NonNull final List openProjects) { + this.openProjects = openProjects; + } + + /** + * @return The open projects tracked by the server + */ + public List openProjects() { + return openProjects; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java new file mode 100644 index 00000000..87208401 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java @@ -0,0 +1,325 @@ +/* + * 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.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.borrowIdValue().length() == 0) { + return Collections.emptyList(); + } + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + Optional modelResult = project.modelResult().getResult(); + if (!modelResult.isPresent()) { + 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)) + // TODO: Use mapMulti when we upgrade jdk>16 + .collect(ArrayList::new, completionsFactory(context, model, smithyFile, id), ArrayList::addAll); + } + + private static BiConsumer, Shape> completionsFactory( + DocumentPositionContext context, + Model model, + SmithyFile smithyFile, + DocumentId id + ) { + TraitBodyVisitor visitor = new TraitBodyVisitor(model); + boolean useFullId = shouldMatchOnAbsoluteId(id, context); + return (acc, shape) -> { + 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); + acc.add(traitWithMembersItem); + } + + if (shape.isStructureShape() && !shape.members().isEmpty()) { + shapeLabel += "()"; + } + CompletionItem defaultCompletionItem = createCompletion( + shapeLabel, shape.getId(), smithyFile, useFullId, id); + acc.add(defaultCompletionItem); + break; + case MEMBER_TARGET: + case MIXIN: + case USE_TARGET: + acc.add(createCompletion(shapeLabel, shape.getId(), smithyFile, useFullId, id)); + break; + case SHAPE_DEF: + case OTHER: + default: + break; + } + }; + } + + private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, SmithyFile smithyFile) { + String importId = shapeId.toString(); + String importNamespace = shapeId.getNamespace(); + CharSequence currentNamespace = smithyFile.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) { + switch (context) { + case TRAIT: + return model.getShapesWithTrait(TraitDefinition.class).stream(); + case MEMBER_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.hasTrait(TraitDefinition.class)); + case MIXIN: + return model.getShapesWithTrait(MixinTrait.class).stream(); + case USE_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.getId().getNamespace().contentEquals(smithyFile.namespace())) + .filter(shape -> !smithyFile.hasImport(shape.getId().toString())); + case SHAPE_DEF: + case OTHER: + default: + return 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 new file mode 100644 index 00000000..a3deb370 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java @@ -0,0 +1,96 @@ +/* + * 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.borrowIdValue().length() == 0) { + return Collections.emptyList(); + } + + Optional modelResult = project.modelResult().getResult(); + if (!modelResult.isPresent()) { + 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) { + switch (context) { + case TRAIT: + return model.getShapesWithTrait(TraitDefinition.class).stream(); + case MEMBER_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.hasTrait(TraitDefinition.class)); + case MIXIN: + return model.getShapesWithTrait(MixinTrait.class).stream(); + case SHAPE_DEF: + case OTHER: + default: + return model.shapes().filter(shape -> !shape.isMemberShape()); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java new file mode 100644 index 00000000..08c61ff0 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandler.java @@ -0,0 +1,112 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.handler; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; +import org.eclipse.lsp4j.FileSystemWatcher; +import org.eclipse.lsp4j.Registration; +import org.eclipse.lsp4j.Unregistration; +import org.eclipse.lsp4j.WatchKind; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectConfigLoader; + +/** + * Handles computing the {@link Registration}s and {@link Unregistration}s for + * that tell the client which files and directories to watch for changes + * + *

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

Clients don't de-duplicate file watchers, so we have to unregister all + * file watchers before sending a new list to watch, or keep track of them to make + * more granular changes. The current behavior is to just unregister and re-register + * everything, since these events should be rarer. But we can optimize it in the + * future. + */ +public final class FileWatcherRegistrationHandler { + private static final Integer SMITHY_WATCH_FILE_KIND = WatchKind.Delete | WatchKind.Create; + private static final String WATCH_BUILD_FILES_ID = "WatchSmithyBuildFiles"; + private static final String WATCH_SMITHY_FILES_ID = "WatchSmithyFiles"; + private static final String WATCH_FILES_METHOD = "workspace/didChangeWatchedFiles"; + private static final List BUILD_FILE_WATCHER_REGISTRATIONS; + private static final List SMITHY_FILE_WATCHER_UNREGISTRATIONS; + + static { + // smithy-build.json + .smithy-project.json + build exts + int buildFileWatcherCount = 2 + ProjectConfigLoader.SMITHY_BUILD_EXTS.length; + List buildFileWatchers = new ArrayList<>(buildFileWatcherCount); + buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_BUILD))); + buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ProjectConfigLoader.SMITHY_PROJECT))); + for (String ext : ProjectConfigLoader.SMITHY_BUILD_EXTS) { + buildFileWatchers.add(new FileSystemWatcher(Either.forLeft(ext))); + } + + BUILD_FILE_WATCHER_REGISTRATIONS = Collections.singletonList(new Registration( + WATCH_BUILD_FILES_ID, + WATCH_FILES_METHOD, + new DidChangeWatchedFilesRegistrationOptions(buildFileWatchers))); + + SMITHY_FILE_WATCHER_UNREGISTRATIONS = Collections.singletonList(new Unregistration( + WATCH_SMITHY_FILES_ID, + WATCH_FILES_METHOD)); + } + + private FileWatcherRegistrationHandler() { + } + + /** + * @return The registrations to watch for build file changes + */ + public static List getBuildFileWatcherRegistrations() { + return BUILD_FILE_WATCHER_REGISTRATIONS; + } + + /** + * @param project The Project to get registrations for + * @return The registrations to watch for Smithy file changes + */ + public static List getSmithyFileWatcherRegistrations(Project project) { + List smithyFileWatchers = Stream.concat(project.sources().stream(), + project.imports().stream()) + .map(FileWatcherRegistrationHandler::smithyFileWatcher) + .collect(Collectors.toList()); + + return Collections.singletonList(new Registration( + WATCH_SMITHY_FILES_ID, + WATCH_FILES_METHOD, + new DidChangeWatchedFilesRegistrationOptions(smithyFileWatchers))); + } + + /** + * @return The unregistrations to stop watching for Smithy file changes + */ + public static List getSmithyFileWatcherUnregistrations() { + return SMITHY_FILE_WATCHER_UNREGISTRATIONS; + } + + private static FileSystemWatcher smithyFileWatcher(Path path) { + String glob = path.toString(); + if (!glob.endsWith(".smithy") && !glob.endsWith(".json")) { + // we have a directory + if (glob.endsWith("/")) { + glob = glob + "**/*.{smithy,json}"; + } else { + glob = glob + "/**/*.{smithy,json}"; + } + } + // Watch the absolute path, either a directory or file + return new FileSystemWatcher(Either.forLeft(glob), SMITHY_WATCH_FILE_KIND); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java new file mode 100644 index 00000000..fdf4d06d --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java @@ -0,0 +1,173 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.handler; + +import static java.util.regex.Matcher.quoteReplacement; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.document.DocumentParser; +import software.amazon.smithy.lsp.document.DocumentPositionContext; +import software.amazon.smithy.lsp.project.Project; +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.borrowIdValue().length() == 0) { + return hover; + } + + ValidatedResult modelResult = project.modelResult(); + if (!modelResult.getResult().isPresent()) { + 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.isPresent()) { + return hover; + } + + Shape shapeToSerialize = matchingShape.get(); + + SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder() + .metadataFilter(key -> false) + .shapeFilter(s -> s.getId().equals(shapeToSerialize.getId())) + // TODO: If we remove the documentation trait in the serializer, + // it also gets removed from members. This causes weird behavior if + // there are applied traits (such as through mixins), where you get + // an empty apply because the documentation trait was removed + // .traitFilter(trait -> !trait.toShapeId().equals(DocumentationTrait.ID)) + .serializePrelude() + .build(); + Map serialized = serializer.serialize(model); + Path path = Paths.get(shapeToSerialize.getId().getNamespace() + ".smithy"); + if (!serialized.containsKey(path)) { + return hover; + } + + StringBuilder hoverContent = new StringBuilder(); + List validationEvents = modelResult.getValidationEvents().stream() + .filter(event -> event.getShapeId().isPresent()) + .filter(event -> event.getShapeId().get().equals(shapeToSerialize.getId())) + .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0) + .collect(Collectors.toList()); + if (!validationEvents.isEmpty()) { + for (ValidationEvent event : validationEvents) { + hoverContent.append("**") + .append(event.getSeverity()) + .append("**") + .append(": ") + .append(event.getMessage()); + } + hoverContent.append("\n\n---\n\n"); + } + + String serializedShape = serialized.get(path) + .substring(15) // remove '$version: "2.0"' + .trim() + .replaceAll(quoteReplacement("\n\n"), "\n"); + int eol = serializedShape.indexOf('\n'); + String namespaceLine = serializedShape.substring(0, eol); + serializedShape = serializedShape.substring(eol + 1); + hoverContent.append(String.format("```smithy\n" + + "%s\n" + + "%s\n" + + "```\n", namespaceLine, serializedShape)); + + // TODO: Add docs to a separate section of the hover content + // if (shapeToSerialize.hasTrait(DocumentationTrait.class)) { + // String docs = shapeToSerialize.expectTrait(DocumentationTrait.class).getValue(); + // hoverContent.append("\n---\n").append(docs); + // } + + MarkupContent content = new MarkupContent("markdown", hoverContent.toString()); + hover.setContents(content); + return hover; + } + + private 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) { + switch (context) { + case TRAIT: + return model.getShapesWithTrait(TraitDefinition.class).stream(); + case MEMBER_TARGET: + return model.shapes() + .filter(shape -> !shape.isMemberShape()) + .filter(shape -> !shape.hasTrait(TraitDefinition.class)); + case MIXIN: + return model.getShapesWithTrait(MixinTrait.class).stream(); + case SHAPE_DEF: + case OTHER: + default: + return model.shapes().filter(shape -> !shape.isMemberShape()); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/package-info.java b/src/main/java/software/amazon/smithy/lsp/package-info.java new file mode 100644 index 00000000..bdee3e45 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/package-info.java @@ -0,0 +1,12 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The Smithy Language Server. + */ +@SmithyInternalApi +package software.amazon.smithy.lsp; + +import software.amazon.smithy.utils.SmithyInternalApi; diff --git a/src/main/java/software/amazon/smithy/lsp/project/Project.java b/src/main/java/software/amazon/smithy/lsp/project/Project.java new file mode 100644 index 00000000..bd65d284 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java @@ -0,0 +1,438 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.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; +import java.util.stream.Collectors; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.loader.ModelAssembler; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.AbstractShapeBuilder; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.utils.IoUtils; + +/** + * A Smithy project open on the client. It keeps track of its Smithy files and + * dependencies, and the currently loaded model. + */ +public final class Project { + private static final Logger LOGGER = Logger.getLogger(Project.class.getName()); + private final Path root; + private final ProjectConfig config; + private final List dependencies; + 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; + } + + /** + * Create an empty project with no Smithy files, dependencies, or loaded model. + * + * @param root Root path of the project + * @return The empty project + */ + public static Project empty(Path root) { + return builder() + .root(root) + .modelResult(ValidatedResult.empty()) + .build(); + } + + /** + * @return The path of the root directory of the project + */ + public Path root() { + return root; + } + + /** + * @return The paths of all Smithy sources specified + * in this project's smithy build configuration files, + * normalized and resolved against {@link #root()}. + */ + public List sources() { + return config.sources().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); + } + + /** + * @return The paths of all Smithy imports specified + * in this project's smithy build configuration files, + * normalized and resolved against {@link #root()}. + */ + public List imports() { + return config.imports().stream() + .map(root::resolve) + .map(Path::normalize) + .collect(Collectors.toList()); + } + + /** + * @return The paths of all resolved dependencies + */ + public List dependencies() { + return dependencies; + } + + /** + * @return A map of paths to the {@link SmithyFile} at that path, containing + * all smithy files loaded in the project. + */ + public Map smithyFiles() { + return this.smithyFiles; + } + + /** + * @return The latest result of loading this project + */ + public ValidatedResult modelResult() { + return modelResult; + } + + /** + * @param uri The URI of the {@link Document} to get + * @return The {@link Document} corresponding to the given {@code uri} if + * it exists in this project, otherwise {@code null} + */ + public Document getDocument(String uri) { + String path = LspAdapter.toPath(uri); + SmithyFile smithyFile = smithyFiles.get(path); + if (smithyFile == null) { + return null; + } + return smithyFile.document(); + } + + /** + * @param uri The URI of the {@link SmithyFile} to get + * @return The {@link SmithyFile} corresponding to the given {@code uri} if + * it exists in this project, otherwise {@code null} + */ + public SmithyFile getSmithyFile(String uri) { + String path = LspAdapter.toPath(uri); + return smithyFiles.get(path); + } + + /** + * Update this project's model without running validation. + * + * @param uri The URI of the Smithy file to update + */ + public void updateModelWithoutValidating(String uri) { + updateFiles(Collections.emptySet(), Collections.emptySet(), Collections.singleton(uri), false); + } + + /** + * Update this project's model and run validation. + * + * @param uri The URI of the Smithy file to update + */ + public void updateAndValidateModel(String uri) { + updateFiles(Collections.emptySet(), Collections.emptySet(), Collections.singleton(uri), true); + } + + /** + * Updates this project by adding and removing files. Runs model validation. + * + *

Added files are assumed to not be managed by the client, and are loaded from disk. + * + * @param addUris URIs of files to add + * @param removeUris URIs of files to remove + */ + public void updateFiles(Set addUris, Set removeUris) { + updateFiles(addUris, removeUris, Collections.emptySet(), true); + } + + /** + * Updates this project by adding, removing, and changing files. Can optionally run validation. + * + *

Added files are assumed to not be managed by the client, and are loaded from disk. + * + * @param addUris URIs of files to add + * @param removeUris URIs of files to remove + * @param changeUris URIs of files that changed + * @param validate Whether to run model validation. + */ + public void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) { + if (!modelResult.getResult().isPresent()) { + // TODO: If there's no model, we didn't collect the smithy files (so no document), so I'm thinking + // maybe we do nothing here. But we could also still update the document, and + // just compute the shapes later? + LOGGER.severe("Attempted to update files in project with no model: " + + addUris + " " + removeUris + " " + changeUris); + return; + } + + if (addUris.isEmpty() && removeUris.isEmpty() && changeUris.isEmpty()) { + LOGGER.info("No files provided to update"); + return; + } + + Model currentModel = modelResult.getResult().get(); // unwrap would throw if the model is broken + ModelAssembler assembler = assemblerFactory.get(); + + // So we don't have to recompute the paths later + Set removedPaths = new HashSet<>(removeUris.size()); + Set changedPaths = new HashSet<>(changeUris.size()); + + Set visited = new HashSet<>(); + + if (!removeUris.isEmpty() || !changeUris.isEmpty()) { + Model.Builder builder = prepBuilderForReload(currentModel); + + for (String uri : removeUris) { + String path = LspAdapter.toPath(uri); + removedPaths.add(path); + + removeFileForReload(assembler, builder, path, visited); + removeDependentsForReload(assembler, builder, path, visited); + + // Note: no need to remove anything from sources/imports, since they're + // based on what's in the build files. + smithyFiles.remove(path); + } + + for (String uri : changeUris) { + String path = LspAdapter.toPath(uri); + changedPaths.add(path); + + removeFileForReload(assembler, builder, path, visited); + removeDependentsForReload(assembler, builder, path, visited); + } + + // visited will be a superset of removePaths + addRemainingMetadataForReload(builder, visited); + + assembler.addModel(builder.build()); + + for (String visitedPath : visited) { + // Only add back stuff we aren't trying to remove. + // Only removed paths will have had their SmithyFile removed. + if (!removedPaths.contains(visitedPath)) { + assembler.addUnparsedModel(visitedPath, smithyFiles.get(visitedPath).document().copyText()); + } + } + } else { + assembler.addModel(currentModel); + } + + for (String uri : addUris) { + assembler.addImport(LspAdapter.toPath(uri)); + } + + if (!validate) { + assembler.disableValidation(); + } + + this.modelResult = assembler.assemble(); + this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult); + this.smithyFileDependenciesIndex = SmithyFileDependenciesIndex.compute(this.modelResult); + + for (String visitedPath : visited) { + if (!removedPaths.contains(visitedPath)) { + SmithyFile current = smithyFiles.get(visitedPath); + Set updatedShapes = getFileShapes(visitedPath, smithyFiles.get(visitedPath).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 mainly exists to explain why we remove the metadata + private Model.Builder prepBuilderForReload(Model model) { + return model.toBuilder() + // clearing the metadata here, and adding back only metadata from other files + // is the only sure-fire way to make sure everything is truly removed, and we + // don't lose anything + .clearMetadata(); + } + + private void removeFileForReload( + ModelAssembler assembler, + Model.Builder builder, + String path, + Set visited + ) { + if (path == null || visited.contains(path) || path.equals(SourceLocation.none().getFilename())) { + return; + } + + visited.add(path); + + for (Shape shape : smithyFiles.get(path).shapes()) { + builder.removeShape(shape.getId()); + + // This shape may have traits applied to it in other files, + // so simply removing the shape loses the information about + // those traits. + + // This shape's dependencies files will be removed and re-loaded + smithyFileDependenciesIndex.getDependenciesFiles(shape).forEach((depPath) -> + removeFileForReload(assembler, builder, depPath, visited)); + + // Traits applied in other files are re-added to the assembler so if/when the shape + // is reloaded, it will have those traits + smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(shape).forEach((trait) -> + assembler.addTrait(shape.getId(), trait)); + } + } + + private void removeDependentsForReload( + ModelAssembler assembler, + Model.Builder builder, + String path, + Set visited + ) { + // This file may apply traits to shapes in other files. Normally, letting the assembler simply reparse + // the file would be fine because it would ignore the duplicated trait application coming from the same + // source location. But if the apply statement is changed/removed, the old application isn't removed, so we + // could get a duplicate trait, or a merged array trait. + smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) -> { + removeFileForReload(assembler, builder, depPath, visited); + }); + smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> { + Shape shape = builder.getCurrentShapes().get(shapeId); + if (shape != null) { + builder.removeShape(shapeId); + AbstractShapeBuilder b = Shape.shapeToBuilder(shape); + for (Trait trait : traits) { + b.removeTrait(trait.toShapeId()); + } + builder.addShape(b.build()); + } + }); + } + + private void addRemainingMetadataForReload(Model.Builder builder, Set filesToSkip) { + for (Map.Entry> e : this.perFileMetadata.entrySet()) { + if (!filesToSkip.contains(e.getKey())) { + e.getValue().forEach(builder::putMetadataProperty); + } + } + } + + private Set getFileShapes(String path, Set orDefault) { + return this.modelResult.getResult() + .map(model -> model.shapes() + .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() { + } + + public Builder root(Path root) { + this.root = root; + return this; + } + + public Builder config(ProjectConfig config) { + this.config = config; + return this; + } + + public Builder dependencies(List paths) { + this.dependencies.clear(); + this.dependencies.addAll(paths); + return this; + } + + public Builder addDependency(Path path) { + this.dependencies.add(path); + return this; + } + + public Builder smithyFiles(Map smithyFiles) { + this.smithyFiles.clear(); + this.smithyFiles.putAll(smithyFiles); + return this; + } + + public Builder modelResult(ValidatedResult modelResult) { + this.modelResult = modelResult; + return this; + } + + public Builder assemblerFactory(Supplier assemblerFactory) { + this.assemblerFactory = assemblerFactory; + return this; + } + + public Builder perFileMetadata(Map> perFileMetadata) { + this.perFileMetadata = perFileMetadata; + return this; + } + + public Builder smithyFileDependenciesIndex(SmithyFileDependenciesIndex smithyFileDependenciesIndex) { + this.smithyFileDependenciesIndex = smithyFileDependenciesIndex; + return this; + } + + public Project build() { + return new Project(this); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java new file mode 100644 index 00000000..33e5ec21 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfig.java @@ -0,0 +1,155 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.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 final List sources; + private final List imports; + private final String outputDirectory; + private final List dependencies; + private final MavenConfig mavenConfig; + + private ProjectConfig(Builder builder) { + this.sources = builder.sources; + this.imports = builder.imports; + this.outputDirectory = builder.outputDirectory; + this.dependencies = builder.dependencies; + this.mavenConfig = builder.mavenConfig; + } + + static ProjectConfig empty() { + return builder().build(); + } + + static Builder builder() { + return new Builder(); + } + + /** + * @return All explicitly configured sources + */ + public List sources() { + return sources; + } + + /** + * @return All explicitly configured imports + */ + public List imports() { + return imports; + } + + /** + * @return The configured output directory, if one is present + */ + public Optional outputDirectory() { + return Optional.ofNullable(outputDirectory); + } + + /** + * @return All configured external (non-maven) dependencies + */ + public List dependencies() { + return dependencies; + } + + /** + * @return The Maven configuration, if present + */ + public Optional maven() { + return Optional.ofNullable(mavenConfig); + } + + static final class Builder { + final List sources = new ArrayList<>(); + final List imports = new ArrayList<>(); + String outputDirectory; + final List dependencies = new ArrayList<>(); + MavenConfig mavenConfig; + + private Builder() { + } + + 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); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.java new file mode 100644 index 00000000..c299ecea --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectConfigLoader.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.project; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.util.Result; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeMapper; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.utils.IoUtils; + +/** + * Loads {@link ProjectConfig}s from a given root directory + * + *

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

Aggregation is done as follows: + *

    + *
  1. + * Start with an empty {@link SmithyBuildConfig.Builder}. This will + * aggregate {@link SmithyBuildConfig} and {@link SmithyBuildExtensions} + *
  2. + *
  3. + * If a smithy-build.json exists, try to load it. If one doesn't exist, + * use an empty {@link SmithyBuildConfig} (with version "1"). Merge the result + * into the builder + *
  4. + *
  5. + * If any {@link ProjectConfigLoader#SMITHY_BUILD_EXTS} exist, try to load + * and merge them into a single {@link SmithyBuildExtensions.Builder} + *
  6. + *
  7. + * If a {@link ProjectConfigLoader#SMITHY_PROJECT} exists, try to load it. + * Otherwise use an empty {@link ProjectConfig.Builder}. This will be the + * result of the load + *
  8. + *
  9. + * Merge any {@link ProjectConfigLoader#SMITHY_BUILD_EXTS} into the original + * {@link SmithyBuildConfig.Builder} and build it + *
  10. + *
  11. + * Add all sources, imports, and MavenConfig from the {@link SmithyBuildConfig} + * to the {@link ProjectConfig.Builder} + *
  12. + *
  13. + * If the {@link ProjectConfig.Builder} doesn't specify an outputDirectory, + * use the one in {@link SmithyBuildConfig}, if present + *
  14. + *
+ */ +public final class ProjectConfigLoader { + public static final String SMITHY_BUILD = "smithy-build.json"; + public static final String[] SMITHY_BUILD_EXTS = {"build/smithy-dependencies.json", ".smithy.json"}; + public static final String SMITHY_PROJECT = ".smithy-project.json"; + + private static final Logger LOGGER = Logger.getLogger(ProjectConfigLoader.class.getName()); + private static final SmithyBuildConfig DEFAULT_SMITHY_BUILD = SmithyBuildConfig.builder().version("1").build(); + private static final NodeMapper NODE_MAPPER = new NodeMapper(); + + private ProjectConfigLoader() { + } + + static Result> loadFromRoot(Path workspaceRoot) { + SmithyBuildConfig.Builder builder = SmithyBuildConfig.builder(); + List exceptions = new ArrayList<>(); + + // TODO: We don't handle cases where the smithy-build.json isn't in the top level of the root. + // In order to do so, we probably need to be able to keep track of multiple projects. + Path smithyBuildPath = workspaceRoot.resolve(SMITHY_BUILD); + if (Files.isRegularFile(smithyBuildPath)) { + LOGGER.info("Loading smithy-build.json from " + smithyBuildPath); + Result result = Result.ofFallible(() -> + SmithyBuildConfig.load(smithyBuildPath)); + result.get().ifPresent(builder::merge); + result.getErr().ifPresent(exceptions::add); + } else { + LOGGER.info("No smithy-build.json found at " + smithyBuildPath + ", defaulting to empty config."); + builder.merge(DEFAULT_SMITHY_BUILD); + } + + SmithyBuildExtensions.Builder extensionsBuilder = SmithyBuildExtensions.builder(); + for (String ext : SMITHY_BUILD_EXTS) { + Path extPath = workspaceRoot.resolve(ext); + if (Files.isRegularFile(extPath)) { + Result result = Result.ofFallible(() -> + loadSmithyBuildExtensions(extPath)); + result.get().ifPresent(extensionsBuilder::merge); + result.getErr().ifPresent(exceptions::add); + } + } + + ProjectConfig.Builder finalConfigBuilder = ProjectConfig.builder(); + Path smithyProjectPath = workspaceRoot.resolve(SMITHY_PROJECT); + if (Files.isRegularFile(smithyProjectPath)) { + LOGGER.info("Loading .smithy-project.json from " + smithyProjectPath); + Result result = Result.ofFallible(() -> + ProjectConfig.Builder.load(smithyProjectPath)); + if (result.isOk()) { + finalConfigBuilder = result.unwrap(); + } else { + exceptions.add(result.unwrapErr()); + } + } + + if (!exceptions.isEmpty()) { + return Result.err(exceptions); + } + + builder.merge(extensionsBuilder.build().asSmithyBuildConfig()); + SmithyBuildConfig config = builder.build(); + finalConfigBuilder.addSources(config.getSources()).addImports(config.getImports()); + config.getMaven().ifPresent(finalConfigBuilder::mavenConfig); + if (finalConfigBuilder.outputDirectory == null) { + config.getOutputDirectory().ifPresent(finalConfigBuilder::outputDirectory); + } + return Result.ok(finalConfigBuilder.build()); + } + + private static SmithyBuildExtensions loadSmithyBuildExtensions(Path path) { + // NOTE: This is the legacy way we loaded build extensions. It used to throw a checked exception. + String content = IoUtils.readUtf8File(path); + ObjectNode node = Node.parseJsonWithComments(content, path.toString()).expectObjectNode(); + SmithyBuildExtensions config = NODE_MAPPER.deserialize(node, SmithyBuildExtensions.class); + config.mergeMavenFromSmithyBuildConfig(SmithyBuildConfig.load(path)); + return config; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java new file mode 100644 index 00000000..a6e5347a --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependency.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; + +/** + * An arbitrary project dependency, used to specify non-maven dependencies + * that exist locally. + */ +final class ProjectDependency { + private final String name; + private final String path; + + private ProjectDependency(String name, String path) { + this.name = name; + this.path = path; + } + + static ProjectDependency fromNode(Node node) { + ObjectNode objectNode = node.expectObjectNode(); + String name = objectNode.expectStringMember("name").getValue(); + String path = objectNode.expectStringMember("path").getValue(); + return new ProjectDependency(name, path); + } + + /** + * @return The name of the dependency + */ + public String name() { + return name; + } + + /** + * @return The path of the dependency + */ + public String path() { + return path; + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.java new file mode 100644 index 00000000..eca2ecd8 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectDependencyResolver.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.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/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java new file mode 100644 index 00000000..b7f23eed --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -0,0 +1,412 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.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.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; + +/** + * 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. + * + *

Unlike {@link #load(Path, ProjectManager, Set)}, this method isn't + * fallible since it doesn't do any IO that we would want to recover an + * error from. + * + * @param uri URI of the file to load into a project + * @param text Text of the file to load into a project + * @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)) { + return Document.of(text); + } else { + // TODO: Make generic 'please file a bug report' exception + throw new IllegalStateException( + "Attempted to load an unknown source file (" + + filePath + ") in detached project at " + + asPath + ". This is a bug in the language server."); + } + }); + + return builder.smithyFiles(smithyFiles) + .perFileMetadata(computePerFileMetadata(modelResult)) + .build(); + } + + /** + * Loads a {@link Project} at the given root path, using any {@code managedDocuments} + * instead of loading from disk. + * + *

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

The build configuration files are the single source of truth for what will + * be loaded. Previous behavior in the language server was to walk all subdirs of + * the root and find all the .smithy files, but this made it challenging to + * reason about how the project was structured. + * + * @param root Path of the project root + * @param projects Currently loaded projects, for getting content of managed documents + * @param managedDocuments URIs of documents managed by the client + * @return Result of loading the project + */ + public static Result> load( + Path root, + 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())); + } + + List dependencies = resolveResult.unwrap(); + + // The model assembler factory is used to get assemblers that already have the correct + // dependencies resolved for future loads + Result, Exception> assemblerFactoryResult = createModelAssemblerFactory(dependencies); + if (assemblerFactoryResult.isErr()) { + return Result.err(Collections.singletonList(assemblerFactoryResult.unwrapErr())); + } + + Supplier assemblerFactory = assemblerFactoryResult.unwrap(); + ModelAssembler assembler = assemblerFactory.get(); + + // Note: The model assembler can handle loading all smithy files in a directory, so there's some potential + // here for inconsistent behavior. + List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports()); + + Result, Exception> loadModelResult = Result.ofFallible(() -> { + for (Path path : allSmithyFilePaths) { + 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)); + }); + + return Result.ok(projectBuilder.smithyFiles(smithyFiles) + .perFileMetadata(computePerFileMetadata(modelResult)) + .smithyFileDependenciesIndex(SmithyFileDependenciesIndex.compute(modelResult)) + .build()); + } + + static Result> load(Path root) { + return load(root, new ProjectManager(), new HashSet<>(0)); + } + + private static Map computeSmithyFiles( + List allSmithyFilePaths, + ValidatedResult modelResult, + Function documentProvider + ) { + Map> shapesByFile; + if (modelResult.getResult().isPresent()) { + Model model = modelResult.getResult().get(); + 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); + } + + // This is gross, but necessary to deal with the way that array metadata gets merged. + // When we try to reload a single file, we need to make sure we remove the metadata for + // that file. But if there's array metadata, a single key contains merged elements from + // other files. This splits up the metadata by source file, creating an artificial array + // node for elements that are merged. + // + // This definitely has the potential to cause a performance hit if there's a huge amount + // of metadata, since we are recomputing this on every change. + static Map> computePerFileMetadata(ValidatedResult modelResult) { + Map metadata = modelResult.getResult().map(Model::getMetadata).orElse(new HashMap<>(0)); + Map> perFileMetadata = new HashMap<>(); + for (Map.Entry entry : metadata.entrySet()) { + if (entry.getValue().isArrayNode()) { + Map arrayByFile = new HashMap<>(); + for (Node node : entry.getValue().expectArrayNode()) { + String filename = node.getSourceLocation().getFilename(); + arrayByFile.computeIfAbsent(filename, (f) -> ArrayNode.builder()).withValue(node); + } + for (Map.Entry arrayByFileEntry : arrayByFile.entrySet()) { + perFileMetadata.computeIfAbsent(arrayByFileEntry.getKey(), (f) -> new HashMap<>()) + .put(entry.getKey(), arrayByFileEntry.getValue().build()); + } + } else { + String filename = entry.getValue().getSourceLocation().getFilename(); + perFileMetadata.computeIfAbsent(filename, (f) -> new HashMap<>()) + .put(entry.getKey(), entry.getValue()); + } + } + return perFileMetadata; + } + + private static Result, Exception> createModelAssemblerFactory(List dependencies) { + // We don't want the model to be broken when there are unknown traits, + // because that will essentially disable language server features, so + // we need to allow unknown traits for each factory. + + // TODO: There's almost certainly a better way to to this + if (dependencies.isEmpty()) { + return Result.ok(() -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true)); + } + + Result result = createDependenciesClassLoader(dependencies); + if (result.isErr()) { + return Result.err(result.unwrapErr()); + } + return Result.ok(() -> { + URLClassLoader classLoader = result.unwrap(); + return Model.assembler(classLoader) + .discoverModels(classLoader) + .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); + }); + } + + private static Result createDependenciesClassLoader(List dependencies) { + // Taken (roughly) from smithy-ci IsolatedRunnable + try { + URL[] urls = new URL[dependencies.size()]; + int i = 0; + for (Path dependency : dependencies) { + urls[i++] = dependency.toUri().toURL(); + } + return Result.ok(new URLClassLoader(urls)); + } catch (MalformedURLException e) { + return Result.err(e); + } + } + + // sources and imports can contain directories or files, relative or absolute + private static List collectAllSmithyPaths(Path root, List sources, List imports) { + List paths = new ArrayList<>(); + for (String file : sources) { + Path path = root.resolve(file).normalize(); + collectDirectory(paths, root, path); + } + for (String file : imports) { + Path path = root.resolve(file).normalize(); + collectDirectory(paths, root, path); + } + return paths; + } + + // All of this copied from smithy-build SourcesPlugin + private static void collectDirectory(List accumulator, Path root, Path current) { + try { + if (Files.isDirectory(current)) { + try (Stream paths = Files.list(current)) { + paths.filter(p -> !p.equals(current)) + .filter(p -> Files.isDirectory(p) || Files.isRegularFile(p)) + .forEach(p -> collectDirectory(accumulator, root, p)); + } + } else if (Files.isRegularFile(current)) { + if (current.toString().endsWith(".jar")) { + String jarRoot = root.equals(current) + ? current.toString() + : (current + File.separator); + collectJar(accumulator, jarRoot, current); + } else { + collectFile(accumulator, current); + } + } + } catch (IOException ignored) { + // For now just ignore this - the assembler would have run into the same issues + } + } + + private static void collectJar(List accumulator, String jarRoot, Path jarPath) throws IOException { + URL manifestUrl = ModelDiscovery.createSmithyJarManifestUrl(jarPath.toString()); + + String prefix = computeJarFilePrefix(jarRoot, jarPath); + for (URL model : ModelDiscovery.findModels(manifestUrl)) { + String name = ModelDiscovery.getSmithyModelPathFromJarUrl(model); + Path target = Paths.get(prefix + name); + collectFile(accumulator, target); + } + } + + private static String computeJarFilePrefix(String jarRoot, Path jarPath) { + Path jarFilenamePath = jarPath.getFileName(); + + if (jarFilenamePath == null) { + return jarRoot; + } + + String jarFilename = jarFilenamePath.toString(); + return jarRoot + jarFilename.substring(0, jarFilename.length() - ".jar".length()) + File.separator; + } + + private static void collectFile(List accumulator, Path target) { + if (target == null) { + return; + } + String filename = target.toString(); + if (filename.endsWith(".smithy") || filename.endsWith(".json")) { + accumulator.add(target); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.java new file mode 100644 index 00000000..07cfb337 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectManager.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.project; + +import java.util.HashMap; +import java.util.Map; +import org.eclipse.lsp4j.InitializeParams; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +/** + * Manages open projects tracked by the server. + */ +public final class ProjectManager { + private final Map detached = new HashMap<>(); + // TODO: Handle multiple main projects + private Project mainProject; + + public ProjectManager() { + } + + /** + * @return The main project (the one with a smithy-build.json). Note that + * this will always be present after + * {@link org.eclipse.lsp4j.services.LanguageServer#initialize(InitializeParams)} + * is called. If there's no smithy-build.json, this is just an empty project. + */ + public Project mainProject() { + return mainProject; + } + + /** + * @param updated The updated main project. Overwrites existing main project + * without doing a partial update + */ + public void updateMainProject(Project updated) { + this.mainProject = updated; + } + + /** + * @return A map of URIs of open files that aren't attached to the main project + * to their own detached projects. These projects contain only the file that + * corresponds to the key in the map. + */ + public Map detachedProjects() { + return detached; + } + + /** + * @param uri The URI of the file belonging to the project to get + * @return The project the given {@code uri} belongs to + */ + public Project getProject(String uri) { + String path = LspAdapter.toPath(uri); + if (isDetached(uri)) { + return detached.get(uri); + } else if (mainProject.smithyFiles().containsKey(path)) { + return mainProject; + } else { + // Note: In practice, this shouldn't really happen because the server shouldn't + // be tracking any files that aren't attached to a project. But for testing, this + // is useful to ensure that fact. + return null; + } + } + + /** + * 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 the main project, + // but was opened before the project loaded. This would result in it + // being placed in a detached project. Removing it here is basically + // like removing it lazily, although it does feel a little hacky. + String path = LspAdapter.toPath(uri); + if (mainProject.smithyFiles().containsKey(path) && detached.containsKey(uri)) { + removeDetachedProject(uri); + } + + return detached.containsKey(uri); + } + + /** + * @param uri The URI of the file to create a detached project for + * @param text The text of the file to create a detached project for + * @return A new detached project of the given {@code uri} and {@code text} + */ + public Project createDetachedProject(String uri, String text) { + Project project = ProjectLoader.loadDetached(uri, text); + detached.put(uri, project); + return project; + } + + /** + * @param uri The URI of the file to remove a detached project for + * @return The removed project, or null if none existed + */ + public Project removeDetachedProject(String uri) { + return detached.remove(uri); + } + + /** + * @param uri The URI of the file to get the document of + * @return The {@link Document} corresponding to the given {@code uri}, if + * it exists in any projects, otherwise {@code null}. + */ + public Document getDocument(String uri) { + Project project = getProject(uri); + if (project == null) { + return null; + } + return project.getDocument(uri); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/ext/model/SmithyBuildExtensions.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java similarity index 90% rename from src/main/java/software/amazon/smithy/lsp/ext/model/SmithyBuildExtensions.java rename to src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java index e75ad1aa..35e2a576 100644 --- a/src/main/java/software/amazon/smithy/lsp/ext/model/SmithyBuildExtensions.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyBuildExtensions.java @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -package software.amazon.smithy.lsp.ext.model; +package software.amazon.smithy.lsp.project; import java.util.ArrayList; import java.util.Collection; @@ -27,6 +27,10 @@ import software.amazon.smithy.utils.SmithyBuilder; import software.amazon.smithy.utils.ToSmithyBuilder; +/** + * Legacy build config that supports a subset of {@link SmithyBuildConfig}, in addition to + * top-level {@code mavenRepositories} and {@code mavenDependencies} properties. + */ public final class SmithyBuildExtensions implements ToSmithyBuilder { private final List imports; private final List mavenRepositories; @@ -42,11 +46,11 @@ private SmithyBuildExtensions(Builder b) { lastModifiedInMillis = b.lastModifiedInMillis; } - public List getImports() { + public List imports() { return imports; } - public MavenConfig getMavenConfig() { + public MavenConfig mavenConfig() { return maven; } @@ -76,6 +80,18 @@ public void mergeMavenFromSmithyBuildConfig(SmithyBuildConfig config) { } } + /** + * @return This as {@link SmithyBuildConfig} + */ + public SmithyBuildConfig asSmithyBuildConfig() { + return SmithyBuildConfig.builder() + .version("1") + .imports(imports()) + .maven(mavenConfig()) + .lastModifiedInMillis(getLastModifiedInMillis()) + .build(); + } + public static final class Builder implements SmithyBuilder { private final List mavenRepositories = new ArrayList<>(); private final List mavenDependencies = new ArrayList<>(); @@ -99,16 +115,16 @@ public Builder merge(SmithyBuildExtensions other) { List dependencies = new ArrayList<>(maven.getDependencies()); // Merge dependencies from other extension, preferring those defined on MavenConfig. - if (other.getMavenConfig().getDependencies().isEmpty()) { + if (other.mavenConfig().getDependencies().isEmpty()) { dependencies.addAll(other.mavenDependencies); } else { - dependencies.addAll(other.getMavenConfig().getDependencies()); + dependencies.addAll(other.mavenConfig().getDependencies()); } mavenConfigBuilder.dependencies(dependencies); List repositories = new ArrayList<>(maven.getRepositories()); // Merge repositories from other extension, preferring those defined on MavenConfig. - if (other.getMavenConfig().getRepositories().isEmpty()) { + if (other.mavenConfig().getRepositories().isEmpty()) { repositories.addAll(other.mavenRepositories.stream() .map(repo -> MavenRepository.builder().url(repo).build()) .collect(Collectors.toList())); @@ -124,12 +140,12 @@ public Builder merge(SmithyBuildExtensions other) { } /** - * @deprecated Use {@link MavenConfig.Builder#repositories(Collection)} - * * Adds resolvers to the builder. * * @param mavenRepositories list of maven-compatible repositories * @return builder + * + * @deprecated Use {@link MavenConfig.Builder#repositories(Collection)} */ @Deprecated public Builder mavenRepositories(Collection mavenRepositories) { @@ -150,12 +166,12 @@ public Builder mavenRepositories(Collection mavenRepositories) { } /** - * @deprecated use {@link MavenConfig.Builder#dependencies(Collection)} - * * Adds dependencies to the builder. * * @param mavenDependencies list of artifacts in the org:name:version format * @return builder + * + * @deprecated use {@link MavenConfig.Builder#dependencies(Collection)} */ @Deprecated public Builder mavenDependencies(Collection mavenDependencies) { diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java new file mode 100644 index 00000000..ba4374c0 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -0,0 +1,202 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.document.DocumentImports; +import software.amazon.smithy.lsp.document.DocumentNamespace; +import software.amazon.smithy.lsp.document.DocumentShape; +import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.model.shapes.Shape; + +/** + * The language server's representation of a Smithy file. + * + *

Note: This currently is only ever a .smithy file, but could represent + * a .json file in the future. + */ +public final class SmithyFile { + private final String path; + private final Document document; + // 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; + } + + /** + * @return The path of this Smithy file + */ + public String path() { + return path; + } + + /** + * @return The {@link Document} backing this Smithy file + */ + 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 + */ + 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); + } + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java new file mode 100644 index 00000000..f9c939eb --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFileDependenciesIndex.java @@ -0,0 +1,127 @@ +/* + * 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().isPresent()) { + 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/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java new file mode 100644 index 00000000..51e4ce85 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java @@ -0,0 +1,225 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.logging.Logger; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.model.SourceLocation; + +/** + * Utility methods for converting to and from LSP types {@link Range}, {@link Position}, + * {@link Location} and URI (which is just a string). + * TODO: Using a string internally for URI is pretty brittle. We could wrap it in a custom + * class, or try to use the {@link URI}, which has its own issues because of the + * 'smithyjar:' scheme we use. + */ +public final class LspAdapter { + private static final Logger LOGGER = Logger.getLogger(LspAdapter.class.getName()); + + private LspAdapter() { + } + + /** + * @return Range of (0, 0) - (0, 0) + */ + public static Range origin() { + return new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(0) + .build(); + } + + /** + * @param point Position to create a point range of + * @return Range of (point) - (point) + */ + public static Range point(Position point) { + return new Range(point, point); + } + + /** + * @param line Line of the point + * @param character Character offset on the line + * @return Range of (line, character) - (line, character) + */ + public static Range point(int line, int character) { + return point(new Position(line, character)); + } + + /** + * @param line Line the span is on + * @param startCharacter Start character of the span + * @param endCharacter End character of the span + * @return Range of (line, startCharacter) - (line, endCharacter) + */ + public static Range lineSpan(int line, int startCharacter, int endCharacter) { + return of(line, startCharacter, line, endCharacter); + } + + /** + * @param offset Offset from (0, 0) + * @return Range of (0, 0) - (offset) + */ + public static Range offset(Position offset) { + return new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(offset.getLine()) + .endCharacter(offset.getCharacter()) + .build(); + } + + /** + * @param offset Offset from (offset.line, 0) + * @return Range of (offset.line, 0) - (offset) + */ + public static Range lineOffset(Position offset) { + return new RangeBuilder() + .startLine(offset.getLine()) + .startCharacter(0) + .endLine(offset.getLine()) + .endCharacter(offset.getCharacter()) + .build(); + } + + /** + * @param startLine Range start line + * @param startCharacter Range start character + * @param endLine Range end line + * @param endCharacter Range end character + * @return Range of (startLine, startCharacter) - (endLine, endCharacter) + */ + public static Range of(int startLine, int startCharacter, int endLine, int endCharacter) { + return new RangeBuilder() + .startLine(startLine) + .startCharacter(startCharacter) + .endLine(endLine) + .endCharacter(endCharacter) + .build(); + } + + /** + * Get a {@link Position} from a {@link SourceLocation}, making the line/columns + * 0-indexed. + * + * @param sourceLocation The source location to get the position of + * @return The position + */ + public static Position toPosition(SourceLocation sourceLocation) { + return new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1); + } + + /** + * Get a {@link Location} from a {@link SourceLocation}, with the filename + * transformed to a URI, and the line/column made 0-indexed. + * + * @param sourceLocation The source location to get a Location from + * @return The equivalent Location + */ + public static Location toLocation(SourceLocation sourceLocation) { + return new Location(toUri(sourceLocation.getFilename()), point( + new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1))); + } + + /** + * @param uri LSP URI to convert to a path + * @return A path representation of the {@code uri}, with the scheme removed + */ + public static String toPath(String uri) { + if (uri.startsWith("file://")) { + return Paths.get(URI.create(uri)).toString(); + } else if (isSmithyJarFile(uri)) { + String decoded = decode(uri); + return fixJarScheme(decoded); + } + return uri; + } + + /** + * @param path Path to convert to LSP URI + * @return A URI representation of the given {@code path}, modified to have the + * correct scheme for our jars + */ + public static String toUri(String path) { + if (path.startsWith("jar:file")) { + return path.replaceFirst("jar:file", "smithyjar"); + } else if (path.startsWith("smithyjar:")) { + return path; + } else { + return Paths.get(path).toUri().toString(); + } + } + + /** + * Checks if a given LSP URI is a file in a Smithy jar, which is a Smithy + * Language Server specific file scheme (smithyjar:) used for providing + * contents of Smithy files within Jars. + * + * @param uri LSP URI to check + * @return Returns whether the uri points to a smithy file in a jar + */ + public static boolean isSmithyJarFile(String uri) { + return uri.startsWith("smithyjar:"); + } + + /** + * @param uri LSP URI to check + * @return Returns whether the uri points to a file in jar + */ + public static boolean isJarFile(String uri) { + return uri.startsWith("jar:"); + } + + /** + * Get a {@link URL} for the Jar represented by the given URI or path. + * + * @param uriOrPath LSP URI or regular path + * @return The {@link URL}, or throw if the uri/path cannot be decoded + */ + public static URL jarUrl(String uriOrPath) { + try { + String decodedUri = decode(uriOrPath); + return URI.create(fixJarScheme(decodedUri)).toURL(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String decode(String uriOrPath) { + try { + // Some clients encode parts of the jar, like !/ + return URLDecoder.decode(uriOrPath, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + LOGGER.severe("Failed to decode " + uriOrPath + " : " + e.getMessage()); + return uriOrPath; + } + } + + private static String fixJarScheme(String uriOrPath) { + if (uriOrPath.startsWith("smithyjar:")) { + uriOrPath = uriOrPath.replaceFirst("smithyjar:", ""); + } + if (uriOrPath.startsWith("jar:")) { + return uriOrPath; + } else if (uriOrPath.startsWith("file:")) { + return "jar:" + uriOrPath; + } else { + return "jar:file:" + uriOrPath; + } + } + +} diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java b/src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java new file mode 100644 index 00000000..0dfb8a47 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/protocol/RangeBuilder.java @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.protocol; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +/** + * Builder for constructing LSP's {@link Range}. + */ +public final class RangeBuilder { + private int startLine; + private int startCharacter; + private int endLine; + private int endCharacter; + + /** + * @return This range adapter, with the start/end characters incremented by one + */ + public RangeBuilder shiftRight() { + return this.shiftRight(1); + } + + /** + * @param offset Offset to shift + * @return This range adapter, with the start/end characters incremented by {@code offset} + */ + public RangeBuilder shiftRight(int offset) { + this.startCharacter += offset; + this.endCharacter += offset; + + return this; + } + + /** + * @return This range adapter, with start/end lines incremented by one, and the start/end + * characters span shifted to begin at 0 + */ + public RangeBuilder shiftNewLine() { + this.startLine = this.startLine + 1; + this.endLine = this.endLine + 1; + + int charDiff = this.endCharacter - this.startCharacter; + this.startCharacter = 0; + this.endCharacter = charDiff; + + return this; + } + + /** + * @param startLine The start line for the range + * @return The updated range adapter + */ + public RangeBuilder startLine(int startLine) { + this.startLine = startLine; + return this; + } + + /** + * @param startCharacter The start character for the range + * @return The updated range adapter + */ + public RangeBuilder startCharacter(int startCharacter) { + this.startCharacter = startCharacter; + return this; + } + + /** + * @param endLine The end line for the range + * @return The updated range adapter + */ + public RangeBuilder endLine(int endLine) { + this.endLine = endLine; + return this; + } + + /** + * @param endCharacter The end character for the range + * @return The updated range adapter + */ + public RangeBuilder endCharacter(int endCharacter) { + this.endCharacter = endCharacter; + return this; + } + + /** + * @return The built Range + */ + public Range build() { + return new Range( + new Position(startLine, startCharacter), + new Position(endLine, endCharacter)); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/util/Result.java b/src/main/java/software/amazon/smithy/lsp/util/Result.java new file mode 100644 index 00000000..8ee93e67 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/util/Result.java @@ -0,0 +1,163 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.util; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Type representing the result of an operation that could be successful + * or fail. + * + * @param Type of successful result + * @param Type of failed result + */ +public final class Result { + private final T value; + private final E error; + + private Result(T value, E error) { + this.value = value; + this.error = error; + } + + /** + * @param value The success value + * @param Type of successful result + * @param Type of failed result + * @return The successful result + */ + public static Result ok(T value) { + return new Result<>(value, null); + } + + /** + * @param error The failed value + * @param Type of successful result + * @param Type of failed result + * @return The failed result + */ + public static Result err(E error) { + return new Result<>(null, error); + } + + /** + * @param fallible A function that may fail + * @param Type of successful result + * @return A result containing the result of calling {@code fallible} + */ + public static Result ofFallible(Supplier fallible) { + try { + return Result.ok(fallible.get()); + } catch (Exception e) { + return Result.err(e); + } + } + + /** + * @param throwing A function that may throw + * @param Type of successful result + * @return A result containing the result of calling {@code throwing} + */ + public static Result ofThrowing(ThrowingSupplier throwing) { + try { + return Result.ok(throwing.get()); + } catch (Exception e) { + return Result.err(e); + } + } + + /** + * @return Whether this result is successful + */ + public boolean isOk() { + return this.value != null; + } + + /** + * @return Whether this result is failed + */ + public boolean isErr() { + return this.error != null; + } + + /** + * @return The successful value, or throw an exception if this Result is failed + */ + public T unwrap() { + if (!get().isPresent()) { + throw new RuntimeException("Called unwrap on an Err Result: " + getErr().get()); + } + return get().get(); + } + + /** + * @return The failed value, or throw an exception if this Result is successful + */ + public E unwrapErr() { + if (!getErr().isPresent()) { + throw new RuntimeException("Called unwrapErr on an Ok Result: " + get().get()); + } + return getErr().get(); + } + + /** + * @return Get the successful value if present + */ + public Optional get() { + return Optional.ofNullable(value); + } + + /** + * @return Get the failed value if present + */ + public Optional getErr() { + return Optional.ofNullable(error); + } + + /** + * Transforms the successful value of this Result, if present. + * + * @param mapper Function to apply to the successful value of this result + * @param The type to map to + * @return A new result with {@code mapper} applied, if this result is a + * successful one + */ + public Result map(Function mapper) { + if (isOk()) { + return Result.ok(mapper.apply(unwrap())); + } + return Result.err(unwrapErr()); + } + + /** + * Transforms the failed value of this Result, if present. + * + * @param mapper Function to apply to the failed value of this result + * @param The type to map to + * @return A new result with {@code mapper} applied, if this result is a + * failed one + */ + public Result mapErr(Function mapper) { + if (isErr()) { + return Result.err(mapper.apply(unwrapErr())); + } + return Result.ok(unwrap()); + } + + + /** + * A supplier that throws a checked exception. + * + * @param The output of the supplier + * @param The exception type that can be thrown + */ + @FunctionalInterface + public interface ThrowingSupplier { + T get() throws E; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java new file mode 100644 index 00000000..be51d9c5 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -0,0 +1,93 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import software.amazon.smithy.lsp.document.Document; + +/** + * Hamcrest matchers for LSP4J types. + */ +public final class LspMatchers { + private LspMatchers() {} + + public static Matcher hasLabel(String label) { + return new CustomTypeSafeMatcher("a completion item with the label + `" + label + "`") { + @Override + protected boolean matchesSafely(CompletionItem item) { + return item.getLabel().equals(label); + } + + @Override + public void describeMismatchSafely(CompletionItem item, Description description) { + description.appendText("Expected completion item with label '" + + label + "' but was '" + item.getLabel() + "'"); + } + }; + } + + public static Matcher makesEditedDocument(Document document, String expected) { + return new CustomTypeSafeMatcher("makes an edited document " + expected) { + @Override + protected boolean matchesSafely(TextEdit item) { + Document copy = document.copy(); + copy.applyEdit(item.getRange(), item.getNewText()); + return copy.copyText().equals(expected); + } + + @Override + public void describeMismatchSafely(TextEdit textEdit, Description description) { + Document copy = document.copy(); + copy.applyEdit(textEdit.getRange(), textEdit.getNewText()); + String actual = copy.copyText(); + description.appendText("expected:\n'" + expected + "'\nbut was: \n'" + actual + "'\n"); + } + }; + } + + public static Matcher hasText(Document document, Matcher expected) { + return new CustomTypeSafeMatcher("text in range") { + @Override + protected boolean matchesSafely(Range item) { + CharSequence borrowed = document.borrowRange(item); + if (borrowed == null) { + return false; + } + return expected.matches(borrowed.toString()); + } + + @Override + public void describeMismatchSafely(Range range, Description description) { + if (document.borrowRange(range) == null) { + description.appendText("text was null"); + } else { + description.appendDescriptionOf(expected) + .appendText("was " + document.borrowRange(range).toString()); + } + } + }; + } + + public static Matcher diagnosticWithMessage(Matcher message) { + return new CustomTypeSafeMatcher("has matching message") { + @Override + protected boolean matchesSafely(Diagnostic item) { + return message.matches(item.getMessage()); + } + + @Override + public void describeMismatchSafely(Diagnostic event, Description description) { + description.appendDescriptionOf(message).appendText("was " + event.getMessage()); + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java new file mode 100644 index 00000000..2a033e5f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/RequestBuilders.java @@ -0,0 +1,253 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.net.URI; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.eclipse.lsp4j.ClientCapabilities; +import org.eclipse.lsp4j.CompletionContext; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.CompletionTriggerKind; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; +import software.amazon.smithy.utils.IoUtils; + +/** + * Contains builder classes for LSP requests/notifications used for testing + */ +public final class RequestBuilders { + private RequestBuilders() {} + + public static DidChange didChange() { + return new DidChange(); + } + + public static DidOpen didOpen() { + return new DidOpen(); + } + + public static DidSave didSave() { + return new DidSave(); + } + + public static DidClose didClose() { + return new DidClose(); + } + + public static Initialize initialize() { + return new Initialize(); + } + + public static PositionRequest positionRequest() { + return new PositionRequest(); + } + + public static DidChangeWatchedFiles didChangeWatchedFiles() { + return new DidChangeWatchedFiles(); + } + + public static final class DidChange { + public String uri; + public Integer version; + public Range range; + public String text; + + public DidChange next() { + this.version += 1; + return this; + } + + public DidChange uri(String uri) { + this.uri = uri; + return this; + } + + public DidChange version(Integer version) { + this.version = version; + return this; + } + + public DidChange range(Range range) { + this.range = range; + return this; + } + + public DidChange text(String text) { + this.text = text; + return this; + } + + public DidChangeTextDocumentParams build() { + VersionedTextDocumentIdentifier id = new VersionedTextDocumentIdentifier(uri, version); + TextDocumentContentChangeEvent change; + if (range != null) { + change = new TextDocumentContentChangeEvent(range, text); + } else { + change = new TextDocumentContentChangeEvent(text); + } + return new DidChangeTextDocumentParams(id, Collections.singletonList(change)); + } + + } + + public static final class Initialize { + public List workspaceFolders = new ArrayList<>(); + public Object initializationOptions; + + public Initialize workspaceFolder(String uri, String name) { + this.workspaceFolders.add(new WorkspaceFolder(uri, name)); + return this; + } + + public Initialize initializationOptions(Object object) { + this.initializationOptions = object; + return this; + } + + public InitializeParams build() { + InitializeParams params = new InitializeParams(); + params.setCapabilities(new ClientCapabilities()); // non-null + params.setWorkspaceFolders(workspaceFolders); + if (initializationOptions != null) { + params.setInitializationOptions(initializationOptions); + } + return params; + } + } + + public static final class DidClose { + public String uri; + + public DidClose uri(String uri) { + this.uri = uri; + return this; + } + + public DidCloseTextDocumentParams build() { + return new DidCloseTextDocumentParams(new TextDocumentIdentifier(uri)); + } + } + + public static final class DidOpen { + public String uri; + public String languageId = "smithy"; + public int version = 1; + public String text; + + public DidOpen uri(String uri) { + this.uri = uri; + return this; + } + + public DidOpen languageId(String languageId) { + this.languageId = languageId; + return this; + } + + public DidOpen version(int version) { + this.version = version; + return this; + } + + public DidOpen text(String text) { + this.text = text; + return this; + } + + public DidOpenTextDocumentParams build() { + if (text == null) { + text = IoUtils.readUtf8File(Paths.get(URI.create(uri))); + } + return new DidOpenTextDocumentParams(new TextDocumentItem(uri, languageId, version, text)); + } + } + + public static final class DidSave { + String uri; + + public DidSave uri(String uri) { + this.uri = uri; + return this; + } + + public DidSaveTextDocumentParams build() { + return new DidSaveTextDocumentParams(new TextDocumentIdentifier(uri)); + } + } + + public static final class PositionRequest { + String uri; + int line; + int character; + + public PositionRequest uri(String uri) { + this.uri = uri; + return this; + } + + public PositionRequest line(int line) { + this.line = line; + return this; + } + + public PositionRequest character(int character) { + this.character = character; + return this; + } + + public PositionRequest position(Position position) { + this.line = position.getLine(); + this.character = position.getCharacter(); + return this; + } + + public HoverParams buildHover() { + return new HoverParams(new TextDocumentIdentifier(uri), new Position(line, character)); + } + + public DefinitionParams buildDefinition() { + return new DefinitionParams(new TextDocumentIdentifier(uri), new Position(line, character)); + } + + public CompletionParams buildCompletion() { + return new CompletionParams( + new TextDocumentIdentifier(uri), + new Position(line, character), + new CompletionContext(CompletionTriggerKind.Invoked)); + } + } + + public static final class DidChangeWatchedFiles { + public final List changes = new ArrayList<>(); + + public DidChangeWatchedFiles event(String uri, FileChangeType type) { + this.changes.add(new FileEvent(uri, type)); + return this; + } + + public DidChangeWatchedFilesParams build() { + return new DidChangeWatchedFilesParams(changes); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java deleted file mode 100644 index 77fd2945..00000000 --- a/src/test/java/software/amazon/smithy/lsp/SmithyInterfaceTest.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertFalse; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.junit.Test; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.model.validation.ValidationEvent; - -public class SmithyInterfaceTest { - private static final String baseDirName = "external-jars"; - private static final String testTraitsModelFilename = "test-traits.smithy"; - private static final String testTraitsDependencyFilename = "smithy-test-traits.jar"; - private static final ShapeId testTraitShapeId = ShapeId.from("smithy.test#test"); - - @Test - public void loadModelWithDependencies() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List externalJars = getFiles(testTraitsDependencyFilename); - - Either> result = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(result.isRight()); - assertTrue(result.getRight().getValidationEvents().isEmpty()); - Model model = result.getRight().getResult().get(); - assertTrue(model.getShape(testTraitShapeId).isPresent()); - } - - @Test - public void reloadingModelWithDependencies() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List externalJars = getFiles(testTraitsDependencyFilename); - - Either> result = SmithyInterface.readModel(modelFiles, externalJars); - Either> resultTwo = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(result.isRight()); - assertTrue(result.getRight().getValidationEvents().isEmpty()); - assertTrue(resultTwo.isRight()); - assertTrue(resultTwo.getRight().getValidationEvents().isEmpty()); - } - - @Test - public void addingDependency() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List noExternalJars = new ArrayList<>(); - List externalJars = getFiles(testTraitsDependencyFilename); - - Either> noDependency = SmithyInterface.readModel(modelFiles, noExternalJars); - Either> withDependency = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(noDependency.isRight()); - assertTrue(withDependency.isRight()); - Model modelWithDependency = withDependency.getRight().getResult().get(); - assertTrue(modelWithDependency.getShape(testTraitShapeId).isPresent()); - } - - @Test - public void removingDependency() throws Exception { - List modelFiles = getFiles(testTraitsModelFilename); - List externalJars = getFiles(testTraitsDependencyFilename); - List noExternalJars = new ArrayList<>(); - - Either> withDependency = SmithyInterface.readModel(modelFiles, externalJars); - Either> noDependency = SmithyInterface.readModel(modelFiles, noExternalJars); - - assertTrue(withDependency.isRight()); - assertTrue(noDependency.isRight()); - Model modelWithoutDependency = noDependency.getRight().getResult().get(); - assertFalse(modelWithoutDependency.getShape(testTraitShapeId).isPresent()); - } - - @Test - public void runValidators() throws Exception { - List modelFiles = getFiles("test-validators.smithy"); - List externalJars = getFiles("alloy-core.jar"); - Either> result = SmithyInterface.readModel(modelFiles, externalJars); - - assertTrue(result.isRight()); - List validationEvents = result.getRight().getValidationEvents(); - assertFalse(validationEvents.isEmpty()); - - String expectedMessage = "Proto index 1 is used muliple times in members name," + - "age of shape (structure: `some.test#MyStruct`)."; - Optional matchingEvent = validationEvents.stream() - .filter(ev ->ev.getMessage().equals(expectedMessage)).findFirst(); - - if (!matchingEvent.isPresent()) { - throw new AssertionError("Expected validation event with message `" + expectedMessage - + "`, but events were " + validationEvents); - } - } - - private static List getFiles(String... filenames) throws Exception { - Path baseDir = Paths.get(SmithyInterface.class.getResource(SmithyInterfaceTest.baseDirName).toURI()); - return Arrays.stream(filenames).map(baseDir::resolve).map(Path::toFile).collect(Collectors.toList()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index c3e1f727..4ec04ab1 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -1,78 +1,1700 @@ package software.amazon.smithy.lsp; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static software.amazon.smithy.lsp.LspMatchers.diagnosticWithMessage; +import static software.amazon.smithy.lsp.LspMatchers.hasLabel; +import static software.amazon.smithy.lsp.LspMatchers.hasText; +import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument; +import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; +import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; +import static software.amazon.smithy.lsp.SmithyMatchers.hasValue; +import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; +import static software.amazon.smithy.lsp.project.ProjectTest.toPath; import com.google.gson.JsonObject; -import java.io.File; -import java.nio.file.Files; - -import org.eclipse.lsp4j.CodeActionOptions; -import org.eclipse.lsp4j.CompletionOptions; -import org.eclipse.lsp4j.InitializeParams; -import org.eclipse.lsp4j.InitializeResult; -import org.eclipse.lsp4j.ServerCapabilities; -import org.eclipse.lsp4j.TextDocumentSyncKind; -import org.eclipse.lsp4j.WorkspaceFolder; -import org.junit.FixMethodOrder; -import org.junit.Test; -import org.junit.runners.MethodSorters; -import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; -import software.amazon.smithy.utils.ListUtils; - -@FixMethodOrder(MethodSorters.NAME_ASCENDING) +import com.google.gson.JsonPrimitive; +import java.nio.file.Path; +import java.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.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.SymbolInformation; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageClient; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.MavenConfig; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.protocol.RangeBuilder; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.LengthTrait; + public class SmithyLanguageServerTest { @Test - public void initializeServer() throws Exception { - InitializeParams initParams = new InitializeParams(); - File temp = Files.createTempDirectory("smithy-lsp-test").toFile(); - temp.deleteOnExit(); - initParams.setWorkspaceFolders(ListUtils.of(new WorkspaceFolder(temp.toURI().toString()))); - SmithyLanguageServer languageServer = new SmithyLanguageServer(); - InitializeResult initResults = languageServer.initialize(initParams).get(); - ServerCapabilities capabilities = initResults.getCapabilities(); - File lspLog = new File(temp + "/.smithy.lsp.log"); - - assertNull(languageServer.tempWorkspaceRoot); - assertEquals(TextDocumentSyncKind.Full, capabilities.getTextDocumentSync().getLeft()); - assertEquals(new CodeActionOptions(SmithyCodeActions.all()), capabilities.getCodeActionProvider().getRight()); - assertTrue(capabilities.getDefinitionProvider().getLeft()); - assertTrue(capabilities.getDeclarationProvider().getLeft()); - assertEquals(new CompletionOptions(true, null), capabilities.getCompletionProvider()); - assertTrue(capabilities.getHoverProvider().getLeft()); - // LspLog is disabled by default. - assertFalse(lspLog.exists()); - } - - @Test - public void initializeWithTemporaryWorkspace() { - InitializeParams initParams = new InitializeParams(); - SmithyLanguageServer languageServer = new SmithyLanguageServer(); - languageServer.initialize(initParams); - - assertNotNull(languageServer.tempWorkspaceRoot); - assertTrue(languageServer.tempWorkspaceRoot.exists()); - assertTrue(languageServer.tempWorkspaceRoot.isDirectory()); - assertTrue(languageServer.tempWorkspaceRoot.canWrite()); - } - - @Test - public void lspLogCanBeEnabled() throws Exception { - InitializeParams initParams = new InitializeParams(); - File temp = Files.createTempDirectory("smithy-lsp-log-test").toFile(); - temp.deleteOnExit(); - initParams.setWorkspaceFolders(ListUtils.of(new WorkspaceFolder(temp.toURI().toString()))); - JsonObject initOptions = new JsonObject(); - initOptions.addProperty("logToFile", "enabled"); - initParams.setInitializationOptions(initOptions); - SmithyLanguageServer languageServer = new SmithyLanguageServer(); - languageServer.initialize(initParams); - - File expectedLspLog = new File(temp + "/.smithy.lsp.log"); - - assertTrue(expectedLspLog.exists()); + public void runsSelector() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + SelectorParams params = new SelectorParams("string"); + List locations = server.selectorCommand(params).get(); + + assertThat(locations, not(empty())); + } + + @Test + public void completion() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: String\n" + + "}\n" + + "\n" + + "@default(0)\n" + + "integer Bar\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + // String + CompletionParams memberTargetParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(4) + .character(10) + .buildCompletion(); + // @default + CompletionParams traitParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(7) + .character(2) + .buildCompletion(); + CompletionParams wsParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(2) + .character(1) + .buildCompletion(); + + List memberTargetCompletions = server.completion(memberTargetParams).get().getLeft(); + List traitCompletions = server.completion(traitParams).get().getLeft(); + List wsCompletions = server.completion(wsParams).get().getLeft(); + + assertThat(memberTargetCompletions, containsInAnyOrder(hasLabel("String"))); + assertThat(traitCompletions, containsInAnyOrder(hasLabel("default"))); + assertThat(wsCompletions, empty()); + } + + @Test + public void completionImports() throws Exception { + String model1 = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + "}\n"); + String model2 = safeString("$version: \"2\"\n" + + "namespace com.bar\n" + + "\n" + + "string Bar\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(model1, model2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("model-0.smithy"); + + DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() + .uri(uri) + .text(model1) + .build(); + server.didOpen(openParams); + + DidChangeTextDocumentParams changeParams = new RequestBuilders.DidChange() + .uri(uri) + .version(2) + .range(new 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.getProject().getDocument(uri); + // TODO: The server puts the 'use' on the wrong line + assertThat(completions.get(0).getAdditionalTextEdits(), containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "use com.bar#Bar\n" + + "\n" + + "structure Foo {\n" + + " bar: Ba\n" + + "}\n")))); + } + + @Test + public void definition() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@trait\n" + + "string myTrait\n" + + "\n" + + "structure Foo {\n" + + " bar: Baz\n" + + "}\n" + + "\n" + + "@myTrait(\"\")\n" + + "string Baz\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + // bar: Baz + DefinitionParams memberTargetParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(7) + .character(9) + .buildDefinition(); + // @myTrait + DefinitionParams traitParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(10) + .character(1) + .buildDefinition(); + DefinitionParams wsParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(2) + .character(0) + .buildDefinition(); + + List memberTargetLocations = server.definition(memberTargetParams).get().getLeft(); + List traitLocations = server.definition(traitParams).get().getLeft(); + List wsLocations = server.definition(wsParams).get().getLeft(); + + Document document = server.getProject().getDocument(uri); + assertNotNull(document); + + assertThat(memberTargetLocations, hasSize(1)); + Location memberTargetLocation = memberTargetLocations.get(0); + assertThat(memberTargetLocation.getUri(), equalTo(uri)); + assertThat(memberTargetLocation.getRange().getStart(), equalTo(new Position(11, 0))); + // TODO + // assertThat(document.borrowRange(memberTargetLocation.getRange()), equalTo("")); + + assertThat(traitLocations, hasSize(1)); + Location traitLocation = traitLocations.get(0); + assertThat(traitLocation.getUri(), equalTo(uri)); + assertThat(traitLocation.getRange().getStart(), equalTo(new Position(4, 0))); + + assertThat(wsLocations, empty()); + } + + @Test + public void hover() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@trait\n" + + "string myTrait\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "@myTrait(\"\")\n" + + "structure Bar {\n" + + " baz: String\n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + // bar: Bar + HoverParams memberParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(7) + .character(9) + .buildHover(); + // @myTrait("") + HoverParams traitParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(10) + .character(1) + .buildHover(); + HoverParams wsParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(2) + .character(0) + .buildHover(); + + Hover memberHover = server.hover(memberParams).get(); + Hover traitHover = server.hover(traitParams).get(); + Hover wsHover = server.hover(wsParams).get(); + + assertThat(memberHover.getContents().getRight().getValue(), containsString("structure Bar")); + assertThat(traitHover.getContents().getRight().getValue(), containsString("string myTrait")); + assertThat(wsHover.getContents().getRight().getValue(), equalTo("")); + } + + @Test + public void hoverWithBrokenModel() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + " baz: String\n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + // baz: String + HoverParams params = new RequestBuilders.PositionRequest() + .uri(uri) + .line(5) + .character(9) + .buildHover(); + Hover hover = server.hover(params).get(); + + assertThat(hover.getContents().getRight().getValue(), containsString("string String")); + } + + @Test + public void documentSymbol() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@trait\n" + + "string myTrait\n" + + "\n" + + "structure Foo {\n" + + " @required\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "structure Bar {\n" + + " @myTrait(\"foo\")\n" + + " baz: Baz\n" + + "}\n" + + "\n" + + "@myTrait(\"abc\")\n" + + "integer Baz\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + DocumentSymbolParams params = new DocumentSymbolParams(new TextDocumentIdentifier(uri)); + List> response = server.documentSymbol(params).get(); + List documentSymbols = response.stream().map(Either::getRight).collect(Collectors.toList()); + List names = documentSymbols.stream().map(DocumentSymbol::getName).collect(Collectors.toList()); + + assertThat(names, hasItem("myTrait")); + assertThat(names, hasItem("Foo")); + assertThat(names, hasItem("bar")); + assertThat(names, hasItem("Bar")); + assertThat(names, hasItem("baz")); + assertThat(names, hasItem("Baz")); + } + + @Test + public void formatting() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo{\n" + + "bar: Baz}\n" + + "\n" + + "@tags(\n" + + "[\"a\",\n" + + " \"b\"])\n" + + "string Baz\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + TextDocumentIdentifier id = new TextDocumentIdentifier(uri); + DocumentFormattingParams params = new DocumentFormattingParams(id, new FormattingOptions()); + List edits = server.formatting(params).get(); + Document document = server.getProject().getDocument(uri); + + assertThat(edits, (Matcher) containsInAnyOrder(makesEditedDocument(document, safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Baz\n" + + "}\n" + + "\n" + + "@tags([\"a\", \"b\"])\n" + + "string Baz\n")))); + } + + @Test + public void didChange() throws Exception { + String model = safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure GetFooInput {\n" + + "}\n" + + "\n" + + "operation GetFoo {\n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build(); + server.didOpen(openParams); + + RangeBuilder rangeBuilder = new RangeBuilder() + .startLine(7) + .startCharacter(18) + .endLine(7) + .endCharacter(18); + RequestBuilders.DidChange changeBuilder = new RequestBuilders.DidChange().uri(uri); + + // Add new line and leading spaces + server.didChange(changeBuilder.range(rangeBuilder.build()).text(safeString("\n ")).build()); + // add 'input: G' + server.didChange(changeBuilder.range(rangeBuilder.shiftNewLine().shiftRight(4).build()).text("i").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("n").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("p").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("u").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("t").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(":").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text(" ").build()); + server.didChange(changeBuilder.range(rangeBuilder.shiftRight().build()).text("G").build()); + + server.getLifecycleManager().waitForAllTasks(); + + // mostly so you can see what it looks like + assertThat(server.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "structure GetFooInput {\n" + + "}\n" + + "\n" + + "operation GetFoo {\n" + + " input: G\n" + + "}\n"))); + + // input: G + CompletionParams completionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .position(rangeBuilder.shiftRight().build().getStart()) + .buildCompletion(); + List completions = server.completion(completionParams).get().getLeft(); + + assertThat(completions, hasItem(hasLabel("GetFooInput"))); + } + + @Test + public void didChangeReloadsModel() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "operation Foo {}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + DidOpenTextDocumentParams openParams = new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build(); + server.didOpen(openParams); + assertThat(server.getProject().modelResult().getValidationEvents(), empty()); + + DidChangeTextDocumentParams didChangeParams = new RequestBuilders.DidChange() + .uri(uri) + .text("@http(method:\"\", uri: \"\")\n") + .range(LspAdapter.point(3, 0)) + .build(); + server.didChange(didChangeParams); + + server.getLifecycleManager().getTask(uri).get(); + + assertThat(server.getProject().modelResult().getValidationEvents(), + containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); + + DidSaveTextDocumentParams didSaveParams = new RequestBuilders.DidSave().uri(uri).build(); + server.didSave(didSaveParams); + + assertThat(server.getProject().modelResult().getValidationEvents(), + containsInAnyOrder(eventWithMessage(containsString("Error creating trait")))); + } + + @Test + public void didChangeThenDefinition() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "string Bar\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + DefinitionParams definitionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .line(4) + .character(9) + .buildDefinition(); + Location initialLocation = server.definition(definitionParams).get().getLeft().get(0); + assertThat(initialLocation.getUri(), equalTo(uri)); + assertThat(initialLocation.getRange().getStart(), equalTo(new Position(7, 0))); + + 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.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n" + + "\n" + + "string Baz\n" + + "\n" + + "string Bar\n"))); + + Location afterChanges = server.definition(definitionParams).get().getLeft().get(0); + assertThat(afterChanges.getUri(), equalTo(uri)); + assertThat(afterChanges.getRange().getStart(), equalTo(new Position(9, 0))); + } + + @Test + public void definitionWithApply() throws Exception { + Path root = toPath(getClass().getResource("project/apply")); + SmithyLanguageServer server = initFromRoot(root); + String foo = root.resolve("model/foo.smithy").toUri().toString(); + String bar = root.resolve("model/bar.smithy").toUri().toString(); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(foo) + .build()); + + // on 'apply >MyOpInput' + RequestBuilders.PositionRequest myOpInputRequest = new RequestBuilders.PositionRequest() + .uri(foo) + .line(5) + .character(6); + + Location myOpInputLocation = server.definition(myOpInputRequest.buildDefinition()).get().getLeft().get(0); + assertThat(myOpInputLocation.getUri(), equalTo(foo)); + assertThat(myOpInputLocation.getRange().getStart(), equalTo(new Position(9, 0))); + + Hover myOpInputHover = server.hover(myOpInputRequest.buildHover()).get(); + String myOpInputHoverContent = myOpInputHover.getContents().getRight().getValue(); + assertThat(myOpInputHoverContent, containsString("@tags")); + assertThat(myOpInputHoverContent, containsString("structure MyOpInput with [HasMyBool]")); + assertThat(myOpInputHoverContent, containsString("/// even more docs")); + assertThat(myOpInputHoverContent, containsString("apply MyOpInput$myBool")); + + // on 'with [>HasMyBool]' + RequestBuilders.PositionRequest hasMyBoolRequest = new RequestBuilders.PositionRequest() + .uri(foo) + .line(9) + .character(26); + + Location hasMyBoolLocation = server.definition(hasMyBoolRequest.buildDefinition()).get().getLeft().get(0); + assertThat(hasMyBoolLocation.getUri(), equalTo(bar)); + assertThat(hasMyBoolLocation.getRange().getStart(), equalTo(new Position(6, 0))); + + Hover hasMyBoolHover = server.hover(hasMyBoolRequest.buildHover()).get(); + String hasMyBoolHoverContent = hasMyBoolHover.getContents().getRight().getValue(); + assertThat(hasMyBoolHoverContent, containsString("@mixin")); + assertThat(hasMyBoolHoverContent, containsString("@tags")); + assertThat(hasMyBoolHoverContent, containsString("structure HasMyBool")); + assertThat(hasMyBoolHoverContent, not(containsString("///"))); + assertThat(hasMyBoolHoverContent, not(containsString("@documentation"))); + } + + @Test + public void newShapeMixinCompletion() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + + 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.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar with [F]"))); + + Position currentPosition = range.build().getStart(); + CompletionParams completionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .position(range.shiftRight().build().getStart()) + .buildCompletion(); + + assertThat(server.getProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + + List completions = server.completion(completionParams).get().getLeft(); + + assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); + } + + @Test + public void existingShapeMixinCompletion() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar {}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + + 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.getProject().getDocument(uri).copyText(), equalTo(safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "@mixin\n" + + "structure Foo {}\n" + + "\n" + + "structure Bar with [F] {}\n"))); + + Position currentPosition = range.build().getStart(); + CompletionParams completionParams = new RequestBuilders.PositionRequest() + .uri(uri) + .position(range.shiftRight().build().getStart()) + .buildCompletion(); + + assertThat(server.getProject().getDocument(uri).copyToken(currentPosition), equalTo("F")); + + List completions = server.completion(completionParams).get().getLeft(); + + assertThat(completions, containsInAnyOrder(hasLabel("Foo"))); + } + + @Test + public void diagnosticsOnMemberTarget() { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: Bar\n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + List diagnostics = server.getFileDiagnostics(uri); + + assertThat(diagnostics, hasSize(1)); + Diagnostic diagnostic = diagnostics.get(0); + assertThat(diagnostic.getMessage(), startsWith("Target.UnresolvedShape")); + + Document document = server.getProject().getDocument(uri); + assertThat(diagnostic.getRange(), hasText(document, equalTo("Bar"))); + } + + @Test + public void diagnosticOnTrait() { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " @bar\n" + + " bar: String\n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + + List diagnostics = server.getFileDiagnostics(uri); + + assertThat(diagnostics, hasSize(1)); + Diagnostic diagnostic = diagnostics.get(0); + assertThat(diagnostic.getMessage(), startsWith("Model.UnresolvedTrait")); + + Document document = server.getProject().getDocument(uri); + assertThat(diagnostic.getRange(), hasText(document, equalTo("@bar"))); + } + + @Test + public void diagnosticsOnShape() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "list Foo {\n" + + " \n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + StubClient client = new StubClient(); + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + JsonObject opts = new JsonObject(); + opts.add("diagnostics.minimumSeverity", new JsonPrimitive("NOTE")); + server.initialize(new RequestBuilders.Initialize() + .workspaceFolder(workspace.getRoot().toUri().toString(), "test") + .initializationOptions(opts) + .build()) + .get(); + + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen() + .uri(uri) + .text(model) + .build()); + + server.didSave(new RequestBuilders.DidSave() + .uri(uri) + .build()); + + List diagnostics = server.getFileDiagnostics(uri); + + assertThat(diagnostics, hasSize(1)); + Diagnostic diagnostic = diagnostics.get(0); + assertThat(diagnostic.getMessage(), containsString("Missing required member")); + // TODO: In this case, the event is attached to the shape, but the shape isn't in the model + // because it could not be successfully created. So we can't know the actual position of + // the shape, because determining it depends on where its defined in the model. + // assertThat(diagnostic.getRange().getStart(), equalTo(new Position(3, 5))); + // assertThat(diagnostic.getRange().getEnd(), equalTo(new Position(3, 8))); + } + + @Test + public void insideJar() throws Exception { + String model = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "structure Foo {\n" + + " bar: PrimitiveInteger\n" + + "}\n"); + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(model) + .build()); + + Location preludeLocation = server.definition(RequestBuilders.positionRequest() + .uri(uri) + .line(4) + .character(9) + .buildDefinition()) + .get() + .getLeft() + .get(0); + + String preludeUri = preludeLocation.getUri(); + assertThat(preludeUri, startsWith("smithyjar")); + Logger.getLogger(getClass().getName()).severe("DOCUMENT LINES: " + server.getProject().getDocument(preludeUri).fullRange()); + + Hover appliedTraitInPreludeHover = server.hover(RequestBuilders.positionRequest() + .uri(preludeUri) + .line(preludeLocation.getRange().getStart().getLine() - 1) // trait applied above 'PrimitiveInteger' + .character(1) + .buildHover()) + .get(); + String content = appliedTraitInPreludeHover.getContents().getRight().getValue(); + assertThat(content, containsString("document default")); + } + + @Test + public void addingWatchedFile() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String filename = "model/main.smithy"; + String modelText = ""; + workspace.addModel(filename, modelText); + String uri = workspace.getUri(filename); + + // The file may be opened before the client notifies the server it's been created + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + server.documentSymbol(new DocumentSymbolParams(new TextDocumentIdentifier(uri))); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Created) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.origin()) + .text("$") + .build()); + + // Make sure the task is running, then wait for it + CompletableFuture future = server.getLifecycleManager().getTask(uri); + assertThat(future, notNullValue()); + future.get(); + + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().mainProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProjects().mainProject().getDocument(uri), notNullValue()); + assertThat(server.getProjects().mainProject().getDocument(uri).copyText(), equalTo("$")); + } + + @Test + public void removingWatchedFile() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "model/main.smithy"; + String modelText = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"); + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + workspace.deleteModel(filename); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) + .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(false)); + assertThat(server.getProjects().getDocument(uri), nullValue()); + } + + @Test + public void addingDetachedFile() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "main.smithy"; + String modelText = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"); + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + + String movedFilename = "model/main.smithy"; + workspace.moveModel(filename, movedFilename); + String movedUri = workspace.getUri(movedFilename); + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(movedUri) + .text(modelText) + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(movedUri, FileChangeType.Created) + .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(false)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), nullValue()); + assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); + assertThat(server.getProjects().isDetached(movedUri), is(false)); + assertThat(server.getProjects().getProject(movedUri), notNullValue()); + } + + @Test + public void removingAttachedFile() { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "model/main.smithy"; + String modelText = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"); + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + + String movedFilename = "main.smithy"; + workspace.moveModel(filename, movedFilename); + String movedUri = workspace.getUri(movedFilename); + + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(movedUri) + .text(modelText) + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) + .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(false)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), nullValue()); + assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); + assertThat(server.getProjects().isDetached(movedUri), is(true)); + assertThat(server.getProjects().getProject(movedUri), notNullValue()); + } + + @Test + public void loadsProjectWithUnNormalizedSourcesDirs() { + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version("1") + .sources(Collections.singletonList("./././smithy")) + .build(); + String filename = "smithy/main.smithy"; + String modelText = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"); + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(TestWorkspace.dir() + .path("./smithy") + .withSourceFile("main.smithy", modelText)) + .withConfig(config) + .build(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + assertThat(server.getLifecycleManager().isManaged(uri), is(true)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + } + + @Test + public void reloadingProjectWithArrayMetadataValues() throws Exception { + String modelText1 = safeString("$version: \"2\"\n" + + "\n" + + "metadata foo = [1]\n" + + "metadata foo = [2]\n" + + "metadata bar = {a: [1]}\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + + "\n" + + "metadata foo = [3]\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Bar\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + Map metadataBefore = server.getProject().modelResult().unwrap().getMetadata(); + assertThat(metadataBefore, hasKey("foo")); + assertThat(metadataBefore, hasKey("bar")); + assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataBefore.get("foo").expectArrayNode().size(), equalTo(3)); + + String uri = workspace.getUri("model-0.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText1) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.lineSpan(8, 0, 0)) + .text(safeString("\nstring Baz\n")) + .build()); + server.didSave(RequestBuilders.didSave() + .uri(uri) + .build()); + + server.getLifecycleManager().getTask(uri).get(); + + Map metadataAfter = server.getProject().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(3)); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.of(2, 0, 3, 0)) // removing the first 'foo' metadata + .text("") + .build()); + + server.getLifecycleManager().getTask(uri).get(); + + Map metadataAfter2 = server.getProject().modelResult().unwrap().getMetadata(); + assertThat(metadataAfter2, hasKey("foo")); + assertThat(metadataAfter2, hasKey("bar")); + assertThat(metadataAfter2.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataAfter2.get("foo").expectArrayNode().size(), equalTo(2)); + } + + @Test + public void changingWatchedFilesWithMetadata() throws Exception { + String modelText1 = safeString("$version: \"2\"\n" + + "\n" + + "metadata foo = [1]\n" + + "metadata foo = [2]\n" + + "metadata bar = {a: [1]}\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + + "\n" + + "metadata foo = [3]\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Bar\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + Map metadataBefore = server.getProject().modelResult().unwrap().getMetadata(); + assertThat(metadataBefore, hasKey("foo")); + assertThat(metadataBefore, hasKey("bar")); + assertThat(metadataBefore.get("foo"), instanceOf(ArrayNode.class)); + assertThat(metadataBefore.get("foo").expectArrayNode().size(), equalTo(3)); + + String uri = workspace.getUri("model-1.smithy"); + + workspace.deleteModel("model-1.smithy"); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Deleted) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + Map metadataAfter = server.getProject().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(); + String filename = "main.smithy"; + String modelText = safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"); + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + assertThat(server.getLifecycleManager().managedDocuments(), not(hasItem(uri))); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().getProject(uri), nullValue()); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.point(3, 0)) + .text(safeString("string Bar\n")) + .build()); + + // Add the already-opened file to the project + List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); + updatedSources.add("main.smithy"); + workspace.updateConfig(workspace.getConfig() + .toBuilder() + .sources(updatedSources) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getLifecycleManager().managedDocuments(), hasItem(uri)); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProject().modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + assertThat(server.getProject().modelResult().unwrap(), hasShapeWithId("com.foo#Bar")); + } + + @Test + public void detachingOpenedFile() throws Exception { + String modelText = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"); + TestWorkspace workspace = TestWorkspace.singleModel(modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("main.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.point(3, 0)) + .text(safeString("string Bar\n")) + .build()); + + workspace.updateConfig(workspace.getConfig() + .toBuilder() + .sources(new ArrayList<>()) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getLifecycleManager().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"))); + } + + @Test + public void movingDetachedFile() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + String filename = "main.smithy"; + String modelText = safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "string Foo\n"); + workspace.addModel(filename, modelText); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + // Moving to an also detached file - the server doesn't send DidChangeWatchedFiles + String movedFilename = "main-2.smithy"; + workspace.moveModel(filename, movedFilename); + String movedUri = workspace.getUri(movedFilename); + + server.didClose(RequestBuilders.didClose() + .uri(uri) + .build()); + server.didOpen(RequestBuilders.didOpen() + .uri(movedUri) + .text(modelText) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getLifecycleManager().isManaged(uri), is(false)); + assertThat(server.getProjects().getProject(uri), nullValue()); + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getLifecycleManager().isManaged(movedUri), is(true)); + assertThat(server.getProjects().getProject(movedUri), notNullValue()); + assertThat(server.getProjects().isDetached(movedUri), is(true)); + } + + @Test + public void updatesDiagnosticsAfterReload() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + + String filename1 = "model/main.smithy"; + String modelText1 = safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "// using an unknown trait\n" + + "@foo\n" + + "string Bar\n"); + workspace.addModel(filename1, modelText1); + + StubClient client = new StubClient(); + SmithyLanguageServer server = initFromWorkspace(workspace, client); + + String uri1 = workspace.getUri(filename1); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri1) + .text(modelText1) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + List publishedDiagnostics1 = client.diagnostics; + assertThat(publishedDiagnostics1, hasSize(1)); + assertThat(publishedDiagnostics1.get(0).getUri(), equalTo(uri1)); + assertThat(publishedDiagnostics1.get(0).getDiagnostics(), containsInAnyOrder( + diagnosticWithMessage(containsString("Model.UnresolvedTrait")))); + + String filename2 = "model/trait.smithy"; + String modelText2 = safeString("$version: \"2\"\n" + + "\n" + + "namespace com.foo\n" + + "\n" + + "// adding the missing trait\n" + + "@trait\n" + + "structure foo {}\n"); + workspace.addModel(filename2, modelText2); + + String uri2 = workspace.getUri(filename2); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri2, FileChangeType.Created) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + List publishedDiagnostics2 = client.diagnostics; + assertThat(publishedDiagnostics2, hasSize(2)); // sent more diagnostics + assertThat(publishedDiagnostics2.get(1).getUri(), equalTo(uri1)); // sent diagnostics for opened file + assertThat(publishedDiagnostics2.get(1).getDiagnostics(), empty()); // adding the trait cleared the event + } + + @Test + public void invalidSyntaxModelPartiallyLoads() { + String modelText1 = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"); + String modelText2 = safeString("string Bar\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("model-0.smithy"); + + assertThat(server.getProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProject().modelResult().isBroken(), is(true)); + assertThat(server.getProject().modelResult().getResult().isPresent(), is(true)); + assertThat(server.getProject().modelResult().getResult().get(), hasShapeWithId("com.foo#Foo")); + } + + @Test + public void invalidSyntaxDetachedProjectBecomesValid() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String filename = "main.smithy"; + String modelText = safeString("string Foo\n"); + workspace.addModel(filename, modelText); + + String uri = workspace.getUri(filename); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(true)); + assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); + assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.origin()) + .text(safeString("$version: \"2\"\nnamespace com.foo\n")) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getProjects().getProject(uri).modelResult().isBroken(), is(false)); + assertThat(server.getProjects().getProject(uri).modelResult().getResult().isPresent(), is(true)); + assertThat(server.getProjects().getProject(uri).smithyFiles().keySet(), hasItem(endsWith(filename))); + assertThat(server.getProjects().getProject(uri).modelResult().unwrap(), hasShapeWithId("com.foo#Foo")); + } + + // TODO: apparently flaky + @Test + public void addingDetachedFileWithInvalidSyntax() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String filename = "main.smithy"; + workspace.addModel(filename, ""); + + String uri = workspace.getUri(filename); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text("") + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(true)); + assertThat(server.getProjects().getProject(uri), notNullValue()); + assertThat(server.getProjects().getProject(uri).getSmithyFile(uri), notNullValue()); + + List updatedSources = new ArrayList<>(workspace.getConfig().getSources()); + updatedSources.add(filename); + workspace.updateConfig(workspace.getConfig() + .toBuilder() + .sources(updatedSources) + .build()); + + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(safeString("$version: \"2\"\n")) + .range(LspAdapter.origin()) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(safeString("namespace com.foo\n")) + .range(LspAdapter.point(1, 0)) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(safeString("string Foo\n")) + .range(LspAdapter.point(2, 0)) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProjects().isDetached(uri), is(false)); + assertThat(server.getProjects().detachedProjects().keySet(), empty()); + assertThat(server.getProject().getSmithyFile(uri), notNullValue()); + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Foo"))); + } + + @Test + public void appliedTraitsAreMaintainedInPartialLoad() throws Exception { + String modelText1 = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @length(min: 1)\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri2 = workspace.getUri("model-1.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri2) + .text(modelText2) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri2) + .range(LspAdapter.of(3, 23, 3, 24)) + .text("2") + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + Shape foo = server.getProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); + assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); + + String uri1 = workspace.getUri("model-0.smithy"); + + server.didOpen(RequestBuilders.didOpen() + .uri(uri1) + .text(modelText1) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri1) + .range(LspAdapter.point(3, 0)) + .text(safeString("string Another\n")) + .build()); + + server.getLifecycleManager().waitForAllTasks(); + + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Bar"))); + assertThat(server.getProject().modelResult(), hasValue(hasShapeWithId("com.foo#Another"))); + foo = server.getProject().modelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID)); + assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L))); + } + + @Test + public void brokenBuildFileEventuallyConsistent() throws Exception { + TestWorkspace workspace = TestWorkspace.emptyWithDirSource(); + SmithyLanguageServer server = initFromWorkspace(workspace); + + workspace.addModel("model/main.smithy", ""); + String uri = workspace.getUri("model/main.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text("") + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(uri, FileChangeType.Created) + .build()); + + String invalidDependency = "software.amazon.smithy:smithy-smoke-test-traits:[1.0, 2.0["; + workspace.updateConfig(workspace.getConfig().toBuilder() + .maven(MavenConfig.builder() + .dependencies(Collections.singletonList(invalidDependency)) + .build()) + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + String fixed = "software.amazon.smithy:smithy-smoke-test-traits:1.49.0"; + workspace.updateConfig(workspace.getConfig().toBuilder() + .maven(MavenConfig.builder() + .dependencies(Collections.singletonList(fixed)) + .build()) + .build()); + server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles() + .event(workspace.getUri("smithy-build.json"), FileChangeType.Changed) + .build()); + + server.didChange(RequestBuilders.didChange() + .uri(uri) + .text(safeString("$version: \"2\"\nnamespace com.foo\nstring Foo\n")) + .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\"\n" + + "namespace com.foo\n" + + "use com.bar#Bar\n" + + "@com.bar#baz\n" + + "structure Foo {\n" + + " bar: com.bar#Bar\n" + + "}\n"); + String modelText2 = safeString("$version: \"2\"\n" + + "namespace com.bar\n" + + "string Bar\n" + + "string Bar2\n" + + "@trait\n" + + "structure baz {}\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("model-0.smithy"); + + // use com.b + RequestBuilders.PositionRequest useTarget = RequestBuilders.positionRequest() + .uri(uri) + .line(2) + .character(8); + // @com.b + RequestBuilders.PositionRequest trait = RequestBuilders.positionRequest() + .uri(uri) + .line(3) + .character(2); + // bar: com.ba + RequestBuilders.PositionRequest memberTarget = RequestBuilders.positionRequest() + .uri(uri) + .line(5) + .character(14); + + List useTargetCompletions = server.completion(useTarget.buildCompletion()).get().getLeft(); + List traitCompletions = server.completion(trait.buildCompletion()).get().getLeft(); + List memberTargetCompletions = server.completion(memberTarget.buildCompletion()).get().getLeft(); + + assertThat(useTargetCompletions, containsInAnyOrder(hasLabel("com.bar#Bar2"))); // won't match 'Bar' because its already imported + assertThat(traitCompletions, containsInAnyOrder(hasLabel("com.bar#baz"))); + assertThat(memberTargetCompletions, containsInAnyOrder(hasLabel("com.bar#Bar"), hasLabel("com.bar#Bar2"))); + + List useTargetLocations = server.definition(useTarget.buildDefinition()).get().getLeft(); + List traitLocations = server.definition(trait.buildDefinition()).get().getLeft(); + List memberTargetLocations = server.definition(memberTarget.buildDefinition()).get().getLeft(); + + String uri1 = workspace.getUri("model-1.smithy"); + + assertThat(useTargetLocations, hasSize(1)); + assertThat(useTargetLocations.get(0).getUri(), equalTo(uri1)); + assertThat(useTargetLocations.get(0).getRange().getStart(), equalTo(new Position(2, 0))); + + assertThat(traitLocations, hasSize(1)); + assertThat(traitLocations.get(0).getUri(), equalTo(uri1)); + assertThat(traitLocations.get(0).getRange().getStart(), equalTo(new Position(5, 0))); + + assertThat(memberTargetLocations, hasSize(1)); + assertThat(memberTargetLocations.get(0).getUri(), equalTo(uri1)); + assertThat(memberTargetLocations.get(0).getRange().getStart(), equalTo(new Position(2, 0))); + + Hover useTargetHover = server.hover(useTarget.buildHover()).get(); + Hover traitHover = server.hover(trait.buildHover()).get(); + Hover memberTargetHover = server.hover(memberTarget.buildHover()).get(); + + assertThat(useTargetHover.getContents().getRight().getValue(), containsString("string Bar")); + assertThat(traitHover.getContents().getRight().getValue(), containsString("structure baz {}")); + assertThat(memberTargetHover.getContents().getRight().getValue(), containsString("string Bar")); + } + + @Test + public void useCompletionDoesntAutoImport() throws Exception { + String modelText1 = safeString("$version: \"2\"\n" + + "namespace com.foo\n"); + String modelText2 = safeString("$version: \"2\"\n" + + "namespace com.bar\n" + + "string Bar\n"); + TestWorkspace workspace = TestWorkspace.multipleModels(modelText1, modelText2); + SmithyLanguageServer server = initFromWorkspace(workspace); + + String uri = workspace.getUri("model-0.smithy"); + server.didOpen(RequestBuilders.didOpen() + .uri(uri) + .text(modelText1) + .build()); + server.didChange(RequestBuilders.didChange() + .uri(uri) + .range(LspAdapter.point(2, 0)) + .text("use co") + .build()); + + List completions = server.completion(RequestBuilders.positionRequest() + .uri(uri) + .line(2) + .character(5) + .buildCompletion()) + .get() + .getLeft(); + + assertThat(completions, containsInAnyOrder(hasLabel("com.bar#Bar"))); + assertThat(completions.get(0).getAdditionalTextEdits(), nullValue()); + } + + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) { + return initFromWorkspace(workspace, new StubClient()); + } + + public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace, LanguageClient client) { + try { + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + server.initialize(RequestBuilders.initialize() + .workspaceFolder(workspace.getRoot().toUri().toString(), "test") + .build()) + .get(); + + return server; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static SmithyLanguageServer initFromRoot(Path root) { + try { + LanguageClient client = new StubClient(); + SmithyLanguageServer server = new SmithyLanguageServer(); + server.connect(client); + + server.initialize(new RequestBuilders.Initialize() + .workspaceFolder(root.toUri().toString(), "test") + .build()) + .get(); + + return server; + } catch (Exception e) { + throw new RuntimeException(e); + } } } diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java new file mode 100644 index 00000000..6145b423 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java @@ -0,0 +1,74 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.Set; +import java.util.stream.Collectors; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; + +/** + * Hamcrest matchers for Smithy library types. + */ +public final class SmithyMatchers { + private SmithyMatchers() {} + + public static Matcher> hasValue(Matcher matcher) { + return new CustomTypeSafeMatcher>("A validated result with value " + matcher.toString()) { + @Override + protected boolean matchesSafely(ValidatedResult item) { + return item.getResult().isPresent() && matcher.matches(item.getResult().get()); + } + + @Override + public void describeMismatchSafely(ValidatedResult item, Description description) { + if (item.getResult().isPresent()) { + matcher.describeMismatch(item.getResult().get(), description); + } else { + description.appendText("Expected a value but result was empty."); + } + } + }; + } + + public static Matcher hasShapeWithId(String id) { + return new CustomTypeSafeMatcher("a model with the shape id `" + id + "`") { + @Override + protected boolean matchesSafely(Model item) { + return item.getShape(ShapeId.from(id)).isPresent(); + } + + @Override + public void describeMismatchSafely(Model model, Description description) { + Set nonPreludeIds = model.shapes().filter(shape -> !Prelude.isPreludeShape(shape)) + .map(Shape::toShapeId) + .collect(Collectors.toSet()); + description.appendText("had only these non-prelude shapes: " + nonPreludeIds); + } + }; + } + + public static Matcher eventWithMessage(Matcher message) { + return new CustomTypeSafeMatcher("has matching message") { + @Override + protected boolean matchesSafely(ValidationEvent item) { + return message.matches(item.getMessage()); + } + + @Override + public void describeMismatchSafely(ValidationEvent event, Description description) { + description.appendDescriptionOf(message).appendText("was " + event.getMessage()); + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java deleted file mode 100644 index 5c0e4773..00000000 --- a/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java +++ /dev/null @@ -1,999 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DefinitionParams; -import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.DidSaveTextDocumentParams; -import org.eclipse.lsp4j.DocumentSymbol; -import org.eclipse.lsp4j.DocumentSymbolParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.MessageActionItem; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.ShowMessageRequestParams; -import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.SymbolKind; -import org.eclipse.lsp4j.TextDocumentContentChangeEvent; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TextDocumentItem; -import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.eclipse.lsp4j.services.LanguageClient; -import org.junit.Test; -import software.amazon.smithy.lsp.ext.Harness; -import software.amazon.smithy.lsp.ext.SmithyProjectTest; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.MapUtils; -import software.amazon.smithy.utils.SetUtils; - -public class SmithyTextDocumentServiceTest { - - // All successful hover responses are wrapped between these strings - private static final String HOVER_DEFAULT_PREFIX = "```smithy\n$version: \"2.0\"\n\n"; - private static final String HOVER_DEFAULT_SUFFIX = "\n```"; - - @Test - public void correctlyAttributingDiagnostics() throws Exception { - String brokenFileName = "foo/broken.smithy"; - String goodFileName = "good.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(brokenFileName, "$version: \"2\"\nnamespace testFoo\n string_ MyId"), - MapUtils.entry(goodFileName, "$version: \"2\"\nnamespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - tds.createProject(hs.getConfig(), hs.getRoot()); - - File broken = hs.file(brokenFileName); - File good = hs.file(goodFileName); - - // When compiling broken file - Set filesWithDiagnostics = tds.recompile(broken, Optional.empty()).getRight().stream() - .filter(pds -> (pds.getDiagnostics().size() > 0)).map(PublishDiagnosticsParams::getUri) - .collect(Collectors.toSet()); - assertEquals(SetUtils.of(uri(broken)), filesWithDiagnostics); - - // When compiling good file - filesWithDiagnostics = tds.recompile(good, Optional.empty()).getRight().stream() - .filter(pds -> (pds.getDiagnostics().size() > 0)).map(PublishDiagnosticsParams::getUri) - .collect(Collectors.toSet()); - assertEquals(SetUtils.of(uri(broken)), filesWithDiagnostics); - - } - - } - - @Test - public void sendingDiagnosticsToTheClient() throws Exception { - String brokenFileName = "foo/broken.smithy"; - String goodFileName = "good.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(brokenFileName, "$version: \"2\"\nnamespace testFoo; string_ MyId"), - MapUtils.entry(goodFileName, "$version: \"2\"\nnamespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - File broken = hs.file(brokenFileName); - File good = hs.file(goodFileName); - - // OPEN - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(broken, files.get(brokenFileName)))); - - // broken file has a diagnostic published against it - assertEquals(1, filePublishedDiagnostics(broken, client.diagnostics).size()); - assertEquals(ListUtils.of(DiagnosticSeverity.Error), getSeverities(broken, client.diagnostics)); - // To clear diagnostics correctly, we must *explicitly* publish an empty - // list of diagnostics against files with no errors - - assertEquals(1, filePublishedDiagnostics(good, client.diagnostics).size()); - assertEquals(ListUtils.of(), filePublishedDiagnostics(good, client.diagnostics).get(0).getDiagnostics()); - - client.clear(); - - // SAVE - - tds.didSave(new DidSaveTextDocumentParams(new TextDocumentIdentifier(uri(broken)))); - - // broken file has a diagnostic published against it - assertEquals(1, filePublishedDiagnostics(broken, client.diagnostics).size()); - assertEquals(ListUtils.of(DiagnosticSeverity.Error), getSeverities(broken, client.diagnostics)); - // To clear diagnostics correctly, we must *explicitly* publish an empty - // list of diagnostics against files with no errors - assertEquals(1, filePublishedDiagnostics(good, client.diagnostics).size()); - assertEquals(ListUtils.of(), filePublishedDiagnostics(good, client.diagnostics).get(0).getDiagnostics()); - - } - - } - - @Test - public void attributesDiagnosticsForUnknownTraits() throws Exception { - String modelFilename = "ext/models/unknown-trait.smithy"; - Path modelFilePath = Paths.get(getClass().getResource(modelFilename).toURI()); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - File modelFile = hs.file(modelFilename); - - // There must be one warning diagnostic at the unknown trait's location - Range unknownTraitRange = new Range(new Position(6, 0), new Position(6, 0)); - long matchingDiagnostics = tds.recompile(modelFile, Optional.empty()).getRight().stream() - .flatMap(params -> params.getDiagnostics().stream()) - .filter(diagnostic -> diagnostic.getSeverity().equals(DiagnosticSeverity.Warning)) - .filter(diagnostic -> diagnostic.getRange().equals(unknownTraitRange)) - .count(); - assertEquals(1, matchingDiagnostics); - } - } - - @Test - public void allowsDefinitionWhenThereAreUnknownTraits() throws Exception { - Path baseDir = Paths.get(getClass().getResource("ext/models").toURI()); - String modelFilename = "unknown-trait.smithy"; - Path modelFilePath = baseDir.resolve(modelFilename); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - // We should still be able to respond with a location when there are unknown traits in the model - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - int locationCount = tds.definition(definitionParams(tdi, 10, 13)).get().getLeft().size(); - assertEquals(locationCount, 1); - } - } - - @Test - public void allowsHoverWhenThereAreUnknownTraits() throws Exception { - Path baseDir = Paths.get(getClass().getResource("ext/models").toURI()); - String modelFilename = "unknown-trait.smithy"; - Path modelFilePath = baseDir.resolve(modelFilename); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - // We should still be able to respond with hover content when there are unknown traits in the model - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - Hover hover = tds.hover(hoverParams(tdi, 14, 13)).get(); - correctHover("namespace com.foo\n\n", "structure Bar {\n member: Foo\n}", hover); - } - } - - @Test - public void hoverOnBrokenShapeAppendsValidations() throws Exception { - Path baseDir = Paths.get(getClass().getResource("ext/models").toURI()); - String modelFilename = "unknown-trait.smithy"; - Path modelFilePath = baseDir.resolve(modelFilename); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFilePath))) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - Hover hover = tds.hover(hoverParams(tdi, 10, 13)).get(); - MarkupContent hoverContent = hover.getContents().getRight(); - assertEquals(hoverContent.getKind(),"markdown"); - assertTrue(hoverContent.getValue().startsWith("```smithy")); - assertTrue(hoverContent.getValue().contains("structure Foo {}")); - assertTrue(hoverContent.getValue().contains("WARNING: Unable to resolve trait `com.external#unknownTrait`")); - } - } - - @Test - public void handlingChanges() throws Exception { - String fileName1 = "foo/bla.smithy"; - String fileName2 = "good.smithy"; - - Map files = MapUtils.ofEntries(MapUtils.entry(fileName1, "namespace testFoo\n string MyId"), - MapUtils.entry(fileName2, "namespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - File file1 = hs.file(fileName1); - File file2 = hs.file(fileName2); - - // OPEN - - tds.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri(file1), 1), - ListUtils.of(new TextDocumentContentChangeEvent("inspect broken")))); - - // Only diagnostics for existing files are reported - assertEquals(SetUtils.of(uri(file1), uri(file2)), SetUtils.copyOf(getUris(client.diagnostics))); - - } - - } - - @Test - public void definitionsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - // Resolves via token => shape name. - DefinitionParams commentParams = definitionParams(mainTdi, 43, 37); - Location commentLocation = tds.definition(commentParams).get().getLeft().get(0); - - // Resolves via shape target location in model. - DefinitionParams memberParams = definitionParams(mainTdi, 12, 18); - Location memberTargetLocation = tds.definition(memberParams).get().getLeft().get(0); - - // Resolves via member shape target location in prelude. - DefinitionParams preludeTargetParams = definitionParams(mainTdi, 36, 12); - Location preludeTargetLocation = tds.definition(preludeTargetParams).get().getLeft().get(0); - - // Resolves via top-level trait location in prelude. - DefinitionParams preludeTraitParams = definitionParams(mainTdi, 25, 3); - Location preludeTraitLocation = tds.definition(preludeTraitParams).get().getLeft().get(0); - - // Resolves via member-applied trait location in prelude. - DefinitionParams preludeMemberTraitParams = definitionParams(mainTdi, 59, 10); - Location preludeMemberTraitLocation = tds.definition(preludeMemberTraitParams).get().getLeft().get(0); - - // Resolves to current location. - DefinitionParams selfParams = definitionParams(mainTdi, 36, 0); - Location selfLocation = tds.definition(selfParams).get().getLeft().get(0); - - // Resolves via operation input. - DefinitionParams inputParams = definitionParams(mainTdi, 52, 16); - Location inputLocation = tds.definition(inputParams).get().getLeft().get(0); - - // Resolves via operation output. - DefinitionParams outputParams = definitionParams(mainTdi, 53, 17); - Location outputLocation = tds.definition(outputParams).get().getLeft().get(0); - - // Resolves via operation error. - DefinitionParams errorParams = definitionParams(mainTdi, 54, 14); - Location errorLocation = tds.definition(errorParams).get().getLeft().get(0); - - // Resolves via resource ids. - DefinitionParams idParams = definitionParams(mainTdi, 75, 29); - Location idLocation = tds.definition(idParams).get().getLeft().get(0); - - // Resolves via resource read. - DefinitionParams readParams = definitionParams(mainTdi, 76, 12); - Location readLocation = tds.definition(readParams).get().getLeft().get(0); - - // Does not correspond to shape. - DefinitionParams noMatchParams = definitionParams(mainTdi, 0, 0); - List noMatchLocationList = (List) tds.definition(noMatchParams).get().getLeft(); - - correctLocation(commentLocation, modelFilename, 20, 0, 21, 14); - correctLocation(memberTargetLocation, modelFilename, 4, 0, 4, 23); - correctLocation(selfLocation, modelFilename, 35, 0, 37, 1); - correctLocation(inputLocation, modelFilename, 57, 0, 61, 1); - correctLocation(outputLocation, modelFilename, 63, 0, 66, 1); - correctLocation(errorLocation, modelFilename, 69, 0, 72, 1); - correctLocation(idLocation, modelFilename, 79, 0, 79, 11); - correctLocation(readLocation, modelFilename, 51, 0, 55, 1); - assertTrue(preludeTargetLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeMemberTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(noMatchLocationList.isEmpty()); - } - } - - @Test - public void definitionsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - // Resolves via token => shape name. - DefinitionParams commentParams = definitionParams(mainTdi, 45, 37); - Location commentLocation = tds.definition(commentParams).get().getLeft().get(0); - - // Resolves via shape target location in model. - DefinitionParams memberParams = definitionParams(mainTdi, 14, 18); - Location memberTargetLocation = tds.definition(memberParams).get().getLeft().get(0); - - // Resolves via member shape target location in prelude. - DefinitionParams preludeTargetParams = definitionParams(mainTdi, 38, 12); - Location preludeTargetLocation = tds.definition(preludeTargetParams).get().getLeft().get(0); - - // Resolves via top-level trait location in prelude. - DefinitionParams preludeTraitParams = definitionParams(mainTdi, 27, 3); - Location preludeTraitLocation = tds.definition(preludeTraitParams).get().getLeft().get(0); - - // Resolves via member-applied trait location in prelude. - DefinitionParams preludeMemberTraitParams = definitionParams(mainTdi, 61, 10); - Location preludeMemberTraitLocation = tds.definition(preludeMemberTraitParams).get().getLeft().get(0); - - // Resolves to current location. - DefinitionParams selfParams = definitionParams(mainTdi, 38, 0); - Location selfLocation = tds.definition(selfParams).get().getLeft().get(0); - - // Resolves via operation input. - DefinitionParams inputParams = definitionParams(mainTdi, 54, 16); - Location inputLocation = tds.definition(inputParams).get().getLeft().get(0); - - // Resolves via operation output. - DefinitionParams outputParams = definitionParams(mainTdi, 55, 17); - Location outputLocation = tds.definition(outputParams).get().getLeft().get(0); - - // Resolves via operation error. - DefinitionParams errorParams = definitionParams(mainTdi, 56, 14); - Location errorLocation = tds.definition(errorParams).get().getLeft().get(0); - - // Resolves via resource ids. - DefinitionParams idParams = definitionParams(mainTdi, 77, 29); - Location idLocation = tds.definition(idParams).get().getLeft().get(0); - - // Resolves via resource read. - DefinitionParams readParams = definitionParams(mainTdi, 78, 12); - Location readLocation = tds.definition(readParams).get().getLeft().get(0); - - // Does not correspond to shape. - DefinitionParams noMatchParams = definitionParams(mainTdi, 0, 0); - List noMatchLocationList = (List) tds.definition(noMatchParams).get().getLeft(); - - // Resolves via mixin target on operation input. - DefinitionParams mixinInputParams = definitionParams(mainTdi, 143, 24); - Location mixinInputLocation = tds.definition(mixinInputParams).get().getLeft().get(0); - - // Resolves via mixin target on operation output. - DefinitionParams mixinOutputParams = definitionParams(mainTdi, 149, 36); - Location mixinOutputLocation = tds.definition(mixinOutputParams).get().getLeft().get(0); - - // Resolves via mixin target on structure. - DefinitionParams mixinStructureParams = definitionParams(mainTdi, 134, 36); - Location mixinStructureLocation = tds.definition(mixinStructureParams).get().getLeft().get(0); - - correctLocation(commentLocation, modelFilename, 22, 0, 23, 14); - correctLocation(memberTargetLocation, modelFilename, 6, 0, 6, 23); - correctLocation(selfLocation, modelFilename, 37, 0, 39, 1); - correctLocation(inputLocation, modelFilename, 59, 0, 63, 1); - correctLocation(outputLocation, modelFilename, 65, 0, 68, 1); - correctLocation(errorLocation, modelFilename, 71, 0, 74, 1); - correctLocation(idLocation, modelFilename, 81, 0, 81, 11); - correctLocation(readLocation, modelFilename, 53, 0, 57, 1); - correctLocation(mixinInputLocation, modelFilename, 112, 0, 118, 1); - correctLocation(mixinOutputLocation, modelFilename, 121, 0, 123, 1); - correctLocation(mixinStructureLocation, modelFilename, 112, 0, 118, 1); - assertTrue(preludeTargetLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(preludeMemberTraitLocation.getUri().endsWith("prelude.smithy")); - assertTrue(noMatchLocationList.isEmpty()); - } - } - - @Test - public void completionsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - CompletionParams traitParams = completionParams(mainTdi, 85, 10); - List traitCompletionItems = tds.completion(traitParams).get().getLeft(); - - CompletionParams shapeParams = completionParams(mainTdi, 51,16); - List shapeCompletionItems = tds.completion(shapeParams).get().getLeft(); - - CompletionParams applyStatementParams = completionParams(mainTdi,83, 23); - List applyStatementCompletionItems = tds.completion(applyStatementParams).get().getLeft(); - - CompletionParams whiteSpaceParams = completionParams(mainTdi, 0, 0); - List whiteSpaceCompletionItems = tds.completion(whiteSpaceParams).get().getLeft(); - - assertEquals(SetUtils.of("MyOperation", "MyOperationInput", "MyOperationOutput"), - completionLabels(shapeCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(applyStatementCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(traitCompletionItems)); - - assertTrue(whiteSpaceCompletionItems.isEmpty()); - } - } - - @Test - public void hoverV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - String testFilename = "test.smithy"; - Path modelTest = baseDir.resolve(testFilename); - String clutteredPreambleFilename = "cluttered-preamble.smithy"; - Path modelClutteredPreamble = baseDir.resolve(clutteredPreambleFilename); - String extrasToImportFilename = "extras-to-import.smithy"; - Path modelExtras = baseDir.resolve(extrasToImportFilename); - List modelFiles = ListUtils.of(modelMain, modelTest, modelClutteredPreamble, modelExtras); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - TextDocumentIdentifier testTdi = new TextDocumentIdentifier(hs.file(testFilename).toString()); - TextDocumentIdentifier clutteredTdi = new TextDocumentIdentifier(hs.file(clutteredPreambleFilename).toString()); - - // Namespace and use statements in hover response - String preludeHoverPrefix = "namespace smithy.api\n\n"; - String mainHoverPrefix = "namespace com.foo\n\n"; - String testHoverPrefix = "namespace com.example\n\nuse com.foo#emptyTraitStruct\n\n"; - String clutteredHoverWithDependenciesPrefix = "namespace com.clutter\n\nuse " + - "com.example#OtherStructure\nuse com.extras#Extra\n\n"; - String clutteredHoverWithNoDependenciesPrefix = "namespace com.clutter\n\n"; - - // Resolves via top-level trait location in prelude. - Hover preludeTraitHover = tds.hover(hoverParams(mainTdi, 25, 3)).get(); - MarkupContent preludeTraitHoverContents = preludeTraitHover.getContents().getRight(); - assertEquals(preludeTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix + - "/// Specializes a structure for use only as the input")); - assertTrue(preludeTraitHoverContents.getValue().endsWith("structure input {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeMemberTraitHover = tds.hover(hoverParams(mainTdi, 59, 10)).get(); - MarkupContent preludeMemberTraitHoverContents = preludeMemberTraitHover.getContents().getRight(); - assertEquals(preludeMemberTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeMemberTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix + - "/// Marks a structure member as required")); - assertTrue(preludeMemberTraitHoverContents.getValue().endsWith("structure required {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeTargetHover = tds.hover(hoverParams(mainTdi, 36, 12)).get(); - correctHover(preludeHoverPrefix , "string String", preludeTargetHover); - - // Resolves via token => shape name. - Hover commentHover = tds.hover(hoverParams(mainTdi, 43, 37)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"foo\"\n])\nstructure MultiTrait {\n a: String\n}", commentHover); - - // Resolves via shape target location in model. - Hover memberTargetHover = tds.hover(hoverParams(mainTdi, 12, 18)).get(); - correctHover(mainHoverPrefix, "structure SingleLine {}", memberTargetHover); - - // Resolves from member key to shape target location in model. - Hover memberIdentifierHover = tds.hover(hoverParams(mainTdi, 64, 7)).get(); - correctHover(preludeHoverPrefix, "string String", memberIdentifierHover); - - // Resolves to current location. - Hover selfHover = tds.hover(hoverParams(mainTdi, 36, 0)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"a\"\n \"b\"\n \"c\"\n \"d\"\n \"e\"\n \"f\"\n" - + "])\nstructure MultiTraitAndLineComments {\n a: String\n}", selfHover); - - // Resolves via operation input. - Hover inputHover = tds.hover(hoverParams(mainTdi, 52, 16)).get(); - correctHover(mainHoverPrefix, "structure MyOperationInput {\n foo: String\n @required\n myId: MyId\n}", - inputHover); - - // Resolves via operation output. - Hover outputHover = tds.hover(hoverParams(mainTdi, 53, 17)).get(); - correctHover(mainHoverPrefix, "structure MyOperationOutput {\n corge: String\n qux: String\n}", outputHover); - - // Resolves via operation error. - Hover errorHover = tds.hover(hoverParams(mainTdi, 54, 14)).get(); - correctHover(mainHoverPrefix, "@error(\"client\")\nstructure MyError {\n blah: String\n blahhhh: Integer\n}", - errorHover); - - // Resolves via resource ids. - Hover idHover = tds.hover(hoverParams(mainTdi, 75, 29)).get(); - correctHover(mainHoverPrefix, "string MyId", idHover); - - // Resolves via resource read. - Hover readHover = tds.hover(hoverParams(mainTdi, 76, 12)).get(); - assertTrue(readHover.getContents().getRight().getValue().contains("@http(\n method: \"PUT\"\n " - + "uri: \"/bar\"\n code: 200\n)\n@readonly\noperation MyOperation {\n input: " - + "MyOperationInput\n output: MyOperationOutput\n errors: [\n MyError\n ]\n}")); - - // Does not correspond to shape. - Hover noMatchHover = tds.hover(hoverParams(mainTdi, 0, 0)).get(); - assertNull(noMatchHover.getContents().getRight().getValue()); - - // Resolves between multiple model files. - Hover multiFileHover = tds.hover(hoverParams(testTdi, 7, 15)).get(); - correctHover(testHoverPrefix, "@emptyTraitStruct\nstructure OtherStructure {\n foo: String\n bar: String\n" - + " baz: Integer\n}", multiFileHover); - - // Resolves a shape including its dependencies in the preamble - Hover clutteredWithDependenciesHover = tds.hover(hoverParams(clutteredTdi, 25, 17)).get(); - correctHover(clutteredHoverWithDependenciesPrefix, "/// With doc comment\n" - + "structure StructureWithDependencies {\n" - + " extra: Extra\n example: OtherStructure\n}", clutteredWithDependenciesHover); - - // Resolves shape with no dependencies, but doesn't include cluttered preamble - Hover clutteredWithNoDependenciesHover = tds.hover(hoverParams(clutteredTdi, 30, 17)).get(); - correctHover(clutteredHoverWithNoDependenciesPrefix, "structure StructureWithNoDependencies {\n" - + " member: String\n}", clutteredWithNoDependenciesHover); - - } - } - - @Test - public void hoverV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - String testFilename = "test.smithy"; - Path modelTest = baseDir.resolve(testFilename); - String clutteredPreambleFilename = "cluttered-preamble.smithy"; - Path modelClutteredPreamble = baseDir.resolve(clutteredPreambleFilename); - String extrasToImportFilename = "extras-to-import.smithy"; - Path modelExtras = baseDir.resolve(extrasToImportFilename); - List modelFiles = ListUtils.of(modelMain, modelTest, modelClutteredPreamble, modelExtras); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - TextDocumentIdentifier testTdi = new TextDocumentIdentifier(hs.file(testFilename).toString()); - TextDocumentIdentifier clutteredTdi = new TextDocumentIdentifier(hs.file(clutteredPreambleFilename).toString()); - - // Namespace and use statements in hover response - String preludeHoverPrefix = "namespace smithy.api\n\n"; - String mainHoverPrefix = "namespace com.foo\n\n"; - String testHoverPrefix = "namespace com.example\n\nuse com.foo#emptyTraitStruct\n\n"; - String clutteredHoverWithDependenciesPrefix = "namespace com.clutter\n\nuse " + - "com.example#OtherStructure\nuse com.extras#Extra\n\n"; - String clutteredHoverInlineOpPrefix = "namespace com.clutter\n\n"; - - // Resolves via top-level trait location in prelude. - Hover preludeTraitHover = tds.hover(hoverParams(mainTdi, 27, 3)).get(); - MarkupContent preludeTraitHoverContents = preludeTraitHover.getContents().getRight(); - assertEquals(preludeTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix - + "/// Specializes a structure for use only as the" + " input")); - assertTrue(preludeTraitHoverContents.getValue().endsWith("structure input {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeMemberTraitHover = tds.hover(hoverParams(mainTdi, 61, 10)).get(); - MarkupContent preludeMemberTraitHoverContents = preludeMemberTraitHover.getContents().getRight(); - assertEquals(preludeMemberTraitHoverContents.getKind(), "markdown"); - assertTrue(preludeMemberTraitHoverContents.getValue().startsWith(HOVER_DEFAULT_PREFIX + preludeHoverPrefix - + "/// Marks a structure member as required")); - assertTrue(preludeMemberTraitHoverContents.getValue().endsWith("structure required {}" + HOVER_DEFAULT_SUFFIX)); - - // Resolves via member shape target location in prelude. - Hover preludeTargetHover = tds.hover(hoverParams(mainTdi, 38, 12)).get(); - correctHover(preludeHoverPrefix, "string String", preludeTargetHover); - - // Resolves via token => shape name. - Hover commentHover = tds.hover(hoverParams(mainTdi, 45, 37)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"foo\"\n])\nstructure MultiTrait {\n a: String\n}", commentHover); - - // Resolves via shape target location in model. - Hover memberTargetHover = tds.hover(hoverParams(mainTdi, 14, 18)).get(); - correctHover(mainHoverPrefix, "structure SingleLine {}", memberTargetHover); - - // Resolves from member key to shape target location in model. - Hover memberIdentifierHover = tds.hover(hoverParams(mainTdi, 66, 7)).get(); - correctHover(preludeHoverPrefix, "string String", memberIdentifierHover); - - // Resolves to current location. - Hover selfHover = tds.hover(hoverParams(mainTdi, 38, 0)).get(); - correctHover(mainHoverPrefix, "@input\n@tags([\n \"a\"\n \"b\"\n \"c\"\n \"d\"\n \"e\"\n \"f\"\n" - + "])\nstructure MultiTraitAndLineComments {\n a: String\n}", selfHover); - - // Resolves via operation input. - Hover inputHover = tds.hover(hoverParams(mainTdi, 54, 16)).get(); - correctHover(mainHoverPrefix, "structure MyOperationInput {\n foo: String\n @required\n myId: MyId\n}", - inputHover); - - // Resolves via operation output. - Hover outputHover = tds.hover(hoverParams(mainTdi, 55, 17)).get(); - correctHover(mainHoverPrefix, "structure MyOperationOutput {\n corge: String\n qux: String\n}", outputHover); - - // Resolves via operation error. - Hover errorHover = tds.hover(hoverParams(mainTdi, 56, 14)).get(); - correctHover(mainHoverPrefix, "@error(\"client\")\nstructure MyError {\n blah: String\n blahhhh: Integer\n}", - errorHover); - - // Resolves via resource ids. - Hover idHover = tds.hover(hoverParams(mainTdi, 77, 29)).get(); - correctHover(mainHoverPrefix, "string MyId", idHover); - - // Resolves via resource read. - Hover readHover = tds.hover(hoverParams(mainTdi, 78, 12)).get(); - assertTrue(readHover.getContents().getRight().getValue().contains("@http(\n method: \"PUT\"\n " - + "uri: \"/bar\"\n code: 200\n)\n@readonly\noperation MyOperation {\n input: " - + "MyOperationInput\n output: MyOperationOutput\n errors: [\n MyError\n ]\n}")); - - // Does not correspond to shape. - Hover noMatchHover = tds.hover(hoverParams(mainTdi, 0, 0)).get(); - assertNull(noMatchHover.getContents().getRight().getValue()); - - // Resolves between multiple model files. - Hover multiFileHover = tds.hover(hoverParams(testTdi, 7, 15)).get(); - correctHover(testHoverPrefix, "@emptyTraitStruct\nstructure OtherStructure {\n foo: String\n bar: String\n" - + " baz: Integer\n}", multiFileHover); - - // Resolves mixin used within an inlined input/output in an operation shape - Hover operationInlineMixinHover = tds.hover(hoverParams(mainTdi, 143, 36)).get(); - correctHover(mainHoverPrefix, "@mixin\nstructure UserDetails {\n status: String\n}", operationInlineMixinHover); - - // Resolves mixin used on a structure - Hover structureMixinHover = tds.hover(hoverParams(mainTdi, 134, 45)).get(); - correctHover(mainHoverPrefix, "@mixin\nstructure UserDetails {\n status: String\n}", structureMixinHover); - - // Resolves shape with a name that matches operation input/output suffix but is not inlined - Hover falseOperationInlineHover = tds.hover(hoverParams(mainTdi, 176, 18)).get(); - correctHover(mainHoverPrefix, "structure FalseInlinedFooInput {\n a: String\n}", falseOperationInlineHover); - - // Resolves a shape including its dependencies in the preamble - Hover clutteredWithDependenciesHover = tds.hover(hoverParams(clutteredTdi, 26, 17)).get(); - correctHover(clutteredHoverWithDependenciesPrefix, "/// With doc comment\n@mixin\n" - + "structure StructureWithDependencies {\n" - + " extra: Extra\n example: OtherStructure\n}", clutteredWithDependenciesHover); - - // Resolves operation with inlined input/output, but doesn't include cluttered preamble - Hover clutteredInlineOpHover = tds.hover(hoverParams(clutteredTdi, 31, 17)).get(); - correctHover(clutteredHoverInlineOpPrefix, "operation ClutteredInlineOperation {\n" - + " input: ClutteredInlineOperationIn\n" - + " output: ClutteredInlineOperationOut\n}", clutteredInlineOpHover); - } - } - - @Test - public void completionsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(modelFilename).toString()); - - CompletionParams traitParams = completionParams(mainTdi, 87, 10); - List traitCompletionItems = tds.completion(traitParams).get().getLeft(); - - CompletionParams shapeParams = completionParams(mainTdi, 53, 16); - List shapeCompletionItems = tds.completion(shapeParams).get().getLeft(); - - CompletionParams applyStatementParams = completionParams(mainTdi, 85, 23); - List applyStatementCompletionItems = tds.completion(applyStatementParams).get().getLeft(); - - CompletionParams whiteSpaceParams = completionParams(mainTdi, 0,0); - List whiteSpaceCompletionItems = tds.completion(whiteSpaceParams).get().getLeft(); - - assertEquals(SetUtils.of("MyOperation", "MyOperationInput", "MyOperationOutput"), - completionLabels(shapeCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(applyStatementCompletionItems)); - - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completionLabels(traitCompletionItems)); - - assertTrue(whiteSpaceCompletionItems.isEmpty()); - } - } - - @Test - public void runSelectorV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isRight()); - assertFalse(result.getRight().isEmpty()); - - Optional location = result.getRight().stream() - .filter(location1 -> location1.getRange().getStart().getLine() == 20) - .findFirst(); - - assertTrue(location.isPresent()); - correctLocation(location.get(), modelFilename, 20, 0, 21, 14); - } - } - - @Test - public void runSelectorV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - String modelFilename = "main.smithy"; - Path modelMain = baseDir.resolve(modelFilename); - List modelFiles = ListUtils.of(modelMain); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isRight()); - assertFalse(result.getRight().isEmpty()); - - Optional location = result.getRight().stream() - .filter(location1 -> location1.getRange().getStart().getLine() == 22) - .findFirst(); - - assertTrue(location.isPresent()); - correctLocation(location.get(), modelFilename, 22, 0, 23, 14); - } - } - - @Test - public void runSelectorAgainstModelWithErrorsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path broken = baseDir.resolve("broken.smithy"); - List modelFiles = ListUtils.of(broken); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isLeft()); - assertTrue(result.getLeft().getMessage().contains("Result contained ERROR severity validation events:")); - } - } - - @Test - public void runSelectorAgainstModelWithErrorsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path broken = baseDir.resolve("broken.smithy"); - List modelFiles = ListUtils.of(broken); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - Either> result = tds.runSelector("[id|namespace=com.foo]"); - - assertTrue(result.isLeft()); - assertTrue(result.getLeft().getMessage().contains("Result contained ERROR severity validation events:")); - } - } - - @Test - public void ensureVersionDiagnostic() throws Exception { - String fileName1 = "no-version.smithy"; - String fileName2 = "old-version.smithy"; - String fileName3 = "good-version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(fileName1, "namespace test"), - MapUtils.entry(fileName2, "$version: \"1\"\nnamespace test2"), - MapUtils.entry(fileName3, "$version: \"2\"\nnamespace test3") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); - tds.setClient(client); - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName1), files.get(fileName1)))); - assertEquals(1, fileDiagnostics(hs.file(fileName1), client.diagnostics).size()); - - client.clear(); - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName2), files.get(fileName2)))); - assertEquals(1, fileDiagnostics(hs.file(fileName2), client.diagnostics).size()); - - client.clear(); - - tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName3), files.get(fileName3)))); - assertEquals(0, fileDiagnostics(hs.file(fileName3), client.diagnostics).size()); - } - - } - - @Test - public void documentSymbols() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/document-symbols").toURI()); - - String currentFile = "current.smithy"; - String anotherFile = "another.smithy"; - - List files = ListUtils.of(baseDir.resolve(currentFile),baseDir.resolve(anotherFile)); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - tds.createProject(hs.getConfig(), hs.getRoot()); - - TextDocumentIdentifier currentDocumentIdent = new TextDocumentIdentifier(uri(hs.file(currentFile))); - - List> symbols = - tds.documentSymbol(new DocumentSymbolParams(currentDocumentIdent)).get(); - - assertEquals(2, symbols.size()); - - assertEquals("city", symbols.get(0).getRight().getName()); - assertEquals(SymbolKind.Field, symbols.get(0).getRight().getKind()); - - assertEquals("Weather", symbols.get(1).getRight().getName()); - assertEquals(SymbolKind.Struct, symbols.get(1).getRight().getKind()); - } - - } - - private static class StubClient implements LanguageClient { - public List diagnostics = new ArrayList<>(); - public List shown = new ArrayList<>(); - public List logged = new ArrayList<>(); - - public StubClient() { - } - - public void clear() { - this.diagnostics.clear(); - this.shown.clear(); - this.logged.clear(); - } - - @Override - public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { - this.diagnostics.add(diagnostics); - } - - @Override - public void telemetryEvent(Object object) { - // TODO Auto-generated method stub - - } - - @Override - public void logMessage(MessageParams message) { - this.logged.add(message); - } - - @Override - public void showMessage(MessageParams messageParams) { - this.shown.add(messageParams); - } - - @Override - public CompletableFuture showMessageRequest(ShowMessageRequestParams requestParams) { - // TODO Auto-generated method stub - return null; - } - } - - private Set getUris(Collection diagnostics) { - return diagnostics.stream().map(PublishDiagnosticsParams::getUri).collect(Collectors.toSet()); - } - - private List filePublishedDiagnostics(File f, List diags) { - return diags.stream().filter(pds -> pds.getUri().equals(uri(f))).collect(Collectors.toList()); - } - - private List fileDiagnostics(File f, List diags) { - return diags.stream().filter(pds -> pds.getUri().equals(uri(f))).flatMap(pd -> pd.getDiagnostics().stream()) - .collect(Collectors.toList()); - } - - private List getSeverities(File f, List diags) { - return filePublishedDiagnostics(f, diags).stream() - .flatMap(pds -> pds.getDiagnostics().stream().map(Diagnostic::getSeverity)).collect(Collectors.toList()); - } - - private TextDocumentItem textDocumentItem(File f, String text) { - return new TextDocumentItem(uri(f), "smithy", 1, text); - } - - private String uri(File f) { - return f.toURI().toString(); - } - - private DefinitionParams definitionParams(TextDocumentIdentifier tdi, int line, int character) { - return new DefinitionParams(tdi, new Position(line, character)); - } - - private HoverParams hoverParams(TextDocumentIdentifier tdi, int line, int character) { - return new HoverParams(tdi, new Position(line, character)); - } - - private void correctHover(String expectedPrefix, String expectedBody, Hover hover) { - MarkupContent content = hover.getContents().getRight(); - assertEquals("markdown", content.getKind()); - assertEquals(HOVER_DEFAULT_PREFIX + expectedPrefix + expectedBody + HOVER_DEFAULT_SUFFIX, content.getValue()); - } - - private void correctLocation(Location location, String uri, int startLine, int startCol, int endLine, int endCol) { - assertEquals(startLine, location.getRange().getStart().getLine()); - assertEquals(startCol, location.getRange().getStart().getCharacter()); - assertEquals(endLine, location.getRange().getEnd().getLine()); - assertEquals(endCol, location.getRange().getEnd().getCharacter()); - assertTrue(location.getUri().endsWith(uri)); - } - - private CompletionParams completionParams(TextDocumentIdentifier tdi, int line, int character) { - return new CompletionParams(tdi, new Position(line, character)); - } - - private Set completionLabels(List completionItems) { - return completionItems.stream().map(item -> item.getLabel()).collect(Collectors.toSet()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java index f8cca93c..5349b874 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyVersionRefactoringTest.java @@ -15,23 +15,30 @@ package software.amazon.smithy.lsp; -import java.util.Collections; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static software.amazon.smithy.lsp.SmithyLanguageServerTest.initFromWorkspace; + import java.util.List; -import java.util.Map; +import java.util.stream.Collectors; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionContext; import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.CodeActionTriggerKind; +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.junit.Test; -import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; -import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; -import software.amazon.smithy.lsp.ext.Harness; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.MapUtils; - -import static org.junit.Assert.assertEquals; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * This test suite test the generation of the correct {@link CodeAction} given {@link CodeActionParams} @@ -39,74 +46,137 @@ * some content in it. */ public class SmithyVersionRefactoringTest { + @Test + public void noVersionDiagnostic() throws Exception { + String model = "namespace com.foo\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + StubClient client = new StubClient(); + SmithyLanguageServer server = initFromWorkspace(workspace, client); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .collect(Collectors.toList()); + assertThat(codes, hasItem(SmithyDiagnostics.DEFINE_VERSION)); + + List defineVersionDiagnostics = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .filter(d -> d.getCode().getLeft().equals(SmithyDiagnostics.DEFINE_VERSION)) + .collect(Collectors.toList()); + assertThat(defineVersionDiagnostics, hasSize(1)); + + Diagnostic diagnostic = defineVersionDiagnostics.get(0); + assertThat(diagnostic.getRange().getStart(), equalTo(new Position(0, 0))); + assertThat(diagnostic.getRange().getEnd(), equalTo(new Position(0, 17))); + CodeActionContext context = new CodeActionContext(diagnostics); + context.setTriggerKind(CodeActionTriggerKind.Automatic); + CodeActionParams codeActionParams = new CodeActionParams( + new TextDocumentIdentifier(uri), + LspAdapter.point(0, 3), + context); + List> response = server.codeAction(codeActionParams).get(); + assertThat(response, hasSize(1)); + CodeAction action = response.get(0).getRight(); + assertThat(action.getEdit().getChanges(), hasKey(uri)); + List edits = action.getEdit().getChanges().get(uri); + assertThat(edits, hasSize(1)); + TextEdit edit = edits.get(0); + Document document = server.getProject().getDocument(uri); + document.applyEdit(edit.getRange(), edit.getNewText()); + assertThat(document.copyText(), equalTo("$version: \"1\"\n" + + "\n" + + "namespace com.foo\n" + + "string Foo\n")); + } @Test - public void noVersionCodeAction() throws Exception { - String filename = "no-version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(filename, "namespace test") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - Range range0 = new Range(new Position(0, 0), new Position(0, 0)); - - CodeActionParams params = new CodeActionParams( - new TextDocumentIdentifier(hs.file(filename).toURI().toString()), - range0, - new CodeActionContext(VersionDiagnostics.createVersionDiagnostics(hs.file(filename), Collections.emptyMap())) - ); - List result = SmithyCodeActions.versionCodeActions(params); - assertEquals(1, result.size()); - assertEquals("Define the Smithy version", result.get(0).getTitle()); - // range is (0,0) - assertEquals(range0, result.get(0).getEdit().getChanges().values().stream().findFirst().get().get(0).getRange()); - } + public void oldVersionDiagnostic() throws Exception { + String model = "$version: \"1\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + StubClient client = new StubClient(); + SmithyLanguageServer server = initFromWorkspace(workspace, client); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .collect(Collectors.toList()); + assertThat(codes, hasItem(SmithyDiagnostics.UPDATE_VERSION)); + + List updateVersionDiagnostics = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .filter(d -> d.getCode().getLeft().equals(SmithyDiagnostics.UPDATE_VERSION)) + .collect(Collectors.toList()); + assertThat(updateVersionDiagnostics, hasSize(1)); + + Diagnostic diagnostic = updateVersionDiagnostics.get(0); + assertThat(diagnostic.getRange().getStart(), equalTo(new Position(0, 0))); + assertThat(diagnostic.getRange().getEnd(), equalTo(new Position(0, 13))); + CodeActionContext context = new CodeActionContext(diagnostics); + context.setTriggerKind(CodeActionTriggerKind.Automatic); + CodeActionParams codeActionParams = new CodeActionParams( + new TextDocumentIdentifier(uri), + LspAdapter.point(0, 3), + context); + List> response = server.codeAction(codeActionParams).get(); + assertThat(response, hasSize(1)); + CodeAction action = response.get(0).getRight(); + assertThat(action.getEdit().getChanges(), hasKey(uri)); + List edits = action.getEdit().getChanges().get(uri); + assertThat(edits, hasSize(1)); + TextEdit edit = edits.get(0); + Document document = server.getProject().getDocument(uri); + document.applyEdit(edit.getRange(), edit.getNewText()); + assertThat(document.copyText(), equalTo("$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n")); } @Test - public void outdatedVersionCodeAction() throws Exception { - String filename = "old-version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(filename, "$version: \"1\"\nnamespace test2") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - Range range0 = new Range(new Position(0, 0), new Position(0, 0)); - - Range firstLineRange = new Range(new Position(0, 0), new Position(0, 13)); - CodeActionParams params = new CodeActionParams( - new TextDocumentIdentifier(hs.file(filename).toURI().toString()), - range0, - new CodeActionContext(VersionDiagnostics.createVersionDiagnostics(hs.file(filename), Collections.emptyMap())) - ); - List result = SmithyCodeActions.versionCodeActions(params); - assertEquals(1, result.size()); - assertEquals("Update the Smithy version to 2", result.get(0).getTitle()); - // range is where the diagnostic is found - assertEquals(firstLineRange, result.get(0).getEdit().getChanges().values().stream().findFirst().get().get(0).getRange()); - } + public void mostRecentVersion() { + String model = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .filter(c -> c.equals(SmithyDiagnostics.DEFINE_VERSION) + || c.equals(SmithyDiagnostics.UPDATE_VERSION)) + .collect(Collectors.toList()); + assertThat(codes, hasSize(0)); } @Test - public void correctVersionCodeAction() throws Exception { - String filename = "version.smithy"; - - Map files = MapUtils.ofEntries( - MapUtils.entry(filename, "$version: \"2\"\nnamespace test2") - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - Range range0 = new Range(new Position(0, 0), new Position(0, 0)); - - CodeActionParams params = new CodeActionParams( - new TextDocumentIdentifier(hs.file(filename).toURI().toString()), - range0, - new CodeActionContext(VersionDiagnostics.createVersionDiagnostics(hs.file(filename), Collections.emptyMap())) - ); - List result = SmithyCodeActions.versionCodeActions(params); - assertEquals(0, result.size()); - } + public void noShapes() { + String model = "namespace com.foo\n"; + TestWorkspace workspace = TestWorkspace.singleModel(model); + SmithyLanguageServer server = initFromWorkspace(workspace); + String uri = workspace.getUri("main.smithy"); + + server.didOpen(new RequestBuilders.DidOpen().uri(uri).text(model).build()); + + List diagnostics = server.getFileDiagnostics(uri); + List codes = diagnostics.stream() + .filter(d -> d.getCode().isLeft()) + .map(d -> d.getCode().getLeft()) + .collect(Collectors.toList()); + assertThat(codes, containsInAnyOrder(SmithyDiagnostics.DEFINE_VERSION)); } -} \ No newline at end of file +} diff --git a/src/test/java/software/amazon/smithy/lsp/StubClient.java b/src/test/java/software/amazon/smithy/lsp/StubClient.java new file mode 100644 index 00000000..f8b1d130 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/StubClient.java @@ -0,0 +1,66 @@ +package software.amazon.smithy.lsp; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.RegistrationParams; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.services.LanguageClient; + +public final class StubClient implements LanguageClient { + public final List diagnostics = new ArrayList<>(); + public List shown = new ArrayList<>(); + public List logged = new ArrayList<>(); + + public StubClient() { + } + + public void clear() { + this.diagnostics.clear(); + this.shown.clear(); + this.logged.clear(); + } + + @Override + public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { + synchronized (this.diagnostics) { + this.diagnostics.add(diagnostics); + } + } + + @Override + public void telemetryEvent(Object object) { + // TODO Auto-generated method stub + + } + + @Override + public void logMessage(MessageParams message) { + this.logged.add(message); + } + + @Override + public void showMessage(MessageParams messageParams) { + this.shown.add(messageParams); + } + + @Override + public CompletableFuture showMessageRequest(ShowMessageRequestParams requestParams) { + // TODO Auto-generated method stub + return null; + } + + @Override + public CompletableFuture registerCapability(RegistrationParams params) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture unregisterCapability(UnregistrationParams params) { + return CompletableFuture.completedFuture(null); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java new file mode 100644 index 00000000..3dc37675 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/TestWorkspace.java @@ -0,0 +1,256 @@ +/* + * 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.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeMapper; + +/** + * Sets up a temporary directory containing a Smithy project + */ +public final class TestWorkspace { + private static final NodeMapper MAPPER = new NodeMapper(); + private final Path root; + private SmithyBuildConfig config; + + private TestWorkspace(Path root, SmithyBuildConfig config) { + this.root = root; + this.config = config; + } + + /** + * @return The path of the workspace root + */ + public Path getRoot() { + return root; + } + + public SmithyBuildConfig getConfig() { + return config; + } + + /** + * @param filename The name of the file to get the URI for, relative to the root + * @return The LSP URI for the given filename + */ + public String getUri(String filename) { + return this.root.resolve(filename).toUri().toString(); + } + + /** + * @param relativePath The path where the model will be added, relative to the root + * @param model The text of the model to add + */ + public void addModel(String relativePath, String model) { + try { + Files.write(root.resolve(relativePath), model.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void moveModel(String currentPath, String toPath) { + try { + Files.move(root.resolve(currentPath), root.resolve(toPath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void deleteModel(String relativePath) { + try { + Files.delete(root.resolve(relativePath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void updateConfig(SmithyBuildConfig newConfig) { + writeConfig(root, newConfig); + this.config = newConfig; + } + + /** + * @param model String of the model to create in the workspace + * @return A workspace with a single model, "main.smithy", with the given contents, and + * a smithy-build.json with sources = ["main.smithy"] + */ + public static TestWorkspace singleModel(String model) { + return builder() + .withSourceFile("main.smithy", model) + .build(); + } + + /** + * @return A workspace with no models, and a smithy-build.json with sources = ["model/"] + */ + public static TestWorkspace emptyWithDirSource() { + return builder() + .withSourceDir(new Dir().path("model")) + .build(); + } + + /** + * @param models Strings of the models to create in the workspace + * @return A workspace with n models, each "model-n.smithy", with their given contents, + * and a smithy-build.json with sources = ["model-0.smithy", ..., "model-n.smithy"] + */ + public static TestWorkspace multipleModels(String... models) { + Builder builder = builder(); + for (int i = 0; i < models.length; i++) { + builder.withSourceFile("model-" + i + ".smithy", models[i]); + } + return builder.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static Dir dir() { + return new Dir(); + } + + public static class Dir { + String path; + Map sourceModels = new HashMap<>(); + Map importModels = new HashMap<>(); + List sourceDirs = new ArrayList<>(); + List importDirs = new ArrayList<>(); + + public Dir path(String path) { + this.path = path; + return this; + } + + public Dir withSourceFile(String filename, String model) { + this.sourceModels.put(filename, model); + return this; + } + + public Dir withImportFile(String filename, String model) { + this.importModels.put(filename, model); + return this; + } + + public Dir withSourceDir(Dir dir) { + this.sourceDirs.add(dir); + return this; + } + + public Dir withImportDir(Dir dir) { + this.importDirs.add(dir); + return this; + } + + protected void writeModels(Path toDir) { + try { + if (!Files.exists(toDir)) { + Files.createDirectory(toDir); + } + writeModels(toDir, sourceModels); + writeModels(toDir, importModels); + sourceDirs.forEach(d -> d.writeModels(toDir.resolve(d.path))); + importDirs.forEach(d -> d.writeModels(toDir.resolve(d.path))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void writeModels(Path toDir, Map models) throws Exception { + for (Map.Entry entry : models.entrySet()) { + Files.write(toDir.resolve(entry.getKey()), entry.getValue().getBytes(StandardCharsets.UTF_8)); + } + } + } + + public static final class Builder extends Dir { + private SmithyBuildConfig config = null; + private Builder() {} + + @Override + public Builder withSourceFile(String filename, String model) { + super.withSourceFile(filename, model); + return this; + } + + @Override + public Builder withImportFile(String filename, String model) { + super.withImportFile(filename, model); + return this; + } + + @Override + public Builder withSourceDir(Dir dir) { + super.withSourceDir(dir); + return this; + } + + @Override + public Builder withImportDir(Dir dir) { + super.withImportDir(dir); + return this; + } + + public Builder withConfig(SmithyBuildConfig config) { + this.config = config; + return this; + } + + public TestWorkspace build() { + try { + if (path == null) { + path = "test"; + } + Path root = Files.createTempDirectory(path); + root.toFile().deleteOnExit(); + + List sources = new ArrayList<>(); + sources.addAll(sourceModels.keySet()); + sources.addAll(sourceDirs.stream().map(d -> d.path).collect(Collectors.toList())); + + List imports = new ArrayList<>(); + imports.addAll(importModels.keySet()); + imports.addAll(importDirs.stream().map(d -> d.path).collect(Collectors.toList())); + + if (config == null) { + config = SmithyBuildConfig.builder() + .version("1") + .sources(sources) + .imports(imports) + .build(); + } + writeConfig(root, config); + + writeModels(root); + + return new TestWorkspace(root, config); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + private static void writeConfig(Path root, SmithyBuildConfig config) { + String configString = Node.prettyPrintJson(MAPPER.serialize(config)); + Path configPath = root.resolve("smithy-build.json"); + try { + Files.write(configPath, configString.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java new file mode 100644 index 00000000..8c643d5d --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/UtilMatchers.java @@ -0,0 +1,36 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp; + +import java.util.Optional; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +/** + * Utility hamcrest matchers. + */ +public final class UtilMatchers { + private UtilMatchers() {} + + public static Matcher> anOptionalOf(Matcher matcher) { + return new CustomTypeSafeMatcher>("An optional that is present with value " + matcher.toString()) { + @Override + protected boolean matchesSafely(Optional item) { + return item.isPresent() && matcher.matches(item.get()); + } + + @Override + public void describeMismatchSafely(Optional item, Description description) { + if (!item.isPresent()) { + description.appendText("was an empty optional"); + } else { + matcher.describeMismatch(item.get(), description); + } + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java new file mode 100644 index 00000000..cdd1c44e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -0,0 +1,318 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static software.amazon.smithy.lsp.document.DocumentTest.safeIndex; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; +import static software.amazon.smithy.lsp.document.DocumentTest.string; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.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\n" + + "def\n" + + "ghi\n" + + "\n" + + "\n"; + 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")); + DocumentParser incompleteNamespace = DocumentParser.of(safeString("abc\nnamespac")); + DocumentParser incompleteNamespaceValue = DocumentParser.of(safeString("namespace ")); + DocumentParser likeNamespace = DocumentParser.of(safeString("anamespace com.foo\n")); + DocumentParser otherLikeNamespace = DocumentParser.of(safeString("namespacea com.foo")); + DocumentParser namespaceAtEnd = DocumentParser.of(safeString("\n\nnamespace com.foo")); + DocumentParser brokenNamespace = DocumentParser.of(safeString("\nname space com.foo\n")); + DocumentParser commentedNamespace = DocumentParser.of(safeString("abc\n//namespace com.foo\n")); + DocumentParser wsPrefixedNamespace = DocumentParser.of(safeString("abc\n namespace com.foo\n")); + DocumentParser notNamespace = DocumentParser.of(safeString("namespace !foo")); + DocumentParser trailingComment = DocumentParser.of(safeString("namespace com.foo//foo\n")); + + assertThat(noNamespace.documentNamespace(), nullValue()); + assertThat(incompleteNamespace.documentNamespace(), nullValue()); + assertThat(incompleteNamespaceValue.documentNamespace(), nullValue()); + assertThat(likeNamespace.documentNamespace(), nullValue()); + assertThat(otherLikeNamespace.documentNamespace(), nullValue()); + assertThat(namespaceAtEnd.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(namespaceAtEnd.documentNamespace().statementRange(), equalTo(LspAdapter.of(2, 0, 2, 17))); + assertThat(brokenNamespace.documentNamespace(), nullValue()); + assertThat(commentedNamespace.documentNamespace(), nullValue()); + assertThat(wsPrefixedNamespace.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(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))); + } + + @Test + public void getsDocumentImports() { + DocumentParser noImports = DocumentParser.of(safeString("abc\ndef\n")); + DocumentParser incompleteImport = DocumentParser.of(safeString("abc\nus")); + DocumentParser incompleteImportValue = DocumentParser.of(safeString("use ")); + DocumentParser oneImport = DocumentParser.of(safeString("use com.foo#bar")); + DocumentParser leadingWsImport = DocumentParser.of(safeString(" use com.foo#bar")); + DocumentParser trailingCommentImport = DocumentParser.of(safeString("use com.foo#bar//foo")); + DocumentParser commentedImport = DocumentParser.of(safeString("//use com.foo#bar")); + DocumentParser multiImports = DocumentParser.of(safeString("use com.foo#bar\nuse com.foo#baz")); + DocumentParser notImport = DocumentParser.of(safeString("usea com.foo#bar")); + + assertThat(noImports.documentImports(), nullValue()); + assertThat(incompleteImport.documentImports(), nullValue()); + assertThat(incompleteImportValue.documentImports(), nullValue()); + assertThat(oneImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); + assertThat(leadingWsImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); + assertThat(trailingCommentImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); + assertThat(commentedImport.documentImports(), nullValue()); + assertThat(multiImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz")); + assertThat(notImport.documentImports(), nullValue()); + + // Some of these aren't shape ids, but its ok + DocumentParser brokenImport = DocumentParser.of(safeString("use com.foo")); + DocumentParser commentSeparatedImports = DocumentParser.of(safeString("use com.foo#bar //foo\nuse com.foo#baz\n//abc\nuse com.foo#foo")); + DocumentParser oneBrokenImport = DocumentParser.of(safeString("use com.foo\nuse com.foo#bar")); + DocumentParser innerBrokenImport = DocumentParser.of(safeString("use com.foo#bar\nuse com.foo\nuse com.foo#baz")); + DocumentParser innerNotImport = DocumentParser.of(safeString("use com.foo#bar\nstring Foo\nuse com.foo#baz")); + assertThat(brokenImport.documentImports().imports(), containsInAnyOrder("com.foo")); + assertThat(commentSeparatedImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz", "com.foo#foo")); + assertThat(oneBrokenImport.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo")); + assertThat(innerBrokenImport.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo", "com.foo#baz")); + assertThat(innerNotImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); + } + + @Test + public void getsDocumentVersion() { + DocumentParser noVersion = DocumentParser.of(safeString("abc\ndef")); + DocumentParser notVersion = DocumentParser.of(safeString("$versionNot: \"2\"")); + DocumentParser noDollar = DocumentParser.of(safeString("version: \"2\"")); + DocumentParser noColon = DocumentParser.of(safeString("$version \"2\"")); + DocumentParser commented = DocumentParser.of(safeString("//$version: \"2\"")); + DocumentParser leadingWs = DocumentParser.of(safeString(" $version: \"2\"")); + DocumentParser leadingLines = DocumentParser.of(safeString("\n\n//abc\n$version: \"2\"")); + DocumentParser notStringNode = DocumentParser.of(safeString("$version: 2")); + DocumentParser trailingComment = DocumentParser.of(safeString("$version: \"2\"//abc")); + DocumentParser trailingLine = DocumentParser.of(safeString("$version: \"2\"\n")); + DocumentParser invalidNode = DocumentParser.of(safeString("$version: \"2")); + DocumentParser notFirst = DocumentParser.of(safeString("$foo: \"bar\"\n// abc\n$version: \"2\"")); + DocumentParser notSecond = DocumentParser.of(safeString("$foo: \"bar\"\n$bar: 1\n// abc\n$baz: 2\n $version: \"2\"")); + DocumentParser notFirstNoVersion = DocumentParser.of(safeString("$foo: \"bar\"\nfoo\n")); + + assertThat(noVersion.documentVersion(), nullValue()); + assertThat(notVersion.documentVersion(), nullValue()); + assertThat(noDollar.documentVersion(), nullValue()); + assertThat(noColon.documentVersion(), nullValue()); + assertThat(commented.documentVersion(), nullValue()); + assertThat(leadingWs.documentVersion().version(), equalTo("2")); + assertThat(leadingLines.documentVersion().version(), equalTo("2")); + assertThat(notStringNode.documentVersion(), nullValue()); + assertThat(trailingComment.documentVersion().version(), equalTo("2")); + assertThat(trailingLine.documentVersion().version(), equalTo("2")); + assertThat(invalidNode.documentVersion(), nullValue()); + assertThat(notFirst.documentVersion().version(), equalTo("2")); + assertThat(notSecond.documentVersion().version(), equalTo("2")); + assertThat(notFirstNoVersion.documentVersion(), nullValue()); + + Range leadingWsRange = leadingWs.documentVersion().range(); + Range trailingCommentRange = trailingComment.documentVersion().range(); + Range trailingLineRange = trailingLine.documentVersion().range(); + Range notFirstRange = notFirst.documentVersion().range(); + Range notSecondRange = notSecond.documentVersion().range(); + assertThat(leadingWs.getDocument().copyRange(leadingWsRange), equalTo("$version: \"2\"")); + assertThat(trailingComment.getDocument().copyRange(trailingCommentRange), equalTo("$version: \"2\"")); + assertThat(trailingLine.getDocument().copyRange(trailingLineRange), equalTo("$version: \"2\"")); + assertThat(notFirst.getDocument().copyRange(notFirstRange), equalTo("$version: \"2\"")); + assertThat(notSecond.getDocument().copyRange(notSecondRange), equalTo("$version: \"2\"")); + } + + @Test + public void getsDocumentShapes() { + String text = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "structure Bar {\n" + + " bar: Foo\n" + + "}\n" + + "enum Baz {\n" + + " ONE\n" + + " TWO\n" + + "}\n" + + "intEnum Biz {\n" + + " ONE = 1\n" + + "}\n" + + "@mixin\n" + + "structure Boz {\n" + + " elided: String\n" + + "}\n" + + "structure Mixed with [Boz] {\n" + + " $elided\n" + + "}\n" + + "operation Get {\n" + + " input := {\n" + + " a: Integer\n" + + " }\n" + + "}\n"; + Set shapes = Model.assembler() + .addUnparsedModel("main.smithy", text) + .assemble() + .unwrap() + .shapes() + .filter(shape -> shape.getId().getNamespace().equals("com.foo")) + .collect(Collectors.toSet()); + + DocumentParser parser = DocumentParser.of(safeString(text)); + Map documentShapes = parser.documentShapes(shapes); + + DocumentShape fooDef = documentShapes.get(new Position(2, 7)); + DocumentShape barDef = documentShapes.get(new Position(3, 10)); + DocumentShape barMemberDef = documentShapes.get(new Position(4, 4)); + DocumentShape targetFoo = documentShapes.get(new Position(4, 9)); + DocumentShape bazDef = documentShapes.get(new Position(6, 5)); + DocumentShape bazOneDef = documentShapes.get(new Position(7, 4)); + DocumentShape bazTwoDef = documentShapes.get(new Position(8, 4)); + DocumentShape bizDef = documentShapes.get(new Position(10, 8)); + DocumentShape bizOneDef = documentShapes.get(new Position(11, 4)); + DocumentShape bozDef = documentShapes.get(new Position(14, 10)); + DocumentShape elidedDef = documentShapes.get(new Position(15, 4)); + DocumentShape targetString = documentShapes.get(new Position(15, 12)); + DocumentShape mixedDef = documentShapes.get(new Position(17, 10)); + DocumentShape elided = documentShapes.get(new Position(18, 4)); + DocumentShape get = documentShapes.get(new Position(20, 10)); + DocumentShape getInput = documentShapes.get(new Position(21, 13)); + DocumentShape getInputA = documentShapes.get(new Position(22, 8)); + + assertThat(fooDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(fooDef.shapeName(), string("Foo")); + assertThat(barDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(barDef.shapeName(), string("Bar")); + assertThat(barMemberDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); + assertThat(barMemberDef.shapeName(), string("bar")); + assertThat(barMemberDef.targetReference(), equalTo(targetFoo)); + assertThat(targetFoo.kind(), equalTo(DocumentShape.Kind.Targeted)); + assertThat(targetFoo.shapeName(), string("Foo")); + assertThat(bazDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(bazDef.shapeName(), string("Baz")); + assertThat(bazOneDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); + assertThat(bazOneDef.shapeName(), string("ONE")); + assertThat(bazTwoDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); + assertThat(bazTwoDef.shapeName(), string("TWO")); + assertThat(bizDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(bizDef.shapeName(), string("Biz")); + assertThat(bizOneDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); + assertThat(bizOneDef.shapeName(), string("ONE")); + assertThat(bozDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(bozDef.shapeName(), string("Boz")); + assertThat(elidedDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); + assertThat(elidedDef.shapeName(), string("elided")); + assertThat(elidedDef.targetReference(), equalTo(targetString)); + assertThat(targetString.kind(), equalTo(DocumentShape.Kind.Targeted)); + assertThat(targetString.shapeName(), string("String")); + assertThat(mixedDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(mixedDef.shapeName(), string("Mixed")); + assertThat(elided.kind(), equalTo(DocumentShape.Kind.Elided)); + assertThat(elided.shapeName(), string("elided")); + assertThat(parser.getDocument().borrowRange(elided.range()), string("$elided")); + assertThat(get.kind(), equalTo(DocumentShape.Kind.DefinedShape)); + assertThat(get.shapeName(), string("Get")); + assertThat(getInput.kind(), equalTo(DocumentShape.Kind.Inline)); + assertThat(getInputA.kind(), equalTo(DocumentShape.Kind.DefinedMember)); + assertThat(getInputA.shapeName(), string("a")); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java new file mode 100644 index 00000000..b2da248e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentTest.java @@ -0,0 +1,496 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.document; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.hamcrest.CustomTypeSafeMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.RangeBuilder; + +public class DocumentTest { + @Test + public void appliesTrailingReplacementEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(1) + .startCharacter(2) + .endLine(1) + .endCharacter(3) + .build(); + String editText = "g"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("abc\n" + + "deg"))); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); + } + + @Test + public void appliesAppendingEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(1) + .startCharacter(3) + .endLine(1) + .endCharacter(3) + .build(); + String editText = "g"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("abc\n" + + "defg"))); + assertThat(document.indexOfLine(0), equalTo(safeIndex(0, 0))); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); + } + + @Test + public void appliesLeadingReplacementEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(1) + .build(); + String editText = "z"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("zbc\n" + + "def"))); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); + } + + @Test + public void appliesPrependingEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(0) + .build(); + String editText = "z"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("zabc\n" + + "def"))); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(5, 1))); + } + + @Test + public void appliesInnerReplacementEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(0) + .startCharacter(1) + .endLine(1) + .endCharacter(1) + .build(); + String editText = safeString("zy\n" + + "x"); + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("azy\n" + + "xef"))); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); + } + + @Test + public void appliesPrependingAndReplacingEdit() { + String s = "abc"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(1) + .build(); + String editText = "zy"; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo("zybc")); + assertThat(document.indexOfLine(0), equalTo(0)); + } + + @Test + public void appliesInsertionEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(0) + .startCharacter(2) + .endLine(0) + .endCharacter(2) + .build(); + String editText = safeString("zx\n" + + "y"); + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("abzx\n" + + "yc\n" + + "def"))); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(5, 1))); + assertThat(document.indexOfLine(2), equalTo(safeIndex(8, 2))); + } + + @Test + public void appliesDeletionEdit() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Range editRange = new RangeBuilder() + .startLine(0) + .startCharacter(0) + .endLine(0) + .endCharacter(1) + .build(); + String editText = ""; + + document.applyEdit(editRange, editText); + + assertThat(document.copyText(), equalTo(safeString("bc\n" + + "def"))); + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(3, 1))); + } + + @Test + public void getsIndexOfLine() { + String s = "abc\n" + + "def\n" + + "hij\n"; + Document document = makeDocument(s); + + assertThat(document.indexOfLine(0), equalTo(0)); + assertThat(document.indexOfLine(-1), equalTo(-1)); + assertThat(document.indexOfLine(1), equalTo(safeIndex(4, 1))); + assertThat(document.indexOfLine(2), equalTo(safeIndex(8, 2))); + assertThat(document.indexOfLine(3), equalTo(safeIndex(12, 3))); + assertThat(document.indexOfLine(4), equalTo(-1)); + } + + @Test + public void getsIndexOfPosition() { + Document document = makeDocument("abc\ndef"); + + assertThat(makeDocument("").indexOfPosition(new Position(0, 0)), is(-1)); + assertThat(makeDocument("").indexOfPosition(new Position(-1, 0)), is(-1)); + assertThat(document.indexOfPosition(new Position(0, 0)), is(0)); + assertThat(document.indexOfPosition(new Position(0, 3)), is(3)); + assertThat(document.indexOfPosition(new Position(1, 0)), is(safeIndex(4, 1))); + assertThat(document.indexOfPosition(new Position(1, 2)), is(safeIndex(6, 1))); + assertThat(document.indexOfPosition(new Position(1, 3)), is(-1)); + assertThat(document.indexOfPosition(new Position(0, 6)), is(-1)); + assertThat(document.indexOfPosition(new Position(2, 0)), is(-1)); + } + + @Test + public void getsPositionAtIndex() { + Document document = makeDocument("abc\ndef\nhij\n"); + + assertThat(makeDocument("").positionAtIndex(0), nullValue()); + assertThat(makeDocument("").positionAtIndex(-1), nullValue()); + assertThat(document.positionAtIndex(0), equalTo(new Position(0, 0))); + assertThat(document.positionAtIndex(3), equalTo(new Position(0, 3))); + assertThat(document.positionAtIndex(safeIndex(4, 1)), equalTo(new Position(1, 0))); + assertThat(document.positionAtIndex(safeIndex(11, 2)), equalTo(new Position(2, 3))); + assertThat(document.positionAtIndex(safeIndex(12, 3)), nullValue()); + } + + @Test + public void getsEnd() { + String s = "abc\n" + + "def"; + Document document = makeDocument(s); + + Position end = document.end(); + + assertThat(end.getLine(), equalTo(1)); + assertThat(end.getCharacter(), equalTo(3)); + } + + @Test + public void borrowsToken() { + String s = "abc\n" + + "def"; + Document document = 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"); + + assertThat(makeDocument("").nextIndexOf("a", 0), is(-1)); + assertThat(document.nextIndexOf("a", 0), is(0)); + assertThat(document.nextIndexOf("a", 1), is(-1)); + assertThat(document.nextIndexOf("abc", 0), is(0)); + assertThat(document.nextIndexOf("abc", 1), is(-1)); // doesn't match if match goes out of boundary + assertThat(document.nextIndexOf(System.lineSeparator(), 3), is(3)); + assertThat(document.nextIndexOf("f", safeIndex(6, 1)), is(safeIndex(6, 1))); + assertThat(document.nextIndexOf("f", safeIndex(7, 1)), is(-1)); // oob + } + + @Test + public void getsLastIndexOf() { + Document document = makeDocument("abc\ndef"); + + assertThat(makeDocument("").lastIndexOf("a", 1), is(-1)); + assertThat(document.lastIndexOf("a", 0), is(0)); // start + assertThat(document.lastIndexOf("a", 1), is(0)); + assertThat(document.lastIndexOf("a", safeIndex(6, 1)), is(0)); + assertThat(document.lastIndexOf("f", safeIndex(6, 1)), is(safeIndex(6, 1))); + assertThat(document.lastIndexOf("f", safeIndex(7, 1)), is(safeIndex(6, 1))); // oob + assertThat(document.lastIndexOf(System.lineSeparator(), 3), is(3)); + assertThat(document.lastIndexOf("ab", 1), is(0)); + assertThat(document.lastIndexOf("ab", 0), is(0)); // can match even if match goes out of boundary + assertThat(document.lastIndexOf("ab", -1), is(-1)); + assertThat(document.lastIndexOf(" ", 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(""); + Document single = makeDocument("abc"); + Document twoLine = makeDocument("abc\ndef"); + Document leadingAndTrailingWs = makeDocument("\nabc\n"); + Document threeLine = makeDocument("abc\ndef\nhij\n"); + + assertThat(empty.lineOfIndex(1), is(-1)); // oob + assertThat(single.lineOfIndex(0), is(0)); // start + assertThat(single.lineOfIndex(2), is(0)); // end + assertThat(single.lineOfIndex(3), is(-1)); // oob + assertThat(twoLine.lineOfIndex(1), is(0)); // first line + assertThat(twoLine.lineOfIndex(safeIndex(4, 1)), is(1)); // second line start + assertThat(twoLine.lineOfIndex(3), is(0)); // new line + assertThat(twoLine.lineOfIndex(safeIndex(6, 1)), is(1)); // end + assertThat(twoLine.lineOfIndex(safeIndex(7, 1)), is(-1)); // oob + assertThat(leadingAndTrailingWs.lineOfIndex(0), is(0)); // new line + assertThat(leadingAndTrailingWs.lineOfIndex(safeIndex(1, 1)), is(1)); // start of line + assertThat(leadingAndTrailingWs.lineOfIndex(safeIndex(4, 1)), is(1)); // new line + assertThat(threeLine.lineOfIndex(safeIndex(12, 3)), is(-1)); + assertThat(threeLine.lineOfIndex(safeIndex(11, 2)), is(2)); + } + + @Test + public void borrowsDocumentShapeId() { + Document empty = 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)); + } + + // This is used to convert the character offset in a file that assumes a single character + // line break, and make that same offset safe with multi character line breaks. + // + // This is preferable to simply adjusting how we test Document because bugs in these low-level + // primitive methods will break a lot of stuff, so it's good to be exact. + public static int safeIndex(int standardOffset, int line) { + return standardOffset + (line * (System.lineSeparator().length() - 1)); + } + + // Makes a string literal with '\n' newline characters use the actual OS line separator. + // Don't use this if you didn't manually type out the '\n's. + // TODO: Remove this for textblocks + public static String safeString(String s) { + return s.replace("\n", System.lineSeparator()); + } + + private static Document makeDocument(String s) { + return Document.of(safeString(s)); + } + + public static Matcher string(String other) { + return new CustomTypeSafeMatcher(other) { + @Override + protected boolean matchesSafely(CharSequence item) { + return other.replace("\n", "\\n").replace("\r", "\\r").equals(item.toString().replace("\n", "\\n").replace("\r", "\\r")); + } + @Override + public void describeMismatchSafely(CharSequence item, Description description) { + String o = other.replace("\n", "\\n").replace("\r", "\\r"); + String it = item.toString().replace("\n", "\\n").replace("\r", "\\r"); + equalTo(o).describeMismatch(it, description); + } + }; + } + + public static Matcher documentShapeId(String other, DocumentId.Type type) { + return new CustomTypeSafeMatcher(other + " with type: " + type) { + @Override + protected boolean matchesSafely(DocumentId item) { + return other.equals(item.copyIdValue()) && item.type() == type; + } + }; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java b/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java deleted file mode 100644 index 4f0fadc9..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.CompletionItem; -import org.junit.Test; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.utils.IoUtils; -import software.amazon.smithy.utils.MapUtils; -import software.amazon.smithy.utils.SetUtils; - -public class CompletionsTest { - - @Test - public void resolveCurrentNamespace() throws Exception { - String barNamespace = "namespace bar"; - - String barContent = barNamespace + "\nstructure Hello{}\ninteger MyId2"; - String testContent = "namespace test\n@trait\nstructure Foo {}"; - Map files = MapUtils.ofEntries( - MapUtils.entry("bar/def1.smithy", barContent), - MapUtils.entry("test/def2.smithy", testContent) - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyProject proj = hs.getProject(); - - DocumentPreamble testPreamble = Document.detectPreamble(hs.readFile(hs.file("test/def2.smithy"))); - List itemsWithEdit = Completions.resolveImports(proj.getCompletions("Hello", false, Optional.empty()), - testPreamble); - assertEquals("\nuse bar#Hello\n", itemsWithEdit.get(0).getAdditionalTextEdits().get(0).getNewText()); - - DocumentPreamble barPreamble = Document.detectPreamble(hs.readFile(hs.file("bar/def1.smithy"))); - List itemsWithEdit2 = Completions.resolveImports(proj.getCompletions("Hello", false, Optional.empty()), - barPreamble); - assertNull(itemsWithEdit2.get(0).getAdditionalTextEdits()); - } - } - - @Test - public void multiFileV1() throws Exception { - Path baseDir = Paths.get(Completions.class.getResource("models/v1").toURI()); - Path traitDefModel = baseDir.resolve("trait-def.smithy"); - String traitDef = IoUtils.readUtf8File(traitDefModel); - - Map files = MapUtils.ofEntries( - MapUtils.entry("def1.smithy", "namespace test\nstring MyId"), - MapUtils.entry("bar/def2.smithy", "namespace test\nstructure Hello{}\ninteger MyId2"), - MapUtils.entry("foo/hello/def3.smithy", "namespace test\n@test()\n@trait\nstructure Foo {}"), - MapUtils.entry("foo/hello/def4.smithy", "namespace test\n@http()\noperation Bar{}"), - MapUtils.entry("trait-def.smithy", traitDef) - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyProject proj = hs.getProject(); - // Complete match - assertEquals(SetUtils.of("Foo"), completeNames(proj, "Foo", false)); - // Partial match - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "MyI", false)); - // Partial match (case insensitive) - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "myi", false)); - - // no matches - assertEquals(SetUtils.of(), completeNames(proj, "basdasdasdasd", false)); - // empty token - assertEquals(SetUtils.of(), completeNames(proj, "", false)); - // built-in - assertEquals(SetUtils.of("string", "String"), completeNames(proj, "Strin", false)); - assertEquals(SetUtils.of("integer", "Integer"), completeNames(proj, "intege", false)); - // Structure trait with zero required members and default. - assertEquals(SetUtils.of("trait", "trait()"), completeNames(proj, "trai", true, "test#Foo")); - // Completions for each supported node value type. - assertEquals(SetUtils.of("test(blob: \"\", bool: true|false, short: , integer: , long: , float: ," + - " double: , bigDecimal: , bigInteger: , string: \"\", timestamp: \"\", list: []," + - " set: [], map: {}, struct: {nested: {nestedMember: \"\"}}, union: {})", "test()"), - completeNames(proj, "test", true, "test#Foo")); - // Limit completions to traits that can be applied to target shape. - // Other http* traits cannot apply to an operation. - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completeNames(proj, "htt", true, "test#Bar")); - - } - } - - @Test - public void multiFileV2() throws Exception { - Path baseDir = Paths.get(Completions.class.getResource("models/v2").toURI()); - Path traitDefModel = baseDir.resolve("trait-def.smithy"); - String traitDef = IoUtils.readUtf8File(traitDefModel); - - Map files = MapUtils.ofEntries( - MapUtils.entry("def1.smithy", "namespace test\nstring MyId"), - MapUtils.entry("bar/def2.smithy", "namespace test\nstructure Hello{}\ninteger MyId2"), - MapUtils.entry("foo/hello/def3.smithy", "namespace test\n@test()\n@trait\nstructure Foo {}"), - MapUtils.entry("foo/hello/def4.smithy", "namespace test\n@http()\noperation Bar{}"), - MapUtils.entry("trait-def.smithy", traitDef) - ); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - SmithyProject proj = hs.getProject(); - // Complete match - assertEquals(SetUtils.of("Foo"), completeNames(proj, "Foo", false)); - // Partial match - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "MyI", false)); - // Partial match (case insensitive) - assertEquals(SetUtils.of("MyId", "MyId2"), completeNames(proj, "myi", false)); - - // no matches - assertEquals(SetUtils.of(), completeNames(proj, "basdasdasdasd", false)); - // empty token - assertEquals(SetUtils.of(), completeNames(proj, "", false)); - // built-in - assertEquals(SetUtils.of("string", "String"), completeNames(proj, "Strin", false)); - assertEquals(SetUtils.of("integer", "Integer"), completeNames(proj, "intege", false)); - // Structure trait with zero required members and default. - assertEquals(SetUtils.of("trait", "trait()"), completeNames(proj, "trai", true, "test#Foo")); - // Completions for each supported node value type. - assertEquals(SetUtils.of("test(blob: \"\", bool: true|false, short: , integer: , long: , float: ," + - " double: , bigDecimal: , bigInteger: , string: \"\", timestamp: \"\", list: []," + - " map: {}, struct: {nested: {nestedMember: \"\"}}, union: {})", "test()"), - completeNames(proj, "test", true, "test#Foo")); - // Limit completions to traits that can be applied to target shape. - // Other http* traits cannot apply to an operation. - assertEquals(SetUtils.of("http(method: \"\", uri: \"\")", "http()", "httpChecksumRequired"), - completeNames(proj, "htt", true, "test#Bar")); - - } - } - - Set completeNames(SmithyProject proj, String token, boolean isTrait) { - return completeNames(proj, token, isTrait, null); - } - - Set completeNames(SmithyProject proj, String token, boolean isTrait, String shapeId) { - Optional target = Optional.empty(); - if (shapeId != null) { - target = Optional.of(ShapeId.from(shapeId)); - } - return proj.getCompletions(token, isTrait, target).stream().map(ci -> ci.getCompletionItem().getLabel()) - .collect(Collectors.toSet()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java b/src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java deleted file mode 100644 index 40d2ca90..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/DocumentTest.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; -import org.eclipse.lsp4j.Position; -import org.junit.Test; -import software.amazon.smithy.utils.ListUtils; - -public class DocumentTest { - - @Test - public void detectPreambleV1() throws Exception { - Path baseDir = Paths.get(Document.class.getResource("models/v1").toURI()); - Path preambleModel = baseDir.resolve("preamble.smithy"); - List lines = Files.readAllLines(preambleModel); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertEquals(new Position(2, 0), preamble.getNamespaceRange().getStart()); - assertEquals(new Position(2, 21), preamble.getNamespaceRange().getEnd()); - assertEquals("1.0", preamble.getIdlVersion().get()); - assertEquals(Optional.empty(), preamble.getOperationInputSuffix()); - assertEquals(Optional.empty(), preamble.getOperationOutputSuffix()); - assertEquals(new Position(4, 0), preamble.getUseBlockRange().getStart()); - assertEquals(new Position(6, 19), preamble.getUseBlockRange().getEnd()); - assertTrue(preamble.hasImport("ns.foo#FooTrait")); - assertTrue(preamble.hasImport("ns.bar#BarTrait")); - assertFalse(preamble.hasImport("ns.baz#Baz")); - assertTrue(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleV2() throws Exception { - Path baseDir = Paths.get(Document.class.getResource("models/v2").toURI()); - Path preambleModel = baseDir.resolve("preamble.smithy"); - List lines = Files.readAllLines(preambleModel); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertEquals(new Position(4, 0), preamble.getNamespaceRange().getStart()); - assertEquals(new Position(4, 21), preamble.getNamespaceRange().getEnd()); - assertEquals("2.0", preamble.getIdlVersion().get()); - assertEquals("Request", preamble.getOperationInputSuffix().get()); - assertEquals("Response", preamble.getOperationOutputSuffix().get()); - assertEquals(new Position(6, 0), preamble.getUseBlockRange().getStart()); - assertEquals(new Position(8, 19), preamble.getUseBlockRange().getEnd()); - assertTrue(preamble.hasImport("ns.foo#FooTrait")); - assertTrue(preamble.hasImport("ns.bar#BarTrait")); - assertFalse(preamble.hasImport("ns.baz#Baz")); - assertTrue(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleMajorIdlVersionOnly() { - List linesIdl1 = ListUtils.of( - "$version: \"1\"", - "namespace ns.one", - "@Foo", - "string MyString" - ); - DocumentPreamble preambleIdl1 = Document.detectPreamble(linesIdl1); - - List linesIdl2 = ListUtils.of( - "$version: \"2\"", - "namespace ns.two", - "@Foo", - "string MyString" - ); - DocumentPreamble preambleIdl2 = Document.detectPreamble(linesIdl2); - - assertEquals("ns.one", preambleIdl1.getCurrentNamespace().get()); - assertEquals("1", preambleIdl1.getIdlVersion().get()); - - assertEquals("ns.two", preambleIdl2.getCurrentNamespace().get()); - assertEquals("2", preambleIdl2.getIdlVersion().get()); - } - - @Test - public void detectPreambleNonBlankSeparated() { - List lines = ListUtils.of( - "$version: \"1.0\"", - "namespace ns.preamble", - "use ns.foo#Foo", - "@Foo", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertEquals(new Position(2, 0), preamble.getUseBlockRange().getStart()); - assertEquals(new Position(2, 14), preamble.getUseBlockRange().getEnd()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithoutUseStatements() { - List lines = ListUtils.of( - "$version: \"1.0\"", - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithoutVersionStatement() { - List lines = ListUtils.of( - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithMetadataAndVersion() { - List lines = ListUtils.of( - "$version: \"1.0\"", - "metadata foo = [", - " { bar: \"baz\" }", - "]", - "metadata hello = there", - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithMetadataWithoutVersionStatement() { - List lines = ListUtils.of( - "metadata foo = [", - " { bar: \"baz\" }", - "]", - "metadata hello = there", - "", - "namespace ns.preamble", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertFalse(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithMetadataWithoutVersionStatementBlankSeparated() { - List lines = ListUtils.of( - "metadata foo = [", - " { bar: \"baz\" }", - "]", - "metadata hello = there", - "", - "namespace ns.preamble", - "", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertTrue(preamble.isBlankSeparated()); - } - - @Test - public void detectPreambleWithoutMetadataOrVersionStatement() { - List lines = ListUtils.of( - "namespace ns.preamble", - "", - "string MyString" - ); - DocumentPreamble preamble = Document.detectPreamble(lines); - - assertEquals("ns.preamble", preamble.getCurrentNamespace().get()); - assertTrue(preamble.isBlankSeparated()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/Harness.java b/src/test/java/software/amazon/smithy/lsp/ext/Harness.java deleted file mode 100644 index 26019edd..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/Harness.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.io.File; -import java.io.FileWriter; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; -import software.amazon.smithy.lsp.Utils; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.IoUtils; -import software.amazon.smithy.utils.ListUtils; - -public class Harness implements AutoCloseable { - private final File root; - private final File temp; - private final SmithyProject project; - private final SmithyBuildExtensions config; - - private Harness(File root, File temporary, SmithyProject project, SmithyBuildExtensions config) { - this.root = root; - this.temp = temporary; - this.project = project; - this.config = config; - } - - public File getRoot() { - return this.root; - } - - public SmithyProject getProject() { - return this.project; - } - - public File getTempFolder() { - return this.temp; - } - - public SmithyBuildExtensions getConfig() { - return this.config; - } - - private static File safeCreateFile(String path, String contents, File root) throws Exception { - File f = Paths.get(root.getAbsolutePath(), path).toFile(); - new File(f.getParent()).mkdirs(); - try (FileWriter fw = new FileWriter(f)) { - fw.write(contents); - fw.flush(); - } - - return f; - } - - public File file(String path) { - return Paths.get(root.getAbsolutePath(), path).toFile(); - } - - public List readFile(File file) throws Exception { - return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); - } - - @Override - public void close() { - root.deleteOnExit(); - } - - public static Harness create(SmithyBuildExtensions ext) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - return loadHarness(ext, hs, tmp, new MockDependencyResolver(ListUtils.of())); - } - - public static Harness create(SmithyBuildExtensions ext, Map files) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - for (Entry entry : files.entrySet()) { - safeCreateFile(entry.getKey(), entry.getValue(), hs); - } - return loadHarness(ext, hs, tmp, new MockDependencyResolver(ListUtils.of())); - } - - public static Harness create(SmithyBuildExtensions ext, List files) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - for (Path path : files) { - if (Utils.isJarFile(path.toString())) { - String contents = String.join(System.lineSeparator(), Utils.jarFileContents(path.toString())); - safeCreateFile(path.getFileName().toString(), contents, hs); - } else { - safeCreateFile(path.getFileName().toString(), IoUtils.readUtf8File(path), hs); - } - } - return loadHarness(ext, hs, tmp, new MockDependencyResolver(ListUtils.of())); - } - - public static Harness create(SmithyBuildExtensions ext, DependencyResolver resolver) throws Exception { - File hs = Files.createTempDirectory("hs").toFile(); - File tmp = Files.createTempDirectory("tmp").toFile(); - return loadHarness(ext, hs, tmp, resolver); - } - - private static Harness loadHarness(SmithyBuildExtensions ext, File hs, File tmp, DependencyResolver resolver) throws Exception { - Either loaded = SmithyProject.load(ext, hs, resolver); - if (loaded.isRight()) - return new Harness(hs, tmp, loaded.getRight(), ext); - else - throw loaded.getLeft(); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java b/src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java deleted file mode 100644 index d2489b54..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/MockDependencyResolver.java +++ /dev/null @@ -1,32 +0,0 @@ -package software.amazon.smithy.lsp.ext; - -import java.util.ArrayList; -import java.util.List; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; - -public class MockDependencyResolver implements DependencyResolver { - final List artifacts; - final List repositories = new ArrayList<>(); - final List coordinates = new ArrayList<>(); - - MockDependencyResolver(List artifacts) { - this.artifacts = artifacts; - } - - @Override - public void addRepository(MavenRepository repository) { - repositories.add(repository); - } - - @Override - public void addDependency(String coordinates) { - this.coordinates.add(coordinates); - } - - @Override - public List resolve() { - return artifacts; - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java b/src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java deleted file mode 100644 index c516ec72..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/ProtocolAdapterTests.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static software.amazon.smithy.model.validation.Severity.WARNING; - -import org.eclipse.lsp4j.Diagnostic; -import org.junit.Test; -import software.amazon.smithy.lsp.ProtocolAdapter; -import software.amazon.smithy.model.validation.ValidationEvent; - -public class ProtocolAdapterTests { - @Test - public void addIdToDiagnostic() { - final ValidationEvent vEvent = ValidationEvent.builder() - .message("Oops") - .id("should-show-up") - .severity(WARNING) - .build(); - final Diagnostic actual = ProtocolAdapter.toDiagnostic(vEvent); - assertEquals("should-show-up: Oops", actual.getMessage()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java deleted file mode 100644 index b65c5bfa..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildExtensionsTest.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.stream.Collectors; -import org.junit.Test; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.SetUtils; - -public class SmithyBuildExtensionsTest { - - @Test - public void parsingSmithyBuildFromString() throws ValidationException { - String mavenConfig = "{\"maven\": {\"dependencies\": [\"d1\", \"d2\"], \"repositories\":" + - "[{\"url\": \"r1\"}, {\"url\": \"r2\"}]}}"; - SmithyBuildExtensions loadedMavenConfig = SmithyBuildLoader.load(getResourcePath(), mavenConfig); - - assertEquals(SetUtils.of("d1", "d2"), loadedMavenConfig.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2"), loadedMavenConfig.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @Test - public void parsingSmithyBuildFromStringWithDeprecatedKeys() throws ValidationException { - String json = "{\"mavenRepositories\": [\"bla\", \"ta\"], \"mavenDependencies\": [\"a1\", \"a2\"]}"; - SmithyBuildExtensions loaded = SmithyBuildLoader.load(getResourcePath(), json); - - assertEquals(SetUtils.of("a1", "a2"), loaded.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("bla", "ta"), loaded.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @Test - public void partialParsing() throws ValidationException { - String noExtensions = "{}"; - SmithyBuildExtensions loadedNoExtensions = SmithyBuildLoader.load(getResourcePath(), noExtensions); - assertNotNull(loadedNoExtensions.getMavenConfig()); - assertEquals(SetUtils.of(), loadedNoExtensions.getMavenConfig().getDependencies()); - assertEquals(SetUtils.of(), loadedNoExtensions.getMavenConfig().getRepositories()); - - String noConfiguration = "{\"imports\": [\".\"]}"; - SmithyBuildExtensions loadedNoConfiguration = SmithyBuildLoader.load(getResourcePath(), noConfiguration); - assertNotNull(loadedNoConfiguration.getMavenConfig()); - assertEquals(SetUtils.of(), loadedNoConfiguration.getMavenConfig().getDependencies()); - assertEquals(SetUtils.of(), loadedNoConfiguration.getMavenConfig().getRepositories()); - - String noRepositories = "{\"mavenDependencies\": [\"a1\", \"a2\"]}"; - SmithyBuildExtensions loadedNoRepositories = SmithyBuildLoader.load(getResourcePath(), noRepositories); - assertNotNull(loadedNoRepositories.getMavenConfig()); - assertEquals(SetUtils.of("a1", "a2"), loadedNoRepositories.getMavenConfig().getDependencies()); - assertEquals(SetUtils.of(), loadedNoRepositories.getMavenConfig().getRepositories()); - - String noArtifacts = "{\"mavenRepositories\": [\"r1\", \"r2\"]}"; - SmithyBuildExtensions loadedNoArtifacts = SmithyBuildLoader.load(getResourcePath(), noArtifacts); - assertNotNull(loadedNoArtifacts.getMavenConfig()); - assertEquals(SetUtils.of(), loadedNoArtifacts.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2"), loadedNoArtifacts.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @Test - public void preferMavenConfig() throws ValidationException { - String conflictingConfig = "{\"maven\": {\"dependencies\": [\"d1\", \"d2\"], \"repositories\":" + - "[{\"url\": \"r1\"}, {\"url\": \"r2\"}]}, \"mavenRepositories\": [\"m1\", \"m2\"]," + - "\"mavenDependencies\": [\"a1\", \"a2\"]}"; - SmithyBuildExtensions loadedConflictingConfig = SmithyBuildLoader.load(getResourcePath(), conflictingConfig); - assertEquals(SetUtils.of("d1", "d2"), loadedConflictingConfig.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2"), loadedConflictingConfig.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - } - - @SuppressWarnings("deprecation") - @Test - public void merging() { - SmithyBuildExtensions.Builder builder = SmithyBuildExtensions.builder(); - - SmithyBuildExtensions other = SmithyBuildExtensions.builder().mavenDependencies(Arrays.asList("hello", "world")) - .mavenRepositories(Arrays.asList("hi", "there")).imports(Arrays.asList("i3", "i4")).build(); - - SmithyBuildExtensions result = builder.mavenDependencies(Arrays.asList("d1", "d2")) - .mavenRepositories(Arrays.asList("r1", "r2")).imports(Arrays.asList("i1", "i2")).merge(other).build(); - - assertEquals(SetUtils.of("d1", "d2", "hello", "world"), result.getMavenConfig().getDependencies()); - assertEquals(ListUtils.of("r1", "r2", "hi", "there"), result.getMavenConfig().getRepositories() - .stream().map(MavenRepository::getUrl).collect(Collectors.toList())); - assertEquals(ListUtils.of("i1", "i2", "i3", "i4"), result.getImports()); - } - - private Path getResourcePath() { - try { - return Paths.get(SmithyBuildExtensionsTest.class.getResource("empty-config.json").toURI()); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java deleted file mode 100644 index 5b7194de..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyBuildLoaderTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package software.amazon.smithy.lsp.ext; - -import static junit.framework.TestCase.assertTrue; - -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.nio.file.Paths; -import org.junit.Test; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; - -public class SmithyBuildLoaderTest { - - @Test - public void mergesSmithyBuildConfigWhenLoading() throws ValidationException { - System.setProperty("FOO", "bar"); - SmithyBuildExtensions config = SmithyBuildLoader.load(getResourcePath()); - - MavenRepository repository = config.getMavenConfig().getRepositories().stream().findFirst().get(); - assertTrue(repository.getUrl().contains("example.com/maven/my_repo")); - assertTrue(repository.getHttpCredentials().get().contains("my_user:bar")); - } - - private Path getResourcePath() { - try { - return Paths.get(SmithyBuildLoaderTest.class.getResource("config-with-env.json").toURI()); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java deleted file mode 100644 index e7c68347..00000000 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java +++ /dev/null @@ -1,513 +0,0 @@ -/* - * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.smithy.lsp.ext; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.Location; -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.junit.Test; -import software.amazon.smithy.build.model.MavenConfig; -import software.amazon.smithy.build.model.MavenRepository; -import software.amazon.smithy.cli.EnvironmentVariable; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.StringShape; -import software.amazon.smithy.model.traits.DocumentationTrait; -import software.amazon.smithy.model.traits.SinceTrait; -import software.amazon.smithy.model.validation.ValidatedResult; -import software.amazon.smithy.utils.IoUtils; -import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.MapUtils; -import software.amazon.smithy.utils.SetUtils; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.StringContains.containsString; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class SmithyProjectTest { - - @Test - public void respectingImports() throws Exception { - List imports = Arrays.asList("bla", "foo"); - Map files = MapUtils.ofEntries(MapUtils.entry("test.smithy", "namespace testRoot"), - MapUtils.entry("bar/test.smithy", "namespace testBar"), - MapUtils.entry("foo/test.smithy", "namespace testFoo"), - MapUtils.entry("bla/test.smithy", "namespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().imports(imports).build(), files)) { - File inFoo = hs.file("foo/test.smithy"); - File inBla = hs.file("bla/test.smithy"); - - List smithyFiles = hs.getProject().getSmithyFiles(); - - assertEquals(ListUtils.of(inBla, inFoo), smithyFiles); - } - } - - @Test - public void respectingEmptyConfig() throws Exception { - Map files = MapUtils.ofEntries(MapUtils.entry("test.smithy", "namespace testRoot"), - MapUtils.entry("bar/test.smithy", "namespace testBar"), - MapUtils.entry("foo/test.smithy", "namespace testFoo"), - MapUtils.entry("bla/test.smithy", "namespace testBla")); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - - List expectedFiles = Files.walk(hs.getRoot().toPath()) - .filter(f -> f.getFileName().toString().endsWith(Constants.SMITHY_EXTENSION)).map(Path::toFile) - .collect(Collectors.toList()); - - List smithyFiles = hs.getProject().getSmithyFiles(); - - assertEquals(expectedFiles, smithyFiles); - } - } - - @Test - public void defaultsToMavenCentral() throws Exception { - SmithyBuildExtensions extensions = SmithyBuildExtensions.builder().build(); - MockDependencyResolver delegate = new MockDependencyResolver(ListUtils.of()); - File cache = File.createTempFile("classpath", ".json"); - DependencyResolver resolver = new FileCacheResolver(cache, System.currentTimeMillis(), delegate); - try (Harness hs = Harness.create(extensions, resolver)) { - assertEquals(delegate.repositories.stream().findFirst().get().getUrl(), "https://repo.maven.apache.org/maven2"); - } - } - - @Test - public void cachesExternalJars() throws Exception { - String repo1 = "https://repo.smithy.io"; - String repo2 = "https://repo.foo.com"; - System.setProperty(EnvironmentVariable.SMITHY_MAVEN_REPOS.toString(), - String.join("|", repo1, repo2)); - String dependency = "com.foo:bar:1.0.0"; - MavenRepository configuredRepo = MavenRepository.builder() - .url("https://repo.example.com") - .httpCredentials("user:pw") - .build(); - MavenConfig maven = MavenConfig.builder() - .dependencies(ListUtils.of(dependency)) - .repositories(SetUtils.of(configuredRepo)) - .build(); - List expectedRepos = ListUtils.of( - MavenRepository.builder().url(repo1).build(), - MavenRepository.builder().url(repo2).build(), - configuredRepo - ); - SmithyBuildExtensions extensions = SmithyBuildExtensions.builder().maven(maven).build(); - File cache = File.createTempFile("classpath", ".json"); - File jar = File.createTempFile("foo", ".json"); - Files.write(jar.toPath(), "{}".getBytes(StandardCharsets.UTF_8)); - ResolvedArtifact artifact = ResolvedArtifact.fromCoordinates(jar.toPath(), "com.foo:bar:1.0.0"); - MockDependencyResolver delegate = new MockDependencyResolver(ListUtils.of(artifact)); - DependencyResolver resolver = new FileCacheResolver(cache, System.currentTimeMillis(), delegate); - try (Harness hs = Harness.create(extensions, resolver)) { - assertTrue(delegate.repositories.containsAll(expectedRepos)); - assertEquals(expectedRepos.size(), delegate.repositories.size()); - assertEquals(dependency, delegate.coordinates.get(0)); - assertThat(IoUtils.readUtf8File(cache.toPath()), containsString(dependency)); - } - } - - @Test - public void ableToLoadWithUnknownTrait() throws Exception { - Path modelFile = Paths.get(getClass().getResource("models/unknown-trait.smithy").toURI()); - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), ListUtils.of(modelFile))) { - ValidatedResult modelValidatedResult = hs.getProject().getModel(); - assertFalse(modelValidatedResult.isBroken()); - } - } - - @Test - public void ignoresUnmodeledApplyStatements() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path main = baseDir.resolve("apply.smithy"); - Path imports = baseDir.resolve("apply-imports.smithy"); - List modelFiles = ListUtils.of(main, imports); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - // Structure shape unchanged by apply - correctLocation(locationMap, "com.main#SomeOpInput", 12, 0, 15, 1); - - // Member is unchanged by apply - correctLocation(locationMap, "com.main#SomeOpInput$body", 14, 4, 14, 16); - - // The mixed in member should have the source location from the mixin. - correctLocation(locationMap, "com.main#SomeOpInput$isTest", 8, 4, 8, 19); - - // Structure shape unchanged by apply - correctLocation(locationMap, "com.main#ArbitraryStructure", 25, 0, 27, 1); - - // Member is unchanged by apply - correctLocation(locationMap, "com.main#ArbitraryStructure$member", 26, 4, 26, 18); - - // Mixed-in member in another namespace unchanged by apply - correctLocation(locationMap, "com.imports#HasIsTestParam$isTest", 8, 4, 8, 19); - - // Structure in another namespace unchanged by apply - correctLocation(locationMap, "com.imports#HasIsTestParam", 7, 0, 9, 1); - } - } - - // https://github.com/awslabs/smithy-language-server/issues/100 - @Test - public void allowsEmptyStructsWithMixins() throws Exception { - String fileText = "$version: \"2\"\n" + - "\n" + - "namespace demo\n" + - "\n" + - "operation MyOp {\n" + - " output: MyOpOutput\n" + - "}\n" + - "\n" + - "@output\n" + - "structure MyOpOutput {}\n"; - - Map files = MapUtils.of("main.smithy", fileText); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), files)) { - assertNotNull(hs.getProject()); - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "demo#MyOpOutput", 9, 0, 9, 23); - } - } - - // https://github.com/awslabs/smithy-language-server/issues/110 - // Note: This test is flaky, it may succeed even if the code being tested is incorrect. - @Test - public void handlesSameOperationNameBetweenNamespaces() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/operation-name-conflict").toURI()); - Path modelA = baseDir.resolve("a.smithy"); - Path modelB = baseDir.resolve("b.smithy"); - List modelFiles = ListUtils.of(modelA, modelB); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "a#HelloWorld", 4, 0, 13, 1); - correctLocation(locationMap, "b#HelloWorld", 6, 0, 15, 1); - } - } - - @Test - public void definitionLocationsV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "com.foo#SingleLine", 4, 0, 4, 23); - correctLocation(locationMap, "com.foo#MultiLine", 6, 8,13, 9); - correctLocation(locationMap, "com.foo#SingleTrait", 16, 4, 16, 22); - correctLocation(locationMap, "com.foo#MultiTrait", 20, 0,21, 14); - correctLocation(locationMap, "com.foo#MultiTraitAndLineComments", 35, 0,37, 1); - correctLocation(locationMap,"com.foo#MultiTraitAndDocComments", 46, 0,48, 1); - correctLocation(locationMap, "com.example#OtherStructure", 7, 0, 11, 1); - } - } - - @Test - public void definitionLocationsV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - Map locationMap = hs.getProject().getLocations(); - - correctLocation(locationMap, "com.foo#SingleLine", 6, 0, 6, 23); - correctLocation(locationMap, "com.foo#MultiLine", 8, 8,15, 9); - correctLocation(locationMap, "com.foo#SingleTrait", 18, 4, 18, 22); - correctLocation(locationMap, "com.foo#MultiTrait", 22, 0,23, 14); - correctLocation(locationMap, "com.foo#MultiTraitAndLineComments", 37, 0,39, 1); - correctLocation(locationMap, "com.foo#MultiTraitAndDocComments", 48, 0, 50, 1); - correctLocation(locationMap, "com.example#OtherStructure", 7, 0, 11, 1); - correctLocation(locationMap, "com.foo#StructWithDefaultSugar", 97, 0, 99, 1); - correctLocation(locationMap, "com.foo#MyInlineOperation", 101, 0, 109, 1); - correctLocation(locationMap, "com.foo#MyInlineOperationFooInput", 102, 13, 105, 5); - correctLocation(locationMap, "com.foo#MyInlineOperationBarOutput", 106, 14, 108, 5); - correctLocation(locationMap, "com.foo#UserIds", 112, 0, 118, 1); - correctLocation(locationMap, "com.foo#UserIds$email", 114, 4, 114, 17); - correctLocation(locationMap, "com.foo#UserIds$id", 117, 4, 117, 14); - correctLocation(locationMap, "com.foo#UserDetails", 121, 0, 123, 1); - correctLocation(locationMap, "com.foo#UserDetails$status", 122, 4, 122, 18); - correctLocation(locationMap, "com.foo#GetUser", 125, 0, 132, 1); - correctLocation(locationMap, "com.foo#GetUserFooInput", 126, 13, 128, 5); - correctLocation(locationMap, "com.foo#GetUserBarOutput", 129, 14, 131, 5); - correctLocation(locationMap, "com.foo#ElidedUserInfo", 134, 0, 140, 1); - correctLocation(locationMap, "com.foo#ElidedUserInfo$email", 136, 4, 136, 10); - correctLocation(locationMap, "com.foo#ElidedUserInfo$status", 139, 4, 139, 11); - correctLocation(locationMap, "com.foo#ElidedGetUser", 142, 0, 155, 1); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput", 143, 13, 148, 5); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$id", 146, 7, 146, 10); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$optional", 147, 7, 147, 23); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput", 149, 14, 154, 5); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$status", 151, 8, 151, 15); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$description", 153, 8, 153, 27); - correctLocation(locationMap, "com.foo#Suit", 157, 0, 162, 1); - correctLocation(locationMap, "com.foo#Suit$CLUB", 159, 4, 159, 17); - correctLocation(locationMap, "com.foo#Suit$SPADE", 161, 4, 161, 19); - - correctLocation(locationMap, "com.foo#MyInlineOperationReversed", 164, 0, 171, 1); - correctLocation(locationMap, "com.foo#MyInlineOperationReversedFooInput", 168, 13, 170, 5); - correctLocation(locationMap, "com.foo#MyInlineOperationReversedBarOutput", 165, 14, 167, 5); - - correctLocation(locationMap, "com.foo#FalseInlined", 175, 0, 178, 1); - correctLocation(locationMap, "com.foo#FalseInlinedFooInput", 180, 0, 182, 1); - correctLocation(locationMap, "com.foo#FalseInlinedBarOutput", 184, 0, 186, 1); - - correctLocation(locationMap, "com.foo#FalseInlinedReversed", 188, 0, 191, 1); - correctLocation(locationMap, "com.foo#FalseInlinedReversedFooInput", 193, 0, 195, 1); - correctLocation(locationMap, "com.foo#FalseInlinedReversedBarOutput", 197, 0, 199, 1); - - // Elided members from source mixin structure. - correctLocation(locationMap, "com.foo#ElidedUserInfo$id", 117, 4, 117, 14); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$email", 114, 4, 114, 17); - correctLocation(locationMap, "com.foo#ElidedGetUserFooInput$status", 122, 4, 122, 18); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$email", 114, 4, 114, 17); - correctLocation(locationMap, "com.foo#ElidedGetUserBarOutput$id", 117, 4, 117, 14); - } - } - - @Test - public void definitionLocationsEmptySourceLocationsOnTraitV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path modelMain = baseDir.resolve("empty-source-location-trait.smithy"); - - StringShape stringShapeBar = StringShape.builder() - .id("ns.foo#Bar") - .source(new SourceLocation(modelMain.toString(), 5, 1)) - .build(); - - StringShape stringShapeBaz = StringShape.builder() - .id("ns.foo#Baz") - .addTrait(new DocumentationTrait("docs", SourceLocation.NONE)) - .addTrait(new SinceTrait("2022-05-12", new SourceLocation(modelMain.toString(), 7, 1))) - .source(new SourceLocation(modelMain.toString(), 8, 1)) - .build(); - - Model unvalidatedModel = Model.builder() - .addShape(stringShapeBar) - .addShape(stringShapeBaz) - .build(); - ValidatedResult model = Model.assembler().addModel(unvalidatedModel).assemble(); - SmithyProject project = new SmithyProject(Collections.emptyList(), Collections.emptyList(), - Collections.emptyList(), baseDir.toFile(), model); - Map locationMap = project.getLocations(); - - correctLocation(locationMap, "ns.foo#Bar", 4, 0, 4, 10); - correctLocation(locationMap, "ns.foo#Baz", 7, 0, 7, 10); - } - - @Test - public void definitionLocationsEmptySourceLocationsOnTraitV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path modelMain = baseDir.resolve("empty-source-location-trait.smithy"); - - StringShape stringShapeBar = StringShape.builder() - .id("ns.foo#Bar") - .source(new SourceLocation(modelMain.toString(), 5, 1)) - .build(); - - StringShape stringShapeBaz = StringShape.builder() - .id("ns.foo#Baz") - .addTrait(new DocumentationTrait("docs", SourceLocation.NONE)) - .addTrait(new SinceTrait("2022-05-12", new SourceLocation(modelMain.toString(), 7, 1))) - .source(new SourceLocation(modelMain.toString(), 8, 1)) - .build(); - - Model unvalidatedModel = Model.builder() - .addShape(stringShapeBar) - .addShape(stringShapeBaz) - .build(); - ValidatedResult model = Model.assembler().addModel(unvalidatedModel).assemble(); - SmithyProject project = new SmithyProject(Collections.emptyList(), Collections.emptyList(), - Collections.emptyList(), baseDir.toFile(), model); - Map locationMap = project.getLocations(); - - correctLocation(locationMap, "ns.foo#Bar", 4, 0, 4, 10); - correctLocation(locationMap, "ns.foo#Baz", 7, 0, 7, 10); - } - - @Test - public void shapeIdFromLocationV1() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v1").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyProject project = hs.getProject(); - String uri = hs.file("main.smithy").toString(); - String testUri = hs.file("test.smithy").toString(); - - assertFalse(project.getShapeIdFromLocation("non-existent-model-file.smithy", new Position(0, 0)).isPresent()); - assertFalse(project.getShapeIdFromLocation(uri, new Position(0, 0)).isPresent()); - // Position on shape start line, but before char start - assertFalse(project.getShapeIdFromLocation(uri, new Position(17, 0)).isPresent()); - // Position on shape end line, but after char end - assertFalse(project.getShapeIdFromLocation(uri, new Position(14, 10)).isPresent()); - // Position on shape start line - assertEquals(ShapeId.from("com.foo#SingleLine"), project.getShapeIdFromLocation(uri, - new Position(4, 10)).get()); - // Position on multi-line shape start line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(6, 8)).get()); - // Position on multi-line shape end line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(13, 6)).get()); - // Member positions - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(7,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(10,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(12,14)).get()); - // Member positions on target - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(7,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(10,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(12,18)).get()); - assertEquals(ShapeId.from("com.example#OtherStructure"), project.getShapeIdFromLocation(testUri, - new Position(7, 15)).get()); - } - } - - @Test - public void shapeIdFromLocationV2() throws Exception { - Path baseDir = Paths.get(SmithyProjectTest.class.getResource("models/v2").toURI()); - Path modelMain = baseDir.resolve("main.smithy"); - Path modelTest = baseDir.resolve("test.smithy"); - List modelFiles = ListUtils.of(modelMain, modelTest); - - try (Harness hs = Harness.create(SmithyBuildExtensions.builder().build(), modelFiles)) { - SmithyProject project = hs.getProject(); - String uri = hs.file("main.smithy").toString(); - String testUri = hs.file("test.smithy").toString(); - - assertFalse(project.getShapeIdFromLocation("non-existent-model-file.smithy", new Position(0, 0)).isPresent()); - assertFalse(project.getShapeIdFromLocation(uri, new Position(0, 0)).isPresent()); - // Position on shape start line, but before char start - assertFalse(project.getShapeIdFromLocation(uri, new Position(19, 0)).isPresent()); - // Position on shape end line, but after char end - assertFalse(project.getShapeIdFromLocation(uri, new Position(16, 10)).isPresent()); - // Position on shape start line - Optional foo = project.getShapeIdFromLocation(uri, new Position(6, 10)); - assertEquals(ShapeId.from("com.foo#SingleLine"), project.getShapeIdFromLocation(uri, - new Position(6, 10)).get()); - // Position on multi-line shape start line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(8, 8)).get()); - // Position on multi-line shape end line - assertEquals(ShapeId.from("com.foo#MultiLine"), project.getShapeIdFromLocation(uri, - new Position(15, 6)).get()); - // Member positions - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(9,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(12,14)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(14,14)).get()); - // Member positions on target - assertEquals(ShapeId.from("com.foo#MultiLine$a"), project.getShapeIdFromLocation(uri, - new Position(9,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$b"), project.getShapeIdFromLocation(uri, - new Position(12,18)).get()); - assertEquals(ShapeId.from("com.foo#MultiLine$c"), project.getShapeIdFromLocation(uri, - new Position(14,18)).get()); - assertEquals(ShapeId.from("com.foo#GetUser"), project.getShapeIdFromLocation(uri, - new Position(125,13)).get()); - assertEquals(ShapeId.from("com.foo#GetUserFooInput$optional"), project.getShapeIdFromLocation(uri, - new Position(127,14)).get()); - assertEquals(ShapeId.from("com.foo#GetUserBarOutput"), project.getShapeIdFromLocation(uri, - new Position(129,19)).get()); - assertEquals(ShapeId.from("com.foo#GetUserBarOutput$description"), project.getShapeIdFromLocation(uri, - new Position(130,12)).get()); - assertEquals(ShapeId.from("com.foo#ElidedUserInfo"), project.getShapeIdFromLocation(uri, - new Position(134,17)).get()); - assertEquals(ShapeId.from("com.foo#ElidedUserInfo$email"), project.getShapeIdFromLocation(uri, - new Position(136,8)).get()); - assertEquals(ShapeId.from("com.foo#ElidedUserInfo$status"), project.getShapeIdFromLocation(uri, - new Position(139,9)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUser"), project.getShapeIdFromLocation(uri, - new Position(142,18)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserFooInput"), project.getShapeIdFromLocation(uri, - new Position(144,18)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserFooInput$id"), project.getShapeIdFromLocation(uri, - new Position(146,10)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserFooInput$optional"), project.getShapeIdFromLocation(uri, - new Position(147,13)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserBarOutput"), project.getShapeIdFromLocation(uri, - new Position(149,16)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserBarOutput$status"), project.getShapeIdFromLocation(uri, - new Position(151,12)).get()); - assertEquals(ShapeId.from("com.foo#ElidedGetUserBarOutput$description"), project.getShapeIdFromLocation(uri, - new Position(153,18)).get()); - assertEquals(ShapeId.from("com.foo#Suit"), project.getShapeIdFromLocation(uri, - new Position(157,8)).get()); - assertEquals(ShapeId.from("com.foo#Suit$DIAMOND"), project.getShapeIdFromLocation(uri, - new Position(158,8)).get()); - assertEquals(ShapeId.from("com.foo#Suit$HEART"), project.getShapeIdFromLocation(uri, - new Position(160,8)).get()); - assertEquals(ShapeId.from("com.example#OtherStructure"), project.getShapeIdFromLocation(testUri, - new Position(7, 15)).get()); - assertEquals(ShapeId.from("com.foo#ShortI"), project.getShapeIdFromLocation(uri, - new Position(210,5)).get()); - assertEquals(ShapeId.from("com.foo#ShortI$c"), project.getShapeIdFromLocation(uri, - new Position(211,6)).get()); - assertEquals(ShapeId.from("com.foo#ShortO"), project.getShapeIdFromLocation(uri, - new Position(215,5)).get()); - assertEquals(ShapeId.from("com.foo#ShortO$d"), project.getShapeIdFromLocation(uri, - new Position(216,6)).get()); - } - } - - private void correctLocation(Map locationMap, String shapeId, int startLine, - int startColumn, int endLine, int endColumn) { - Location location = locationMap.get(ShapeId.from(shapeId)); - Range range = new Range(new Position(startLine, startColumn), new Position(endLine, endColumn)); - assertEquals(range, location.getRange()); - } -} diff --git a/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java new file mode 100644 index 00000000..2cdf44ee --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/handler/FileWatcherRegistrationHandlerTest.java @@ -0,0 +1,60 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.handler; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.endsWith; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; +import org.eclipse.lsp4j.Registration; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.SmithyBuildConfig; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.ProjectManager; +import software.amazon.smithy.utils.ListUtils; + +public class FileWatcherRegistrationHandlerTest { + @Test + public void createsCorrectRegistrations() { + TestWorkspace workspace = TestWorkspace.builder() + .withSourceDir(new TestWorkspace.Dir() + .path("foo") + .withSourceDir(new TestWorkspace.Dir() + .path("bar") + .withSourceFile("bar.smithy", "") + .withSourceFile("baz.smithy", "")) + .withSourceFile("baz.smithy", "")) + .withSourceDir(new TestWorkspace.Dir() + .path("other") + .withSourceFile("other.smithy", "")) + .withSourceFile("abc.smithy", "") + .withConfig(SmithyBuildConfig.builder() + .version("1") + .sources(ListUtils.of("foo", "other/", "abc.smithy")) + .build()) + .build(); + + Project project = ProjectLoader.load(workspace.getRoot(), new ProjectManager(), new HashSet<>()).unwrap(); + List watcherPatterns = FileWatcherRegistrationHandler.getSmithyFileWatcherRegistrations(project) + .stream() + .map(Registration::getRegisterOptions) + .map(o -> (DidChangeWatchedFilesRegistrationOptions) o) + .flatMap(options -> options.getWatchers().stream()) + .map(watcher -> watcher.getGlobPattern().getLeft()) + .collect(Collectors.toList()); + + assertThat(watcherPatterns, containsInAnyOrder( + endsWith("foo/**/*.{smithy,json}"), + endsWith("other/**/*.{smithy,json}"), + endsWith("abc.smithy"))); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java new file mode 100644 index 00000000..7e0d9f62 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectConfigLoaderTest.java @@ -0,0 +1,80 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.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/ProjectManagerTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java new file mode 100644 index 00000000..1b8bea7f --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectManagerTest.java @@ -0,0 +1,43 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class ProjectManagerTest { + @Test + public void canCheckIfAFileIsTracked() { + Path attachedRoot = ProjectTest.toPath(getClass().getResource("flat")); + Project mainProject = ProjectLoader.load(attachedRoot).unwrap(); + + ProjectManager manager = new ProjectManager(); + manager.updateMainProject(mainProject); + + String detachedUri = LspAdapter.toUri("/foo/bar"); + manager.createDetachedProject(detachedUri, ""); + + String mainUri = LspAdapter.toUri(attachedRoot.resolve("main.smithy").toString()); + + assertThat(manager.isTracked(mainUri), is(true)); + assertThat(manager.getProject(mainUri), notNullValue()); + assertThat(manager.getProject(mainUri).getSmithyFile(mainUri), notNullValue()); + + assertThat(manager.isTracked(detachedUri), is(true)); + assertThat(manager.getProject(detachedUri), notNullValue()); + assertThat(manager.getProject(detachedUri).getSmithyFile(detachedUri), notNullValue()); + + String untrackedUri = LspAdapter.toUri("/bar/baz.smithy"); + assertThat(manager.isTracked(untrackedUri), is(false)); + assertThat(manager.getProject(untrackedUri), nullValue()); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java new file mode 100644 index 00000000..d5aad874 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -0,0 +1,616 @@ +/* + * 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.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.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.util.Result; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.LengthTrait; +import software.amazon.smithy.model.traits.PatternTrait; +import software.amazon.smithy.model.traits.TagsTrait; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; + +public class ProjectTest { + @Test + public void loadsFlatProject() { + Path root = toPath(getClass().getResource("flat")); + Project project = ProjectLoader.load(root).unwrap(); + + 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 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @length(min: 1)\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + 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))); + } + + @Test + public void changeFileApplyingListTrait() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + } + + @Test + public void changeFileApplyingListTraitWithUnrelatedDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "string Baz\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Baz @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + Shape baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(baz.hasTrait("length"), is(true)); + assertThat(baz.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + + String uri = workspace.getUri("model-0.smithy"); + Document document = project.getDocument(uri); + document.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + baz = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Baz")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(baz.hasTrait("length"), is(true)); + assertThat(baz.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void changingFileApplyingListTraitWithRelatedDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + + String uri = workspace.getUri("model-0.smithy"); + Document document = project.getDocument(uri); + document.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void changingFileApplyingListTraitWithRelatedArrayTraitDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @tags([\"bar\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + 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")); + } + + @Test + public void changingFileWithDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + 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))); + } + + @Test + public void changingFileWithArrayDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @tags([\"foo\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + } + + @Test + public void changingFileWithMixedArrayDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "@tags([\"foo\"])\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @tags([\"foo\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + 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")); + } + + @Test + public void changingFileWithArrayDependenciesWithDependencies() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Foo\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n" + + "apply Foo @tags([\"foo\"])\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @length(min: 1)\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(bar.hasTrait("length"), is(true)); + 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.applyEdit(LspAdapter.point(document.end()), "\n"); + + project.updateModelWithoutValidating(uri); + + foo = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Foo")); + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(foo.hasTrait("tags"), is(true)); + assertThat(foo.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("foo")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + } + + @Test + public void removingSimpleApply() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @length(min: 1)\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @pattern(\"a\")\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("pattern"), is(true)); + assertThat(bar.expectTrait(PatternTrait.class).getPattern().pattern(), equalTo("a")); + assertThat(bar.hasTrait("length"), is(true)); + assertThat(bar.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(1L))); + + String uri = workspace.getUri("model-0.smithy"); + Document document = project.getDocument(uri); + document.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); + + project.updateModelWithoutValidating(uri); + + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("pattern"), is(true)); + assertThat(bar.expectTrait(PatternTrait.class).getPattern().pattern(), equalTo("a")); + assertThat(bar.hasTrait("length"), is(false)); + } + + @Test + public void removingArrayApply() { + String m1 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @tags([\"foo\"])\n"; + String m2 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "string Bar\n"; + String m3 = "$version: \"2\"\n" + + "namespace com.foo\n" + + "apply Bar @tags([\"bar\"])\n"; + TestWorkspace workspace = TestWorkspace.multipleModels(m1, m2, m3); + Project project = ProjectLoader.load(workspace.getRoot()).unwrap(); + + Shape bar = project.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.applyEdit(LspAdapter.lineSpan(2, 0, document.lineEnd(2)), ""); + + project.updateModelWithoutValidating(uri); + + bar = project.modelResult().unwrap().expectShape(ShapeId.from("com.foo#Bar")); + assertThat(bar.hasTrait("tags"), is(true)); + assertThat(bar.expectTrait(TagsTrait.class).getTags(), containsInAnyOrder("bar")); + } + + public static Path toPath(URL url) { + try { + return Paths.get(url.toURI()); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java b/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java new file mode 100644 index 00000000..65f90a9c --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/project/SmithyBuildExtensionsTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.lsp.project; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.model.MavenConfig; +import software.amazon.smithy.build.model.MavenRepository; + +public class SmithyBuildExtensionsTest { + @SuppressWarnings("deprecation") + @Test + public void merging() { + SmithyBuildExtensions.Builder builder = SmithyBuildExtensions.builder(); + + SmithyBuildExtensions other = SmithyBuildExtensions.builder().mavenDependencies(Arrays.asList("hello", "world")) + .mavenRepositories(Arrays.asList("hi", "there")).imports(Arrays.asList("i3", "i4")).build(); + + SmithyBuildExtensions result = builder.mavenDependencies(Arrays.asList("d1", "d2")) + .mavenRepositories(Arrays.asList("r1", "r2")).imports(Arrays.asList("i1", "i2")).merge(other).build(); + + MavenConfig mavenConfig = result.mavenConfig(); + assertThat(mavenConfig.getDependencies(), containsInAnyOrder("d1", "d2", "hello", "world")); + List urls = mavenConfig.getRepositories().stream() + .map(MavenRepository::getUrl) + .collect(Collectors.toList()); + assertThat(urls, containsInAnyOrder("r1", "r2", "hi", "there")); + assertThat(result.imports(), containsInAnyOrder("i1", "i2", "i3", "i4")); + } +} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json b/src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json deleted file mode 100644 index b9f92c0d..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/empty-config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "2.0" -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy deleted file mode 100644 index 9228eaa1..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/another.smithy +++ /dev/null @@ -1,3 +0,0 @@ -$version: "2" -namespace test -structure City { } diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy deleted file mode 100644 index 9a699186..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/document-symbols/current.smithy +++ /dev/null @@ -1,5 +0,0 @@ -$version: "2" -namespace test -structure Weather { - @required city: City -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy deleted file mode 100644 index db473a06..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/unknown-trait.smithy +++ /dev/null @@ -1,16 +0,0 @@ -$version: "2.0" - -namespace com.foo - -use com.external#unknownTrait - -@unknownTrait -structure Foo {} - -structure Bar { - member: Foo -} - -structure Baz { - member: Bar -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy deleted file mode 100644 index c581ea26..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/apply.smithy +++ /dev/null @@ -1,6 +0,0 @@ -$version: "1.0" - -namespace com.foo - -apply com.foo#MultiTrait @documentation("docs") -apply com.foo#MultiTrait$a @documentation("member docs") \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy deleted file mode 100644 index 18d22ec0..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/broken.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "1.0" - -namespace foo.com - -structure MyStruct { - a: AnotherStruct -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy deleted file mode 100644 index 3c7679e1..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/cluttered-preamble.smithy +++ /dev/null @@ -1,33 +0,0 @@ -$version: "2.0" - -$operationInputSuffix: "ClutteredInput" -$operationOutputSuffix: "ClutteredOutput" - - -$extraneous: "extraneous" - - -// Comments in preamble -// Whitespace - - - -namespace com.clutter - - -// Use statements -use com.example#OtherStructure - - - -use com.extras#Extra - -/// With doc comment -structure StructureWithDependencies { - extra: Extra - example: OtherStructure -} - -structure StructureWithNoDependencies { - member: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy deleted file mode 100644 index 7269e626..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/empty-source-location-trait.smithy +++ /dev/null @@ -1,8 +0,0 @@ -$version: "1.0" - -namespace ns.foo - -string Bar - -@since("2022-05-12") -string Baz diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy deleted file mode 100644 index c75e6264..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/extras-to-import.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "2.0" - -namespace com.extras - -structure Extra { - extraMember: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy deleted file mode 100644 index 158b36e5..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/main.smithy +++ /dev/null @@ -1,97 +0,0 @@ -$version: "1.0" - -namespace com.foo - -structure SingleLine {} - - structure MultiLine { - a: String, - - - b: String, - @required - c: SingleLine - } - - @pattern("^[A-Za-z0-9 ]+$") - string SingleTrait - -@input -@tags(["foo"]) -structure MultiTrait { - a: String} - -// Line comments -// comments - @input - // comments - @tags(["a", - "b", - "c", - "d", - "e", - "f" - ] -) -structure MultiTraitAndLineComments { - a: String -} - - - - -/// Doc comments -/// Comment about corresponding MultiTrait shape -@input -@tags(["foo"]) -structure MultiTraitAndDocComments { - a: String -} - -@readonly -operation MyOperation { - input: MyOperationInput, - output: MyOperationOutput, - errors: [MyError] -} - -structure MyOperationInput { - foo: String, - @required - myId: MyId -} - -structure MyOperationOutput { - corge: String, - qux: String -} - -@error("client") -structure MyError { - blah: String, - blahhhh: Integer -} - -resource MyResource { - identifiers: { myId: MyId }, - read: MyOperation -} - -string MyId - -string InputString - -apply MyOperation @http(method: "PUT", uri: "/bar", code: 200) - - @http(method: "PUT", uri: "/foo", code: 200) - @documentation("doc has parens ()") - @tags(["foo)", - "bar)", - "baz)"]) - @examples([{ - title: "An)Operation" - }]) - operation AnOperation {} - -@trait -structure emptyTraitStruct {} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy deleted file mode 100644 index 400d8097..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/preamble.smithy +++ /dev/null @@ -1,11 +0,0 @@ -$version: "1.0" - -namespace ns.preamble - -use ns.foo#FooTrait - -use ns.bar#BarTrait - -@FooTrait -@BarTrait -string MyString \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy deleted file mode 100644 index cf6ad2d0..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/test.smithy +++ /dev/null @@ -1,15 +0,0 @@ -$version: "1.0" - -namespace com.example - -use com.foo#emptyTraitStruct - -@emptyTraitStruct -structure OtherStructure { - foo: String, - bar: String, - baz: Integer -} - - - diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy deleted file mode 100644 index 28c45f6d..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v1/trait-def.smithy +++ /dev/null @@ -1,86 +0,0 @@ -$version: "1.0" - -namespace test - -@trait -structure test { - @required - blob: Blob, - - @required - bool: Boolean, - - @requird - byte: Byte, - - @required - short: Short, - - @required - integer: Integer, - - @required - long: Long, - - @required - float: Float, - - @required - double: Double, - - @required - bigDecimal: BigDecimal, - - @required - bigInteger: BigInteger, - - @required - string: String, - - @required - timestamp: Timestamp, - - @required - list: ListA, - - @required - set: SetA, - - @required - map: MapA, - - @required - struct: StructureA, - - @required - union: UnionA -} - -list ListA { - member: String -} - -set SetA { - member: String -} - -map MapA { - key: String, - value: Integer -} - -structure StructureA { - @required - nested: StructureB -} - -structure StructureB { - @required - nestedMember: String -} - -union UnionA { - a: Integer, - b: String, - c: Timestamp -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy deleted file mode 100644 index 240708ac..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply-imports.smithy +++ /dev/null @@ -1,10 +0,0 @@ -$version: "2.0" - -namespace com.imports - -boolean IsTestInput - -@mixin -structure HasIsTestParam { - isTest: Boolean -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy deleted file mode 100644 index 3a63103c..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/apply.smithy +++ /dev/null @@ -1,38 +0,0 @@ -$version: "2.0" - -namespace com.main - -use com.imports#HasIsTestParam - -// Apply before shape definition -apply SomeOpInput @tags(["someTag"]) - -// Apply as first line in shapes section -apply ArbitraryStructure$member @tags(["someTag"]) - -structure SomeOpInput with [HasIsTestParam] { - @required - body: String -} - -/// Arbitrary doc comment - -// Arbitrary comment - -// Apply targeting a mixed in member from another namespace -apply SomeOpInput$isTest @documentation("Some documentation") - -// Structure to break up applys -structure ArbitraryStructure { - member: String -} - -// Multiple applys before first shape definition -apply ArbitraryStructure @documentation("Some documentation") - -// Apply targeting non-mixed in member -apply SomeOpInput$body @documentation("Some documentation") - - -// Apply targeting a shape from another namespace -apply HasIsTestParam @documentation("Some documentation") diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy deleted file mode 100644 index 029f5759..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/broken.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "2.0" - -namespace foo.com - -structure MyStruct { - a: AnotherStruct -} diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy deleted file mode 100644 index 1542b45b..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/cluttered-preamble.smithy +++ /dev/null @@ -1,39 +0,0 @@ -$version: "2.0" - -$operationInputSuffix: "In" -$operationOutputSuffix: "Out" - - -$extraneous: "extraneous" - - -// Comments in preamble -// Whitespace - - - -namespace com.clutter - - -// Use statements -use com.example#OtherStructure - - - -use com.extras#Extra - -/// With doc comment -@mixin -structure StructureWithDependencies { - extra: Extra - example: OtherStructure -} - -operation ClutteredInlineOperation { - input := with [StructureWithDependencies] { - } - output := with [StructureWithDependencies] { - additional: Integer - } -} - diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy deleted file mode 100644 index 3f48ace7..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/empty-source-location-trait.smithy +++ /dev/null @@ -1,8 +0,0 @@ -$version: "2.0" - -namespace ns.foo - -string Bar - -@since("2022-05-12") -string Baz diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy deleted file mode 100644 index c75e6264..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/extras-to-import.smithy +++ /dev/null @@ -1,7 +0,0 @@ -$version: "2.0" - -namespace com.extras - -structure Extra { - extraMember: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy deleted file mode 100644 index d6a0e9ce..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/main.smithy +++ /dev/null @@ -1,218 +0,0 @@ -$version: "2.0" -$operationInputSuffix: "FooInput" -$operationOutputSuffix: "BarOutput" - -namespace com.foo - -structure SingleLine {} - - structure MultiLine { - a: String - - - b: String - @required - c: SingleLine - } - - @pattern("^[A-Za-z0-9 ]+$") - string SingleTrait - -@input -@tags(["foo"]) -structure MultiTrait { - a: String} - -// Line comments -// comments - @input - // comments - @tags(["a", - "b", - "c", - "d", - "e", - "f" - ] -) -structure MultiTraitAndLineComments { - a: String -} - - - - -/// Doc comments -/// Comment about corresponding MultiTrait shape -@input -@tags(["foo"]) -structure MultiTraitAndDocComments { - a: String -} - -@readonly -operation MyOperation { - input: MyOperationInput - output: MyOperationOutput - errors: [MyError] -} - -structure MyOperationInput { - foo: String - @required - myId: MyId -} - -structure MyOperationOutput { - corge: String - qux: String -} - -@error("client") -structure MyError { - blah: String - blahhhh: Integer -} - -resource MyResource { - identifiers: { myId: MyId } - read: MyOperation -} - -string MyId - -string InputString - -apply MyOperation @http(method: "PUT", uri: "/bar", code: 200) - - @http(method: "PUT", uri: "/foo", code: 200) - @documentation("doc has parens ()") - @tags(["foo)", - "bar)", - "baz)"]) - @examples([{ - title: "An)Operation" - }]) - operation AnOperation {} - -structure StructWithDefaultSugar { - foo: String = "bar" -} - -operation MyInlineOperation { - input := { - foo: String - bar: String - } - output := { - baz: String - } -} - -@mixin -structure UserIds { - @required - email: String - - @required - id: String -} - -@mixin -structure UserDetails { - status: String -} - -operation GetUser { - input := with [UserIds, UserDetails] { - optional: String - } - output := with [UserIds, UserDetails] { - description: String - } -} - -structure ElidedUserInfo with [UserIds, UserDetails]{ - @tags(["foo", "bar"]) - $email - - @tags(["baz"]) - $status -} - -operation ElidedGetUser { - input := with [UserIds, UserDetails] { - - @tags(["hello"]) - $id - optional: String - } - output := with [UserIds, UserDetails] { - @tags(["goodbye"]) - $status - - description: String - } -} - -enum Suit { - DIAMOND = "diamond" - CLUB = "club" - HEART = "heart" - SPADE = "spade" -} - -operation MyInlineOperationReversed { - output := { - baz: String - } - input := { - foo: String - } -} - -// The input and output match the name conventions for inline inputs and outputs, -// but are not actually inlined. -operation FalseInlined { - input: FalseInlinedFooInput - output: FalseInlinedBarOutput -} - -structure FalseInlinedFooInput { - a: String -} - -structure FalseInlinedBarOutput { - b: String -} - -operation FalseInlinedReversed { - output: FalseInlinedReversedBarOutput - input: FalseInlinedReversedFooInput -} - -structure FalseInlinedReversedFooInput { - c: String -} - -structure FalseInlinedReversedBarOutput { - d: String -} - -@trait -structure emptyTraitStruct {} - -operation ShortInputOutput { - output: ShortO - input: ShortI -} - -@input -structure ShortI { - c: String -} - -@output -structure ShortO { - d: String -} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy deleted file mode 100644 index 1ca2a8f9..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/preamble.smithy +++ /dev/null @@ -1,13 +0,0 @@ -$version: "2.0" -$operationInputSuffix: "Request" -$operationOutputSuffix: "Response" - -namespace ns.preamble - -use ns.foo#FooTrait - -use ns.bar#BarTrait - -@FooTrait -@BarTrait -string MyString diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy deleted file mode 100644 index 6654c3a3..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/test.smithy +++ /dev/null @@ -1,15 +0,0 @@ -$version: "2.0" - -namespace com.example - -use com.foo#emptyTraitStruct - -@emptyTraitStruct -structure OtherStructure { - foo: String - bar: String - baz: Integer -} - - - diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy b/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy deleted file mode 100644 index 3fcfbd3f..00000000 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/v2/trait-def.smithy +++ /dev/null @@ -1,79 +0,0 @@ -$version: "2.0" - -namespace test - -@trait -structure test { - @required - blob: Blob - - @required - bool: Boolean - - @requird - byte: Byte - - @required - short: Short - - @required - integer: Integer - - @required - long: Long - - @required - float: Float - - @required - double: Double - - @required - bigDecimal: BigDecimal - - @required - bigInteger: BigInteger - - @required - string: String - - @required - timestamp: Timestamp - - @required - list: ListA - - @required - map: MapA - - @required - struct: StructureA - - @required - union: UnionA -} - -list ListA { - member: String -} - -map MapA { - key: String - value: Integer -} - -structure StructureA { - @required - nested: StructureB -} - -structure StructureB { - @required - nestedMember: String -} - -union UnionA { - a: Integer - b: String - c: Timestamp -} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy new file mode 100644 index 00000000..c01994b4 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/bar.smithy @@ -0,0 +1,9 @@ +$version: "2.0" +namespace com.bar + +boolean MyBool + +@mixin +structure HasMyBool { + myBool: MyBool +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy new file mode 100644 index 00000000..99b60dd6 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/apply/model/foo.smithy @@ -0,0 +1,25 @@ +$version: "2.0" +namespace com.foo + +use com.bar#HasMyBool + +apply MyOpInput @tags(["foo"]) + +apply MyStruct$member @tags(["bar"]) + +structure MyOpInput with [HasMyBool] { + @required + body: String +} + +apply MyOpInput$myBool @documentation("docs") + +structure MyStruct { + member: String +} + +apply MyStruct @documentation("more docs") + +apply MyOpInput$body @documentation("even more docs") + +apply HasMyBool @tags(["baz"]) \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json new file mode 100644 index 00000000..905545df --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/apply/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["model"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/missing-version/smithy-build.json @@ -0,0 +1 @@ +{} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json new file mode 100644 index 00000000..a5f28129 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/parse-failure/smithy-build.json @@ -0,0 +1,3 @@ +{ + version +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json new file mode 100644 index 00000000..bc582239 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/source-doesnt-exist/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["missing.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json new file mode 100644 index 00000000..8071c461 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-maven-dependency/smithy-build.json @@ -0,0 +1,8 @@ +{ + "version": "1", + "maven": { + "dependencies": [ + "software.amazon.smithy.lsp:not-smithy-language-server:0.0.1" + ] + } +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json new file mode 100644 index 00000000..a9261493 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/broken/unresolvable-project-dependency/.smithy-project.json @@ -0,0 +1,8 @@ +{ + "dependencies": [ + { + "name": "doesn't exist", + "path": "./doesnt-exist.jar" + } + ] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json new file mode 100644 index 00000000..4c6a3109 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/.smithy.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "imports": ["main.smithy"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy new file mode 100644 index 00000000..b9febef1 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/main.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace main + +string Main diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy new file mode 100644 index 00000000..37608eb6 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/other.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace other + +string Other diff --git a/src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json new file mode 100644 index 00000000..3c4e9e0c --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/build-exts/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "imports": ["other.smithy"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/config-with-env.json b/src/test/resources/software/amazon/smithy/lsp/project/env-config/smithy-build.json similarity index 93% rename from src/test/resources/software/amazon/smithy/lsp/ext/config-with-env.json rename to src/test/resources/software/amazon/smithy/lsp/project/env-config/smithy-build.json index fd2ea93f..18b95311 100644 --- a/src/test/resources/software/amazon/smithy/lsp/ext/config-with-env.json +++ b/src/test/resources/software/amazon/smithy/lsp/project/env-config/smithy-build.json @@ -1,5 +1,5 @@ { - "version": "2.0", + "version": "1", "maven": { "repositories": [ { diff --git a/src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json new file mode 100644 index 00000000..d36ade43 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/.smithy-project.json @@ -0,0 +1,12 @@ +{ + "dependencies": [ + { + "name": "alloy-core", + "path": "./alloy-core.jar" + }, + { + "name": "smithy-test-traits", + "path": "./smithy-test-traits.jar" + } + ] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/alloy-core.jar b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/alloy-core.jar similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/alloy-core.jar rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/alloy-core.jar diff --git a/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json new file mode 100644 index 00000000..5cb61a6e --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["test-traits.smithy", "test-validators.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/smithy-test-traits.jar b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-test-traits.jar similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/smithy-test-traits.jar rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/smithy-test-traits.jar diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/test-traits.smithy b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-traits.smithy similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/test-traits.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-traits.smithy diff --git a/src/test/resources/software/amazon/smithy/lsp/external-jars/test-validators.smithy b/src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-validators.smithy similarity index 100% rename from src/test/resources/software/amazon/smithy/lsp/external-jars/test-validators.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/external-jars/test-validators.smithy diff --git a/src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy new file mode 100644 index 00000000..a7cc6e82 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/flat/main.smithy @@ -0,0 +1,9 @@ +$version: "2.0" + +namespace com.foo + +string Foo + +structure Abc { + foo: String +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json new file mode 100644 index 00000000..e80ed259 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/flat/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["main.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy new file mode 100644 index 00000000..db5569e9 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/main.smithy @@ -0,0 +1,9 @@ +$version: "2.0" + +namespace com.foo + +structure Foo { + bar: String +} + +structure A diff --git a/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json new file mode 100644 index 00000000..e80ed259 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/invalid-syntax/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["main.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json new file mode 100644 index 00000000..5bcb8be3 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config-with-conflicts/.smithy.json @@ -0,0 +1,16 @@ +{ + "version": "1", + "maven": { + "dependencies": ["dep1", "dep2"], + "repositories": [ + { + "url": "url1" + }, + { + "url": "url2" + } + ] + }, + "mavenRepositories": ["m1", "m2"], + "mavenDependencies": ["mdep1", "mdep2"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json new file mode 100644 index 00000000..20ed9aa0 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/legacy-config/.smithy.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "mavenRepositories": ["foo", "bar"], + "mavenDependencies": ["baz"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy new file mode 100644 index 00000000..638a01ed --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/main.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Foo diff --git a/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json new file mode 100644 index 00000000..ee714970 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/maven-dep/smithy-build.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "sources": ["main.smithy"], + "maven": { + "dependencies": [ + "software.amazon.smithy:smithy-smoke-test-traits:1.45.0" + ] + } +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/a.smithy b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/a.smithy similarity index 58% rename from src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/a.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/a.smithy index 40b1aad0..247452f5 100644 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/a.smithy +++ b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/a.smithy @@ -1,14 +1,12 @@ -$version: "2" +$version: "2.0" namespace a -operation HelloWorld { +operation Hello { input := { - @required name: String } output := { - @required name: String } } diff --git a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/b.smithy b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/b.smithy similarity index 53% rename from src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/b.smithy rename to src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/b.smithy index b37f9092..90a1a55f 100644 --- a/src/test/resources/software/amazon/smithy/lsp/ext/models/operation-name-conflict/b.smithy +++ b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/model/b.smithy @@ -1,16 +1,12 @@ -$version: "2" +$version: "2.0" namespace b -string Ignored - -operation HelloWorld { +operation Hello { input := { - @required name: String } output := { - @required name: String } } diff --git a/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json new file mode 100644 index 00000000..905545df --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/multiple-namespaces/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["model"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy new file mode 100644 index 00000000..638a01ed --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/main.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Foo diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy new file mode 100644 index 00000000..bfd9721c --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model/subdir/sub.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Bar diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy new file mode 100644 index 00000000..84eda2dd --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/sub2.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Baz diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy new file mode 100644 index 00000000..9ae8aac5 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/model2/subdir2/subsubdir/subsub.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Boz diff --git a/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json new file mode 100644 index 00000000..fde48f72 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/subdirs/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["model", "model2"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy new file mode 100644 index 00000000..56e5d606 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/main.smithy @@ -0,0 +1,6 @@ +$version: "2.0" + +namespace com.foo + +@unknown +structure Foo {} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json new file mode 100644 index 00000000..e80ed259 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unknown-trait/smithy-build.json @@ -0,0 +1,4 @@ +{ + "version": "1", + "sources": ["main.smithy"] +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json new file mode 100644 index 00000000..93c3a0a7 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/.smithy-project.json @@ -0,0 +1,8 @@ +{ + "dependencies": [ + { + "name": "smithy-test-traits", + "path": "./././/smithy-test-traits.jar" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy new file mode 100644 index 00000000..9662a7fe --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/one.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string One diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy new file mode 100644 index 00000000..4a24f099 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model/test-traits.smithy @@ -0,0 +1,26 @@ +$version: "1.0" + +namespace ns.test + +use smithy.test#test + +@test() +service Weather { + version: "2022-05-24", + operations: [GetCurrentTime] +} + +@readonly +operation GetCurrentTime { + input: GetCurrentTimeInput, + output: GetCurrentTimeOutput +} + +@input +structure GetCurrentTimeInput {} + +@output +structure GetCurrentTimeOutput { + @required + time: Timestamp, +} diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy new file mode 100644 index 00000000..578a80df --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model2/two.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Two diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy new file mode 100644 index 00000000..d7abefa2 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/model3/three.smithy @@ -0,0 +1,5 @@ +$version: "2.0" + +namespace com.foo + +string Three diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json new file mode 100644 index 00000000..4c2ee8e4 --- /dev/null +++ b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-build.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "sources": ["./model/", "model2////"], + "imports": ["././././model3//"] +} \ No newline at end of file diff --git a/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-test-traits.jar b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-test-traits.jar new file mode 100644 index 00000000..f775bfa6 Binary files /dev/null and b/src/test/resources/software/amazon/smithy/lsp/project/unnormalized-dirs/smithy-test-traits.jar differ