diff --git a/.gitignore b/.gitignore index d47eb117..5ae1ecbd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,4 @@ bin .settings .java-version -*.smithy -!/src/test/resources/**/*.smithy .ammonite \ No newline at end of file diff --git a/build.gradle b/build.gradle index 339f1131..e050c8ba 100644 --- a/build.gradle +++ b/build.gradle @@ -141,6 +141,9 @@ publishing { } } +checkstyle { + toolVersion = "10.12.4" +} dependencies { implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1" @@ -153,6 +156,8 @@ dependencies { testImplementation "org.hamcrest:hamcrest:2.2" testRuntimeOnly "org.junit.platform:junit-platform-launcher" + + checkstyle "com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}" } tasks.withType(Javadoc).all { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index fa284ede..c6658c32 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -182,7 +182,6 @@ - diff --git a/src/main/java/software/amazon/smithy/lsp/ServerState.java b/src/main/java/software/amazon/smithy/lsp/ServerState.java index 20f30733..9481d38d 100644 --- a/src/main/java/software/amazon/smithy/lsp/ServerState.java +++ b/src/main/java/software/amazon/smithy/lsp/ServerState.java @@ -96,7 +96,7 @@ ProjectAndFile findProjectAndFile(String uri) { String path = LspAdapter.toPath(uri); ProjectFile projectFile = detachedProject.getProjectFile(path); if (projectFile != null) { - return new ProjectAndFile(detachedProject, projectFile); + return new ProjectAndFile(uri, detachedProject, projectFile, true); } } @@ -133,17 +133,16 @@ private ProjectAndFile findAttachedAndRemoveDetached(String uri) { ProjectFile projectFile = project.getProjectFile(path); if (projectFile != null) { detachedProjects.remove(uri); - return new ProjectAndFile(project, projectFile); + return new ProjectAndFile(uri, project, projectFile, false); } } return null; } - Project createDetachedProject(String uri, String text) { + void createDetachedProject(String uri, String text) { Project project = ProjectLoader.loadDetached(uri, text); detachedProjects.put(uri, project); - return project; } List tryInitProject(Path root) { diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 5e5fb320..e78467b9 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -45,7 +45,6 @@ import org.eclipse.lsp4j.CompletionParams; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; -import org.eclipse.lsp4j.DiagnosticSeverity; import org.eclipse.lsp4j.DidChangeConfigurationParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidChangeWatchedFilesParams; @@ -74,7 +73,6 @@ import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.SetTraceParams; import org.eclipse.lsp4j.SymbolInformation; -import org.eclipse.lsp4j.SymbolKind; import org.eclipse.lsp4j.TextDocumentChangeRegistrationOptions; import org.eclipse.lsp4j.TextDocumentContentChangeEvent; import org.eclipse.lsp4j.TextDocumentIdentifier; @@ -100,26 +98,25 @@ import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; import software.amazon.smithy.lsp.diagnostics.SmithyDiagnostics; import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.document.DocumentParser; -import software.amazon.smithy.lsp.document.DocumentShape; import software.amazon.smithy.lsp.ext.OpenProject; import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.ext.ServerStatus; import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; -import software.amazon.smithy.lsp.handler.CompletionHandler; -import software.amazon.smithy.lsp.handler.DefinitionHandler; -import software.amazon.smithy.lsp.handler.HoverHandler; +import software.amazon.smithy.lsp.language.CompletionHandler; +import software.amazon.smithy.lsp.language.DefinitionHandler; +import software.amazon.smithy.lsp.language.DocumentSymbolHandler; +import software.amazon.smithy.lsp.language.HoverHandler; import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.loader.IdlTokenizer; import software.amazon.smithy.model.selector.Selector; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.syntax.Formatter; import software.amazon.smithy.syntax.TokenTree; import software.amazon.smithy.utils.IoUtils; @@ -163,6 +160,10 @@ ServerState getState() { return state; } + Severity getMinimumSeverity() { + return minimumSeverity; + } + @Override public void connect(LanguageClient client) { LOGGER.finest("Connect"); @@ -517,10 +518,11 @@ public void didChange(DidChangeTextDocumentParams params) { } // Don't reload or update the project on build file changes, only on save - if (projectAndFile.file() instanceof BuildFile) { + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { return; } + smithyFile.reparse(); if (!onlyReloadOnSave) { Project project = projectAndFile.project(); @@ -529,7 +531,7 @@ public void didChange(DidChangeTextDocumentParams params) { // Report any parse/shape/trait loading errors CompletableFuture future = CompletableFuture .runAsync(() -> project.updateModelWithoutValidating(uri)) - .thenComposeAsync(unused -> sendFileDiagnostics(uri)); + .thenComposeAsync(unused -> sendFileDiagnostics(projectAndFile)); state.lifecycleManager().putTask(uri, future); } } @@ -549,9 +551,10 @@ public void didOpen(DidOpenTextDocumentParams params) { projectAndFile.file().document().applyEdit(null, text); } else { state.createDetachedProject(uri, text); + projectAndFile = state.findProjectAndFile(uri); // Note: This will always be present } - state.lifecycleManager().putTask(uri, sendFileDiagnostics(uri)); + state.lifecycleManager().putTask(uri, sendFileDiagnostics(projectAndFile)); } @Override @@ -597,7 +600,7 @@ public void didSave(DidSaveTextDocumentParams params) { } else { CompletableFuture future = CompletableFuture .runAsync(() -> project.updateAndValidateModel(uri)) - .thenCompose(unused -> sendFileDiagnostics(uri)); + .thenCompose(unused -> sendFileDiagnostics(projectAndFile)); state.lifecycleManager().putTask(uri, future); } } @@ -613,15 +616,13 @@ public CompletableFuture, CompletionList>> completio return completedFuture(Either.forLeft(Collections.emptyList())); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { return completedFuture(Either.forLeft(List.of())); } Project project = projectAndFile.project(); - return CompletableFutures.computeAsync((cc) -> { - CompletionHandler handler = new CompletionHandler(project, smithyFile); - return Either.forLeft(handler.handle(params, cc)); - }); + var handler = new CompletionHandler(project, smithyFile); + return CompletableFutures.computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); } @Override @@ -643,54 +644,13 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return completedFuture(Collections.emptyList()); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile idlFile)) { return completedFuture(List.of()); } - return CompletableFutures.computeAsync((cc) -> { - Collection documentShapes = smithyFile.documentShapes(); - if (documentShapes.isEmpty()) { - return Collections.emptyList(); - } - - if (cc.isCanceled()) { - return Collections.emptyList(); - } - - List> documentSymbols = new ArrayList<>(documentShapes.size()); - for (DocumentShape documentShape : documentShapes) { - SymbolKind symbolKind; - switch (documentShape.kind()) { - case Inline: - // No shape name in the document text, so no symbol - continue; - case DefinedMember: - case Elided: - symbolKind = SymbolKind.Property; - break; - case DefinedShape: - case Targeted: - default: - symbolKind = SymbolKind.Class; - break; - } - - // Check before copying shapeName, which is actually a reference to the underlying document, and may - // be changed. - cc.checkCanceled(); - - String symbolName = documentShape.shapeName().toString(); - if (symbolName.isEmpty()) { - LOGGER.warning("[DocumentSymbols] Empty shape name for " + documentShape); - continue; - } - Range symbolRange = documentShape.range(); - DocumentSymbol symbol = new DocumentSymbol(symbolName, symbolKind, symbolRange, symbolRange); - documentSymbols.add(Either.forRight(symbol)); - } - - return documentSymbols; - }); + List statements = idlFile.getParse().statements(); + var handler = new DocumentSymbolHandler(idlFile.document(), statements); + return CompletableFuture.supplyAsync(handler::handle); } @Override @@ -705,13 +665,13 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return completedFuture(null); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { return completedFuture(null); } Project project = projectAndFile.project(); - List locations = new DefinitionHandler(project, smithyFile).handle(params); - return completedFuture(Either.forLeft(locations)); + var handler = new DefinitionHandler(project, smithyFile); + return CompletableFuture.supplyAsync(() -> Either.forLeft(handler.handle(params))); } @Override @@ -725,15 +685,15 @@ public CompletableFuture hover(HoverParams params) { return completedFuture(null); } - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { return completedFuture(null); } Project project = projectAndFile.project(); // TODO: Abstract away passing minimum severity - Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); - return completedFuture(hover); + var handler = new HoverHandler(project, smithyFile, minimumSeverity); + return CompletableFuture.supplyAsync(() -> handler.handle(params)); } @Override @@ -772,99 +732,16 @@ public CompletableFuture> formatting(DocumentFormatting private void sendFileDiagnosticsForManagedDocuments() { for (String managedDocumentUri : state.managedUris()) { - state.lifecycleManager().putOrComposeTask(managedDocumentUri, sendFileDiagnostics(managedDocumentUri)); + ProjectAndFile projectAndFile = state.findProjectAndFile(managedDocumentUri); + state.lifecycleManager().putOrComposeTask(managedDocumentUri, sendFileDiagnostics(projectAndFile)); } } - private CompletableFuture sendFileDiagnostics(String uri) { + private CompletableFuture sendFileDiagnostics(ProjectAndFile projectAndFile) { return CompletableFuture.runAsync(() -> { - List diagnostics = getFileDiagnostics(uri); - PublishDiagnosticsParams publishDiagnosticsParams = new PublishDiagnosticsParams(uri, diagnostics); + List diagnostics = SmithyDiagnostics.getFileDiagnostics(projectAndFile, minimumSeverity); + var publishDiagnosticsParams = new PublishDiagnosticsParams(projectAndFile.uri(), diagnostics); client.publishDiagnostics(publishDiagnosticsParams); }); } - - List getFileDiagnostics(String uri) { - if (LspAdapter.isJarFile(uri) || LspAdapter.isSmithyJarFile(uri)) { - // Don't send diagnostics to jar files since they can't be edited - // and diagnostics could be misleading. - return Collections.emptyList(); - } - - ProjectAndFile projectAndFile = state.findProjectAndFile(uri); - if (projectAndFile == null) { - client.unknownFileError(uri, "diagnostics"); - return List.of(); - } - - if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { - return List.of(); - } - - Project project = projectAndFile.project(); - String path = LspAdapter.toPath(uri); - - List diagnostics = project.modelResult().getValidationEvents().stream() - .filter(validationEvent -> validationEvent.getSeverity().compareTo(minimumSeverity) >= 0) - .filter(validationEvent -> validationEvent.getSourceLocation().getFilename().equals(path)) - .map(validationEvent -> toDiagnostic(validationEvent, smithyFile)) - .collect(Collectors.toCollection(ArrayList::new)); - - Diagnostic versionDiagnostic = SmithyDiagnostics.versionDiagnostic(smithyFile); - if (versionDiagnostic != null) { - diagnostics.add(versionDiagnostic); - } - - if (state.isDetached(uri)) { - diagnostics.add(SmithyDiagnostics.detachedDiagnostic(smithyFile)); - } - - return diagnostics; - } - - private static Diagnostic toDiagnostic(ValidationEvent validationEvent, SmithyFile smithyFile) { - DiagnosticSeverity severity = toDiagnosticSeverity(validationEvent.getSeverity()); - SourceLocation sourceLocation = validationEvent.getSourceLocation(); - Range range = determineRange(validationEvent, sourceLocation, smithyFile); - String message = validationEvent.getId() + ": " + validationEvent.getMessage(); - return new Diagnostic(range, message, severity, "Smithy"); - } - - private static Range determineRange(ValidationEvent validationEvent, - SourceLocation sourceLocation, - SmithyFile smithyFile) { - final Range defaultRange = LspAdapter.lineOffset(LspAdapter.toPosition(sourceLocation)); - - if (smithyFile == null) { - return defaultRange; - } - - DocumentParser parser = DocumentParser.forDocument(smithyFile.document()); - - // Case where we have shapes present - if (validationEvent.getShapeId().isPresent()) { - // Event is (probably) on a member target - if (validationEvent.containsId("Target")) { - DocumentShape documentShape = smithyFile.documentShapesByStartPosition() - .get(LspAdapter.toPosition(sourceLocation)); - if (documentShape != null && documentShape.hasMemberTarget()) { - return documentShape.targetReference().range(); - } - } else { - // Check if the event location is on a trait application - return Objects.requireNonNullElse(parser.traitIdRange(sourceLocation), defaultRange); - } - } - - return Objects.requireNonNullElse(parser.findContiguousRange(sourceLocation), defaultRange); - } - - private static DiagnosticSeverity toDiagnosticSeverity(Severity severity) { - return switch (severity) { - case ERROR, DANGER -> DiagnosticSeverity.Error; - case WARNING -> DiagnosticSeverity.Warning; - case NOTE -> DiagnosticSeverity.Information; - default -> DiagnosticSeverity.Hint; - }; - } } diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java index 2f4452d8..54459b17 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java @@ -5,17 +5,28 @@ package software.amazon.smithy.lsp.diagnostics; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DiagnosticCodeDescription; import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; -import software.amazon.smithy.lsp.document.DocumentVersion; +import software.amazon.smithy.lsp.document.DocumentParser; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectAndFile; import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; /** - * Utility class for creating different kinds of file diagnostics, that aren't - * necessarily connected to model validation events. + * Creates diagnostics for Smithy files. */ public final class SmithyDiagnostics { public static final String UPDATE_VERSION = "migrating-idl-1-to-2"; @@ -29,38 +40,65 @@ private SmithyDiagnostics() { } /** - * Creates a diagnostic for when a $version control statement hasn't been defined, - * or when it has been defined for IDL 1.0. - * - * @param smithyFile The Smithy file to get a version diagnostic for - * @return The version diagnostic associated with the Smithy file, or null - * if one doesn't exist + * @param projectAndFile Project and file to get diagnostics for + * @param minimumSeverity Minimum severity of validation events to diagnose + * @return A list of diagnostics for the given project and file */ - public static Diagnostic versionDiagnostic(SmithyFile smithyFile) { - if (smithyFile.documentVersion().isPresent()) { - DocumentVersion documentVersion = smithyFile.documentVersion().get(); - if (!documentVersion.version().startsWith("2")) { - Diagnostic diagnostic = createDiagnostic( - documentVersion.range(), "You can upgrade to idl version 2.", UPDATE_VERSION); - diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); - return diagnostic; - } - } else if (smithyFile.document() != null) { + public static List getFileDiagnostics(ProjectAndFile projectAndFile, Severity minimumSeverity) { + if (LspAdapter.isJarFile(projectAndFile.uri()) || LspAdapter.isSmithyJarFile(projectAndFile.uri())) { + // Don't send diagnostics to jar files since they can't be edited + // and diagnostics could be misleading. + return List.of(); + } + + if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) { + return List.of(); + } + + Project project = projectAndFile.project(); + String path = projectAndFile.file().path(); + + EventToDiagnostic eventToDiagnostic = eventToDiagnostic(smithyFile); + + List diagnostics = project.modelResult().getValidationEvents().stream() + .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0 + && event.getSourceLocation().getFilename().equals(path)) + .map(eventToDiagnostic::toDiagnostic) + .collect(Collectors.toCollection(ArrayList::new)); + + Diagnostic versionDiagnostic = versionDiagnostic(smithyFile); + if (versionDiagnostic != null) { + diagnostics.add(versionDiagnostic); + } + + if (projectAndFile.isDetached()) { + diagnostics.add(detachedDiagnostic(smithyFile)); + } + + return diagnostics; + } + + private static Diagnostic versionDiagnostic(SmithyFile smithyFile) { + if (!(smithyFile instanceof IdlFile idlFile)) { + return null; + } + + Syntax.IdlParseResult syntaxInfo = idlFile.getParse(); + if (syntaxInfo.version().version().startsWith("2")) { + return null; + } else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) { + var diagnostic = createDiagnostic( + syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION); + diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION); + return diagnostic; + } else { int end = smithyFile.document().lineEnd(0); Range range = LspAdapter.lineSpan(0, 0, end); return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION); } - return null; } - /** - * Creates a diagnostic for when a Smithy file is not connected to a - * Smithy project via smithy-build.json or other build file. - * - * @param smithyFile The Smithy file to get a detached diagnostic for - * @return The detached diagnostic associated with the Smithy file - */ - public static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { + private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { Range range; if (smithyFile.document() == null) { range = LspAdapter.origin(); @@ -75,4 +113,71 @@ public static Diagnostic detachedDiagnostic(SmithyFile smithyFile) { private static Diagnostic createDiagnostic(Range range, String title, String code) { return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code); } + + private static EventToDiagnostic eventToDiagnostic(SmithyFile smithyFile) { + if (!(smithyFile instanceof IdlFile idlFile)) { + return new Simple(); + } + + var idlParse = idlFile.getParse(); + var view = StatementView.createAtStart(idlParse).orElse(null); + if (view == null) { + return new Simple(); + } else { + var documentParser = DocumentParser.forStatements(smithyFile.document(), view.parseResult().statements()); + return new Idl(view, documentParser); + } + } + + private sealed interface EventToDiagnostic { + default Range getDiagnosticRange(ValidationEvent event) { + var start = LspAdapter.toPosition(event.getSourceLocation()); + var end = LspAdapter.toPosition(event.getSourceLocation()); + end.setCharacter(end.getCharacter() + 1); // Range is exclusive + + return new Range(start, end); + } + + default Diagnostic toDiagnostic(ValidationEvent event) { + var diagnosticSeverity = switch (event.getSeverity()) { + case ERROR, DANGER -> DiagnosticSeverity.Error; + case WARNING -> DiagnosticSeverity.Warning; + case NOTE -> DiagnosticSeverity.Information; + default -> DiagnosticSeverity.Hint; + }; + var diagnosticRange = getDiagnosticRange(event); + var message = event.getId() + ": " + event.getMessage(); + return new Diagnostic(diagnosticRange, message, diagnosticSeverity, "Smithy"); + } + } + + private record Simple() implements EventToDiagnostic {} + + private record Idl(StatementView view, DocumentParser parser) implements EventToDiagnostic { + @Override + public Range getDiagnosticRange(ValidationEvent event) { + Position eventStart = LspAdapter.toPosition(event.getSourceLocation()); + final Range defaultRange = EventToDiagnostic.super.getDiagnosticRange(event); + + if (event.getShapeId().isPresent()) { + int eventStartIndex = parser.getDocument().indexOfPosition(eventStart); + Syntax.Statement statement = view.getStatementAt(eventStartIndex).orElse(null); + + if (statement instanceof Syntax.Statement.MemberDef def + && event.containsId("Target") + && def.target() != null) { + Range targetRange = LspAdapter.identRange(def.target(), parser.getDocument()); + return Objects.requireNonNullElse(targetRange, defaultRange); + } else if (statement instanceof Syntax.Statement.TraitApplication app) { + Range traitIdRange = LspAdapter.identRange(app.id(), parser.getDocument()); + if (traitIdRange != null) { + traitIdRange.getStart().setCharacter(traitIdRange.getStart().getCharacter() - 1); // include @ + } + return Objects.requireNonNullElse(traitIdRange, defaultRange); + } + } + + return Objects.requireNonNullElse(parser.findContiguousRange(event.getSourceLocation()), defaultRange); + } + } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java index 75ee0e15..74c3f0c7 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -97,20 +97,31 @@ public int indexOfLine(int line) { * doesn't exist */ public int lineOfIndex(int idx) { - // TODO: Use binary search or similar - if (idx >= length() || idx < 0) { - return -1; - } - - for (int line = 0; line <= lastLine() - 1; line++) { - int currentLineIdx = indexOfLine(line); - int nextLineIdx = indexOfLine(line + 1); - if (idx >= currentLineIdx && idx < nextLineIdx) { - return line; + int low = 0; + int up = lastLine(); + + while (low <= up) { + int mid = (low + up) / 2; + int midLineIdx = lineIndices[mid]; + int midLineEndIdx = lineEndUnchecked(mid); + if (idx >= midLineIdx && idx <= midLineEndIdx) { + return mid; + } else if (idx < midLineIdx) { + up = mid - 1; + } else { + low = mid + 1; } } - return lastLine(); + return -1; + } + + private int lineEndUnchecked(int line) { + if (line == lastLine()) { + return length() - 1; + } else { + return lineIndices[line + 1] - 1; + } } /** @@ -167,6 +178,34 @@ public Position positionAtIndex(int index) { return new Position(line, character); } + /** + * @param start The start character offset + * @param end The end character offset + * @return The range between the two given offsets + */ + public Range rangeBetween(int start, int end) { + if (end < start || start < 0) { + return null; + } + + // The start is inclusive, so it should be within the bounds of the document + Position startPos = positionAtIndex(start); + if (startPos == null) { + return null; + } + + Position endPos; + if (end == length()) { + int lastLine = lastLine(); + int lastCol = length() - lineIndices[lastLine]; + endPos = new Position(lastLine, lastCol); + } else { + endPos = positionAtIndex(end); + } + + return new Range(startPos, endPos); + } + /** * @param line The line to find the end of * @return The index of the end of the given line, or {@code -1} if the @@ -220,23 +259,6 @@ public int lastIndexOf(String s, int before) { return buffer.lastIndexOf(s, before); } - /** - * @param c The character to find the last index of - * @param before The index to stop the search at - * @param line The line to search within - * @return The index of the last occurrence of {@code c} before {@code before} - * on the line {@code line} or {@code -1} if one doesn't exist - */ - int lastIndexOfOnLine(char c, int before, int line) { - int lineIdx = indexOfLine(line); - for (int i = before; i >= lineIdx; i--) { - if (buffer.charAt(i) == c) { - return i; - } - } - return -1; - } - /** * @return A reference to the text in this document */ @@ -312,19 +334,6 @@ public CharBuffer borrowToken(Position position) { return CharBuffer.wrap(buffer, startIdx + 1, endIdx); } - /** - * @param position The position within the id to borrow - * @return A reference to the id that the given {@code position} is - * within, or {@code null} if the position is not within an id - */ - public CharBuffer borrowId(Position position) { - DocumentId id = copyDocumentId(position); - if (id == null) { - return null; - } - return id.idSlice(); - } - /** * @param line The line to borrow * @return A reference to the text in the given line, or {@code null} if @@ -383,32 +392,6 @@ public String copyRange(Range range) { return borrowed.toString(); } - /** - * @param position The position within the token to copy - * @return A copy of the token that the given {@code position} is within, - * or {@code null} if the position is not within a token - */ - public String copyToken(Position position) { - CharSequence token = borrowToken(position); - if (token == null) { - return null; - } - return token.toString(); - } - - /** - * @param position The position within the id to copy - * @return A copy of the id that the given {@code position} is - * within, or {@code null} if the position is not within an id - */ - public String copyId(Position position) { - CharBuffer id = borrowId(position); - if (id == null) { - return null; - } - return id.toString(); - } - /** * @param position The position within the id to get * @return A new id that the given {@code position} is @@ -495,10 +478,18 @@ public DocumentId copyDocumentId(Position position) { type = DocumentId.Type.ID; } - int actualStartIdx = startIdx + 1; // because we go past the actual start in the loop - CharBuffer wrapped = CharBuffer.wrap(buffer, actualStartIdx, endIdx); // endIdx here is non-inclusive - Position start = positionAtIndex(actualStartIdx); - Position end = positionAtIndex(endIdx - 1); // because we go pas the actual end in the loop + // We go past the start and end in each loop, so startIdx is before the start character, and endIdx + // is after the end character. + int startCharIdx = startIdx + 1; + int endCharIdx = endIdx - 1; + + // For creating the buffer and the range, the start is inclusive, and the end is exclusive. + CharBuffer wrapped = CharBuffer.wrap(buffer, startCharIdx, endCharIdx + 1); + Position start = positionAtIndex(startCharIdx); + // However, we can't get the position for an index that may be out of bounds, so we need to make + // the end position exclusive manually. + Position end = positionAtIndex(endCharIdx); + end.setCharacter(end.getCharacter() + 1); Range range = new Range(start, end); return new DocumentId(type, wrapped, range); } @@ -507,19 +498,6 @@ private static boolean isIdChar(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.'; } - /** - * @param line The line to copy - * @return A copy of the text in the given line, or {@code null} if the line - * doesn't exist - */ - public String copyLine(int line) { - CharBuffer borrowed = borrowLine(line); - if (borrowed == null) { - return null; - } - return borrowed.toString(); - } - /** * @param start The index of the start of the span to copy * @param end The index of the end of the span to copy @@ -541,18 +519,6 @@ public int length() { return buffer.length(); } - /** - * @param index The index to get the character at - * @return The character at the given index, or {@code \u0000} if one - * doesn't exist - */ - char charAt(int index) { - if (index < 0 || index >= length()) { - return '\u0000'; - } - return buffer.charAt(index); - } - // Adapted from String::split private static int[] computeLineIndicies(StringBuilder buffer) { int matchCount = 0; diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java index ec7c5f39..f20dd67b 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java @@ -51,4 +51,15 @@ public enum Type { public String copyIdValue() { return idSlice.toString(); } + + /** + * @return The value of the id without a leading '$' + */ + public String copyIdValueForElidedMember() { + String idValue = copyIdValue(); + if (idValue.startsWith("$")) { + return idValue.substring(1); + } + return idValue; + } } diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java index 0c5d9c60..47eefd7e 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentImports.java @@ -7,6 +7,7 @@ import java.util.Set; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The imports of a document, including the range they occupy. @@ -14,4 +15,6 @@ * @param importsRange The range of the imports * @param imports The set of imported shape ids. They are not guaranteed to be valid shape ids */ -public record DocumentImports(Range importsRange, Set imports) {} +public record DocumentImports(Range importsRange, Set imports) { + static final DocumentImports EMPTY = new DocumentImports(LspAdapter.origin(), Set.of()); +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java index 94c8b79b..d6e6ce39 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java @@ -6,6 +6,7 @@ package software.amazon.smithy.lsp.document; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.protocol.LspAdapter; /** * The namespace of the document, including the range it occupies. @@ -13,4 +14,6 @@ * @param statementRange The range of the statement, including {@code namespace} * @param namespace The namespace of the document. Not guaranteed to be a valid namespace */ -public record DocumentNamespace(Range statementRange, CharSequence namespace) {} +public record DocumentNamespace(Range statementRange, String namespace) { + static final DocumentNamespace NONE = new DocumentNamespace(LspAdapter.origin(), ""); +} diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java index d311e03e..6b322ee6 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -5,303 +5,112 @@ package software.amazon.smithy.lsp.document; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; +import java.util.List; import java.util.Set; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.loader.ParserUtils; -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.node.StringNode; -import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.utils.SimpleParser; /** - * 'Parser' that uses the line-indexed property of the underlying {@link Document} - * to jump around the document, parsing small pieces without needing to start at - * the beginning. - * - *

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

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

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

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

