imports) {
+ static final DocumentImports EMPTY = new DocumentImports(LspAdapter.origin(), Set.of());
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java
index 94c8b79b..d6e6ce39 100644
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java
+++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentNamespace.java
@@ -6,6 +6,7 @@
package software.amazon.smithy.lsp.document;
import org.eclipse.lsp4j.Range;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
/**
* The namespace of the document, including the range it occupies.
@@ -13,4 +14,6 @@
* @param statementRange The range of the statement, including {@code namespace}
* @param namespace The namespace of the document. Not guaranteed to be a valid namespace
*/
-public record DocumentNamespace(Range statementRange, CharSequence namespace) {}
+public record DocumentNamespace(Range statementRange, String namespace) {
+ static final DocumentNamespace NONE = new DocumentNamespace(LspAdapter.origin(), "");
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java
index d311e03e..6b322ee6 100644
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java
+++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java
@@ -5,303 +5,112 @@
package software.amazon.smithy.lsp.document;
-import java.util.HashMap;
import java.util.HashSet;
-import java.util.Map;
+import java.util.List;
import java.util.Set;
import org.eclipse.lsp4j.Position;
import org.eclipse.lsp4j.Range;
import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.Syntax;
import software.amazon.smithy.model.SourceLocation;
-import software.amazon.smithy.model.loader.ParserUtils;
-import software.amazon.smithy.model.node.Node;
-import software.amazon.smithy.model.node.StringNode;
-import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.utils.SimpleParser;
/**
- * 'Parser' that uses the line-indexed property of the underlying {@link Document}
- * to jump around the document, parsing small pieces without needing to start at
- * the beginning.
- *
- * This isn't really a parser as much as it is a way to get very specific
- * information about a document, such as whether a given position lies within
- * a trait application, a member target, etc. It won't tell you whether syntax
- * is valid.
- *
- *
Methods on this class often return {@code -1} or {@code null} for failure
- * cases to reduce allocations, since these methods may be called frequently.
+ * Essentially a wrapper around a list of {@link Syntax.Statement}, to map
+ * them into the current "Document*" objects used by the rest of the server,
+ * until we replace those too.
*/
public final class DocumentParser extends SimpleParser {
private final Document document;
+ private final List statements;
- private DocumentParser(Document document) {
+ private DocumentParser(Document document, List statements) {
super(document.borrowText());
this.document = document;
+ this.statements = statements;
}
static DocumentParser of(String text) {
- return DocumentParser.forDocument(Document.of(text));
+ Document document = Document.of(text);
+ Syntax.IdlParseResult parse = Syntax.parseIdl(document);
+ return DocumentParser.forStatements(document, parse.statements());
}
/**
* @param document Document to create a parser for
- * @return A parser for the given document
+ * @param statements The statements the parser should use
+ * @return The parser for the given document and statements
*/
- public static DocumentParser forDocument(Document document) {
- return new DocumentParser(document);
+ public static DocumentParser forStatements(Document document, List statements) {
+ return new DocumentParser(document, statements);
}
/**
- * @return The {@link DocumentNamespace} for the underlying document, or
- * {@code null} if it couldn't be found
+ * @return The {@link DocumentNamespace} for the underlying document.
*/
public DocumentNamespace documentNamespace() {
- int namespaceStartIdx = firstIndexOfWithOnlyLeadingWs("namespace");
- if (namespaceStartIdx < 0) {
- return null;
- }
-
- Position namespaceStatementStartPosition = document.positionAtIndex(namespaceStartIdx);
- if (namespaceStatementStartPosition == null) {
- // Shouldn't happen on account of the previous check
- return null;
- }
- jumpToPosition(namespaceStatementStartPosition);
- skip(); // n
- skip(); // a
- skip(); // m
- skip(); // e
- skip(); // s
- skip(); // p
- skip(); // a
- skip(); // c
- skip(); // e
-
- if (!isSp()) {
- return null;
- }
-
- sp();
-
- if (!isNamespaceChar()) {
- return null;
- }
-
- int start = position();
- while (isNamespaceChar()) {
- skip();
- }
- int end = position();
- CharSequence namespace = document.borrowSpan(start, end);
-
- consumeRemainingCharactersOnLine();
- Position namespaceStatementEnd = currentPosition();
-
- return new DocumentNamespace(new Range(namespaceStatementStartPosition, namespaceStatementEnd), namespace);
- }
-
- /**
- * @return The {@link DocumentImports} for the underlying document, or
- * {@code null} if they couldn't be found
- */
- public DocumentImports documentImports() {
- // TODO: What if its 'uses', not just 'use'?
- // Should we look for another?
- int firstUseStartIdx = firstIndexOfWithOnlyLeadingWs("use");
- if (firstUseStartIdx < 0) {
- // No use
- return null;
- }
-
- Position firstUsePosition = document.positionAtIndex(firstUseStartIdx);
- if (firstUsePosition == null) {
- // Shouldn't happen on account of the previous check
- return null;
- }
- rewind(firstUseStartIdx, firstUsePosition.getLine() + 1, firstUsePosition.getCharacter() + 1);
-
- Set imports = new HashSet<>();
- Position lastUseEnd; // At this point we know there's at least one
- do {
- skip(); // u
- skip(); // s
- skip(); // e
-
- String id = getImport(); // handles skipping the ws
- if (id != null) {
- imports.add(id);
+ for (Syntax.Statement statement : statements) {
+ if (statement instanceof Syntax.Statement.Namespace namespace) {
+ Range range = document.rangeBetween(namespace.start(), namespace.end());
+ String namespaceValue = namespace.namespace().stringValue();
+ return new DocumentNamespace(range, namespaceValue);
+ } else if (statement instanceof Syntax.Statement.ShapeDef) {
+ break;
}
- consumeRemainingCharactersOnLine();
- lastUseEnd = currentPosition();
- nextNonWsNonComment();
- } while (isUse());
-
- if (imports.isEmpty()) {
- return null;
}
-
- return new DocumentImports(new Range(firstUsePosition, lastUseEnd), imports);
+ return DocumentNamespace.NONE;
}
/**
- * @param shapes The shapes defined in the underlying document
- * @return A map of the starting positions of shapes defined or referenced
- * in the underlying document to their corresponding {@link DocumentShape}
+ * @return The {@link DocumentImports} for the underlying document.
*/
- public Map documentShapes(Set shapes) {
- Map documentShapes = new HashMap<>(shapes.size());
- for (Shape shape : shapes) {
- if (!jumpToSource(shape.getSourceLocation())) {
- continue;
- }
-
- DocumentShape documentShape;
- if (shape.isMemberShape()) {
- DocumentShape.Kind kind = DocumentShape.Kind.DefinedMember;
- if (is('$')) {
- kind = DocumentShape.Kind.Elided;
+ public DocumentImports documentImports() {
+ Set imports;
+ for (int i = 0; i < statements.size(); i++) {
+ Syntax.Statement statement = statements.get(i);
+ if (statement instanceof Syntax.Statement.Use firstUse) {
+ imports = new HashSet<>();
+ imports.add(firstUse.use().stringValue());
+ Range useRange = document.rangeBetween(firstUse.start(), firstUse.end());
+ Position start = useRange.getStart();
+ Position end = useRange.getEnd();
+ i++;
+ while (i < statements.size()) {
+ statement = statements.get(i);
+ if (statement instanceof Syntax.Statement.Use use) {
+ imports.add(use.use().stringValue());
+ end = document.rangeBetween(use.start(), use.end()).getEnd();
+ i++;
+ } else {
+ break;
+ }
}
- documentShape = documentShape(kind);
- } else {
- skipAlpha(); // shape type
- sp();
- documentShape = documentShape(DocumentShape.Kind.DefinedShape);
- }
-
- documentShapes.put(documentShape.range().getStart(), documentShape);
- if (documentShape.hasMemberTarget()) {
- DocumentShape memberTarget = documentShape.targetReference();
- documentShapes.put(memberTarget.range().getStart(), memberTarget);
- }
- }
- return documentShapes;
- }
-
- private DocumentShape documentShape(DocumentShape.Kind kind) {
- Position start = currentPosition();
- int startIdx = position();
- if (kind == DocumentShape.Kind.Elided) {
- skip(); // '$'
- startIdx = position(); // so the name doesn't contain '$' - we need to match it later
- }
- skipIdentifier(); // shape name
- Position end = currentPosition();
- int endIdx = position();
- Range range = new Range(start, end);
- CharSequence shapeName = document.borrowSpan(startIdx, endIdx);
-
- // This is a bit ugly, but it avoids intermediate allocations (like a builder would require)
- DocumentShape targetReference = null;
- if (kind == DocumentShape.Kind.DefinedMember) {
- sp();
- if (is(':')) {
- skip();
- sp();
- targetReference = documentShape(DocumentShape.Kind.Targeted);
+ return new DocumentImports(new Range(start, end), imports);
+ } else if (statement instanceof Syntax.Statement.ShapeDef) {
+ break;
}
- } else if (kind == DocumentShape.Kind.DefinedShape && (shapeName == null || shapeName.isEmpty())) {
- kind = DocumentShape.Kind.Inline;
}
-
- return new DocumentShape(range, shapeName, kind, targetReference);
+ return DocumentImports.EMPTY;
}
/**
- * @return The {@link DocumentVersion} for the underlying document, or
- * {@code null} if it couldn't be found
+ * @return The {@link DocumentVersion} for the underlying document.
*/
public DocumentVersion documentVersion() {
- firstIndexOfNonWsNonComment();
- if (!is('$')) {
- return null;
- }
- while (is('$') && !isVersion()) {
- // Skip this line
- if (!jumpToLine(line())) {
- return null;
+ for (Syntax.Statement statement : statements) {
+ if (statement instanceof Syntax.Statement.Control control
+ && control.value() instanceof Syntax.Node.Str str) {
+ String key = control.key().stringValue();
+ if (key.equals("version")) {
+ String version = str.stringValue();
+ Range range = document.rangeBetween(control.start(), control.end());
+ return new DocumentVersion(range, version);
+ }
+ } else if (statement instanceof Syntax.Statement.Namespace) {
+ break;
}
- // Skip any ws and docs
- nextNonWsNonComment();
- }
-
- // Found a non-control statement before version.
- if (!is('$')) {
- return null;
- }
-
- Position start = currentPosition();
- skip(); // $
- skipAlpha(); // version
- sp();
- if (!is(':')) {
- return null;
- }
- skip(); // ':'
- sp();
- int nodeStartCharacter = column() - 1;
- CharSequence span = document.borrowSpan(position(), document.lineEnd(line() - 1) + 1);
- if (span == null) {
- return null;
- }
-
- // TODO: Ew
- Node node;
- try {
- node = StringNode.parseJsonWithComments(span.toString());
- } catch (Exception e) {
- return null;
- }
-
- if (node.isStringNode()) {
- String version = node.expectStringNode().getValue();
- int end = nodeStartCharacter + version.length() + 2; // ?
- Range range = LspAdapter.of(start.getLine(), start.getCharacter(), start.getLine(), end);
- return new DocumentVersion(range, version);
}
- return null;
- }
-
- /**
- * @param sourceLocation The source location of the start of the trait
- * application. The filename must be the same as
- * the underlying document's (this is not checked),
- * and the position must be on the {@code @}
- * @return The range of the trait id from the {@code @} up to the trait's
- * body or end, or null if the {@code sourceLocation} isn't on an {@code @}
- * or there's no id next to the {@code @}
- */
- public Range traitIdRange(SourceLocation sourceLocation) {
- if (!jumpToSource(sourceLocation)) {
- return null;
- }
-
- if (!is('@')) {
- return null;
- }
-
- skip();
-
- while (isShapeIdChar()) {
- skip();
- }
-
- return new Range(LspAdapter.toPosition(sourceLocation), currentPosition());
- }
-
- /**
- * Jumps the parser location to the start of the given {@code line}.
- *
- * @param line The line in the underlying document to jump to
- * @return Whether the parser successfully jumped
- */
- public boolean jumpToLine(int line) {
- int idx = this.document.indexOfLine(line);
- if (idx >= 0) {
- this.rewind(idx, line + 1, 1);
- return true;
- }
- return false;
+ return DocumentVersion.EMPTY;
}
/**
@@ -320,13 +129,6 @@ public boolean jumpToSource(SourceLocation source) {
return true;
}
- /**
- * @return The current position of the parser
- */
- public Position currentPosition() {
- return new Position(line() - 1, column() - 1);
- }
-
/**
* @return The underlying document
*/
@@ -334,264 +136,6 @@ public Document getDocument() {
return this.document;
}
- /**
- * @param position The position in the document to check
- * @return The context at that position
- */
- public DocumentPositionContext determineContext(Position position) {
- // TODO: Support additional contexts
- // Also can compute these in one pass probably.
- if (isTrait(position)) {
- return DocumentPositionContext.TRAIT;
- } else if (isMemberTarget(position)) {
- return DocumentPositionContext.MEMBER_TARGET;
- } else if (isShapeDef(position)) {
- return DocumentPositionContext.SHAPE_DEF;
- } else if (isMixin(position)) {
- return DocumentPositionContext.MIXIN;
- } else if (isUseTarget(position)) {
- return DocumentPositionContext.USE_TARGET;
- } else {
- return DocumentPositionContext.OTHER;
- }
- }
-
- private boolean isTrait(Position position) {
- if (!jumpToPosition(position)) {
- return false;
- }
- CharSequence line = document.borrowLine(position.getLine());
- if (line == null) {
- return false;
- }
-
- for (int i = position.getCharacter() - 1; i >= 0; i--) {
- char c = line.charAt(i);
- if (c == '@') {
- return true;
- }
- if (!isShapeIdChar()) {
- return false;
- }
- }
- return false;
- }
-
- private boolean isMixin(Position position) {
- int idx = document.indexOfPosition(position);
- if (idx < 0) {
- return false;
- }
-
- int lastWithIndex = document.lastIndexOf("with", idx);
- if (lastWithIndex < 0) {
- return false;
- }
-
- jumpToPosition(document.positionAtIndex(lastWithIndex));
- if (!isWs(-1)) {
- return false;
- }
- skip();
- skip();
- skip();
- skip();
-
- if (position() >= idx) {
- return false;
- }
-
- ws();
-
- if (position() >= idx) {
- return false;
- }
-
- if (!is('[')) {
- return false;
- }
-
- skip();
-
- while (position() < idx) {
- if (!isWs() && !isShapeIdChar() && !is(',')) {
- return false;
- }
- ws();
- skipShapeId();
- ws();
- if (is(',')) {
- skip();
- ws();
- }
- }
-
- return true;
- }
-
- private boolean isShapeDef(Position position) {
- int idx = document.indexOfPosition(position);
- if (idx < 0) {
- return false;
- }
-
- if (!jumpToLine(position.getLine())) {
- return false;
- }
-
- if (position() >= idx) {
- return false;
- }
-
- if (!isShapeType()) {
- return false;
- }
-
- skipAlpha();
-
- if (position() >= idx) {
- return false;
- }
-
- if (!isSp()) {
- return false;
- }
-
- sp();
- skipIdentifier();
-
- return position() >= idx;
- }
-
- private boolean isMemberTarget(Position position) {
- int idx = document.indexOfPosition(position);
- if (idx < 0) {
- return false;
- }
-
- int lastColonIndex = document.lastIndexOfOnLine(':', idx, position.getLine());
- if (lastColonIndex < 0) {
- return false;
- }
-
- if (!jumpToPosition(document.positionAtIndex(lastColonIndex))) {
- return false;
- }
-
- skip(); // ':'
- sp();
-
- if (position() >= idx) {
- return true;
- }
-
- skipShapeId();
-
- return position() >= idx;
- }
-
- private boolean isUseTarget(Position position) {
- int idx = document.indexOfPosition(position);
- if (idx < 0) {
- return false;
- }
- int lineStartIdx = document.indexOfLine(document.lineOfIndex(idx));
-
- int useIdx = nextIndexOfWithOnlyLeadingWs("use", lineStartIdx, idx);
- if (useIdx < 0) {
- return false;
- }
-
- jumpToPosition(document.positionAtIndex(useIdx));
-
- skip(); // u
- skip(); // s
- skip(); // e
-
- if (!isSp()) {
- return false;
- }
-
- sp();
-
- skipShapeId();
-
- return position() >= idx;
- }
-
- private boolean jumpToPosition(Position position) {
- int idx = this.document.indexOfPosition(position);
- if (idx < 0) {
- return false;
- }
- this.rewind(idx, position.getLine() + 1, position.getCharacter() + 1);
- return true;
- }
-
- private void skipAlpha() {
- while (isAlpha()) {
- skip();
- }
- }
-
- private void skipIdentifier() {
- if (isAlpha() || isUnder()) {
- skip();
- }
- while (isAlpha() || isDigit() || isUnder()) {
- skip();
- }
- }
-
- private boolean isIdentifierStart() {
- return isAlpha() || isUnder();
- }
-
- private boolean isIdentifierChar() {
- return isAlpha() || isUnder() || isDigit();
- }
-
- private boolean isAlpha() {
- return Character.isAlphabetic(peek());
- }
-
- private boolean isUnder() {
- return peek() == '_';
- }
-
- private boolean isDigit() {
- return Character.isDigit(peek());
- }
-
- private boolean isUse() {
- return is('u', 0) && is('s', 1) && is('e', 2);
- }
-
- private boolean isVersion() {
- return is('$', 0) && is('v', 1) && is('e', 2) && is('r', 3) && is('s', 4) && is('i', 5) && is('o', 6)
- && is('n', 7) && (is(':', 8) || is(' ', 8) || is('\t', 8));
-
- }
-
- private String getImport() {
- if (!is(' ', 0) && !is('\t', 0)) {
- // should be a space after use
- return null;
- }
-
- sp(); // skip space after use
-
- try {
- return ParserUtils.parseRootShapeId(this);
- } catch (Exception e) {
- return null;
- }
- }
-
- private boolean is(char c, int offset) {
- return peek(offset) == c;
- }
-
private boolean is(char c) {
return peek() == c;
}
@@ -620,91 +164,6 @@ private boolean isEof() {
return is(EOF);
}
- private boolean isShapeIdChar() {
- return isIdentifierChar() || is('#') || is('.') || is('$');
- }
-
- private void skipShapeId() {
- while (isShapeIdChar()) {
- skip();
- }
- }
-
- private boolean isShapeIdChar(char c) {
- return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '.';
- }
-
- private boolean isNamespaceChar() {
- return isIdentifierChar() || is('.');
- }
-
- private boolean isShapeType() {
- CharSequence token = document.borrowToken(currentPosition());
- if (token == null) {
- return false;
- }
-
- return switch (token.toString()) {
- case "structure", "operation", "string", "integer", "list", "map", "boolean", "enum", "union", "blob",
- "byte", "short", "long", "float", "double", "timestamp", "intEnum", "document", "service",
- "resource", "bigDecimal", "bigInteger" -> true;
- default -> false;
- };
- }
-
- private int firstIndexOfWithOnlyLeadingWs(String s) {
- return nextIndexOfWithOnlyLeadingWs(s, 0, document.length());
- }
-
- private int nextIndexOfWithOnlyLeadingWs(String s, int start, int end) {
- int searchFrom = start;
- int previousSearchFrom;
- do {
- int idx = document.nextIndexOf(s, searchFrom);
- if (idx < 0) {
- return -1;
- }
- int lineStart = document.lastIndexOf(System.lineSeparator(), idx) + 1;
- if (idx == lineStart) {
- return idx;
- }
- CharSequence before = document.borrowSpan(lineStart, idx);
- if (before == null) {
- return -1;
- }
- if (before.chars().allMatch(Character::isWhitespace)) {
- return idx;
- }
- previousSearchFrom = searchFrom;
- searchFrom = idx + 1;
- } while (previousSearchFrom != searchFrom && searchFrom < end);
- return -1;
- }
-
- private int firstIndexOfNonWsNonComment() {
- reset();
- do {
- ws();
- if (is('/')) {
- consumeRemainingCharactersOnLine();
- }
- } while (isWs());
- return position();
- }
-
- private void nextNonWsNonComment() {
- do {
- ws();
- if (is('/')) {
- consumeRemainingCharactersOnLine();
- }
- } while (isWs());
- }
-
- private void reset() {
- rewind(0, 1, 1);
- }
-
/**
* Finds a contiguous range of non-whitespace characters starting from the given SourceLocation.
* If the sourceLocation happens to be a whitespace character, it returns a Range representing that column.
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java
deleted file mode 100644
index e3007332..00000000
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.document;
-
-/**
- * Represents what kind of construct might exist at a certain position in a document.
- */
-public enum DocumentPositionContext {
- /**
- * Within a trait id, that is anywhere from the {@code @} to the start of the
- * trait's body, or its end (if there is no trait body).
- */
- TRAIT,
-
- /**
- * Within the target of a member.
- */
- MEMBER_TARGET,
-
- /**
- * Within a shape definition, specifically anywhere from the beginning of
- * the shape type token, and the end of the shape name token. Does not
- * include members.
- */
- SHAPE_DEF,
-
- /**
- * Within a mixed in shape, specifically in the {@code []} next to {@code with}.
- */
- MIXIN,
-
- /**
- * Within the target (shape id) of a {@code use} statement.
- */
- USE_TARGET,
-
- /**
- * An unknown or indeterminate position.
- */
- OTHER
-}
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java
deleted file mode 100644
index 1fe748e1..00000000
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentShape.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.document;
-
-import org.eclipse.lsp4j.Range;
-
-/**
- * A Shape definition OR reference within a document, including the range it occupies.
- *
- * Shapes can be defined/referenced in various ways within a Smithy file, each
- * corresponding to a specific {@link Kind}. For each kind, the range spans the
- * shape name/id only.
- */
-public record DocumentShape(
- Range range,
- CharSequence shapeName,
- Kind kind,
- DocumentShape targetReference
-) {
- public boolean isKind(Kind other) {
- return this.kind.equals(other);
- }
-
- public boolean hasMemberTarget() {
- return isKind(Kind.DefinedMember) && targetReference() != null;
- }
-
- /**
- * The different kinds of {@link DocumentShape}s that can exist, corresponding to places
- * that a shape definition or reference may appear. This is non-exhaustive (for now).
- */
- public enum Kind {
- DefinedShape,
- DefinedMember,
- Elided,
- Targeted,
- Inline
- }
-}
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java
index da710cc3..a64512bb 100644
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java
+++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentVersion.java
@@ -6,6 +6,7 @@
package software.amazon.smithy.lsp.document;
import org.eclipse.lsp4j.Range;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
/**
* The Smithy version of the document, including the range it occupies.
@@ -13,4 +14,6 @@
* @param range The range of the version statement
* @param version The literal text of the version value
*/
-public record DocumentVersion(Range range, String version) {}
+public record DocumentVersion(Range range, String version) {
+ static final DocumentVersion EMPTY = new DocumentVersion(LspAdapter.origin(), "");
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java
deleted file mode 100644
index 874cb048..00000000
--- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java
+++ /dev/null
@@ -1,315 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.handler;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.BiConsumer;
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-import java.util.stream.Stream;
-import org.eclipse.lsp4j.CompletionContext;
-import org.eclipse.lsp4j.CompletionItem;
-import org.eclipse.lsp4j.CompletionItemKind;
-import org.eclipse.lsp4j.CompletionParams;
-import org.eclipse.lsp4j.CompletionTriggerKind;
-import org.eclipse.lsp4j.Position;
-import org.eclipse.lsp4j.Range;
-import org.eclipse.lsp4j.TextEdit;
-import org.eclipse.lsp4j.jsonrpc.CancelChecker;
-import org.eclipse.lsp4j.jsonrpc.messages.Either;
-import software.amazon.smithy.lsp.document.DocumentId;
-import software.amazon.smithy.lsp.document.DocumentParser;
-import software.amazon.smithy.lsp.document.DocumentPositionContext;
-import software.amazon.smithy.lsp.project.Project;
-import software.amazon.smithy.lsp.project.SmithyFile;
-import software.amazon.smithy.lsp.protocol.LspAdapter;
-import software.amazon.smithy.model.Model;
-import software.amazon.smithy.model.loader.Prelude;
-import software.amazon.smithy.model.shapes.BlobShape;
-import software.amazon.smithy.model.shapes.BooleanShape;
-import software.amazon.smithy.model.shapes.ListShape;
-import software.amazon.smithy.model.shapes.MapShape;
-import software.amazon.smithy.model.shapes.MemberShape;
-import software.amazon.smithy.model.shapes.SetShape;
-import software.amazon.smithy.model.shapes.Shape;
-import software.amazon.smithy.model.shapes.ShapeId;
-import software.amazon.smithy.model.shapes.ShapeVisitor;
-import software.amazon.smithy.model.shapes.StringShape;
-import software.amazon.smithy.model.shapes.StructureShape;
-import software.amazon.smithy.model.shapes.TimestampShape;
-import software.amazon.smithy.model.shapes.UnionShape;
-import software.amazon.smithy.model.traits.MixinTrait;
-import software.amazon.smithy.model.traits.RequiredTrait;
-import software.amazon.smithy.model.traits.TraitDefinition;
-
-/**
- * Handles completion requests.
- */
-public final class CompletionHandler {
- // TODO: Handle keyword completions
- private static final List KEYWORDS = Arrays.asList("bigDecimal", "bigInteger", "blob", "boolean", "byte",
- "create", "collectionOperations", "delete", "document", "double", "errors", "float", "identifiers", "input",
- "integer", "integer", "key", "list", "long", "map", "member", "metadata", "namespace", "operation",
- "operations",
- "output", "put", "read", "rename", "resource", "resources", "service", "set", "short", "string",
- "structure",
- "timestamp", "union", "update", "use", "value", "version");
-
- private final Project project;
- private final SmithyFile smithyFile;
-
- public CompletionHandler(Project project, SmithyFile smithyFile) {
- this.project = project;
- this.smithyFile = smithyFile;
- }
-
- /**
- * @param params The request params
- * @return A list of possible completions
- */
- public List handle(CompletionParams params, CancelChecker cc) {
- // TODO: This method has to check for cancellation before using shared resources,
- // and before performing expensive operations. If we have to change this, or do
- // the same type of thing elsewhere, it would be nice to have some type of state
- // machine abstraction or similar to make sure cancellation is properly checked.
- if (cc.isCanceled()) {
- return Collections.emptyList();
- }
-
- Position position = params.getPosition();
- CompletionContext completionContext = params.getContext();
- if (completionContext != null
- && completionContext.getTriggerKind().equals(CompletionTriggerKind.Invoked)
- && position.getCharacter() > 0) {
- // When the trigger is 'Invoked', the position is the next character
- position.setCharacter(position.getCharacter() - 1);
- }
-
- if (cc.isCanceled()) {
- return Collections.emptyList();
- }
-
- // TODO: Maybe we should only copy the token up to the current character
- DocumentId id = smithyFile.document().copyDocumentId(position);
- if (id == null || id.idSlice().isEmpty()) {
- return Collections.emptyList();
- }
-
- if (cc.isCanceled()) {
- return Collections.emptyList();
- }
-
- Optional modelResult = project.modelResult().getResult();
- if (modelResult.isEmpty()) {
- return Collections.emptyList();
- }
- Model model = modelResult.get();
- DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document())
- .determineContext(position);
-
- if (cc.isCanceled()) {
- return Collections.emptyList();
- }
-
- return contextualShapes(model, context, smithyFile)
- .filter(contextualMatcher(id, context))
- .mapMulti(completionsFactory(context, model, smithyFile, id))
- .toList();
- }
-
- private static BiConsumer> completionsFactory(
- DocumentPositionContext context,
- Model model,
- SmithyFile smithyFile,
- DocumentId id
- ) {
- TraitBodyVisitor visitor = new TraitBodyVisitor(model);
- boolean useFullId = shouldMatchOnAbsoluteId(id, context);
- return (shape, consumer) -> {
- String shapeLabel = useFullId
- ? shape.getId().toString()
- : shape.getId().getName();
-
- switch (context) {
- case TRAIT -> {
- String traitBody = shape.accept(visitor);
- // Strip outside pair of brackets from any structure traits.
- if (!traitBody.isEmpty() && traitBody.charAt(0) == '{') {
- traitBody = traitBody.substring(1, traitBody.length() - 1);
- }
-
- if (!traitBody.isEmpty()) {
- CompletionItem traitWithMembersItem = createCompletion(
- shapeLabel + "(" + traitBody + ")", shape.getId(), smithyFile, useFullId, id);
- consumer.accept(traitWithMembersItem);
- }
-
- if (shape.isStructureShape() && !shape.members().isEmpty()) {
- shapeLabel += "()";
- }
- CompletionItem defaultItem = createCompletion(shapeLabel, shape.getId(), smithyFile, useFullId, id);
- consumer.accept(defaultItem);
- }
- case MEMBER_TARGET, MIXIN, USE_TARGET -> {
- CompletionItem item = createCompletion(shapeLabel, shape.getId(), smithyFile, useFullId, id);
- consumer.accept(item);
- }
- default -> {
- }
- }
- };
- }
-
- private static void addTextEdits(CompletionItem completionItem, ShapeId shapeId, SmithyFile smithyFile) {
- String importId = shapeId.toString();
- String importNamespace = shapeId.getNamespace();
- CharSequence currentNamespace = smithyFile.namespace();
-
- if (importNamespace.contentEquals(currentNamespace)
- || Prelude.isPreludeShape(shapeId)
- || smithyFile.hasImport(importId)) {
- return;
- }
-
- TextEdit textEdit = getImportTextEdit(smithyFile, importId);
- if (textEdit != null) {
- completionItem.setAdditionalTextEdits(Collections.singletonList(textEdit));
- }
- }
-
- private static TextEdit getImportTextEdit(SmithyFile smithyFile, String importId) {
- String insertText = System.lineSeparator() + "use " + importId;
- // We can only know where to put the import if there's already use statements, or a namespace
- if (smithyFile.documentImports().isPresent()) {
- Range importsRange = smithyFile.documentImports().get().importsRange();
- Range editRange = LspAdapter.point(importsRange.getEnd());
- return new TextEdit(editRange, insertText);
- } else if (smithyFile.documentNamespace().isPresent()) {
- Range namespaceStatementRange = smithyFile.documentNamespace().get().statementRange();
- Range editRange = LspAdapter.point(namespaceStatementRange.getEnd());
- return new TextEdit(editRange, insertText);
- }
-
- return null;
- }
-
- private static Stream contextualShapes(Model model, DocumentPositionContext context, SmithyFile smithyFile) {
- return switch (context) {
- case TRAIT -> model.getShapesWithTrait(TraitDefinition.class).stream();
- case MEMBER_TARGET -> model.shapes()
- .filter(shape -> !shape.isMemberShape())
- .filter(shape -> !shape.hasTrait(TraitDefinition.class));
- case MIXIN -> model.getShapesWithTrait(MixinTrait.class).stream();
- case USE_TARGET -> model.shapes()
- .filter(shape -> !shape.isMemberShape())
- .filter(shape -> !shape.getId().getNamespace().contentEquals(smithyFile.namespace()))
- .filter(shape -> !smithyFile.hasImport(shape.getId().toString()));
- default -> Stream.empty();
- };
- }
-
- private static Predicate contextualMatcher(DocumentId id, DocumentPositionContext context) {
- String matchToken = id.copyIdValue().toLowerCase();
- if (shouldMatchOnAbsoluteId(id, context)) {
- return (shape) -> shape.getId().toString().toLowerCase().startsWith(matchToken);
- } else {
- return (shape) -> shape.getId().getName().toLowerCase().startsWith(matchToken);
- }
- }
-
- private static boolean shouldMatchOnAbsoluteId(DocumentId id, DocumentPositionContext context) {
- return context == DocumentPositionContext.USE_TARGET
- || id.type() == DocumentId.Type.NAMESPACE
- || id.type() == DocumentId.Type.ABSOLUTE_ID;
- }
-
- private static CompletionItem createCompletion(
- String label,
- ShapeId shapeId,
- SmithyFile smithyFile,
- boolean useFullId,
- DocumentId id
- ) {
- CompletionItem completionItem = new CompletionItem(label);
- completionItem.setKind(CompletionItemKind.Class);
- TextEdit textEdit = new TextEdit(id.range(), label);
- completionItem.setTextEdit(Either.forLeft(textEdit));
- if (!useFullId) {
- addTextEdits(completionItem, shapeId, smithyFile);
- }
- return completionItem;
- }
-
- private static final class TraitBodyVisitor extends ShapeVisitor.Default {
- private final Model model;
-
- TraitBodyVisitor(Model model) {
- this.model = model;
- }
-
- @Override
- protected String getDefault(Shape shape) {
- return "";
- }
-
- @Override
- public String blobShape(BlobShape shape) {
- return "\"\"";
- }
-
- @Override
- public String booleanShape(BooleanShape shape) {
- return "true|false";
- }
-
- @Override
- public String listShape(ListShape shape) {
- return "[]";
- }
-
- @Override
- public String mapShape(MapShape shape) {
- return "{}";
- }
-
- @Override
- public String setShape(SetShape shape) {
- return "[]";
- }
-
- @Override
- public String stringShape(StringShape shape) {
- return "\"\"";
- }
-
- @Override
- public String structureShape(StructureShape shape) {
- List entries = new ArrayList<>();
- for (MemberShape memberShape : shape.members()) {
- if (memberShape.hasTrait(RequiredTrait.class)) {
- Shape targetShape = model.expectShape(memberShape.getTarget());
- entries.add(memberShape.getMemberName() + ": " + targetShape.accept(this));
- }
- }
- return "{" + String.join(", ", entries) + "}";
- }
-
- @Override
- public String timestampShape(TimestampShape shape) {
- // TODO: Handle timestampFormat (which could indicate a numeric default)
- return "\"\"";
- }
-
- @Override
- public String unionShape(UnionShape shape) {
- return "{}";
- }
- }
-}
diff --git a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java
deleted file mode 100644
index 264960c4..00000000
--- a/src/main/java/software/amazon/smithy/lsp/handler/DefinitionHandler.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.handler;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Predicate;
-import java.util.stream.Stream;
-import org.eclipse.lsp4j.DefinitionParams;
-import org.eclipse.lsp4j.Location;
-import org.eclipse.lsp4j.Position;
-import software.amazon.smithy.lsp.document.DocumentId;
-import software.amazon.smithy.lsp.document.DocumentParser;
-import software.amazon.smithy.lsp.document.DocumentPositionContext;
-import software.amazon.smithy.lsp.project.Project;
-import software.amazon.smithy.lsp.project.SmithyFile;
-import software.amazon.smithy.lsp.protocol.LspAdapter;
-import software.amazon.smithy.model.Model;
-import software.amazon.smithy.model.loader.Prelude;
-import software.amazon.smithy.model.shapes.Shape;
-import software.amazon.smithy.model.traits.MixinTrait;
-import software.amazon.smithy.model.traits.TraitDefinition;
-
-/**
- * Handles go-to-definition requests.
- */
-public final class DefinitionHandler {
- private final Project project;
- private final SmithyFile smithyFile;
-
- public DefinitionHandler(Project project, SmithyFile smithyFile) {
- this.project = project;
- this.smithyFile = smithyFile;
- }
-
- /**
- * @param params The request params
- * @return A list of possible definition locations
- */
- public List handle(DefinitionParams params) {
- Position position = params.getPosition();
- DocumentId id = smithyFile.document().copyDocumentId(position);
- if (id == null || id.idSlice().isEmpty()) {
- return Collections.emptyList();
- }
-
- Optional modelResult = project.modelResult().getResult();
- if (modelResult.isEmpty()) {
- return Collections.emptyList();
- }
-
- Model model = modelResult.get();
- DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document())
- .determineContext(position);
- return contextualShapes(model, context)
- .filter(contextualMatcher(smithyFile, id))
- .findFirst()
- .map(Shape::getSourceLocation)
- .map(LspAdapter::toLocation)
- .map(Collections::singletonList)
- .orElse(Collections.emptyList());
- }
-
- private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) {
- String token = id.copyIdValue();
- if (id.type() == DocumentId.Type.ABSOLUTE_ID) {
- return (shape) -> shape.getId().toString().equals(token);
- } else {
- return (shape) -> (Prelude.isPublicPreludeShape(shape)
- || shape.getId().getNamespace().contentEquals(smithyFile.namespace())
- || smithyFile.hasImport(shape.getId().toString()))
- && shape.getId().getName().equals(token);
- }
- }
-
- private static Stream contextualShapes(Model model, DocumentPositionContext context) {
- return switch (context) {
- case TRAIT -> model.getShapesWithTrait(TraitDefinition.class).stream();
- case MEMBER_TARGET -> model.shapes()
- .filter(shape -> !shape.isMemberShape())
- .filter(shape -> !shape.hasTrait(TraitDefinition.class));
- case MIXIN -> model.getShapesWithTrait(MixinTrait.class).stream();
- default -> model.shapes().filter(shape -> !shape.isMemberShape());
- };
- }
-}
diff --git a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java
deleted file mode 100644
index d0cf640a..00000000
--- a/src/main/java/software/amazon/smithy/lsp/handler/HoverHandler.java
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.handler;
-
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.function.Predicate;
-import java.util.regex.Matcher;
-import java.util.stream.Stream;
-import org.eclipse.lsp4j.Hover;
-import org.eclipse.lsp4j.HoverParams;
-import org.eclipse.lsp4j.MarkupContent;
-import org.eclipse.lsp4j.Position;
-import software.amazon.smithy.lsp.document.DocumentId;
-import software.amazon.smithy.lsp.document.DocumentParser;
-import software.amazon.smithy.lsp.document.DocumentPositionContext;
-import software.amazon.smithy.lsp.project.Project;
-import software.amazon.smithy.lsp.project.SmithyFile;
-import software.amazon.smithy.model.Model;
-import software.amazon.smithy.model.loader.Prelude;
-import software.amazon.smithy.model.shapes.Shape;
-import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer;
-import software.amazon.smithy.model.traits.MixinTrait;
-import software.amazon.smithy.model.traits.TraitDefinition;
-import software.amazon.smithy.model.validation.Severity;
-import software.amazon.smithy.model.validation.ValidatedResult;
-import software.amazon.smithy.model.validation.ValidationEvent;
-
-/**
- * Handles hover requests.
- */
-public final class HoverHandler {
- private final Project project;
- private final SmithyFile smithyFile;
-
- public HoverHandler(Project project, SmithyFile smithyFile) {
- this.project = project;
- this.smithyFile = smithyFile;
- }
-
- /**
- * @return A {@link Hover} instance with empty markdown content.
- */
- public static Hover emptyContents() {
- Hover hover = new Hover();
- hover.setContents(new MarkupContent("markdown", ""));
- return hover;
- }
-
- /**
- * @param params The request params
- * @param minimumSeverity The minimum severity of events to show
- * @return The hover content
- */
- public Hover handle(HoverParams params, Severity minimumSeverity) {
- Hover hover = emptyContents();
- Position position = params.getPosition();
- DocumentId id = smithyFile.document().copyDocumentId(position);
- if (id == null || id.idSlice().isEmpty()) {
- return hover;
- }
-
- ValidatedResult modelResult = project.modelResult();
- if (modelResult.getResult().isEmpty()) {
- return hover;
- }
-
- Model model = modelResult.getResult().get();
- DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document())
- .determineContext(position);
- Optional matchingShape = contextualShapes(model, context)
- .filter(contextualMatcher(smithyFile, id))
- .findFirst();
-
- if (matchingShape.isEmpty()) {
- return hover;
- }
-
- Shape shapeToSerialize = matchingShape.get();
-
- SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder()
- .metadataFilter(key -> false)
- .shapeFilter(s -> s.getId().equals(shapeToSerialize.getId()))
- // TODO: If we remove the documentation trait in the serializer,
- // it also gets removed from members. This causes weird behavior if
- // there are applied traits (such as through mixins), where you get
- // an empty apply because the documentation trait was removed
- // .traitFilter(trait -> !trait.toShapeId().equals(DocumentationTrait.ID))
- .serializePrelude()
- .build();
- Map serialized = serializer.serialize(model);
- Path path = Paths.get(shapeToSerialize.getId().getNamespace() + ".smithy");
- if (!serialized.containsKey(path)) {
- return hover;
- }
-
- StringBuilder hoverContent = new StringBuilder();
- List validationEvents = modelResult.getValidationEvents().stream()
- .filter(event -> event.getShapeId().isPresent())
- .filter(event -> event.getShapeId().get().equals(shapeToSerialize.getId()))
- .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0)
- .toList();
- if (!validationEvents.isEmpty()) {
- for (ValidationEvent event : validationEvents) {
- hoverContent.append("**")
- .append(event.getSeverity())
- .append("**")
- .append(": ")
- .append(event.getMessage());
- }
- hoverContent.append(System.lineSeparator())
- .append(System.lineSeparator())
- .append("---")
- .append(System.lineSeparator())
- .append(System.lineSeparator());
- }
-
- String serializedShape = serialized.get(path)
- .substring(15) // remove '$version: "2.0"'
- .trim()
- .replaceAll(Matcher.quoteReplacement(
- // Replace newline literals with actual newlines
- System.lineSeparator() + System.lineSeparator()), System.lineSeparator());
- hoverContent.append(String.format("""
- ```smithy
- %s
- ```
- """, serializedShape));
-
- // TODO: Add docs to a separate section of the hover content
- // if (shapeToSerialize.hasTrait(DocumentationTrait.class)) {
- // String docs = shapeToSerialize.expectTrait(DocumentationTrait.class).getValue();
- // hoverContent.append("\n---\n").append(docs);
- // }
-
- MarkupContent content = new MarkupContent("markdown", hoverContent.toString());
- hover.setContents(content);
- return hover;
- }
-
- private static Predicate contextualMatcher(SmithyFile smithyFile, DocumentId id) {
- String token = id.copyIdValue();
- if (id.type() == DocumentId.Type.ABSOLUTE_ID) {
- return (shape) -> shape.getId().toString().equals(token);
- } else {
- return (shape) -> (Prelude.isPublicPreludeShape(shape)
- || shape.getId().getNamespace().contentEquals(smithyFile.namespace())
- || smithyFile.hasImport(shape.getId().toString()))
- && shape.getId().getName().equals(token);
- }
- }
-
- private Stream contextualShapes(Model model, DocumentPositionContext context) {
- return switch (context) {
- case TRAIT -> model.getShapesWithTrait(TraitDefinition.class).stream();
- case MEMBER_TARGET -> model.shapes()
- .filter(shape -> !shape.isMemberShape())
- .filter(shape -> !shape.hasTrait(TraitDefinition.class));
- case MIXIN -> model.getShapesWithTrait(MixinTrait.class).stream();
- default -> model.shapes().filter(shape -> !shape.isMemberShape());
- };
- }
-}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java
new file mode 100644
index 00000000..cad276e3
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.shapes.StructureShape;
+
+/**
+ * Provides access to a Smithy model used to model various builtin constructs
+ * of the Smithy language, such as metadata validators.
+ *
+ * As a modeling language, Smithy is, unsurprisingly, good at modeling stuff.
+ * Instead of building a whole separate abstraction to provide completions and
+ * hover information for stuff like metadata validators, the language server uses
+ * a Smithy model for the structure and documentation. This means we can re-use the
+ * same mechanisms of model/node-traversal we do for regular models.
+ *
+ * See the Smithy model for docs on the specific shapes.
+ */
+final class Builtins {
+ static final String NAMESPACE = "smithy.lang.server";
+
+ static final Model MODEL = Model.assembler()
+ .disableValidation()
+ .addImport(Builtins.class.getResource("builtins.smithy"))
+ .addImport(Builtins.class.getResource("control.smithy"))
+ .addImport(Builtins.class.getResource("metadata.smithy"))
+ .addImport(Builtins.class.getResource("members.smithy"))
+ .assemble()
+ .unwrap();
+
+ static final Map BUILTIN_SHAPES = Arrays.stream(BuiltinShape.values())
+ .collect(Collectors.toMap(
+ builtinShape -> id(builtinShape.name()),
+ builtinShape -> builtinShape));
+
+ static final Shape CONTROL = MODEL.expectShape(id("BuiltinControl"));
+
+ static final Shape METADATA = MODEL.expectShape(id("BuiltinMetadata"));
+
+ static final Shape VALIDATORS = MODEL.expectShape(id("BuiltinValidators"));
+
+ static final Shape SHAPE_MEMBER_TARGETS = MODEL.expectShape(id("ShapeMemberTargets"));
+
+ static final Map VALIDATOR_CONFIG_MAPPING = VALIDATORS.members().stream()
+ .collect(Collectors.toMap(
+ MemberShape::getMemberName,
+ memberShape -> memberShape.getTarget()));
+
+ private Builtins() {
+ }
+
+ /**
+ * Shapes in the builtin model that require some custom processing by consumers.
+ *
+ * Some values are special - they don't correspond to a specific shape type,
+ * can't be represented by a Smithy model, or have some known constraints that
+ * aren't as efficient to model. These values get their own dedicated shape in
+ * the builtin model, corresponding to the names of this enum.
+ */
+ enum BuiltinShape {
+ SmithyIdlVersion,
+ AnyNamespace,
+ ValidatorName,
+ AnyShape,
+ AnyTrait,
+ AnyMixin,
+ AnyString,
+ AnyError,
+ AnyOperation,
+ AnyResource,
+ AnyMemberTarget
+ }
+
+ static Shape getMetadataValue(String metadataKey) {
+ return METADATA.getMember(metadataKey)
+ .map(memberShape -> MODEL.expectShape(memberShape.getTarget()))
+ .orElse(null);
+ }
+
+ static StructureShape getMembersForShapeType(String shapeType) {
+ return SHAPE_MEMBER_TARGETS.getMember(shapeType)
+ .map(memberShape -> MODEL.expectShape(memberShape.getTarget(), StructureShape.class))
+ .orElse(null);
+ }
+
+ static Shape getMemberTargetForShapeType(String shapeType, String memberName) {
+ StructureShape memberTargets = getMembersForShapeType(shapeType);
+ if (memberTargets == null) {
+ return null;
+ }
+
+ return memberTargets.getMember(memberName)
+ .map(memberShape -> MODEL.expectShape(memberShape.getTarget()))
+ .orElse(null);
+ }
+
+ private static ShapeId id(String name) {
+ return ShapeId.fromParts(NAMESPACE, name);
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java b/src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java
new file mode 100644
index 00000000..125356ea
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/CompleterContext.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Set;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.Range;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.Project;
+
+/**
+ * Simple POJO capturing common properties that completers need.
+ */
+final class CompleterContext {
+ private final String matchToken;
+ private final Range insertRange;
+ private final Project project;
+ private Set exclude = Set.of();
+ private CompletionItemKind literalKind = CompletionItemKind.Field;
+
+ private CompleterContext(String matchToken, Range insertRange, Project project) {
+ this.matchToken = matchToken;
+ this.insertRange = insertRange;
+ this.project = project;
+ }
+
+ /**
+ * @param id The id at the cursor position.
+ * @param insertRange The range to insert completion text in.
+ * @param project The project the completion was triggered in.
+ * @return A new completer context.
+ */
+ static CompleterContext create(DocumentId id, Range insertRange, Project project) {
+ String matchToken = getMatchToken(id);
+ return new CompleterContext(matchToken, insertRange, project);
+ }
+
+ private static String getMatchToken(DocumentId id) {
+ return id != null
+ ? id.copyIdValue().toLowerCase()
+ : "";
+ }
+
+ /**
+ * @return The token to match candidates against.
+ */
+ String matchToken() {
+ return matchToken;
+ }
+
+ /**
+ * @return The range to insert completion text.
+ */
+ Range insertRange() {
+ return insertRange;
+ }
+
+ /**
+ * @return The project the completion was triggered in.
+ */
+ Project project() {
+ return project;
+ }
+
+ /**
+ * @return The set of tokens to exclude.
+ */
+ Set exclude() {
+ return exclude;
+ }
+
+ CompleterContext withExclude(Set exclude) {
+ this.exclude = exclude;
+ return this;
+ }
+
+ /**
+ * @return The kind of completion to use for {@link CompletionCandidates.Literals},
+ * which will be displayed in the client.
+ */
+ CompletionItemKind literalKind() {
+ return literalKind;
+ }
+
+ CompleterContext withLiteralKind(CompletionItemKind literalKind) {
+ this.literalKind = literalKind;
+ return this;
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java
new file mode 100644
index 00000000..44b2fa8b
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionCandidates.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import software.amazon.smithy.lsp.util.StreamUtils;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.EnumShape;
+import software.amazon.smithy.model.shapes.IntEnumShape;
+import software.amazon.smithy.model.shapes.ListShape;
+import software.amazon.smithy.model.shapes.MapShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.traits.DefaultTrait;
+import software.amazon.smithy.model.traits.IdRefTrait;
+
+/**
+ * Candidates for code completions.
+ *
+ * There are different kinds of completion candidates, each of which may
+ * need to be represented differently, filtered, and/or mapped to IDE-specific
+ * data structures in their own way.
+ */
+sealed interface CompletionCandidates {
+ Constant NONE = new Constant("");
+ Constant EMPTY_STRING = new Constant("\"\"");
+ Constant EMPTY_OBJ = new Constant("{}");
+ Constant EMPTY_ARR = new Constant("[]");
+ Literals BOOL = new Literals(List.of("true", "false"));
+ Literals KEYWORD = new Literals(List.of(
+ "metadata", "namespace", "use",
+ "blob", "boolean", "string", "byte", "short", "integer", "long", "float", "double",
+ "bigInteger", "bigDecimal", "timestamp", "document", "enum", "intEnum",
+ "list", "map", "structure", "union",
+ "service", "resource", "operation",
+ "apply"));
+ Literals BUILTIN_CONTROLS = new Literals(Builtins.CONTROL.members().stream()
+ .map(member -> "$" + member.getMemberName() + ": " + defaultCandidates(member).value())
+ .toList());
+ Literals BUILTIN_METADATA = new Literals(Builtins.METADATA.members().stream()
+ .map(member -> member.getMemberName() + " = []")
+ .toList());
+ Labeled SMITHY_IDL_VERSION = new Labeled(Stream.of("1.0", "2.0")
+ .collect(StreamUtils.toWrappedMap()));
+ Labeled VALIDATOR_NAMES = new Labeled(Builtins.VALIDATOR_CONFIG_MAPPING.keySet().stream()
+ .collect(StreamUtils.toWrappedMap()));
+
+ /**
+ * @apiNote This purposefully does not handle {@link software.amazon.smithy.lsp.language.Builtins.BuiltinShape}
+ * as it is meant to be used for member target default values.
+ *
+ * @param shape The shape to get candidates for.
+ * @return A constant value corresponding to the 'default' or 'empty' value
+ * of a shape.
+ */
+ static Constant defaultCandidates(Shape shape) {
+ if (shape.hasTrait(DefaultTrait.class)) {
+ DefaultTrait defaultTrait = shape.expectTrait(DefaultTrait.class);
+ return new Constant(Node.printJson(defaultTrait.toNode()));
+ }
+
+ if (shape.isBlobShape() || (shape.isStringShape() && !shape.hasTrait(IdRefTrait.class))) {
+ return EMPTY_STRING;
+ } else if (ShapeSearch.isObjectShape(shape)) {
+ return EMPTY_OBJ;
+ } else if (shape.isListShape()) {
+ return EMPTY_ARR;
+ } else {
+ return NONE;
+ }
+ }
+
+ /**
+ * @param result The search result to get candidates from.
+ * @return The completion candidates for {@code result}.
+ */
+ static CompletionCandidates fromSearchResult(NodeSearch.Result result) {
+ return switch (result) {
+ case NodeSearch.Result.TerminalShape(Shape shape, var ignored) ->
+ terminalCandidates(shape);
+
+ case NodeSearch.Result.ObjectKey(var ignored, Shape shape, Model model) ->
+ membersCandidates(model, shape);
+
+ case NodeSearch.Result.ObjectShape(var ignored, Shape shape, Model model) ->
+ membersCandidates(model, shape);
+
+ case NodeSearch.Result.ArrayShape(var ignored, ListShape shape, Model model) ->
+ model.getShape(shape.getMember().getTarget())
+ .map(CompletionCandidates::terminalCandidates)
+ .orElse(NONE);
+
+ default -> NONE;
+ };
+ }
+
+ /**
+ * @param idlPosition The position in the idl to get candidates for.
+ * @return The candidates for shape completions.
+ */
+ static CompletionCandidates shapeCandidates(IdlPosition idlPosition) {
+ return switch (idlPosition) {
+ case IdlPosition.UseTarget ignored -> Shapes.USE_TARGET;
+ case IdlPosition.TraitId ignored -> Shapes.TRAITS;
+ case IdlPosition.Mixin ignored -> Shapes.MIXINS;
+ case IdlPosition.ForResource ignored -> Shapes.RESOURCE_SHAPES;
+ case IdlPosition.MemberTarget ignored -> Shapes.MEMBER_TARGETABLE;
+ case IdlPosition.ApplyTarget ignored -> Shapes.ANY_SHAPE;
+ case IdlPosition.NodeMemberTarget nodeMemberTarget -> fromSearchResult(
+ ShapeSearch.searchNodeMemberTarget(nodeMemberTarget));
+ default -> CompletionCandidates.NONE;
+ };
+ }
+
+ /**
+ * @param model The model that {@code shape} is a part of.
+ * @param shape The shape to get member candidates for.
+ * @return If a struct or union shape, returns {@link Members} candidates.
+ * Otherwise, {@link #NONE}.
+ */
+ static CompletionCandidates membersCandidates(Model model, Shape shape) {
+ if (shape.isStructureShape() || shape.isUnionShape()) {
+ return new Members(shape.getAllMembers().entrySet().stream()
+ .collect(StreamUtils.mappingValue(member -> model.getShape(member.getTarget())
+ .map(CompletionCandidates::defaultCandidates)
+ .orElse(NONE))));
+ } else if (shape instanceof MapShape mapShape) {
+ return model.getShape(mapShape.getKey().getTarget())
+ .flatMap(Shape::asEnumShape)
+ .map(CompletionCandidates::terminalCandidates)
+ .orElse(NONE);
+ }
+ return NONE;
+ }
+
+ private static CompletionCandidates terminalCandidates(Shape shape) {
+ Builtins.BuiltinShape builtinShape = Builtins.BUILTIN_SHAPES.get(shape.getId());
+ if (builtinShape != null) {
+ return forBuiltin(builtinShape);
+ }
+
+ return switch (shape) {
+ case EnumShape enumShape -> new Labeled(enumShape.getEnumValues()
+ .entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, entry -> "\"" + entry.getValue() + "\"")));
+
+ case IntEnumShape intEnumShape -> new Labeled(intEnumShape.getEnumValues()
+ .entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().toString())));
+
+ case Shape s when s.hasTrait(IdRefTrait.class) -> Shapes.ANY_SHAPE;
+
+ case Shape s when s.isBooleanShape() -> BOOL;
+
+ default -> defaultCandidates(shape);
+ };
+ }
+
+ private static CompletionCandidates forBuiltin(Builtins.BuiltinShape builtinShape) {
+ return switch (builtinShape) {
+ case SmithyIdlVersion -> SMITHY_IDL_VERSION;
+ case AnyNamespace -> Custom.NAMESPACE_FILTER;
+ case ValidatorName -> Custom.VALIDATOR_NAME;
+ case AnyShape -> Shapes.ANY_SHAPE;
+ case AnyTrait -> Shapes.TRAITS;
+ case AnyMixin -> Shapes.MIXINS;
+ case AnyString -> Shapes.STRING_SHAPES;
+ case AnyError -> Shapes.ERROR_SHAPES;
+ case AnyOperation -> Shapes.OPERATION_SHAPES;
+ case AnyResource -> Shapes.RESOURCE_SHAPES;
+ case AnyMemberTarget -> Shapes.MEMBER_TARGETABLE;
+ };
+ }
+
+ /**
+ * A single, constant-value completion, like an empty string, for example.
+ *
+ * @param value The completion value.
+ */
+ record Constant(String value) implements CompletionCandidates {}
+
+ /**
+ * Multiple values to be completed as literals, like keywords.
+ *
+ * @param literals The completion values.
+ */
+ record Literals(List literals) implements CompletionCandidates {}
+
+ /**
+ * Multiple label -> value pairs, where the label is displayed to the user,
+ * and may be used for matching, and the value is the literal text to complete.
+ *
+ * For example, completing enum value in a trait may display and match on the
+ * name, like FOO, but complete the actual value, like "foo".
+ *
+ * @param labeled The labeled completion values.
+ */
+ record Labeled(Map labeled) implements CompletionCandidates {}
+
+ /**
+ * Multiple name -> constant pairs, where the name corresponds to a member
+ * name, and the constant is a default/empty value for that member.
+ *
+ * For example, shape members can be completed as {@code name: constant}.
+ *
+ * @param members The members completion values.
+ */
+ record Members(Map members) implements CompletionCandidates {}
+
+ /**
+ * Multiple member names to complete as elided members.
+ *
+ * @apiNote These are distinct from {@link Literals} because they may have
+ * custom filtering/mapping, and may appear _with_ {@link Literals} in an
+ * {@link And}.
+ *
+ * @param memberNames The member names completion values.
+ */
+ record ElidedMembers(Collection memberNames) implements CompletionCandidates {}
+
+ /**
+ * A combination of two sets of completion candidates, of possibly different
+ * types.
+ *
+ * @param one The first set of completion candidates.
+ * @param two The second set of completion candidates.
+ */
+ record And(CompletionCandidates one, CompletionCandidates two) implements CompletionCandidates {}
+
+ /**
+ * Shape completion candidates, each corresponding to a different set of
+ * shapes that will be selected from the model.
+ */
+ enum Shapes implements CompletionCandidates {
+ ANY_SHAPE,
+ USE_TARGET,
+ TRAITS,
+ MIXINS,
+ STRING_SHAPES,
+ ERROR_SHAPES,
+ RESOURCE_SHAPES,
+ OPERATION_SHAPES,
+ MEMBER_TARGETABLE
+ }
+
+ /**
+ * Candidates that require a custom computation to generate, lazily.
+ */
+ enum Custom implements CompletionCandidates {
+ NAMESPACE_FILTER,
+ VALIDATOR_NAME,
+ PROJECT_NAMESPACES,
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java
new file mode 100644
index 00000000..48fc881e
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.lsp4j.CompletionContext;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionParams;
+import org.eclipse.lsp4j.CompletionTriggerKind;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.jsonrpc.CancelChecker;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.IdlFile;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.StatementView;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.StructureShape;
+
+/**
+ * Handles completion requests for the Smithy IDL.
+ */
+public final class CompletionHandler {
+ private final Project project;
+ private final IdlFile smithyFile;
+
+ public CompletionHandler(Project project, IdlFile smithyFile) {
+ this.project = project;
+ this.smithyFile = smithyFile;
+ }
+
+ /**
+ * @param params The request params
+ * @return A list of possible completions
+ */
+ public List handle(CompletionParams params, CancelChecker cc) {
+ // TODO: This method has to check for cancellation before using shared resources,
+ // and before performing expensive operations. If we have to change this, or do
+ // the same type of thing elsewhere, it would be nice to have some type of state
+ // machine abstraction or similar to make sure cancellation is properly checked.
+ if (cc.isCanceled()) {
+ return Collections.emptyList();
+ }
+
+ Position position = getTokenPosition(params);
+ DocumentId id = smithyFile.document().copyDocumentId(position);
+ Range insertRange = getInsertRange(id, position);
+
+ if (cc.isCanceled()) {
+ return Collections.emptyList();
+ }
+
+ Syntax.IdlParseResult parseResult = smithyFile.getParse();
+ int documentIndex = smithyFile.document().indexOfPosition(position);
+ IdlPosition idlPosition = StatementView.createAt(parseResult, documentIndex)
+ .map(IdlPosition::of)
+ .orElse(null);
+
+ if (cc.isCanceled() || idlPosition == null) {
+ return Collections.emptyList();
+ }
+
+ CompleterContext context = CompleterContext.create(id, insertRange, project);
+
+ return switch (idlPosition) {
+ case IdlPosition.ControlKey ignored ->
+ new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Constant))
+ .getCompletionItems(CompletionCandidates.BUILTIN_CONTROLS);
+
+ case IdlPosition.MetadataKey ignored ->
+ new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Field))
+ .getCompletionItems(CompletionCandidates.BUILTIN_METADATA);
+
+ case IdlPosition.StatementKeyword ignored ->
+ new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Keyword))
+ .getCompletionItems(CompletionCandidates.KEYWORD);
+
+ case IdlPosition.Namespace ignored ->
+ new SimpleCompleter(context.withLiteralKind(CompletionItemKind.Module))
+ .getCompletionItems(CompletionCandidates.Custom.PROJECT_NAMESPACES);
+
+ case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, context);
+
+ case IdlPosition.MemberName memberName -> memberNameCompletions(memberName, context);
+
+ default -> modelBasedCompletions(idlPosition, context);
+ };
+ }
+
+ private static Position getTokenPosition(CompletionParams params) {
+ Position position = params.getPosition();
+ CompletionContext context = params.getContext();
+ if (context != null
+ && context.getTriggerKind() == CompletionTriggerKind.Invoked
+ && position.getCharacter() > 0) {
+ position.setCharacter(position.getCharacter() - 1);
+ }
+ return position;
+ }
+
+ private static Range getInsertRange(DocumentId id, Position position) {
+ if (id == null || id.idSlice().isEmpty()) {
+ // When we receive the completion request, we're always on the
+ // character either after what has just been typed, or we're in
+ // empty space and have manually triggered a completion. To account
+ // for this when extracting the DocumentId the cursor is on, we move
+ // the cursor back one. But when we're not on a DocumentId (as is the case here),
+ // we want to insert any completion text at the current cursor position.
+ Position point = new Position(position.getLine(), position.getCharacter() + 1);
+ return LspAdapter.point(point);
+ }
+ return id.range();
+ }
+
+ private List metadataValueCompletions(
+ IdlPosition.MetadataValue metadataValue,
+ CompleterContext context
+ ) {
+ var result = ShapeSearch.searchMetadataValue(metadataValue);
+ Set excludeKeys = result.getOtherPresentKeys();
+ CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result);
+ return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates);
+ }
+
+ private List modelBasedCompletions(IdlPosition idlPosition, CompleterContext context) {
+ if (project.modelResult().getResult().isEmpty()) {
+ return List.of();
+ }
+
+ Model model = project.modelResult().getResult().get();
+
+ if (idlPosition instanceof IdlPosition.ElidedMember elidedMember) {
+ return elidedMemberCompletions(elidedMember, context, model);
+ } else if (idlPosition instanceof IdlPosition.TraitValue traitValue) {
+ return traitValueCompletions(traitValue, context, model);
+ }
+
+ CompletionCandidates candidates = CompletionCandidates.shapeCandidates(idlPosition);
+ if (candidates instanceof CompletionCandidates.Shapes shapes) {
+ return new ShapeCompleter(idlPosition, model, context).getCompletionItems(shapes);
+ } else if (candidates != CompletionCandidates.NONE) {
+ return new SimpleCompleter(context).getCompletionItems(candidates);
+ }
+
+ return List.of();
+ }
+
+ private List elidedMemberCompletions(
+ IdlPosition.ElidedMember elidedMember,
+ CompleterContext context,
+ Model model
+ ) {
+ CompletionCandidates candidates = getElidableMemberCandidates(elidedMember, model);
+ if (candidates == null) {
+ return List.of();
+ }
+
+ Set otherMembers = elidedMember.view().otherMemberNames();
+ return new SimpleCompleter(context.withExclude(otherMembers)).getCompletionItems(candidates);
+ }
+
+ private List traitValueCompletions(
+ IdlPosition.TraitValue traitValue,
+ CompleterContext context,
+ Model model
+ ) {
+ var result = ShapeSearch.searchTraitValue(traitValue, model);
+ Set excludeKeys = result.getOtherPresentKeys();
+ CompletionCandidates candidates = CompletionCandidates.fromSearchResult(result);
+ return new SimpleCompleter(context.withExclude(excludeKeys)).getCompletionItems(candidates);
+ }
+
+ private List memberNameCompletions(IdlPosition.MemberName memberName, CompleterContext context) {
+ Syntax.Statement.ShapeDef shapeDef = memberName.view().nearestShapeDefBefore();
+
+ if (shapeDef == null) {
+ return List.of();
+ }
+
+ String shapeType = shapeDef.shapeType().stringValue();
+ StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType);
+
+ CompletionCandidates candidates = null;
+ if (shapeMembersDef != null) {
+ candidates = CompletionCandidates.membersCandidates(Builtins.MODEL, shapeMembersDef);
+ }
+
+ if (project.modelResult().getResult().isPresent()) {
+ CompletionCandidates elidedCandidates = getElidableMemberCandidates(
+ memberName,
+ project.modelResult().getResult().get());
+
+ if (elidedCandidates != null) {
+ candidates = candidates == null
+ ? elidedCandidates
+ : new CompletionCandidates.And(candidates, elidedCandidates);
+ }
+ }
+
+ if (candidates == null) {
+ return List.of();
+ }
+
+ Set otherMembers = memberName.view().otherMemberNames();
+ return new SimpleCompleter(context.withExclude(otherMembers)).getCompletionItems(candidates);
+ }
+
+ private CompletionCandidates getElidableMemberCandidates(IdlPosition idlPosition, Model model) {
+ Set memberNames = new HashSet<>();
+
+ var forResourceAndMixins = idlPosition.view().nearestForResourceAndMixinsBefore();
+ ShapeSearch.findResource(forResourceAndMixins.forResource(), idlPosition.view(), model)
+ .ifPresent(resourceShape -> {
+ memberNames.addAll(resourceShape.getIdentifiers().keySet());
+ memberNames.addAll(resourceShape.getProperties().keySet());
+ });
+ ShapeSearch.findMixins(forResourceAndMixins.mixins(), idlPosition.view(), model)
+ .forEach(mixinShape -> memberNames.addAll(mixinShape.getMemberNames()));
+
+ if (memberNames.isEmpty()) {
+ return null;
+ }
+
+ return new CompletionCandidates.ElidedMembers(memberNames);
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java
new file mode 100644
index 00000000..30e066fd
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.lsp4j.DefinitionParams;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.IdlFile;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.StatementView;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+
+/**
+ * Handles go-to-definition requests for the Smithy IDL.
+ */
+public final class DefinitionHandler {
+ final Project project;
+ final IdlFile smithyFile;
+
+ public DefinitionHandler(Project project, IdlFile smithyFile) {
+ this.project = project;
+ this.smithyFile = smithyFile;
+ }
+
+ /**
+ * @param params The request params
+ * @return A list of possible definition locations
+ */
+ public List handle(DefinitionParams params) {
+ Position position = params.getPosition();
+ DocumentId id = smithyFile.document().copyDocumentId(position);
+ if (id == null || id.idSlice().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Optional modelResult = project.modelResult().getResult();
+ if (modelResult.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Model model = modelResult.get();
+ Syntax.IdlParseResult parseResult = smithyFile.getParse();
+ int documentIndex = smithyFile.document().indexOfPosition(position);
+ return StatementView.createAt(parseResult, documentIndex)
+ .map(IdlPosition::of)
+ .flatMap(idlPosition -> ShapeSearch.findShapeDefinition(idlPosition, id, model))
+ .map(LspAdapter::toLocation)
+ .map(List::of)
+ .orElse(List.of());
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java
new file mode 100644
index 00000000..7aa47fa0
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/DocumentSymbolHandler.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.lsp4j.DocumentSymbol;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SymbolInformation;
+import org.eclipse.lsp4j.SymbolKind;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.Syntax;
+
+public record DocumentSymbolHandler(Document document, List statements) {
+ /**
+ * @return A list of DocumentSymbol
+ */
+ public List> handle() {
+ return statements.stream()
+ .mapMulti(this::addSymbols)
+ .toList();
+ }
+
+ private void addSymbols(Syntax.Statement statement, Consumer> consumer) {
+ switch (statement) {
+ case Syntax.Statement.TraitApplication app -> addSymbol(consumer, app.id(), SymbolKind.Class);
+
+ case Syntax.Statement.ShapeDef def -> addSymbol(consumer, def.shapeName(), SymbolKind.Class);
+
+ case Syntax.Statement.EnumMemberDef def -> addSymbol(consumer, def.name(), SymbolKind.Enum);
+
+ case Syntax.Statement.ElidedMemberDef def -> addSymbol(consumer, def.name(), SymbolKind.Property);
+
+ case Syntax.Statement.MemberDef def -> {
+ addSymbol(consumer, def.name(), SymbolKind.Property);
+ if (def.target() != null) {
+ addSymbol(consumer, def.target(), SymbolKind.Class);
+ }
+ }
+ default -> {
+ }
+ }
+ }
+
+ private void addSymbol(
+ Consumer> consumer,
+ Syntax.Ident ident,
+ SymbolKind symbolKind
+ ) {
+ Range range = LspAdapter.identRange(ident, document);
+ if (range == null) {
+ return;
+ }
+
+ DocumentSymbol symbol = new DocumentSymbol(ident.stringValue(), symbolKind, range, range);
+ consumer.accept(Either.forRight(symbol));
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java
new file mode 100644
index 00000000..70082804
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Map;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * An abstraction to allow computing the target of a member dynamically, instead
+ * of just using what's in the model, when traversing a model using a
+ * {@link NodeCursor}.
+ *
+ * For example, the examples trait has two members, input and output, whose
+ * values are represented by the target operation's input and output shapes,
+ * respectively. In the model however, these members just target Document shapes,
+ * because we don't have a way to directly model the relationship. It would be
+ * really useful for customers to get e.g. completions despite that, which is the
+ * purpose of this interface.
+ *
+ * @implNote One of the ideas behind this is that you should not have to pay for
+ * computing the member target unless necessary.
+ */
+sealed interface DynamicMemberTarget {
+ /**
+ * @param cursor The cursor being used to traverse the model.
+ * @param model The model being traversed.
+ * @return The target of the member shape at the cursor's current position.
+ */
+ Shape getTarget(NodeCursor cursor, Model model);
+
+ static Map forTrait(Shape traitShape, IdlPosition.TraitValue traitValue) {
+ Syntax.IdlParseResult syntaxInfo = traitValue.view().parseResult();
+ return switch (traitShape.getId().toString()) {
+ case "smithy.test#smokeTests" -> Map.of(
+ ShapeId.from("smithy.test#SmokeTestCase$params"),
+ new OperationInput(traitValue),
+ ShapeId.from("smithy.test#SmokeTestCase$vendorParams"),
+ new ShapeIdDependent("vendorParamsShape", syntaxInfo));
+
+ case "smithy.api#examples" -> Map.of(
+ ShapeId.from("smithy.api#Example$input"),
+ new OperationInput(traitValue),
+ ShapeId.from("smithy.api#Example$output"),
+ new OperationOutput(traitValue));
+
+ case "smithy.test#httpRequestTests" -> Map.of(
+ ShapeId.from("smithy.test#HttpRequestTestCase$params"),
+ new OperationInput(traitValue),
+ ShapeId.from("smithy.test#HttpRequestTestCase$vendorParams"),
+ new ShapeIdDependent("vendorParamsShape", syntaxInfo));
+
+ case "smithy.test#httpResponseTests" -> Map.of(
+ ShapeId.from("smithy.test#HttpResponseTestCase$params"),
+ new OperationOutput(traitValue),
+ ShapeId.from("smithy.test#HttpResponseTestCase$vendorParams"),
+ new ShapeIdDependent("vendorParamsShape", syntaxInfo));
+
+ default -> null;
+ };
+ }
+
+ static Map forMetadata(String metadataKey) {
+ return switch (metadataKey) {
+ case "validators" -> Map.of(
+ ShapeId.from("smithy.lang.server#Validator$configuration"), new MappedDependent(
+ "name",
+ Builtins.VALIDATOR_CONFIG_MAPPING));
+ default -> null;
+ };
+ }
+
+ /**
+ * Computes the input shape of the operation targeted by {@code traitValue},
+ * to use as the member target.
+ *
+ * @param traitValue The position, in the applied trait value.
+ */
+ record OperationInput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget {
+ @Override
+ public Shape getTarget(NodeCursor cursor, Model model) {
+ return ShapeSearch.findTraitTarget(traitValue, model)
+ .flatMap(Shape::asOperationShape)
+ .flatMap(operationShape -> model.getShape(operationShape.getInputShape()))
+ .orElse(null);
+ }
+ }
+
+ /**
+ * Computes the output shape of the operation targeted by {@code traitValue},
+ * to use as the member target.
+ *
+ * @param traitValue The position, in the applied trait value.
+ */
+ record OperationOutput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget {
+ @Override
+ public Shape getTarget(NodeCursor cursor, Model model) {
+ return ShapeSearch.findTraitTarget(traitValue, model)
+ .flatMap(Shape::asOperationShape)
+ .flatMap(operationShape -> model.getShape(operationShape.getOutputShape()))
+ .orElse(null);
+ }
+ }
+
+ /**
+ * Computes the value of another member in the node, {@code memberName},
+ * using that as the id of the target shape.
+ *
+ * @param memberName The name of the other member to compute the value of.
+ * @param parseResult The parse result of the file the node is within.
+ */
+ record ShapeIdDependent(String memberName, Syntax.IdlParseResult parseResult) implements DynamicMemberTarget {
+ @Override
+ public Shape getTarget(NodeCursor cursor, Model model) {
+ Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor);
+ if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) {
+ String id = str.stringValue();
+ return ShapeSearch.findShape(parseResult, id, model).orElse(null);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Computes the value of another member in the node, {@code memberName},
+ * and looks up the id of the target shape from {@code mapping} using that
+ * value.
+ *
+ * @param memberName The name of the member to compute the value of.
+ * @param mapping A mapping of {@code memberName} values to corresponding
+ * member target ids.
+ */
+ record MappedDependent(String memberName, Map mapping) implements DynamicMemberTarget {
+ @Override
+ public Shape getTarget(NodeCursor cursor, Model model) {
+ Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor);
+ if (matchingKvp != null && matchingKvp.value() instanceof Syntax.Node.Str str) {
+ String value = str.stringValue();
+ ShapeId targetId = mapping.get(value);
+ if (targetId != null) {
+ return model.getShape(targetId).orElse(null);
+ }
+ }
+ return null;
+ }
+ }
+
+ // Note: This is suboptimal in isolation, but it should be called rarely in
+ // comparison to parsing or NodeCursor construction, which are optimized for
+ // speed and memory usage (instead of key lookup), and the number of keys
+ // is assumed to be low in most cases.
+ private static Syntax.Node.Kvp findMatchingKvp(String keyName, NodeCursor cursor) {
+ // This will be called after skipping a ValueForKey, so that will be previous
+ if (!cursor.hasPrevious()) {
+ // TODO: Log
+ return null;
+ }
+ NodeCursor.Edge edge = cursor.previous();
+ if (edge instanceof NodeCursor.ValueForKey(var ignored, Syntax.Node.Kvps parent)) {
+ for (Syntax.Node.Kvp kvp : parent.kvps()) {
+ String key = kvp.key().stringValue();
+ if (!keyName.equals(key)) {
+ continue;
+ }
+
+ return kvp;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java
new file mode 100644
index 00000000..79ba7073
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import org.eclipse.lsp4j.Hover;
+import org.eclipse.lsp4j.HoverParams;
+import org.eclipse.lsp4j.MarkupContent;
+import org.eclipse.lsp4j.Position;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.IdlFile;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.StatementView;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer;
+import software.amazon.smithy.model.traits.DocumentationTrait;
+import software.amazon.smithy.model.traits.IdRefTrait;
+import software.amazon.smithy.model.traits.StringTrait;
+import software.amazon.smithy.model.validation.Severity;
+import software.amazon.smithy.model.validation.ValidatedResult;
+import software.amazon.smithy.model.validation.ValidationEvent;
+
+/**
+ * Handles hover requests for the Smithy IDL.
+ */
+public final class HoverHandler {
+ /**
+ * Empty markdown hover content.
+ */
+ public static final Hover EMPTY = new Hover(new MarkupContent("markdown", ""));
+
+ private final Project project;
+ private final IdlFile smithyFile;
+ private final Severity minimumSeverity;
+
+ /**
+ * @param project Project the hover is in
+ * @param smithyFile Smithy file the hover is in
+ * @param minimumSeverity Minimum severity of validation events to show
+ */
+ public HoverHandler(Project project, IdlFile smithyFile, Severity minimumSeverity) {
+ this.project = project;
+ this.smithyFile = smithyFile;
+ this.minimumSeverity = minimumSeverity;
+ }
+
+ /**
+ * @param params The request params
+ * @return The hover content
+ */
+ public Hover handle(HoverParams params) {
+ Position position = params.getPosition();
+ DocumentId id = smithyFile.document().copyDocumentId(position);
+ if (id == null || id.idSlice().isEmpty()) {
+ return EMPTY;
+ }
+
+ Syntax.IdlParseResult parseResult = smithyFile.getParse();
+ int documentIndex = smithyFile.document().indexOfPosition(position);
+ IdlPosition idlPosition = StatementView.createAt(parseResult, documentIndex)
+ .map(IdlPosition::of)
+ .orElse(null);
+
+ return switch (idlPosition) {
+ case IdlPosition.ControlKey ignored -> Builtins.CONTROL.getMember(id.copyIdValueForElidedMember())
+ .map(HoverHandler::withShapeDocs)
+ .orElse(EMPTY);
+
+ case IdlPosition.MetadataKey ignored -> Builtins.METADATA.getMember(id.copyIdValue())
+ .map(HoverHandler::withShapeDocs)
+ .orElse(EMPTY);
+
+ case IdlPosition.MetadataValue metadataValue -> takeShapeReference(
+ ShapeSearch.searchMetadataValue(metadataValue))
+ .map(HoverHandler::withShapeDocs)
+ .orElse(EMPTY);
+
+ case null -> EMPTY;
+
+ default -> modelSensitiveHover(id, idlPosition);
+ };
+ }
+
+ private static Optional 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, id, model)
+ .flatMap(shape -> shape.getMember(id.copyIdValueForElidedMember()));
+
+ default -> ShapeSearch.findShapeDefinition(idlPosition, id, model);
+ };
+
+ if (matchingShape.isEmpty()) {
+ return EMPTY;
+ }
+
+ return withShapeAndValidationEvents(matchingShape.get(), model, validatedModel.getValidationEvents());
+ }
+
+ private Hover withShapeAndValidationEvents(Shape shape, Model model, List events) {
+ String serializedShape = switch (shape) {
+ case MemberShape memberShape -> serializeMember(memberShape);
+ default -> serializeShape(model, shape);
+ };
+
+ if (serializedShape == null) {
+ return EMPTY;
+ }
+
+ String serializedValidationEvents = serializeValidationEvents(events, shape);
+
+ String hoverContent = String.format("""
+ %s
+ ```smithy
+ %s
+ ```
+ """, serializedValidationEvents, serializedShape);
+
+ // TODO: Add docs to a separate section of the hover content
+ // if (shapeToSerialize.hasTrait(DocumentationTrait.class)) {
+ // String docs = shapeToSerialize.expectTrait(DocumentationTrait.class).getValue();
+ // hoverContent.append("\n---\n").append(docs);
+ // }
+
+ return withMarkupContents(hoverContent);
+ }
+
+ private String serializeValidationEvents(List events, Shape shape) {
+ StringBuilder serialized = new StringBuilder();
+ List applicableEvents = events.stream()
+ .filter(event -> event.getShapeId().isPresent())
+ .filter(event -> event.getShapeId().get().equals(shape.getId()))
+ .filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0)
+ .toList();
+
+ if (!applicableEvents.isEmpty()) {
+ for (ValidationEvent event : applicableEvents) {
+ serialized.append("**")
+ .append(event.getSeverity())
+ .append("**")
+ .append(": ")
+ .append(event.getMessage());
+ }
+ serialized.append(System.lineSeparator())
+ .append(System.lineSeparator())
+ .append("---")
+ .append(System.lineSeparator())
+ .append(System.lineSeparator());
+ }
+
+ return serialized.toString();
+ }
+
+ private static Hover withShapeDocs(Shape shape) {
+ return shape.getTrait(DocumentationTrait.class)
+ .map(StringTrait::getValue)
+ .map(HoverHandler::withMarkupContents)
+ .orElse(EMPTY);
+ }
+
+ private static Hover withMarkupContents(String text) {
+ return new Hover(new MarkupContent("markdown", text));
+ }
+
+ private static String serializeMember(MemberShape memberShape) {
+ StringBuilder contents = new StringBuilder();
+ contents.append("namespace")
+ .append(" ")
+ .append(memberShape.getId().getNamespace())
+ .append(System.lineSeparator())
+ .append(System.lineSeparator());
+
+ for (var trait : memberShape.getAllTraits().values()) {
+ if (trait.toShapeId().equals(DocumentationTrait.ID)) {
+ continue;
+ }
+
+ contents.append("@")
+ .append(trait.toShapeId().getName())
+ .append("(")
+ .append(Node.printJson(trait.toNode()))
+ .append(")")
+ .append(System.lineSeparator());
+ }
+
+ contents.append(memberShape.getMemberName())
+ .append(": ")
+ .append(memberShape.getTarget().getName())
+ .append(System.lineSeparator());
+ return contents.toString();
+ }
+
+ private static String serializeShape(Model model, Shape shape) {
+ SmithyIdlModelSerializer serializer = SmithyIdlModelSerializer.builder()
+ .metadataFilter(key -> false)
+ .shapeFilter(s -> s.getId().equals(shape.getId()))
+ // TODO: If we remove the documentation trait in the serializer,
+ // it also gets removed from members. This causes weird behavior if
+ // there are applied traits (such as through mixins), where you get
+ // an empty apply because the documentation trait was removed
+ // .traitFilter(trait -> !trait.toShapeId().equals(DocumentationTrait.ID))
+ .serializePrelude()
+ .build();
+ Map serialized = serializer.serialize(model);
+ Path path = Paths.get(shape.getId().getNamespace() + ".smithy");
+ if (!serialized.containsKey(path)) {
+ return null;
+ }
+
+ String serializedShape = serialized.get(path)
+ .substring(15) // remove '$version: "2.0"'
+ .trim()
+ .replaceAll(Matcher.quoteReplacement(
+ // Replace newline literals with actual newlines
+ System.lineSeparator() + System.lineSeparator()), System.lineSeparator());
+ return serializedShape;
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java
new file mode 100644
index 00000000..a13a4697
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import software.amazon.smithy.lsp.syntax.StatementView;
+import software.amazon.smithy.lsp.syntax.Syntax;
+
+/**
+ * Represents different kinds of positions within an IDL file.
+ */
+sealed interface IdlPosition {
+ /**
+ * @return Whether the token at this position is definitely a reference
+ * to a root/top-level shape.
+ */
+ default boolean isRootShapeReference() {
+ return switch (this) {
+ case TraitId ignored -> true;
+ case MemberTarget ignored -> true;
+ case ShapeDef ignored -> true;
+ case ForResource ignored -> true;
+ case Mixin ignored -> true;
+ case UseTarget ignored -> true;
+ case ApplyTarget ignored -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * @return The view this position is within.
+ */
+ StatementView view();
+
+ record TraitId(StatementView view) implements IdlPosition {}
+
+ record MemberTarget(StatementView view) implements IdlPosition {}
+
+ record ShapeDef(StatementView view) implements IdlPosition {}
+
+ record Mixin(StatementView view) implements IdlPosition {}
+
+ record ApplyTarget(StatementView view) implements IdlPosition {}
+
+ record UseTarget(StatementView view) implements IdlPosition {}
+
+ record Namespace(StatementView view) implements IdlPosition {}
+
+ record TraitValue(StatementView view, Syntax.Statement.TraitApplication application) implements IdlPosition {}
+
+ record NodeMemberTarget(StatementView view, Syntax.Statement.NodeMemberDef nodeMember) implements IdlPosition {}
+
+ record ControlKey(StatementView view) implements IdlPosition {}
+
+ record MetadataKey(StatementView view) implements IdlPosition {}
+
+ record MetadataValue(StatementView view, Syntax.Statement.Metadata metadata) implements IdlPosition {}
+
+ record StatementKeyword(StatementView view) implements IdlPosition {}
+
+ record MemberName(StatementView view, String name) implements IdlPosition {}
+
+ record ElidedMember(StatementView view) implements IdlPosition {}
+
+ record ForResource(StatementView view) implements IdlPosition {}
+
+ record Unknown(StatementView view) implements IdlPosition {}
+
+ static IdlPosition of(StatementView view) {
+ int documentIndex = view.documentIndex();
+ return switch (view.getStatement()) {
+ case Syntax.Statement.Incomplete incomplete
+ when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view);
+
+ case Syntax.Statement.ShapeDef shapeDef
+ when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(view);
+
+ case Syntax.Statement.Apply apply
+ when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(view);
+
+ case Syntax.Statement.Metadata m
+ when m.key().isIn(documentIndex) -> new IdlPosition.MetadataKey(view);
+
+ case Syntax.Statement.Metadata m
+ when m.value() != null && m.value().isIn(documentIndex) -> new IdlPosition.MetadataValue(view, m);
+
+ case Syntax.Statement.Control c
+ when c.key().isIn(documentIndex) -> new IdlPosition.ControlKey(view);
+
+ case Syntax.Statement.TraitApplication t
+ when t.id().isEmpty() || t.id().isIn(documentIndex) -> new IdlPosition.TraitId(view);
+
+ case Syntax.Statement.Use u
+ when u.use().isIn(documentIndex) -> new IdlPosition.UseTarget(view);
+
+ case Syntax.Statement.MemberDef m
+ when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(view);
+
+ case Syntax.Statement.MemberDef m
+ when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(view, m.name().stringValue());
+
+ case Syntax.Statement.NodeMemberDef m
+ when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget(view, m);
+
+ case Syntax.Statement.Namespace n
+ when n.namespace().isIn(documentIndex) -> new IdlPosition.Namespace(view);
+
+ case Syntax.Statement.TraitApplication t
+ when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue(view, t);
+
+ case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember(view);
+
+ case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(view);
+
+ case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(view);
+
+ case Syntax.Statement.NodeMemberDef m -> new IdlPosition.MemberName(view, m.name().stringValue());
+
+ case Syntax.Statement.Block ignored -> new IdlPosition.MemberName(view, "");
+
+ case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(view);
+
+ default -> new IdlPosition.Unknown(view);
+ };
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java
new file mode 100644
index 00000000..a81de9f4
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.ListShape;
+import software.amazon.smithy.model.shapes.MapShape;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * Searches models along the path of {@link NodeCursor}s, with support for
+ * dynamically computing member targets via {@link DynamicMemberTarget}.
+ */
+final class NodeSearch {
+ private NodeSearch() {
+ }
+
+ /**
+ * @param cursor The cursor to search along.
+ * @param model The model to search within.
+ * @param startingShape The shape to start the search at.
+ * @return The search result.
+ */
+ static Result search(NodeCursor cursor, Model model, Shape startingShape) {
+ return new DefaultSearch(model).search(cursor, startingShape);
+ }
+
+ /**
+ * @param cursor The cursor to search along.
+ * @param model The model to search within.
+ * @param startingShape The shape to start the search at.
+ * @param dynamicMemberTargets A map of member shape id to dynamic member
+ * targets to use for the search.
+ * @return The search result.
+ */
+ static Result search(
+ NodeCursor cursor,
+ Model model,
+ Shape startingShape,
+ Map dynamicMemberTargets
+ ) {
+ if (dynamicMemberTargets == null || dynamicMemberTargets.isEmpty()) {
+ return search(cursor, model, startingShape);
+ }
+
+ return new SearchWithDynamicMemberTargets(model, dynamicMemberTargets).search(cursor, startingShape);
+ }
+
+ /**
+ * The different types of results of a search. The result will be {@link None}
+ * if at any point the cursor doesn't line up with the model (i.e. if the
+ * cursor was an array edge, but in the model we were at a structure shape).
+ *
+ * @apiNote Each result type, besides {@link None}, also includes the model,
+ * because it may be necessary to interpret the results (i.e. if you need
+ * member targets). This is done so that other APIs can wrap {@link NodeSearch}
+ * and callers don't have to know about which model was used in the search
+ * under the hood, or to allow switching the model if necessary during a search.
+ */
+ sealed interface Result {
+ None NONE = new None();
+
+ /**
+ * @return The string values of other keys in {@link ObjectKey} and {@link ObjectShape},
+ * or an empty set.
+ */
+ default Set getOtherPresentKeys() {
+ Syntax.Node.Kvps terminalContainer;
+ NodeCursor.Key terminalKey;
+ switch (this) {
+ case NodeSearch.Result.ObjectShape obj -> {
+ terminalContainer = obj.node();
+ terminalKey = null;
+ }
+ case NodeSearch.Result.ObjectKey key -> {
+ terminalContainer = key.key().parent();
+ terminalKey = key.key();
+ }
+ default -> {
+ return Set.of();
+ }
+ }
+
+ Set otherPresentKeys = new HashSet<>();
+ for (var kvp : terminalContainer.kvps()) {
+ otherPresentKeys.add(kvp.key().stringValue());
+ }
+
+ if (terminalKey != null) {
+ otherPresentKeys.remove(terminalKey.name());
+ }
+
+ return otherPresentKeys;
+ }
+
+ /**
+ * No result - the path is invalid in the model.
+ */
+ record None() implements Result {}
+
+ /**
+ * The path ended on a shape.
+ *
+ * @param shape The shape at the end of the path.
+ * @param model The model {@code shape} is within.
+ */
+ record TerminalShape(Shape shape, Model model) implements Result {}
+
+ /**
+ * The path ended on a key or member name of an object-like shape.
+ *
+ * @param key The key node the path ended at.
+ * @param containerShape The shape containing the key.
+ * @param model The model {@code containerShape} is within.
+ */
+ record ObjectKey(NodeCursor.Key key, Shape containerShape, Model model) implements Result {}
+
+ /**
+ * The path ended on an object-like shape.
+ *
+ * @param node The node the path ended at.
+ * @param shape The shape at the end of the path.
+ * @param model The model {@code shape} is within.
+ */
+ record ObjectShape(Syntax.Node.Kvps node, Shape shape, Model model) implements Result {}
+
+ /**
+ * The path ended on an array-like shape.
+ *
+ * @param node The node the path ended at.
+ * @param shape The shape at the end of the path.
+ * @param model The model {@code shape} is within.
+ */
+ record ArrayShape(Syntax.Node.Arr node, ListShape shape, Model model) implements Result {}
+ }
+
+ private static sealed class DefaultSearch {
+ protected final Model model;
+
+ private DefaultSearch(Model model) {
+ this.model = model;
+ }
+
+ Result search(NodeCursor cursor, Shape shape) {
+ if (!cursor.hasNext() || shape == null) {
+ return Result.NONE;
+ }
+
+ NodeCursor.Edge edge = cursor.next();
+ return switch (edge) {
+ case NodeCursor.Obj obj
+ when ShapeSearch.isObjectShape(shape) -> searchObj(cursor, obj, shape);
+
+ case NodeCursor.Arr arr
+ when shape instanceof ListShape list -> searchArr(cursor, arr, list);
+
+ case NodeCursor.Terminal ignored -> new Result.TerminalShape(shape, model);
+
+ default -> Result.NONE;
+ };
+ }
+
+ private Result searchObj(NodeCursor cursor, NodeCursor.Obj obj, Shape shape) {
+ if (!cursor.hasNext()) {
+ return new Result.ObjectShape(obj.node(), shape, model);
+ }
+
+ return switch (cursor.next()) {
+ case NodeCursor.Terminal ignored -> new Result.ObjectShape(obj.node(), shape, model);
+
+ case NodeCursor.Key key -> new Result.ObjectKey(key, shape, model);
+
+ case NodeCursor.ValueForKey ignored
+ when shape instanceof MapShape map -> searchTarget(cursor, map.getValue());
+
+ case NodeCursor.ValueForKey value -> shape.getMember(value.keyName())
+ .map(member -> searchTarget(cursor, member))
+ .orElse(Result.NONE);
+
+ default -> Result.NONE;
+ };
+ }
+
+ private Result searchArr(NodeCursor cursor, NodeCursor.Arr arr, ListShape shape) {
+ if (!cursor.hasNext()) {
+ return new Result.ArrayShape(arr.node(), shape, model);
+ }
+
+ return switch (cursor.next()) {
+ case NodeCursor.Terminal ignored -> new Result.ArrayShape(arr.node(), shape, model);
+
+ case NodeCursor.Elem ignored -> searchTarget(cursor, shape.getMember());
+
+ default -> Result.NONE;
+ };
+ }
+
+ protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) {
+ return search(cursor, model.getShape(memberShape.getTarget()).orElse(null));
+ }
+ }
+
+ private static final class SearchWithDynamicMemberTargets extends DefaultSearch {
+ private final Map dynamicMemberTargets;
+
+ private SearchWithDynamicMemberTargets(
+ Model model,
+ Map dynamicMemberTargets
+ ) {
+ super(model);
+ this.dynamicMemberTargets = dynamicMemberTargets;
+ }
+
+ @Override
+ protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) {
+ DynamicMemberTarget dynamicMemberTarget = dynamicMemberTargets.get(memberShape.getId());
+ if (dynamicMemberTarget != null) {
+ cursor.setCheckpoint();
+ Shape target = dynamicMemberTarget.getTarget(cursor, model);
+ cursor.returnToCheckpoint();
+ if (target != null) {
+ return search(cursor, target);
+ }
+ }
+
+ return super.searchTarget(cursor, memberShape);
+ }
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java
new file mode 100644
index 00000000..7c5cc339
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompleter.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionItemLabelDetails;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import software.amazon.smithy.lsp.document.DocumentNamespace;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.loader.Prelude;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.shapes.ShapeVisitor;
+import software.amazon.smithy.model.shapes.StructureShape;
+import software.amazon.smithy.model.traits.ErrorTrait;
+import software.amazon.smithy.model.traits.MixinTrait;
+import software.amazon.smithy.model.traits.PrivateTrait;
+import software.amazon.smithy.model.traits.RequiredTrait;
+import software.amazon.smithy.model.traits.TraitDefinition;
+
+/**
+ * Maps {@link CompletionCandidates.Shapes} to {@link CompletionItem}s.
+ *
+ * @param idlPosition The position of the cursor in the IDL file.
+ * @param model The model to get shape completions from.
+ * @param context The context for creating completions.
+ */
+record ShapeCompleter(IdlPosition idlPosition, Model model, CompleterContext context) {
+ List getCompletionItems(CompletionCandidates.Shapes candidates) {
+ AddItems addItems;
+ if (idlPosition instanceof IdlPosition.TraitId) {
+ addItems = new AddDeepTraitBodyItem(model);
+ } else {
+ addItems = AddItems.NOOP;
+ }
+
+ ToLabel toLabel;
+ ModifyItems modifyItems;
+ boolean shouldMatchFullId = idlPosition instanceof IdlPosition.UseTarget
+ || context.matchToken().contains("#")
+ || context.matchToken().contains(".");
+ if (shouldMatchFullId) {
+ toLabel = (shape) -> shape.getId().toString();
+ modifyItems = ModifyItems.NOOP;
+ } else {
+ toLabel = (shape) -> shape.getId().getName();
+ modifyItems = new AddImportTextEdits(idlPosition.view().parseResult());
+ }
+
+ Matcher matcher = new Matcher(context.matchToken(), toLabel, idlPosition.view().parseResult().namespace());
+ Mapper mapper = new Mapper(context.insertRange(), toLabel, addItems, modifyItems);
+ return streamCandidates(candidates)
+ .filter(matcher::test)
+ .mapMulti(mapper::accept)
+ .toList();
+ }
+
+ private Stream extends Shape> streamCandidates(CompletionCandidates.Shapes candidates) {
+ return switch (candidates) {
+ case ANY_SHAPE -> model.shapes();
+ case STRING_SHAPES -> model.getStringShapes().stream();
+ case RESOURCE_SHAPES -> model.getResourceShapes().stream();
+ case OPERATION_SHAPES -> model.getOperationShapes().stream();
+ case ERROR_SHAPES -> model.getShapesWithTrait(ErrorTrait.class).stream();
+ case TRAITS -> model.getShapesWithTrait(TraitDefinition.class).stream();
+ case MIXINS -> model.getShapesWithTrait(MixinTrait.class).stream();
+ case MEMBER_TARGETABLE -> model.shapes()
+ .filter(shape -> !shape.isMemberShape()
+ && !shape.hasTrait(TraitDefinition.ID)
+ && !shape.hasTrait(MixinTrait.ID));
+ case USE_TARGET -> model.shapes().filter(this::shouldImport);
+ };
+ }
+
+ private boolean shouldImport(Shape shape) {
+ return !shape.isMemberShape()
+ && !shape.getId().getNamespace().equals(idlPosition.view().parseResult().namespace().namespace())
+ && !idlPosition.view().parseResult().imports().imports().contains(shape.getId().toString())
+ && !shape.hasTrait(PrivateTrait.ID);
+ }
+
+ /**
+ * Filters shape candidates based on whether they are accessible and match
+ * the match token.
+ *
+ * @param matchToken The token to match shapes against, i.e. the token
+ * being typed.
+ * @param toLabel The way to get the label to match against from a shape.
+ * @param namespace The namespace of the current Smithy file.
+ */
+ private record Matcher(String matchToken, ToLabel toLabel, DocumentNamespace namespace) {
+ boolean test(Shape shape) {
+ return toLabel.toLabel(shape).toLowerCase().startsWith(matchToken)
+ && (shape.getId().getNamespace().equals(namespace.namespace()) || !shape.hasTrait(PrivateTrait.ID));
+ }
+ }
+
+ /**
+ * Maps matching shape candidates to {@link CompletionItem}.
+ *
+ * @param insertRange Range the completion text will be inserted into.
+ * @param toLabel The way to get the label to show in the completion item.
+ * @param addItems Adds extra completion items for a shape.
+ * @param modifyItems Modifies created completion items for a shape.
+ */
+ private record Mapper(Range insertRange, ToLabel toLabel, AddItems addItems, ModifyItems modifyItems) {
+ void accept(Shape shape, Consumer completionItemConsumer) {
+ String shapeLabel = toLabel.toLabel(shape);
+ CompletionItem defaultItem = shapeCompletion(shapeLabel, shape);
+ completionItemConsumer.accept(defaultItem);
+ addItems.add(this, shapeLabel, shape, completionItemConsumer);
+ }
+
+ private CompletionItem shapeCompletion(String shapeLabel, Shape shape) {
+ var completionItem = new CompletionItem(shapeLabel);
+ completionItem.setKind(CompletionItemKind.Class);
+ completionItem.setDetail(shape.getType().toString());
+
+ var labelDetails = new CompletionItemLabelDetails();
+ labelDetails.setDetail(shape.getId().getNamespace());
+ completionItem.setLabelDetails(labelDetails);
+
+ TextEdit edit = new TextEdit(insertRange, shapeLabel);
+ completionItem.setTextEdit(Either.forLeft(edit));
+
+ modifyItems.modify(this, shapeLabel, shape, completionItem);
+ return completionItem;
+ }
+ }
+
+ /**
+ * Strategy to get the completion label from {@link Shape}s used for
+ * matching and constructing the completion item.
+ */
+ private interface ToLabel {
+ String toLabel(Shape shape);
+ }
+
+ /**
+ * A customization point for adding extra completions items for a given
+ * shape.
+ */
+ private interface AddItems {
+ AddItems NOOP = new AddItems() {
+ };
+
+ default void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) {
+ }
+ }
+
+ /**
+ * Adds a completion item that fills out required member names.
+ *
+ * TODO: Need to check what happens for recursive traits. The model won't
+ * be valid, but it may still be loaded and could blow this up.
+ */
+ private static final class AddDeepTraitBodyItem extends ShapeVisitor.Default implements AddItems {
+ private final Model model;
+
+ AddDeepTraitBodyItem(Model model) {
+ this.model = model;
+ }
+
+ @Override
+ public void add(Mapper mapper, String shapeLabel, Shape shape, Consumer consumer) {
+ String traitBody = shape.accept(this);
+ // Strip outside pair of brackets from any structure traits.
+ if (!traitBody.isEmpty() && traitBody.charAt(0) == '{') {
+ traitBody = traitBody.substring(1, traitBody.length() - 1);
+ }
+
+ if (!traitBody.isEmpty()) {
+ String label = String.format("%s(%s)", shapeLabel, traitBody);
+ var traitWithMembersItem = mapper.shapeCompletion(label, shape);
+ consumer.accept(traitWithMembersItem);
+ }
+ }
+
+ @Override
+ protected String getDefault(Shape shape) {
+ return CompletionCandidates.defaultCandidates(shape).value();
+ }
+
+ @Override
+ public String structureShape(StructureShape shape) {
+ List entries = new ArrayList<>();
+ for (MemberShape memberShape : shape.members()) {
+ if (memberShape.hasTrait(RequiredTrait.class)) {
+ entries.add(memberShape.getMemberName() + ": " + memberShape.accept(this));
+ }
+ }
+ return "{" + String.join(", ", entries) + "}";
+ }
+
+ @Override
+ public String memberShape(MemberShape shape) {
+ return model.getShape(shape.getTarget())
+ .map(target -> target.accept(this))
+ .orElse("");
+ }
+ }
+
+ /**
+ * A customization point for modifying created completion items, adding
+ * context, additional text edits, etc.
+ */
+ private interface ModifyItems {
+ ModifyItems NOOP = new ModifyItems() {
+ };
+
+ default void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) {
+ }
+ }
+
+ /**
+ * Adds text edits for use statements for shapes that need to be imported.
+ *
+ * @param syntaxInfo Syntax info of the current Smithy file.
+ */
+ private record AddImportTextEdits(Syntax.IdlParseResult syntaxInfo) implements ModifyItems {
+ @Override
+ public void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) {
+ if (inScope(shape.getId())) {
+ return;
+ }
+
+ // We can only know where to put the import if there's already use statements, or a namespace
+ if (!syntaxInfo.imports().imports().isEmpty()) {
+ addEdit(completionItem, syntaxInfo.imports().importsRange(), shape);
+ } else if (!syntaxInfo.namespace().namespace().isEmpty()) {
+ addEdit(completionItem, syntaxInfo.namespace().statementRange(), shape);
+ }
+ }
+
+ private boolean inScope(ShapeId shapeId) {
+ return Prelude.isPublicPreludeShape(shapeId)
+ || shapeId.getNamespace().equals(syntaxInfo.namespace().namespace())
+ || syntaxInfo.imports().imports().contains(shapeId.toString());
+ }
+
+ private void addEdit(CompletionItem completionItem, Range range, Shape shape) {
+ Range editRange = LspAdapter.point(range.getEnd());
+ String insertText = System.lineSeparator() + "use " + shape.getId().toString();
+ TextEdit importEdit = new TextEdit(editRange, insertText);
+ completionItem.setAdditionalTextEdits(List.of(importEdit));
+ }
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java
new file mode 100644
index 00000000..d86e9be9
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java
@@ -0,0 +1,313 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.StatementView;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.loader.Prelude;
+import software.amazon.smithy.model.shapes.ResourceShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.shapes.ShapeIdSyntaxException;
+import software.amazon.smithy.model.traits.IdRefTrait;
+
+/**
+ * Provides methods to search for shapes, using context and syntax specific
+ * information, like the current {@link SmithyFile} or {@link IdlPosition}.
+ */
+final class ShapeSearch {
+ private ShapeSearch() {
+ }
+
+ /**
+ * Attempts to find a shape using a token, {@code nameOrId}.
+ *
+ * When {@code nameOrId} does not contain a '#', this searches for shapes
+ * either in {@code idlParse}'s namespace, in {@code idlParse}'s
+ * imports, or the prelude, in that order. When {@code nameOrId} does contain
+ * a '#', it is assumed to be a full shape id and is searched for directly.
+ *
+ * @param parseResult The parse result of the file {@code nameOrId} is within.
+ * @param nameOrId The name or shape id of the shape to find.
+ * @param model The model to search.
+ * @return The shape, if found.
+ */
+ static Optional findShape(Syntax.IdlParseResult parseResult, String nameOrId, Model model) {
+ return switch (nameOrId) {
+ case String s when s.isEmpty() -> Optional.empty();
+ case String s when s.contains("#") -> tryFrom(s).flatMap(model::getShape);
+ case String s -> {
+ Optional fromCurrent = tryFromParts(parseResult.namespace().namespace(), s)
+ .flatMap(model::getShape);
+ if (fromCurrent.isPresent()) {
+ yield fromCurrent;
+ }
+
+ for (String fileImport : parseResult.imports().imports()) {
+ Optional imported = tryFrom(fileImport)
+ .filter(importId -> importId.getName().equals(s))
+ .flatMap(model::getShape);
+ if (imported.isPresent()) {
+ yield imported;
+ }
+ }
+
+ yield tryFromParts(Prelude.NAMESPACE, s).flatMap(model::getShape);
+ }
+ case null -> Optional.empty();
+ };
+ }
+
+ private static Optional tryFrom(String id) {
+ try {
+ return Optional.of(ShapeId.from(id));
+ } catch (ShapeIdSyntaxException ignored) {
+ return Optional.empty();
+ }
+ }
+
+ private static Optional tryFromParts(String namespace, String name) {
+ try {
+ return Optional.of(ShapeId.fromRelative(namespace, name));
+ } catch (ShapeIdSyntaxException ignored) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Attempts to find the shape referenced by {@code id} at {@code idlPosition} in {@code model}.
+ *
+ * @param idlPosition The position of the potential shape reference.
+ * @param id The identifier at {@code idlPosition}.
+ * @param model The model to search for shapes in.
+ * @return The shape, if found.
+ */
+ static Optional extends Shape> findShapeDefinition(IdlPosition idlPosition, DocumentId id, Model model) {
+ return switch (idlPosition) {
+ case IdlPosition.TraitValue traitValue -> {
+ var result = searchTraitValue(traitValue, model);
+ if (result instanceof NodeSearch.Result.TerminalShape(var s, var m) && s.hasTrait(IdRefTrait.class)) {
+ yield findShape(idlPosition.view().parseResult(), id.copyIdValue(), m);
+ } else if (result instanceof NodeSearch.Result.ObjectKey(var key, var container, var m)
+ && !container.isMapShape()) {
+ yield container.getMember(key.name());
+ }
+ yield Optional.empty();
+ }
+
+ case IdlPosition.NodeMemberTarget nodeMemberTarget -> {
+ var result = searchNodeMemberTarget(nodeMemberTarget);
+ if (result instanceof NodeSearch.Result.TerminalShape(Shape shape, var ignored)
+ && shape.hasTrait(IdRefTrait.class)) {
+ yield findShape(nodeMemberTarget.view().parseResult(), id.copyIdValue(), model);
+ }
+ yield Optional.empty();
+ }
+
+ // Note: This could be made more specific, at least for mixins
+ case IdlPosition.ElidedMember elidedMember ->
+ findElidedMemberParent(elidedMember, id, model);
+
+ case IdlPosition.MemberName memberName -> {
+ var parentDef = memberName.view().nearestShapeDefBefore();
+ if (parentDef == null) {
+ yield Optional.empty();
+ }
+ var relativeId = parentDef.shapeName().stringValue() + "$" + memberName.name();
+ yield findShape(memberName.view().parseResult(), relativeId, model);
+ }
+
+ case IdlPosition pos when pos.isRootShapeReference() ->
+ findShape(pos.view().parseResult(), id.copyIdValue(), model);
+
+ default -> Optional.empty();
+ };
+ }
+
+ /**
+ * @param forResource The nullable for-resource statement.
+ * @param view A statement view containing the for-resource statement.
+ * @param model The model to search in.
+ * @return A resource shape matching the given for-resource statement, if found.
+ */
+ static Optional findResource(
+ Syntax.Statement.ForResource forResource,
+ StatementView view,
+ Model model
+ ) {
+ if (forResource != null) {
+ String resourceNameOrId = forResource.resource().stringValue();
+ return findShape(view.parseResult(), resourceNameOrId, model)
+ .flatMap(Shape::asResourceShape);
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * @param mixins The nullable mixins statement.
+ * @param view The statement view containing the mixins statement.
+ * @param model The model to search in.
+ * @return A list of the mixin shapes matching those in the mixin statement.
+ */
+ static List findMixins(Syntax.Statement.Mixins mixins, StatementView view, Model model) {
+ if (mixins != null) {
+ List mixinShapes = new ArrayList<>(mixins.mixins().size());
+ for (Syntax.Ident ident : mixins.mixins()) {
+ String mixinNameOrId = ident.stringValue();
+ findShape(view.parseResult(), mixinNameOrId, model).ifPresent(mixinShapes::add);
+ }
+ return mixinShapes;
+ }
+ return List.of();
+ }
+
+ /**
+ * @param elidedMember The elided member position
+ * @param id The identifier of the elided member
+ * @param model The model to search in
+ * @return The shape the elided member comes from, if found.
+ */
+ static Optional extends Shape> findElidedMemberParent(
+ IdlPosition.ElidedMember elidedMember,
+ DocumentId id,
+ Model model
+ ) {
+ var view = elidedMember.view();
+ var forResourceAndMixins = view.nearestForResourceAndMixinsBefore();
+
+ String searchToken = id.copyIdValueForElidedMember();
+
+ // TODO: Handle ambiguity
+ Optional foundResource = findResource(forResourceAndMixins.forResource(), view, model)
+ .filter(shape -> shape.getIdentifiers().containsKey(searchToken)
+ || shape.getProperties().containsKey(searchToken));
+ if (foundResource.isPresent()) {
+ return foundResource;
+ }
+
+ return findMixins(forResourceAndMixins.mixins(), view, model)
+ .stream()
+ .filter(shape -> shape.getAllMembers().containsKey(searchToken))
+ .findFirst();
+ }
+
+ /**
+ * @param traitValue The trait value position
+ * @param model The model to search in
+ * @return The shape that {@code traitValue} is being applied to, if found.
+ */
+ static Optional findTraitTarget(IdlPosition.TraitValue traitValue, Model model) {
+ Syntax.Statement.ShapeDef shapeDef = traitValue.view().nearestShapeDefAfter();
+
+ if (shapeDef == null) {
+ return Optional.empty();
+ }
+
+ String shapeName = shapeDef.shapeName().stringValue();
+ return findShape(traitValue.view().parseResult(), shapeName, model);
+ }
+
+ /**
+ * @param shape The shape to check
+ * @return Whether {@code shape} is represented as an object in a
+ * {@link software.amazon.smithy.lsp.syntax.Syntax.Node}.
+ */
+ static boolean isObjectShape(Shape shape) {
+ return switch (shape.getType()) {
+ case STRUCTURE, UNION, MAP -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * @param metadataValue The metadata value position
+ * @return The result of searching from the given metadata value within the
+ * {@link Builtins} model.
+ */
+ static NodeSearch.Result searchMetadataValue(IdlPosition.MetadataValue metadataValue) {
+ String metadataKey = metadataValue.metadata().key().stringValue();
+ Shape metadataValueShapeDef = Builtins.getMetadataValue(metadataKey);
+ if (metadataValueShapeDef == null) {
+ return NodeSearch.Result.NONE;
+ }
+
+ NodeCursor cursor = NodeCursor.create(
+ metadataValue.metadata().value(),
+ metadataValue.view().documentIndex());
+ var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey);
+ return NodeSearch.search(cursor, Builtins.MODEL, metadataValueShapeDef, dynamicTargets);
+ }
+
+ /**
+ * @param nodeMemberTarget The node member target position
+ * @return The result of searching from the given node member target value
+ * within the {@link Builtins} model.
+ */
+ static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nodeMemberTarget) {
+ Syntax.Statement.ShapeDef shapeDef = nodeMemberTarget.view().nearestShapeDefBefore();
+
+ if (shapeDef == null) {
+ return NodeSearch.Result.NONE;
+ }
+
+ String shapeType = shapeDef.shapeType().stringValue();
+ String memberName = nodeMemberTarget.nodeMember().name().stringValue();
+ Shape memberShapeDef = Builtins.getMemberTargetForShapeType(shapeType, memberName);
+
+ if (memberShapeDef == null) {
+ return NodeSearch.Result.NONE;
+ }
+
+ // This is a workaround for the case when you just have 'operations: '.
+ // Alternatively, we could add an 'empty' Node value, if this situation comes up
+ // elsewhere.
+ //
+ // TODO: Note that searchTraitValue has to do a similar thing, but parsing
+ // trait values always yields at least an empty Kvps, so it is kind of the same.
+ if (nodeMemberTarget.nodeMember().value() == null) {
+ return new NodeSearch.Result.TerminalShape(memberShapeDef, Builtins.MODEL);
+ }
+
+ NodeCursor cursor = NodeCursor.create(
+ nodeMemberTarget.nodeMember().value(),
+ nodeMemberTarget.view().documentIndex());
+ return NodeSearch.search(cursor, Builtins.MODEL, memberShapeDef);
+ }
+
+ /**
+ * @param traitValue The trait value position
+ * @param model The model to search
+ * @return The result of searching from {@code traitValue} within {@code model}.
+ */
+ static NodeSearch.Result searchTraitValue(IdlPosition.TraitValue traitValue, Model model) {
+ String traitName = traitValue.application().id().stringValue();
+ Optional maybeTraitShape = findShape(traitValue.view().parseResult(), traitName, model);
+ if (maybeTraitShape.isEmpty()) {
+ return NodeSearch.Result.NONE;
+ }
+
+ Shape traitShape = maybeTraitShape.get();
+ NodeCursor cursor = NodeCursor.create(
+ traitValue.application().value(),
+ traitValue.view().documentIndex());
+ if (cursor.isTerminal() && isObjectShape(traitShape)) {
+ // In this case, we've just started to type '@myTrait(foo)', which to the parser looks like 'foo' is just
+ // an identifier. But this would mean you don't get member completions when typing the first trait value
+ // member, so we can modify the node path to make it _look_ like it's actually a key
+ cursor.edges().addFirst(new NodeCursor.Obj(new Syntax.Node.Kvps()));
+ }
+
+ var dynamicTargets = DynamicMemberTarget.forTrait(traitShape, traitValue);
+ return NodeSearch.search(cursor, model, traitShape, dynamicTargets);
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java
new file mode 100644
index 00000000..04150084
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import software.amazon.smithy.lsp.project.IdlFile;
+import software.amazon.smithy.lsp.util.StreamUtils;
+
+/**
+ * Maps simple {@link CompletionCandidates} to {@link CompletionItem}s.
+ *
+ * @param context The context for creating completions.
+ *
+ * @see ShapeCompleter for what maps {@link CompletionCandidates.Shapes}.
+ */
+record SimpleCompleter(CompleterContext context) {
+ List getCompletionItems(CompletionCandidates candidates) {
+ Matcher matcher;
+ if (context.exclude().isEmpty()) {
+ matcher = new DefaultMatcher(context.matchToken());
+ } else {
+ matcher = new ExcludingMatcher(context.matchToken(), context.exclude());
+ }
+
+ Mapper mapper = new Mapper(context().insertRange(), context().literalKind());
+
+ return getCompletionItems(candidates, matcher, mapper);
+ }
+
+ private List getCompletionItems(CompletionCandidates candidates, Matcher matcher, Mapper mapper) {
+ return switch (candidates) {
+ case CompletionCandidates.Constant(var value)
+ when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value));
+
+ case CompletionCandidates.Literals(var literals) -> literals.stream()
+ .filter(matcher::testLiteral)
+ .map(mapper::literal)
+ .toList();
+
+ case CompletionCandidates.Labeled(var labeled) -> labeled.entrySet().stream()
+ .filter(matcher::testLabeled)
+ .map(mapper::labeled)
+ .toList();
+
+ case CompletionCandidates.Members(var members) -> members.entrySet().stream()
+ .filter(matcher::testMember)
+ .map(mapper::member)
+ .toList();
+
+ case CompletionCandidates.ElidedMembers(var memberNames) -> memberNames.stream()
+ .filter(matcher::testElided)
+ .map(mapper::elided)
+ .toList();
+
+ case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher, mapper);
+
+ case CompletionCandidates.And(var one, var two) -> {
+ List oneItems = getCompletionItems(one);
+ List twoItems = getCompletionItems(two);
+ List completionItems = new ArrayList<>(oneItems.size() + twoItems.size());
+ completionItems.addAll(oneItems);
+ completionItems.addAll(twoItems);
+ yield completionItems;
+ }
+
+ default -> List.of();
+ };
+ }
+
+ private CompletionCandidates customCandidates(CompletionCandidates.Custom custom) {
+ return switch (custom) {
+ case NAMESPACE_FILTER -> new CompletionCandidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces())
+ .collect(StreamUtils.toWrappedMap()));
+
+ case VALIDATOR_NAME -> CompletionCandidates.VALIDATOR_NAMES;
+
+ case PROJECT_NAMESPACES -> new CompletionCandidates.Literals(streamNamespaces().toList());
+ };
+ }
+
+ private Stream streamNamespaces() {
+ return context().project().smithyFiles().values().stream()
+ .map(smithyFile -> switch (smithyFile) {
+ case IdlFile idlFile -> idlFile.getParse().namespace().namespace();
+ default -> "";
+ })
+ .filter(namespace -> !namespace.isEmpty());
+ }
+
+ /**
+ * Matches different kinds of completion candidates against the text of
+ * whatever triggered the completion, used to filter out candidates.
+ *
+ * @apiNote LSP has support for client-side matching/filtering, but only when
+ * the completion items don't have text edits. We use text edits to have more
+ * control over the range the completion text will occupy, so we need to do
+ * matching/filtering server-side.
+ *
+ * @see LSP Completion Docs
+ */
+ private sealed interface Matcher {
+ String matchToken();
+
+ default boolean testConstant(String constant) {
+ return test(constant);
+ }
+
+ default boolean testLiteral(String literal) {
+ return test(literal);
+ }
+
+ default boolean testLabeled(Map.Entry labeled) {
+ return test(labeled.getKey()) || test(labeled.getValue());
+ }
+
+ default boolean testMember(Map.Entry member) {
+ return test(member.getKey());
+ }
+
+ default boolean testElided(String memberName) {
+ return test(memberName) || test("$" + memberName);
+ }
+
+ default boolean test(String s) {
+ return s.toLowerCase().startsWith(matchToken());
+ }
+ }
+
+ private record DefaultMatcher(String matchToken) implements Matcher {}
+
+ private record ExcludingMatcher(String matchToken, Set exclude) implements Matcher {
+ @Override
+ public boolean testElided(String memberName) {
+ // Exclusion set doesn't contain member names with leading '$', so we don't
+ // want to delegate to the regular `test` method
+ return !exclude.contains(memberName)
+ && (Matcher.super.test(memberName) || Matcher.super.test("$" + memberName));
+ }
+
+ @Override
+ public boolean test(String s) {
+ return !exclude.contains(s) && Matcher.super.test(s);
+ }
+ }
+
+ /**
+ * Maps different kinds of completion candidates to {@link CompletionItem}s.
+ *
+ * @param insertRange The range the completion text will occupy.
+ * @param literalKind The completion item kind that will be shown in the
+ * client for {@link CompletionCandidates.Literals}.
+ */
+ private record Mapper(Range insertRange, CompletionItemKind literalKind) {
+ CompletionItem constant(String value) {
+ return textEditCompletion(value, CompletionItemKind.Constant);
+ }
+
+ CompletionItem literal(String value) {
+ return textEditCompletion(value, literalKind);
+ }
+
+ CompletionItem labeled(Map.Entry entry) {
+ return textEditCompletion(entry.getKey(), CompletionItemKind.EnumMember, entry.getValue());
+ }
+
+ CompletionItem member(Map.Entry entry) {
+ String value = entry.getKey() + ": " + entry.getValue().value();
+ return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value);
+ }
+
+ CompletionItem elided(String memberName) {
+ return textEditCompletion("$" + memberName, CompletionItemKind.Field);
+ }
+
+ private CompletionItem textEditCompletion(String label, CompletionItemKind kind) {
+ return textEditCompletion(label, kind, label);
+ }
+
+ private CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) {
+ CompletionItem item = new CompletionItem(label);
+ item.setKind(kind);
+ TextEdit textEdit = new TextEdit(insertRange, insertText);
+ item.setTextEdit(Either.forLeft(textEdit));
+ return item;
+ }
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java b/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java
new file mode 100644
index 00000000..f171fbc6
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/project/IdlFile.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.project;
+
+import java.util.concurrent.locks.ReentrantLock;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.lsp.syntax.Syntax;
+
+public final class IdlFile extends SmithyFile {
+ private final ReentrantLock idlParseLock = new ReentrantLock();
+ private Syntax.IdlParseResult parseResult;
+
+ IdlFile(String path, Document document, Syntax.IdlParseResult parseResult) {
+ super(path, document);
+ this.parseResult = parseResult;
+ }
+
+ @Override
+ public void reparse() {
+ Syntax.IdlParseResult parse = Syntax.parseIdl(document());
+
+ idlParseLock.lock();
+ try {
+ this.parseResult = parse;
+ } finally {
+ idlParseLock.unlock();
+ }
+ }
+
+ /**
+ * @return The latest computed {@link Syntax.IdlParseResult} of this Smithy file
+ * @apiNote Don't call this method over and over. {@link Syntax.IdlParseResult} is
+ * immutable so just call this once and use the returned value.
+ */
+ public Syntax.IdlParseResult getParse() {
+ idlParseLock.lock();
+ try {
+ return parseResult;
+ } finally {
+ idlParseLock.unlock();
+ }
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/project/Project.java b/src/main/java/software/amazon/smithy/lsp/project/Project.java
index 1a793200..baae3773 100644
--- a/src/main/java/software/amazon/smithy/lsp/project/Project.java
+++ b/src/main/java/software/amazon/smithy/lsp/project/Project.java
@@ -6,13 +6,11 @@
package software.amazon.smithy.lsp.project;
import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
import java.util.logging.Logger;
@@ -25,6 +23,7 @@
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.shapes.AbstractShapeBuilder;
import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ToShapeId;
import software.amazon.smithy.model.traits.Trait;
import software.amazon.smithy.model.validation.ValidatedResult;
import software.amazon.smithy.utils.IoUtils;
@@ -40,20 +39,30 @@ public final class Project {
private final List dependencies;
private final Map smithyFiles;
private final Supplier assemblerFactory;
+ private final Map> definedShapesByFile;
private ValidatedResult modelResult;
// TODO: Move this into SmithyFileDependenciesIndex
private Map> perFileMetadata;
private SmithyFileDependenciesIndex smithyFileDependenciesIndex;
- private Project(Builder builder) {
- this.root = Objects.requireNonNull(builder.root);
- this.config = builder.config;
- this.dependencies = builder.dependencies;
- this.smithyFiles = builder.smithyFiles;
- this.modelResult = builder.modelResult;
- this.assemblerFactory = builder.assemblerFactory;
- this.perFileMetadata = builder.perFileMetadata;
- this.smithyFileDependenciesIndex = builder.smithyFileDependenciesIndex;
+ Project(Path root,
+ ProjectConfig config,
+ List dependencies,
+ Map smithyFiles,
+ Supplier assemblerFactory,
+ Map> definedShapesByFile,
+ ValidatedResult modelResult,
+ Map> perFileMetadata,
+ SmithyFileDependenciesIndex smithyFileDependenciesIndex) {
+ this.root = root;
+ this.config = config;
+ this.dependencies = dependencies;
+ this.smithyFiles = smithyFiles;
+ this.assemblerFactory = assemblerFactory;
+ this.definedShapesByFile = definedShapesByFile;
+ this.modelResult = modelResult;
+ this.perFileMetadata = perFileMetadata;
+ this.smithyFileDependenciesIndex = smithyFileDependenciesIndex;
}
/**
@@ -63,10 +72,15 @@ private Project(Builder builder) {
* @return The empty project
*/
public static Project empty(Path root) {
- return builder()
- .root(root)
- .modelResult(ValidatedResult.empty())
- .build();
+ return new Project(root,
+ ProjectConfig.empty(),
+ List.of(),
+ new HashMap<>(),
+ Model::assembler,
+ new HashMap<>(),
+ ValidatedResult.empty(),
+ new HashMap<>(),
+ new SmithyFileDependenciesIndex());
}
/**
@@ -119,6 +133,13 @@ public Map smithyFiles() {
return this.smithyFiles;
}
+ /**
+ * @return A map of paths to the set of shape ids defined in the file at that path.
+ */
+ public Map> definedShapesByFile() {
+ return this.definedShapesByFile;
+ }
+
/**
* @return The latest result of loading this project
*/
@@ -224,7 +245,6 @@ public void updateFiles(Set addUris, Set removeUris, Set
// So we don't have to recompute the paths later
Set removedPaths = new HashSet<>(removeUris.size());
- Set changedPaths = new HashSet<>(changeUris.size());
Set visited = new HashSet<>();
@@ -245,7 +265,6 @@ public void updateFiles(Set addUris, Set removeUris, Set
for (String uri : changeUris) {
String path = LspAdapter.toPath(uri);
- changedPaths.add(path);
removeFileForReload(assembler, builder, path, visited);
removeDependentsForReload(assembler, builder, path, visited);
@@ -281,25 +300,19 @@ public void updateFiles(Set addUris, Set removeUris, Set
for (String visitedPath : visited) {
if (!removedPaths.contains(visitedPath)) {
- SmithyFile current = smithyFiles.get(visitedPath);
- Set updatedShapes = getFileShapes(visitedPath, smithyFiles.get(visitedPath).shapes());
- // Only recompute the rest of the smithy file if it changed
- if (changedPaths.contains(visitedPath)) {
- // TODO: Could cache validation events
- this.smithyFiles.put(visitedPath,
- ProjectLoader.buildSmithyFile(visitedPath, current.document(), updatedShapes).build());
- } else {
- current.setShapes(updatedShapes);
- }
+ Set currentShapes = definedShapesByFile.getOrDefault(visitedPath, Set.of());
+ this.definedShapesByFile.put(visitedPath, getFileShapes(visitedPath, currentShapes));
+ } else {
+ this.definedShapesByFile.remove(visitedPath);
}
}
for (String uri : addUris) {
String path = LspAdapter.toPath(uri);
- Set fileShapes = getFileShapes(path, Collections.emptySet());
Document document = Document.of(IoUtils.readUtf8File(path));
- SmithyFile smithyFile = ProjectLoader.buildSmithyFile(path, document, fileShapes).build();
- smithyFiles.put(path, smithyFile);
+ SmithyFile smithyFile = SmithyFile.create(path, document);
+ this.smithyFiles.put(path, smithyFile);
+ this.definedShapesByFile.put(path, getFileShapes(path, Set.of()));
}
}
@@ -324,21 +337,21 @@ private void removeFileForReload(
visited.add(path);
- for (Shape shape : smithyFiles.get(path).shapes()) {
- builder.removeShape(shape.getId());
+ for (ToShapeId toShapeId : definedShapesByFile.getOrDefault(path, Set.of())) {
+ builder.removeShape(toShapeId.toShapeId());
// This shape may have traits applied to it in other files,
// so simply removing the shape loses the information about
// those traits.
// This shape's dependencies files will be removed and re-loaded
- smithyFileDependenciesIndex.getDependenciesFiles(shape).forEach((depPath) ->
+ smithyFileDependenciesIndex.getDependenciesFiles(toShapeId).forEach((depPath) ->
removeFileForReload(assembler, builder, depPath, visited));
// Traits applied in other files are re-added to the assembler so if/when the shape
// is reloaded, it will have those traits
- smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(shape).forEach((trait) ->
- assembler.addTrait(shape.getId(), trait));
+ smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(toShapeId).forEach((trait) ->
+ assembler.addTrait(toShapeId.toShapeId(), trait));
}
}
@@ -350,8 +363,8 @@ private void removeDependentsForReload(
) {
// This file may apply traits to shapes in other files. Normally, letting the assembler simply reparse
// the file would be fine because it would ignore the duplicated trait application coming from the same
- // source location. But if the apply statement is changed/removed, the old application isn't removed, so we
- // could get a duplicate trait, or a merged array trait.
+ // source location. But if the apply statement is changed/removed, the old trait isn't removed, so we
+ // could get a duplicate application, or a merged array application.
smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) ->
removeFileForReload(assembler, builder, depPath, visited));
smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> {
@@ -375,80 +388,11 @@ private void addRemainingMetadataForReload(Model.Builder builder, Set fi
}
}
- private Set getFileShapes(String path, Set orDefault) {
+ private Set getFileShapes(String path, Set orDefault) {
return this.modelResult.getResult()
.map(model -> model.shapes()
.filter(shape -> shape.getSourceLocation().getFilename().equals(path))
- .collect(Collectors.toSet()))
+ .collect(Collectors.toSet()))
.orElse(orDefault);
}
-
- static Builder builder() {
- return new Builder();
- }
-
- static final class Builder {
- private Path root;
- private ProjectConfig config = ProjectConfig.empty();
- private final List dependencies = new ArrayList<>();
- private final Map smithyFiles = new HashMap<>();
- private ValidatedResult modelResult;
- private Supplier assemblerFactory = Model::assembler;
- private Map> perFileMetadata = new HashMap<>();
- private SmithyFileDependenciesIndex smithyFileDependenciesIndex = new SmithyFileDependenciesIndex();
-
- private Builder() {
- }
-
- public Builder root(Path root) {
- this.root = root;
- return this;
- }
-
- public Builder config(ProjectConfig config) {
- this.config = config;
- return this;
- }
-
- public Builder dependencies(List paths) {
- this.dependencies.clear();
- this.dependencies.addAll(paths);
- return this;
- }
-
- public Builder addDependency(Path path) {
- this.dependencies.add(path);
- return this;
- }
-
- public Builder smithyFiles(Map smithyFiles) {
- this.smithyFiles.clear();
- this.smithyFiles.putAll(smithyFiles);
- return this;
- }
-
- public Builder modelResult(ValidatedResult modelResult) {
- this.modelResult = modelResult;
- return this;
- }
-
- public Builder assemblerFactory(Supplier assemblerFactory) {
- this.assemblerFactory = assemblerFactory;
- return this;
- }
-
- public Builder perFileMetadata(Map> perFileMetadata) {
- this.perFileMetadata = perFileMetadata;
- return this;
- }
-
- public Builder smithyFileDependenciesIndex(SmithyFileDependenciesIndex smithyFileDependenciesIndex) {
- this.smithyFileDependenciesIndex = smithyFileDependenciesIndex;
- return this;
- }
-
- public Project build() {
- return new Project(this);
- }
- }
}
diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java
index 0d8b7494..0a79da85 100644
--- a/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java
+++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectAndFile.java
@@ -9,8 +9,10 @@
* Simple wrapper for a project and a file in that project, which many
* server functions act upon.
*
+ * @param uri The uri of the file
* @param project The project, non-nullable
* @param file The file within {@code project}, non-nullable
+ * @param isDetached Whether the project and file represent a detached project
*/
-public record ProjectAndFile(Project project, ProjectFile file) {
+public record ProjectAndFile(String uri, Project project, ProjectFile file, boolean isDetached) {
}
diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java
index 86b8b550..538b5cca 100644
--- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java
+++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java
@@ -24,14 +24,8 @@
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import org.eclipse.lsp4j.Position;
import software.amazon.smithy.lsp.ServerState;
import software.amazon.smithy.lsp.document.Document;
-import software.amazon.smithy.lsp.document.DocumentImports;
-import software.amazon.smithy.lsp.document.DocumentNamespace;
-import software.amazon.smithy.lsp.document.DocumentParser;
-import software.amazon.smithy.lsp.document.DocumentShape;
-import software.amazon.smithy.lsp.document.DocumentVersion;
import software.amazon.smithy.lsp.protocol.LspAdapter;
import software.amazon.smithy.lsp.util.Result;
import software.amazon.smithy.model.Model;
@@ -39,7 +33,7 @@
import software.amazon.smithy.model.loader.ModelDiscovery;
import software.amazon.smithy.model.node.ArrayNode;
import software.amazon.smithy.model.node.Node;
-import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ToShapeId;
import software.amazon.smithy.model.validation.ValidatedResult;
import software.amazon.smithy.utils.IoUtils;
@@ -68,28 +62,29 @@ private ProjectLoader() {
public static Project loadDetached(String uri, String text) {
LOGGER.info("Loading detachedProjects project at " + uri);
String asPath = LspAdapter.toPath(uri);
- ValidatedResult modelResult = Model.assembler()
+ Supplier assemblerFactory;
+ try {
+ assemblerFactory = createModelAssemblerFactory(List.of());
+ } catch (MalformedURLException e) {
+ // Note: This can't happen because we have no dependencies to turn into URLs
+ throw new RuntimeException(e);
+ }
+
+ ValidatedResult modelResult = assemblerFactory.get()
.addUnparsedModel(asPath, text)
.assemble();
Path path = Paths.get(asPath);
List sources = Collections.singletonList(path);
- Project.Builder builder = Project.builder()
- .root(path.getParent())
- .config(ProjectConfig.builder()
- .sources(Collections.singletonList(asPath))
- .build())
- .modelResult(modelResult);
-
- Map smithyFiles = computeSmithyFiles(sources, modelResult, (filePath) -> {
+ var definedShapesByFile = computeDefinedShapesByFile(sources, modelResult);
+ var smithyFiles = createSmithyFiles(definedShapesByFile, (filePath) -> {
// NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but
// the model stores jar paths as URIs
if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) {
return Document.of(IoUtils.readUtf8Url(LspAdapter.jarUrl(filePath)));
} else if (filePath.equals(asPath)) {
- Document document = Document.of(text);
- return document;
+ return Document.of(text);
} else {
// TODO: Make generic 'please file a bug report' exception
throw new IllegalStateException(
@@ -99,9 +94,15 @@ public static Project loadDetached(String uri, String text) {
}
});
- return builder.smithyFiles(smithyFiles)
- .perFileMetadata(computePerFileMetadata(modelResult))
- .build();
+ return new Project(path.getParent(),
+ ProjectConfig.builder().sources(List.of(asPath)).build(),
+ List.of(),
+ smithyFiles,
+ assemblerFactory,
+ definedShapesByFile,
+ modelResult,
+ computePerFileMetadata(modelResult),
+ new SmithyFileDependenciesIndex());
}
/**
@@ -136,30 +137,20 @@ public static Result> load(Path root, ServerState state
// The model assembler factory is used to get assemblers that already have the correct
// dependencies resolved for future loads
- Result, Exception> assemblerFactoryResult = createModelAssemblerFactory(dependencies);
- if (assemblerFactoryResult.isErr()) {
- return Result.err(Collections.singletonList(assemblerFactoryResult.unwrapErr()));
+ Supplier assemblerFactory;
+ try {
+ assemblerFactory = createModelAssemblerFactory(dependencies);
+ } catch (MalformedURLException e) {
+ return Result.err(List.of(e));
}
- Supplier assemblerFactory = assemblerFactoryResult.unwrap();
ModelAssembler assembler = assemblerFactory.get();
// Note: The model assembler can handle loading all smithy files in a directory, so there's some potential
// here for inconsistent behavior.
List allSmithyFilePaths = collectAllSmithyPaths(root, config.sources(), config.imports());
- Result, Exception> loadModelResult = Result.ofFallible(() -> {
- for (Path path : allSmithyFilePaths) {
- Document managed = state.getManagedDocument(path);
- if (managed != null) {
- assembler.addUnparsedModel(path.toString(), managed.copyText());
- } else {
- assembler.addImport(path);
- }
- }
-
- return assembler.assemble();
- });
+ Result, Exception> loadModelResult = loadModel(state, allSmithyFilePaths, assembler);
// TODO: Assembler can fail if a file is not found. We can be more intelligent about
// handling this case to allow partially loading the project, but we will need to
// collect and report the errors somehow. For now, using collectAllSmithyPaths skips
@@ -170,15 +161,8 @@ public static Result> load(Path root, ServerState state
}
ValidatedResult modelResult = loadModelResult.unwrap();
-
- Project.Builder projectBuilder = Project.builder()
- .root(root)
- .config(config)
- .dependencies(dependencies)
- .modelResult(modelResult)
- .assemblerFactory(assemblerFactory);
-
- Map smithyFiles = computeSmithyFiles(allSmithyFilePaths, modelResult, (filePath) -> {
+ var definedShapesByFile = computeDefinedShapesByFile(allSmithyFilePaths, modelResult);
+ var smithyFiles = createSmithyFiles(definedShapesByFile, (filePath) -> {
// NOTE: isSmithyJarFile and isJarFile typically take in a URI (filePath is a path), but
// the model stores jar paths as URIs
if (LspAdapter.isSmithyJarFile(filePath) || LspAdapter.isJarFile(filePath)) {
@@ -196,75 +180,75 @@ public static Result> load(Path root, ServerState state
return Document.of(IoUtils.readUtf8File(filePath));
});
- return Result.ok(projectBuilder.smithyFiles(smithyFiles)
- .perFileMetadata(computePerFileMetadata(modelResult))
- .smithyFileDependenciesIndex(SmithyFileDependenciesIndex.compute(modelResult))
- .build());
+ return Result.ok(new Project(root,
+ config,
+ dependencies,
+ smithyFiles,
+ assemblerFactory,
+ definedShapesByFile,
+ modelResult,
+ computePerFileMetadata(modelResult),
+ SmithyFileDependenciesIndex.compute(modelResult)));
+ }
+
+ private static Result, Exception> loadModel(
+ ServerState state,
+ List models,
+ ModelAssembler assembler
+ ) {
+ try {
+ for (Path path : models) {
+ Document managed = state.getManagedDocument(path);
+ if (managed != null) {
+ assembler.addUnparsedModel(path.toString(), managed.copyText());
+ } else {
+ assembler.addImport(path);
+ }
+ }
+
+ return Result.ok(assembler.assemble());
+ } catch (Exception e) {
+ return Result.err(e);
+ }
}
static Result> load(Path root) {
return load(root, new ServerState());
}
- private static Map computeSmithyFiles(
+ private static Map> computeDefinedShapesByFile(
List allSmithyFilePaths,
- ValidatedResult modelResult,
- Function documentProvider
+ ValidatedResult modelResult
) {
- Map> shapesByFile;
- if (modelResult.getResult().isPresent()) {
- Model model = modelResult.getResult().get();
- shapesByFile = model.shapes().collect(Collectors.groupingByConcurrent(
- shape -> shape.getSourceLocation().getFilename(), Collectors.toSet()));
- } else {
- shapesByFile = new HashMap<>(allSmithyFilePaths.size());
- }
+ Map> definedShapesByFile = modelResult.getResult().map(Model::shapes)
+ .orElseGet(Stream::empty)
+ .collect(Collectors.groupingByConcurrent(
+ shape -> shape.getSourceLocation().getFilename(), Collectors.toSet()));
- // There may be smithy files part of the project that aren't part of the model
+ // There may be smithy files part of the project that aren't part of the model, e.g. empty files
for (Path smithyFilePath : allSmithyFilePaths) {
String pathString = smithyFilePath.toString();
- if (!shapesByFile.containsKey(pathString)) {
- shapesByFile.put(pathString, Collections.emptySet());
- }
+ definedShapesByFile.putIfAbsent(pathString, Set.of());
}
- Map smithyFiles = new HashMap<>(allSmithyFilePaths.size());
- for (Map.Entry> shapesByFileEntry : shapesByFile.entrySet()) {
- String path = shapesByFileEntry.getKey();
+ return definedShapesByFile;
+ }
+
+ private static Map createSmithyFiles(
+ Map> definedShapesByFile,
+ Function documentProvider
+ ) {
+ Map smithyFiles = new HashMap<>(definedShapesByFile.size());
+
+ for (String path : definedShapesByFile.keySet()) {
Document document = documentProvider.apply(path);
- Set fileShapes = shapesByFileEntry.getValue();
- SmithyFile smithyFile = buildSmithyFile(path, document, fileShapes).build();
+ SmithyFile smithyFile = SmithyFile.create(path, document);
smithyFiles.put(path, smithyFile);
}
return smithyFiles;
}
- /**
- * Computes extra information about what is in the Smithy file and where,
- * such as the namespace, imports, version number, and shapes.
- *
- * @param path Path of the Smithy file
- * @param document The document backing the Smithy file
- * @param shapes The shapes defined in the Smithy file
- * @return A builder for the Smithy file
- */
- public static SmithyFile.Builder buildSmithyFile(String path, Document document, Set shapes) {
- DocumentParser documentParser = DocumentParser.forDocument(document);
- DocumentNamespace namespace = documentParser.documentNamespace();
- DocumentImports imports = documentParser.documentImports();
- Map documentShapes = documentParser.documentShapes(shapes);
- DocumentVersion documentVersion = documentParser.documentVersion();
- return SmithyFile.builder()
- .path(path)
- .document(document)
- .shapes(shapes)
- .namespace(namespace)
- .imports(imports)
- .documentShapes(documentShapes)
- .documentVersion(documentVersion);
- }
-
// This is gross, but necessary to deal with the way that array metadata gets merged.
// When we try to reload a single file, we need to make sure we remove the metadata for
// that file. But if there's array metadata, a single key contains merged elements from
@@ -296,40 +280,30 @@ static Map> computePerFileMetadata(ValidatedResult, Exception> createModelAssemblerFactory(List dependencies) {
+ private static Supplier createModelAssemblerFactory(List dependencies)
+ throws MalformedURLException {
// We don't want the model to be broken when there are unknown traits,
// because that will essentially disable language server features, so
// we need to allow unknown traits for each factory.
- // TODO: There's almost certainly a better way to to this
if (dependencies.isEmpty()) {
- return Result.ok(() -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true));
+ return () -> Model.assembler().putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true);
}
- Result result = createDependenciesClassLoader(dependencies);
- if (result.isErr()) {
- return Result.err(result.unwrapErr());
- }
- return Result.ok(() -> {
- URLClassLoader classLoader = result.unwrap();
- return Model.assembler(classLoader)
- .discoverModels(classLoader)
- .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true);
- });
+ URLClassLoader classLoader = createDependenciesClassLoader(dependencies);
+ return () -> Model.assembler(classLoader)
+ .discoverModels(classLoader)
+ .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true);
}
- private static Result createDependenciesClassLoader(List dependencies) {
+ private static URLClassLoader createDependenciesClassLoader(List dependencies) throws MalformedURLException {
// Taken (roughly) from smithy-ci IsolatedRunnable
- try {
- URL[] urls = new URL[dependencies.size()];
- int i = 0;
- for (Path dependency : dependencies) {
- urls[i++] = dependency.toUri().toURL();
- }
- return Result.ok(new URLClassLoader(urls));
- } catch (MalformedURLException e) {
- return Result.err(e);
+ URL[] urls = new URL[dependencies.size()];
+ int i = 0;
+ for (Path dependency : dependencies) {
+ urls[i++] = dependency.toUri().toURL();
}
+ return new URLClassLoader(urls);
}
// sources and imports can contain directories or files, relative or absolute
diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java
index a30cec1e..a3251e11 100644
--- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java
+++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java
@@ -5,198 +5,45 @@
package software.amazon.smithy.lsp.project;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.lsp4j.Position;
import software.amazon.smithy.lsp.document.Document;
-import software.amazon.smithy.lsp.document.DocumentImports;
-import software.amazon.smithy.lsp.document.DocumentNamespace;
-import software.amazon.smithy.lsp.document.DocumentShape;
-import software.amazon.smithy.lsp.document.DocumentVersion;
-import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.lsp.syntax.Syntax;
/**
* The language server's representation of a Smithy file.
- *
- * Note: This currently is only ever a .smithy file, but could represent
- * a .json file in the future.
*/
-public final class SmithyFile implements ProjectFile {
+public sealed class SmithyFile implements ProjectFile permits IdlFile {
private final String path;
private final Document document;
- // TODO: If we have more complex use-cases for partially updating SmithyFile, we
- // could use a toBuilder()
- private Set shapes;
- private final DocumentNamespace namespace;
- private final DocumentImports imports;
- private final Map documentShapes;
- private final DocumentVersion documentVersion;
- private SmithyFile(Builder builder) {
- this.path = builder.path;
- this.document = builder.document;
- this.shapes = builder.shapes;
- this.namespace = builder.namespace;
- this.imports = builder.imports;
- this.documentShapes = builder.documentShapes;
- this.documentVersion = builder.documentVersion;
+ SmithyFile(String path, Document document) {
+ this.path = path;
+ this.document = document;
}
- /**
- * @return The path of this Smithy file
- */
+ static SmithyFile create(String path, Document document) {
+ // TODO: Make a better abstraction for loading an arbitrary project file
+ if (path.endsWith(".smithy")) {
+ Syntax.IdlParseResult parse = Syntax.parseIdl(document);
+ return new IdlFile(path, document, parse);
+ } else {
+ return new SmithyFile(path, document);
+ }
+ }
+
+ @Override
public String path() {
return path;
}
- /**
- * @return The {@link Document} backing this Smithy file
- */
+ @Override
public Document document() {
return document;
}
/**
- * @return The Shapes defined in this Smithy file
- */
- public Set shapes() {
- return shapes;
- }
-
- void setShapes(Set shapes) {
- this.shapes = shapes;
- }
-
- /**
- * @return This Smithy file's imports, if they exist
- */
- public Optional documentImports() {
- return Optional.ofNullable(this.imports);
- }
-
- /**
- * @return The ids of shapes imported into this Smithy file
- */
- public Set imports() {
- return documentImports()
- .map(DocumentImports::imports)
- .orElse(Collections.emptySet());
- }
-
- /**
- * @return This Smithy file's namespace, if one exists
- */
- public Optional documentNamespace() {
- return Optional.ofNullable(namespace);
- }
-
- /**
- * @return The shapes in this Smithy file, including referenced shapes
- */
- public Collection documentShapes() {
- if (documentShapes == null) {
- return Collections.emptyList();
- }
- return documentShapes.values();
- }
-
- /**
- * @return A map of {@link Position} to the {@link DocumentShape} they are
- * the starting position of
- */
- public Map documentShapesByStartPosition() {
- if (documentShapes == null) {
- return Collections.emptyMap();
- }
- return documentShapes;
- }
-
- /**
- * @return The string literal namespace of this Smithy file, or an empty string
- */
- public CharSequence namespace() {
- return documentNamespace()
- .map(DocumentNamespace::namespace)
- .orElse("");
- }
-
- /**
- * @return This Smithy file's version, if it exists
- */
- public Optional documentVersion() {
- return Optional.ofNullable(documentVersion);
- }
-
- /**
- * @param shapeId The shape id to check
- * @return Whether {@code shapeId} is in this SmithyFile's imports
+ * Reparse the underlying {@link #document()}.
*/
- public boolean hasImport(String shapeId) {
- if (imports == null || imports.imports().isEmpty()) {
- return false;
- }
- return imports.imports().contains(shapeId);
- }
-
- /**
- * @return A {@link SmithyFile} builder
- */
- public static Builder builder() {
- return new Builder();
- }
-
- public static final class Builder {
- private String path;
- private Document document;
- private Set shapes;
- private DocumentNamespace namespace;
- private DocumentImports imports;
- private Map documentShapes;
- private DocumentVersion documentVersion;
-
- private Builder() {
- }
-
- public Builder path(String path) {
- this.path = path;
- return this;
- }
-
- public Builder document(Document document) {
- this.document = document;
- return this;
- }
-
- public Builder shapes(Set shapes) {
- this.shapes = shapes;
- return this;
- }
-
- public Builder namespace(DocumentNamespace namespace) {
- this.namespace = namespace;
- return this;
- }
-
- public Builder imports(DocumentImports imports) {
- this.imports = imports;
- return this;
- }
-
- public Builder documentShapes(Map documentShapes) {
- this.documentShapes = documentShapes;
- return this;
- }
-
- public Builder documentVersion(DocumentVersion documentVersion) {
- this.documentVersion = documentVersion;
- return this;
- }
-
- public SmithyFile build() {
- return new SmithyFile(this);
- }
+ public void reparse() {
+ // Don't parse JSON files, at least for now
}
}
diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java
index 59e62ead..1bd9e540 100644
--- a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java
+++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java
@@ -15,6 +15,9 @@
import org.eclipse.lsp4j.Location;
import org.eclipse.lsp4j.Position;
import org.eclipse.lsp4j.Range;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.FromSourceLocation;
import software.amazon.smithy.model.SourceLocation;
/**
@@ -111,6 +114,35 @@ public static Range of(int startLine, int startCharacter, int endLine, int endCh
.build();
}
+ /**
+ * @param ident Identifier to get the range of
+ * @param document Document the identifier is in
+ * @return The range of the identifier in the given document
+ */
+ public static Range identRange(Syntax.Ident ident, Document document) {
+ int line = document.lineOfIndex(ident.start());
+ if (line < 0) {
+ return null;
+ }
+
+ int lineStart = document.indexOfLine(line);
+ if (lineStart < 0) {
+ return null;
+ }
+
+ int startCharacter = ident.start() - lineStart;
+ int endCharacter = ident.end() - lineStart;
+ return LspAdapter.lineSpan(line, startCharacter, endCharacter);
+ }
+
+ /**
+ * @param range The range to check
+ * @return Whether the range's start is equal to it's end
+ */
+ public static boolean isEmpty(Range range) {
+ return range.getStart().equals(range.getEnd());
+ }
+
/**
* Get a {@link Position} from a {@link SourceLocation}, making the line/columns
* 0-indexed.
@@ -126,10 +158,11 @@ public static Position toPosition(SourceLocation sourceLocation) {
* Get a {@link Location} from a {@link SourceLocation}, with the filename
* transformed to a URI, and the line/column made 0-indexed.
*
- * @param sourceLocation The source location to get a Location from
+ * @param fromSourceLocation The source location to get a Location from
* @return The equivalent Location
*/
- public static Location toLocation(SourceLocation sourceLocation) {
+ public static Location toLocation(FromSourceLocation fromSourceLocation) {
+ SourceLocation sourceLocation = fromSourceLocation.getSourceLocation();
return new Location(toUri(sourceLocation.getFilename()), point(
new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1)));
}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java
new file mode 100644
index 00000000..ac1cc3a5
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A moveable index into a path from the root of a {@link Syntax.Node} to a
+ * position somewhere within that node. The path supports iteration both
+ * forward and backward, as well as storing a 'checkpoint' along the path
+ * that can be returned to at a later point.
+ */
+public final class NodeCursor {
+ private final List edges;
+ private int pos = 0;
+ private int checkpoint = 0;
+
+ NodeCursor(List edges) {
+ this.edges = edges;
+ }
+
+ /**
+ * @param value The node value to create the cursor for
+ * @param documentIndex The index within the document to create the cursor for
+ * @return A node cursor from the start of {@code value} to {@code documentIndex}
+ * within {@code document}.
+ */
+ public static NodeCursor create(Syntax.Node value, int documentIndex) {
+ List edges = new ArrayList<>();
+ NodeCursor cursor = new NodeCursor(edges);
+
+ if (value == null || documentIndex < 0) {
+ return cursor;
+ }
+
+ Syntax.Node next = value;
+ while (true) {
+ iteration: switch (next) {
+ case Syntax.Node.Kvps kvps -> {
+ edges.add(new NodeCursor.Obj(kvps));
+ Syntax.Node.Kvp lastKvp = null;
+ for (Syntax.Node.Kvp kvp : kvps.kvps()) {
+ if (kvp.key.isIn(documentIndex)) {
+ String key = kvp.key.stringValue();
+ edges.add(new NodeCursor.Key(key, kvps));
+ edges.add(new NodeCursor.Terminal(kvp));
+ return cursor;
+ } else if (kvp.inValue(documentIndex)) {
+ if (kvp.value == null) {
+ lastKvp = kvp;
+ break;
+ }
+ String key = kvp.key.stringValue();
+ edges.add(new NodeCursor.ValueForKey(key, kvps));
+ next = kvp.value;
+ break iteration;
+ } else {
+ lastKvp = kvp;
+ }
+ }
+ if (lastKvp != null && lastKvp.value == null) {
+ edges.add(new NodeCursor.ValueForKey(lastKvp.key.stringValue(), kvps));
+ edges.add(new NodeCursor.Terminal(lastKvp));
+ return cursor;
+ }
+ return cursor;
+ }
+ case Syntax.Node.Obj obj -> {
+ next = obj.kvps;
+ }
+ case Syntax.Node.Arr arr -> {
+ edges.add(new NodeCursor.Arr(arr));
+ for (int i = 0; i < arr.elements.size(); i++) {
+ Syntax.Node elem = arr.elements.get(i);
+ if (elem.isIn(documentIndex)) {
+ edges.add(new NodeCursor.Elem(i, arr));
+ next = elem;
+ break iteration;
+ }
+ }
+ return cursor;
+ }
+ case null -> {
+ edges.add(new NodeCursor.Terminal(null));
+ return cursor;
+ }
+ default -> {
+ edges.add(new NodeCursor.Terminal(next));
+ return cursor;
+ }
+ }
+ }
+ }
+
+ public List edges() {
+ return edges;
+ }
+
+ /**
+ * @return Whether the cursor is not at the end of the path. A return value
+ * of {@code true} means {@link #next()} may be called safely.
+ */
+ public boolean hasNext() {
+ return pos < edges.size();
+ }
+
+ /**
+ * @return The next edge along the path. Also moves the cursor forward.
+ */
+ public Edge next() {
+ Edge edge = edges.get(pos);
+ pos++;
+ return edge;
+ }
+
+ /**
+ * @return Whether the cursor is not at the start of the path. A return value
+ * of {@code true} means {@link #previous()} may be called safely.
+ */
+ public boolean hasPrevious() {
+ return edges.size() - pos >= 0;
+ }
+
+ /**
+ * @return The previous edge along the path. Also moves the cursor backward.
+ */
+ public Edge previous() {
+ pos--;
+ return edges.get(pos);
+ }
+
+ /**
+ * @return Whether the path consists of a single, terminal, node.
+ */
+ public boolean isTerminal() {
+ return edges.size() == 1 && edges.getFirst() instanceof Terminal;
+ }
+
+ /**
+ * Store the current cursor position to be returned to later. Subsequent
+ * calls overwrite the checkpoint.
+ */
+ public void setCheckpoint() {
+ this.checkpoint = pos;
+ }
+
+ /**
+ * Return to a previously set checkpoint. Subsequent calls continue to
+ * the same checkpoint, unless overwritten.
+ */
+ public void returnToCheckpoint() {
+ this.pos = checkpoint;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ for (Edge edge : edges) {
+ switch (edge) {
+ case Obj ignored -> builder.append("Obj,");
+ case Arr ignored -> builder.append("Arr,");
+ case Terminal ignored -> builder.append("Terminal,");
+ case Elem elem -> builder.append("Elem(").append(elem.index).append("),");
+ case Key key -> builder.append("Key(").append(key.name).append("),");
+ case ValueForKey valueForKey -> builder.append("ValueForKey(").append(valueForKey.keyName).append("),");
+ }
+ }
+ return builder.toString();
+ }
+
+ /**
+ * An edge along a path within a {@link Syntax.Node}. Edges are fine-grained
+ * structurally, so there is a distinction between e.g. a path into an object,
+ * an object key, and a value for an object key, but there is no distinction
+ * between e.g. a path into a string value vs a numeric value. Each edge stores
+ * a reference to the underlying node, or a reference to the parent node.
+ */
+ public sealed interface Edge {}
+
+ /**
+ * Within an object, i.e. within the braces: '{}'.
+ * @param node The value of the underlying node at this edge.
+ */
+ public record Obj(Syntax.Node.Kvps node) implements Edge {}
+
+ /**
+ * Within an array/list, i.e. within the brackets: '[]'.
+ * @param node The value of the underlying node at this edge.
+ */
+ public record Arr(Syntax.Node.Arr node) implements Edge {}
+
+ /**
+ * The end of a path. Will always be present at the end of any non-empty path.
+ * @param node The value of the underlying node at this edge.
+ */
+ public record Terminal(Syntax.Node node) implements Edge {}
+
+ /**
+ * Within a key of an object, i.e. '{"here": null}'
+ * @param name The name of the key.
+ * @param parent The object node the key is within.
+ */
+ public record Key(String name, Syntax.Node.Kvps parent) implements Edge {}
+
+ /**
+ * Within a value corresponding to a key of an object, i.e. '{"key": "here"}'
+ * @param keyName The name of the key.
+ * @param parent The object node the value is within.
+ */
+ public record ValueForKey(String keyName, Syntax.Node.Kvps parent) implements Edge {}
+
+ /**
+ * Within an element of an array/list, i.e. '["here"]'.
+ * @param index The index of the element.
+ * @param parent The array node the element is within.
+ */
+ public record Elem(int index, Syntax.Node.Arr parent) implements Edge {}
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java
new file mode 100644
index 00000000..9f2684be
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java
@@ -0,0 +1,1037 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.utils.SimpleParser;
+
+/**
+ * Parser for {@link Syntax.Node} and {@link Syntax.Statement}. See
+ * {@link Syntax} for more details on the design of the parser.
+ *
+ * This parser can be used to parse a single {@link Syntax.Node} by itself,
+ * or to parse a list of {@link Syntax.Statement} in a Smithy file.
+ */
+final class Parser extends SimpleParser {
+ final List errors = new ArrayList<>();
+ final List statements = new ArrayList<>();
+ private final Document document;
+
+ Parser(Document document) {
+ super(document.borrowText());
+ this.document = document;
+ }
+
+ Syntax.Node parseNode() {
+ ws();
+ return switch (peek()) {
+ case '{' -> obj();
+ case '"' -> str();
+ case '[' -> arr();
+ case '-' -> num();
+ default -> {
+ if (isDigit()) {
+ yield num();
+ } else if (isIdentStart()) {
+ yield ident();
+ }
+
+ int start = position();
+ do {
+ skip();
+ } while (!isWs() && !isNodeStructuralBreakpoint() && !eof());
+ int end = position();
+ Syntax.Node.Err err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end));
+ err.start = start;
+ err.end = end;
+ yield err;
+ }
+ };
+ }
+
+ void parseIdl() {
+ try {
+ ws();
+ while (!eof()) {
+ statement();
+ ws();
+ }
+ } catch (Parser.Eof e) {
+ // This is used to stop parsing when eof is encountered even if we're
+ // within many layers of method calls.
+ Syntax.Statement.Err err = new Syntax.Statement.Err(e.message);
+ err.start = position();
+ err.end = position();
+ addError(err);
+ }
+ }
+
+ void parseIdlBetween(int start, int end) {
+ try {
+ rewindTo(start);
+ ws();
+ while (!eof() && position() < end) {
+ statement();
+ ws();
+ }
+ } catch (Parser.Eof e) {
+ Syntax.Statement.Err err = new Syntax.Statement.Err(e.message);
+ err.start = position();
+ err.end = position();
+ addError(err);
+ }
+ }
+
+ private void addStatement(Syntax.Statement statement) {
+ statements.add(statement);
+ }
+
+ private void addError(Syntax.Err err) {
+ errors.add(err);
+ }
+
+ private void setStart(Syntax.Item item) {
+ if (eof()) {
+ item.start = position() - 1;
+ } else {
+ item.start = position();
+ }
+ }
+
+ private int positionForStart() {
+ if (eof()) {
+ return position() - 1;
+ } else {
+ return position();
+ }
+ }
+
+ private void setEnd(Syntax.Item item) {
+ item.end = position();
+ }
+
+ private void rewindTo(int pos) {
+ int line = document.lineOfIndex(pos);
+ int lineIndex = document.indexOfLine(line);
+ this.rewind(pos, line + 1, pos - lineIndex + 1);
+ }
+
+ private Syntax.Node traitNode() {
+ skip(); // '('
+ ws();
+ return switch (peek()) {
+ case '{' -> obj();
+ case '"' -> {
+ int pos = position();
+ Syntax.Node str = str();
+ ws();
+ if (is(':')) {
+ yield traitValueKvps(pos);
+ } else {
+ yield str;
+ }
+ }
+ case '[' -> arr();
+ case '-' -> num();
+ default -> {
+ if (isDigit()) {
+ yield num();
+ } else if (isIdentStart()) {
+ int pos = position();
+ Syntax.Node ident = nodeIdent();
+ ws();
+ if (is(':')) {
+ yield traitValueKvps(pos);
+ } else {
+ yield ident;
+ }
+ } else if (is(')')) {
+ Syntax.Node.Kvps kvps = new Syntax.Node.Kvps();
+ setStart(kvps);
+ setEnd(kvps);
+ skip();
+ yield kvps;
+ }
+
+ int start = position();
+ do {
+ skip();
+ } while (!isWs() && !isStructuralBreakpoint() && !eof());
+ int end = position();
+ Syntax.Node.Err err;
+ if (eof()) {
+ err = new Syntax.Node.Err("unexpected eof");
+ } else {
+ err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end));
+ }
+ err.start = start;
+ err.end = end;
+ yield err;
+ }
+ };
+ }
+
+ private Syntax.Node traitValueKvps(int from) {
+ rewindTo(from);
+ Syntax.Node.Kvps kvps = new Syntax.Node.Kvps();
+ setStart(kvps);
+ while (!eof()) {
+ if (is(')')) {
+ setEnd(kvps);
+ skip();
+ return kvps;
+ }
+
+ Syntax.Node.Err kvpErr = kvp(kvps, ')');
+ if (kvpErr != null) {
+ addError(kvpErr);
+ }
+
+ ws();
+ }
+ kvps.end = position() - 1;
+ return kvps;
+ }
+
+ private Syntax.Node nodeIdent() {
+ int start = position();
+ // assume there's _something_ here
+ do {
+ skip();
+ } while (!isWs() && !isStructuralBreakpoint() && !eof());
+ int end = position();
+ return new Syntax.Ident(start, end, document.copySpan(start, end));
+ }
+
+ private Syntax.Node.Obj obj() {
+ Syntax.Node.Obj obj = new Syntax.Node.Obj();
+ setStart(obj);
+ skip();
+ ws();
+ while (!eof()) {
+ if (is('}')) {
+ skip();
+ setEnd(obj);
+ return obj;
+ }
+
+ Syntax.Err kvpErr = kvp(obj.kvps, '}');
+ if (kvpErr != null) {
+ addError(kvpErr);
+ }
+
+ ws();
+ }
+
+ Syntax.Node.Err err = new Syntax.Node.Err("missing }");
+ setStart(err);
+ setEnd(err);
+ addError(err);
+
+ setEnd(obj);
+ return obj;
+ }
+
+ private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) {
+ int start = positionForStart();
+ Syntax.Node keyValue = parseNode();
+ Syntax.Node.Err err = null;
+ Syntax.Node.Str key = null;
+ switch (keyValue) {
+ case Syntax.Node.Str s -> {
+ key = s;
+ }
+ case Syntax.Node.Err e -> {
+ err = e;
+ }
+ default -> {
+ err = nodeErr(keyValue, "unexpected " + keyValue.type());
+ }
+ }
+
+ ws();
+
+ Syntax.Node.Kvp kvp = null;
+ if (key != null) {
+ kvp = new Syntax.Node.Kvp(key);
+ kvp.start = start;
+ kvps.add(kvp);
+ }
+
+ if (is(':')) {
+ if (kvp != null) {
+ kvp.colonPos = position();
+ }
+ skip();
+ ws();
+ } else if (eof()) {
+ return nodeErr("unexpected eof");
+ } else {
+ if (err != null) {
+ addError(err);
+ }
+
+ err = nodeErr("expected :");
+ }
+
+ if (is(close)) {
+ if (err != null) {
+ addError(err);
+ }
+
+ return nodeErr("expected value");
+ }
+
+ if (is(',')) {
+ skip();
+ if (kvp != null) {
+ setEnd(kvp);
+ }
+ if (err != null) {
+ addError(err);
+ }
+
+ return nodeErr("expected value");
+ }
+
+ Syntax.Node value = parseNode();
+ if (value instanceof Syntax.Node.Err e) {
+ if (err != null) {
+ addError(err);
+ }
+ err = e;
+ } else if (err == null) {
+ kvp.value = value;
+ if (is(',')) {
+ skip();
+ }
+ return null;
+ }
+
+ return err;
+ }
+
+ private Syntax.Node.Arr arr() {
+ Syntax.Node.Arr arr = new Syntax.Node.Arr();
+ setStart(arr);
+ skip();
+ ws();
+ while (!eof()) {
+ if (is(']')) {
+ skip();
+ setEnd(arr);
+ return arr;
+ }
+
+ Syntax.Node elem = parseNode();
+ if (elem instanceof Syntax.Node.Err e) {
+ addError(e);
+ } else {
+ arr.elements.add(elem);
+ }
+ ws();
+ }
+
+ Syntax.Node.Err err = nodeErr("missing ]");
+ addError(err);
+
+ setEnd(arr);
+ return arr;
+ }
+
+ private Syntax.Node str() {
+ int start = position();
+ skip(); // '"'
+ if (is('"')) {
+ skip();
+
+ if (is('"')) {
+ skip();
+
+ // text block
+ int end = document.nextIndexOf("\"\"\"", position());
+ if (end == -1) {
+ rewindTo(document.length() - 1);
+ Syntax.Node.Err err = new Syntax.Node.Err("unclosed text block");
+ err.start = start;
+ err.end = document.length();
+ return err;
+ }
+
+ rewindTo(end + 3);
+ int strEnd = position();
+ return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 3, strEnd - 3));
+ }
+
+ // Empty string
+ skip();
+ int strEnd = position();
+ return new Syntax.Node.Str(start, strEnd, "");
+ }
+
+ int last = '"';
+
+ // Potential micro-optimization - only loop while position < line end
+ while (!isNl() && !eof()) {
+ if (is('"') && last != '\\') {
+ skip(); // '"'
+ int strEnd = position();
+ return new Syntax.Node.Str(start, strEnd, document.copySpan(start + 1, strEnd - 1));
+ }
+ last = peek();
+ skip();
+ }
+
+ Syntax.Node.Err err = new Syntax.Node.Err("unclosed string literal");
+ err.start = start;
+ setEnd(err);
+ return err;
+ }
+
+ private Syntax.Node num() {
+ int start = position();
+ while (!isWs() && !isNodeStructuralBreakpoint() && !eof()) {
+ skip();
+ }
+
+ String token = document.copySpan(start, position());
+ if (token == null) {
+ throw new RuntimeException("unhandled eof in node num");
+ }
+
+ Syntax.Node value;
+ try {
+ BigDecimal numValue = new BigDecimal(token);
+ value = new Syntax.Node.Num(numValue);
+ } catch (NumberFormatException e) {
+ value = new Syntax.Node.Err(String.format("%s is not a valid number", token));
+ }
+ value.start = start;
+ setEnd(value);
+ return value;
+ }
+
+ private boolean isNodeStructuralBreakpoint() {
+ return switch (peek()) {
+ case '{', '[', '}', ']', ',', ':', ')' -> true;
+ default -> false;
+ };
+ }
+
+ private Syntax.Node.Err nodeErr(Syntax.Node from, String message) {
+ Syntax.Node.Err err = new Syntax.Node.Err(message);
+ err.start = from.start;
+ err.end = from.end;
+ return err;
+ }
+
+ private Syntax.Node.Err nodeErr(String message) {
+ Syntax.Node.Err err = new Syntax.Node.Err(message);
+ setStart(err);
+ setEnd(err);
+ return err;
+ }
+
+ private void skipUntilStatementStart() {
+ while (!is('@') && !is('$') && !isIdentStart() && !eof()) {
+ skip();
+ }
+ }
+
+ private void statement() {
+ if (is('@')) {
+ traitApplication(null);
+ } else if (is('$')) {
+ control();
+ } else {
+ // Shape, apply
+ int start = position();
+ Syntax.Ident ident = ident();
+ if (ident.isEmpty()) {
+ if (!isWs()) {
+ // TODO: Capture all this in an error
+ skipUntilStatementStart();
+ }
+ return;
+ }
+
+ sp();
+ Syntax.Ident name = ident();
+ if (name.isEmpty()) {
+ Syntax.Statement.Incomplete incomplete = new Syntax.Statement.Incomplete(ident);
+ incomplete.start = start;
+ incomplete.end = position();
+ addStatement(incomplete);
+
+ if (!isWs()) {
+ skip();
+ }
+ return;
+ }
+
+ String identCopy = ident.stringValue();
+
+ switch (identCopy) {
+ case "apply" -> {
+ apply(start, name);
+ return;
+ }
+ case "metadata" -> {
+ metadata(start, name);
+ return;
+ }
+ case "use" -> {
+ use(start, name);
+ return;
+ }
+ case "namespace" -> {
+ namespace(start, name);
+ return;
+ }
+ default -> {
+ }
+ }
+
+ Syntax.Statement.ShapeDef shapeDef = new Syntax.Statement.ShapeDef(ident, name);
+ shapeDef.start = start;
+ setEnd(shapeDef);
+ addStatement(shapeDef);
+
+ sp();
+ optionalForResourceAndMixins();
+ ws();
+
+ switch (identCopy) {
+ case "enum", "intEnum" -> {
+ var block = startBlock(null);
+
+ ws();
+ while (!is('}') && !eof()) {
+ enumMember(block);
+ ws();
+ }
+
+ endBlock(block);
+ }
+ case "structure", "list", "map", "union" -> {
+ var block = startBlock(null);
+
+ ws();
+ while (!is('}') && !eof()) {
+ member(block);
+ ws();
+ }
+
+ endBlock(block);
+ }
+ case "resource", "service" -> {
+ var block = startBlock(null);
+
+ ws();
+ while (!is('}') && !eof()) {
+ nodeMember(block);
+ ws();
+ }
+
+ endBlock(block);
+ }
+ case "operation" -> {
+ var block = startBlock(null);
+ // This is different from the other member parsing because it needs more fine-grained loop/branch
+ // control to deal with inline structures
+ operationMembers(block);
+ endBlock(block);
+ }
+ default -> {
+ }
+ }
+ }
+ }
+
+ private Syntax.Statement.Block startBlock(Syntax.Statement.Block parent) {
+ Syntax.Statement.Block block = new Syntax.Statement.Block(parent, statements.size());
+ setStart(block);
+ addStatement(block);
+ if (is('{')) {
+ skip();
+ } else {
+ addErr(position(), position(), "expected {");
+ recoverToMemberStart();
+ }
+ return block;
+ }
+
+ private void endBlock(Syntax.Statement.Block block) {
+ block.lastStatementIndex = statements.size() - 1;
+ throwIfEofAndFinish("expected }", block); // This will stop execution
+ skip(); // '}'
+ setEnd(block);
+ }
+
+ private void operationMembers(Syntax.Statement.Block parent) {
+ ws();
+ while (!is('}') && !eof()) {
+ int opMemberStart = position();
+ Syntax.Ident memberName = ident();
+
+ int colonPos = -1;
+ sp();
+ if (is(':')) {
+ colonPos = position();
+ skip(); // ':'
+ } else {
+ addErr(position(), position(), "expected :");
+ if (isWs()) {
+ var memberDef = new Syntax.Statement.MemberDef(parent, memberName);
+ memberDef.start = opMemberStart;
+ setEnd(memberDef);
+ addStatement(memberDef);
+ ws();
+ continue;
+ }
+ }
+
+ if (is('=')) {
+ skip(); // '='
+ inlineMember(parent, opMemberStart, memberName);
+ ws();
+ continue;
+ }
+
+ ws();
+
+ if (isIdentStart()) {
+ var opMemberDef = new Syntax.Statement.MemberDef(parent, memberName);
+ opMemberDef.start = opMemberStart;
+ opMemberDef.colonPos = colonPos;
+ opMemberDef.target = ident();
+ setEnd(opMemberDef);
+ addStatement(opMemberDef);
+ } else {
+ var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName);
+ nodeMemberDef.start = opMemberStart;
+ nodeMemberDef.colonPos = colonPos;
+ nodeMemberDef.value = parseNode();
+ setEnd(nodeMemberDef);
+ addStatement(nodeMemberDef);
+ }
+
+ ws();
+ }
+ }
+
+ private void control() {
+ int start = position();
+ skip(); // '$'
+ Syntax.Ident ident = ident();
+ Syntax.Statement.Control control = new Syntax.Statement.Control(ident);
+ control.start = start;
+ addStatement(control);
+ sp();
+
+ if (!is(':')) {
+ addErr(position(), position(), "expected :");
+ if (isWs()) {
+ setEnd(control);
+ return;
+ }
+ } else {
+ skip();
+ }
+
+ control.value = parseNode();
+ setEnd(control);
+ }
+
+ private void apply(int start, Syntax.Ident name) {
+ Syntax.Statement.Apply apply = new Syntax.Statement.Apply(name);
+ apply.start = start;
+ setEnd(apply);
+ addStatement(apply);
+
+ sp();
+ if (is('@')) {
+ traitApplication(null);
+ } else if (is('{')) {
+ var block = startBlock(null);
+
+ ws();
+ while (!is('}') && !eof()) {
+ if (!is('@')) {
+ addErr(position(), position(), "expected trait");
+ return;
+ }
+ traitApplication(block);
+ ws();
+ }
+
+ endBlock(block);
+ } else {
+ addErr(position(), position(), "expected trait or block");
+ }
+ }
+
+ private void metadata(int start, Syntax.Ident name) {
+ Syntax.Statement.Metadata metadata = new Syntax.Statement.Metadata(name);
+ metadata.start = start;
+ addStatement(metadata);
+
+ sp();
+ if (!is('=')) {
+ addErr(position(), position(), "expected =");
+ if (isWs()) {
+ setEnd(metadata);
+ return;
+ }
+ } else {
+ skip();
+ }
+ metadata.value = parseNode();
+ setEnd(metadata);
+ }
+
+ private void use(int start, Syntax.Ident name) {
+ Syntax.Statement.Use use = new Syntax.Statement.Use(name);
+ use.start = start;
+ setEnd(use);
+ addStatement(use);
+ }
+
+ private void namespace(int start, Syntax.Ident name) {
+ Syntax.Statement.Namespace namespace = new Syntax.Statement.Namespace(name);
+ namespace.start = start;
+ setEnd(namespace);
+ addStatement(namespace);
+ }
+
+ private void optionalForResourceAndMixins() {
+ int maybeStart = position();
+ Syntax.Ident maybe = optIdent();
+
+ if (maybe.stringValue().equals("for")) {
+ sp();
+ Syntax.Ident resource = ident();
+ Syntax.Statement.ForResource forResource = new Syntax.Statement.ForResource(resource);
+ forResource.start = maybeStart;
+ addStatement(forResource);
+ ws();
+ setEnd(forResource);
+ maybeStart = position();
+ maybe = optIdent();
+ }
+
+ if (maybe.stringValue().equals("with")) {
+ sp();
+ Syntax.Statement.Mixins mixins = new Syntax.Statement.Mixins();
+ mixins.start = maybeStart;
+
+ if (!is('[')) {
+ addErr(position(), position(), "expected [");
+
+ // If we're on an identifier, just assume the [ was meant to be there
+ if (!isIdentStart()) {
+ setEnd(mixins);
+ addStatement(mixins);
+ return;
+ }
+ } else {
+ skip();
+ }
+
+ ws();
+ while (!isStructuralBreakpoint() && !eof()) {
+ mixins.mixins.add(ident());
+ ws();
+ }
+
+ if (is(']')) {
+ skip(); // ']'
+ } else {
+ // We either have another structural breakpoint, or eof
+ addErr(position(), position(), "expected ]");
+ }
+
+ setEnd(mixins);
+ addStatement(mixins);
+ }
+ }
+
+ private void member(Syntax.Statement.Block parent) {
+ if (is('@')) {
+ traitApplication(parent);
+ } else if (is('$')) {
+ elidedMember(parent);
+ } else if (isIdentStart()) {
+ int start = positionForStart();
+ Syntax.Ident name = ident();
+ Syntax.Statement.MemberDef memberDef = new Syntax.Statement.MemberDef(parent, name);
+ memberDef.start = start;
+ addStatement(memberDef);
+
+ sp();
+ if (is(':')) {
+ memberDef.colonPos = position();
+ skip();
+ } else {
+ addErr(position(), position(), "expected :");
+ if (isWs() || is('}')) {
+ setEnd(memberDef);
+ addStatement(memberDef);
+ return;
+ }
+ }
+ ws();
+
+ memberDef.target = ident();
+ setEnd(memberDef);
+ ws();
+
+ if (is('=')) {
+ skip();
+ parseNode();
+ ws();
+ }
+
+ } else {
+ addErr(position(), position(),
+ "unexpected token " + peekSingleCharForMessage() + " expected trait or member");
+ recoverToMemberStart();
+ }
+ }
+
+ private void enumMember(Syntax.Statement.Block parent) {
+ if (is('@')) {
+ traitApplication(parent);
+ } else if (isIdentStart()) {
+ int start = positionForStart();
+ Syntax.Ident name = ident();
+ var enumMemberDef = new Syntax.Statement.EnumMemberDef(parent, name);
+ enumMemberDef.start = start;
+ addStatement(enumMemberDef);
+
+ ws();
+ if (is('=')) {
+ skip(); // '='
+ ws();
+ enumMemberDef.value = parseNode();
+ }
+ setEnd(enumMemberDef);
+ } else {
+ addErr(position(), position(),
+ "unexpected token " + peekSingleCharForMessage() + " expected trait or member");
+ recoverToMemberStart();
+ }
+ }
+
+ private void elidedMember(Syntax.Statement.Block parent) {
+ int start = positionForStart();
+ skip(); // '$'
+ Syntax.Ident name = ident();
+ var elidedMemberDef = new Syntax.Statement.ElidedMemberDef(parent, name);
+ elidedMemberDef.start = start;
+ setEnd(elidedMemberDef);
+ addStatement(elidedMemberDef);
+ }
+
+ private void inlineMember(Syntax.Statement.Block parent, int start, Syntax.Ident name) {
+ var inlineMemberDef = new Syntax.Statement.InlineMemberDef(parent, name);
+ inlineMemberDef.start = start;
+ setEnd(inlineMemberDef);
+ addStatement(inlineMemberDef);
+
+ ws();
+ while (is('@')) {
+ traitApplication(parent);
+ ws();
+ }
+ throwIfEof("expected {");
+
+ optionalForResourceAndMixins();
+ ws();
+
+ var block = startBlock(parent);
+ ws();
+ while (!is('}') && !eof()) {
+ member(block);
+ ws();
+ }
+ endBlock(block);
+ }
+
+ private void nodeMember(Syntax.Statement.Block parent) {
+ int start = positionForStart();
+ Syntax.Ident name = ident();
+ var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, name);
+ nodeMemberDef.start = start;
+
+ sp();
+ if (is(':')) {
+ nodeMemberDef.colonPos = position();
+ skip(); // ':'
+ } else {
+ addErr(position(), position(), "expected :");
+ if (isWs() || is('}')) {
+ setEnd(nodeMemberDef);
+ addStatement(nodeMemberDef);
+ return;
+ }
+ }
+
+ ws();
+ if (is('}')) {
+ addErr(nodeMemberDef.colonPos, nodeMemberDef.colonPos, "expected node");
+ } else {
+ nodeMemberDef.value = parseNode();
+ }
+ setEnd(nodeMemberDef);
+ addStatement(nodeMemberDef);
+ }
+
+ private void traitApplication(Syntax.Statement.Block parent) {
+ int startPos = position();
+ skip(); // '@'
+ Syntax.Ident id = ident();
+ var application = new Syntax.Statement.TraitApplication(parent, id);
+ application.start = startPos;
+ addStatement(application);
+
+ if (is('(')) {
+ int start = position();
+ application.value = traitNode();
+ application.value.start = start;
+ ws();
+ if (is(')')) {
+ setEnd(application.value);
+ skip(); // ')'
+ }
+ // Otherwise, traitNode() probably ate it.
+ }
+ setEnd(application);
+ }
+
+ private Syntax.Ident optIdent() {
+ if (!isIdentStart()) {
+ return Syntax.Ident.EMPTY;
+ }
+ return ident();
+ }
+
+ private Syntax.Ident ident() {
+ int start = position();
+ if (!isIdentStart()) {
+ addErr(start, start, "expected identifier");
+ return Syntax.Ident.EMPTY;
+ }
+
+ do {
+ skip();
+ } while (isIdentChar());
+
+ int end = position();
+ if (start == end) {
+ addErr(start, end, "expected identifier");
+ return Syntax.Ident.EMPTY;
+ }
+ return new Syntax.Ident(start, end, document.copySpan(start, end));
+ }
+
+ private void addErr(int start, int end, String message) {
+ Syntax.Statement.Err err = new Syntax.Statement.Err(message);
+ err.start = start;
+ err.end = end;
+ addError(err);
+ }
+
+ private void recoverToMemberStart() {
+ ws();
+ while (!isIdentStart() && !is('@') && !is('$') && !eof()) {
+ skip();
+ ws();
+ }
+
+ throwIfEof("expected member or trait");
+ }
+
+ private boolean isStructuralBreakpoint() {
+ return switch (peek()) {
+ case '{', '[', '(', '}', ']', ')', ':', '=', '@' -> true;
+ default -> false;
+ };
+ }
+
+ private boolean isIdentStart() {
+ char peeked = peek();
+ return Character.isLetter(peeked) || peeked == '_';
+ }
+
+ private boolean isIdentChar() {
+ char peeked = peek();
+ return Character.isLetterOrDigit(peeked) || peeked == '_' || peeked == '$' || peeked == '.' || peeked == '#';
+ }
+
+ private boolean isDigit() {
+ return Character.isDigit(peek());
+ }
+
+ private boolean isNl() {
+ return switch (peek()) {
+ case '\n', '\r' -> true;
+ default -> false;
+ };
+ }
+
+ private boolean isWs() {
+ return switch (peek()) {
+ case '\n', '\r', ' ', ',', '\t' -> true;
+ default -> false;
+ };
+ }
+
+ private boolean is(char c) {
+ return peek() == c;
+ }
+
+ private void throwIfEof(String message) {
+ if (eof()) {
+ throw new Eof(message);
+ }
+ }
+
+ private void throwIfEofAndFinish(String message, Syntax.Item item) {
+ if (eof()) {
+ setEnd(item);
+ throw new Eof(message);
+ }
+ }
+
+ /**
+ * Used to halt parsing when we reach the end of the file,
+ * without having to bubble up multiple layers.
+ */
+ private static final class Eof extends RuntimeException {
+ final String message;
+
+ Eof(String message) {
+ this.message = message;
+ }
+ }
+
+ @Override
+ public void ws() {
+ while (this.isWs() || is('/')) {
+ if (is('/')) {
+ while (!isNl() && !eof()) {
+ this.skip();
+ }
+ } else {
+ this.skip();
+ }
+ }
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java b/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java
new file mode 100644
index 00000000..b9884e38
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/StatementView.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * An IDL parse result at a specific position within the underlying document.
+ *
+ * @param parseResult The IDL parse result
+ * @param statementIndex The index of the statement {@code documentIndex} is within
+ * @param documentIndex The index within the underlying document
+ */
+public record StatementView(Syntax.IdlParseResult parseResult, int statementIndex, int documentIndex) {
+
+ /**
+ * @param parseResult The parse result to create a view of
+ * @return An optional view of the first statement in the given parse result,
+ * or empty if the parse result has no statements
+ */
+ public static Optional createAtStart(Syntax.IdlParseResult parseResult) {
+ if (parseResult.statements().isEmpty()) {
+ return Optional.empty();
+ }
+
+ return createAt(parseResult, parseResult.statements().getFirst().start());
+ }
+
+ /**
+ * @param parseResult The parse result to create a view of
+ * @param documentIndex The index within the underlying document
+ * @return An optional view of the statement the given documentIndex is within
+ * in the given parse result, or empty if the index is not within a statement
+ */
+ public static Optional createAt(Syntax.IdlParseResult parseResult, int documentIndex) {
+ if (documentIndex < 0) {
+ return Optional.empty();
+ }
+
+ int statementIndex = statementIndex(parseResult.statements(), documentIndex);
+ if (statementIndex < 0) {
+ return Optional.empty();
+ }
+
+ return Optional.of(new StatementView(parseResult, statementIndex, documentIndex));
+ }
+
+ private static int statementIndex(List statements, int position) {
+ int low = 0;
+ int up = statements.size() - 1;
+
+ while (low <= up) {
+ int mid = (low + up) / 2;
+ Syntax.Statement statement = statements.get(mid);
+ if (statement.isIn(position)) {
+ if (statement instanceof Syntax.Statement.Block) {
+ return statementIndexBetween(statements, mid, up, position);
+ } else {
+ return mid;
+ }
+ } else if (statement.start() > position) {
+ up = mid - 1;
+ } else if (statement.end() < position) {
+ low = mid + 1;
+ } else {
+ return -1;
+ }
+ }
+
+ Syntax.Statement last = statements.get(up);
+ if (last instanceof Syntax.Statement.MemberStatement memberStatement) {
+ // Note: parent() can be null for TraitApplication.
+ if (memberStatement.parent() != null && memberStatement.parent().isIn(position)) {
+ return memberStatement.parent().statementIndex();
+ }
+ }
+
+ return -1;
+ }
+
+ private static int statementIndexBetween(List statements, int lower, int upper, int position) {
+ int ogLower = lower;
+ lower += 1;
+ while (lower <= upper) {
+ int mid = (lower + upper) / 2;
+ Syntax.Statement statement = statements.get(mid);
+ if (statement.isIn(position)) {
+ // Could have nested blocks, like in an inline structure definition
+ if (statement instanceof Syntax.Statement.Block) {
+ return statementIndexBetween(statements, mid, upper, position);
+ }
+ return mid;
+ } else if (statement.start() > position) {
+ upper = mid - 1;
+ } else if (statement.end() < position) {
+ lower = mid + 1;
+ } else {
+ return ogLower;
+ }
+ }
+
+ return ogLower;
+ }
+
+ /**
+ * @return The non-nullable statement that {@link #documentIndex()} is within
+ */
+ public Syntax.Statement getStatement() {
+ return parseResult.statements().get(statementIndex);
+ }
+
+ /**
+ * @param documentIndex The index within the underlying document
+ * @return The optional statement the given index is within
+ */
+ public Optional getStatementAt(int documentIndex) {
+ int statementIndex = statementIndex(parseResult.statements(), documentIndex);
+ if (statementIndex < 0) {
+ return Optional.empty();
+ }
+ return Optional.of(parseResult.statements().get(statementIndex));
+ }
+
+ /**
+ * @return The nearest shape def before this view
+ */
+ public Syntax.Statement.ShapeDef nearestShapeDefBefore() {
+ int searchStatementIndex = statementIndex - 1;
+ while (searchStatementIndex >= 0) {
+ Syntax.Statement statement = parseResult.statements().get(searchStatementIndex);
+ if (statement instanceof Syntax.Statement.ShapeDef shapeDef) {
+ return shapeDef;
+ }
+ searchStatementIndex--;
+ }
+ return null;
+ }
+
+ /**
+ * @return The nearest for resource and mixins before this view
+ */
+ public Syntax.ForResourceAndMixins nearestForResourceAndMixinsBefore() {
+ int searchStatementIndex = statementIndex;
+ while (searchStatementIndex >= 0) {
+ Syntax.Statement searchStatement = parseResult.statements().get(searchStatementIndex);
+ if (searchStatement instanceof Syntax.Statement.Block) {
+ Syntax.Statement.ForResource forResource = null;
+ Syntax.Statement.Mixins mixins = null;
+
+ int lastSearchIndex = searchStatementIndex - 2;
+ searchStatementIndex--;
+ while (searchStatementIndex >= 0 && searchStatementIndex >= lastSearchIndex) {
+ Syntax.Statement candidateStatement = parseResult.statements().get(searchStatementIndex);
+ if (candidateStatement instanceof Syntax.Statement.Mixins m) {
+ mixins = m;
+ } else if (candidateStatement instanceof Syntax.Statement.ForResource f) {
+ forResource = f;
+ }
+ searchStatementIndex--;
+ }
+
+ return new Syntax.ForResourceAndMixins(forResource, mixins);
+ }
+ searchStatementIndex--;
+ }
+
+ return new Syntax.ForResourceAndMixins(null, null);
+ }
+
+ /**
+ * @return The names of all the other members around this view
+ */
+ public Set otherMemberNames() {
+ Set found = new HashSet<>();
+ int searchIndex = statementIndex;
+ int lastMemberStatementIndex = statementIndex;
+ while (searchIndex >= 0) {
+ Syntax.Statement statement = parseResult.statements().get(searchIndex);
+ if (statement instanceof Syntax.Statement.Block block) {
+ lastMemberStatementIndex = block.lastStatementIndex();
+ break;
+ } else if (searchIndex != statementIndex) {
+ addMemberName(found, statement);
+ }
+ searchIndex--;
+ }
+ searchIndex = statementIndex + 1;
+ while (searchIndex <= lastMemberStatementIndex) {
+ Syntax.Statement statement = parseResult.statements().get(searchIndex);
+ addMemberName(found, statement);
+ searchIndex++;
+ }
+ return found;
+ }
+
+ private static void addMemberName(Set memberNames, Syntax.Statement statement) {
+ switch (statement) {
+ case Syntax.Statement.MemberDef def -> memberNames.add(def.name().stringValue());
+ case Syntax.Statement.NodeMemberDef def -> memberNames.add(def.name().stringValue());
+ case Syntax.Statement.InlineMemberDef def -> memberNames.add(def.name().stringValue());
+ case Syntax.Statement.ElidedMemberDef def -> memberNames.add(def.name().stringValue());
+ default -> {
+ }
+ }
+ }
+
+ /**
+ * @return The nearest shape def after this view
+ */
+ public Syntax.Statement.ShapeDef nearestShapeDefAfter() {
+ for (int i = statementIndex + 1; i < parseResult.statements().size(); i++) {
+ Syntax.Statement statement = parseResult.statements().get(i);
+ if (statement instanceof Syntax.Statement.ShapeDef shapeDef) {
+ return shapeDef;
+ } else if (!(statement instanceof Syntax.Statement.TraitApplication)) {
+ return null;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java
new file mode 100644
index 00000000..e6b27667
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java
@@ -0,0 +1,787 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.lsp.document.DocumentImports;
+import software.amazon.smithy.lsp.document.DocumentNamespace;
+import software.amazon.smithy.lsp.document.DocumentParser;
+import software.amazon.smithy.lsp.document.DocumentVersion;
+
+/**
+ * Provides classes that represent the syntactic structure of a Smithy file, and
+ * a means to parse Smithy files into those classes.
+ *
+ * IDL Syntax
+ * The result of a parse, {@link IdlParseResult}, is a list of {@link Statement},
+ * rather than a syntax tree. For example, the following:
+ *
+ * \@someTrait
+ * structure Foo with [Bar] {
+ * \@otherTrait
+ * foo: String
+ * }
+ *
+ * Produces the following list of statements:
+ *
+ * TraitApplication,
+ * ShapeDef,
+ * Mixins,
+ * Block,
+ * TraitApplication,
+ * MemberDef
+ *
+ * While this sacrifices the ability to walk directly from the `foo` member def
+ * to the `Foo` structure (or vice-versa), it simplifies error handling in the
+ * parser by allowing more _nearly_ correct syntax, and localizes any errors as
+ * close to their "cause" as possible. In general, the parser is as lenient as
+ * possible, always producing a {@link Statement} for any given text, even if
+ * the statement is incomplete or invalid. This means that consumers of the
+ * parse result will always have _something_ they can analyze, despite the text
+ * having invalid syntax, so the server stays responsive as you type.
+ *
+ * At a high-level, the design decisions of the parser and {@link Statement}
+ * are guided by the following ideas:
+ * - Minimal lookahead or structural validation to be as fast as possible.
+ * - Minimal memory allocations, for intermediate objects and the parse result.
+ * - Minimal sensitivity to context, leaving the door open to easily implement
+ * incremental/partial re-parsing of changes if it becomes necessary.
+ * - Provide strongly-typed, concrete syntax productions so consumers don't need
+ * to create their own wrappers.
+ *
+ *
There are a few things to note about the public API of {@link Statement}s
+ * produced by the parser.
+ * - Any `final` field is definitely assigned, whereas any non `final` field
+ * may be null (other than {@link Statement#start} and {@link Statement#end},
+ * which are definitely assigned).
+ *
+ * Node Syntax
+ * This class also provides classes for the JSON-like Smithy Node, which can
+ * be used standalone (see {@link Syntax#parseNode(Document)}). {@link Node}
+ * is a more typical recursive parse tree, so parsing produces a single
+ * {@link Node}, and any given {@link Node} may be a {@link Node.Err}. Like
+ * {@link Statement}, the parser tries to be as lenient as possible here too.
+ */
+public final class Syntax {
+ private Syntax() {
+ }
+
+ /**
+ * Wrapper for {@link Statement.ForResource} and {@link Statement.Mixins},
+ * which often are used together.
+ *
+ * @param forResource The nullable for-resource statement.
+ * @param mixins The nullable mixins statement.
+ */
+ public record ForResourceAndMixins(Statement.ForResource forResource, Statement.Mixins mixins) {}
+
+ /**
+ * The result of parsing an IDL document, containing some extra computed
+ * info that is used often.
+ *
+ * @param statements The parsed statements.
+ * @param errors The errors that occurred during parsing.
+ * @param version The IDL version that was parsed.
+ * @param namespace The namespace that was parsed
+ * @param imports The imports that were parsed.
+ */
+ public record IdlParseResult(
+ List statements,
+ List errors,
+ DocumentVersion version,
+ DocumentNamespace namespace,
+ DocumentImports imports
+ ) {}
+
+ /**
+ * @param document The document to parse.
+ * @return The IDL parse result.
+ */
+ public static IdlParseResult parseIdl(Document document) {
+ Parser parser = new Parser(document);
+ parser.parseIdl();
+ List statements = parser.statements;
+ DocumentParser documentParser = DocumentParser.forStatements(document, statements);
+ return new IdlParseResult(
+ statements,
+ parser.errors,
+ documentParser.documentVersion(),
+ documentParser.documentNamespace(),
+ documentParser.documentImports());
+ }
+
+ /**
+ * The result of parsing a Node document.
+ *
+ * @param value The parsed node.
+ * @param errors The errors that occurred during parsing.
+ */
+ public record NodeParseResult(Node value, List errors) {}
+
+ /**
+ * @param document The document to parse.
+ * @return The Node parse result.
+ */
+ public static NodeParseResult parseNode(Document document) {
+ Parser parser = new Parser(document);
+ Node node = parser.parseNode();
+ return new NodeParseResult(node, parser.errors);
+ }
+
+ /**
+ * Any syntactic construct has this base type. Mostly used to share
+ * {@link #start()} and {@link #end()} that all items have.
+ */
+ public abstract static sealed class Item {
+ int start;
+ int end;
+
+ public final int start() {
+ return start;
+ }
+
+ public final int end() {
+ return end;
+ }
+
+ /**
+ * @param pos The character offset in a file to check
+ * @return Whether {@code pos} is within this item
+ */
+ public final boolean isIn(int pos) {
+ return start <= pos && end > pos;
+ }
+ }
+
+ /**
+ * Common type of all JSON-like node syntax productions.
+ */
+ public abstract static sealed class Node extends Item {
+ /**
+ * @return The type of the node.
+ */
+ public final Type type() {
+ return switch (this) {
+ case Kvps ignored -> Type.Kvps;
+ case Kvp ignored -> Type.Kvp;
+ case Obj ignored -> Type.Obj;
+ case Arr ignored -> Type.Arr;
+ case Ident ignored -> Type.Ident;
+ case Str ignored -> Type.Str;
+ case Num ignored -> Type.Num;
+ case Err ignored -> Type.Err;
+ };
+ }
+
+ /**
+ * Applies this node to {@code consumer}, and traverses this node in
+ * depth-first order.
+ *
+ * @param consumer Consumer to do something with each node.
+ */
+ public final void consume(Consumer consumer) {
+ consumer.accept(this);
+ switch (this) {
+ case Kvps kvps -> kvps.kvps().forEach(kvp -> kvp.consume(consumer));
+ case Kvp kvp -> {
+ kvp.key.consume(consumer);
+ if (kvp.value != null) {
+ kvp.value.consume(consumer);
+ }
+ }
+ case Obj obj -> obj.kvps.consume(consumer);
+ case Arr arr -> arr.elements.forEach(elem -> elem.consume(consumer));
+ default -> {
+ }
+ }
+ }
+
+ public enum Type {
+ Kvps,
+ Kvp,
+ Obj,
+ Arr,
+ Str,
+ Num,
+ Ident,
+ Err
+ }
+
+ /**
+ * A list of key-value pairs. May be within an {@link Obj}, or standalone
+ * (like in a trait body).
+ */
+ public static final class Kvps extends Node {
+ private final List kvps = new ArrayList<>();
+
+ void add(Kvp kvp) {
+ kvps.add(kvp);
+ }
+
+ public List kvps() {
+ return kvps;
+ }
+ }
+
+ /**
+ * A single key-value pair. {@link #key} will definitely be present,
+ * while {@link #value} may be null.
+ */
+ public static final class Kvp extends Node {
+ final Str key;
+ int colonPos = -1;
+ Node value;
+
+ Kvp(Str key) {
+ this.key = key;
+ }
+
+ public Str key() {
+ return key;
+ }
+
+ public Node value() {
+ return value;
+ }
+
+ /**
+ * @param pos The character offset to check
+ * @return Whether the given offset is within the value of this pair
+ */
+ public boolean inValue(int pos) {
+ if (colonPos < 0) {
+ return false;
+ } else if (value == null) {
+ return pos > colonPos && pos < end;
+ } else {
+ return value.isIn(pos);
+ }
+ }
+ }
+
+ /**
+ * Wrapper around {@link Kvps}, for objects enclosed in {}.
+ */
+ public static final class Obj extends Node {
+ final Kvps kvps = new Kvps();
+
+ public Kvps kvps() {
+ return kvps;
+ }
+ }
+
+ /**
+ * An array of {@link Node}.
+ */
+ public static final class Arr extends Node {
+ final List elements = new ArrayList<>();
+
+ public List elements() {
+ return elements;
+ }
+ }
+
+ /**
+ * A string value. The Smithy {@link Node}s can also be regular
+ * identifiers, so this class a single subclass {@link Ident}.
+ */
+ public static sealed class Str extends Node {
+ final String value;
+
+ Str(int start, int end, String value) {
+ this.start = start;
+ this.end = end;
+ this.value = value;
+ }
+
+ public String stringValue() {
+ return value;
+ }
+ }
+
+ /**
+ * A numeric value.
+ */
+ public static final class Num extends Node {
+ final BigDecimal value;
+
+ Num(BigDecimal value) {
+ this.value = value;
+ }
+
+ public BigDecimal value() {
+ return value;
+ }
+ }
+
+ /**
+ * An error representing an invalid {@link Node} value.
+ */
+ public static final class Err extends Node implements Syntax.Err {
+ final String message;
+
+ Err(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String message() {
+ return message;
+ }
+ }
+ }
+
+ /**
+ * Common type of all IDL syntax productions.
+ */
+ public abstract static sealed class Statement extends Item {
+ /**
+ * @return The type of the statement.
+ */
+ public final Type type() {
+ return switch (this) {
+ case Incomplete ignored -> Type.Incomplete;
+ case Control ignored -> Type.Control;
+ case Metadata ignored -> Type.Metadata;
+ case Namespace ignored -> Type.Namespace;
+ case Use ignored -> Type.Use;
+ case Apply ignored -> Type.Apply;
+ case ShapeDef ignored -> Type.ShapeDef;
+ case ForResource ignored -> Type.ForResource;
+ case Mixins ignored -> Type.Mixins;
+ case TraitApplication ignored -> Type.TraitApplication;
+ case MemberDef ignored -> Type.MemberDef;
+ case EnumMemberDef ignored -> Type.EnumMemberDef;
+ case ElidedMemberDef ignored -> Type.ElidedMemberDef;
+ case InlineMemberDef ignored -> Type.InlineMemberDef;
+ case NodeMemberDef ignored -> Type.NodeMemberDef;
+ case Block ignored -> Type.Block;
+ case Err ignored -> Type.Err;
+ };
+ }
+
+ public enum Type {
+ Incomplete,
+ Control,
+ Metadata,
+ Namespace,
+ Use,
+ Apply,
+ ShapeNode,
+ ShapeDef,
+ ForResource,
+ Mixins,
+ TraitApplication,
+ MemberDef,
+ EnumMemberDef,
+ ElidedMemberDef,
+ InlineMemberDef,
+ NodeMemberDef,
+ Block,
+ Err;
+ }
+
+ /**
+ * A single identifier that can't be associated with an actual statement.
+ * For example, `stru` by itself is an incomplete statement.
+ */
+ public static final class Incomplete extends Statement {
+ final Ident ident;
+
+ Incomplete(Ident ident) {
+ this.ident = ident;
+ }
+
+ public Ident ident() {
+ return ident;
+ }
+ }
+
+ /**
+ * A control statement.
+ */
+ public static final class Control extends Statement {
+ final Ident key;
+ Node value;
+
+ Control(Ident key) {
+ this.key = key;
+ }
+
+ public Ident key() {
+ return key;
+ }
+
+ public Node value() {
+ return value;
+ }
+ }
+
+ /**
+ * A metadata statement.
+ */
+ public static final class Metadata extends Statement {
+ final Ident key;
+ Node value;
+
+ Metadata(Ident key) {
+ this.key = key;
+ }
+
+ public Ident key() {
+ return key;
+ }
+
+ public Node value() {
+ return value;
+ }
+ }
+
+ /**
+ * A namespace statement, i.e. `namespace` followed by an identifier.
+ */
+ public static final class Namespace extends Statement {
+ final Ident namespace;
+
+ Namespace(Ident namespace) {
+ this.namespace = namespace;
+ }
+
+ public Ident namespace() {
+ return namespace;
+ }
+ }
+
+ /**
+ * A use statement, i.e. `use` followed by an identifier.
+ */
+ public static final class Use extends Statement {
+ final Ident use;
+
+ Use(Ident use) {
+ this.use = use;
+ }
+
+ public Ident use() {
+ return use;
+ }
+ }
+
+ /**
+ * An apply statement, i.e. `apply` followed by an identifier. Doesn't
+ * include, require, or care about subsequent trait applications.
+ */
+ public static final class Apply extends Statement {
+ final Ident id;
+
+ Apply(Ident id) {
+ this.id = id;
+ }
+
+ public Ident id() {
+ return id;
+ }
+ }
+
+ /**
+ * A shape definition, i.e. a shape type followed by an identifier.
+ */
+ public static final class ShapeDef extends Statement {
+ final Ident shapeType;
+ final Ident shapeName;
+
+ ShapeDef(Ident shapeType, Ident shapeName) {
+ this.shapeType = shapeType;
+ this.shapeName = shapeName;
+ }
+
+ public Ident shapeType() {
+ return shapeType;
+ }
+
+ public Ident shapeName() {
+ return shapeName;
+ }
+ }
+
+ /**
+ * `for` followed by an identifier. Only appears after a {@link ShapeDef}
+ * or after an {@link InlineMemberDef}.
+ */
+ public static final class ForResource extends Statement {
+ final Ident resource;
+
+ ForResource(Ident resource) {
+ this.resource = resource;
+ }
+
+ public Ident resource() {
+ return resource;
+ }
+ }
+
+ /**
+ * `with` followed by an array. The array may not be present in text,
+ * but it is in this production. Only appears after a {@link ShapeDef},
+ * {@link InlineMemberDef}, or {@link ForResource}.
+ */
+ public static final class Mixins extends Statement {
+ final List mixins = new ArrayList<>();
+
+ public List mixins() {
+ return mixins;
+ }
+ }
+
+ /**
+ * Common type of productions that can appear within shape bodies, i.e.
+ * within a {@link Block}.
+ *
+ * The sole purpose of this class is to make it cheap to navigate
+ * from a statement to the {@link Block} it resides within when
+ * searching for the statement corresponding to a given character offset
+ * in a document.
+ */
+ abstract static sealed class MemberStatement extends Statement {
+ final Block parent;
+
+ protected MemberStatement(Block parent) {
+ this.parent = parent;
+ }
+
+ /**
+ * @return The possibly null block enclosing this statement.
+ */
+ public Block parent() {
+ return parent;
+ }
+ }
+
+ /**
+ * A trait application, i.e. `@` followed by an identifier.
+ */
+ public static final class TraitApplication extends MemberStatement {
+ final Ident id;
+ Node value;
+
+ TraitApplication(Block parent, Ident id) {
+ super(parent);
+ this.id = id;
+ }
+
+ public Ident id() {
+ return id;
+ }
+
+ public Node value() {
+ return value;
+ }
+ }
+
+ /**
+ * A member definition, i.e. identifier `:` identifier. Only appears
+ * in {@link Block}s.
+ */
+ public static final class MemberDef extends MemberStatement {
+ final Ident name;
+ int colonPos = -1;
+ Ident target;
+
+ MemberDef(Block parent, Ident name) {
+ super(parent);
+ this.name = name;
+ }
+
+ public Ident name() {
+ return name;
+ }
+
+ public Ident target() {
+ return target;
+ }
+
+ /**
+ * @param pos The character offset to check
+ * @return Whether the given offset is within this member's target
+ */
+ public boolean inTarget(int pos) {
+ if (colonPos < 0) {
+ return false;
+ } else if (target == null || target.isEmpty()) {
+ return pos > colonPos;
+ } else {
+ return target.isIn(pos);
+ }
+ }
+ }
+
+ /**
+ * An enum member definition, i.e. an identifier followed by an optional
+ * value assignment. Only appears in {@link Block}s.
+ */
+ public static final class EnumMemberDef extends MemberStatement {
+ final Ident name;
+ Node value;
+
+ EnumMemberDef(Block parent, Ident name) {
+ super(parent);
+ this.name = name;
+ }
+
+ public Ident name() {
+ return name;
+ }
+ }
+
+ /**
+ * An elided member definition, i.e. `$` followed by an identifier. Only
+ * appears in {@link Block}s.
+ */
+ public static final class ElidedMemberDef extends MemberStatement {
+ final Ident name;
+
+ ElidedMemberDef(Block parent, Ident name) {
+ super(parent);
+ this.name = name;
+ }
+
+ public Ident name() {
+ return name;
+ }
+ }
+
+ /**
+ * An inline member definition, i.e. an identifier followed by `:=`. Only
+ * appears in {@link Block}s, and doesn't include the actual definition,
+ * just the member name.
+ */
+ public static final class InlineMemberDef extends MemberStatement {
+ final Ident name;
+
+ InlineMemberDef(Block parent, Ident name) {
+ super(parent);
+ this.name = name;
+ }
+
+ public Ident name() {
+ return name;
+ }
+ }
+
+ /**
+ * A member definition with a node value, i.e. identifier `:` node value.
+ * Only appears in {@link Block}s.
+ */
+ public static final class NodeMemberDef extends MemberStatement {
+ final Ident name;
+ int colonPos = -1;
+ Node value;
+
+ NodeMemberDef(Block parent, Ident name) {
+ super(parent);
+ this.name = name;
+ }
+
+ public Ident name() {
+ return name;
+ }
+
+ public Node value() {
+ return value;
+ }
+
+ /**
+ * @param pos The character offset to check
+ * @return Whether the given {@code pos} is within this member's value
+ */
+ public boolean inValue(int pos) {
+ return (value != null && value.isIn(pos))
+ || (colonPos >= 0 && pos > colonPos);
+ }
+ }
+
+ /**
+ * Used to indicate the start of a block, i.e. {}.
+ */
+ public static final class Block extends MemberStatement {
+ final int statementIndex;
+ int lastStatementIndex;
+
+ Block(Block parent, int lastStatementIndex) {
+ super(parent);
+ this.statementIndex = lastStatementIndex;
+ this.lastStatementIndex = lastStatementIndex;
+ }
+
+ public int statementIndex() {
+ return statementIndex;
+ }
+
+ public int lastStatementIndex() {
+ return lastStatementIndex;
+ }
+ }
+
+ /**
+ * An error that occurred during IDL parsing. This is distinct from
+ * {@link Node.Err} primarily because {@link Node.Err} is an actual
+ * value a {@link Node} can have.
+ */
+ public static final class Err extends Statement implements Syntax.Err {
+ final String message;
+
+ Err(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String message() {
+ return message;
+ }
+ }
+ }
+
+ /**
+ * An identifier in a {@link Node} or {@link Statement}. Starts with any
+ * alpha or `_` character, followed by any sequence of Shape ID characters
+ * (i.e. `.`, `#`, `$`, `_` digits, alphas).
+ */
+ public static final class Ident extends Node.Str {
+ static final Ident EMPTY = new Ident(-1, -1, "");
+
+ Ident(int start, int end, String value) {
+ super(start, end, value);
+ }
+
+ public boolean isEmpty() {
+ return (start - end) == 0;
+ }
+ }
+
+ /**
+ * Represents any syntax error, either {@link Node} or {@link Statement}.
+ */
+ public sealed interface Err {
+ /**
+ * @return The start index of the error.
+ */
+ int start();
+
+ /**
+ * @return The end index of the error.
+ */
+ int end();
+
+ /**
+ * @return The error message.
+ */
+ String message();
+ }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java
new file mode 100644
index 00000000..55187018
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.util;
+
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+
+public final class StreamUtils {
+ private StreamUtils() {
+ }
+
+ public static Collector> toWrappedMap() {
+ return Collectors.toMap(s -> s, s -> "\"" + s + "\"");
+ }
+
+ public static Collector