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 extends Shape> 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 extends Shape> 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 extends Shape> 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 extends Shape> 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 extends Shape> 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);
+ }
+ }
+}