+ * + *

See the Smithy model for docs on the specific shapes.

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

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

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

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

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

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

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

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

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

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

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

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

+ *

IDL Syntax

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

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

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

+ *

Node Syntax

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

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

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

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

+ * + * @param raw The raw string with position markers + * @return {@link TextWithPositions} with positions where the markers were, + * and those markers removed. + */ + public static TextWithPositions from(String raw) { + Document document = Document.of(safeString(raw)); + List positions = new ArrayList<>(); + int i = 0; + while (true) { + int next = document.nextIndexOf(POSITION_MARKER, i); + if (next < 0) { + break; + } + Position position = document.positionAtIndex(next); + positions.add(position); + i = next + 1; + } + String text = document.copyText().replace(POSITION_MARKER, ""); + return new TextWithPositions(text, positions.toArray(new Position[0])); + }} diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java index 27e31ed6..814882e5 100644 --- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java @@ -6,21 +6,13 @@ package software.amazon.smithy.lsp.document; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static software.amazon.smithy.lsp.document.DocumentTest.safeIndex; import static software.amazon.smithy.lsp.document.DocumentTest.safeString; -import static software.amazon.smithy.lsp.document.DocumentTest.string; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import java.util.stream.Stream; - import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.junit.jupiter.api.Test; @@ -28,99 +20,9 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import software.amazon.smithy.lsp.protocol.LspAdapter; -import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceLocation; -import software.amazon.smithy.model.shapes.Shape; public class DocumentParserTest { - @Test - public void jumpsToLines() { - String text = """ - abc - def - ghi - - - """; - DocumentParser parser = DocumentParser.of(safeString(text)); - assertEquals(0, parser.position()); - assertEquals(1, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(0); - assertEquals(0, parser.position()); - assertEquals(1, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(1); - assertEquals(safeIndex(4, 1), parser.position()); - assertEquals(2, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(2); - assertEquals(safeIndex(8, 2), parser.position()); - assertEquals(3, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(3); - assertEquals(safeIndex(12, 3), parser.position()); - assertEquals(4, parser.line()); - assertEquals(1, parser.column()); - - parser.jumpToLine(4); - assertEquals(safeIndex(13, 4), parser.position()); - assertEquals(5, parser.line()); - assertEquals(1, parser.column()); - } - - @Test - public void jumpsToSource() { - String text = "abc\ndef\nghi\n"; - DocumentParser parser = DocumentParser.of(safeString(text)); - assertThat(parser.position(), is(0)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(1)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 0))); - - boolean ok = parser.jumpToSource(new SourceLocation("", 1, 2)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(1)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(2)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 1))); - - ok = parser.jumpToSource(new SourceLocation("", 1, 4)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(3)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - - ok = parser.jumpToSource(new SourceLocation("", 1, 6)); - assertThat(ok, is(false)); - assertThat(parser.position(), is(3)); - assertThat(parser.line(), is(1)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(0, 3))); - - ok = parser.jumpToSource(new SourceLocation("", 2, 1)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(safeIndex(4, 1))); - assertThat(parser.line(), is(2)); - assertThat(parser.column(), is(1)); - assertThat(parser.currentPosition(), equalTo(new Position(1, 0))); - - ok = parser.jumpToSource(new SourceLocation("", 4, 1)); - assertThat(ok, is(false)); - - ok = parser.jumpToSource(new SourceLocation("", 3, 4)); - assertThat(ok, is(true)); - assertThat(parser.position(), is(safeIndex(11, 2))); - assertThat(parser.line(), is(3)); - assertThat(parser.column(), is(4)); - assertThat(parser.currentPosition(), equalTo(new Position(2, 3))); - } - @Test public void getsDocumentNamespace() { DocumentParser noNamespace = DocumentParser.of(safeString("abc\ndef\n")); @@ -135,20 +37,20 @@ public void getsDocumentNamespace() { DocumentParser notNamespace = DocumentParser.of(safeString("namespace !foo")); DocumentParser trailingComment = DocumentParser.of(safeString("namespace com.foo//foo\n")); - assertThat(noNamespace.documentNamespace(), nullValue()); - assertThat(incompleteNamespace.documentNamespace(), nullValue()); - assertThat(incompleteNamespaceValue.documentNamespace(), nullValue()); - assertThat(likeNamespace.documentNamespace(), nullValue()); - assertThat(otherLikeNamespace.documentNamespace(), nullValue()); - assertThat(namespaceAtEnd.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(noNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteNamespaceValue.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(likeNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(otherLikeNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(namespaceAtEnd.documentNamespace().namespace(), equalTo("com.foo")); assertThat(namespaceAtEnd.documentNamespace().statementRange(), equalTo(LspAdapter.of(2, 0, 2, 17))); - assertThat(brokenNamespace.documentNamespace(), nullValue()); - assertThat(commentedNamespace.documentNamespace(), nullValue()); - assertThat(wsPrefixedNamespace.documentNamespace().namespace().toString(), equalTo("com.foo")); + assertThat(brokenNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(commentedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(wsPrefixedNamespace.documentNamespace().namespace(), equalTo("com.foo")); assertThat(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.of(1, 4, 1, 21))); - assertThat(notNamespace.documentNamespace(), nullValue()); - assertThat(trailingComment.documentNamespace().namespace().toString(), equalTo("com.foo")); - assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 22))); + assertThat(notNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(trailingComment.documentNamespace().namespace(), equalTo("com.foo")); + assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 17))); } @Test @@ -163,15 +65,15 @@ public void getsDocumentImports() { DocumentParser multiImports = DocumentParser.of(safeString("use com.foo#bar\nuse com.foo#baz")); DocumentParser notImport = DocumentParser.of(safeString("usea com.foo#bar")); - assertThat(noImports.documentImports(), nullValue()); - assertThat(incompleteImport.documentImports(), nullValue()); - assertThat(incompleteImportValue.documentImports(), nullValue()); + assertThat(noImports.documentImports().importsRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); + assertThat(incompleteImportValue.documentImports().importsRange(), equalTo(LspAdapter.origin())); assertThat(oneImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); assertThat(leadingWsImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); assertThat(trailingCommentImport.documentImports().imports(), containsInAnyOrder("com.foo#bar")); - assertThat(commentedImport.documentImports(), nullValue()); + assertThat(commentedImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); assertThat(multiImports.documentImports().imports(), containsInAnyOrder("com.foo#bar", "com.foo#baz")); - assertThat(notImport.documentImports(), nullValue()); + assertThat(notImport.documentImports().importsRange(), equalTo(LspAdapter.origin())); // Some of these aren't shape ids, but its ok DocumentParser brokenImport = DocumentParser.of(safeString("use com.foo")); @@ -203,20 +105,20 @@ public void getsDocumentVersion() { DocumentParser notSecond = DocumentParser.of(safeString("$foo: \"bar\"\n$bar: 1\n// abc\n$baz: 2\n $version: \"2\"")); DocumentParser notFirstNoVersion = DocumentParser.of(safeString("$foo: \"bar\"\nfoo\n")); - assertThat(noVersion.documentVersion(), nullValue()); - assertThat(notVersion.documentVersion(), nullValue()); - assertThat(noDollar.documentVersion(), nullValue()); - assertThat(noColon.documentVersion(), nullValue()); - assertThat(commented.documentVersion(), nullValue()); + assertThat(noVersion.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(notVersion.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(noDollar.documentVersion().range(), equalTo(LspAdapter.origin())); + assertThat(noColon.documentVersion().version(), equalTo("2")); + assertThat(commented.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(leadingWs.documentVersion().version(), equalTo("2")); assertThat(leadingLines.documentVersion().version(), equalTo("2")); - assertThat(notStringNode.documentVersion(), nullValue()); + assertThat(notStringNode.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(trailingComment.documentVersion().version(), equalTo("2")); assertThat(trailingLine.documentVersion().version(), equalTo("2")); - assertThat(invalidNode.documentVersion(), nullValue()); + assertThat(invalidNode.documentVersion().range(), equalTo(LspAdapter.origin())); assertThat(notFirst.documentVersion().version(), equalTo("2")); assertThat(notSecond.documentVersion().version(), equalTo("2")); - assertThat(notFirstNoVersion.documentVersion(), nullValue()); + assertThat(notFirstNoVersion.documentVersion().range(), equalTo(LspAdapter.origin())); Range leadingWsRange = leadingWs.documentVersion().range(); Range trailingCommentRange = trailingComment.documentVersion().range(); @@ -230,102 +132,6 @@ public void getsDocumentVersion() { assertThat(notSecond.getDocument().copyRange(notSecondRange), equalTo("$version: \"2\"")); } - @Test - public void getsDocumentShapes() { - String text = """ - $version: "2" - namespace com.foo - string Foo - structure Bar { - bar: Foo - } - enum Baz { - ONE - TWO - } - intEnum Biz { - ONE = 1 - } - @mixin - structure Boz { - elided: String - } - structure Mixed with [Boz] { - $elided - } - operation Get { - input := { - a: Integer - } - } - """; - Set shapes = Model.assembler() - .addUnparsedModel("main.smithy", text) - .assemble() - .unwrap() - .shapes() - .filter(shape -> shape.getId().getNamespace().equals("com.foo")) - .collect(Collectors.toSet()); - - DocumentParser parser = DocumentParser.of(safeString(text)); - Map documentShapes = parser.documentShapes(shapes); - - DocumentShape fooDef = documentShapes.get(new Position(2, 7)); - DocumentShape barDef = documentShapes.get(new Position(3, 10)); - DocumentShape barMemberDef = documentShapes.get(new Position(4, 4)); - DocumentShape targetFoo = documentShapes.get(new Position(4, 9)); - DocumentShape bazDef = documentShapes.get(new Position(6, 5)); - DocumentShape bazOneDef = documentShapes.get(new Position(7, 4)); - DocumentShape bazTwoDef = documentShapes.get(new Position(8, 4)); - DocumentShape bizDef = documentShapes.get(new Position(10, 8)); - DocumentShape bizOneDef = documentShapes.get(new Position(11, 4)); - DocumentShape bozDef = documentShapes.get(new Position(14, 10)); - DocumentShape elidedDef = documentShapes.get(new Position(15, 4)); - DocumentShape targetString = documentShapes.get(new Position(15, 12)); - DocumentShape mixedDef = documentShapes.get(new Position(17, 10)); - DocumentShape elided = documentShapes.get(new Position(18, 4)); - DocumentShape get = documentShapes.get(new Position(20, 10)); - DocumentShape getInput = documentShapes.get(new Position(21, 13)); - DocumentShape getInputA = documentShapes.get(new Position(22, 8)); - - assertThat(fooDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(fooDef.shapeName(), string("Foo")); - assertThat(barDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(barDef.shapeName(), string("Bar")); - assertThat(barMemberDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(barMemberDef.shapeName(), string("bar")); - assertThat(barMemberDef.targetReference(), equalTo(targetFoo)); - assertThat(targetFoo.kind(), equalTo(DocumentShape.Kind.Targeted)); - assertThat(targetFoo.shapeName(), string("Foo")); - assertThat(bazDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(bazDef.shapeName(), string("Baz")); - assertThat(bazOneDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(bazOneDef.shapeName(), string("ONE")); - assertThat(bazTwoDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(bazTwoDef.shapeName(), string("TWO")); - assertThat(bizDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(bizDef.shapeName(), string("Biz")); - assertThat(bizOneDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(bizOneDef.shapeName(), string("ONE")); - assertThat(bozDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(bozDef.shapeName(), string("Boz")); - assertThat(elidedDef.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(elidedDef.shapeName(), string("elided")); - assertThat(elidedDef.targetReference(), equalTo(targetString)); - assertThat(targetString.kind(), equalTo(DocumentShape.Kind.Targeted)); - assertThat(targetString.shapeName(), string("String")); - assertThat(mixedDef.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(mixedDef.shapeName(), string("Mixed")); - assertThat(elided.kind(), equalTo(DocumentShape.Kind.Elided)); - assertThat(elided.shapeName(), string("elided")); - assertThat(parser.getDocument().borrowRange(elided.range()), string("$elided")); - assertThat(get.kind(), equalTo(DocumentShape.Kind.DefinedShape)); - assertThat(get.shapeName(), string("Get")); - assertThat(getInput.kind(), equalTo(DocumentShape.Kind.Inline)); - assertThat(getInputA.kind(), equalTo(DocumentShape.Kind.DefinedMember)); - assertThat(getInputA.shapeName(), string("a")); - } - @ParameterizedTest @MethodSource("contiguousRangeTestCases") public void findsContiguousRange(SourceLocation input, Range expected) { diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java new file mode 100644 index 00000000..b5d3e324 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -0,0 +1,1104 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.LspMatchers; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.document.Document; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; + +public class CompletionHandlerTest { + @Test + public void getsCompletions() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar", "baz")); + } + + @Test + public void completesTraitMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(bar: %) + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"")); + } + + @Test + public void completesMetadataMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace:% + }]"""); + List comps = getCompLabels(text); + + assertThat(comps, not(empty())); + } + + @Test + public void doesntDuplicateTraitBodyMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: String + } + + @foo(bar: "", ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void doesntDuplicateMetadataMembers() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace: "foo" + %}] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "reason")); + } + + @Test + public void doesntDuplicateListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + list L { + member: String + %} + map M { + key: String + %} + """); + List comps = getCompLabels(text); + + + assertThat(comps, contains("value")); + } + + @Test + public void doesntDuplicateOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + operation O { + input := {} + %} + """); + List comps = getCompLabels(text); + assertThat(comps, containsInAnyOrder("output", "errors")); + } + + @Test + public void doesntDuplicateServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + service S { + version: "2024-08-31" + operations: [] + %} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("rename", "resources", "errors")); + } + + @Test + public void doesntDuplicateResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + resource R { + identifiers: {} + properties: {} + read: Op + create: Op + %} + + operation Op {} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "list", "put", "delete", "update", "collectionOperations", "operations", "resources")); + } + + @Test + public void completesEnumTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + enum foo { + ONE + TWO + THREE + } + + @foo(T%) + """); + List comps = getCompItems(text.text(), text.positions()); + + List labels = comps.stream().map(CompletionItem::getLabel).toList(); + List editText = comps.stream() + .map(completionItem -> { + if (completionItem.getTextEdit() != null) { + return completionItem.getTextEdit().getLeft().getNewText(); + } else { + return completionItem.getInsertText(); + } + }).toList(); + + assertThat(labels, containsInAnyOrder("TWO", "THREE")); + assertThat(editText, containsInAnyOrder("\"TWO\"", "\"THREE\"")); + // TODO: Fix this issue where the string is inserted within the enclosing "" + } + + @Test + public void completesFromSingleCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @http(m%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method")); + } + + @Test + public void completesBuiltinControlKeys() { + TextWithPositions text = TextWithPositions.from(""" + $ver% + $ope%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + startsWith("$version: \"2.0\""), + startsWith("$operationInputSuffix: \"Input\""), + startsWith("$operationOutputSuffix: \"Output\""))); + } + + @Test + public void completesBuiltinMetadataKeys() { + TextWithPositions text = TextWithPositions.from(""" + metadata su% + metadata va%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("suppressions = []", "validators = []")); + } + + @Test + public void completesStatementKeywords() { + TextWithPositions text = TextWithPositions.from(""" + us% + ma% + met% + nam% + blo% + boo% + str% + byt% + sho% + int% + lon% + flo% + dou% + big% + tim% + doc% + enu% + lis% + uni% + ser% + res% + ope% + app%"""); + List comps = getCompLabels(text); + + String[] keywords = CompletionCandidates.KEYWORD.literals().toArray(new String[0]); + assertThat(comps, containsInAnyOrder(keywords)); + } + + @Test + public void completesServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + service One { + ver% + ope% + res% + err% + ren% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("version", "operations", "resources", "errors", "rename")); + } + + @Test + public void completesResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + resource A { + ide% + pro% + cre% + pu% + rea% + upd% + del% + lis% + ope% + coll% + res% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "identifiers", + "properties", + "create", + "put", + "read", + "update", + "delete", + "list", + "operations", + "collectionOperations", + "resources")); + } + + @Test + public void completesOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + operation Op { + inp% + out% + err% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("input", "output", "errors")); + } + + @Test + public void completesListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + map M { + k% + v% + } + list L { + m% + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("key", "value", "member")); + } + + @Test + public void completesMetadataValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata validators = [{ nam% }] + metadata suppressions = [{ rea% }] + metadata severityOverrides = [{ sev% }] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("namespaces", "name", "reason", "severity")); + } + + @Test + public void completesMetadataValueWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "namespace", "reason")); + } + + @Test + public void completesTraitValueWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + @http(% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method", "uri", "code")); + } + + @Test + public void completesShapeMemberNameWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + list Foo { + % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("member")); + } + + // TODO: These next two shouldn't need the space after ':' + @Test + public void completesMemberTargetsWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + structure Foo { + bar: % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("String", "Integer", "Float")); + } + + @Test + public void completesOperationMemberTargetsWithoutStartingCharacters() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + structure Foo {} + operation Bar { + input: % + }"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("Foo")); + } + + @Test + public void completesTraitsWithoutStartingCharacter() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + @%"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("http")); + } + + @Test + public void completesOperationErrors() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + @error("client") + structure MyError {} + + operation Foo { + errors: [% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("MyError")); + } + + @Test + public void completesServiceMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + service Foo { + operations: [%] + resources: [%] + errors: [%] + } + operation MyOp {} + resource MyResource {} + @error("client") + structure MyError {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("MyOp", "MyResource", "MyError")); + } + + @Test + public void completesResourceMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo + resource Foo { + create: M% + operations: [O%] + resources: [%] + } + operation MyOp {} + operation OtherOp {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("MyOp", "OtherOp", "Foo")); + } + + @Test + public void insertionTextHasCorrectRange() { + TextWithPositions text = TextWithPositions.from("metadata suppressions = [%]"); + + var comps = getCompItems(text.text(), text.positions()); + var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList(); + + assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "metadata suppressions = [{}]")); + } + + @Test + public void replacementTextHasCorrectRange() { + TextWithPositions text = TextWithPositions.from("strin%"); + + var comps = getCompItems(text.text(), text.positions()); + var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList(); + + assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "string")); + } + + @Test + public void completesNamespace() { + TextWithPositions text = TextWithPositions.from(""" + namespace com.foo%"""); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("com.foo")); + } + + // TODO: This shouldn't need the space after the ':' + @Test + public void completesInlineOpMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + operation Op { + input := + @tags([]) + { + foo: % + } + } + """); + List comps = getCompLabels(text); + + + assertThat(comps, hasItem("String")); + } + + @Test + public void completesNamespacesInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata suppressions = [{ + id: "foo" + namespace:% + }] + namespace com.foo + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("*")); + } + + @Test + public void completesSeverityInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata severityOverrides = [{ + id: "foo" + severity:% + }] + namespace com.foo + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("WARNING", "DANGER")); + } + + @Test + public void completesValidatorNamesInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata validators = [{ + id: "foo" + name:% + }] + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItems("EmitEachSelector", "EmitNoneSelector")); + } + + @Test + public void completesValidatorConfigInMetadata() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + metadata validators = [{ + id: "foo" + name: "EmitNoneSelector" + configuration: {%} + }] + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("selector")); + } + + @Test + public void doesntCompleteTraitsAfterClosingParen() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @error("client")% + structure Foo {} + """); + List comps = getCompLabels(text); + + assertThat(comps, empty()); + } + + @Test + public void doesntCompleteTraitsAfterClosingParen2() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bool: Boolean + } + + @foo(bool: true)% + structure Foo {} + """); + List comps = getCompLabels(text); + + assertThat(comps, empty()); + } + + @Test + public void recursiveTraitDef() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + bar: Bar + } + + @foo(bar: { bar: { b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar")); + } + + @Test + public void recursiveTraitDef2() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + one: Baz + } + + structure Baz { + two: Bar + } + + @foo(bar: { one: { two: { o% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("one")); + } + + @Test + public void recursiveTraitDef3() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + list Bar { + member: Baz + } + + structure Baz { + bar: Bar + } + + @foo(bar: [{bar: [{b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar")); + } + + @Test + public void recursiveTraitDef4() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + baz: Baz + } + + list Baz { + member: Bar + } + + @foo(bar: {baz:[{baz:[{b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void recursiveTraitDef5() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: Bar + } + + structure Bar { + baz: Baz + } + + map Baz { + key: String + value: Bar + } + + @foo(bar: {baz: {key: {baz: {key: {b% + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void completesInlineForResource() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + resource MyResource { + } + + operation Foo { + input := for % + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("MyResource")); + } + + @Test + public void completesElidedMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + resource MyResource { + identifiers: { one: String } + properties: { abc: String } + } + + resource MyResource2 { + identifiers: { two: String } + properties: { def: String } + } + + @mixin + structure MyMixin { + foo: String + } + + @mixin + structure MyMixin2 { + bar: String + } + + structure One for MyResource { + $% + } + + structure Two with [MyMixin] { + $% + } + + operation MyOp { + input := for MyResource2 { + $% + } + output := with [MyMixin2] { + $% + } + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("$one", "$foo", "$two", "$bar", "$abc", "$def")); + } + + @Test + public void traitsWithMaps() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + myMap: MyMap + } + + map MyMap { + key: String + value: String + } + + @foo(myMap: %) + structure A {} + + @foo(myMap: {%}) + structure B {} + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("{}")); + } + + @Test + public void applyTarget() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + string Zzz + + apply Z% + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("Zzz")); + } + + @Test + public void enumMapKeys() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + enum Keys { + FOO = "foo" + BAR = "bar" + } + + @trait + map mapTrait { + key: Keys + value: String + } + + @mapTrait(%) + string Foo + + @mapTrait({%}) + string Bar + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("FOO", "BAR", "FOO", "BAR")); + } + + @Test + public void dynamicTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace smithy.test + + @trait + list smokeTests { + member: SmokeTestCase + } + + structure SmokeTestCase { + params: Document + vendorParams: Document + vendorParamsShape: ShapeId + } + + @idRef + string ShapeId + + @smokeTests([ + { + params: {%} + vendorParamsShape: MyVendorParams + vendorParams: {%} + } + ]) + operation Foo { + input := { + bar: String + } + } + + structure MyVendorParams { + abc: String + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("bar", "abc")); + } + + @Test + public void doesntDuplicateElidedMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + ade: String + } + + structure Bar with [Foo] { + $abc + $% + } + + structure Baz with [Foo] { + abc: String + $% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$ade", "$ade")); + } + + @Test + public void knownMemberNamesWithElided() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + map Foo { + key: String + value: String + } + + map Bar with [Foo] { + key: String + % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("value", "$value")); + } + + @Test + public void unknownMemberNamesWithElided() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + def: String + } + + structure Bar with [Foo] { + $abc + % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$def")); + } + + @Test + public void completesElidedMembersWithoutLeadingDollar() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + abc: String + } + + structure Bar with [Foo] { + ab% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, contains("$abc")); + } + + @Test + public void completesNodeMemberTargetStart() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + service A { + version: % + } + service B { + operations: % + } + resource C { + identifiers: % + } + operation D { + errors: % + } + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"", "[]", "{}", "[]")); + } + + @Test + public void completesAbsoluteShapeIds() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: smithy.% + } + """); + List comps = getCompLabels(text); + + assertThat(comps, hasItem("smithy.api#String")); + } + + @Test + public void completesUseTarget() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + use smithy.api#Strin% + """); + List comps = getCompItems(text.text(), text.positions()); + + assertThat(comps, hasSize(1)); + CompletionItem item = comps.get(0); + assertThat(item.getTextEdit().getLeft().getNewText(), equalTo("smithy.api#String")); + assertThat(item.getAdditionalTextEdits(), nullValue()); + } + + private static List getCompLabels(TextWithPositions textWithPositions) { + return getCompLabels(textWithPositions.text(), textWithPositions.positions()); + } + + private static List getCompLabels(String text, Position... positions) { + return getCompItems(text, positions).stream().map(CompletionItem::getLabel).toList(); + } + + private static List getCompItems(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + IdlFile smithyFile = (IdlFile) project.getSmithyFile(uri); + + List completionItems = new ArrayList<>(); + CompletionHandler handler = new CompletionHandler(project, smithyFile); + for (Position position : positions) { + CompletionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildCompletion(); + completionItems.addAll(handler.handle(params, () -> {})); + } + + return completionItems; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java new file mode 100644 index 00000000..0dbed9c4 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -0,0 +1,384 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; + +public class DefinitionHandlerTest { + @Test + public void getsPreludeTraitIdLocations() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @tags([]) + string Foo + """); + GetLocationsResult onAt = getLocations(text, new Position(3, 0)); + GetLocationsResult ok = getLocations(text, new Position(3, 1)); + GetLocationsResult atEnd = getLocations(text, new Position(3, 5)); + + assertThat(onAt.locations, empty()); + + assertThat(ok.locations, hasSize(1)); + assertThat(ok.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(ok, ok.locations.getFirst(), "list tags"); + + assertThat(atEnd.locations, empty()); + } + + @Test + public void getsTraitIdsLocationsInCurrentFile() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + string foo + + @foo + string Bar + """); + GetLocationsResult result = getLocations(text, new Position(6, 1)); + + assertThat(result.locations, hasSize(1)); + Location location = result.locations.getFirst(); + assertThat(location.getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, location, "string foo"); + } + + @Test + public void shapeDefs() { + String text = safeString(""" + $version: "2" + namespace com.foo + + structure Foo {} + + structure Bar { + foo: Foo + } + """); + GetLocationsResult onShapeDef = getLocations(text, new Position(3, 10)); + assertThat(onShapeDef.locations, hasSize(1)); + assertThat(onShapeDef.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(onShapeDef, onShapeDef.locations.getFirst(), "structure Foo"); + + GetLocationsResult memberTarget = getLocations(text, new Position(6, 9)); + assertThat(memberTarget.locations, hasSize(1)); + assertThat(memberTarget.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(memberTarget, memberTarget.locations.getFirst(), "structure Foo"); + } + + @Test + public void forResource() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo {} + + structure Bar for Foo {} + """); + GetLocationsResult result = getLocations(text, new Position(5, 18)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "resource Foo"); + } + + @Test + public void mixin() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo {} + + structure Bar with [Foo] {} + """); + GetLocationsResult result = getLocations(text, new Position(6, 20)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void useTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + use smithy.api#tags + """); + GetLocationsResult result = getLocations(text, new Position(2, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "list tags"); + } + + @Test + public void applyTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + structure Foo {} + + apply Foo @tags([]) + """); + GetLocationsResult result = getLocations(text, new Position(5, 6)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void nodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + service Foo { + version: "0" + operations: [Bar] + } + + operation Bar {} + """); + GetLocationsResult result = getLocations(text, new Position(5, 17)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "operation Bar"); + } + + @Test + public void nestedNodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo { + identifiers: { + foo: String + } + } + """); + GetLocationsResult result = getLocations(text, new Position(5, 13)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string String"); + } + + @Test + public void traitValueTopLevelKey() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + } + + @foo(bar: "") + string Baz + """); + GetLocationsResult result = getLocations(text, new Position(8, 7)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "bar: String"); + } + + @Test + public void traitValueNestedKey() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: BarList + } + + list BarList { + member: Bar + } + + structure Bar { + baz: String + } + + @foo(bar: [{ baz: "one" }, { baz: "two" }]) + string S + """); + GetLocationsResult result = getLocations(text, new Position(16, 29)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "baz: String"); + } + + @Test + public void elidedMixinMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + bar: String + } + + structure Bar with [Foo] { + $bar + } + """); + GetLocationsResult result = getLocations(text, new Position(9, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "structure Foo"); + } + + @Test + public void elidedResourceMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + resource Foo { + identifiers: { + bar: String + } + } + + structure Bar for Foo { + $bar + } + """); + GetLocationsResult result = getLocations(text, new Position(10, 4)); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "resource Foo"); + } + + @Test + public void idRefTraitValue() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @idRef + string ShapeId + + @trait + structure foo { + id: ShapeId + } + + string Bar + + @foo(id: %Bar) + structure Baz {} + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string Bar"); + } + + @Test + public void absoluteShapeId() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: %smithy.api#String + } + """); + GetLocationsResult result = getLocations(text); + assertThat(result.locations, hasSize(1)); + assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy")); + assertIsShapeDef(result, result.locations.getFirst(), "string String"); + } + + private static void assertIsShapeDef( + GetLocationsResult result, + Location location, + String expected + ) { + SmithyFile smithyFile = result.handler.project.getSmithyFile(location.getUri()); + assertThat(smithyFile, notNullValue()); + + int documentIndex = smithyFile.document().indexOfPosition(location.getRange().getStart()); + assertThat(documentIndex, greaterThanOrEqualTo(0)); + + StatementView view = StatementView.createAt(((IdlFile) smithyFile).getParse(), documentIndex).orElse(null); + assertThat(view, notNullValue()); + assertThat(view.statementIndex(), greaterThanOrEqualTo(0)); + + var statement = view.getStatement(); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + String shapeType = shapeDef.shapeType().stringValue(); + String shapeName = shapeDef.shapeName().stringValue(); + assertThat(shapeType + " " + shapeName, equalTo(expected)); + } else if (statement instanceof Syntax.Statement.MemberDef memberDef) { + String memberName = memberDef.name().stringValue(); + String memberTarget = memberDef.target().stringValue(); + assertThat(memberName + ": " + memberTarget, equalTo(expected)); + } else { + fail("Expected shape or member def, but was " + statement.getClass().getName()); + } + } + + record GetLocationsResult(DefinitionHandler handler, List locations) {} + + private static GetLocationsResult getLocations(TextWithPositions textWithPositions) { + return getLocations(textWithPositions.text(), textWithPositions.positions()); + } + + private static GetLocationsResult getLocations(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + SmithyFile smithyFile = project.getSmithyFile(uri); + + List locations = new ArrayList<>(); + DefinitionHandler handler = new DefinitionHandler(project, (IdlFile) smithyFile); + for (Position position : positions) { + DefinitionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildDefinition(); + locations.addAll(handler.handle(params)); + } + + return new GetLocationsResult(handler, locations); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java new file mode 100644 index 00000000..ab2e521e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItems; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; + +public class DocumentSymbolTest { + @Test + public void documentSymbols() { + String model = safeString(""" + $version: "2" + namespace com.foo + + @trait + string myTrait + + structure Foo { + @required + bar: Bar + } + + structure Bar { + @myTrait("foo") + baz: Baz + } + + @myTrait("abc") + integer Baz + """); + List names = getDocumentSymbolNames(model); + + assertThat(names, hasItems("myTrait", "Foo", "bar", "Bar", "baz", "Baz")); + } + + private static List getDocumentSymbolNames(String text) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + IdlFile idlFile = (IdlFile) project.getSmithyFile(uri); + + List names = new ArrayList<>(); + var handler = new DocumentSymbolHandler(idlFile.document(), idlFile.getParse().statements()); + for (var sym : handler.handle()) { + names.add(sym.getRight().getName()); + } + return names; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java new file mode 100644 index 00000000..5f37e89e --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -0,0 +1,173 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.ServerState; +import software.amazon.smithy.lsp.TestWorkspace; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.IdlFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.model.validation.Severity; + +public class HoverHandlerTest { + @Test + public void controlKey() { + String text = safeString(""" + $version: "2" + """); + List hovers = getHovers(text, new Position(0, 1)); + + assertThat(hovers, contains(containsString("version"))); + } + + @Test + public void metadataKey() { + String text = safeString(""" + metadata suppressions = [] + """); + List hovers = getHovers(text, new Position(0, 9)); + + assertThat(hovers, contains(containsString("suppressions"))); + } + + @Test + public void metadataValue() { + String text = safeString(""" + metadata suppressions = [{id: "foo"}] + """); + List hovers = getHovers(text, new Position(0, 26)); + + assertThat(hovers, contains(containsString("id"))); + } + + @Test + public void traitValue() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @http(method: "GET", uri: "/") + operation Foo {} + """); + List hovers = getHovers(text, new Position(3, 7)); + + assertThat(hovers, contains(containsString("method: NonEmptyString"))); + } + + @Test + public void elidedMember() { + String text = safeString(""" + $version: "2" + namespace com.foo + + @mixin + structure Foo { + bar: String + } + + structure Bar with [Foo] { + $bar + } + """); + List hovers = getHovers(text, new Position(9, 5)); + + assertThat(hovers, contains(containsString("bar: String"))); + } + + @Test + public void nodeMemberTarget() { + String text = safeString(""" + $version: "2" + namespace com.foo + + service Foo { + version: "0" + operations: [Bar] + } + + operation Bar {} + """); + List hovers = getHovers(text, new Position(5, 17)); + + assertThat(hovers, contains(containsString("operation Bar"))); + } + + @Test + public void absoluteShapeId() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + bar: %smithy.api#String + } + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("string String"))); + } + + @Test + public void selfShapeDefinition() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure %Foo {} + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("structure Foo"))); + } + + @Test + public void selfMemberDefinition() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + structure Foo { + %bar: String + } + """); + List hovers = getHovers(text); + + assertThat(hovers, contains(containsString("bar: String"))); + } + + private static List getHovers(TextWithPositions text) { + return getHovers(text.text(), text.positions()); + } + + private static List getHovers(String text, Position... positions) { + TestWorkspace workspace = TestWorkspace.singleModel(text); + Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap(); + String uri = workspace.getUri("main.smithy"); + SmithyFile smithyFile = project.getSmithyFile(uri); + + List hover = new ArrayList<>(); + HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile, Severity.WARNING); + for (Position position : positions) { + HoverParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildHover(); + hover.add(handler.handle(params).getContents().getRight().getValue()); + } + + return hover; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java index 21790ba4..a7c983f3 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -19,13 +19,13 @@ import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage; import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId; import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf; -import static software.amazon.smithy.lsp.document.DocumentTest.string; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Set; import java.util.logging.Logger; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; @@ -36,6 +36,7 @@ import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.LengthTrait; import software.amazon.smithy.model.traits.PatternTrait; import software.amazon.smithy.model.traits.TagsTrait; @@ -120,24 +121,19 @@ public void loadsWhenModelHasInvalidSyntax() { assertThat(eventIds, hasItem("Model")); assertThat(project.smithyFiles().keySet(), hasItem(containsString("main.smithy"))); - SmithyFile main = project.getSmithyFile(LspAdapter.toUri(root.resolve("main.smithy").toString())); + IdlFile main = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("main.smithy").toString())); assertThat(main, not(nullValue())); assertThat(main.document(), not(nullValue())); - assertThat(main.namespace(), string("com.foo")); - assertThat(main.imports(), empty()); + assertThat(main.getParse().namespace().namespace(), equalTo("com.foo")); + assertThat(main.getParse().imports().imports(), empty()); - assertThat(main.shapes(), hasSize(2)); - List shapeIds = main.shapes().stream() - .map(Shape::toShapeId) + assertThat(project.definedShapesByFile().keySet(), hasItem(main.path())); + Set mainShapes = project.definedShapesByFile().get(main.path()); + List shapeIds = mainShapes.stream() + .map(ToShapeId::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar")); - - assertThat(main.documentShapes(), hasSize(3)); - List documentShapeNames = main.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(documentShapeNames, hasItems("Foo", "bar", "String")); } @Test @@ -149,31 +145,29 @@ public void loadsProjectWithMultipleNamespaces() { assertThat(project.modelResult().getValidationEvents(), empty()); assertThat(project.smithyFiles().keySet(), hasItems(containsString("a.smithy"), containsString("b.smithy"))); - SmithyFile a = project.getSmithyFile(LspAdapter.toUri(root.resolve("model/a.smithy").toString())); + IdlFile a = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("model/a.smithy").toString())); assertThat(a.document(), not(nullValue())); - assertThat(a.namespace(), string("a")); - List aShapeIds = a.shapes().stream() - .map(Shape::toShapeId) + assertThat(a.getParse().namespace().namespace(), equalTo("a")); + + assertThat(project.definedShapesByFile().keySet(), hasItem(a.path())); + Set aShapes = project.definedShapesByFile().get(a.path()); + List aShapeIds = aShapes.stream() + .map(ToShapeId::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(aShapeIds, hasItems("a#Hello", "a#HelloInput", "a#HelloOutput")); - List aDocumentShapeNames = a.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(aDocumentShapeNames, hasItems("Hello", "name", "String")); - SmithyFile b = project.getSmithyFile(LspAdapter.toUri(root.resolve("model/b.smithy").toString())); + IdlFile b = (IdlFile) project.getSmithyFile(LspAdapter.toUri(root.resolve("model/b.smithy").toString())); assertThat(b.document(), not(nullValue())); - assertThat(b.namespace(), string("b")); - List bShapeIds = b.shapes().stream() - .map(Shape::toShapeId) + assertThat(b.getParse().namespace().namespace(), equalTo("b")); + + assertThat(project.definedShapesByFile().keySet(), hasItem(b.path())); + Set bShapes = project.definedShapesByFile().get(b.path()); + List bShapeIds = bShapes.stream() + .map(ToShapeId::toShapeId) .map(ShapeId::toString) .collect(Collectors.toList()); assertThat(bShapeIds, hasItems("b#Hello", "b#HelloInput", "b#HelloOutput")); - List bDocumentShapeNames = b.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(bDocumentShapeNames, hasItems("Hello", "name", "String")); } @Test diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java new file mode 100644 index 00000000..004cceb0 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -0,0 +1,469 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.document.Document; + +public class IdlParserTest { + @Test + public void parses() { + String text = """ + string Foo + @tags(["foo"]) + structure Bar { + baz: String + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef); + } + + @Test + public void parsesStatements() { + String text = """ + $version: "2" + metadata foo = [{ bar: 2 }] + namespace com.foo + + use com.bar#baz + + @baz + structure Foo { + @baz + bar: String + } + + enum Bar { + BAZ = "BAZ" + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Metadata, + Syntax.Statement.Type.Namespace, + Syntax.Statement.Type.Use, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.EnumMemberDef); + } + + @Test + public void parsesMixinsAndForResource() { + String text = """ + structure Foo with [Mix] {} + structure Bar for Resource {} + structure Baz for Resource with [Mix] {} + structure Bux with [One, Two, Three] {} + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.Mixins); + } + + @Test + public void parsesOp() { + String text = """ + operation One {} + operation Two { + input: Input + } + operation Three { + input: Input + output: Output + } + operation Four { + input: Input + errors: [Err] + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void parsesOpInline() { + String text = """ + operation One { + input := { + foo: String + } + output := { + @foo + foo: String + } + } + operation Two { + input := for Foo { + foo: String + } + output := with [Bar] { + bar: String + } + } + operation Three { + input := for Foo with [Bar, Baz] {} + } + operation Four { + input := @foo {} + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.ForResource, + Syntax.Statement.Type.Mixins, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication); + } + + @Test + public void parsesOpInlineWithTraits() { + String text = safeString(""" + operation Op { + input := @foo { + foo: Foo + } + output := {} + }"""); + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.InlineMemberDef); + } + + @Test + public void parsesServiceAndResource() { + String text = """ + service Foo { + version: "2024-08-15 + operations: [ + Op1 + Op2 + ] + errors: [ + Err1 + Err2 + ] + } + resource Bar { + identifiers: { id: String } + properties: { prop: String } + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void ignoresComments() { + String text = """ + // one + $version: "2" // two + + namespace com.foo // three + // four + use com.bar#baz // five + + // six + @baz // seven + structure Foo // eight + { // nine + // ten + bar: String // eleven + } // twelve + + enum Bar // thirteen + { // fourteen + // fifteen + BAR // sixteen + } // seventeen + service Baz // eighteen + { // nineteen + // twenty + version: "" // twenty one + } // twenty two + """; + + assertTypesEqual(text, + Syntax.Statement.Type.Control, + Syntax.Statement.Type.Namespace, + Syntax.Statement.Type.Use, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.EnumMemberDef, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.NodeMemberDef); + } + + @Test + public void defaultAssignments() { + String text = """ + structure Foo { + one: One = "" + two: Two = 2 + three: Three = false + four: Four = [] + five: Five = {} + } + """; + + assertTypesEqual(text, + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef, + Syntax.Statement.Type.MemberDef); + } + + @Test + public void stringKeysInTraits() { + String text = """ + @foo( + "bar": "baz" + ) + """; + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + assertThat(parse.statements(), hasSize(1)); + assertThat(parse.statements().get(0), instanceOf(Syntax.Statement.TraitApplication.class)); + + Syntax.Statement.TraitApplication traitApplication = (Syntax.Statement.TraitApplication) parse.statements().get(0); + var nodeTypes = NodeParserTest.getNodeTypes(traitApplication.value()); + + assertThat(nodeTypes, contains( + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str)); + } + + @ParameterizedTest + @MethodSource("brokenProvider") + public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { + if (desc.equals("trait missing member value")) { + System.out.println(); + } + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); + List types = parse.statements().stream() + .map(Syntax.Statement::type) + .filter(type -> type != Syntax.Statement.Type.Block) + .toList(); + + assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); + assertThat(desc, types, equalTo(expectedTypes)); + } + + record InvalidSyntaxTestCase( + String description, + String text, + List expectedErrorMessages, + List expectedTypes + ) {} + + private static final List INVALID_SYNTAX_TEST_CASES = List.of( + new InvalidSyntaxTestCase( + "empty", + "", + List.of(), + List.of() + ), + new InvalidSyntaxTestCase( + "just shape type", + "structure", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "missing resource", + "string Foo for", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.ForResource) + ), + new InvalidSyntaxTestCase( + "unexpected line break", + "string \nstring Foo", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete, Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "unexpected token", + "string [", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.Incomplete) + ), + new InvalidSyntaxTestCase( + "unexpected token 2", + "string Foo [", + List.of("expected identifier"), + List.of(Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "enum missing {", + "enum Foo\nBAR}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef) + ), + new InvalidSyntaxTestCase( + "enum missing }", + "enum Foo {BAR", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing {", + "structure Foo\nbar: String}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "regular shape missing }", + "structure Foo {bar: String", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef) + ), + new InvalidSyntaxTestCase( + "op with inline missing {", + "operation Foo\ninput := {}}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef) + ), + new InvalidSyntaxTestCase( + "op with inline missing }", + "operation Foo{input:={}", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing {", + "resource Foo\nidentifiers:{}}", + List.of("expected {"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "node shape with missing }", + "service Foo{operations:[]", + List.of("expected }"), + List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef) + ), + new InvalidSyntaxTestCase( + "apply missing @", + "apply Foo", + List.of("expected trait or block"), + List.of(Syntax.Statement.Type.Apply) + ), + new InvalidSyntaxTestCase( + "apply missing }", + "apply Foo {@bar", + List.of("expected }"), + List.of(Syntax.Statement.Type.Apply, Syntax.Statement.Type.TraitApplication) + ), + new InvalidSyntaxTestCase( + "trait missing member value", + "@foo(bar: )\nstring Foo", + List.of("expected value"), + List.of(Syntax.Statement.Type.TraitApplication, Syntax.Statement.Type.ShapeDef) + ), + new InvalidSyntaxTestCase( + "inline with member missing target", + """ + operation Op { + input := + @tags([]) + { + foo:\s + } + }""", + List.of("expected identifier"), + List.of( + Syntax.Statement.Type.ShapeDef, + Syntax.Statement.Type.InlineMemberDef, + Syntax.Statement.Type.TraitApplication, + Syntax.Statement.Type.MemberDef) + ) + ); + + private static Stream brokenProvider() { + return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of( + invalidSyntaxTestCase.description, + invalidSyntaxTestCase.text, + invalidSyntaxTestCase.expectedErrorMessages, + invalidSyntaxTestCase.expectedTypes)); + } + + private static void assertTypesEqual(String text, Syntax.Statement.Type... types) { + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); + List actualTypes = parse.statements().stream() + .map(Syntax.Statement::type) + .filter(type -> type != Syntax.Statement.Type.Block) + .toList(); + assertThat(actualTypes, contains(types)); + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java new file mode 100644 index 00000000..6f45d5f7 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java @@ -0,0 +1,406 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.lsp.syntax; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.fail; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.lsp.document.Document; + +public class NodeParserTest { + @Test + public void goodEmptyObj() { + String text = "{}"; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps); + } + + @Test + public void goodEmptyObjWithWs() { + String text = "{ }"; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps); + } + + @Test + public void goodObjSingleKey() { + String text = """ + {"abc": "def"}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodObjMultiKey() { + String text = """ + {"abc": "def", "ghi": "jkl"}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodNestedObjs() { + String text = """ + {"abc": {"abc": {"abc": "abc"}, "def": "def"}}"""; + assertTypesEqual(text, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Str, + Syntax.Node.Type.Str); + } + + @Test + public void goodEmptyArr() { + String text = "[]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr); + } + + @Test + public void goodEmptyArrWithWs() { + String text = "[ ]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr); + } + + @Test + public void goodSingleElemArr() { + String text = "[1]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num); + } + + @Test + public void goodMultiElemArr() { + String text = """ + [1, 2, "3"]"""; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str); + } + + @Test + public void goodNestedArr() { + String text = """ + [[1, [1, 2], []] 3]"""; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num); + } + + @ParameterizedTest + @MethodSource("goodStringsProvider") + public void goodStrings(String text, String expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + if (value instanceof Syntax.Node.Str s) { + String actualValue = s.stringValue(); + if (!expectedValue.equals(actualValue)) { + fail(String.format("expected text of %s to be parsed as a string with value %s, but was %s", + text, expectedValue, actualValue)); + } + } else { + fail(String.format("expected text of %s to be parsed as a string, but was %s", + text, value.type())); + } + } + + private static Stream goodStringsProvider() { + return Stream.of( + Arguments.of("\"foo\"", "foo"), + Arguments.of("\"\"", "") + ); + } + + @ParameterizedTest + @MethodSource("goodIdentsProvider") + public void goodIdents(String text, String expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + if (value instanceof Syntax.Ident ident) { + String actualValue = ident.stringValue(); + if (!expectedValue.equals(actualValue)) { + fail(String.format("expected text of %s to be parsed as an ident with value %s, but was %s", + text, expectedValue, actualValue)); + } + } else { + fail(String.format("expected text of %s to be parsed as an ident, but was %s", + text, value.type())); + } + } + + private static Stream goodIdentsProvider() { + return Stream.of( + Arguments.of("true", "true"), + Arguments.of("false", "false"), + Arguments.of("null", "null") + ); + } + + @ParameterizedTest + @MethodSource("goodNumbersProvider") + public void goodNumbers(String text, BigDecimal expectedValue) { + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + + if (value instanceof Syntax.Node.Num num) { + if (!expectedValue.equals(num.value)) { + fail(String.format("Expected text of %s to be parsed as a number with value %s, but was %s", + text, expectedValue, num.value)); + } + } else { + fail(String.format("Expected text of %s to be parsed as a number but was %s", + text, value.type())); + } + } + + private static Stream goodNumbersProvider() { + return Stream.of( + Arguments.of("-10", BigDecimal.valueOf(-10)), + Arguments.of("0", BigDecimal.valueOf(0)), + Arguments.of("123", BigDecimal.valueOf(123)) + ); + } + + @ParameterizedTest + @MethodSource("brokenProvider") + public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { + Syntax.NodeParseResult parse = Syntax.parseNode(Document.of(text)); + List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); + List types = getNodeTypes(parse.value()); + + assertThat(desc, errorMessages, equalTo(expectedErrorMessages)); + assertThat(desc, types, equalTo(expectedTypes)); + } + + record InvalidSyntaxTestCase( + String description, + String text, + List expectedErrorMessages, + List expectedTypes + ) {} + + private static final List INVALID_SYNTAX_TEST_CASES = List.of( + new InvalidSyntaxTestCase( + "invalid element token", + "[1, 2}]", + List.of("unexpected token }"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed empty", + "[", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr) + ), + new InvalidSyntaxTestCase( + "unclosed", + "[1,", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed with sp", + "[1, ", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem", + "[1,a", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem and sp", + "[1,a ", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident) + ), + new InvalidSyntaxTestCase( + "unclosed with multi elem no ,", + "[a 2", + List.of("missing ]"), + List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Ident, Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "unclosed in member", + "{foo: [1, 2}", + List.of("unexpected token }", "missing ]", "missing }"), + List.of( + Syntax.Node.Type.Obj, + Syntax.Node.Type.Kvps, + Syntax.Node.Type.Kvp, + Syntax.Node.Type.Ident, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Num, + Syntax.Node.Type.Num) + ), + new InvalidSyntaxTestCase( + "Non-string key with no value", + "{1}", + List.of("unexpected Num", "expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Non-string key with : but no value", + "{1:}", + List.of("unexpected Num", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "String key with no value", + "{\"1\"}", + List.of("expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no value", + "{\"1\":}", + List.of("expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with no value but a trailing ,", + "{\"1\",}", + List.of("expected :", "expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "String key with : but no value and a trailing ,", + "{\"1\":,}", + List.of("expected value"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ), + new InvalidSyntaxTestCase( + "Invalid key", + "{\"abc}", + List.of("unexpected eof", "missing }"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps) + ), + new InvalidSyntaxTestCase( + "Missing :", + "{\"abc\" 1}", + List.of("expected :"), + List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str) + ) + ); + + private static Stream brokenProvider() { + return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of( + invalidSyntaxTestCase.description, + invalidSyntaxTestCase.text, + invalidSyntaxTestCase.expectedErrorMessages, + invalidSyntaxTestCase.expectedTypes)); + } + + @Test + public void parsesStringsWithEscapes() { + String text = """ + "a\\"b" + """; + assertTypesEqual(text, + Syntax.Node.Type.Str); + } + + @Test + public void parsesTextBlocks() { + String text = "[\"\"\"foo\"\"\", 2, \"bar\", 3, \"\", 4, \"\"\"\"\"\"]"; + assertTypesEqual(text, + Syntax.Node.Type.Arr, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str, + Syntax.Node.Type.Num, + Syntax.Node.Type.Str); + } + + @Test + public void stringValues() { + Syntax.Node node = Syntax.parseNode(Document.of(""" + [ + "abc", + "", + \""" + foo + \""" + ] + """)).value(); + + assertThat(node, instanceOf(Syntax.Node.Arr.class)); + Syntax.Node.Arr arr = (Syntax.Node.Arr) node; + assertThat(arr.elements(), hasSize(3)); + + Syntax.Node first = arr.elements().get(0); + assertThat(first, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) first).stringValue(), equalTo("abc")); + + Syntax.Node second = arr.elements().get(1); + assertThat(second, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) second).stringValue(), equalTo("")); + + Syntax.Node third = arr.elements().get(2); + assertThat(third, instanceOf(Syntax.Node.Str.class)); + assertThat(((Syntax.Node.Str) third).stringValue().trim(), equalTo("foo")); + } + + private static void assertTypesEqual(String text, Syntax.Node.Type... types) { + assertThat(getNodeTypes(Syntax.parseNode(Document.of(text)).value()), contains(types)); + } + + static List getNodeTypes(Syntax.Node value) { + List types = new ArrayList<>(); + value.consume(v -> types.add(v.type())); + return types; + } +} diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java new file mode 100644 index 00000000..167a3994 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java @@ -0,0 +1,55 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.syntax; + +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.smithy.lsp.document.DocumentTest.safeString; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.document.Document; + +public class SyntaxSearchTest { + @Test + public void findsNodeCursor() { + String text = safeString(""" + { + "foo": "bar" + }"""); + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); + + assertCursorMatches(cursor, new NodeCursor(List.of( + new NodeCursor.Obj(null), + new NodeCursor.Key("foo", null), + new NodeCursor.Terminal(null) + ))); + } + + @Test + public void findsNodeCursorWhenBroken() { + String text = safeString(""" + { + "foo" + }"""); + Document document = Document.of(text); + Syntax.Node value = Syntax.parseNode(document).value(); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); + + assertCursorMatches(cursor, new NodeCursor(List.of( + new NodeCursor.Obj(null), + new NodeCursor.Key("foo", null), + new NodeCursor.Terminal(null) + ))); + } + + private static void assertCursorMatches(NodeCursor actual, NodeCursor expected) { + if (!actual.toString().equals(expected.toString())) { + fail("Expected cursor to match:\n" + expected + "\nbut was:\n" + actual); + } + } +}