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 3e7ce8b8..16c73f07 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -104,9 +104,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; @@ -500,10 +500,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(); @@ -715,7 +716,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); + } + } +}