From 7f0628b1e6ec06e59a27ed0851d399f85541e318 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Tue, 24 Sep 2024 12:05:07 -0400 Subject: [PATCH 1/5] Upgrade completions, definition, hover This commit is a rewrite of how language features (i.e. completions, definition, hover) are implemented. It improves the accuracy and expands the functionality of each feature significantly. Improvements include: - Completions - Trait values - Builtin control keys and metadata - Namespaces, based on other namespaces in the project - Keywords - Member names (like inside resources, maps) - Member values (like inside the list of operation errors, resource property targets, etc.) - Elided members - Some trait values have special completions, like `examples` has completions for the target operation's input/output parameters - Definition - Trait values - Elided members - Shape ids referenced within trait values - Hover - Trait values - Elided members - Builtin metadata There's a lot going on here, but there's a few key pieces of this commit that all work together to make this work: At the core of these improvements is the addition of a custom parser for the IDL that provides the needed syntactic information to implement these features. See the javadoc on the Syntax class for more details on how the parser works, and why it was written that way. At a high level though, the parser produces a flat list of `Syntax.Statement`, and that list is searched through to find things, such as the statement the cursor is currently in. It is also used to search 'around' a statement, like to find the shape a trait is being applied to. Another key piece of these changes is `NodeCursor` and `NodeSearch`. There are a few places in the syntax of a smithy file where you may have a node value whose structure is (or can be) described by a Smithy model. For example, trait values. `NodeCursor` is basically two things: 1. A path from the start of a `Node` to a position within that `Node`, 2. An index into that path. `NodeSearch` is used to search a model along the path of a `NodeCursor`, from a starting shape. For example, when the cursor is within a trait value, the `NodeCursor` is that path from the root of the trait value, to the cursor position, and `NodeSearch` is used to search in the model, starting at the trait's definition, along the path of the `NodeCursor`, to find what shape corresponds to the cursor's location. That shape can then be used e.g. to provide completions. Finally, there's the `Builtins` class, and the corresponding Smithy model it uses. I originally had a completely different abstraction for describing the structure of metadata, different shape types' members, and even `smithy-build.json`. But it was basically just a 'structured graph', like a Smithy model. So I decided to just _use_ a Smithy model itself, since I already had the abstractions for traversing it (like I had to for trait values). The `Builtins` model contains shapes that define the structure of certain Smithy constructs. For example, I use it to model the shape of builtin metadata, like suppressions. I also use it to model the shape of shapes, that is, what members shapes have, and what their targets are. Some shapes in this model are considered 'builtins' (in the builtins.smithy files). Builtins are shapes that require some custom processing, or have some special meaning, like `AnyNamespace`, which is used for describing a namespace that can be used in https://smithy.io/2.0/spec/model-validation.html#suppression-metadata. The builtin model pretty 'meta', and I don't _love_ it, but it reduces a significant amount of duplicated logic. For example, if we want to give documentation for some metadata, it is as easy as adding it to the builtins model. We can also use it to add support for smithy-build.json completions, hover, and even validation, later. It would be nice if these definitions lived elsewhere, so other tooling could consume them, like the Smithy docs for example, and I have some other ideas on how we can use it, but they're out of scope here. Testing for this commit comes mostly from the completions, definitions, and hover tests, which indirectly test lower-level components like the parser (there are still some parser tests, though). --- .gitignore | 2 - build.gradle | 5 + config/checkstyle/checkstyle.xml | 1 - .../smithy/lsp/SmithyLanguageServer.java | 11 +- .../amazon/smithy/lsp/document/Document.java | 67 +- .../smithy/lsp/document/DocumentId.java | 11 + .../smithy/lsp/document/DocumentParser.java | 674 ++--------- .../lsp/document/DocumentPositionContext.java | 44 - .../smithy/lsp/handler/CompletionHandler.java | 315 ----- .../smithy/lsp/handler/DefinitionHandler.java | 90 -- .../smithy/lsp/handler/HoverHandler.java | 169 --- .../amazon/smithy/lsp/language/Builtins.java | 110 ++ .../smithy/lsp/language/Candidates.java | 247 ++++ .../lsp/language/CompletionHandler.java | 296 +++++ .../lsp/language/DefinitionHandler.java | 55 + .../lsp/language/DynamicMemberTarget.java | 182 +++ .../smithy/lsp/language/HoverHandler.java | 245 ++++ .../smithy/lsp/language/IdlPosition.java | 152 +++ .../smithy/lsp/language/NodeSearch.java | 204 ++++ .../smithy/lsp/language/ShapeCompletions.java | 266 +++++ .../smithy/lsp/language/ShapeSearch.java | 309 +++++ .../lsp/language/SimpleCompletions.java | 230 ++++ .../smithy/lsp/project/ProjectLoader.java | 9 +- .../amazon/smithy/lsp/project/SmithyFile.java | 59 + .../smithy/lsp/protocol/LspAdapter.java | 6 +- .../amazon/smithy/lsp/syntax/NodeCursor.java | 224 ++++ .../amazon/smithy/lsp/syntax/Parser.java | 1010 ++++++++++++++++ .../amazon/smithy/lsp/syntax/Syntax.java | 765 ++++++++++++ .../smithy/lsp/syntax/SyntaxSearch.java | 214 ++++ .../amazon/smithy/lsp/util/StreamUtils.java | 23 + src/main/resources/.gitkeep | 1 - .../smithy/lsp/language/builtins.smithy | 37 + .../amazon/smithy/lsp/language/control.smithy | 17 + .../amazon/smithy/lsp/language/members.smithy | 75 ++ .../smithy/lsp/language/metadata.smithy | 95 ++ .../amazon/smithy/lsp/LspMatchers.java | 29 + .../smithy/lsp/SmithyLanguageServerTest.java | 4 +- .../amazon/smithy/lsp/TextWithPositions.java | 53 + .../lsp/document/DocumentParserTest.java | 111 +- .../lsp/language/CompletionHandlerTest.java | 1062 +++++++++++++++++ .../lsp/language/DefinitionHandlerTest.java | 367 ++++++ .../smithy/lsp/language/HoverHandlerTest.java | 127 ++ .../smithy/lsp/project/ProjectTest.java | 4 +- .../smithy/lsp/syntax/IdlParserTest.java | 469 ++++++++ .../smithy/lsp/syntax/NodeParserTest.java | 375 ++++++ .../smithy/lsp/syntax/SyntaxSearchTest.java | 55 + 46 files changed, 7551 insertions(+), 1325 deletions(-) delete mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/Builtins.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/Candidates.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/Parser.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java create mode 100644 src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java delete mode 100644 src/main/resources/.gitkeep create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/control.smithy create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/members.smithy create mode 100644 src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy create mode 100644 src/test/java/software/amazon/smithy/lsp/TextWithPositions.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java create mode 100644 src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java 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/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 5e5fb320..7b6b4944 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -106,9 +106,9 @@ 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.HoverHandler; import software.amazon.smithy.lsp.project.BuildFile; import software.amazon.smithy.lsp.project.Project; import software.amazon.smithy.lsp.project.ProjectAndFile; @@ -517,10 +517,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(); @@ -732,7 +733,7 @@ public CompletableFuture hover(HoverParams params) { Project project = projectAndFile.project(); // TODO: Abstract away passing minimum severity - Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity); + Hover hover = new HoverHandler(project, smithyFile, minimumSeverity).handle(params); return completedFuture(hover); } 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..365af4c9 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -24,7 +24,7 @@ public final class Document { private final StringBuilder buffer; private int[] lineIndices; - private Document(StringBuilder buffer, int[] lineIndices) { + private Document(StringBuilder buffer, int[] lineIndices, int changeVersion) { this.buffer = buffer; this.lineIndices = lineIndices; } @@ -36,14 +36,14 @@ private Document(StringBuilder buffer, int[] lineIndices) { public static Document of(String string) { StringBuilder buffer = new StringBuilder(string); int[] lineIndicies = computeLineIndicies(buffer); - return new Document(buffer, lineIndicies); + return new Document(buffer, lineIndicies, 0); } /** * @return A copy of this document */ public Document copy() { - return new Document(new StringBuilder(copyText()), lineIndices.clone()); + return new Document(new StringBuilder(copyText()), lineIndices.clone(), 0); } /** @@ -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 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/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java index d311e03e..2299e2bf 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java +++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java @@ -7,37 +7,30 @@ import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.lsp.syntax.SyntaxSearch; 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) { @@ -49,7 +42,17 @@ static DocumentParser of(String text) { * @return A parser for the given document */ public static DocumentParser forDocument(Document document) { - return new DocumentParser(document); + Syntax.IdlParse parse = Syntax.parseIdl(document); + return new DocumentParser(document, parse.statements()); + } + + /** + * @param document Document to create a parser for + * @param statements The statements the parser should use + * @return The parser for the given document and statements + */ + public static DocumentParser forStatements(Document document, List statements) { + return new DocumentParser(document, statements); } /** @@ -57,48 +60,14 @@ public static DocumentParser forDocument(Document document) { * {@code null} if it couldn't be found */ public DocumentNamespace documentNamespace() { - int namespaceStartIdx = firstIndexOfWithOnlyLeadingWs("namespace"); - if (namespaceStartIdx < 0) { - return null; - } - - Position namespaceStatementStartPosition = document.positionAtIndex(namespaceStartIdx); - if (namespaceStatementStartPosition == null) { - // Shouldn't happen on account of the previous check - return null; - } - jumpToPosition(namespaceStatementStartPosition); - skip(); // n - skip(); // a - skip(); // m - skip(); // e - skip(); // s - skip(); // p - skip(); // a - skip(); // c - skip(); // e - - if (!isSp()) { - return null; - } - - sp(); - - if (!isNamespaceChar()) { - return null; - } - - int start = position(); - while (isNamespaceChar()) { - skip(); + for (Syntax.Statement statement : statements) { + if (statement instanceof Syntax.Statement.Namespace namespace) { + Range range = namespace.rangeIn(document); + String namespaceValue = namespace.namespace().copyValueFrom(document); + return new DocumentNamespace(range, namespaceValue); + } } - int end = position(); - CharSequence namespace = document.borrowSpan(start, end); - - consumeRemainingCharactersOnLine(); - Position namespaceStatementEnd = currentPosition(); - - return new DocumentNamespace(new Range(namespaceStatementStartPosition, namespaceStatementEnd), namespace); + return null; } /** @@ -106,158 +75,95 @@ public DocumentNamespace documentNamespace() { * {@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); + 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().copyValueFrom(document)); + Range useRange = firstUse.rangeIn(document); + 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().copyValueFrom(document)); + end = use.rangeIn(document).getEnd(); + i++; + } else { + break; + } + } + return new DocumentImports(new Range(start, end), imports); } - consumeRemainingCharactersOnLine(); - lastUseEnd = currentPosition(); - nextNonWsNonComment(); - } while (isUse()); - - if (imports.isEmpty()) { - return null; } - - return new DocumentImports(new Range(firstUsePosition, lastUseEnd), imports); + return null; } /** - * @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 A map of start position to {@link DocumentShape} for each shape + * and/or shape reference in the 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 Map documentShapes() { + Map documentShapes = new HashMap<>(); + for (Syntax.Statement statement : statements) { + switch (statement) { + case Syntax.Statement.ShapeDef shapeDef -> { + String shapeName = shapeDef.shapeName().copyValueFrom(document); + Range range = shapeDef.shapeName().rangeIn(document); + var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedShape, null); + documentShapes.put(range.getStart(), shape); + } + case Syntax.Statement.MemberDef memberDef -> { + String shapeName = memberDef.name().copyValueFrom(document); + Range range = memberDef.name().rangeIn(document); + DocumentShape target = null; + if (memberDef.target() != null && !memberDef.target().isEmpty()) { + String targetName = memberDef.target().copyValueFrom(document); + Range targetRange = memberDef.target().rangeIn(document); + target = new DocumentShape(targetRange, targetName, DocumentShape.Kind.Targeted, null); + documentShapes.put(targetRange.getStart(), target); + } + var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedMember, target); + documentShapes.put(range.getStart(), shape); + } + case Syntax.Statement.ElidedMemberDef elidedMemberDef -> { + String shapeName = elidedMemberDef.name().copyValueFrom(document); + Range range = elidedMemberDef.rangeIn(document); + var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.Elided, null); + documentShapes.put(range.getStart(), shape); + } + case Syntax.Statement.EnumMemberDef enumMemberDef -> { + String shapeName = enumMemberDef.name().copyValueFrom(document); + Range range = enumMemberDef.rangeIn(document); + var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedMember, null); + documentShapes.put(range.getStart(), shape); + } + default -> { } - 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); - } - } else if (kind == DocumentShape.Kind.DefinedShape && (shapeName == null || shapeName.isEmpty())) { - kind = DocumentShape.Kind.Inline; - } - - return new DocumentShape(range, shapeName, kind, targetReference); - } - /** * @return The {@link DocumentVersion} for the underlying document, or * {@code null} if it couldn't be found */ public DocumentVersion documentVersion() { - firstIndexOfNonWsNonComment(); - if (!is('$')) { - return null; - } - while (is('$') && !isVersion()) { - // Skip this line - if (!jumpToLine(line())) { - return null; + for (Syntax.Statement statement : statements) { + if (statement instanceof Syntax.Statement.Control control + && control.value() instanceof Syntax.Node.Str str) { + String key = control.key().copyValueFrom(document); + if (key.equals("version")) { + String version = str.copyValueFrom(document); + Range range = control.rangeIn(document); + 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; } @@ -272,36 +178,18 @@ public DocumentVersion documentVersion() { * or there's no id next to the {@code @} */ public Range traitIdRange(SourceLocation sourceLocation) { - if (!jumpToSource(sourceLocation)) { - return null; - } - - if (!is('@')) { + int position = document.indexOfPosition(LspAdapter.toPosition(sourceLocation)); + int statementIndex = SyntaxSearch.statementIndex(statements, position); + if (statementIndex < 0) { 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; + if (statements.get(statementIndex) instanceof Syntax.Statement.TraitApplication traitApplication) { + Range range = traitApplication.id().rangeIn(document); + range.getStart().setCharacter(range.getStart().getCharacter() - 1); // include @ + return range; } - return false; + return null; } /** @@ -320,13 +208,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 +215,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 +243,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/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/Candidates.java b/src/main/java/software/amazon/smithy/lsp/language/Candidates.java new file mode 100644 index 00000000..f59fa78d --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/Candidates.java @@ -0,0 +1,247 @@ +/* + * 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 Candidates { + 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")); + // TODO: Maybe BUILTIN_CONTROLS and BUILTIN_METADATA should be regular + // Labeled/Members, with custom mappers. + Literals BUILTIN_CONTROLS = new Candidates.Literals( + Builtins.CONTROL.members().stream() + .map(member -> "$" + member.getMemberName() + ": " + Candidates.defaultCandidates(member).value()) + .toList()); + Literals BUILTIN_METADATA = new Candidates.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 Candidates.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 Candidates 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(Candidates::terminalCandidates) + .orElse(NONE); + + default -> 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 Candidates membersCandidates(Model model, Shape shape) { + if (shape.isStructureShape() || shape.isUnionShape()) { + return new Members(shape.getAllMembers().entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> model.getShape(entry.getValue().getTarget()) + .map(Candidates::defaultCandidates) + .orElse(NONE)))); + } else if (shape instanceof MapShape mapShape) { + EnumShape enumKey = model.getShape(mapShape.getKey().getTarget()) + .flatMap(Shape::asEnumShape) + .orElse(null); + if (enumKey != null) { + return terminalCandidates(enumKey); + } + } + return NONE; + } + + private static Candidates 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 Candidates 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 Candidates {} + + /** + * Multiple values to be completed as literals, like keywords. + * + * @param literals The completion values. + */ + record Literals(List literals) implements Candidates {} + + /** + * 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. + * + * @param labeled The labeled completion values. + */ + record Labeled(Map labeled) implements Candidates {} + + /** + * Multiple name -> constant pairs, where the name corresponds to a member + * name, and the constant is a default/empty value for that member. + * + * @param members The members completion values. + */ + record Members(Map members) implements Candidates {} + + /** + * 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 Candidates {} + + /** + * 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(Candidates one, Candidates two) implements Candidates {} + + /** + * Shape completion candidates, each corresponding to a different set of + * shapes that will be selected from the model. + */ + enum Shapes implements Candidates { + 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 Candidates { + 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..182e1f10 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -0,0 +1,296 @@ +/* + * 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.Project; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.lsp.syntax.SyntaxSearch; +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 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 = getTokenPosition(params); + DocumentId id = smithyFile.document().copyDocumentId(position); + Range insertRange = getInsertRange(id, position); + + if (cc.isCanceled()) { + return Collections.emptyList(); + } + + IdlPosition idlPosition = IdlPosition.at(smithyFile, position).orElse(null); + + if (cc.isCanceled() || idlPosition == null) { + return Collections.emptyList(); + } + + SimpleCompletions.Builder builder = SimpleCompletions.builder(id, insertRange).project(project); + + return switch (idlPosition) { + case IdlPosition.ControlKey ignored -> builder + .literalKind(CompletionItemKind.Constant) + .buildSimpleCompletions() + .getCompletionItems(Candidates.BUILTIN_CONTROLS); + + case IdlPosition.MetadataKey ignored -> builder + .literalKind(CompletionItemKind.Field) + .buildSimpleCompletions() + .getCompletionItems(Candidates.BUILTIN_METADATA); + + case IdlPosition.StatementKeyword ignored -> builder + .literalKind(CompletionItemKind.Keyword) + .buildSimpleCompletions() + .getCompletionItems(Candidates.KEYWORD); + + case IdlPosition.Namespace ignored -> builder + .literalKind(CompletionItemKind.Module) + .buildSimpleCompletions() + .getCompletionItems(Candidates.Custom.PROJECT_NAMESPACES); + + case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, builder); + + case IdlPosition.MemberName memberName -> memberNameCompletions(memberName, builder); + + default -> modelBasedCompletions(idlPosition, builder); + }; + } + + 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()) { + // TODO: This is confusing + // 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, + SimpleCompletions.Builder builder + ) { + var result = ShapeSearch.searchMetadataValue(metadataValue); + Set excludeKeys = getOtherPresentKeys(result); + Candidates candidates = Candidates.fromSearchResult(result); + return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates); + } + + private Set getOtherPresentKeys(NodeSearch.Result result) { + Syntax.Node.Kvps terminalContainer; + NodeCursor.Key terminalKey; + switch (result) { + case NodeSearch.Result.ObjectShape obj -> { + terminalContainer = obj.node(); + terminalKey = null; + } + case NodeSearch.Result.ObjectKey key -> { + terminalContainer = key.key().parent(); + terminalKey = key.key(); + } + default -> { + return null; + } + } + + Set ignoreKeys = new HashSet<>(); + terminalContainer.kvps().forEach(kvp -> { + String key = kvp.key().copyValueFrom(smithyFile.document()); + ignoreKeys.add(key); + }); + + if (terminalKey != null) { + ignoreKeys.remove(terminalKey.name()); + } + + return ignoreKeys; + } + + private List modelBasedCompletions(IdlPosition idlPosition, SimpleCompletions.Builder builder) { + if (project.modelResult().getResult().isEmpty()) { + return List.of(); + } + + Model model = project.modelResult().getResult().get(); + + if (idlPosition instanceof IdlPosition.ElidedMember elidedMember) { + return elidedMemberCompletions(elidedMember, model, builder); + } else if (idlPosition instanceof IdlPosition.TraitValue traitValue) { + return traitValueCompletions(traitValue, model, builder); + } + + Candidates candidates = shapeCandidates(idlPosition); + if (candidates instanceof Candidates.Shapes shapes) { + return builder.buildShapeCompletions(idlPosition, model).getCompletionItems(shapes); + } else if (candidates != Candidates.NONE) { + return builder.buildSimpleCompletions().getCompletionItems(candidates); + } + + return List.of(); + } + + private List elidedMemberCompletions( + IdlPosition.ElidedMember elidedMember, + Model model, + SimpleCompletions.Builder builder + ) { + Candidates candidates = getElidableMemberCandidates(elidedMember.statementIndex(), model); + if (candidates == null) { + return List.of(); + } + + Set otherMembers = SyntaxSearch.otherMemberNames( + elidedMember.smithyFile().document(), + elidedMember.smithyFile().statements(), + elidedMember.statementIndex()); + return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates); + } + + private List traitValueCompletions( + IdlPosition.TraitValue traitValue, + Model model, + SimpleCompletions.Builder builder + ) { + var result = ShapeSearch.searchTraitValue(traitValue, model); + Set excludeKeys = getOtherPresentKeys(result); + Candidates candidates = Candidates.fromSearchResult(result); + return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates); + } + + private Candidates shapeCandidates(IdlPosition idlPosition) { + return switch (idlPosition) { + case IdlPosition.UseTarget ignored -> Candidates.Shapes.USE_TARGET; + case IdlPosition.TraitId ignored -> Candidates.Shapes.TRAITS; + case IdlPosition.Mixin ignored -> Candidates.Shapes.MIXINS; + case IdlPosition.ForResource ignored -> Candidates.Shapes.RESOURCE_SHAPES; + case IdlPosition.MemberTarget ignored -> Candidates.Shapes.MEMBER_TARGETABLE; + case IdlPosition.ApplyTarget ignored -> Candidates.Shapes.ANY_SHAPE; + case IdlPosition.NodeMemberTarget nodeMemberTarget -> Candidates.fromSearchResult( + ShapeSearch.searchNodeMemberTarget(nodeMemberTarget)); + default -> Candidates.NONE; + }; + } + + private List memberNameCompletions( + IdlPosition.MemberName memberName, + SimpleCompletions.Builder builder + ) { + Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefBeforeMember( + smithyFile.statements(), + memberName.statementIndex()); + + if (shapeDef == null) { + return List.of(); + } + + String shapeType = shapeDef.shapeType().copyValueFrom(smithyFile.document()); + StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType); + + Candidates candidates = null; + if (shapeMembersDef != null) { + candidates = Candidates.membersCandidates(Builtins.MODEL, shapeMembersDef); + } + + if (project.modelResult().getResult().isPresent()) { + Candidates elidedCandidates = getElidableMemberCandidates( + memberName.statementIndex(), + project.modelResult().getResult().get()); + + if (elidedCandidates != null) { + candidates = candidates == null + ? elidedCandidates + : new Candidates.And(candidates, elidedCandidates); + } + } + + if (candidates == null) { + return List.of(); + } + + Set otherMembers = SyntaxSearch.otherMemberNames( + smithyFile.document(), + smithyFile.statements(), + memberName.statementIndex()); + return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates); + } + + private Candidates getElidableMemberCandidates(int statementIndex, Model model) { + var resourceAndMixins = ShapeSearch.findForResourceAndMixins( + SyntaxSearch.closestForResourceAndMixinsBeforeMember(smithyFile.statements(), statementIndex), + smithyFile, + model); + + Set memberNames = new HashSet<>(); + + if (resourceAndMixins.resource() != null) { + memberNames.addAll(resourceAndMixins.resource().getIdentifiers().keySet()); + memberNames.addAll(resourceAndMixins.resource().getProperties().keySet()); + } + + resourceAndMixins.mixins() + .forEach(mixinShape -> memberNames.addAll(mixinShape.getMemberNames())); + + if (memberNames.isEmpty()) { + return null; + } + + return new Candidates.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..9986f1f4 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.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.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.Project; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.model.Model; + +/** + * Handles go-to-definition requests for the Smithy IDL. + */ +public final class DefinitionHandler { + final Project project; + 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(); + return IdlPosition.at(smithyFile, position) + .flatMap(idlPosition -> ShapeSearch.findShapeDefinition(idlPosition, model, id)) + .map(LspAdapter::toLocation) + .map(List::of) + .orElse(List.of()); + } +} 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..e133c21b --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java @@ -0,0 +1,182 @@ +/* + * 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.document.Document; +import software.amazon.smithy.lsp.project.SmithyFile; +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) { + SmithyFile smithyFile = traitValue.smithyFile(); + 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", smithyFile)); + + 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", smithyFile)); + + case "smithy.test#httpResponseTests" -> Map.of( + ShapeId.from("smithy.test#HttpResponseTestCase$params"), + new OperationOutput(traitValue), + ShapeId.from("smithy.test#HttpResponseTestCase$vendorParams"), + new ShapeIdDependent("vendorParamsShape", smithyFile)); + + default -> null; + }; + } + + static Map forMetadata(String metadataKey, SmithyFile smithyFile) { + return switch (metadataKey) { + case "validators" -> Map.of( + ShapeId.from("smithy.lang.server#Validator$configuration"), new MappedDependent( + "name", + smithyFile.document(), + 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 smithyFile The file the node is within. + */ + record ShapeIdDependent(String memberName, SmithyFile smithyFile) implements DynamicMemberTarget { + @Override + public Shape getTarget(NodeCursor cursor, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor, smithyFile.document()); + if (matchingKvp.value() instanceof Syntax.Node.Str str) { + String id = str.copyValueFrom(smithyFile.document()); + return ShapeSearch.findShape(smithyFile, model, id).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 document The document the node is within. + * @param mapping A mapping of {@code memberName} values to corresponding + * member target ids. + */ + record MappedDependent(String memberName, Document document, Map mapping) + implements DynamicMemberTarget { + @Override + public Shape getTarget(NodeCursor cursor, Model model) { + Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor, document); + if (matchingKvp.value() instanceof Syntax.Node.Str str) { + String value = str.copyValueFrom(document); + 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, Document document) { + // 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().copyValueFrom(document); + 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..cabdae6b --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -0,0 +1,245 @@ +/* + * 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.Project; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.NodeCursor; +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 SmithyFile 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, SmithyFile 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; + } + + IdlPosition idlPosition = IdlPosition.at(smithyFile, position).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, model, id) + .flatMap(shape -> shape.getMember(id.copyIdValueForElidedMember())); + + default -> ShapeSearch.findShapeDefinition(idlPosition, model, id); + }; + + 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..84b9d6eb --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java @@ -0,0 +1,152 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.Optional; +import org.eclipse.lsp4j.Position; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.lsp.syntax.SyntaxSearch; + +sealed interface IdlPosition { + default boolean isEasyShapeReference() { + 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; + }; + } + + SmithyFile smithyFile(); + + record TraitId(SmithyFile smithyFile) implements IdlPosition {} + + record MemberTarget(SmithyFile smithyFile) implements IdlPosition {} + + record ShapeDef(SmithyFile smithyFile) implements IdlPosition {} + + record Mixin(SmithyFile smithyFile) implements IdlPosition {} + + record ApplyTarget(SmithyFile smithyFile) implements IdlPosition {} + + record UseTarget(SmithyFile smithyFile) implements IdlPosition {} + + record Namespace(SmithyFile smithyFile) implements IdlPosition {} + + record TraitValue( + int documentIndex, + int statementIndex, + Syntax.Statement.TraitApplication traitApplication, + SmithyFile smithyFile + ) implements IdlPosition {} + + record NodeMemberTarget( + int documentIndex, + int statementIndex, + Syntax.Statement.NodeMemberDef nodeMemberDef, + SmithyFile smithyFile + ) implements IdlPosition {} + + record ControlKey(SmithyFile smithyFile) implements IdlPosition {} + + record MetadataKey(SmithyFile smithyFile) implements IdlPosition {} + + record MetadataValue( + int documentIndex, + Syntax.Statement.Metadata metadata, + SmithyFile smithyFile + ) implements IdlPosition {} + + record StatementKeyword(SmithyFile smithyFile) implements IdlPosition {} + + record MemberName(int documentIndex, int statementIndex, SmithyFile smithyFile) implements IdlPosition {} + + record ElidedMember(int documentIndex, int statementIndex, SmithyFile smithyFile) implements IdlPosition {} + + record ForResource(SmithyFile smithyFile) implements IdlPosition {} + + static Optional at(SmithyFile smithyFile, Position position) { + int documentIndex = smithyFile.document().indexOfPosition(position); + if (documentIndex < 0) { + return Optional.empty(); + } + + int statementIndex = SyntaxSearch.statementIndex(smithyFile.statements(), documentIndex); + if (statementIndex < 0) { + return Optional.empty(); + } + + Syntax.Statement statement = smithyFile.statements().get(statementIndex); + IdlPosition idlPosition = switch (statement) { + case Syntax.Statement.Incomplete incomplete + when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(smithyFile); + + case Syntax.Statement.ShapeDef shapeDef + when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(smithyFile); + + case Syntax.Statement.Apply apply + when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(smithyFile); + + case Syntax.Statement.Metadata m + when m.key().isIn(documentIndex) -> new IdlPosition.MetadataKey(smithyFile); + + case Syntax.Statement.Metadata m + when m.value() != null && m.value().isIn(documentIndex) -> new IdlPosition.MetadataValue( + documentIndex, m, smithyFile); + + case Syntax.Statement.Control c + when c.key().isIn(documentIndex) -> new IdlPosition.ControlKey(smithyFile); + + case Syntax.Statement.TraitApplication t + when t.id().isEmpty() || t.id().isIn(documentIndex) -> new IdlPosition.TraitId(smithyFile); + + case Syntax.Statement.Use u + when u.use().isIn(documentIndex) -> new IdlPosition.UseTarget(smithyFile); + + case Syntax.Statement.MemberDef m + when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(smithyFile); + + case Syntax.Statement.MemberDef m + when m.name().isIn(documentIndex) -> new IdlPosition.MemberName( + documentIndex, statementIndex, smithyFile); + + case Syntax.Statement.NodeMemberDef m + when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget( + documentIndex, statementIndex, m, smithyFile); + + case Syntax.Statement.Namespace n + when n.namespace().isIn(documentIndex) -> new IdlPosition.Namespace(smithyFile); + + case Syntax.Statement.TraitApplication t + when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue( + documentIndex, statementIndex, t, smithyFile); + + case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember( + documentIndex, statementIndex, smithyFile); + + case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(smithyFile); + + case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(smithyFile); + + case Syntax.Statement.NodeMemberDef ignored -> new IdlPosition.MemberName( + documentIndex, statementIndex, smithyFile); + + case Syntax.Statement.Block ignored -> new IdlPosition.MemberName( + documentIndex, statementIndex, smithyFile); + + case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(smithyFile); + + default -> null; + }; + + return Optional.ofNullable(idlPosition); + } +} 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..493c80c2 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java @@ -0,0 +1,204 @@ +/* + * 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.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(); + + /** + * 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/ShapeCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java new file mode 100644 index 00000000..42571928 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java @@ -0,0 +1,266 @@ +/* + * 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.DocumentImports; +import software.amazon.smithy.lsp.document.DocumentNamespace; +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.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +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.RequiredTrait; +import software.amazon.smithy.model.traits.TraitDefinition; + +/** + * Maps {@link Candidates.Shapes} to {@link CompletionItem}s. + */ +final class ShapeCompletions { + private final Model model; + private final SmithyFile smithyFile; + private final Matcher matcher; + private final Mapper mapper; + + private ShapeCompletions(Model model, SmithyFile smithyFile, Matcher matcher, Mapper mapper) { + this.model = model; + this.smithyFile = smithyFile; + this.matcher = matcher; + this.mapper = mapper; + } + + List getCompletionItems(Candidates.Shapes candidates) { + return streamShapes(candidates) + .filter(matcher::test) + .mapMulti(mapper::accept) + .toList(); + } + + private Stream streamShapes(Candidates.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(shape -> !shape.isMemberShape() + && !shape.getId().getNamespace().contentEquals(smithyFile.namespace()) + && !smithyFile.hasImport(shape.getId().toString())); + }; + } + + static ShapeCompletions create( + IdlPosition idlPosition, + Model model, + String matchToken, + Range insertRange + ) { + AddItems addItems = AddItems.DEFAULT; + ModifyItems modifyItems = ModifyItems.DEFAULT; + + if (idlPosition instanceof IdlPosition.TraitId) { + addItems = new AddDeepTraitBodyItem(model); + } + + ToLabel toLabel; + if (shouldMatchFullId(idlPosition, matchToken)) { + toLabel = (shape) -> shape.getId().toString(); + } else { + toLabel = (shape) -> shape.getId().getName(); + modifyItems = new AddImportTextEdits(idlPosition.smithyFile()); + } + + Matcher matcher = new Matcher(matchToken, toLabel, idlPosition.smithyFile()); + Mapper mapper = new Mapper(insertRange, toLabel, addItems, modifyItems); + return new ShapeCompletions(model, idlPosition.smithyFile(), matcher, mapper); + } + + private static boolean shouldMatchFullId(IdlPosition idlPosition, String matchToken) { + return idlPosition instanceof IdlPosition.UseTarget + || matchToken.contains("#") + || matchToken.contains("."); + } + + /** + * 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 smithyFile The current Smithy file. + */ + private record Matcher(String matchToken, ToLabel toLabel, SmithyFile smithyFile) { + boolean test(Shape shape) { + return smithyFile.isAccessible(shape) && toLabel.toLabel(shape).toLowerCase().startsWith(matchToken); + } + } + + /** + * 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 DEFAULT = 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 Candidates.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 DEFAULT = 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. + */ + private static final class AddImportTextEdits implements ModifyItems { + private final SmithyFile smithyFile; + + AddImportTextEdits(SmithyFile smithyFile) { + this.smithyFile = smithyFile; + } + + @Override + public void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { + if (smithyFile.inScope(shape.getId())) { + return; + } + + // We can only know where to put the import if there's already use statements, or a namespace + smithyFile.documentImports().map(DocumentImports::importsRange) + .or(() -> smithyFile.documentNamespace().map(DocumentNamespace::statementRange)) + .ifPresent(range -> { + 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..0ebb3967 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -0,0 +1,309 @@ +/* + * 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.Syntax; +import software.amazon.smithy.lsp.syntax.SyntaxSearch; +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 smithyFile}'s namespace, in {@code smithyFile}'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 smithyFile The file {@code nameOrId} is within. + * @param model The model to search. + * @param nameOrId The name or shape id of the shape to find. + * @return The shape, if found. + */ + static Optional findShape(SmithyFile smithyFile, Model model, String nameOrId) { + 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(smithyFile.namespace().toString(), s) + .flatMap(model::getShape); + if (fromCurrent.isPresent()) { + yield fromCurrent; + } + + for (String fileImport : smithyFile.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.fromParts(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 model The model to search for shapes in. + * @param id The identifier at {@code idlPosition}. + * @return The shape, if found. + */ + static Optional findShapeDefinition(IdlPosition idlPosition, Model model, DocumentId id) { + 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.smithyFile(), m, id.copyIdValue()); + } 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.smithyFile(), model, id.copyIdValue()); + } + yield Optional.empty(); + } + + // Note: This could be made more specific, at least for mixins + case IdlPosition.ElidedMember elidedMember -> + findElidedMemberParent(elidedMember, model, id); + + case IdlPosition pos when pos.isEasyShapeReference() -> + findShape(pos.smithyFile(), model, id.copyIdValue()); + + default -> Optional.empty(); + }; + } + + record ForResourceAndMixins(ResourceShape resource, List mixins) {} + + static ForResourceAndMixins findForResourceAndMixins( + SyntaxSearch.ForResourceAndMixins forResourceAndMixins, + SmithyFile smithyFile, + Model model + ) { + ResourceShape resourceShape = null; + if (forResourceAndMixins.forResource() != null) { + String resourceNameOrId = forResourceAndMixins.forResource() + .resource() + .copyValueFrom(smithyFile.document()); + + resourceShape = findShape(smithyFile, model, resourceNameOrId) + .flatMap(Shape::asResourceShape) + .orElse(null); + } + List mixins = List.of(); + if (forResourceAndMixins.mixins() != null) { + mixins = new ArrayList<>(forResourceAndMixins.mixins().mixins().size()); + for (Syntax.Ident ident : forResourceAndMixins.mixins().mixins()) { + String mixinNameOrId = ident.copyValueFrom(smithyFile.document()); + findShape(smithyFile, model, mixinNameOrId).ifPresent(mixins::add); + } + } + + return new ForResourceAndMixins(resourceShape, mixins); + } + + /** + * @param elidedMember The elided member position + * @param model The model to search in + * @param id The identifier of the elided member + * @return The shape the elided member comes from, if found. + */ + static Optional findElidedMemberParent( + IdlPosition.ElidedMember elidedMember, + Model model, + DocumentId id + ) { + var forResourceAndMixins = findForResourceAndMixins( + SyntaxSearch.closestForResourceAndMixinsBeforeMember( + elidedMember.smithyFile().statements(), + elidedMember.statementIndex()), + elidedMember.smithyFile(), + model); + + String searchToken = id.copyIdValueForElidedMember(); + + // TODO: Handle ambiguity + Optional foundResource = Optional.ofNullable(forResourceAndMixins.resource()) + .filter(shape -> shape.getIdentifiers().containsKey(searchToken) + || shape.getProperties().containsKey(searchToken)); + if (foundResource.isPresent()) { + return foundResource; + } + + return forResourceAndMixins.mixins() + .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 = SyntaxSearch.closestShapeDefAfterTrait( + traitValue.smithyFile().statements(), + traitValue.statementIndex()); + + if (shapeDef == null) { + return Optional.empty(); + } + + String shapeName = shapeDef.shapeName().copyValueFrom(traitValue.smithyFile().document()); + return findShape(traitValue.smithyFile(), model, shapeName); + } + + /** + * @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().copyValueFrom(metadataValue.smithyFile().document()); + Shape metadataValueShapeDef = Builtins.getMetadataValue(metadataKey); + if (metadataValueShapeDef == null) { + return NodeSearch.Result.NONE; + } + + NodeCursor cursor = NodeCursor.create( + metadataValue.smithyFile().document(), + metadataValue.metadata().value(), + metadataValue.documentIndex()); + var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey, metadataValue.smithyFile()); + 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 = SyntaxSearch.closestShapeDefBeforeMember( + nodeMemberTarget.smithyFile().statements(), + nodeMemberTarget.statementIndex()); + + if (shapeDef == null) { + return NodeSearch.Result.NONE; + } + + String shapeType = shapeDef.shapeType().copyValueFrom(nodeMemberTarget.smithyFile().document()); + String memberName = nodeMemberTarget.nodeMemberDef() + .name() + .copyValueFrom(nodeMemberTarget.smithyFile().document()); + 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.nodeMemberDef().value() == null) { + return new NodeSearch.Result.TerminalShape(memberShapeDef, Builtins.MODEL); + } + + NodeCursor cursor = NodeCursor.create( + nodeMemberTarget.smithyFile().document(), + nodeMemberTarget.nodeMemberDef().value(), + nodeMemberTarget.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.traitApplication().id().copyValueFrom(traitValue.smithyFile().document()); + Optional maybeTraitShape = findShape(traitValue.smithyFile(), model, traitName); + if (maybeTraitShape.isEmpty()) { + return NodeSearch.Result.NONE; + } + + Shape traitShape = maybeTraitShape.get(); + NodeCursor cursor = NodeCursor.create( + traitValue.smithyFile().document(), + traitValue.traitApplication().value(), + traitValue.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/SimpleCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java new file mode 100644 index 00000000..c1de0327 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java @@ -0,0 +1,230 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +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.document.DocumentId; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.util.StreamUtils; +import software.amazon.smithy.model.Model; + +final class SimpleCompletions { + private final Project project; + private final Matcher matcher; + private final Mapper mapper; + + private SimpleCompletions(Project project, Matcher matcher, Mapper mapper) { + this.project = project; + this.matcher = matcher; + this.mapper = mapper; + } + + List getCompletionItems(Candidates candidates) { + return switch (candidates) { + case Candidates.Constant(var value) + when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value)); + + case Candidates.Literals(var literals) -> literals.stream() + .filter(matcher::testLiteral) + .map(mapper::literal) + .toList(); + + case Candidates.Labeled(var labeled) -> labeled.entrySet().stream() + .filter(matcher::testLabeled) + .map(mapper::labeled) + .toList(); + + case Candidates.Members(var members) -> members.entrySet().stream() + .filter(matcher::testMember) + .map(mapper::member) + .toList(); + + case Candidates.ElidedMembers(var memberNames) -> memberNames.stream() + .filter(matcher::testElided) + .map(mapper::elided) + .toList(); + + case Candidates.Custom custom + // TODO: Need to get rid of this stupid null check + when project != null -> getCompletionItems(customCandidates(custom)); + + case Candidates.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 Candidates customCandidates(Candidates.Custom custom) { + return switch (custom) { + case NAMESPACE_FILTER -> new Candidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces()) + .collect(StreamUtils.toWrappedMap())); + + case VALIDATOR_NAME -> Candidates.VALIDATOR_NAMES; + + case PROJECT_NAMESPACES -> new Candidates.Literals(streamNamespaces().toList()); + }; + } + + private Stream streamNamespaces() { + return project.smithyFiles().values().stream() + .map(smithyFile -> smithyFile.namespace().toString()) + .filter(namespace -> !namespace.isEmpty()); + } + + static Builder builder(DocumentId id, Range insertRange) { + return new Builder(id, insertRange); + } + + static final class Builder { + private final DocumentId id; + private final Range insertRange; + private Project project = null; + private Set exclude = null; + private CompletionItemKind literalKind = CompletionItemKind.Field; + + private Builder(DocumentId id, Range insertRange) { + this.id = id; + this.insertRange = insertRange; + } + + Builder project(Project project) { + this.project = project; + return this; + } + + Builder exclude(Set exclude) { + this.exclude = exclude; + return this; + } + + Builder literalKind(CompletionItemKind literalKind) { + this.literalKind = literalKind; + return this; + } + + SimpleCompletions buildSimpleCompletions() { + Matcher matcher = getMatcher(id, exclude); + Mapper mapper = new Mapper(insertRange, literalKind); + return new SimpleCompletions(project, matcher, mapper); + } + + ShapeCompletions buildShapeCompletions(IdlPosition idlPosition, Model model) { + return ShapeCompletions.create(idlPosition, model, getMatchToken(id), insertRange); + } + } + + private static Matcher getMatcher(DocumentId id, Set exclude) { + String matchToken = getMatchToken(id); + if (exclude == null || exclude.isEmpty()) { + return new DefaultMatcher(matchToken); + } else { + return new ExcludingMatcher(matchToken, exclude); + } + } + + private static String getMatchToken(DocumentId id) { + return id != null + ? id.copyIdValue().toLowerCase() + : ""; + } + + private sealed interface Matcher extends Predicate { + 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); + } + + @Override + 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); + } + } + + private record Mapper(Range insertRange, CompletionItemKind literalKind) { + CompletionItem constant(String value) { + return textEditCompletion(value, CompletionItemKind.Constant); + } + + CompletionItem literal(String value) { + return textEditCompletion(value, CompletionItemKind.Field); + } + + 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/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java index 86b8b550..dafbb7ba 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -33,6 +33,7 @@ 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.syntax.Syntax; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.ModelAssembler; @@ -253,8 +254,10 @@ public static SmithyFile.Builder buildSmithyFile(String path, Document document, DocumentParser documentParser = DocumentParser.forDocument(document); DocumentNamespace namespace = documentParser.documentNamespace(); DocumentImports imports = documentParser.documentImports(); - Map documentShapes = documentParser.documentShapes(shapes); + Map documentShapes = documentParser.documentShapes(); DocumentVersion documentVersion = documentParser.documentVersion(); + Syntax.IdlParse parse = Syntax.parseIdl(document); + List statements = parse.statements(); return SmithyFile.builder() .path(path) .document(document) @@ -262,7 +265,9 @@ public static SmithyFile.Builder buildSmithyFile(String path, Document document, .namespace(namespace) .imports(imports) .documentShapes(documentShapes) - .documentVersion(documentVersion); + .documentVersion(documentVersion) + .statements(statements); + // .changeVersion(document.changeVersion()); } // This is gross, but necessary to deal with the way that array metadata gets merged. 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..ba6d3680 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -7,6 +7,7 @@ import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -16,7 +17,11 @@ 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.lsp.syntax.Syntax; +import software.amazon.smithy.model.loader.Prelude; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.PrivateTrait; /** * The language server's representation of a Smithy file. @@ -34,6 +39,8 @@ public final class SmithyFile implements ProjectFile { private final DocumentImports imports; private final Map documentShapes; private final DocumentVersion documentVersion; + private List statements; + private int changeVersion; private SmithyFile(Builder builder) { this.path = builder.path; @@ -43,6 +50,8 @@ private SmithyFile(Builder builder) { this.imports = builder.imports; this.documentShapes = builder.documentShapes; this.documentVersion = builder.documentVersion; + this.statements = builder.statements; + this.changeVersion = builder.changeVersion; } /** @@ -141,6 +150,44 @@ public boolean hasImport(String shapeId) { return imports.imports().contains(shapeId); } + public boolean isAccessible(Shape shape) { + return shape.getId().getNamespace().contentEquals(namespace()) + || !shape.hasTrait(PrivateTrait.ID); + } + + public int changeVersion() { + return changeVersion; + } + + public void setChangeVersion(int changeVersion) { + this.changeVersion = changeVersion; + } + + /** + * @return The parsed statements in this file + */ + public List statements() { + return statements; + } + + /** + * Re-parses the underlying {@link #document()}, updating {@link #statements()}. + */ + public void reparse() { + Syntax.IdlParse parse = Syntax.parseIdl(document); + this.statements = parse.statements(); + } + + /** + * @param shapeId The shape id to check + * @return Whether the given shape id is in scope for this file + */ + public boolean inScope(ShapeId shapeId) { + return Prelude.isPublicPreludeShape(shapeId) + || shapeId.getNamespace().contentEquals(namespace()) + || hasImport(shapeId.toString()); + } + /** * @return A {@link SmithyFile} builder */ @@ -156,6 +203,8 @@ public static final class Builder { private DocumentImports imports; private Map documentShapes; private DocumentVersion documentVersion; + private List statements; + private int changeVersion; private Builder() { } @@ -195,6 +244,16 @@ public Builder documentVersion(DocumentVersion documentVersion) { return this; } + public Builder statements(List statements) { + this.statements = statements; + return this; + } + + public Builder changeVersion(int changeVersion) { + this.changeVersion = changeVersion; + return this; + } + public SmithyFile build() { return new SmithyFile(this); } 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..106fa18d 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,7 @@ import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; +import software.amazon.smithy.model.FromSourceLocation; import software.amazon.smithy.model.SourceLocation; /** @@ -126,10 +127,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..a8e4edb9 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java @@ -0,0 +1,224 @@ +/* + * 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; +import software.amazon.smithy.lsp.document.Document; + +/** + * 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 document The document the node value is within + * @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(Document document, 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.copyValueFrom(document); + 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.copyValueFrom(document); + 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.copyValueFrom(document), 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.get(0) 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..087050f1 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -0,0 +1,1010 @@ +/* + * 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(); + 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) { + errors.add(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()); + return new Syntax.Ident(start, position()); + } + + 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) { + errors.add(kvpErr); + } + + ws(); + } + + Syntax.Node.Err err = new Syntax.Node.Err("missing }"); + setStart(err); + setEnd(err); + errors.add(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) { + errors.add(err); + } + + err = nodeErr("expected :"); + } + + if (is(close)) { + if (err != null) { + errors.add(err); + } + + return nodeErr("expected value"); + } + + if (is(',')) { + skip(); + if (kvp != null) { + setEnd(kvp); + } + if (err != null) { + errors.add(err); + } + + return nodeErr("expected value"); + } + + Syntax.Node value = parseNode(); + if (value instanceof Syntax.Node.Err e) { + if (err != null) { + errors.add(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) { + errors.add(e); + } else { + arr.elements.add(elem); + } + ws(); + } + + Syntax.Node.Err err = nodeErr("missing ]"); + errors.add(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); + Syntax.Node.Str str = new Syntax.Node.Str(); + str.start = start; + setEnd(str); + return str; + } + + skip(); + Syntax.Node.Str str = new Syntax.Node.Str(); + str.start = start; + setEnd(str); + return str; + } + + int last = '"'; + + // Potential micro-optimization - only loop while position < line end + while (!isNl() && !eof()) { + if (is('"') && last != '\\') { + skip(); // '"' + Syntax.Node.Str str = new Syntax.Node.Str(); + str.start = start; + setEnd(str); + return str; + } + 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 statement() { + if (is('@')) { + traitApplication(null); + } else if (is('$')) { + control(); + } else { + // Shape, apply + int start = position(); + Syntax.Ident ident = ident(); + if (ident.isEmpty()) { + if (!isWs()) { + skip(); + } + return; + } + + sp(); + Syntax.Ident name = ident(); + if (name.isEmpty()) { + Syntax.Statement.Incomplete incomplete = new Syntax.Statement.Incomplete(ident); + incomplete.start = start; + incomplete.end = position(); + statements.add(incomplete); + + if (!isWs()) { + skip(); + } + return; + } + + String identCopy = ident.copyValueFrom(document); + + 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); + statements.add(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); + statements.add(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); + statements.add(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); + statements.add(opMemberDef); + } else { + var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName); + nodeMemberDef.start = opMemberStart; + nodeMemberDef.colonPos = colonPos; + nodeMemberDef.value = parseNode(); + setEnd(nodeMemberDef); + statements.add(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; + statements.add(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); + statements.add(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; + statements.add(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); + statements.add(use); + } + + private void namespace(int start, Syntax.Ident name) { + Syntax.Statement.Namespace namespace = new Syntax.Statement.Namespace(name); + namespace.start = start; + setEnd(namespace); + statements.add(namespace); + } + + private void optionalForResourceAndMixins() { + int maybeStart = position(); + Syntax.Ident maybe = optIdent(); + + if (maybe.copyValueFrom(document).equals("for")) { + sp(); + Syntax.Ident resource = ident(); + Syntax.Statement.ForResource forResource = new Syntax.Statement.ForResource(resource); + forResource.start = maybeStart; + statements.add(forResource); + ws(); + setEnd(forResource); + maybeStart = position(); + maybe = optIdent(); + } + + if (maybe.copyValueFrom(document).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); + statements.add(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); + statements.add(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; + statements.add(memberDef); + + sp(); + if (is(':')) { + memberDef.colonPos = position(); + skip(); + } else { + addErr(position(), position(), "expected :"); + if (isWs() || is('}')) { + setEnd(memberDef); + statements.add(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; + statements.add(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); + statements.add(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); + statements.add(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); + statements.add(nodeMemberDef); + return; + } + } + + ws(); + if (is('}')) { + addErr(nodeMemberDef.colonPos, nodeMemberDef.colonPos, "expected node"); + } else { + nodeMemberDef.value = parseNode(); + } + setEnd(nodeMemberDef); + statements.add(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; + statements.add(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); + } + + private void addErr(int start, int end, String message) { + Syntax.Statement.Err err = new Syntax.Statement.Err(message); + err.start = start; + err.end = end; + errors.add(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/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java new file mode 100644 index 00000000..d6740fbf --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -0,0 +1,765 @@ +/* + * 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 org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.Document; + +/** + * 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 Syntax.IdlParse}, 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). + * - Concrete text is not stored in {@link Statement}s. Instead, + * {@link Statement#start} and {@link Statement#end} can be used to copy a + * value from the underlying document as needed. This is done to reduce the + * memory footprint of parsing. + *

+ *

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() { + } + + public record IdlParse(List statements, List errors) {} + + public record NodeParse(Node value, List errors) {} + + /** + * @param document The document to parse + * @return The IDL parse result + */ + public static IdlParse parseIdl(Document document) { + Parser parser = new Parser(document); + parser.parseIdl(); + return new IdlParse(parser.statements, parser.errors); + } + + /** + * @param document The document to parse + * @return The Node parse result + */ + public static NodeParse parseNode(Document document) { + Parser parser = new Parser(document); + Node node = parser.parseNode(); + return new NodeParse(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; + } + + /** + * @param document The document to get the range in + * @return The range of this item in the given {@code document} + */ + public final Range rangeIn(Document document) { + return document.rangeBetween(start, end); + } + } + + /** + * 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 { + /** + * @param document Document to copy the string value from + * @return The literal string value, excluding enclosing "" + */ + public String copyValueFrom(Document document) { + return document.copySpan(start + 1, end - 1); // Don't include the '"'s + } + } + + /** + * 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.

+ * + * @see SyntaxSearch#statementIndex(List, int) + */ + 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) { + this.start = start; + this.end = end; + } + + public boolean isEmpty() { + return (start - end) == 0; + } + + @Override + public String copyValueFrom(Document document) { + if (start < 0 && end < 0) { + return ""; + } + return document.copySpan(start, end); // There's no '"'s here + } + } + + /** + * 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/syntax/SyntaxSearch.java b/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java new file mode 100644 index 00000000..ae3720b4 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java @@ -0,0 +1,214 @@ +/* + * 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.Set; +import software.amazon.smithy.lsp.document.Document; + +/** + * Low-level API to query specific information about {@link Syntax.Statement}s + * and {@link Syntax.Node}s. + */ +public final class SyntaxSearch { + private SyntaxSearch() { + } + + /** + * @param statements The statements to search + * @param position The character offset in the document + * @return The index of the statement in the list of statements that the + * given position is within, or -1 if it was not found. + */ + public 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; + } + + /** + * @param statements The statements to search + * @param memberStatementIndex The index of the statement to search from + * @return The closest shape def statement appearing before the given index + * or {@code null} if none was found. + */ + public static Syntax.Statement.ShapeDef closestShapeDefBeforeMember( + List statements, + int memberStatementIndex + ) { + int searchStatementIdx = memberStatementIndex - 1; + while (searchStatementIdx >= 0) { + Syntax.Statement searchStatement = statements.get(searchStatementIdx); + if (searchStatement instanceof Syntax.Statement.ShapeDef shapeDef) { + return shapeDef; + } + searchStatementIdx--; + } + return null; + } + + /** + * @param forResource The nullable for-resource statement + * @param mixins The nullable mixins statement + */ + public record ForResourceAndMixins(Syntax.Statement.ForResource forResource, Syntax.Statement.Mixins mixins) {} + + /** + * @param statements The statements to search + * @param memberStatementIndex The index of the statement to search from + * @return The closest adjacent {@link Syntax.Statement.ForResource} and + * {@link Syntax.Statement.Mixins} to the statement at the given index. + */ + public static ForResourceAndMixins closestForResourceAndMixinsBeforeMember( + List statements, + int memberStatementIndex + ) { + int searchStatementIndex = memberStatementIndex; + while (searchStatementIndex >= 0) { + Syntax.Statement searchStatement = 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 = 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 ForResourceAndMixins(forResource, mixins); + } + searchStatementIndex--; + } + + return new ForResourceAndMixins(null, null); + } + + /** + * @param document The document to search within + * @param statements The statements to search + * @param memberStatementIndex The index of the member statement to search around + * @return The names of other members around (but not including) the member at + * {@code memberStatementIndex}. + */ + public static Set otherMemberNames( + Document document, + List statements, + int memberStatementIndex + ) { + Set found = new HashSet<>(); + int searchIndex = memberStatementIndex; + int lastMemberStatementIndex = memberStatementIndex; + while (searchIndex >= 0) { + Syntax.Statement statement = statements.get(searchIndex); + if (statement instanceof Syntax.Statement.Block block) { + lastMemberStatementIndex = block.lastStatementIndex(); + break; + } else if (searchIndex != memberStatementIndex) { + addMemberName(document, found, statement); + } + searchIndex--; + } + searchIndex = memberStatementIndex + 1; + while (searchIndex <= lastMemberStatementIndex) { + Syntax.Statement statement = statements.get(searchIndex); + addMemberName(document, found, statement); + searchIndex++; + } + return found; + } + + private static void addMemberName(Document document, Set memberNames, Syntax.Statement statement) { + switch (statement) { + case Syntax.Statement.MemberDef def -> memberNames.add(def.name().copyValueFrom(document)); + case Syntax.Statement.NodeMemberDef def -> memberNames.add(def.name().copyValueFrom(document)); + case Syntax.Statement.InlineMemberDef def -> memberNames.add(def.name().copyValueFrom(document)); + case Syntax.Statement.ElidedMemberDef def -> memberNames.add(def.name().copyValueFrom(document)); + default -> { + } + } + } + + /** + * @param statements The statements to search + * @param traitStatementIndex The index of the trait statement to search from + * @return The closest shape def statement after {@code traitStatementIndex}, + * or null if none was found. + */ + public static Syntax.Statement.ShapeDef closestShapeDefAfterTrait( + List statements, + int traitStatementIndex + ) { + for (int i = traitStatementIndex + 1; i < statements.size(); i++) { + Syntax.Statement statement = 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/util/StreamUtils.java b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java new file mode 100644 index 00000000..86b0d669 --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java @@ -0,0 +1,23 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.util; + +import java.util.Map; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +public final class StreamUtils { + private StreamUtils() { + } + + public static Collector, ?, Map> toMap() { + return Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue); + } + + public static Collector> toWrappedMap() { + return Collectors.toMap(s -> s, s -> "\"" + s + "\""); + } +} 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..dde93b90 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java @@ -185,7 +185,7 @@ public void completionImports() throws Exception { CompletionParams completionParams = new RequestBuilders.PositionRequest() .uri(uri) .line(4) - .character(10) + .character(11) .buildCompletion(); List completions = server.completion(completionParams).get().getLeft(); @@ -1827,7 +1827,7 @@ public void useCompletionDoesntAutoImport() throws Exception { List completions = server.completion(RequestBuilders.positionRequest() .uri(uri) .line(2) - .character(5) + .character(6) .buildCompletion()) .get() .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..0891145b 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,16 @@ 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 +23,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")); @@ -148,7 +53,7 @@ public void getsDocumentNamespace() { 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(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 17))); } @Test @@ -206,7 +111,7 @@ public void getsDocumentVersion() { assertThat(noVersion.documentVersion(), nullValue()); assertThat(notVersion.documentVersion(), nullValue()); assertThat(noDollar.documentVersion(), nullValue()); - assertThat(noColon.documentVersion(), nullValue()); + assertThat(noColon.documentVersion().version(), equalTo("2")); assertThat(commented.documentVersion(), nullValue()); assertThat(leadingWs.documentVersion().version(), equalTo("2")); assertThat(leadingLines.documentVersion().version(), equalTo("2")); @@ -259,16 +164,8 @@ enum Baz { } } """; - 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); + Map documentShapes = parser.documentShapes(); DocumentShape fooDef = documentShapes.get(new Position(2, 7)); DocumentShape barDef = documentShapes.get(new Position(3, 10)); @@ -285,7 +182,6 @@ enum Baz { 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)); @@ -321,7 +217,6 @@ enum Baz { assertThat(parser.getDocument().borrowRange(elided.range()), string("$elided")); assertThat(get.kind(), equalTo(DocumentShape.Kind.DefinedShape)); assertThat(get.shapeName(), string("Get")); - assertThat(getInput.kind(), equalTo(DocumentShape.Kind.Inline)); assertThat(getInputA.kind(), equalTo(DocumentShape.Kind.DefinedMember)); assertThat(getInputA.shapeName(), string("a")); } diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java new file mode 100644 index 00000000..a633888c --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -0,0 +1,1062 @@ +/* + * 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.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +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.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; + +public class CompletionHandlerTest { + @Test + public void getsCompletions() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("bar", "baz")); + } + + @Test + public void completesTraitMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: Integer + } + + @foo(bar: %) + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("\"\"")); + } + + @Test + public void completesMetadataMemberValues() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace:% + }]"""); + List comps = getCompLabels(text); + + assertThat(comps, not(empty())); + } + + @Test + public void doesntDuplicateTraitBodyMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + structure foo { + bar: String + baz: String + } + + @foo(bar: "", ba%)"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("baz")); + } + + @Test + public void doesntDuplicateMetadataMembers() { + TextWithPositions text = TextWithPositions.from(""" + metadata suppressions = [{ + namespace: "foo" + %}] + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("id", "reason")); + } + + @Test + public void doesntDuplicateListAndMapMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + list L { + member: String + %} + map M { + key: String + %} + """); + List comps = getCompLabels(text); + + + assertThat(comps, contains("value")); + } + + @Test + public void doesntDuplicateOperationMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + operation O { + input := {} + %} + """); + List comps = getCompLabels(text); + assertThat(comps, containsInAnyOrder("output", "errors")); + } + + @Test + public void doesntDuplicateServiceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + service S { + version: "2024-08-31" + operations: [] + %} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("rename", "resources", "errors")); + } + + @Test + public void doesntDuplicateResourceMembers() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + resource R { + identifiers: {} + properties: {} + read: Op + create: Op + %} + + operation Op {} + """); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + "list", "put", "delete", "update", "collectionOperations", "operations", "resources")); + } + + @Test + public void completesEnumTraitValues() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @trait + enum foo { + ONE + TWO + THREE + } + + @foo(T%) + """); + List comps = getCompItems(text.text(), text.positions()); + + List labels = comps.stream().map(CompletionItem::getLabel).toList(); + List editText = comps.stream() + .map(completionItem -> { + if (completionItem.getTextEdit() != null) { + return completionItem.getTextEdit().getLeft().getNewText(); + } else { + return completionItem.getInsertText(); + } + }).toList(); + + assertThat(labels, containsInAnyOrder("TWO", "THREE")); + assertThat(editText, containsInAnyOrder("\"TWO\"", "\"THREE\"")); + // TODO: Fix this issue where the string is inserted within the enclosing "" + } + + @Test + public void completesFromSingleCharacter() { + TextWithPositions text = TextWithPositions.from(""" + $version: "2" + namespace com.foo + + @http(m%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("method")); + } + + @Test + public void completesBuiltinControlKeys() { + TextWithPositions text = TextWithPositions.from(""" + $ver% + $ope%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder( + startsWith("$version: \"2.0\""), + startsWith("$operationInputSuffix: \"Input\""), + startsWith("$operationOutputSuffix: \"Output\""))); + } + + @Test + public void completesBuiltinMetadataKeys() { + TextWithPositions text = TextWithPositions.from(""" + metadata su% + metadata va%"""); + List comps = getCompLabels(text); + + assertThat(comps, containsInAnyOrder("suppressions = []", "validators = []")); + } + + @Test + public void completesStatementKeywords() { + TextWithPositions text = TextWithPositions.from(""" + us% + ma% + met% + nam% + blo% + boo% + str% + byt% + sho% + int% + lon% + flo% + dou% + big% + tim% + doc% + enu% + lis% + uni% + ser% + res% + ope% + app%"""); + List comps = getCompLabels(text); + + String[] keywords = Candidates.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 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("\"\"", "[]", "{}", "[]")); + } + + 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"); + SmithyFile smithyFile = 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..5383d527 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -0,0 +1,367 @@ +/* + * 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.Project; +import software.amazon.smithy.lsp.project.ProjectLoader; +import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.Syntax; +import software.amazon.smithy.lsp.syntax.SyntaxSearch; + +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"); + } + + 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)); + + int statementIndex = SyntaxSearch.statementIndex(smithyFile.statements(), documentIndex); + assertThat(statementIndex, greaterThanOrEqualTo(0)); + + var statement = smithyFile.statements().get(statementIndex); + if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { + String shapeType = shapeDef.shapeType().copyValueFrom(smithyFile.document()); + String shapeName = shapeDef.shapeName().copyValueFrom(smithyFile.document()); + assertThat(shapeType + " " + shapeName, equalTo(expected)); + } else if (statement instanceof Syntax.Statement.MemberDef memberDef) { + String memberName = memberDef.name().copyValueFrom(smithyFile.document()); + String memberTarget = memberDef.target().copyValueFrom(smithyFile.document()); + 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, 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/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java new file mode 100644 index 00000000..026f3f93 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -0,0 +1,127 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.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.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"))); + } + + 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, 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..bffea311 100644 --- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java @@ -133,11 +133,11 @@ public void loadsWhenModelHasInvalidSyntax() { .collect(Collectors.toList()); assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar")); - assertThat(main.documentShapes(), hasSize(3)); + assertThat(main.documentShapes(), hasSize(4)); List documentShapeNames = main.documentShapes().stream() .map(documentShape -> documentShape.shapeName().toString()) .collect(Collectors.toList()); - assertThat(documentShapeNames, hasItems("Foo", "bar", "String")); + assertThat(documentShapeNames, hasItems("Foo", "bar", "String", "A")); } @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..a41a836e --- /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.IdlParse 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.IdlParse 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.IdlParse 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..e6b7dabe --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java @@ -0,0 +1,375 @@ +/* + * 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.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.copyValueFrom(document); + 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.copyValueFrom(document); + 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.NodeParse 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); + } + + 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..cc8d9c16 --- /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(document, 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(document, 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); + } + } +} From 25d9a593f2212fdf5d029e3619c7a1278da143ef Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Tue, 5 Nov 2024 15:48:36 -0500 Subject: [PATCH 2/5] Address feedback --- ...didates.java => CompletionCandidates.java} | 86 +++++++++++-------- .../lsp/language/CompletionHandler.java | 46 ++++------ .../smithy/lsp/language/ShapeCompletions.java | 8 +- .../lsp/language/SimpleCompletions.java | 28 +++--- .../smithy/lsp/project/ProjectLoader.java | 1 - .../amazon/smithy/lsp/project/SmithyFile.java | 16 ---- .../amazon/smithy/lsp/util/StreamUtils.java | 9 +- .../lsp/language/CompletionHandlerTest.java | 2 +- 8 files changed, 92 insertions(+), 104 deletions(-) rename src/main/java/software/amazon/smithy/lsp/language/{Candidates.java => CompletionCandidates.java} (72%) diff --git a/src/main/java/software/amazon/smithy/lsp/language/Candidates.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java similarity index 72% rename from src/main/java/software/amazon/smithy/lsp/language/Candidates.java rename to src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java index f59fa78d..c18e3280 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/Candidates.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java @@ -22,13 +22,13 @@ import software.amazon.smithy.model.traits.IdRefTrait; /** - * Candidates for code-completions. + * 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 Candidates { +sealed interface CompletionCandidates { Constant NONE = new Constant(""); Constant EMPTY_STRING = new Constant("\"\""); Constant EMPTY_OBJ = new Constant("{}"); @@ -43,14 +43,12 @@ sealed interface Candidates { "apply")); // TODO: Maybe BUILTIN_CONTROLS and BUILTIN_METADATA should be regular // Labeled/Members, with custom mappers. - Literals BUILTIN_CONTROLS = new Candidates.Literals( - Builtins.CONTROL.members().stream() - .map(member -> "$" + member.getMemberName() + ": " + Candidates.defaultCandidates(member).value()) - .toList()); - Literals BUILTIN_METADATA = new Candidates.Literals( - Builtins.METADATA.members().stream() - .map(member -> member.getMemberName() + " = []") - .toList()); + 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() @@ -64,7 +62,7 @@ sealed interface Candidates { * @return A constant value corresponding to the 'default' or 'empty' value * of a shape. */ - static Candidates.Constant defaultCandidates(Shape shape) { + static Constant defaultCandidates(Shape shape) { if (shape.hasTrait(DefaultTrait.class)) { DefaultTrait defaultTrait = shape.expectTrait(DefaultTrait.class); return new Constant(Node.printJson(defaultTrait.toNode())); @@ -85,7 +83,7 @@ static Candidates.Constant defaultCandidates(Shape shape) { * @param result The search result to get candidates from. * @return The completion candidates for {@code result}. */ - static Candidates fromSearchResult(NodeSearch.Result result) { + static CompletionCandidates fromSearchResult(NodeSearch.Result result) { return switch (result) { case NodeSearch.Result.TerminalShape(Shape shape, var ignored) -> terminalCandidates(shape); @@ -98,39 +96,53 @@ static Candidates fromSearchResult(NodeSearch.Result result) { case NodeSearch.Result.ArrayShape(var ignored, ListShape shape, Model model) -> model.getShape(shape.getMember().getTarget()) - .map(Candidates::terminalCandidates) + .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 Candidates membersCandidates(Model model, Shape shape) { + static CompletionCandidates membersCandidates(Model model, Shape shape) { if (shape.isStructureShape() || shape.isUnionShape()) { return new Members(shape.getAllMembers().entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> model.getShape(entry.getValue().getTarget()) - .map(Candidates::defaultCandidates) - .orElse(NONE)))); + .collect(StreamUtils.mappingValue(member -> model.getShape(member.getTarget()) + .map(CompletionCandidates::defaultCandidates) + .orElse(NONE)))); } else if (shape instanceof MapShape mapShape) { - EnumShape enumKey = model.getShape(mapShape.getKey().getTarget()) + return model.getShape(mapShape.getKey().getTarget()) .flatMap(Shape::asEnumShape) - .orElse(null); - if (enumKey != null) { - return terminalCandidates(enumKey); - } + .map(CompletionCandidates::terminalCandidates) + .orElse(NONE); } return NONE; } - private static Candidates terminalCandidates(Shape shape) { + private static CompletionCandidates terminalCandidates(Shape shape) { Builtins.BuiltinShape builtinShape = Builtins.BUILTIN_SHAPES.get(shape.getId()); if (builtinShape != null) { return forBuiltin(builtinShape); @@ -155,7 +167,7 @@ private static Candidates terminalCandidates(Shape shape) { }; } - private static Candidates forBuiltin(Builtins.BuiltinShape builtinShape) { + private static CompletionCandidates forBuiltin(Builtins.BuiltinShape builtinShape) { return switch (builtinShape) { case SmithyIdlVersion -> SMITHY_IDL_VERSION; case AnyNamespace -> Custom.NAMESPACE_FILTER; @@ -176,40 +188,46 @@ private static Candidates forBuiltin(Builtins.BuiltinShape builtinShape) { * * @param value The completion value. */ - record Constant(String value) implements Candidates {} + 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 Candidates {} + 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 Candidates {} + 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 Candidates {} + 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 Candidates {} + record ElidedMembers(Collection memberNames) implements CompletionCandidates {} /** * A combination of two sets of completion candidates, of possibly different @@ -218,13 +236,13 @@ record ElidedMembers(Collection memberNames) implements Candidates {} * @param one The first set of completion candidates. * @param two The second set of completion candidates. */ - record And(Candidates one, Candidates two) implements 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 Candidates { + enum Shapes implements CompletionCandidates { ANY_SHAPE, USE_TARGET, TRAITS, @@ -239,7 +257,7 @@ enum Shapes implements Candidates { /** * Candidates that require a custom computation to generate, lazily. */ - enum Custom implements Candidates { + 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 index 182e1f10..f43d1bc6 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -72,22 +72,22 @@ public List handle(CompletionParams params, CancelChecker cc) { case IdlPosition.ControlKey ignored -> builder .literalKind(CompletionItemKind.Constant) .buildSimpleCompletions() - .getCompletionItems(Candidates.BUILTIN_CONTROLS); + .getCompletionItems(CompletionCandidates.BUILTIN_CONTROLS); case IdlPosition.MetadataKey ignored -> builder .literalKind(CompletionItemKind.Field) .buildSimpleCompletions() - .getCompletionItems(Candidates.BUILTIN_METADATA); + .getCompletionItems(CompletionCandidates.BUILTIN_METADATA); case IdlPosition.StatementKeyword ignored -> builder .literalKind(CompletionItemKind.Keyword) .buildSimpleCompletions() - .getCompletionItems(Candidates.KEYWORD); + .getCompletionItems(CompletionCandidates.KEYWORD); case IdlPosition.Namespace ignored -> builder .literalKind(CompletionItemKind.Module) .buildSimpleCompletions() - .getCompletionItems(Candidates.Custom.PROJECT_NAMESPACES); + .getCompletionItems(CompletionCandidates.Custom.PROJECT_NAMESPACES); case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, builder); @@ -129,7 +129,7 @@ private List metadataValueCompletions( ) { var result = ShapeSearch.searchMetadataValue(metadataValue); Set excludeKeys = getOtherPresentKeys(result); - Candidates candidates = Candidates.fromSearchResult(result); + CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates); } @@ -176,10 +176,10 @@ private List modelBasedCompletions(IdlPosition idlPosition, Simp return traitValueCompletions(traitValue, model, builder); } - Candidates candidates = shapeCandidates(idlPosition); - if (candidates instanceof Candidates.Shapes shapes) { + CompletionCandidates candidates = CompletionCandidates.shapeCandidates(idlPosition); + if (candidates instanceof CompletionCandidates.Shapes shapes) { return builder.buildShapeCompletions(idlPosition, model).getCompletionItems(shapes); - } else if (candidates != Candidates.NONE) { + } else if (candidates != CompletionCandidates.NONE) { return builder.buildSimpleCompletions().getCompletionItems(candidates); } @@ -191,7 +191,7 @@ private List elidedMemberCompletions( Model model, SimpleCompletions.Builder builder ) { - Candidates candidates = getElidableMemberCandidates(elidedMember.statementIndex(), model); + CompletionCandidates candidates = getElidableMemberCandidates(elidedMember.statementIndex(), model); if (candidates == null) { return List.of(); } @@ -210,24 +210,10 @@ private List traitValueCompletions( ) { var result = ShapeSearch.searchTraitValue(traitValue, model); Set excludeKeys = getOtherPresentKeys(result); - Candidates candidates = Candidates.fromSearchResult(result); + CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates); } - private Candidates shapeCandidates(IdlPosition idlPosition) { - return switch (idlPosition) { - case IdlPosition.UseTarget ignored -> Candidates.Shapes.USE_TARGET; - case IdlPosition.TraitId ignored -> Candidates.Shapes.TRAITS; - case IdlPosition.Mixin ignored -> Candidates.Shapes.MIXINS; - case IdlPosition.ForResource ignored -> Candidates.Shapes.RESOURCE_SHAPES; - case IdlPosition.MemberTarget ignored -> Candidates.Shapes.MEMBER_TARGETABLE; - case IdlPosition.ApplyTarget ignored -> Candidates.Shapes.ANY_SHAPE; - case IdlPosition.NodeMemberTarget nodeMemberTarget -> Candidates.fromSearchResult( - ShapeSearch.searchNodeMemberTarget(nodeMemberTarget)); - default -> Candidates.NONE; - }; - } - private List memberNameCompletions( IdlPosition.MemberName memberName, SimpleCompletions.Builder builder @@ -243,20 +229,20 @@ private List memberNameCompletions( String shapeType = shapeDef.shapeType().copyValueFrom(smithyFile.document()); StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType); - Candidates candidates = null; + CompletionCandidates candidates = null; if (shapeMembersDef != null) { - candidates = Candidates.membersCandidates(Builtins.MODEL, shapeMembersDef); + candidates = CompletionCandidates.membersCandidates(Builtins.MODEL, shapeMembersDef); } if (project.modelResult().getResult().isPresent()) { - Candidates elidedCandidates = getElidableMemberCandidates( + CompletionCandidates elidedCandidates = getElidableMemberCandidates( memberName.statementIndex(), project.modelResult().getResult().get()); if (elidedCandidates != null) { candidates = candidates == null ? elidedCandidates - : new Candidates.And(candidates, elidedCandidates); + : new CompletionCandidates.And(candidates, elidedCandidates); } } @@ -271,7 +257,7 @@ private List memberNameCompletions( return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates); } - private Candidates getElidableMemberCandidates(int statementIndex, Model model) { + private CompletionCandidates getElidableMemberCandidates(int statementIndex, Model model) { var resourceAndMixins = ShapeSearch.findForResourceAndMixins( SyntaxSearch.closestForResourceAndMixinsBeforeMember(smithyFile.statements(), statementIndex), smithyFile, @@ -291,6 +277,6 @@ private Candidates getElidableMemberCandidates(int statementIndex, Model model) return null; } - return new Candidates.ElidedMembers(memberNames); + return new CompletionCandidates.ElidedMembers(memberNames); } } diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java index 42571928..0c4d8c74 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java @@ -30,7 +30,7 @@ import software.amazon.smithy.model.traits.TraitDefinition; /** - * Maps {@link Candidates.Shapes} to {@link CompletionItem}s. + * Maps {@link CompletionCandidates.Shapes} to {@link CompletionItem}s. */ final class ShapeCompletions { private final Model model; @@ -45,14 +45,14 @@ private ShapeCompletions(Model model, SmithyFile smithyFile, Matcher matcher, Ma this.mapper = mapper; } - List getCompletionItems(Candidates.Shapes candidates) { + List getCompletionItems(CompletionCandidates.Shapes candidates) { return streamShapes(candidates) .filter(matcher::test) .mapMulti(mapper::accept) .toList(); } - private Stream streamShapes(Candidates.Shapes candidates) { + private Stream streamShapes(CompletionCandidates.Shapes candidates) { return switch (candidates) { case ANY_SHAPE -> model.shapes(); case STRING_SHAPES -> model.getStringShapes().stream(); @@ -202,7 +202,7 @@ public void add(Mapper mapper, String shapeLabel, Shape shape, Consumer getCompletionItems(Candidates candidates) { + List getCompletionItems(CompletionCandidates candidates) { return switch (candidates) { - case Candidates.Constant(var value) + case CompletionCandidates.Constant(var value) when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value)); - case Candidates.Literals(var literals) -> literals.stream() + case CompletionCandidates.Literals(var literals) -> literals.stream() .filter(matcher::testLiteral) .map(mapper::literal) .toList(); - case Candidates.Labeled(var labeled) -> labeled.entrySet().stream() + case CompletionCandidates.Labeled(var labeled) -> labeled.entrySet().stream() .filter(matcher::testLabeled) .map(mapper::labeled) .toList(); - case Candidates.Members(var members) -> members.entrySet().stream() + case CompletionCandidates.Members(var members) -> members.entrySet().stream() .filter(matcher::testMember) .map(mapper::member) .toList(); - case Candidates.ElidedMembers(var memberNames) -> memberNames.stream() + case CompletionCandidates.ElidedMembers(var memberNames) -> memberNames.stream() .filter(matcher::testElided) .map(mapper::elided) .toList(); - case Candidates.Custom custom + case CompletionCandidates.Custom custom // TODO: Need to get rid of this stupid null check when project != null -> getCompletionItems(customCandidates(custom)); - case Candidates.And(var one, var two) -> { + case CompletionCandidates.And(var one, var two) -> { List oneItems = getCompletionItems(one); List twoItems = getCompletionItems(two); List completionItems = new ArrayList<>(oneItems.size() + twoItems.size()); @@ -73,14 +73,14 @@ List getCompletionItems(Candidates candidates) { }; } - private Candidates customCandidates(Candidates.Custom custom) { + private CompletionCandidates customCandidates(CompletionCandidates.Custom custom) { return switch (custom) { - case NAMESPACE_FILTER -> new Candidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces()) + case NAMESPACE_FILTER -> new CompletionCandidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces()) .collect(StreamUtils.toWrappedMap())); - case VALIDATOR_NAME -> Candidates.VALIDATOR_NAMES; + case VALIDATOR_NAME -> CompletionCandidates.VALIDATOR_NAMES; - case PROJECT_NAMESPACES -> new Candidates.Literals(streamNamespaces().toList()); + case PROJECT_NAMESPACES -> new CompletionCandidates.Literals(streamNamespaces().toList()); }; } @@ -162,7 +162,7 @@ default boolean testLabeled(Map.Entry labeled) { return test(labeled.getKey()) || test(labeled.getValue()); } - default boolean testMember(Map.Entry member) { + default boolean testMember(Map.Entry member) { return test(member.getKey()); } @@ -206,7 +206,7 @@ CompletionItem labeled(Map.Entry entry) { return textEditCompletion(entry.getKey(), CompletionItemKind.EnumMember, entry.getValue()); } - CompletionItem member(Map.Entry entry) { + CompletionItem member(Map.Entry entry) { String value = entry.getKey() + ": " + entry.getValue().value(); return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value); } diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java index dafbb7ba..6d18ff95 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java +++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java @@ -267,7 +267,6 @@ public static SmithyFile.Builder buildSmithyFile(String path, Document document, .documentShapes(documentShapes) .documentVersion(documentVersion) .statements(statements); - // .changeVersion(document.changeVersion()); } // This is gross, but necessary to deal with the way that array metadata gets merged. 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 ba6d3680..5cc23442 100644 --- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java +++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java @@ -40,7 +40,6 @@ public final class SmithyFile implements ProjectFile { private final Map documentShapes; private final DocumentVersion documentVersion; private List statements; - private int changeVersion; private SmithyFile(Builder builder) { this.path = builder.path; @@ -51,7 +50,6 @@ private SmithyFile(Builder builder) { this.documentShapes = builder.documentShapes; this.documentVersion = builder.documentVersion; this.statements = builder.statements; - this.changeVersion = builder.changeVersion; } /** @@ -155,14 +153,6 @@ public boolean isAccessible(Shape shape) { || !shape.hasTrait(PrivateTrait.ID); } - public int changeVersion() { - return changeVersion; - } - - public void setChangeVersion(int changeVersion) { - this.changeVersion = changeVersion; - } - /** * @return The parsed statements in this file */ @@ -204,7 +194,6 @@ public static final class Builder { private Map documentShapes; private DocumentVersion documentVersion; private List statements; - private int changeVersion; private Builder() { } @@ -249,11 +238,6 @@ public Builder statements(List statements) { return this; } - public Builder changeVersion(int changeVersion) { - this.changeVersion = changeVersion; - return this; - } - public SmithyFile build() { return new SmithyFile(this); } diff --git a/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java index 86b0d669..55187018 100644 --- a/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java +++ b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java @@ -6,6 +6,7 @@ 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; @@ -13,11 +14,11 @@ public final class StreamUtils { private StreamUtils() { } - public static Collector, ?, Map> toMap() { - return Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue); - } - 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/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java index a633888c..81007c00 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -274,7 +274,7 @@ public void completesStatementKeywords() { app%"""); List comps = getCompLabels(text); - String[] keywords = Candidates.KEYWORD.literals().toArray(new String[0]); + String[] keywords = CompletionCandidates.KEYWORD.literals().toArray(new String[0]); assertThat(comps, containsInAnyOrder(keywords)); } From c920942ddbf1f42f108d0b3c75532a257b2dcc62 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Tue, 17 Dec 2024 15:23:42 -0500 Subject: [PATCH 3/5] Refactoring This commit keeps the functionality added to the language features in the previous commits, but does some broad refactoring of those changes to clean up the APIs, get rid of some footguns, and reduce the chance of some concurrency/parallelism issues. The main changes are: - Syntax.Ident/Syntax.Node.Str produced by the new parser now copy the actual string value. Previously, they only stored the start/end positions, and required you to copy the value out of the Document on-demand. This reduced the memory footprint of parsing, but I was concerned about the Document being changed at the same time another thread is trying to copy a value out of it. Copying eagerly avoids this. Plus, we can avoid most of the memory issues by doing partial reparsing (more on that later). - Project now stores an index of files -> shapes defined in that file, instead of storing the shapes on the SmithyFile. This index is only needed to help determine which shapes need to be removed when rebuilding the model, so it doesn't make sense for SmithyFile to know about it. This also ties into the next change... - Multiple changes to SmithyFile. SmithyFile now has a subclass, IdlFile, which stores its parse result. With the addition of the parser, and the changes to make DocumentVersion/DocumentImports/DocumentNamespace be computed from the parse result, SmithyFile can't represent both IDL and AST files. Arguably, it never really did because AST files don't have namespaces/imports. Either way, IdlFile now provides access to the parse result, which contains DocumentNamespace/Version/Imports, as well as the parsed statements. I also added synchronization to handle access to the parse result, since it will be mutated on every change. I don't really like how this works, but I'm going to address that in a future update (which I will describe below). - Added StatementView, which wraps a list of parsed statements and a specific index in that list, providing methods to look "around" that index. This replaces the error-prone and unreadable SyntaxSearch, which required you to pass around int indicies everywhere. Some more minor changes to note: - Moved diagnostics computation into SmithyDiagnostics. It already belonged there probably, but especially with the addition of IdlFile I just had to do it. - Moved document symbols into a 'handler' like definition, etc. - Added `uri` and `isDetached` properties to ProjectAndFile, for convenience. There are still some rough edges with this code, but I plan on making a follow up PR to address them, so I this one doesn't become even larger. Specifically, I want to only parse opened/managed files. This could let us get rid of the whole ProjectFile thing, or at least not require going through a project to find a file (it would be stored directly on ServerState). This also makes the synchronization story much simpler, improves initialization time, and should make it easier to eventually load projects async. --- .../amazon/smithy/lsp/ServerState.java | 7 +- .../smithy/lsp/SmithyLanguageServer.java | 182 +---- .../lsp/diagnostics/SmithyDiagnostics.java | 161 ++++- .../amazon/smithy/lsp/document/Document.java | 87 +-- .../smithy/lsp/document/DocumentImports.java | 5 +- .../lsp/document/DocumentNamespace.java | 5 +- .../smithy/lsp/document/DocumentParser.java | 123 +--- .../smithy/lsp/document/DocumentShape.java | 42 -- .../smithy/lsp/document/DocumentVersion.java | 5 +- .../smithy/lsp/language/CompleterContext.java | 92 +++ .../lsp/language/CompletionCandidates.java | 2 - .../lsp/language/CompletionHandler.java | 159 ++--- .../lsp/language/DefinitionHandler.java | 15 +- .../lsp/language/DocumentSymbolHandler.java | 63 ++ .../lsp/language/DynamicMemberTarget.java | 39 +- .../smithy/lsp/language/HoverHandler.java | 18 +- .../smithy/lsp/language/IdlPosition.java | 121 ++-- .../smithy/lsp/language/NodeSearch.java | 35 + ...peCompletions.java => ShapeCompleter.java} | 137 ++-- .../smithy/lsp/language/ShapeSearch.java | 143 ++-- ...eCompletions.java => SimpleCompleter.java} | 125 ++-- .../amazon/smithy/lsp/project/IdlFile.java | 46 ++ .../amazon/smithy/lsp/project/Project.java | 160 ++--- .../smithy/lsp/project/ProjectAndFile.java | 4 +- .../smithy/lsp/project/ProjectLoader.java | 214 +++--- .../amazon/smithy/lsp/project/SmithyFile.java | 232 +----- .../smithy/lsp/protocol/LspAdapter.java | 31 + .../amazon/smithy/lsp/syntax/NodeCursor.java | 12 +- .../amazon/smithy/lsp/syntax/Parser.java | 129 ++-- .../smithy/lsp/syntax/StatementView.java | 228 ++++++ .../amazon/smithy/lsp/syntax/Syntax.java | 110 +-- .../smithy/lsp/syntax/SyntaxSearch.java | 214 ------ .../smithy/lsp/SmithyLanguageServerTest.java | 670 +----------------- .../lsp/SmithyVersionRefactoringTest.java | 12 +- .../lsp/document/DocumentParserTest.java | 135 +--- .../lsp/language/CompletionHandlerTest.java | 36 +- .../lsp/language/DefinitionHandlerTest.java | 35 +- .../lsp/language/DocumentSymbolTest.java | 62 ++ .../smithy/lsp/language/HoverHandlerTest.java | 22 +- .../smithy/lsp/project/ProjectTest.java | 52 +- .../smithy/lsp/syntax/IdlParserTest.java | 6 +- .../smithy/lsp/syntax/NodeParserTest.java | 37 +- .../smithy/lsp/syntax/SyntaxSearchTest.java | 4 +- 43 files changed, 1580 insertions(+), 2437 deletions(-) delete mode 100644 src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java create mode 100644 src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java rename src/main/java/software/amazon/smithy/lsp/language/{ShapeCompletions.java => ShapeCompleter.java} (69%) rename src/main/java/software/amazon/smithy/lsp/language/{SimpleCompletions.java => SimpleCompleter.java} (67%) create mode 100644 src/main/java/software/amazon/smithy/lsp/project/IdlFile.java create mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java delete mode 100644 src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java create mode 100644 src/test/java/software/amazon/smithy/lsp/language/DocumentSymbolTest.java 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 7b6b4944..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.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"); @@ -530,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); } } @@ -550,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 @@ -598,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); } } @@ -614,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 @@ -644,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 @@ -706,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 @@ -726,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, minimumSeverity).handle(params); - return completedFuture(hover); + var handler = new HoverHandler(project, smithyFile, minimumSeverity); + return CompletableFuture.supplyAsync(() -> handler.handle(params)); } @Override @@ -773,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 365af4c9..aba934f4 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -24,7 +24,7 @@ public final class Document { private final StringBuilder buffer; private int[] lineIndices; - private Document(StringBuilder buffer, int[] lineIndices, int changeVersion) { + private Document(StringBuilder buffer, int[] lineIndices) { this.buffer = buffer; this.lineIndices = lineIndices; } @@ -36,14 +36,14 @@ private Document(StringBuilder buffer, int[] lineIndices, int changeVersion) { public static Document of(String string) { StringBuilder buffer = new StringBuilder(string); int[] lineIndicies = computeLineIndicies(buffer); - return new Document(buffer, lineIndicies, 0); + return new Document(buffer, lineIndicies); } /** * @return A copy of this document */ public Document copy() { - return new Document(new StringBuilder(copyText()), lineIndices.clone(), 0); + return new Document(new StringBuilder(copyText()), lineIndices.clone()); } /** @@ -259,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 */ @@ -351,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 @@ -422,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 @@ -546,19 +490,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 @@ -580,18 +511,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/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 2299e2bf..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,16 +5,13 @@ package software.amazon.smithy.lsp.document; -import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import software.amazon.smithy.lsp.protocol.LspAdapter; import software.amazon.smithy.lsp.syntax.Syntax; -import software.amazon.smithy.lsp.syntax.SyntaxSearch; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.utils.SimpleParser; @@ -34,16 +31,9 @@ private DocumentParser(Document document, List statements) { } static DocumentParser of(String text) { - return DocumentParser.forDocument(Document.of(text)); - } - - /** - * @param document Document to create a parser for - * @return A parser for the given document - */ - public static DocumentParser forDocument(Document document) { - Syntax.IdlParse parse = Syntax.parseIdl(document); - return new DocumentParser(document, parse.statements()); + Document document = Document.of(text); + Syntax.IdlParseResult parse = Syntax.parseIdl(document); + return DocumentParser.forStatements(document, parse.statements()); } /** @@ -56,23 +46,23 @@ public static DocumentParser forStatements(Document document, List imports; @@ -80,116 +70,47 @@ public DocumentImports documentImports() { Syntax.Statement statement = statements.get(i); if (statement instanceof Syntax.Statement.Use firstUse) { imports = new HashSet<>(); - imports.add(firstUse.use().copyValueFrom(document)); - Range useRange = firstUse.rangeIn(document); + 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().copyValueFrom(document)); - end = use.rangeIn(document).getEnd(); + imports.add(use.use().stringValue()); + end = document.rangeBetween(use.start(), use.end()).getEnd(); i++; } else { break; } } return new DocumentImports(new Range(start, end), imports); + } else if (statement instanceof Syntax.Statement.ShapeDef) { + break; } } - return null; - } - - /** - * @return A map of start position to {@link DocumentShape} for each shape - * and/or shape reference in the document. - */ - public Map documentShapes() { - Map documentShapes = new HashMap<>(); - for (Syntax.Statement statement : statements) { - switch (statement) { - case Syntax.Statement.ShapeDef shapeDef -> { - String shapeName = shapeDef.shapeName().copyValueFrom(document); - Range range = shapeDef.shapeName().rangeIn(document); - var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedShape, null); - documentShapes.put(range.getStart(), shape); - } - case Syntax.Statement.MemberDef memberDef -> { - String shapeName = memberDef.name().copyValueFrom(document); - Range range = memberDef.name().rangeIn(document); - DocumentShape target = null; - if (memberDef.target() != null && !memberDef.target().isEmpty()) { - String targetName = memberDef.target().copyValueFrom(document); - Range targetRange = memberDef.target().rangeIn(document); - target = new DocumentShape(targetRange, targetName, DocumentShape.Kind.Targeted, null); - documentShapes.put(targetRange.getStart(), target); - } - var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedMember, target); - documentShapes.put(range.getStart(), shape); - } - case Syntax.Statement.ElidedMemberDef elidedMemberDef -> { - String shapeName = elidedMemberDef.name().copyValueFrom(document); - Range range = elidedMemberDef.rangeIn(document); - var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.Elided, null); - documentShapes.put(range.getStart(), shape); - } - case Syntax.Statement.EnumMemberDef enumMemberDef -> { - String shapeName = enumMemberDef.name().copyValueFrom(document); - Range range = enumMemberDef.rangeIn(document); - var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedMember, null); - documentShapes.put(range.getStart(), shape); - } - default -> { - } - } - } - return documentShapes; + 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() { for (Syntax.Statement statement : statements) { if (statement instanceof Syntax.Statement.Control control && control.value() instanceof Syntax.Node.Str str) { - String key = control.key().copyValueFrom(document); + String key = control.key().stringValue(); if (key.equals("version")) { - String version = str.copyValueFrom(document); - Range range = control.rangeIn(document); + 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; } } - 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) { - int position = document.indexOfPosition(LspAdapter.toPosition(sourceLocation)); - int statementIndex = SyntaxSearch.statementIndex(statements, position); - if (statementIndex < 0) { - return null; - } - - if (statements.get(statementIndex) instanceof Syntax.Statement.TraitApplication traitApplication) { - Range range = traitApplication.id().rangeIn(document); - range.getStart().setCharacter(range.getStart().getCharacter() - 1); // include @ - return range; - } - return null; + return DocumentVersion.EMPTY; } /** 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/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 index c18e3280..44b2fa8b 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java @@ -41,8 +41,6 @@ sealed interface CompletionCandidates { "list", "map", "structure", "union", "service", "resource", "operation", "apply")); - // TODO: Maybe BUILTIN_CONTROLS and BUILTIN_METADATA should be regular - // Labeled/Members, with custom mappers. Literals BUILTIN_CONTROLS = new Literals(Builtins.CONTROL.members().stream() .map(member -> "$" + member.getMemberName() + ": " + defaultCandidates(member).value()) .toList()); diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java index f43d1bc6..48fc881e 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -18,12 +18,11 @@ 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.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; -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.lsp.syntax.SyntaxSearch; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.StructureShape; @@ -32,9 +31,9 @@ */ public final class CompletionHandler { private final Project project; - private final SmithyFile smithyFile; + private final IdlFile smithyFile; - public CompletionHandler(Project project, SmithyFile smithyFile) { + public CompletionHandler(Project project, IdlFile smithyFile) { this.project = project; this.smithyFile = smithyFile; } @@ -60,40 +59,40 @@ public List handle(CompletionParams params, CancelChecker cc) { return Collections.emptyList(); } - IdlPosition idlPosition = IdlPosition.at(smithyFile, position).orElse(null); + 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(); } - SimpleCompletions.Builder builder = SimpleCompletions.builder(id, insertRange).project(project); + CompleterContext context = CompleterContext.create(id, insertRange, project); return switch (idlPosition) { - case IdlPosition.ControlKey ignored -> builder - .literalKind(CompletionItemKind.Constant) - .buildSimpleCompletions() - .getCompletionItems(CompletionCandidates.BUILTIN_CONTROLS); + case IdlPosition.ControlKey ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Constant)) + .getCompletionItems(CompletionCandidates.BUILTIN_CONTROLS); - case IdlPosition.MetadataKey ignored -> builder - .literalKind(CompletionItemKind.Field) - .buildSimpleCompletions() - .getCompletionItems(CompletionCandidates.BUILTIN_METADATA); + case IdlPosition.MetadataKey ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Field)) + .getCompletionItems(CompletionCandidates.BUILTIN_METADATA); - case IdlPosition.StatementKeyword ignored -> builder - .literalKind(CompletionItemKind.Keyword) - .buildSimpleCompletions() - .getCompletionItems(CompletionCandidates.KEYWORD); + case IdlPosition.StatementKeyword ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Keyword)) + .getCompletionItems(CompletionCandidates.KEYWORD); - case IdlPosition.Namespace ignored -> builder - .literalKind(CompletionItemKind.Module) - .buildSimpleCompletions() - .getCompletionItems(CompletionCandidates.Custom.PROJECT_NAMESPACES); + case IdlPosition.Namespace ignored -> + new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Module)) + .getCompletionItems(CompletionCandidates.Custom.PROJECT_NAMESPACES); - case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, builder); + case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, context); - case IdlPosition.MemberName memberName -> memberNameCompletions(memberName, builder); + case IdlPosition.MemberName memberName -> memberNameCompletions(memberName, context); - default -> modelBasedCompletions(idlPosition, builder); + default -> modelBasedCompletions(idlPosition, context); }; } @@ -110,7 +109,6 @@ private static Position getTokenPosition(CompletionParams params) { private static Range getInsertRange(DocumentId id, Position position) { if (id == null || id.idSlice().isEmpty()) { - // TODO: This is confusing // 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 @@ -125,45 +123,15 @@ private static Range getInsertRange(DocumentId id, Position position) { private List metadataValueCompletions( IdlPosition.MetadataValue metadataValue, - SimpleCompletions.Builder builder + CompleterContext context ) { var result = ShapeSearch.searchMetadataValue(metadataValue); - Set excludeKeys = getOtherPresentKeys(result); + Set excludeKeys = result.getOtherPresentKeys(); CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); - return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates); + return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates); } - private Set getOtherPresentKeys(NodeSearch.Result result) { - Syntax.Node.Kvps terminalContainer; - NodeCursor.Key terminalKey; - switch (result) { - case NodeSearch.Result.ObjectShape obj -> { - terminalContainer = obj.node(); - terminalKey = null; - } - case NodeSearch.Result.ObjectKey key -> { - terminalContainer = key.key().parent(); - terminalKey = key.key(); - } - default -> { - return null; - } - } - - Set ignoreKeys = new HashSet<>(); - terminalContainer.kvps().forEach(kvp -> { - String key = kvp.key().copyValueFrom(smithyFile.document()); - ignoreKeys.add(key); - }); - - if (terminalKey != null) { - ignoreKeys.remove(terminalKey.name()); - } - - return ignoreKeys; - } - - private List modelBasedCompletions(IdlPosition idlPosition, SimpleCompletions.Builder builder) { + private List modelBasedCompletions(IdlPosition idlPosition, CompleterContext context) { if (project.modelResult().getResult().isEmpty()) { return List.of(); } @@ -171,16 +139,16 @@ private List modelBasedCompletions(IdlPosition idlPosition, Simp Model model = project.modelResult().getResult().get(); if (idlPosition instanceof IdlPosition.ElidedMember elidedMember) { - return elidedMemberCompletions(elidedMember, model, builder); + return elidedMemberCompletions(elidedMember, context, model); } else if (idlPosition instanceof IdlPosition.TraitValue traitValue) { - return traitValueCompletions(traitValue, model, builder); + return traitValueCompletions(traitValue, context, model); } CompletionCandidates candidates = CompletionCandidates.shapeCandidates(idlPosition); if (candidates instanceof CompletionCandidates.Shapes shapes) { - return builder.buildShapeCompletions(idlPosition, model).getCompletionItems(shapes); + return new ShapeCompleter(idlPosition, model, context).getCompletionItems(shapes); } else if (candidates != CompletionCandidates.NONE) { - return builder.buildSimpleCompletions().getCompletionItems(candidates); + return new SimpleCompleter(context).getCompletionItems(candidates); } return List.of(); @@ -188,45 +156,37 @@ private List modelBasedCompletions(IdlPosition idlPosition, Simp private List elidedMemberCompletions( IdlPosition.ElidedMember elidedMember, - Model model, - SimpleCompletions.Builder builder + CompleterContext context, + Model model ) { - CompletionCandidates candidates = getElidableMemberCandidates(elidedMember.statementIndex(), model); + CompletionCandidates candidates = getElidableMemberCandidates(elidedMember, model); if (candidates == null) { return List.of(); } - Set otherMembers = SyntaxSearch.otherMemberNames( - elidedMember.smithyFile().document(), - elidedMember.smithyFile().statements(), - elidedMember.statementIndex()); - return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates); + Set otherMembers = elidedMember.view().otherMemberNames(); + return new SimpleCompleter(context.withExclude(otherMembers)).getCompletionItems(candidates); } private List traitValueCompletions( IdlPosition.TraitValue traitValue, - Model model, - SimpleCompletions.Builder builder + CompleterContext context, + Model model ) { var result = ShapeSearch.searchTraitValue(traitValue, model); - Set excludeKeys = getOtherPresentKeys(result); + Set excludeKeys = result.getOtherPresentKeys(); CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result); - return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates); + return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates); } - private List memberNameCompletions( - IdlPosition.MemberName memberName, - SimpleCompletions.Builder builder - ) { - Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefBeforeMember( - smithyFile.statements(), - memberName.statementIndex()); + 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().copyValueFrom(smithyFile.document()); + String shapeType = shapeDef.shapeType().stringValue(); StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType); CompletionCandidates candidates = null; @@ -236,7 +196,7 @@ private List memberNameCompletions( if (project.modelResult().getResult().isPresent()) { CompletionCandidates elidedCandidates = getElidableMemberCandidates( - memberName.statementIndex(), + memberName, project.modelResult().getResult().get()); if (elidedCandidates != null) { @@ -250,27 +210,20 @@ private List memberNameCompletions( return List.of(); } - Set otherMembers = SyntaxSearch.otherMemberNames( - smithyFile.document(), - smithyFile.statements(), - memberName.statementIndex()); - return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates); + Set otherMembers = memberName.view().otherMemberNames(); + return new SimpleCompleter(context.withExclude(otherMembers)).getCompletionItems(candidates); } - private CompletionCandidates getElidableMemberCandidates(int statementIndex, Model model) { - var resourceAndMixins = ShapeSearch.findForResourceAndMixins( - SyntaxSearch.closestForResourceAndMixinsBeforeMember(smithyFile.statements(), statementIndex), - smithyFile, - model); - + private CompletionCandidates getElidableMemberCandidates(IdlPosition idlPosition, Model model) { Set memberNames = new HashSet<>(); - if (resourceAndMixins.resource() != null) { - memberNames.addAll(resourceAndMixins.resource().getIdentifiers().keySet()); - memberNames.addAll(resourceAndMixins.resource().getProperties().keySet()); - } - - resourceAndMixins.mixins() + 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()) { diff --git a/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java index 9986f1f4..30e066fd 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java @@ -12,9 +12,11 @@ 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.project.SmithyFile; import software.amazon.smithy.lsp.protocol.LspAdapter; +import software.amazon.smithy.lsp.syntax.StatementView; +import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.Model; /** @@ -22,9 +24,9 @@ */ public final class DefinitionHandler { final Project project; - final SmithyFile smithyFile; + final IdlFile smithyFile; - public DefinitionHandler(Project project, SmithyFile smithyFile) { + public DefinitionHandler(Project project, IdlFile smithyFile) { this.project = project; this.smithyFile = smithyFile; } @@ -46,8 +48,11 @@ public List handle(DefinitionParams params) { } Model model = modelResult.get(); - return IdlPosition.at(smithyFile, position) - .flatMap(idlPosition -> ShapeSearch.findShapeDefinition(idlPosition, model, id)) + 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 index e133c21b..70082804 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java +++ b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java @@ -6,8 +6,6 @@ package software.amazon.smithy.lsp.language; import java.util.Map; -import software.amazon.smithy.lsp.document.Document; -import software.amazon.smithy.lsp.project.SmithyFile; import software.amazon.smithy.lsp.syntax.NodeCursor; import software.amazon.smithy.lsp.syntax.Syntax; import software.amazon.smithy.model.Model; @@ -38,13 +36,13 @@ sealed interface DynamicMemberTarget { Shape getTarget(NodeCursor cursor, Model model); static Map forTrait(Shape traitShape, IdlPosition.TraitValue traitValue) { - SmithyFile smithyFile = traitValue.smithyFile(); + 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", smithyFile)); + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); case "smithy.api#examples" -> Map.of( ShapeId.from("smithy.api#Example$input"), @@ -56,24 +54,23 @@ static Map forTrait(Shape traitShape, IdlPosition. ShapeId.from("smithy.test#HttpRequestTestCase$params"), new OperationInput(traitValue), ShapeId.from("smithy.test#HttpRequestTestCase$vendorParams"), - new ShapeIdDependent("vendorParamsShape", smithyFile)); + 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", smithyFile)); + new ShapeIdDependent("vendorParamsShape", syntaxInfo)); default -> null; }; } - static Map forMetadata(String metadataKey, SmithyFile smithyFile) { + static Map forMetadata(String metadataKey) { return switch (metadataKey) { case "validators" -> Map.of( ShapeId.from("smithy.lang.server#Validator$configuration"), new MappedDependent( "name", - smithyFile.document(), Builtins.VALIDATOR_CONFIG_MAPPING)); default -> null; }; @@ -116,15 +113,15 @@ public Shape getTarget(NodeCursor cursor, Model model) { * using that as the id of the target shape. * * @param memberName The name of the other member to compute the value of. - * @param smithyFile The file the node is within. + * @param parseResult The parse result of the file the node is within. */ - record ShapeIdDependent(String memberName, SmithyFile smithyFile) implements DynamicMemberTarget { + record ShapeIdDependent(String memberName, Syntax.IdlParseResult parseResult) implements DynamicMemberTarget { @Override public Shape getTarget(NodeCursor cursor, Model model) { - Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor, smithyFile.document()); - if (matchingKvp.value() instanceof Syntax.Node.Str str) { - String id = str.copyValueFrom(smithyFile.document()); - return ShapeSearch.findShape(smithyFile, model, id).orElse(null); + 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; } @@ -136,17 +133,15 @@ public Shape getTarget(NodeCursor cursor, Model model) { * value. * * @param memberName The name of the member to compute the value of. - * @param document The document the node is within. * @param mapping A mapping of {@code memberName} values to corresponding * member target ids. */ - record MappedDependent(String memberName, Document document, Map mapping) - implements DynamicMemberTarget { + record MappedDependent(String memberName, Map mapping) implements DynamicMemberTarget { @Override public Shape getTarget(NodeCursor cursor, Model model) { - Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor, document); - if (matchingKvp.value() instanceof Syntax.Node.Str str) { - String value = str.copyValueFrom(document); + 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); @@ -160,7 +155,7 @@ public Shape getTarget(NodeCursor cursor, Model model) { // 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, Document document) { + 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 @@ -169,7 +164,7 @@ private static Syntax.Node.Kvp findMatchingKvp(String keyName, NodeCursor cursor 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().copyValueFrom(document); + String key = kvp.key().stringValue(); if (!keyName.equals(key)) { continue; } diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java index cabdae6b..b7d72b1f 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -16,9 +16,11 @@ 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.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.node.Node; import software.amazon.smithy.model.shapes.MemberShape; @@ -41,7 +43,7 @@ public final class HoverHandler { public static final Hover EMPTY = new Hover(new MarkupContent("markdown", "")); private final Project project; - private final SmithyFile smithyFile; + private final IdlFile smithyFile; private final Severity minimumSeverity; /** @@ -49,7 +51,7 @@ public final class HoverHandler { * @param smithyFile Smithy file the hover is in * @param minimumSeverity Minimum severity of validation events to show */ - public HoverHandler(Project project, SmithyFile smithyFile, Severity minimumSeverity) { + public HoverHandler(Project project, IdlFile smithyFile, Severity minimumSeverity) { this.project = project; this.smithyFile = smithyFile; this.minimumSeverity = minimumSeverity; @@ -66,7 +68,11 @@ public Hover handle(HoverParams params) { return EMPTY; } - IdlPosition idlPosition = IdlPosition.at(smithyFile, position).orElse(null); + 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()) @@ -110,10 +116,10 @@ private Hover modelSensitiveHover(DocumentId id, IdlPosition idlPosition) { Optional matchingShape = switch (idlPosition) { // TODO: Handle resource ids and properties. This only works for mixins right now. case IdlPosition.ElidedMember elidedMember -> - ShapeSearch.findElidedMemberParent(elidedMember, model, id) + ShapeSearch.findElidedMemberParent(elidedMember, id, model) .flatMap(shape -> shape.getMember(id.copyIdValueForElidedMember())); - default -> ShapeSearch.findShapeDefinition(idlPosition, model, id); + default -> ShapeSearch.findShapeDefinition(idlPosition, id, model); }; if (matchingShape.isEmpty()) { diff --git a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java index 84b9d6eb..40f36008 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java +++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java @@ -5,12 +5,12 @@ package software.amazon.smithy.lsp.language; -import java.util.Optional; -import org.eclipse.lsp4j.Position; -import software.amazon.smithy.lsp.project.SmithyFile; +import software.amazon.smithy.lsp.syntax.StatementView; import software.amazon.smithy.lsp.syntax.Syntax; -import software.amazon.smithy.lsp.syntax.SyntaxSearch; +/** + * Represents different kinds of positions within an IDL file. + */ sealed interface IdlPosition { default boolean isEasyShapeReference() { return switch (this) { @@ -25,128 +25,97 @@ default boolean isEasyShapeReference() { }; } - SmithyFile smithyFile(); - - record TraitId(SmithyFile smithyFile) implements IdlPosition {} + StatementView view(); - record MemberTarget(SmithyFile smithyFile) implements IdlPosition {} + record TraitId(StatementView view) implements IdlPosition {} - record ShapeDef(SmithyFile smithyFile) implements IdlPosition {} + record MemberTarget(StatementView view) implements IdlPosition {} - record Mixin(SmithyFile smithyFile) implements IdlPosition {} + record ShapeDef(StatementView view) implements IdlPosition {} - record ApplyTarget(SmithyFile smithyFile) implements IdlPosition {} + record Mixin(StatementView view) implements IdlPosition {} - record UseTarget(SmithyFile smithyFile) implements IdlPosition {} + record ApplyTarget(StatementView view) implements IdlPosition {} - record Namespace(SmithyFile smithyFile) implements IdlPosition {} + record UseTarget(StatementView view) implements IdlPosition {} - record TraitValue( - int documentIndex, - int statementIndex, - Syntax.Statement.TraitApplication traitApplication, - SmithyFile smithyFile - ) implements IdlPosition {} + record Namespace(StatementView view) implements IdlPosition {} - record NodeMemberTarget( - int documentIndex, - int statementIndex, - Syntax.Statement.NodeMemberDef nodeMemberDef, - SmithyFile smithyFile - ) implements IdlPosition {} + record TraitValue(StatementView view, Syntax.Statement.TraitApplication application) implements IdlPosition {} - record ControlKey(SmithyFile smithyFile) implements IdlPosition {} + record NodeMemberTarget(StatementView view, Syntax.Statement.NodeMemberDef nodeMember) implements IdlPosition {} - record MetadataKey(SmithyFile smithyFile) implements IdlPosition {} + record ControlKey(StatementView view) implements IdlPosition {} - record MetadataValue( - int documentIndex, - Syntax.Statement.Metadata metadata, - SmithyFile smithyFile - ) implements IdlPosition {} + record MetadataKey(StatementView view) implements IdlPosition {} - record StatementKeyword(SmithyFile smithyFile) implements IdlPosition {} + record MetadataValue(StatementView view, Syntax.Statement.Metadata metadata) implements IdlPosition {} - record MemberName(int documentIndex, int statementIndex, SmithyFile smithyFile) implements IdlPosition {} + record StatementKeyword(StatementView view) implements IdlPosition {} - record ElidedMember(int documentIndex, int statementIndex, SmithyFile smithyFile) implements IdlPosition {} + record MemberName(StatementView view) implements IdlPosition {} - record ForResource(SmithyFile smithyFile) implements IdlPosition {} + record ElidedMember(StatementView view) implements IdlPosition {} - static Optional at(SmithyFile smithyFile, Position position) { - int documentIndex = smithyFile.document().indexOfPosition(position); - if (documentIndex < 0) { - return Optional.empty(); - } + record ForResource(StatementView view) implements IdlPosition {} - int statementIndex = SyntaxSearch.statementIndex(smithyFile.statements(), documentIndex); - if (statementIndex < 0) { - return Optional.empty(); - } + record Unknown(StatementView view) implements IdlPosition {} - Syntax.Statement statement = smithyFile.statements().get(statementIndex); - IdlPosition idlPosition = switch (statement) { + 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(smithyFile); + when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); case Syntax.Statement.ShapeDef shapeDef - when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(smithyFile); + when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view); case Syntax.Statement.Apply apply - when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(smithyFile); + when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(view); case Syntax.Statement.Metadata m - when m.key().isIn(documentIndex) -> new IdlPosition.MetadataKey(smithyFile); + 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( - documentIndex, m, smithyFile); + 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(smithyFile); + 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(smithyFile); + 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(smithyFile); + when u.use().isIn(documentIndex) -> new IdlPosition.UseTarget(view); case Syntax.Statement.MemberDef m - when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(smithyFile); + when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(view); case Syntax.Statement.MemberDef m - when m.name().isIn(documentIndex) -> new IdlPosition.MemberName( - documentIndex, statementIndex, smithyFile); + when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view); case Syntax.Statement.NodeMemberDef m - when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget( - documentIndex, statementIndex, m, smithyFile); + when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget(view, m); case Syntax.Statement.Namespace n - when n.namespace().isIn(documentIndex) -> new IdlPosition.Namespace(smithyFile); + 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( - documentIndex, statementIndex, t, smithyFile); + when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue(view, t); - case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember( - documentIndex, statementIndex, smithyFile); + case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember(view); - case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(smithyFile); + case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(view); - case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(smithyFile); + case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(view); - case Syntax.Statement.NodeMemberDef ignored -> new IdlPosition.MemberName( - documentIndex, statementIndex, smithyFile); + case Syntax.Statement.NodeMemberDef ignored -> new IdlPosition.MemberName(view); - case Syntax.Statement.Block ignored -> new IdlPosition.MemberName( - documentIndex, statementIndex, smithyFile); + case Syntax.Statement.Block ignored -> new IdlPosition.MemberName(view); - case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(smithyFile); + case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(view); - default -> null; + default -> new IdlPosition.Unknown(view); }; - - return Optional.ofNullable(idlPosition); } } diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java index 493c80c2..a81de9f4 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java @@ -5,7 +5,9 @@ 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; @@ -68,6 +70,39 @@ static Result 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. */ diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java similarity index 69% rename from src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java rename to src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java index 0c4d8c74..7c5cc339 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java @@ -15,44 +15,60 @@ import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.jsonrpc.messages.Either; -import software.amazon.smithy.lsp.document.DocumentImports; import software.amazon.smithy.lsp.document.DocumentNamespace; -import software.amazon.smithy.lsp.project.SmithyFile; 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. */ -final class ShapeCompletions { - private final Model model; - private final SmithyFile smithyFile; - private final Matcher matcher; - private final Mapper mapper; +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; + } - private ShapeCompletions(Model model, SmithyFile smithyFile, Matcher matcher, Mapper mapper) { - this.model = model; - this.smithyFile = smithyFile; - this.matcher = matcher; - this.mapper = mapper; - } + 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()); + } - List getCompletionItems(CompletionCandidates.Shapes candidates) { - return streamShapes(candidates) + 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 streamShapes(CompletionCandidates.Shapes candidates) { + private Stream streamCandidates(CompletionCandidates.Shapes candidates) { return switch (candidates) { case ANY_SHAPE -> model.shapes(); case STRING_SHAPES -> model.getStringShapes().stream(); @@ -65,43 +81,15 @@ private Stream streamShapes(CompletionCandidates.Shapes candida .filter(shape -> !shape.isMemberShape() && !shape.hasTrait(TraitDefinition.ID) && !shape.hasTrait(MixinTrait.ID)); - case USE_TARGET -> model.shapes() - .filter(shape -> !shape.isMemberShape() - && !shape.getId().getNamespace().contentEquals(smithyFile.namespace()) - && !smithyFile.hasImport(shape.getId().toString())); + case USE_TARGET -> model.shapes().filter(this::shouldImport); }; } - static ShapeCompletions create( - IdlPosition idlPosition, - Model model, - String matchToken, - Range insertRange - ) { - AddItems addItems = AddItems.DEFAULT; - ModifyItems modifyItems = ModifyItems.DEFAULT; - - if (idlPosition instanceof IdlPosition.TraitId) { - addItems = new AddDeepTraitBodyItem(model); - } - - ToLabel toLabel; - if (shouldMatchFullId(idlPosition, matchToken)) { - toLabel = (shape) -> shape.getId().toString(); - } else { - toLabel = (shape) -> shape.getId().getName(); - modifyItems = new AddImportTextEdits(idlPosition.smithyFile()); - } - - Matcher matcher = new Matcher(matchToken, toLabel, idlPosition.smithyFile()); - Mapper mapper = new Mapper(insertRange, toLabel, addItems, modifyItems); - return new ShapeCompletions(model, idlPosition.smithyFile(), matcher, mapper); - } - - private static boolean shouldMatchFullId(IdlPosition idlPosition, String matchToken) { - return idlPosition instanceof IdlPosition.UseTarget - || matchToken.contains("#") - || matchToken.contains("."); + 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); } /** @@ -111,11 +99,12 @@ private static boolean shouldMatchFullId(IdlPosition idlPosition, String matchTo * @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 smithyFile The current Smithy file. + * @param namespace The namespace of the current Smithy file. */ - private record Matcher(String matchToken, ToLabel toLabel, SmithyFile smithyFile) { + private record Matcher(String matchToken, ToLabel toLabel, DocumentNamespace namespace) { boolean test(Shape shape) { - return smithyFile.isAccessible(shape) && toLabel.toLabel(shape).toLowerCase().startsWith(matchToken); + return toLabel.toLabel(shape).toLowerCase().startsWith(matchToken) + && (shape.getId().getNamespace().equals(namespace.namespace()) || !shape.hasTrait(PrivateTrait.ID)); } } @@ -165,7 +154,7 @@ private interface ToLabel { * shape. */ private interface AddItems { - AddItems DEFAULT = new AddItems() { + AddItems NOOP = new AddItems() { }; default void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) { @@ -229,7 +218,7 @@ public String memberShape(MemberShape shape) { * context, additional text edits, etc. */ private interface ModifyItems { - ModifyItems DEFAULT = new ModifyItems() { + ModifyItems NOOP = new ModifyItems() { }; default void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { @@ -238,29 +227,35 @@ default void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionIte /** * Adds text edits for use statements for shapes that need to be imported. + * + * @param syntaxInfo Syntax info of the current Smithy file. */ - private static final class AddImportTextEdits implements ModifyItems { - private final SmithyFile smithyFile; - - AddImportTextEdits(SmithyFile smithyFile) { - this.smithyFile = smithyFile; - } - + private record AddImportTextEdits(Syntax.IdlParseResult syntaxInfo) implements ModifyItems { @Override public void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) { - if (smithyFile.inScope(shape.getId())) { + if (inScope(shape.getId())) { return; } // We can only know where to put the import if there's already use statements, or a namespace - smithyFile.documentImports().map(DocumentImports::importsRange) - .or(() -> smithyFile.documentNamespace().map(DocumentNamespace::statementRange)) - .ifPresent(range -> { - 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)); - }); + 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 index 0ebb3967..5eb1c770 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -11,8 +11,8 @@ 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.lsp.syntax.SyntaxSearch; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.Prelude; import software.amazon.smithy.model.shapes.ResourceShape; @@ -33,27 +33,27 @@ 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 smithyFile}'s namespace, in {@code smithyFile}'s + * 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 smithyFile The file {@code nameOrId} is within. - * @param model The model to search. - * @param nameOrId The name or shape id of the shape to find. + * @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(SmithyFile smithyFile, Model model, String nameOrId) { + 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(smithyFile.namespace().toString(), s) + Optional fromCurrent = tryFromParts(parseResult.namespace().namespace(), s) .flatMap(model::getShape); if (fromCurrent.isPresent()) { yield fromCurrent; } - for (String fileImport : smithyFile.imports()) { + for (String fileImport : parseResult.imports().imports()) { Optional imported = tryFrom(fileImport) .filter(importId -> importId.getName().equals(s)) .flatMap(model::getShape); @@ -88,16 +88,16 @@ private static Optional tryFromParts(String namespace, String name) { * 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 model The model to search for shapes in. - * @param id The identifier at {@code idlPosition}. + * @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, Model model, DocumentId id) { + 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.smithyFile(), m, id.copyIdValue()); + 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()); @@ -109,80 +109,84 @@ static Optional findShapeDefinition(IdlPosition idlPosition, Mo var result = searchNodeMemberTarget(nodeMemberTarget); if (result instanceof NodeSearch.Result.TerminalShape(Shape shape, var ignored) && shape.hasTrait(IdRefTrait.class)) { - yield findShape(nodeMemberTarget.smithyFile(), model, id.copyIdValue()); + 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, model, id); + findElidedMemberParent(elidedMember, id, model); case IdlPosition pos when pos.isEasyShapeReference() -> - findShape(pos.smithyFile(), model, id.copyIdValue()); + findShape(pos.view().parseResult(), id.copyIdValue(), model); default -> Optional.empty(); }; } - record ForResourceAndMixins(ResourceShape resource, List mixins) {} - - static ForResourceAndMixins findForResourceAndMixins( - SyntaxSearch.ForResourceAndMixins forResourceAndMixins, - SmithyFile smithyFile, + /** + * @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 ) { - ResourceShape resourceShape = null; - if (forResourceAndMixins.forResource() != null) { - String resourceNameOrId = forResourceAndMixins.forResource() - .resource() - .copyValueFrom(smithyFile.document()); - - resourceShape = findShape(smithyFile, model, resourceNameOrId) - .flatMap(Shape::asResourceShape) - .orElse(null); + if (forResource != null) { + String resourceNameOrId = forResource.resource().stringValue(); + return findShape(view.parseResult(), resourceNameOrId, model) + .flatMap(Shape::asResourceShape); } - List mixins = List.of(); - if (forResourceAndMixins.mixins() != null) { - mixins = new ArrayList<>(forResourceAndMixins.mixins().mixins().size()); - for (Syntax.Ident ident : forResourceAndMixins.mixins().mixins()) { - String mixinNameOrId = ident.copyValueFrom(smithyFile.document()); - findShape(smithyFile, model, mixinNameOrId).ifPresent(mixins::add); + 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 new ForResourceAndMixins(resourceShape, mixins); + return List.of(); } /** * @param elidedMember The elided member position - * @param model The model to search in - * @param id The identifier of the elided member + * @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, - Model model, - DocumentId id + DocumentId id, + Model model ) { - var forResourceAndMixins = findForResourceAndMixins( - SyntaxSearch.closestForResourceAndMixinsBeforeMember( - elidedMember.smithyFile().statements(), - elidedMember.statementIndex()), - elidedMember.smithyFile(), - model); + var view = elidedMember.view(); + var forResourceAndMixins = view.nearestForResourceAndMixinsBefore(); String searchToken = id.copyIdValueForElidedMember(); // TODO: Handle ambiguity - Optional foundResource = Optional.ofNullable(forResourceAndMixins.resource()) + Optional foundResource = findResource(forResourceAndMixins.forResource(), view, model) .filter(shape -> shape.getIdentifiers().containsKey(searchToken) || shape.getProperties().containsKey(searchToken)); if (foundResource.isPresent()) { return foundResource; } - return forResourceAndMixins.mixins() + return findMixins(forResourceAndMixins.mixins(), view, model) .stream() .filter(shape -> shape.getAllMembers().containsKey(searchToken)) .findFirst(); @@ -194,16 +198,14 @@ static Optional findElidedMemberParent( * @return The shape that {@code traitValue} is being applied to, if found. */ static Optional findTraitTarget(IdlPosition.TraitValue traitValue, Model model) { - Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefAfterTrait( - traitValue.smithyFile().statements(), - traitValue.statementIndex()); + Syntax.Statement.ShapeDef shapeDef = traitValue.view().nearestShapeDefAfter(); if (shapeDef == null) { return Optional.empty(); } - String shapeName = shapeDef.shapeName().copyValueFrom(traitValue.smithyFile().document()); - return findShape(traitValue.smithyFile(), model, shapeName); + String shapeName = shapeDef.shapeName().stringValue(); + return findShape(traitValue.view().parseResult(), shapeName, model); } /** @@ -224,17 +226,16 @@ static boolean isObjectShape(Shape shape) { * {@link Builtins} model. */ static NodeSearch.Result searchMetadataValue(IdlPosition.MetadataValue metadataValue) { - String metadataKey = metadataValue.metadata().key().copyValueFrom(metadataValue.smithyFile().document()); + String metadataKey = metadataValue.metadata().key().stringValue(); Shape metadataValueShapeDef = Builtins.getMetadataValue(metadataKey); if (metadataValueShapeDef == null) { return NodeSearch.Result.NONE; } NodeCursor cursor = NodeCursor.create( - metadataValue.smithyFile().document(), metadataValue.metadata().value(), - metadataValue.documentIndex()); - var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey, metadataValue.smithyFile()); + metadataValue.view().documentIndex()); + var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey); return NodeSearch.search(cursor, Builtins.MODEL, metadataValueShapeDef, dynamicTargets); } @@ -244,18 +245,14 @@ static NodeSearch.Result searchMetadataValue(IdlPosition.MetadataValue metadataV * within the {@link Builtins} model. */ static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nodeMemberTarget) { - Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefBeforeMember( - nodeMemberTarget.smithyFile().statements(), - nodeMemberTarget.statementIndex()); + Syntax.Statement.ShapeDef shapeDef = nodeMemberTarget.view().nearestShapeDefBefore(); if (shapeDef == null) { return NodeSearch.Result.NONE; } - String shapeType = shapeDef.shapeType().copyValueFrom(nodeMemberTarget.smithyFile().document()); - String memberName = nodeMemberTarget.nodeMemberDef() - .name() - .copyValueFrom(nodeMemberTarget.smithyFile().document()); + String shapeType = shapeDef.shapeType().stringValue(); + String memberName = nodeMemberTarget.nodeMember().name().stringValue(); Shape memberShapeDef = Builtins.getMemberTargetForShapeType(shapeType, memberName); if (memberShapeDef == null) { @@ -268,14 +265,13 @@ static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nod // // 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.nodeMemberDef().value() == null) { + if (nodeMemberTarget.nodeMember().value() == null) { return new NodeSearch.Result.TerminalShape(memberShapeDef, Builtins.MODEL); } NodeCursor cursor = NodeCursor.create( - nodeMemberTarget.smithyFile().document(), - nodeMemberTarget.nodeMemberDef().value(), - nodeMemberTarget.documentIndex()); + nodeMemberTarget.nodeMember().value(), + nodeMemberTarget.view().documentIndex()); return NodeSearch.search(cursor, Builtins.MODEL, memberShapeDef); } @@ -285,17 +281,16 @@ static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nod * @return The result of searching from {@code traitValue} within {@code model}. */ static NodeSearch.Result searchTraitValue(IdlPosition.TraitValue traitValue, Model model) { - String traitName = traitValue.traitApplication().id().copyValueFrom(traitValue.smithyFile().document()); - Optional maybeTraitShape = findShape(traitValue.smithyFile(), model, traitName); + 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.smithyFile().document(), - traitValue.traitApplication().value(), - traitValue.documentIndex()); + 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 diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java similarity index 67% rename from src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java rename to src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java index a8ed386b..04150084 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java @@ -9,30 +9,37 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Predicate; 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.document.DocumentId; -import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.IdlFile; import software.amazon.smithy.lsp.util.StreamUtils; -import software.amazon.smithy.model.Model; -final class SimpleCompletions { - private final Project project; - private final Matcher matcher; - private final Mapper mapper; +/** + * 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()); + } - private SimpleCompletions(Project project, Matcher matcher, Mapper mapper) { - this.project = project; - this.matcher = matcher; - this.mapper = mapper; + Mapper mapper = new Mapper(context().insertRange(), context().literalKind()); + + return getCompletionItems(candidates, matcher, mapper); } - List getCompletionItems(CompletionCandidates candidates) { + 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)); @@ -57,9 +64,7 @@ List getCompletionItems(CompletionCandidates candidates) { .map(mapper::elided) .toList(); - case CompletionCandidates.Custom custom - // TODO: Need to get rid of this stupid null check - when project != null -> getCompletionItems(customCandidates(custom)); + case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher, mapper); case CompletionCandidates.And(var one, var two) -> { List oneItems = getCompletionItems(one); @@ -69,6 +74,7 @@ List getCompletionItems(CompletionCandidates candidates) { completionItems.addAll(twoItems); yield completionItems; } + default -> List.of(); }; } @@ -85,69 +91,26 @@ private CompletionCandidates customCandidates(CompletionCandidates.Custom custom } private Stream streamNamespaces() { - return project.smithyFiles().values().stream() - .map(smithyFile -> smithyFile.namespace().toString()) + return context().project().smithyFiles().values().stream() + .map(smithyFile -> switch (smithyFile) { + case IdlFile idlFile -> idlFile.getParse().namespace().namespace(); + default -> ""; + }) .filter(namespace -> !namespace.isEmpty()); } - static Builder builder(DocumentId id, Range insertRange) { - return new Builder(id, insertRange); - } - - static final class Builder { - private final DocumentId id; - private final Range insertRange; - private Project project = null; - private Set exclude = null; - private CompletionItemKind literalKind = CompletionItemKind.Field; - - private Builder(DocumentId id, Range insertRange) { - this.id = id; - this.insertRange = insertRange; - } - - Builder project(Project project) { - this.project = project; - return this; - } - - Builder exclude(Set exclude) { - this.exclude = exclude; - return this; - } - - Builder literalKind(CompletionItemKind literalKind) { - this.literalKind = literalKind; - return this; - } - - SimpleCompletions buildSimpleCompletions() { - Matcher matcher = getMatcher(id, exclude); - Mapper mapper = new Mapper(insertRange, literalKind); - return new SimpleCompletions(project, matcher, mapper); - } - - ShapeCompletions buildShapeCompletions(IdlPosition idlPosition, Model model) { - return ShapeCompletions.create(idlPosition, model, getMatchToken(id), insertRange); - } - } - - private static Matcher getMatcher(DocumentId id, Set exclude) { - String matchToken = getMatchToken(id); - if (exclude == null || exclude.isEmpty()) { - return new DefaultMatcher(matchToken); - } else { - return new ExcludingMatcher(matchToken, exclude); - } - } - - private static String getMatchToken(DocumentId id) { - return id != null - ? id.copyIdValue().toLowerCase() - : ""; - } - - private sealed interface Matcher extends Predicate { + /** + * 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) { @@ -170,7 +133,6 @@ default boolean testElided(String memberName) { return test(memberName) || test("$" + memberName); } - @Override default boolean test(String s) { return s.toLowerCase().startsWith(matchToken()); } @@ -193,13 +155,20 @@ public boolean test(String 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, CompletionItemKind.Field); + return textEditCompletion(value, literalKind); } CompletionItem labeled(Map.Entry entry) { 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 6d18ff95..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,23 +24,16 @@ 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.syntax.Syntax; import software.amazon.smithy.lsp.util.Result; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.ModelAssembler; import software.amazon.smithy.model.loader.ModelDiscovery; import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; @@ -69,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( @@ -100,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()); } /** @@ -137,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 @@ -171,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)) { @@ -197,78 +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(); - DocumentVersion documentVersion = documentParser.documentVersion(); - Syntax.IdlParse parse = Syntax.parseIdl(document); - List statements = parse.statements(); - return SmithyFile.builder() - .path(path) - .document(document) - .shapes(shapes) - .namespace(namespace) - .imports(imports) - .documentShapes(documentShapes) - .documentVersion(documentVersion) - .statements(statements); - } - // 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 @@ -300,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 5cc23442..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,241 +5,45 @@ package software.amazon.smithy.lsp.project; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -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.lsp.syntax.Syntax; -import software.amazon.smithy.model.loader.Prelude; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.traits.PrivateTrait; /** * 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 List statements; - 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; - this.statements = builder.statements; + 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 - */ - public boolean hasImport(String shapeId) { - if (imports == null || imports.imports().isEmpty()) { - return false; - } - return imports.imports().contains(shapeId); - } - - public boolean isAccessible(Shape shape) { - return shape.getId().getNamespace().contentEquals(namespace()) - || !shape.hasTrait(PrivateTrait.ID); - } - - /** - * @return The parsed statements in this file - */ - public List statements() { - return statements; - } - - /** - * Re-parses the underlying {@link #document()}, updating {@link #statements()}. + * Reparse the underlying {@link #document()}. */ public void reparse() { - Syntax.IdlParse parse = Syntax.parseIdl(document); - this.statements = parse.statements(); - } - - /** - * @param shapeId The shape id to check - * @return Whether the given shape id is in scope for this file - */ - public boolean inScope(ShapeId shapeId) { - return Prelude.isPublicPreludeShape(shapeId) - || shapeId.getNamespace().contentEquals(namespace()) - || hasImport(shapeId.toString()); - } - - /** - * @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 List statements; - - 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 Builder statements(List statements) { - this.statements = statements; - return this; - } - - public SmithyFile build() { - return new SmithyFile(this); - } + // 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 106fa18d..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,8 @@ 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; @@ -112,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. diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java index a8e4edb9..ac1cc3a5 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.List; -import software.amazon.smithy.lsp.document.Document; /** * A moveable index into a path from the root of a {@link Syntax.Node} to a @@ -25,13 +24,12 @@ public final class NodeCursor { } /** - * @param document The document the node value is within * @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(Document document, Syntax.Node value, int documentIndex) { + public static NodeCursor create(Syntax.Node value, int documentIndex) { List edges = new ArrayList<>(); NodeCursor cursor = new NodeCursor(edges); @@ -47,7 +45,7 @@ public static NodeCursor create(Document document, Syntax.Node value, int docume Syntax.Node.Kvp lastKvp = null; for (Syntax.Node.Kvp kvp : kvps.kvps()) { if (kvp.key.isIn(documentIndex)) { - String key = kvp.key.copyValueFrom(document); + String key = kvp.key.stringValue(); edges.add(new NodeCursor.Key(key, kvps)); edges.add(new NodeCursor.Terminal(kvp)); return cursor; @@ -56,7 +54,7 @@ public static NodeCursor create(Document document, Syntax.Node value, int docume lastKvp = kvp; break; } - String key = kvp.key.copyValueFrom(document); + String key = kvp.key.stringValue(); edges.add(new NodeCursor.ValueForKey(key, kvps)); next = kvp.value; break iteration; @@ -65,7 +63,7 @@ public static NodeCursor create(Document document, Syntax.Node value, int docume } } if (lastKvp != null && lastKvp.value == null) { - edges.add(new NodeCursor.ValueForKey(lastKvp.key.copyValueFrom(document), kvps)); + edges.add(new NodeCursor.ValueForKey(lastKvp.key.stringValue(), kvps)); edges.add(new NodeCursor.Terminal(lastKvp)); return cursor; } @@ -139,7 +137,7 @@ public Edge previous() { * @return Whether the path consists of a single, terminal, node. */ public boolean isTerminal() { - return edges.size() == 1 && edges.get(0) instanceof Terminal; + return edges.size() == 1 && edges.getFirst() instanceof Terminal; } /** diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java index 087050f1..9f2684be 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java @@ -68,10 +68,34 @@ void parseIdl() { Syntax.Statement.Err err = new Syntax.Statement.Err(e.message); err.start = position(); err.end = position(); - errors.add(err); + 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; @@ -166,7 +190,7 @@ private Syntax.Node traitValueKvps(int from) { Syntax.Node.Err kvpErr = kvp(kvps, ')'); if (kvpErr != null) { - errors.add(kvpErr); + addError(kvpErr); } ws(); @@ -181,7 +205,8 @@ private Syntax.Node nodeIdent() { do { skip(); } while (!isWs() && !isStructuralBreakpoint() && !eof()); - return new Syntax.Ident(start, position()); + int end = position(); + return new Syntax.Ident(start, end, document.copySpan(start, end)); } private Syntax.Node.Obj obj() { @@ -198,7 +223,7 @@ private Syntax.Node.Obj obj() { Syntax.Err kvpErr = kvp(obj.kvps, '}'); if (kvpErr != null) { - errors.add(kvpErr); + addError(kvpErr); } ws(); @@ -207,7 +232,7 @@ private Syntax.Node.Obj obj() { Syntax.Node.Err err = new Syntax.Node.Err("missing }"); setStart(err); setEnd(err); - errors.add(err); + addError(err); setEnd(obj); return obj; @@ -249,7 +274,7 @@ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { return nodeErr("unexpected eof"); } else { if (err != null) { - errors.add(err); + addError(err); } err = nodeErr("expected :"); @@ -257,7 +282,7 @@ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { if (is(close)) { if (err != null) { - errors.add(err); + addError(err); } return nodeErr("expected value"); @@ -269,7 +294,7 @@ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { setEnd(kvp); } if (err != null) { - errors.add(err); + addError(err); } return nodeErr("expected value"); @@ -278,7 +303,7 @@ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) { Syntax.Node value = parseNode(); if (value instanceof Syntax.Node.Err e) { if (err != null) { - errors.add(err); + addError(err); } err = e; } else if (err == null) { @@ -306,7 +331,7 @@ private Syntax.Node.Arr arr() { Syntax.Node elem = parseNode(); if (elem instanceof Syntax.Node.Err e) { - errors.add(e); + addError(e); } else { arr.elements.add(elem); } @@ -314,7 +339,7 @@ private Syntax.Node.Arr arr() { } Syntax.Node.Err err = nodeErr("missing ]"); - errors.add(err); + addError(err); setEnd(arr); return arr; @@ -340,17 +365,14 @@ private Syntax.Node str() { } rewindTo(end + 3); - Syntax.Node.Str str = new Syntax.Node.Str(); - str.start = start; - setEnd(str); - return str; + int strEnd = position(); + return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 3, strEnd - 3)); } + // Empty string skip(); - Syntax.Node.Str str = new Syntax.Node.Str(); - str.start = start; - setEnd(str); - return str; + int strEnd = position(); + return new Syntax.Node.Str(start, strEnd, ""); } int last = '"'; @@ -359,10 +381,8 @@ private Syntax.Node str() { while (!isNl() && !eof()) { if (is('"') && last != '\\') { skip(); // '"' - Syntax.Node.Str str = new Syntax.Node.Str(); - str.start = start; - setEnd(str); - return str; + int strEnd = position(); + return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 1, strEnd - 1)); } last = peek(); skip(); @@ -418,6 +438,12 @@ private Syntax.Node.Err nodeErr(String message) { return err; } + private void skipUntilStatementStart() { + while (!is('@') && !is('$') && !isIdentStart() && !eof()) { + skip(); + } + } + private void statement() { if (is('@')) { traitApplication(null); @@ -429,7 +455,8 @@ private void statement() { Syntax.Ident ident = ident(); if (ident.isEmpty()) { if (!isWs()) { - skip(); + // TODO: Capture all this in an error + skipUntilStatementStart(); } return; } @@ -440,7 +467,7 @@ private void statement() { Syntax.Statement.Incomplete incomplete = new Syntax.Statement.Incomplete(ident); incomplete.start = start; incomplete.end = position(); - statements.add(incomplete); + addStatement(incomplete); if (!isWs()) { skip(); @@ -448,7 +475,7 @@ private void statement() { return; } - String identCopy = ident.copyValueFrom(document); + String identCopy = ident.stringValue(); switch (identCopy) { case "apply" -> { @@ -474,7 +501,7 @@ private void statement() { Syntax.Statement.ShapeDef shapeDef = new Syntax.Statement.ShapeDef(ident, name); shapeDef.start = start; setEnd(shapeDef); - statements.add(shapeDef); + addStatement(shapeDef); sp(); optionalForResourceAndMixins(); @@ -530,7 +557,7 @@ private void statement() { private Syntax.Statement.Block startBlock(Syntax.Statement.Block parent) { Syntax.Statement.Block block = new Syntax.Statement.Block(parent, statements.size()); setStart(block); - statements.add(block); + addStatement(block); if (is('{')) { skip(); } else { @@ -564,7 +591,7 @@ private void operationMembers(Syntax.Statement.Block parent) { var memberDef = new Syntax.Statement.MemberDef(parent, memberName); memberDef.start = opMemberStart; setEnd(memberDef); - statements.add(memberDef); + addStatement(memberDef); ws(); continue; } @@ -585,14 +612,14 @@ private void operationMembers(Syntax.Statement.Block parent) { opMemberDef.colonPos = colonPos; opMemberDef.target = ident(); setEnd(opMemberDef); - statements.add(opMemberDef); + addStatement(opMemberDef); } else { var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName); nodeMemberDef.start = opMemberStart; nodeMemberDef.colonPos = colonPos; nodeMemberDef.value = parseNode(); setEnd(nodeMemberDef); - statements.add(nodeMemberDef); + addStatement(nodeMemberDef); } ws(); @@ -605,7 +632,7 @@ private void control() { Syntax.Ident ident = ident(); Syntax.Statement.Control control = new Syntax.Statement.Control(ident); control.start = start; - statements.add(control); + addStatement(control); sp(); if (!is(':')) { @@ -626,7 +653,7 @@ private void apply(int start, Syntax.Ident name) { Syntax.Statement.Apply apply = new Syntax.Statement.Apply(name); apply.start = start; setEnd(apply); - statements.add(apply); + addStatement(apply); sp(); if (is('@')) { @@ -653,7 +680,7 @@ private void apply(int start, Syntax.Ident name) { private void metadata(int start, Syntax.Ident name) { Syntax.Statement.Metadata metadata = new Syntax.Statement.Metadata(name); metadata.start = start; - statements.add(metadata); + addStatement(metadata); sp(); if (!is('=')) { @@ -673,33 +700,33 @@ private void use(int start, Syntax.Ident name) { Syntax.Statement.Use use = new Syntax.Statement.Use(name); use.start = start; setEnd(use); - statements.add(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); - statements.add(namespace); + addStatement(namespace); } private void optionalForResourceAndMixins() { int maybeStart = position(); Syntax.Ident maybe = optIdent(); - if (maybe.copyValueFrom(document).equals("for")) { + if (maybe.stringValue().equals("for")) { sp(); Syntax.Ident resource = ident(); Syntax.Statement.ForResource forResource = new Syntax.Statement.ForResource(resource); forResource.start = maybeStart; - statements.add(forResource); + addStatement(forResource); ws(); setEnd(forResource); maybeStart = position(); maybe = optIdent(); } - if (maybe.copyValueFrom(document).equals("with")) { + if (maybe.stringValue().equals("with")) { sp(); Syntax.Statement.Mixins mixins = new Syntax.Statement.Mixins(); mixins.start = maybeStart; @@ -710,7 +737,7 @@ private void optionalForResourceAndMixins() { // If we're on an identifier, just assume the [ was meant to be there if (!isIdentStart()) { setEnd(mixins); - statements.add(mixins); + addStatement(mixins); return; } } else { @@ -731,7 +758,7 @@ private void optionalForResourceAndMixins() { } setEnd(mixins); - statements.add(mixins); + addStatement(mixins); } } @@ -745,7 +772,7 @@ private void member(Syntax.Statement.Block parent) { Syntax.Ident name = ident(); Syntax.Statement.MemberDef memberDef = new Syntax.Statement.MemberDef(parent, name); memberDef.start = start; - statements.add(memberDef); + addStatement(memberDef); sp(); if (is(':')) { @@ -755,7 +782,7 @@ private void member(Syntax.Statement.Block parent) { addErr(position(), position(), "expected :"); if (isWs() || is('}')) { setEnd(memberDef); - statements.add(memberDef); + addStatement(memberDef); return; } } @@ -786,7 +813,7 @@ private void enumMember(Syntax.Statement.Block parent) { Syntax.Ident name = ident(); var enumMemberDef = new Syntax.Statement.EnumMemberDef(parent, name); enumMemberDef.start = start; - statements.add(enumMemberDef); + addStatement(enumMemberDef); ws(); if (is('=')) { @@ -809,14 +836,14 @@ private void elidedMember(Syntax.Statement.Block parent) { var elidedMemberDef = new Syntax.Statement.ElidedMemberDef(parent, name); elidedMemberDef.start = start; setEnd(elidedMemberDef); - statements.add(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); - statements.add(inlineMemberDef); + addStatement(inlineMemberDef); ws(); while (is('@')) { @@ -851,7 +878,7 @@ private void nodeMember(Syntax.Statement.Block parent) { addErr(position(), position(), "expected :"); if (isWs() || is('}')) { setEnd(nodeMemberDef); - statements.add(nodeMemberDef); + addStatement(nodeMemberDef); return; } } @@ -863,7 +890,7 @@ private void nodeMember(Syntax.Statement.Block parent) { nodeMemberDef.value = parseNode(); } setEnd(nodeMemberDef); - statements.add(nodeMemberDef); + addStatement(nodeMemberDef); } private void traitApplication(Syntax.Statement.Block parent) { @@ -872,7 +899,7 @@ private void traitApplication(Syntax.Statement.Block parent) { Syntax.Ident id = ident(); var application = new Syntax.Statement.TraitApplication(parent, id); application.start = startPos; - statements.add(application); + addStatement(application); if (is('(')) { int start = position(); @@ -911,14 +938,14 @@ private Syntax.Ident ident() { addErr(start, end, "expected identifier"); return Syntax.Ident.EMPTY; } - return new Syntax.Ident(start, end); + 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; - errors.add(err); + addError(err); } private void recoverToMemberStart() { 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 index d6740fbf..e6b27667 100644 --- a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java +++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java @@ -9,15 +9,18 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; -import org.eclipse.lsp4j.Range; 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 Syntax.IdlParse}, is a list of {@link Statement}, + * The result of a parse, {@link IdlParseResult}, is a list of {@link Statement}, * rather than a syntax tree. For example, the following: * * \@someTrait @@ -58,10 +61,6 @@ * - 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). - * - Concrete text is not stored in {@link Statement}s. Instead, - * {@link Statement#start} and {@link Statement#end} can be used to copy a - * value from the underlying document as needed. This is done to reduce the - * memory footprint of parsing. *

*

Node Syntax

* This class also provides classes for the JSON-like Smithy Node, which can @@ -74,28 +73,66 @@ public final class Syntax { private Syntax() { } - public record IdlParse(List statements, List errors) {} + /** + * 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) {} - public record NodeParse(Node value, List errors) {} + /** + * 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 + * @param document The document to parse. + * @return The IDL parse result. */ - public static IdlParse parseIdl(Document document) { + public static IdlParseResult parseIdl(Document document) { Parser parser = new Parser(document); parser.parseIdl(); - return new IdlParse(parser.statements, parser.errors); + List statements = parser.statements; + DocumentParser documentParser = DocumentParser.forStatements(document, statements); + return new IdlParseResult( + statements, + parser.errors, + documentParser.documentVersion(), + documentParser.documentNamespace(), + documentParser.documentImports()); } /** - * @param document The document to parse - * @return The Node parse result + * The result of parsing a Node document. + * + * @param value The parsed node. + * @param errors The errors that occurred during parsing. */ - public static NodeParse parseNode(Document document) { + 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 NodeParse(node, parser.errors); + return new NodeParseResult(node, parser.errors); } /** @@ -121,14 +158,6 @@ public final int end() { public final boolean isIn(int pos) { return start <= pos && end > pos; } - - /** - * @param document The document to get the range in - * @return The range of this item in the given {@code document} - */ - public final Range rangeIn(Document document) { - return document.rangeBetween(start, end); - } } /** @@ -264,12 +293,16 @@ public List elements() { * identifiers, so this class a single subclass {@link Ident}. */ public static sealed class Str extends Node { - /** - * @param document Document to copy the string value from - * @return The literal string value, excluding enclosing "" - */ - public String copyValueFrom(Document document) { - return document.copySpan(start + 1, end - 1); // Don't include the '"'s + final String value; + + Str(int start, int end, String value) { + this.start = start; + this.end = end; + this.value = value; + } + + public String stringValue() { + return value; } } @@ -515,8 +548,6 @@ public List mixins() { * from a statement to the {@link Block} it resides within when * searching for the statement corresponding to a given character offset * in a document.

- * - * @see SyntaxSearch#statementIndex(List, int) */ abstract static sealed class MemberStatement extends Statement { final Block parent; @@ -723,24 +754,15 @@ public String message() { * (i.e. `.`, `#`, `$`, `_` digits, alphas). */ public static final class Ident extends Node.Str { - static final Ident EMPTY = new Ident(-1, -1); + static final Ident EMPTY = new Ident(-1, -1, ""); - Ident(int start, int end) { - this.start = start; - this.end = end; + Ident(int start, int end, String value) { + super(start, end, value); } public boolean isEmpty() { return (start - end) == 0; } - - @Override - public String copyValueFrom(Document document) { - if (start < 0 && end < 0) { - return ""; - } - return document.copySpan(start, end); // There's no '"'s here - } } /** diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java b/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java deleted file mode 100644 index ae3720b4..00000000 --- a/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * 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.Set; -import software.amazon.smithy.lsp.document.Document; - -/** - * Low-level API to query specific information about {@link Syntax.Statement}s - * and {@link Syntax.Node}s. - */ -public final class SyntaxSearch { - private SyntaxSearch() { - } - - /** - * @param statements The statements to search - * @param position The character offset in the document - * @return The index of the statement in the list of statements that the - * given position is within, or -1 if it was not found. - */ - public 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; - } - - /** - * @param statements The statements to search - * @param memberStatementIndex The index of the statement to search from - * @return The closest shape def statement appearing before the given index - * or {@code null} if none was found. - */ - public static Syntax.Statement.ShapeDef closestShapeDefBeforeMember( - List statements, - int memberStatementIndex - ) { - int searchStatementIdx = memberStatementIndex - 1; - while (searchStatementIdx >= 0) { - Syntax.Statement searchStatement = statements.get(searchStatementIdx); - if (searchStatement instanceof Syntax.Statement.ShapeDef shapeDef) { - return shapeDef; - } - searchStatementIdx--; - } - return null; - } - - /** - * @param forResource The nullable for-resource statement - * @param mixins The nullable mixins statement - */ - public record ForResourceAndMixins(Syntax.Statement.ForResource forResource, Syntax.Statement.Mixins mixins) {} - - /** - * @param statements The statements to search - * @param memberStatementIndex The index of the statement to search from - * @return The closest adjacent {@link Syntax.Statement.ForResource} and - * {@link Syntax.Statement.Mixins} to the statement at the given index. - */ - public static ForResourceAndMixins closestForResourceAndMixinsBeforeMember( - List statements, - int memberStatementIndex - ) { - int searchStatementIndex = memberStatementIndex; - while (searchStatementIndex >= 0) { - Syntax.Statement searchStatement = 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 = 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 ForResourceAndMixins(forResource, mixins); - } - searchStatementIndex--; - } - - return new ForResourceAndMixins(null, null); - } - - /** - * @param document The document to search within - * @param statements The statements to search - * @param memberStatementIndex The index of the member statement to search around - * @return The names of other members around (but not including) the member at - * {@code memberStatementIndex}. - */ - public static Set otherMemberNames( - Document document, - List statements, - int memberStatementIndex - ) { - Set found = new HashSet<>(); - int searchIndex = memberStatementIndex; - int lastMemberStatementIndex = memberStatementIndex; - while (searchIndex >= 0) { - Syntax.Statement statement = statements.get(searchIndex); - if (statement instanceof Syntax.Statement.Block block) { - lastMemberStatementIndex = block.lastStatementIndex(); - break; - } else if (searchIndex != memberStatementIndex) { - addMemberName(document, found, statement); - } - searchIndex--; - } - searchIndex = memberStatementIndex + 1; - while (searchIndex <= lastMemberStatementIndex) { - Syntax.Statement statement = statements.get(searchIndex); - addMemberName(document, found, statement); - searchIndex++; - } - return found; - } - - private static void addMemberName(Document document, Set memberNames, Syntax.Statement statement) { - switch (statement) { - case Syntax.Statement.MemberDef def -> memberNames.add(def.name().copyValueFrom(document)); - case Syntax.Statement.NodeMemberDef def -> memberNames.add(def.name().copyValueFrom(document)); - case Syntax.Statement.InlineMemberDef def -> memberNames.add(def.name().copyValueFrom(document)); - case Syntax.Statement.ElidedMemberDef def -> memberNames.add(def.name().copyValueFrom(document)); - default -> { - } - } - } - - /** - * @param statements The statements to search - * @param traitStatementIndex The index of the trait statement to search from - * @return The closest shape def statement after {@code traitStatementIndex}, - * or null if none was found. - */ - public static Syntax.Statement.ShapeDef closestShapeDefAfterTrait( - List statements, - int traitStatementIndex - ) { - for (int i = traitStatementIndex + 1; i < statements.size(); i++) { - Syntax.Statement statement = 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/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java index dde93b90..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(11) - .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(6) - .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/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java index 0891145b..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,15 +6,12 @@ package software.amazon.smithy.lsp.document; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.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.safeString; -import static software.amazon.smithy.lsp.document.DocumentTest.string; -import java.util.Map; import java.util.stream.Stream; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; @@ -40,19 +37,19 @@ 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(notNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.origin())); + assertThat(trailingComment.documentNamespace().namespace(), equalTo("com.foo")); assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 17))); } @@ -68,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")); @@ -108,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(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(), nullValue()); + 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(); @@ -135,92 +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 - } - } - """; - DocumentParser parser = DocumentParser.of(safeString(text)); - Map documentShapes = parser.documentShapes(); - - 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 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(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 index 81007c00..07f16727 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -9,9 +9,12 @@ 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; @@ -26,9 +29,9 @@ 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; -import software.amazon.smithy.lsp.project.SmithyFile; public class CompletionHandlerTest { @Test @@ -1033,6 +1036,35 @@ public void completesNodeMemberTargetStart() { 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()); } @@ -1045,7 +1077,7 @@ private static List getCompItems(String text, Position... positi 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); + IdlFile smithyFile = (IdlFile) project.getSmithyFile(uri); List completionItems = new ArrayList<>(); CompletionHandler handler = new CompletionHandler(project, smithyFile); diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java index 5383d527..0dbed9c4 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java @@ -25,11 +25,12 @@ 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; -import software.amazon.smithy.lsp.syntax.SyntaxSearch; public class DefinitionHandlerTest { @Test @@ -312,6 +313,21 @@ public void idRefTraitValue() { 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, @@ -323,17 +339,18 @@ private static void assertIsShapeDef( int documentIndex = smithyFile.document().indexOfPosition(location.getRange().getStart()); assertThat(documentIndex, greaterThanOrEqualTo(0)); - int statementIndex = SyntaxSearch.statementIndex(smithyFile.statements(), documentIndex); - assertThat(statementIndex, greaterThanOrEqualTo(0)); + StatementView view = StatementView.createAt(((IdlFile) smithyFile).getParse(), documentIndex).orElse(null); + assertThat(view, notNullValue()); + assertThat(view.statementIndex(), greaterThanOrEqualTo(0)); - var statement = smithyFile.statements().get(statementIndex); + var statement = view.getStatement(); if (statement instanceof Syntax.Statement.ShapeDef shapeDef) { - String shapeType = shapeDef.shapeType().copyValueFrom(smithyFile.document()); - String shapeName = shapeDef.shapeName().copyValueFrom(smithyFile.document()); + 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().copyValueFrom(smithyFile.document()); - String memberTarget = memberDef.target().copyValueFrom(smithyFile.document()); + 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()); @@ -353,7 +370,7 @@ private static GetLocationsResult getLocations(String text, Position... position SmithyFile smithyFile = project.getSmithyFile(uri); List locations = new ArrayList<>(); - DefinitionHandler handler = new DefinitionHandler(project, smithyFile); + DefinitionHandler handler = new DefinitionHandler(project, (IdlFile) smithyFile); for (Position position : positions) { DefinitionParams params = RequestBuilders.positionRequest() .uri(uri) 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 index 026f3f93..1bdfdc4c 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -18,6 +18,8 @@ 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; @@ -106,6 +108,24 @@ public void nodeMemberTarget() { 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"))); + } + + 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(); @@ -113,7 +133,7 @@ private static List getHovers(String text, Position... positions) { SmithyFile smithyFile = project.getSmithyFile(uri); List hover = new ArrayList<>(); - HoverHandler handler = new HoverHandler(project, smithyFile, Severity.WARNING); + HoverHandler handler = new HoverHandler(project, (IdlFile) smithyFile, Severity.WARNING); for (Position position : positions) { HoverParams params = RequestBuilders.positionRequest() .uri(uri) 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 bffea311..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(4)); - List documentShapeNames = main.documentShapes().stream() - .map(documentShape -> documentShape.shapeName().toString()) - .collect(Collectors.toList()); - assertThat(documentShapeNames, hasItems("Foo", "bar", "String", "A")); } @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 index a41a836e..004cceb0 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java @@ -290,7 +290,7 @@ public void stringKeysInTraits() { "bar": "baz" ) """; - Syntax.IdlParse parse = Syntax.parseIdl(Document.of(text)); + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); assertThat(parse.statements(), hasSize(1)); assertThat(parse.statements().get(0), instanceOf(Syntax.Statement.TraitApplication.class)); @@ -310,7 +310,7 @@ public void broken(String desc, String text, List expectedErrorMessages, if (desc.equals("trait missing member value")) { System.out.println(); } - Syntax.IdlParse parse = Syntax.parseIdl(Document.of(text)); + 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) @@ -459,7 +459,7 @@ private static Stream brokenProvider() { } private static void assertTypesEqual(String text, Syntax.Statement.Type... types) { - Syntax.IdlParse parse = Syntax.parseIdl(Document.of(text)); + Syntax.IdlParseResult parse = Syntax.parseIdl(Document.of(text)); List actualTypes = parse.statements().stream() .map(Syntax.Statement::type) .filter(type -> type != Syntax.Statement.Type.Block) diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java index e6b7dabe..6f45d5f7 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java @@ -7,6 +7,8 @@ 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; @@ -140,7 +142,7 @@ 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.copyValueFrom(document); + 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)); @@ -164,7 +166,7 @@ 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.copyValueFrom(document); + 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)); @@ -211,7 +213,7 @@ private static Stream goodNumbersProvider() { @ParameterizedTest @MethodSource("brokenProvider") public void broken(String desc, String text, List expectedErrorMessages, List expectedTypes) { - Syntax.NodeParse parse = Syntax.parseNode(Document.of(text)); + Syntax.NodeParseResult parse = Syntax.parseNode(Document.of(text)); List errorMessages = parse.errors().stream().map(Syntax.Err::message).toList(); List types = getNodeTypes(parse.value()); @@ -363,6 +365,35 @@ public void parsesTextBlocks() { 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)); } diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java index cc8d9c16..167a3994 100644 --- a/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java +++ b/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java @@ -21,7 +21,7 @@ public void findsNodeCursor() { }"""); Document document = Document.of(text); Syntax.Node value = Syntax.parseNode(document).value(); - NodeCursor cursor = NodeCursor.create(document, value, document.indexOfPosition(1, 4)); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); assertCursorMatches(cursor, new NodeCursor(List.of( new NodeCursor.Obj(null), @@ -38,7 +38,7 @@ public void findsNodeCursorWhenBroken() { }"""); Document document = Document.of(text); Syntax.Node value = Syntax.parseNode(document).value(); - NodeCursor cursor = NodeCursor.create(document, value, document.indexOfPosition(1, 4)); + NodeCursor cursor = NodeCursor.create(value, document.indexOfPosition(1, 4)); assertCursorMatches(cursor, new NodeCursor(List.of( new NodeCursor.Obj(null), From 046a74ba7709c553cf464a45c8be8c578a9a38c9 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Wed, 18 Dec 2024 13:37:33 -0500 Subject: [PATCH 4/5] Fix hover on member defs --- .../smithy/lsp/language/HoverHandler.java | 1 - .../smithy/lsp/language/IdlPosition.java | 17 ++++++++---- .../smithy/lsp/language/ShapeSearch.java | 13 ++++++++-- .../smithy/lsp/language/HoverHandlerTest.java | 26 +++++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java index b7d72b1f..79ba7073 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java @@ -247,5 +247,4 @@ private static String serializeShape(Model model, Shape shape) { 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 index 40f36008..a13a4697 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java +++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java @@ -12,7 +12,11 @@ * Represents different kinds of positions within an IDL file. */ sealed interface IdlPosition { - default boolean isEasyShapeReference() { + /** + * @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; @@ -25,6 +29,9 @@ default boolean isEasyShapeReference() { }; } + /** + * @return The view this position is within. + */ StatementView view(); record TraitId(StatementView view) implements IdlPosition {} @@ -53,7 +60,7 @@ record MetadataValue(StatementView view, Syntax.Statement.Metadata metadata) imp record StatementKeyword(StatementView view) implements IdlPosition {} - record MemberName(StatementView view) implements IdlPosition {} + record MemberName(StatementView view, String name) implements IdlPosition {} record ElidedMember(StatementView view) implements IdlPosition {} @@ -92,7 +99,7 @@ static IdlPosition of(StatementView view) { when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(view); case Syntax.Statement.MemberDef m - when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view); + 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); @@ -109,9 +116,9 @@ static IdlPosition of(StatementView view) { case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(view); - case Syntax.Statement.NodeMemberDef ignored -> new IdlPosition.MemberName(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.Block ignored -> new IdlPosition.MemberName(view, ""); case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(view); diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java index 5eb1c770..d86e9be9 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java +++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java @@ -78,7 +78,7 @@ private static Optional tryFrom(String id) { private static Optional tryFromParts(String namespace, String name) { try { - return Optional.of(ShapeId.fromParts(namespace, name)); + return Optional.of(ShapeId.fromRelative(namespace, name)); } catch (ShapeIdSyntaxException ignored) { return Optional.empty(); } @@ -118,7 +118,16 @@ static Optional findShapeDefinition(IdlPosition idlPosition, Do case IdlPosition.ElidedMember elidedMember -> findElidedMemberParent(elidedMember, id, model); - case IdlPosition pos when pos.isEasyShapeReference() -> + 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(); diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java index 1bdfdc4c..5f37e89e 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java @@ -122,6 +122,32 @@ public void absoluteShapeId() { 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()); } From 56b1d4c2bfb4635bbbc24998d55613154b130994 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Thu, 2 Jan 2025 10:43:47 -0500 Subject: [PATCH 5/5] Fix replace text range In some cases, when a completion is meant to replace existing text, the range it was supposed to replace would leave an extra character at the end. This was because the range's end position was not exclusive. --- .../amazon/smithy/lsp/document/Document.java | 16 ++++++++++++---- .../lsp/language/CompletionHandlerTest.java | 10 ++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) 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 aba934f4..74c3f0c7 100644 --- a/src/main/java/software/amazon/smithy/lsp/document/Document.java +++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java @@ -478,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); } diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java index 07f16727..b5d3e324 100644 --- a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java +++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java @@ -499,6 +499,16 @@ public void insertionTextHasCorrectRange() { 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("""