diff --git a/.gitignore b/.gitignore
index d47eb117..5ae1ecbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,4 @@ bin
 .settings
 
 .java-version
-*.smithy
-!/src/test/resources/**/*.smithy
 .ammonite
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 339f1131..e050c8ba 100644
--- a/build.gradle
+++ b/build.gradle
@@ -141,6 +141,9 @@ publishing {
     }
 }
 
+checkstyle {
+    toolVersion = "10.12.4"
+}
 
 dependencies {
     implementation "org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1"
@@ -153,6 +156,8 @@ dependencies {
     testImplementation "org.hamcrest:hamcrest:2.2"
 
     testRuntimeOnly "org.junit.platform:junit-platform-launcher"
+
+    checkstyle "com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}"
 }
 
 tasks.withType(Javadoc).all {
diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml
index fa284ede..c6658c32 100644
--- a/config/checkstyle/checkstyle.xml
+++ b/config/checkstyle/checkstyle.xml
@@ -182,7 +182,6 @@
         <!-- See http://checkstyle.sf.net/config_design.html -->
         <module name="FinalClass"/>
         <module name="HideUtilityClassConstructor"/>
-        <module name="InterfaceIsType"/>
         <module name="OneTopLevelClass"/>
 
         <!-- Miscellaneous other checks.                   -->
diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java
index 6aa714ba..dcb3a533 100644
--- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java
+++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java
@@ -104,9 +104,9 @@
 import software.amazon.smithy.lsp.ext.SelectorParams;
 import software.amazon.smithy.lsp.ext.ServerStatus;
 import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions;
-import software.amazon.smithy.lsp.handler.CompletionHandler;
-import software.amazon.smithy.lsp.handler.DefinitionHandler;
-import software.amazon.smithy.lsp.handler.HoverHandler;
+import software.amazon.smithy.lsp.language.CompletionHandler;
+import software.amazon.smithy.lsp.language.DefinitionHandler;
+import software.amazon.smithy.lsp.language.HoverHandler;
 import software.amazon.smithy.lsp.project.BuildFile;
 import software.amazon.smithy.lsp.project.Project;
 import software.amazon.smithy.lsp.project.ProjectAndFile;
@@ -498,10 +498,11 @@ public void didChange(DidChangeTextDocumentParams params) {
         }
 
         // Don't reload or update the project on build file changes, only on save
-        if (projectAndFile.file() instanceof BuildFile) {
+        if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) {
             return;
         }
 
+        smithyFile.reparse();
         if (!onlyReloadOnSave) {
             Project project = projectAndFile.project();
 
@@ -713,7 +714,7 @@ public CompletableFuture<Hover> hover(HoverParams params) {
         Project project = projectAndFile.project();
 
         // TODO: Abstract away passing minimum severity
-        Hover hover = new HoverHandler(project, smithyFile).handle(params, minimumSeverity);
+        Hover hover = new HoverHandler(project, smithyFile, minimumSeverity).handle(params);
         return completedFuture(hover);
     }
 
diff --git a/src/main/java/software/amazon/smithy/lsp/document/Document.java b/src/main/java/software/amazon/smithy/lsp/document/Document.java
index 75ee0e15..365af4c9 100644
--- a/src/main/java/software/amazon/smithy/lsp/document/Document.java
+++ b/src/main/java/software/amazon/smithy/lsp/document/Document.java
@@ -24,7 +24,7 @@ public final class Document {
     private final StringBuilder buffer;
     private int[] lineIndices;
 
-    private Document(StringBuilder buffer, int[] lineIndices) {
+    private Document(StringBuilder buffer, int[] lineIndices, int changeVersion) {
         this.buffer = buffer;
         this.lineIndices = lineIndices;
     }
@@ -36,14 +36,14 @@ private Document(StringBuilder buffer, int[] lineIndices) {
     public static Document of(String string) {
         StringBuilder buffer = new StringBuilder(string);
         int[] lineIndicies = computeLineIndicies(buffer);
-        return new Document(buffer, lineIndicies);
+        return new Document(buffer, lineIndicies, 0);
     }
 
     /**
      * @return A copy of this document
      */
     public Document copy() {
-        return new Document(new StringBuilder(copyText()), lineIndices.clone());
+        return new Document(new StringBuilder(copyText()), lineIndices.clone(), 0);
     }
 
     /**
@@ -97,20 +97,31 @@ public int indexOfLine(int line) {
      *  doesn't exist
      */
     public int lineOfIndex(int idx) {
-        // TODO: Use binary search or similar
-        if (idx >= length() || idx < 0) {
-            return -1;
-        }
-
-        for (int line = 0; line <= lastLine() - 1; line++) {
-            int currentLineIdx = indexOfLine(line);
-            int nextLineIdx = indexOfLine(line + 1);
-            if (idx >= currentLineIdx && idx < nextLineIdx) {
-                return line;
+        int low = 0;
+        int up = lastLine();
+
+        while (low <= up) {
+            int mid = (low + up) / 2;
+            int midLineIdx = lineIndices[mid];
+            int midLineEndIdx = lineEndUnchecked(mid);
+            if (idx >= midLineIdx && idx <= midLineEndIdx) {
+                return mid;
+            } else if (idx < midLineIdx) {
+                up = mid - 1;
+            } else {
+                low = mid + 1;
             }
         }
 
-        return lastLine();
+        return -1;
+    }
+
+    private int lineEndUnchecked(int line) {
+        if (line == lastLine()) {
+            return length() - 1;
+        } else {
+            return lineIndices[line + 1] - 1;
+        }
     }
 
     /**
@@ -167,6 +178,34 @@ public Position positionAtIndex(int index) {
         return new Position(line, character);
     }
 
+    /**
+     * @param start The start character offset
+     * @param end The end character offset
+     * @return The range between the two given offsets
+     */
+    public Range rangeBetween(int start, int end) {
+        if (end < start || start < 0) {
+            return null;
+        }
+
+        // The start is inclusive, so it should be within the bounds of the document
+        Position startPos = positionAtIndex(start);
+        if (startPos == null) {
+            return null;
+        }
+
+        Position endPos;
+        if (end == length()) {
+            int lastLine = lastLine();
+            int lastCol = length() - lineIndices[lastLine];
+            endPos = new Position(lastLine, lastCol);
+        } else {
+            endPos = positionAtIndex(end);
+        }
+
+        return new Range(startPos, endPos);
+    }
+
     /**
      * @param line The line to find the end of
      * @return The index of the end of the given line, or {@code -1} if the
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java
index ec7c5f39..f20dd67b 100644
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java
+++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentId.java
@@ -51,4 +51,15 @@ public enum Type {
     public String copyIdValue() {
         return idSlice.toString();
     }
+
+    /**
+     * @return The value of the id without a leading '$'
+     */
+    public String copyIdValueForElidedMember() {
+        String idValue = copyIdValue();
+        if (idValue.startsWith("$")) {
+            return idValue.substring(1);
+        }
+        return idValue;
+    }
 }
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java
index 3982447b..e3cc49f9 100644
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java
+++ b/src/main/java/software/amazon/smithy/lsp/document/DocumentParser.java
@@ -7,37 +7,28 @@
 
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.lsp4j.Position;
 import org.eclipse.lsp4j.Range;
 import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.lsp.syntax.SyntaxSearch;
 import software.amazon.smithy.model.SourceLocation;
-import software.amazon.smithy.model.loader.ParserUtils;
-import software.amazon.smithy.model.node.Node;
-import software.amazon.smithy.model.node.StringNode;
-import software.amazon.smithy.model.shapes.Shape;
-import software.amazon.smithy.utils.SimpleParser;
 
 /**
- * 'Parser' that uses the line-indexed property of the underlying {@link Document}
- * to jump around the document, parsing small pieces without needing to start at
- * the beginning.
- *
- * <p>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.
- *
- * <p>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 {
+public final class DocumentParser {
     private final Document document;
+    private final List<Syntax.Statement> statements;
 
-    private DocumentParser(Document document) {
-        super(document.borrowText());
+    private DocumentParser(Document document, List<Syntax.Statement> statements) {
         this.document = document;
+        this.statements = statements;
     }
 
     static DocumentParser of(String text) {
@@ -49,7 +40,17 @@ static DocumentParser of(String text) {
      * @return A parser for the given document
      */
     public static DocumentParser forDocument(Document document) {
-        return new DocumentParser(document);
+        Syntax.IdlParse parse = Syntax.parseIdl(document);
+        return new DocumentParser(document, parse.statements());
+    }
+
+    /**
+     * @param document Document to create a parser for
+     * @param statements The statements the parser should use
+     * @return The parser for the given document and statements
+     */
+    public static DocumentParser forStatements(Document document, List<Syntax.Statement> statements) {
+        return new DocumentParser(document, statements);
     }
 
     /**
@@ -57,48 +58,14 @@ public static DocumentParser forDocument(Document document) {
      *  {@code null} if it couldn't be found
      */
     public DocumentNamespace documentNamespace() {
-        int namespaceStartIdx = firstIndexOfWithOnlyLeadingWs("namespace");
-        if (namespaceStartIdx < 0) {
-            return null;
-        }
-
-        Position namespaceStatementStartPosition = document.positionAtIndex(namespaceStartIdx);
-        if (namespaceStatementStartPosition == null) {
-            // Shouldn't happen on account of the previous check
-            return null;
-        }
-        jumpToPosition(namespaceStatementStartPosition);
-        skip(); // n
-        skip(); // a
-        skip(); // m
-        skip(); // e
-        skip(); // s
-        skip(); // p
-        skip(); // a
-        skip(); // c
-        skip(); // e
-
-        if (!isSp()) {
-            return null;
-        }
-
-        sp();
-
-        if (!isNamespaceChar()) {
-            return null;
-        }
-
-        int start = position();
-        while (isNamespaceChar()) {
-            skip();
+        for (Syntax.Statement statement : statements) {
+            if (statement instanceof Syntax.Statement.Namespace namespace) {
+                Range range = namespace.rangeIn(document);
+                String namespaceValue = namespace.namespace().copyValueFrom(document);
+                return new DocumentNamespace(range, namespaceValue);
+            }
         }
-        int end = position();
-        CharSequence namespace = document.borrowSpan(start, end);
-
-        consumeRemainingCharactersOnLine();
-        Position namespaceStatementEnd = currentPosition();
-
-        return new DocumentNamespace(new Range(namespaceStatementStartPosition, namespaceStatementEnd), namespace);
+        return null;
     }
 
     /**
@@ -106,158 +73,95 @@ public DocumentNamespace documentNamespace() {
      *  {@code null} if they couldn't be found
      */
     public DocumentImports documentImports() {
-        // TODO: What if its 'uses', not just 'use'?
-        //  Should we look for another?
-        int firstUseStartIdx = firstIndexOfWithOnlyLeadingWs("use");
-        if (firstUseStartIdx < 0) {
-            // No use
-            return null;
-        }
-
-        Position firstUsePosition = document.positionAtIndex(firstUseStartIdx);
-        if (firstUsePosition == null) {
-            // Shouldn't happen on account of the previous check
-            return null;
-        }
-        rewind(firstUseStartIdx, firstUsePosition.getLine() + 1, firstUsePosition.getCharacter() + 1);
-
-        Set<String> imports = new HashSet<>();
-        Position lastUseEnd; // At this point we know there's at least one
-        do {
-            skip(); // u
-            skip(); // s
-            skip(); // e
-
-            String id = getImport(); // handles skipping the ws
-            if (id != null) {
-                imports.add(id);
+        Set<String> imports;
+        for (int i = 0; i < statements.size(); i++) {
+            Syntax.Statement statement = statements.get(i);
+            if (statement instanceof Syntax.Statement.Use firstUse) {
+                imports = new HashSet<>();
+                imports.add(firstUse.use().copyValueFrom(document));
+                Range useRange = firstUse.rangeIn(document);
+                Position start = useRange.getStart();
+                Position end = useRange.getEnd();
+                i++;
+                while (i < statements.size()) {
+                    statement = statements.get(i);
+                    if (statement instanceof Syntax.Statement.Use use) {
+                        imports.add(use.use().copyValueFrom(document));
+                        end = use.rangeIn(document).getEnd();
+                        i++;
+                    } else {
+                        break;
+                    }
+                }
+                return new DocumentImports(new Range(start, end), imports);
             }
-            consumeRemainingCharactersOnLine();
-            lastUseEnd = currentPosition();
-            nextNonWsNonComment();
-        } while (isUse());
-
-        if (imports.isEmpty()) {
-            return null;
         }
-
-        return new DocumentImports(new Range(firstUsePosition, lastUseEnd), imports);
+        return null;
     }
 
     /**
-     * @param shapes The shapes defined in the underlying document
-     * @return A map of the starting positions of shapes defined or referenced
-     *  in the underlying document to their corresponding {@link DocumentShape}
+     * @return A map of start position to {@link DocumentShape} for each shape
+     * and/or shape reference in the document.
      */
-    public Map<Position, DocumentShape> documentShapes(Set<Shape> shapes) {
-        Map<Position, DocumentShape> documentShapes = new HashMap<>(shapes.size());
-        for (Shape shape : shapes) {
-            if (!jumpToSource(shape.getSourceLocation())) {
-                continue;
-            }
-
-            DocumentShape documentShape;
-            if (shape.isMemberShape()) {
-                DocumentShape.Kind kind = DocumentShape.Kind.DefinedMember;
-                if (is('$')) {
-                    kind = DocumentShape.Kind.Elided;
+    public Map<Position, DocumentShape> documentShapes() {
+        Map<Position, DocumentShape> documentShapes = new HashMap<>();
+        for (Syntax.Statement statement : statements) {
+            switch (statement) {
+                case Syntax.Statement.ShapeDef shapeDef -> {
+                    String shapeName = shapeDef.shapeName().copyValueFrom(document);
+                    Range range = shapeDef.shapeName().rangeIn(document);
+                    var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedShape, null);
+                    documentShapes.put(range.getStart(), shape);
+                }
+                case Syntax.Statement.MemberDef memberDef -> {
+                    String shapeName = memberDef.name().copyValueFrom(document);
+                    Range range = memberDef.name().rangeIn(document);
+                    DocumentShape target = null;
+                    if (memberDef.target() != null && !memberDef.target().isEmpty()) {
+                        String targetName = memberDef.target().copyValueFrom(document);
+                        Range targetRange = memberDef.target().rangeIn(document);
+                        target = new DocumentShape(targetRange, targetName, DocumentShape.Kind.Targeted, null);
+                        documentShapes.put(targetRange.getStart(), target);
+                    }
+                    var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedMember, target);
+                    documentShapes.put(range.getStart(), shape);
+                }
+                case Syntax.Statement.ElidedMemberDef elidedMemberDef -> {
+                    String shapeName = elidedMemberDef.name().copyValueFrom(document);
+                    Range range = elidedMemberDef.rangeIn(document);
+                    var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.Elided, null);
+                    documentShapes.put(range.getStart(), shape);
+                }
+                case Syntax.Statement.EnumMemberDef enumMemberDef -> {
+                    String shapeName = enumMemberDef.name().copyValueFrom(document);
+                    Range range = enumMemberDef.rangeIn(document);
+                    var shape = new DocumentShape(range, shapeName, DocumentShape.Kind.DefinedMember, null);
+                    documentShapes.put(range.getStart(), shape);
+                }
+                default -> {
                 }
-                documentShape = documentShape(kind);
-            } else {
-                skipAlpha(); // shape type
-                sp();
-                documentShape = documentShape(DocumentShape.Kind.DefinedShape);
-            }
-
-            documentShapes.put(documentShape.range().getStart(), documentShape);
-            if (documentShape.hasMemberTarget()) {
-                DocumentShape memberTarget = documentShape.targetReference();
-                documentShapes.put(memberTarget.range().getStart(), memberTarget);
             }
         }
         return documentShapes;
     }
 
-    private DocumentShape documentShape(DocumentShape.Kind kind) {
-        Position start = currentPosition();
-        int startIdx = position();
-        if (kind == DocumentShape.Kind.Elided) {
-            skip(); // '$'
-            startIdx = position(); // so the name doesn't contain '$' - we need to match it later
-        }
-        skipIdentifier(); // shape name
-        Position end = currentPosition();
-        int endIdx = position();
-        Range range = new Range(start, end);
-        CharSequence shapeName = document.borrowSpan(startIdx, endIdx);
-
-        // This is a bit ugly, but it avoids intermediate allocations (like a builder would require)
-        DocumentShape targetReference = null;
-        if (kind == DocumentShape.Kind.DefinedMember) {
-            sp();
-            if (is(':')) {
-                skip();
-                sp();
-                targetReference = documentShape(DocumentShape.Kind.Targeted);
-            }
-        } else if (kind == DocumentShape.Kind.DefinedShape && (shapeName == null || shapeName.isEmpty())) {
-            kind = DocumentShape.Kind.Inline;
-        }
-
-        return new DocumentShape(range, shapeName, kind, targetReference);
-    }
-
     /**
      * @return The {@link DocumentVersion} for the underlying document, or
      *  {@code null} if it couldn't be found
      */
     public DocumentVersion documentVersion() {
-        firstIndexOfNonWsNonComment();
-        if (!is('$')) {
-            return null;
-        }
-        while (is('$') && !isVersion()) {
-            // Skip this line
-            if (!jumpToLine(line())) {
-                return null;
+        for (Syntax.Statement statement : statements) {
+            if (statement instanceof Syntax.Statement.Control control
+                && control.value() instanceof Syntax.Node.Str str) {
+                String key = control.key().copyValueFrom(document);
+                if (key.equals("version")) {
+                    String version = str.copyValueFrom(document);
+                    Range range = control.rangeIn(document);
+                    return new DocumentVersion(range, version);
+                }
+            } else if (statement instanceof Syntax.Statement.Namespace) {
+                break;
             }
-            // Skip any ws and docs
-            nextNonWsNonComment();
-        }
-
-        // Found a non-control statement before version.
-        if (!is('$')) {
-            return null;
-        }
-
-        Position start = currentPosition();
-        skip(); // $
-        skipAlpha(); // version
-        sp();
-        if (!is(':')) {
-            return null;
-        }
-        skip(); // ':'
-        sp();
-        int nodeStartCharacter = column() - 1;
-        CharSequence span = document.borrowSpan(position(), document.lineEnd(line() - 1) + 1);
-        if (span == null) {
-            return null;
-        }
-
-        // TODO: Ew
-        Node node;
-        try {
-            node = StringNode.parseJsonWithComments(span.toString());
-        } catch (Exception e) {
-            return null;
-        }
-
-        if (node.isStringNode()) {
-            String version = node.expectStringNode().getValue();
-            int end = nodeStartCharacter + version.length() + 2; // ?
-            Range range = LspAdapter.of(start.getLine(), start.getCharacter(), start.getLine(), end);
-            return new DocumentVersion(range, version);
         }
         return null;
     }
@@ -272,59 +176,18 @@ public DocumentVersion documentVersion() {
      *  or there's no id next to the {@code @}
      */
     public Range traitIdRange(SourceLocation sourceLocation) {
-        if (!jumpToSource(sourceLocation)) {
+        int position = document.indexOfPosition(LspAdapter.toPosition(sourceLocation));
+        int statementIndex = SyntaxSearch.statementIndex(statements, position);
+        if (statementIndex < 0) {
             return null;
         }
 
-        if (!is('@')) {
-            return null;
+        if (statements.get(statementIndex) instanceof Syntax.Statement.TraitApplication traitApplication) {
+            Range range = traitApplication.id().rangeIn(document);
+            range.getStart().setCharacter(range.getStart().getCharacter() - 1); // include @
+            return range;
         }
-
-        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;
-    }
-
-    /**
-     * Jumps the parser location to the given {@code source}.
-     *
-     * @param source The location to jump to. The filename must be the same as
-     *               the underlying document's filename (this is not checked)
-     * @return Whether the parser successfully jumped
-     */
-    public boolean jumpToSource(SourceLocation source) {
-        int idx = this.document.indexOfPosition(source.getLine() - 1, source.getColumn() - 1);
-        if (idx < 0) {
-            return false;
-        }
-        this.rewind(idx, source.getLine(), source.getColumn());
-        return true;
-    }
-
-    /**
-     * @return The current position of the parser
-     */
-    public Position currentPosition() {
-        return new Position(line() - 1, column() - 1);
+        return null;
     }
 
     /**
@@ -333,375 +196,4 @@ public Position currentPosition() {
     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;
-    }
-
-    private boolean isWs() {
-        return isNl() || isSp();
-    }
-
-    private boolean isNl() {
-        return is('\n') || is('\r');
-    }
-
-    private boolean isSp() {
-        return is(' ') || is('\t');
-    }
-
-    private boolean isWs(int offset) {
-        char peeked = peek(offset);
-        return switch (peeked) {
-            case '\n', '\r', ' ', '\t' -> true;
-            default -> false;
-        };
-    }
-
-    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);
-    }
 }
diff --git a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java b/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java
deleted file mode 100644
index e3007332..00000000
--- a/src/main/java/software/amazon/smithy/lsp/document/DocumentPositionContext.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.document;
-
-/**
- * Represents what kind of construct might exist at a certain position in a document.
- */
-public enum DocumentPositionContext {
-    /**
-     * Within a trait id, that is anywhere from the {@code @} to the start of the
-     * trait's body, or its end (if there is no trait body).
-     */
-    TRAIT,
-
-    /**
-     * Within the target of a member.
-     */
-    MEMBER_TARGET,
-
-    /**
-     * Within a shape definition, specifically anywhere from the beginning of
-     * the shape type token, and the end of the shape name token. Does not
-     * include members.
-     */
-    SHAPE_DEF,
-
-    /**
-     * Within a mixed in shape, specifically in the {@code []} next to {@code with}.
-     */
-    MIXIN,
-
-    /**
-     * Within the target (shape id) of a {@code use} statement.
-     */
-    USE_TARGET,
-
-    /**
-     * An unknown or indeterminate position.
-     */
-    OTHER
-}
diff --git a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java
deleted file mode 100644
index 874cb048..00000000
--- a/src/main/java/software/amazon/smithy/lsp/handler/CompletionHandler.java
+++ /dev/null
@@ -1,315 +0,0 @@
-/*
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package software.amazon.smithy.lsp.handler;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.BiConsumer;
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-import java.util.stream.Stream;
-import org.eclipse.lsp4j.CompletionContext;
-import org.eclipse.lsp4j.CompletionItem;
-import org.eclipse.lsp4j.CompletionItemKind;
-import org.eclipse.lsp4j.CompletionParams;
-import org.eclipse.lsp4j.CompletionTriggerKind;
-import org.eclipse.lsp4j.Position;
-import org.eclipse.lsp4j.Range;
-import org.eclipse.lsp4j.TextEdit;
-import org.eclipse.lsp4j.jsonrpc.CancelChecker;
-import org.eclipse.lsp4j.jsonrpc.messages.Either;
-import software.amazon.smithy.lsp.document.DocumentId;
-import software.amazon.smithy.lsp.document.DocumentParser;
-import software.amazon.smithy.lsp.document.DocumentPositionContext;
-import software.amazon.smithy.lsp.project.Project;
-import software.amazon.smithy.lsp.project.SmithyFile;
-import software.amazon.smithy.lsp.protocol.LspAdapter;
-import software.amazon.smithy.model.Model;
-import software.amazon.smithy.model.loader.Prelude;
-import software.amazon.smithy.model.shapes.BlobShape;
-import software.amazon.smithy.model.shapes.BooleanShape;
-import software.amazon.smithy.model.shapes.ListShape;
-import software.amazon.smithy.model.shapes.MapShape;
-import software.amazon.smithy.model.shapes.MemberShape;
-import software.amazon.smithy.model.shapes.SetShape;
-import software.amazon.smithy.model.shapes.Shape;
-import software.amazon.smithy.model.shapes.ShapeId;
-import software.amazon.smithy.model.shapes.ShapeVisitor;
-import software.amazon.smithy.model.shapes.StringShape;
-import software.amazon.smithy.model.shapes.StructureShape;
-import software.amazon.smithy.model.shapes.TimestampShape;
-import software.amazon.smithy.model.shapes.UnionShape;
-import software.amazon.smithy.model.traits.MixinTrait;
-import software.amazon.smithy.model.traits.RequiredTrait;
-import software.amazon.smithy.model.traits.TraitDefinition;
-
-/**
- * Handles completion requests.
- */
-public final class CompletionHandler {
-    // TODO: Handle keyword completions
-    private static final List<String> 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<CompletionItem> 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<Model> 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<Shape, Consumer<CompletionItem>> 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<Shape> 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<Shape> 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<String> {
-        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<String> 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<Location> handle(DefinitionParams params) {
-        Position position = params.getPosition();
-        DocumentId id = smithyFile.document().copyDocumentId(position);
-        if (id == null || id.idSlice().isEmpty()) {
-            return Collections.emptyList();
-        }
-
-        Optional<Model> 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<Shape> 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<Shape> 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<Model> modelResult = project.modelResult();
-        if (modelResult.getResult().isEmpty()) {
-            return hover;
-        }
-
-        Model model = modelResult.getResult().get();
-        DocumentPositionContext context = DocumentParser.forDocument(smithyFile.document())
-                .determineContext(position);
-        Optional<Shape> 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<Path, String> serialized = serializer.serialize(model);
-        Path path = Paths.get(shapeToSerialize.getId().getNamespace() + ".smithy");
-        if (!serialized.containsKey(path)) {
-            return hover;
-        }
-
-        StringBuilder hoverContent = new StringBuilder();
-        List<ValidationEvent> 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<Shape> 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<Shape> 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.
+ *
+ * <p>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.</p>
+ *
+ * <p>See the Smithy model for docs on the specific shapes.</p>
+ */
+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<ShapeId, BuiltinShape> 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<String, ShapeId> 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.
+     *
+     * <p>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.</p>
+     */
+    enum BuiltinShape {
+        SmithyIdlVersion,
+        AnyNamespace,
+        ValidatorName,
+        AnyShape,
+        AnyTrait,
+        AnyMixin,
+        AnyString,
+        AnyError,
+        AnyOperation,
+        AnyResource,
+        AnyMemberTarget
+    }
+
+    static Shape getMetadataValue(String metadataKey) {
+        return METADATA.getMember(metadataKey)
+                .map(memberShape -> MODEL.expectShape(memberShape.getTarget()))
+                .orElse(null);
+    }
+
+    static StructureShape getMembersForShapeType(String shapeType) {
+        return SHAPE_MEMBER_TARGETS.getMember(shapeType)
+                .map(memberShape -> MODEL.expectShape(memberShape.getTarget(), StructureShape.class))
+                .orElse(null);
+    }
+
+    static Shape getMemberTargetForShapeType(String shapeType, String memberName) {
+        StructureShape memberTargets = getMembersForShapeType(shapeType);
+        if (memberTargets == null) {
+            return null;
+        }
+
+        return memberTargets.getMember(memberName)
+                .map(memberShape -> MODEL.expectShape(memberShape.getTarget()))
+                .orElse(null);
+    }
+
+    private static ShapeId id(String name) {
+        return ShapeId.fromParts(NAMESPACE, name);
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/Candidates.java b/src/main/java/software/amazon/smithy/lsp/language/Candidates.java
new file mode 100644
index 00000000..f59fa78d
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/Candidates.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import software.amazon.smithy.lsp.util.StreamUtils;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.EnumShape;
+import software.amazon.smithy.model.shapes.IntEnumShape;
+import software.amazon.smithy.model.shapes.ListShape;
+import software.amazon.smithy.model.shapes.MapShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.traits.DefaultTrait;
+import software.amazon.smithy.model.traits.IdRefTrait;
+
+/**
+ * Candidates for code-completions.
+ *
+ * <p>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.</p>
+ */
+sealed interface Candidates {
+    Constant NONE = new Constant("");
+    Constant EMPTY_STRING = new Constant("\"\"");
+    Constant EMPTY_OBJ = new Constant("{}");
+    Constant EMPTY_ARR = new Constant("[]");
+    Literals BOOL = new Literals(List.of("true", "false"));
+    Literals KEYWORD = new Literals(List.of(
+            "metadata", "namespace", "use",
+            "blob", "boolean", "string", "byte", "short", "integer", "long", "float", "double",
+            "bigInteger", "bigDecimal", "timestamp", "document", "enum", "intEnum",
+            "list", "map", "structure", "union",
+            "service", "resource", "operation",
+            "apply"));
+    // TODO: Maybe BUILTIN_CONTROLS and BUILTIN_METADATA should be regular
+    //  Labeled/Members, with custom mappers.
+    Literals BUILTIN_CONTROLS = new Candidates.Literals(
+            Builtins.CONTROL.members().stream()
+                    .map(member -> "$" + member.getMemberName() + ": " + Candidates.defaultCandidates(member).value())
+                    .toList());
+    Literals BUILTIN_METADATA = new Candidates.Literals(
+            Builtins.METADATA.members().stream()
+                    .map(member -> member.getMemberName() + " = []")
+                    .toList());
+    Labeled SMITHY_IDL_VERSION = new Labeled(Stream.of("1.0", "2.0")
+            .collect(StreamUtils.toWrappedMap()));
+    Labeled VALIDATOR_NAMES = new Labeled(Builtins.VALIDATOR_CONFIG_MAPPING.keySet().stream()
+            .collect(StreamUtils.toWrappedMap()));
+
+    /**
+     * @apiNote This purposefully does not handle {@link software.amazon.smithy.lsp.language.Builtins.BuiltinShape}
+     * as it is meant to be used for member target default values.
+     *
+     * @param shape The shape to get candidates for.
+     * @return A constant value corresponding to the 'default' or 'empty' value
+     *  of a shape.
+     */
+    static Candidates.Constant defaultCandidates(Shape shape) {
+        if (shape.hasTrait(DefaultTrait.class)) {
+            DefaultTrait defaultTrait = shape.expectTrait(DefaultTrait.class);
+            return new Constant(Node.printJson(defaultTrait.toNode()));
+        }
+
+        if (shape.isBlobShape() || (shape.isStringShape() && !shape.hasTrait(IdRefTrait.class))) {
+            return EMPTY_STRING;
+        } else if (ShapeSearch.isObjectShape(shape)) {
+            return EMPTY_OBJ;
+        } else if (shape.isListShape()) {
+            return EMPTY_ARR;
+        } else {
+            return NONE;
+        }
+    }
+
+    /**
+     * @param result The search result to get candidates from.
+     * @return The completion candidates for {@code result}.
+     */
+    static Candidates fromSearchResult(NodeSearch.Result result) {
+        return switch (result) {
+            case NodeSearch.Result.TerminalShape(Shape shape, var ignored) ->
+                    terminalCandidates(shape);
+
+            case NodeSearch.Result.ObjectKey(var ignored, Shape shape, Model model) ->
+                    membersCandidates(model, shape);
+
+            case NodeSearch.Result.ObjectShape(var ignored, Shape shape, Model model) ->
+                    membersCandidates(model, shape);
+
+            case NodeSearch.Result.ArrayShape(var ignored, ListShape shape, Model model) ->
+                    model.getShape(shape.getMember().getTarget())
+                            .map(Candidates::terminalCandidates)
+                            .orElse(NONE);
+
+            default -> NONE;
+        };
+    }
+
+    /**
+     * @param model The model that {@code shape} is a part of.
+     * @param shape The shape to get member candidates for.
+     * @return If a struct or union shape, returns {@link Members} candidates.
+     *  Otherwise, {@link #NONE}.
+     */
+    static Candidates membersCandidates(Model model, Shape shape) {
+        if (shape.isStructureShape() || shape.isUnionShape()) {
+            return new Members(shape.getAllMembers().entrySet().stream()
+                    .collect(Collectors.toMap(
+                            Map.Entry::getKey,
+                            entry -> model.getShape(entry.getValue().getTarget())
+                                        .map(Candidates::defaultCandidates)
+                                        .orElse(NONE))));
+        } else if (shape instanceof MapShape mapShape) {
+            EnumShape enumKey = model.getShape(mapShape.getKey().getTarget())
+                    .flatMap(Shape::asEnumShape)
+                    .orElse(null);
+            if (enumKey != null) {
+                return terminalCandidates(enumKey);
+            }
+        }
+        return NONE;
+    }
+
+    private static Candidates terminalCandidates(Shape shape) {
+        Builtins.BuiltinShape builtinShape = Builtins.BUILTIN_SHAPES.get(shape.getId());
+        if (builtinShape != null) {
+            return forBuiltin(builtinShape);
+        }
+
+        return switch (shape) {
+            case EnumShape enumShape -> new Labeled(enumShape.getEnumValues()
+                    .entrySet()
+                    .stream()
+                    .collect(Collectors.toMap(Map.Entry::getKey, entry -> "\"" + entry.getValue() + "\"")));
+
+            case IntEnumShape intEnumShape -> new Labeled(intEnumShape.getEnumValues()
+                    .entrySet()
+                    .stream()
+                    .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().toString())));
+
+            case Shape s when s.hasTrait(IdRefTrait.class) -> Shapes.ANY_SHAPE;
+
+            case Shape s when s.isBooleanShape() -> BOOL;
+
+            default -> defaultCandidates(shape);
+        };
+    }
+
+    private static Candidates forBuiltin(Builtins.BuiltinShape builtinShape) {
+        return switch (builtinShape) {
+            case SmithyIdlVersion -> SMITHY_IDL_VERSION;
+            case AnyNamespace -> Custom.NAMESPACE_FILTER;
+            case ValidatorName -> Custom.VALIDATOR_NAME;
+            case AnyShape -> Shapes.ANY_SHAPE;
+            case AnyTrait -> Shapes.TRAITS;
+            case AnyMixin -> Shapes.MIXINS;
+            case AnyString -> Shapes.STRING_SHAPES;
+            case AnyError -> Shapes.ERROR_SHAPES;
+            case AnyOperation -> Shapes.OPERATION_SHAPES;
+            case AnyResource -> Shapes.RESOURCE_SHAPES;
+            case AnyMemberTarget -> Shapes.MEMBER_TARGETABLE;
+        };
+    }
+
+    /**
+     * A single, constant-value completion, like an empty string, for example.
+     *
+     * @param value The completion value.
+     */
+    record Constant(String value) implements Candidates {}
+
+    /**
+     * Multiple values to be completed as literals, like keywords.
+     *
+     * @param literals The completion values.
+     */
+    record Literals(List<String> literals) implements Candidates {}
+
+    /**
+     * Multiple label -> value pairs, where the label is displayed to the user,
+     * and may be used for matching, and the value is the literal text to complete.
+     *
+     * @param labeled The labeled completion values.
+     */
+    record Labeled(Map<String, String> labeled) implements Candidates {}
+
+    /**
+     * Multiple name -> constant pairs, where the name corresponds to a member
+     * name, and the constant is a default/empty value for that member.
+     *
+     * @param members The members completion values.
+     */
+    record Members(Map<String, Candidates.Constant> members) implements Candidates {}
+
+    /**
+     * Multiple member names to complete as elided members.
+     * @apiNote These are distinct from {@link Literals} because they may have
+     * custom filtering/mapping, and may appear _with_ {@link Literals} in an
+     * {@link And}.
+     *
+     * @param memberNames The member names completion values.
+     */
+    record ElidedMembers(Collection<String> memberNames) implements Candidates {}
+
+    /**
+     * A combination of two sets of completion candidates, of possibly different
+     * types.
+     *
+     * @param one The first set of completion candidates.
+     * @param two The second set of completion candidates.
+     */
+    record And(Candidates one, Candidates two) implements Candidates {}
+
+    /**
+     * Shape completion candidates, each corresponding to a different set of
+     * shapes that will be selected from the model.
+     */
+    enum Shapes implements Candidates {
+        ANY_SHAPE,
+        USE_TARGET,
+        TRAITS,
+        MIXINS,
+        STRING_SHAPES,
+        ERROR_SHAPES,
+        RESOURCE_SHAPES,
+        OPERATION_SHAPES,
+        MEMBER_TARGETABLE
+    }
+
+    /**
+     * Candidates that require a custom computation to generate, lazily.
+     */
+    enum Custom implements Candidates {
+        NAMESPACE_FILTER,
+        VALIDATOR_NAME,
+        PROJECT_NAMESPACES,
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java
new file mode 100644
index 00000000..182e1f10
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java
@@ -0,0 +1,296 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.lsp4j.CompletionContext;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionParams;
+import org.eclipse.lsp4j.CompletionTriggerKind;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.jsonrpc.CancelChecker;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.lsp.syntax.SyntaxSearch;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.StructureShape;
+
+/**
+ * Handles completion requests for the Smithy IDL.
+ */
+public final class CompletionHandler {
+    private final Project project;
+    private final SmithyFile smithyFile;
+
+    public CompletionHandler(Project project, SmithyFile smithyFile) {
+        this.project = project;
+        this.smithyFile = smithyFile;
+    }
+
+    /**
+     * @param params The request params
+     * @return A list of possible completions
+     */
+    public List<CompletionItem> handle(CompletionParams params, CancelChecker cc) {
+        // TODO: This method has to check for cancellation before using shared resources,
+        //  and before performing expensive operations. If we have to change this, or do
+        //  the same type of thing elsewhere, it would be nice to have some type of state
+        //  machine abstraction or similar to make sure cancellation is properly checked.
+        if (cc.isCanceled()) {
+            return Collections.emptyList();
+        }
+
+        Position position = getTokenPosition(params);
+        DocumentId id = smithyFile.document().copyDocumentId(position);
+        Range insertRange = getInsertRange(id, position);
+
+        if (cc.isCanceled()) {
+            return Collections.emptyList();
+        }
+
+        IdlPosition idlPosition = IdlPosition.at(smithyFile, position).orElse(null);
+
+        if (cc.isCanceled() || idlPosition == null) {
+            return Collections.emptyList();
+        }
+
+        SimpleCompletions.Builder builder = SimpleCompletions.builder(id, insertRange).project(project);
+
+        return switch (idlPosition) {
+            case IdlPosition.ControlKey ignored -> builder
+                    .literalKind(CompletionItemKind.Constant)
+                    .buildSimpleCompletions()
+                    .getCompletionItems(Candidates.BUILTIN_CONTROLS);
+
+            case IdlPosition.MetadataKey ignored -> builder
+                    .literalKind(CompletionItemKind.Field)
+                    .buildSimpleCompletions()
+                    .getCompletionItems(Candidates.BUILTIN_METADATA);
+
+            case IdlPosition.StatementKeyword ignored -> builder
+                    .literalKind(CompletionItemKind.Keyword)
+                    .buildSimpleCompletions()
+                    .getCompletionItems(Candidates.KEYWORD);
+
+            case IdlPosition.Namespace ignored -> builder
+                    .literalKind(CompletionItemKind.Module)
+                    .buildSimpleCompletions()
+                    .getCompletionItems(Candidates.Custom.PROJECT_NAMESPACES);
+
+            case IdlPosition.MetadataValue metadataValue -> metadataValueCompletions(metadataValue, builder);
+
+            case IdlPosition.MemberName memberName -> memberNameCompletions(memberName, builder);
+
+            default -> modelBasedCompletions(idlPosition, builder);
+        };
+    }
+
+    private static Position getTokenPosition(CompletionParams params) {
+        Position position = params.getPosition();
+        CompletionContext context = params.getContext();
+        if (context != null
+            && context.getTriggerKind() == CompletionTriggerKind.Invoked
+            && position.getCharacter() > 0) {
+            position.setCharacter(position.getCharacter() - 1);
+        }
+        return position;
+    }
+
+    private static Range getInsertRange(DocumentId id, Position position) {
+        if (id == null || id.idSlice().isEmpty()) {
+            // TODO: This is confusing
+            // When we receive the completion request, we're always on the
+            // character either after what has just been typed, or we're in
+            // empty space and have manually triggered a completion. To account
+            // for this when extracting the DocumentId the cursor is on, we move
+            // the cursor back one. But when we're not on a DocumentId (as is the case here),
+            // we want to insert any completion text at the current cursor position.
+            Position point = new Position(position.getLine(), position.getCharacter() + 1);
+            return LspAdapter.point(point);
+        }
+        return id.range();
+    }
+
+    private List<CompletionItem> metadataValueCompletions(
+            IdlPosition.MetadataValue metadataValue,
+            SimpleCompletions.Builder builder
+    ) {
+        var result = ShapeSearch.searchMetadataValue(metadataValue);
+        Set<String> excludeKeys = getOtherPresentKeys(result);
+        Candidates candidates = Candidates.fromSearchResult(result);
+        return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates);
+    }
+
+    private Set<String> getOtherPresentKeys(NodeSearch.Result result) {
+        Syntax.Node.Kvps terminalContainer;
+        NodeCursor.Key terminalKey;
+        switch (result) {
+            case NodeSearch.Result.ObjectShape obj -> {
+                terminalContainer = obj.node();
+                terminalKey = null;
+            }
+            case NodeSearch.Result.ObjectKey key -> {
+                terminalContainer = key.key().parent();
+                terminalKey = key.key();
+            }
+            default -> {
+                return null;
+            }
+        }
+
+        Set<String> ignoreKeys = new HashSet<>();
+        terminalContainer.kvps().forEach(kvp -> {
+            String key = kvp.key().copyValueFrom(smithyFile.document());
+            ignoreKeys.add(key);
+        });
+
+        if (terminalKey != null) {
+            ignoreKeys.remove(terminalKey.name());
+        }
+
+        return ignoreKeys;
+    }
+
+    private List<CompletionItem> modelBasedCompletions(IdlPosition idlPosition, SimpleCompletions.Builder builder) {
+        if (project.modelResult().getResult().isEmpty()) {
+            return List.of();
+        }
+
+        Model model = project.modelResult().getResult().get();
+
+        if (idlPosition instanceof IdlPosition.ElidedMember elidedMember) {
+            return elidedMemberCompletions(elidedMember, model, builder);
+        } else if (idlPosition instanceof IdlPosition.TraitValue traitValue) {
+            return traitValueCompletions(traitValue, model, builder);
+        }
+
+        Candidates candidates = shapeCandidates(idlPosition);
+        if (candidates instanceof Candidates.Shapes shapes) {
+            return builder.buildShapeCompletions(idlPosition, model).getCompletionItems(shapes);
+        } else if (candidates != Candidates.NONE) {
+            return builder.buildSimpleCompletions().getCompletionItems(candidates);
+        }
+
+        return List.of();
+    }
+
+    private List<CompletionItem> elidedMemberCompletions(
+            IdlPosition.ElidedMember elidedMember,
+            Model model,
+            SimpleCompletions.Builder builder
+    ) {
+        Candidates candidates = getElidableMemberCandidates(elidedMember.statementIndex(), model);
+        if (candidates == null) {
+            return List.of();
+        }
+
+        Set<String> otherMembers = SyntaxSearch.otherMemberNames(
+                elidedMember.smithyFile().document(),
+                elidedMember.smithyFile().statements(),
+                elidedMember.statementIndex());
+        return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates);
+    }
+
+    private List<CompletionItem> traitValueCompletions(
+            IdlPosition.TraitValue traitValue,
+            Model model,
+            SimpleCompletions.Builder builder
+    ) {
+        var result = ShapeSearch.searchTraitValue(traitValue, model);
+        Set<String> excludeKeys = getOtherPresentKeys(result);
+        Candidates candidates = Candidates.fromSearchResult(result);
+        return builder.exclude(excludeKeys).buildSimpleCompletions().getCompletionItems(candidates);
+    }
+
+    private Candidates shapeCandidates(IdlPosition idlPosition) {
+        return switch (idlPosition) {
+            case IdlPosition.UseTarget ignored -> Candidates.Shapes.USE_TARGET;
+            case IdlPosition.TraitId ignored -> Candidates.Shapes.TRAITS;
+            case IdlPosition.Mixin ignored -> Candidates.Shapes.MIXINS;
+            case IdlPosition.ForResource ignored -> Candidates.Shapes.RESOURCE_SHAPES;
+            case IdlPosition.MemberTarget ignored -> Candidates.Shapes.MEMBER_TARGETABLE;
+            case IdlPosition.ApplyTarget ignored -> Candidates.Shapes.ANY_SHAPE;
+            case IdlPosition.NodeMemberTarget nodeMemberTarget -> Candidates.fromSearchResult(
+                    ShapeSearch.searchNodeMemberTarget(nodeMemberTarget));
+            default -> Candidates.NONE;
+        };
+    }
+
+    private List<CompletionItem> memberNameCompletions(
+            IdlPosition.MemberName memberName,
+            SimpleCompletions.Builder builder
+    ) {
+        Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefBeforeMember(
+                smithyFile.statements(),
+                memberName.statementIndex());
+
+        if (shapeDef == null) {
+            return List.of();
+        }
+
+        String shapeType = shapeDef.shapeType().copyValueFrom(smithyFile.document());
+        StructureShape shapeMembersDef = Builtins.getMembersForShapeType(shapeType);
+
+        Candidates candidates = null;
+        if (shapeMembersDef != null) {
+            candidates = Candidates.membersCandidates(Builtins.MODEL, shapeMembersDef);
+        }
+
+        if (project.modelResult().getResult().isPresent()) {
+            Candidates elidedCandidates = getElidableMemberCandidates(
+                    memberName.statementIndex(),
+                    project.modelResult().getResult().get());
+
+            if (elidedCandidates != null) {
+                candidates = candidates == null
+                        ? elidedCandidates
+                        : new Candidates.And(candidates, elidedCandidates);
+            }
+        }
+
+        if (candidates == null) {
+            return List.of();
+        }
+
+        Set<String> otherMembers = SyntaxSearch.otherMemberNames(
+                smithyFile.document(),
+                smithyFile.statements(),
+                memberName.statementIndex());
+        return builder.exclude(otherMembers).buildSimpleCompletions().getCompletionItems(candidates);
+    }
+
+    private Candidates getElidableMemberCandidates(int statementIndex, Model model) {
+        var resourceAndMixins = ShapeSearch.findForResourceAndMixins(
+                SyntaxSearch.closestForResourceAndMixinsBeforeMember(smithyFile.statements(), statementIndex),
+                smithyFile,
+                model);
+
+        Set<String> memberNames = new HashSet<>();
+
+        if (resourceAndMixins.resource() != null) {
+            memberNames.addAll(resourceAndMixins.resource().getIdentifiers().keySet());
+            memberNames.addAll(resourceAndMixins.resource().getProperties().keySet());
+        }
+
+        resourceAndMixins.mixins()
+                .forEach(mixinShape -> memberNames.addAll(mixinShape.getMemberNames()));
+
+        if (memberNames.isEmpty()) {
+            return null;
+        }
+
+        return new Candidates.ElidedMembers(memberNames);
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java
new file mode 100644
index 00000000..9986f1f4
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/DefinitionHandler.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.lsp4j.DefinitionParams;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.model.Model;
+
+/**
+ * Handles go-to-definition requests for the Smithy IDL.
+ */
+public final class DefinitionHandler {
+    final Project project;
+    final SmithyFile smithyFile;
+
+    public DefinitionHandler(Project project, SmithyFile smithyFile) {
+        this.project = project;
+        this.smithyFile = smithyFile;
+    }
+
+    /**
+     * @param params The request params
+     * @return A list of possible definition locations
+     */
+    public List<Location> handle(DefinitionParams params) {
+        Position position = params.getPosition();
+        DocumentId id = smithyFile.document().copyDocumentId(position);
+        if (id == null || id.idSlice().isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Optional<Model> modelResult = project.modelResult().getResult();
+        if (modelResult.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Model model = modelResult.get();
+        return IdlPosition.at(smithyFile, position)
+                .flatMap(idlPosition -> ShapeSearch.findShapeDefinition(idlPosition, model, id))
+                .map(LspAdapter::toLocation)
+                .map(List::of)
+                .orElse(List.of());
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java
new file mode 100644
index 00000000..e133c21b
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/DynamicMemberTarget.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Map;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * An abstraction to allow computing the target of a member dynamically, instead
+ * of just using what's in the model, when traversing a model using a
+ * {@link NodeCursor}.
+ *
+ * <p>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.</p>
+ *
+ * @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<ShapeId, DynamicMemberTarget> forTrait(Shape traitShape, IdlPosition.TraitValue traitValue) {
+        SmithyFile smithyFile = traitValue.smithyFile();
+        return switch (traitShape.getId().toString()) {
+            case "smithy.test#smokeTests" -> Map.of(
+                    ShapeId.from("smithy.test#SmokeTestCase$params"),
+                        new OperationInput(traitValue),
+                    ShapeId.from("smithy.test#SmokeTestCase$vendorParams"),
+                        new ShapeIdDependent("vendorParamsShape", smithyFile));
+
+            case "smithy.api#examples" -> Map.of(
+                    ShapeId.from("smithy.api#Example$input"),
+                        new OperationInput(traitValue),
+                    ShapeId.from("smithy.api#Example$output"),
+                        new OperationOutput(traitValue));
+
+            case "smithy.test#httpRequestTests" -> Map.of(
+                    ShapeId.from("smithy.test#HttpRequestTestCase$params"),
+                        new OperationInput(traitValue),
+                    ShapeId.from("smithy.test#HttpRequestTestCase$vendorParams"),
+                        new ShapeIdDependent("vendorParamsShape", smithyFile));
+
+            case "smithy.test#httpResponseTests" -> Map.of(
+                    ShapeId.from("smithy.test#HttpResponseTestCase$params"),
+                        new OperationOutput(traitValue),
+                    ShapeId.from("smithy.test#HttpResponseTestCase$vendorParams"),
+                        new ShapeIdDependent("vendorParamsShape", smithyFile));
+
+            default -> null;
+        };
+    }
+
+    static Map<ShapeId, DynamicMemberTarget> forMetadata(String metadataKey, SmithyFile smithyFile) {
+        return switch (metadataKey) {
+            case "validators" -> Map.of(
+                    ShapeId.from("smithy.lang.server#Validator$configuration"), new MappedDependent(
+                            "name",
+                            smithyFile.document(),
+                            Builtins.VALIDATOR_CONFIG_MAPPING));
+            default -> null;
+        };
+    }
+
+    /**
+     * Computes the input shape of the operation targeted by {@code traitValue},
+     * to use as the member target.
+     *
+     * @param traitValue The position, in the applied trait value.
+     */
+    record OperationInput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget {
+        @Override
+        public Shape getTarget(NodeCursor cursor, Model model) {
+            return ShapeSearch.findTraitTarget(traitValue, model)
+                    .flatMap(Shape::asOperationShape)
+                    .flatMap(operationShape -> model.getShape(operationShape.getInputShape()))
+                    .orElse(null);
+        }
+    }
+
+    /**
+     * Computes the output shape of the operation targeted by {@code traitValue},
+     * to use as the member target.
+     *
+     * @param traitValue The position, in the applied trait value.
+     */
+    record OperationOutput(IdlPosition.TraitValue traitValue) implements DynamicMemberTarget {
+        @Override
+        public Shape getTarget(NodeCursor cursor, Model model) {
+            return ShapeSearch.findTraitTarget(traitValue, model)
+                    .flatMap(Shape::asOperationShape)
+                    .flatMap(operationShape -> model.getShape(operationShape.getOutputShape()))
+                    .orElse(null);
+        }
+    }
+
+    /**
+     * Computes the value of another member in the node, {@code memberName},
+     * using that as the id of the target shape.
+     *
+     * @param memberName The name of the other member to compute the value of.
+     * @param smithyFile The file the node is within.
+     */
+    record ShapeIdDependent(String memberName, SmithyFile smithyFile) implements DynamicMemberTarget {
+        @Override
+        public Shape getTarget(NodeCursor cursor, Model model) {
+            Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor, smithyFile.document());
+            if (matchingKvp.value() instanceof Syntax.Node.Str str) {
+                String id = str.copyValueFrom(smithyFile.document());
+                return ShapeSearch.findShape(smithyFile, model, id).orElse(null);
+            }
+            return null;
+        }
+    }
+
+    /**
+     * Computes the value of another member in the node, {@code memberName},
+     * and looks up the id of the target shape from {@code mapping} using that
+     * value.
+     *
+     * @param memberName The name of the member to compute the value of.
+     * @param document The document the node is within.
+     * @param mapping A mapping of {@code memberName} values to corresponding
+     *                member target ids.
+     */
+    record MappedDependent(String memberName, Document document, Map<String, ShapeId> mapping)
+            implements DynamicMemberTarget {
+        @Override
+        public Shape getTarget(NodeCursor cursor, Model model) {
+            Syntax.Node.Kvp matchingKvp = findMatchingKvp(memberName, cursor, document);
+            if (matchingKvp.value() instanceof Syntax.Node.Str str) {
+                String value = str.copyValueFrom(document);
+                ShapeId targetId = mapping.get(value);
+                if (targetId != null) {
+                    return model.getShape(targetId).orElse(null);
+                }
+            }
+            return null;
+        }
+    }
+
+    // Note: This is suboptimal in isolation, but it should be called rarely in
+    // comparison to parsing or NodeCursor construction, which are optimized for
+    // speed and memory usage (instead of key lookup), and the number of keys
+    // is assumed to be low in most cases.
+    private static Syntax.Node.Kvp findMatchingKvp(String keyName, NodeCursor cursor, Document document) {
+        // This will be called after skipping a ValueForKey, so that will be previous
+        if (!cursor.hasPrevious()) {
+            // TODO: Log
+            return null;
+        }
+        NodeCursor.Edge edge = cursor.previous();
+        if (edge instanceof NodeCursor.ValueForKey(var ignored, Syntax.Node.Kvps parent)) {
+            for (Syntax.Node.Kvp kvp : parent.kvps()) {
+                String key = kvp.key().copyValueFrom(document);
+                if (!keyName.equals(key)) {
+                    continue;
+                }
+
+                return kvp;
+            }
+        }
+        return null;
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java
new file mode 100644
index 00000000..cabdae6b
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/HoverHandler.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import org.eclipse.lsp4j.Hover;
+import org.eclipse.lsp4j.HoverParams;
+import org.eclipse.lsp4j.MarkupContent;
+import org.eclipse.lsp4j.Position;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer;
+import software.amazon.smithy.model.traits.DocumentationTrait;
+import software.amazon.smithy.model.traits.IdRefTrait;
+import software.amazon.smithy.model.traits.StringTrait;
+import software.amazon.smithy.model.validation.Severity;
+import software.amazon.smithy.model.validation.ValidatedResult;
+import software.amazon.smithy.model.validation.ValidationEvent;
+
+/**
+ * Handles hover requests for the Smithy IDL.
+ */
+public final class HoverHandler {
+    /**
+     * Empty markdown hover content.
+     */
+    public static final Hover EMPTY = new Hover(new MarkupContent("markdown", ""));
+
+    private final Project project;
+    private final SmithyFile smithyFile;
+    private final Severity minimumSeverity;
+
+    /**
+     * @param project Project the hover is in
+     * @param smithyFile Smithy file the hover is in
+     * @param minimumSeverity Minimum severity of validation events to show
+     */
+    public HoverHandler(Project project, SmithyFile smithyFile, Severity minimumSeverity) {
+        this.project = project;
+        this.smithyFile = smithyFile;
+        this.minimumSeverity = minimumSeverity;
+    }
+
+    /**
+     * @param params The request params
+     * @return The hover content
+     */
+    public Hover handle(HoverParams params) {
+        Position position = params.getPosition();
+        DocumentId id = smithyFile.document().copyDocumentId(position);
+        if (id == null || id.idSlice().isEmpty()) {
+            return EMPTY;
+        }
+
+        IdlPosition idlPosition = IdlPosition.at(smithyFile, position).orElse(null);
+
+        return switch (idlPosition) {
+            case IdlPosition.ControlKey ignored -> Builtins.CONTROL.getMember(id.copyIdValueForElidedMember())
+                    .map(HoverHandler::withShapeDocs)
+                    .orElse(EMPTY);
+
+            case IdlPosition.MetadataKey ignored -> Builtins.METADATA.getMember(id.copyIdValue())
+                    .map(HoverHandler::withShapeDocs)
+                    .orElse(EMPTY);
+
+            case IdlPosition.MetadataValue metadataValue -> takeShapeReference(
+                            ShapeSearch.searchMetadataValue(metadataValue))
+                    .map(HoverHandler::withShapeDocs)
+                    .orElse(EMPTY);
+
+            case null -> EMPTY;
+
+            default -> modelSensitiveHover(id, idlPosition);
+        };
+    }
+
+    private static Optional<? extends Shape> takeShapeReference(NodeSearch.Result result) {
+        return switch (result) {
+            case NodeSearch.Result.TerminalShape(Shape shape, var ignored)
+                    when shape.hasTrait(IdRefTrait.class) -> Optional.of(shape);
+
+            case NodeSearch.Result.ObjectKey(NodeCursor.Key key, Shape containerShape, var ignored)
+                    when !containerShape.isMapShape() -> containerShape.getMember(key.name());
+
+            default -> Optional.empty();
+        };
+    }
+
+    private Hover modelSensitiveHover(DocumentId id, IdlPosition idlPosition) {
+        ValidatedResult<Model> validatedModel = project.modelResult();
+        if (validatedModel.getResult().isEmpty()) {
+            return EMPTY;
+        }
+
+        Model model = validatedModel.getResult().get();
+        Optional<? extends Shape> matchingShape = switch (idlPosition) {
+            // TODO: Handle resource ids and properties. This only works for mixins right now.
+            case IdlPosition.ElidedMember elidedMember ->
+                    ShapeSearch.findElidedMemberParent(elidedMember, model, id)
+                            .flatMap(shape -> shape.getMember(id.copyIdValueForElidedMember()));
+
+            default -> ShapeSearch.findShapeDefinition(idlPosition, model, id);
+        };
+
+        if (matchingShape.isEmpty()) {
+            return EMPTY;
+        }
+
+        return withShapeAndValidationEvents(matchingShape.get(), model, validatedModel.getValidationEvents());
+    }
+
+    private Hover withShapeAndValidationEvents(Shape shape, Model model, List<ValidationEvent> 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<ValidationEvent> events, Shape shape) {
+        StringBuilder serialized = new StringBuilder();
+        List<ValidationEvent> 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<Path, String> serialized = serializer.serialize(model);
+        Path path = Paths.get(shape.getId().getNamespace() + ".smithy");
+        if (!serialized.containsKey(path)) {
+            return null;
+        }
+
+        String serializedShape = serialized.get(path)
+                .substring(15) // remove '$version: "2.0"'
+                .trim()
+                .replaceAll(Matcher.quoteReplacement(
+                        // Replace newline literals with actual newlines
+                        System.lineSeparator() + System.lineSeparator()), System.lineSeparator());
+        return serializedShape;
+    }
+
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java
new file mode 100644
index 00000000..84b9d6eb
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/IdlPosition.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Optional;
+import org.eclipse.lsp4j.Position;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.lsp.syntax.SyntaxSearch;
+
+sealed interface IdlPosition {
+    default boolean isEasyShapeReference() {
+        return switch (this) {
+            case TraitId ignored -> true;
+            case MemberTarget ignored -> true;
+            case ShapeDef ignored -> true;
+            case ForResource ignored -> true;
+            case Mixin ignored -> true;
+            case UseTarget ignored -> true;
+            case ApplyTarget ignored -> true;
+            default -> false;
+        };
+    }
+
+    SmithyFile smithyFile();
+
+    record TraitId(SmithyFile smithyFile) implements IdlPosition {}
+
+    record MemberTarget(SmithyFile smithyFile) implements IdlPosition {}
+
+    record ShapeDef(SmithyFile smithyFile) implements IdlPosition {}
+
+    record Mixin(SmithyFile smithyFile) implements IdlPosition {}
+
+    record ApplyTarget(SmithyFile smithyFile) implements IdlPosition {}
+
+    record UseTarget(SmithyFile smithyFile) implements IdlPosition {}
+
+    record Namespace(SmithyFile smithyFile) implements IdlPosition {}
+
+    record TraitValue(
+            int documentIndex,
+            int statementIndex,
+            Syntax.Statement.TraitApplication traitApplication,
+            SmithyFile smithyFile
+    ) implements IdlPosition {}
+
+    record NodeMemberTarget(
+            int documentIndex,
+            int statementIndex,
+            Syntax.Statement.NodeMemberDef nodeMemberDef,
+            SmithyFile smithyFile
+    ) implements IdlPosition {}
+
+    record ControlKey(SmithyFile smithyFile) implements IdlPosition {}
+
+    record MetadataKey(SmithyFile smithyFile) implements IdlPosition {}
+
+    record MetadataValue(
+            int documentIndex,
+            Syntax.Statement.Metadata metadata,
+            SmithyFile smithyFile
+    ) implements IdlPosition {}
+
+    record StatementKeyword(SmithyFile smithyFile) implements IdlPosition {}
+
+    record MemberName(int documentIndex, int statementIndex, SmithyFile smithyFile) implements IdlPosition {}
+
+    record ElidedMember(int documentIndex, int statementIndex, SmithyFile smithyFile) implements IdlPosition {}
+
+    record ForResource(SmithyFile smithyFile) implements IdlPosition {}
+
+    static Optional<IdlPosition> at(SmithyFile smithyFile, Position position) {
+        int documentIndex = smithyFile.document().indexOfPosition(position);
+        if (documentIndex < 0) {
+            return Optional.empty();
+        }
+
+        int statementIndex = SyntaxSearch.statementIndex(smithyFile.statements(), documentIndex);
+        if (statementIndex < 0) {
+            return Optional.empty();
+        }
+
+        Syntax.Statement statement = smithyFile.statements().get(statementIndex);
+        IdlPosition idlPosition = switch (statement) {
+            case Syntax.Statement.Incomplete incomplete
+                    when incomplete.ident().isIn(documentIndex) -> new IdlPosition.StatementKeyword(smithyFile);
+
+            case Syntax.Statement.ShapeDef shapeDef
+                    when shapeDef.shapeType().isIn(documentIndex) -> new IdlPosition.StatementKeyword(smithyFile);
+
+            case Syntax.Statement.Apply apply
+                    when apply.id().isIn(documentIndex) -> new IdlPosition.ApplyTarget(smithyFile);
+
+            case Syntax.Statement.Metadata m
+                    when m.key().isIn(documentIndex) -> new IdlPosition.MetadataKey(smithyFile);
+
+            case Syntax.Statement.Metadata m
+                    when m.value() != null && m.value().isIn(documentIndex) -> new IdlPosition.MetadataValue(
+                            documentIndex, m, smithyFile);
+
+            case Syntax.Statement.Control c
+                    when c.key().isIn(documentIndex) -> new IdlPosition.ControlKey(smithyFile);
+
+            case Syntax.Statement.TraitApplication t
+                    when t.id().isEmpty() || t.id().isIn(documentIndex) -> new IdlPosition.TraitId(smithyFile);
+
+            case Syntax.Statement.Use u
+                    when u.use().isIn(documentIndex) -> new IdlPosition.UseTarget(smithyFile);
+
+            case Syntax.Statement.MemberDef m
+                    when m.inTarget(documentIndex) -> new IdlPosition.MemberTarget(smithyFile);
+
+            case Syntax.Statement.MemberDef m
+                    when m.name().isIn(documentIndex) -> new IdlPosition.MemberName(
+                            documentIndex, statementIndex, smithyFile);
+
+            case Syntax.Statement.NodeMemberDef m
+                    when m.inValue(documentIndex) -> new IdlPosition.NodeMemberTarget(
+                            documentIndex, statementIndex, m, smithyFile);
+
+            case Syntax.Statement.Namespace n
+                    when n.namespace().isIn(documentIndex) -> new IdlPosition.Namespace(smithyFile);
+
+            case Syntax.Statement.TraitApplication t
+                    when t.value() != null && t.value().isIn(documentIndex) -> new IdlPosition.TraitValue(
+                            documentIndex, statementIndex, t, smithyFile);
+
+            case Syntax.Statement.ElidedMemberDef ignored -> new IdlPosition.ElidedMember(
+                    documentIndex, statementIndex, smithyFile);
+
+            case Syntax.Statement.Mixins ignored -> new IdlPosition.Mixin(smithyFile);
+
+            case Syntax.Statement.ShapeDef ignored -> new IdlPosition.ShapeDef(smithyFile);
+
+            case Syntax.Statement.NodeMemberDef ignored -> new IdlPosition.MemberName(
+                    documentIndex, statementIndex, smithyFile);
+
+            case Syntax.Statement.Block ignored -> new IdlPosition.MemberName(
+                    documentIndex, statementIndex, smithyFile);
+
+            case Syntax.Statement.ForResource ignored -> new IdlPosition.ForResource(smithyFile);
+
+            default -> null;
+        };
+
+        return Optional.ofNullable(idlPosition);
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java
new file mode 100644
index 00000000..493c80c2
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/NodeSearch.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.Map;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.ListShape;
+import software.amazon.smithy.model.shapes.MapShape;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * Searches models along the path of {@link NodeCursor}s, with support for
+ * dynamically computing member targets via {@link DynamicMemberTarget}.
+ */
+final class NodeSearch {
+    private NodeSearch() {
+    }
+
+    /**
+     * @param cursor The cursor to search along.
+     * @param model The model to search within.
+     * @param startingShape The shape to start the search at.
+     * @return The search result.
+     */
+    static Result search(NodeCursor cursor, Model model, Shape startingShape) {
+        return new DefaultSearch(model).search(cursor, startingShape);
+    }
+
+    /**
+     * @param cursor The cursor to search along.
+     * @param model The model to search within.
+     * @param startingShape The shape to start the search at.
+     * @param dynamicMemberTargets A map of member shape id to dynamic member
+     *                             targets to use for the search.
+     * @return The search result.
+     */
+    static Result search(
+            NodeCursor cursor,
+            Model model,
+            Shape startingShape,
+            Map<ShapeId, DynamicMemberTarget> dynamicMemberTargets
+    ) {
+        if (dynamicMemberTargets == null || dynamicMemberTargets.isEmpty()) {
+            return search(cursor, model, startingShape);
+        }
+
+        return new SearchWithDynamicMemberTargets(model, dynamicMemberTargets).search(cursor, startingShape);
+    }
+
+    /**
+     * The different types of results of a search. The result will be {@link None}
+     * if at any point the cursor doesn't line up with the model (i.e. if the
+     * cursor was an array edge, but in the model we were at a structure shape).
+     *
+     * @apiNote Each result type, besides {@link None}, also includes the model,
+     * because it may be necessary to interpret the results (i.e. if you need
+     * member targets). This is done so that other APIs can wrap {@link NodeSearch}
+     * and callers don't have to know about which model was used in the search
+     * under the hood, or to allow switching the model if necessary during a search.
+     */
+    sealed interface Result {
+        None NONE = new None();
+
+        /**
+         * No result - the path is invalid in the model.
+         */
+        record None() implements Result {}
+
+        /**
+         * The path ended on a shape.
+         *
+         * @param shape The shape at the end of the path.
+         * @param model The model {@code shape} is within.
+         */
+        record TerminalShape(Shape shape, Model model) implements Result {}
+
+        /**
+         * The path ended on a key or member name of an object-like shape.
+         *
+         * @param key The key node the path ended at.
+         * @param containerShape The shape containing the key.
+         * @param model The model {@code containerShape} is within.
+         */
+        record ObjectKey(NodeCursor.Key key, Shape containerShape, Model model) implements Result {}
+
+        /**
+         * The path ended on an object-like shape.
+         *
+         * @param node The node the path ended at.
+         * @param shape The shape at the end of the path.
+         * @param model The model {@code shape} is within.
+         */
+        record ObjectShape(Syntax.Node.Kvps node, Shape shape, Model model) implements Result {}
+
+        /**
+         * The path ended on an array-like shape.
+         *
+         * @param node The node the path ended at.
+         * @param shape The shape at the end of the path.
+         * @param model The model {@code shape} is within.
+         */
+        record ArrayShape(Syntax.Node.Arr node, ListShape shape, Model model) implements Result {}
+    }
+
+    private static sealed class DefaultSearch {
+        protected final Model model;
+
+        private DefaultSearch(Model model) {
+            this.model = model;
+        }
+
+        Result search(NodeCursor cursor, Shape shape) {
+            if (!cursor.hasNext() || shape == null) {
+                return Result.NONE;
+            }
+
+            NodeCursor.Edge edge = cursor.next();
+            return switch (edge) {
+                case NodeCursor.Obj obj
+                        when ShapeSearch.isObjectShape(shape) -> searchObj(cursor, obj, shape);
+
+                case NodeCursor.Arr arr
+                        when shape instanceof ListShape list -> searchArr(cursor, arr, list);
+
+                case NodeCursor.Terminal ignored -> new Result.TerminalShape(shape, model);
+
+                default -> Result.NONE;
+            };
+        }
+
+        private Result searchObj(NodeCursor cursor, NodeCursor.Obj obj, Shape shape) {
+            if (!cursor.hasNext()) {
+                return new Result.ObjectShape(obj.node(), shape, model);
+            }
+
+            return switch (cursor.next()) {
+                case NodeCursor.Terminal ignored -> new Result.ObjectShape(obj.node(), shape, model);
+
+                case NodeCursor.Key key -> new Result.ObjectKey(key, shape, model);
+
+                case NodeCursor.ValueForKey ignored
+                        when shape instanceof MapShape map -> searchTarget(cursor, map.getValue());
+
+                case NodeCursor.ValueForKey value -> shape.getMember(value.keyName())
+                        .map(member -> searchTarget(cursor, member))
+                        .orElse(Result.NONE);
+
+                default -> Result.NONE;
+            };
+        }
+
+        private Result searchArr(NodeCursor cursor, NodeCursor.Arr arr, ListShape shape) {
+            if (!cursor.hasNext()) {
+                return new Result.ArrayShape(arr.node(), shape, model);
+            }
+
+            return switch (cursor.next()) {
+                case NodeCursor.Terminal ignored -> new Result.ArrayShape(arr.node(), shape, model);
+
+                case NodeCursor.Elem ignored -> searchTarget(cursor, shape.getMember());
+
+                default -> Result.NONE;
+            };
+        }
+
+        protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) {
+            return search(cursor, model.getShape(memberShape.getTarget()).orElse(null));
+        }
+    }
+
+    private static final class SearchWithDynamicMemberTargets extends DefaultSearch {
+        private final Map<ShapeId, DynamicMemberTarget> dynamicMemberTargets;
+
+        private SearchWithDynamicMemberTargets(
+                Model model,
+                Map<ShapeId, DynamicMemberTarget> dynamicMemberTargets
+        ) {
+            super(model);
+            this.dynamicMemberTargets = dynamicMemberTargets;
+        }
+
+        @Override
+        protected Result searchTarget(NodeCursor cursor, MemberShape memberShape) {
+            DynamicMemberTarget dynamicMemberTarget = dynamicMemberTargets.get(memberShape.getId());
+            if (dynamicMemberTarget != null) {
+                cursor.setCheckpoint();
+                Shape target = dynamicMemberTarget.getTarget(cursor, model);
+                cursor.returnToCheckpoint();
+                if (target != null) {
+                    return search(cursor, target);
+                }
+            }
+
+            return super.searchTarget(cursor, memberShape);
+        }
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java
new file mode 100644
index 00000000..42571928
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeCompletions.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionItemLabelDetails;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import software.amazon.smithy.lsp.document.DocumentImports;
+import software.amazon.smithy.lsp.document.DocumentNamespace;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeVisitor;
+import software.amazon.smithy.model.shapes.StructureShape;
+import software.amazon.smithy.model.traits.ErrorTrait;
+import software.amazon.smithy.model.traits.MixinTrait;
+import software.amazon.smithy.model.traits.RequiredTrait;
+import software.amazon.smithy.model.traits.TraitDefinition;
+
+/**
+ * Maps {@link Candidates.Shapes} to {@link CompletionItem}s.
+ */
+final class ShapeCompletions {
+    private final Model model;
+    private final SmithyFile smithyFile;
+    private final Matcher matcher;
+    private final Mapper mapper;
+
+    private ShapeCompletions(Model model, SmithyFile smithyFile, Matcher matcher, Mapper mapper) {
+        this.model = model;
+        this.smithyFile = smithyFile;
+        this.matcher = matcher;
+        this.mapper = mapper;
+    }
+
+    List<CompletionItem> getCompletionItems(Candidates.Shapes candidates) {
+        return streamShapes(candidates)
+                .filter(matcher::test)
+                .mapMulti(mapper::accept)
+                .toList();
+    }
+
+    private Stream<? extends Shape> streamShapes(Candidates.Shapes candidates) {
+        return switch (candidates) {
+            case ANY_SHAPE -> model.shapes();
+            case STRING_SHAPES -> model.getStringShapes().stream();
+            case RESOURCE_SHAPES -> model.getResourceShapes().stream();
+            case OPERATION_SHAPES -> model.getOperationShapes().stream();
+            case ERROR_SHAPES -> model.getShapesWithTrait(ErrorTrait.class).stream();
+            case TRAITS -> model.getShapesWithTrait(TraitDefinition.class).stream();
+            case MIXINS -> model.getShapesWithTrait(MixinTrait.class).stream();
+            case MEMBER_TARGETABLE -> model.shapes()
+                    .filter(shape -> !shape.isMemberShape()
+                                     && !shape.hasTrait(TraitDefinition.ID)
+                                     && !shape.hasTrait(MixinTrait.ID));
+            case USE_TARGET -> model.shapes()
+                    .filter(shape -> !shape.isMemberShape()
+                                     && !shape.getId().getNamespace().contentEquals(smithyFile.namespace())
+                                     && !smithyFile.hasImport(shape.getId().toString()));
+        };
+    }
+
+    static ShapeCompletions create(
+            IdlPosition idlPosition,
+            Model model,
+            String matchToken,
+            Range insertRange
+    ) {
+        AddItems addItems = AddItems.DEFAULT;
+        ModifyItems modifyItems = ModifyItems.DEFAULT;
+
+        if (idlPosition instanceof IdlPosition.TraitId) {
+            addItems = new AddDeepTraitBodyItem(model);
+        }
+
+        ToLabel toLabel;
+        if (shouldMatchFullId(idlPosition, matchToken)) {
+            toLabel = (shape) -> shape.getId().toString();
+        } else {
+            toLabel = (shape) -> shape.getId().getName();
+            modifyItems = new AddImportTextEdits(idlPosition.smithyFile());
+        }
+
+        Matcher matcher = new Matcher(matchToken, toLabel, idlPosition.smithyFile());
+        Mapper mapper = new Mapper(insertRange, toLabel, addItems, modifyItems);
+        return new ShapeCompletions(model, idlPosition.smithyFile(), matcher, mapper);
+    }
+
+    private static boolean shouldMatchFullId(IdlPosition idlPosition, String matchToken) {
+        return idlPosition instanceof IdlPosition.UseTarget
+               || matchToken.contains("#")
+               || matchToken.contains(".");
+    }
+
+    /**
+     * Filters shape candidates based on whether they are accessible and match
+     * the match token.
+     *
+     * @param matchToken The token to match shapes against, i.e. the token
+     *                   being typed.
+     * @param toLabel The way to get the label to match against from a shape.
+     * @param smithyFile The current Smithy file.
+     */
+    private record Matcher(String matchToken, ToLabel toLabel, SmithyFile smithyFile) {
+        boolean test(Shape shape) {
+            return smithyFile.isAccessible(shape) && toLabel.toLabel(shape).toLowerCase().startsWith(matchToken);
+        }
+    }
+
+    /**
+     * Maps matching shape candidates to {@link CompletionItem}.
+     *
+     * @param insertRange Range the completion text will be inserted into.
+     * @param toLabel The way to get the label to show in the completion item.
+     * @param addItems Adds extra completion items for a shape.
+     * @param modifyItems Modifies created completion items for a shape.
+     */
+    private record Mapper(Range insertRange, ToLabel toLabel, AddItems addItems, ModifyItems modifyItems) {
+        void accept(Shape shape, Consumer<CompletionItem> completionItemConsumer) {
+            String shapeLabel = toLabel.toLabel(shape);
+            CompletionItem defaultItem = shapeCompletion(shapeLabel, shape);
+            completionItemConsumer.accept(defaultItem);
+            addItems.add(this, shapeLabel, shape, completionItemConsumer);
+        }
+
+        private CompletionItem shapeCompletion(String shapeLabel, Shape shape) {
+            var completionItem = new CompletionItem(shapeLabel);
+            completionItem.setKind(CompletionItemKind.Class);
+            completionItem.setDetail(shape.getType().toString());
+
+            var labelDetails = new CompletionItemLabelDetails();
+            labelDetails.setDetail(shape.getId().getNamespace());
+            completionItem.setLabelDetails(labelDetails);
+
+            TextEdit edit = new TextEdit(insertRange, shapeLabel);
+            completionItem.setTextEdit(Either.forLeft(edit));
+
+            modifyItems.modify(this, shapeLabel, shape, completionItem);
+            return completionItem;
+        }
+    }
+
+    /**
+     * Strategy to get the completion label from {@link Shape}s used for
+     * matching and constructing the completion item.
+     */
+    private interface ToLabel {
+        String toLabel(Shape shape);
+    }
+
+    /**
+     * A customization point for adding extra completions items for a given
+     * shape.
+     */
+    private interface AddItems {
+        AddItems DEFAULT = new AddItems() {
+        };
+
+        default void add(Mapper mapper, String shapeLabel, Shape shape, Consumer<CompletionItem> 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<String> implements AddItems {
+        private final Model model;
+
+        AddDeepTraitBodyItem(Model model) {
+            this.model = model;
+        }
+
+        @Override
+        public void add(Mapper mapper, String shapeLabel, Shape shape, Consumer<CompletionItem> consumer) {
+            String traitBody = shape.accept(this);
+            // Strip outside pair of brackets from any structure traits.
+            if (!traitBody.isEmpty() && traitBody.charAt(0) == '{') {
+                traitBody = traitBody.substring(1, traitBody.length() - 1);
+            }
+
+            if (!traitBody.isEmpty()) {
+                String label = String.format("%s(%s)", shapeLabel, traitBody);
+                var traitWithMembersItem = mapper.shapeCompletion(label, shape);
+                consumer.accept(traitWithMembersItem);
+            }
+        }
+
+        @Override
+        protected String getDefault(Shape shape) {
+            return Candidates.defaultCandidates(shape).value();
+        }
+
+        @Override
+        public String structureShape(StructureShape shape) {
+            List<String> entries = new ArrayList<>();
+            for (MemberShape memberShape : shape.members()) {
+                if (memberShape.hasTrait(RequiredTrait.class)) {
+                    entries.add(memberShape.getMemberName() + ": " + memberShape.accept(this));
+                }
+            }
+            return "{" + String.join(", ", entries) + "}";
+        }
+
+        @Override
+        public String memberShape(MemberShape shape) {
+            return model.getShape(shape.getTarget())
+                    .map(target -> target.accept(this))
+                    .orElse("");
+        }
+    }
+
+    /**
+     * A customization point for modifying created completion items, adding
+     * context, additional text edits, etc.
+     */
+    private interface ModifyItems {
+        ModifyItems DEFAULT = new ModifyItems() {
+        };
+
+        default void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) {
+        }
+    }
+
+    /**
+     * Adds text edits for use statements for shapes that need to be imported.
+     */
+    private static final class AddImportTextEdits implements ModifyItems {
+        private final SmithyFile smithyFile;
+
+        AddImportTextEdits(SmithyFile smithyFile) {
+            this.smithyFile = smithyFile;
+        }
+
+        @Override
+        public void modify(Mapper mapper, String shapeLabel, Shape shape, CompletionItem completionItem) {
+            if (smithyFile.inScope(shape.getId())) {
+                return;
+            }
+
+            // We can only know where to put the import if there's already use statements, or a namespace
+            smithyFile.documentImports().map(DocumentImports::importsRange)
+                    .or(() -> smithyFile.documentNamespace().map(DocumentNamespace::statementRange))
+                    .ifPresent(range -> {
+                        Range editRange = LspAdapter.point(range.getEnd());
+                        String insertText = System.lineSeparator() + "use " + shape.getId().toString();
+                        TextEdit importEdit = new TextEdit(editRange, insertText);
+                        completionItem.setAdditionalTextEdits(List.of(importEdit));
+                    });
+        }
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java
new file mode 100644
index 00000000..0ebb3967
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/ShapeSearch.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.syntax.NodeCursor;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.lsp.syntax.SyntaxSearch;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.loader.Prelude;
+import software.amazon.smithy.model.shapes.ResourceShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.shapes.ShapeIdSyntaxException;
+import software.amazon.smithy.model.traits.IdRefTrait;
+
+/**
+ * Provides methods to search for shapes, using context and syntax specific
+ * information, like the current {@link SmithyFile} or {@link IdlPosition}.
+ */
+final class ShapeSearch {
+    private ShapeSearch() {
+    }
+
+    /**
+     * Attempts to find a shape using a token, {@code nameOrId}.
+     *
+     * <p>When {@code nameOrId} does not contain a '#', this searches for shapes
+     * either in {@code smithyFile}'s namespace, in {@code smithyFile}'s
+     * imports, or the prelude, in that order. When {@code nameOrId} does contain
+     * a '#', it is assumed to be a full shape id and is searched for directly.
+     *
+     * @param smithyFile The file {@code nameOrId} is within.
+     * @param model The model to search.
+     * @param nameOrId The name or shape id of the shape to find.
+     * @return The shape, if found.
+     */
+    static Optional<Shape> findShape(SmithyFile smithyFile, Model model, String nameOrId) {
+        return switch (nameOrId) {
+            case String s when s.isEmpty() -> Optional.empty();
+            case String s when s.contains("#") -> tryFrom(s).flatMap(model::getShape);
+            case String s -> {
+                Optional<Shape> fromCurrent = tryFromParts(smithyFile.namespace().toString(), s)
+                        .flatMap(model::getShape);
+                if (fromCurrent.isPresent()) {
+                    yield fromCurrent;
+                }
+
+                for (String fileImport : smithyFile.imports()) {
+                    Optional<Shape> 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<ShapeId> tryFrom(String id) {
+        try {
+            return Optional.of(ShapeId.from(id));
+        } catch (ShapeIdSyntaxException ignored) {
+            return Optional.empty();
+        }
+    }
+
+    private static Optional<ShapeId> tryFromParts(String namespace, String name) {
+        try {
+            return Optional.of(ShapeId.fromParts(namespace, name));
+        } catch (ShapeIdSyntaxException ignored) {
+            return Optional.empty();
+        }
+    }
+
+    /**
+     * Attempts to find the shape referenced by {@code id} at {@code idlPosition} in {@code model}.
+     *
+     * @param idlPosition The position of the potential shape reference.
+     * @param model The model to search for shapes in.
+     * @param id The identifier at {@code idlPosition}.
+     * @return The shape, if found.
+     */
+    static Optional<? extends Shape> findShapeDefinition(IdlPosition idlPosition, Model model, DocumentId id) {
+        return switch (idlPosition) {
+            case IdlPosition.TraitValue traitValue -> {
+                var result = searchTraitValue(traitValue, model);
+                if (result instanceof NodeSearch.Result.TerminalShape(var s, var m) && s.hasTrait(IdRefTrait.class)) {
+                    yield findShape(idlPosition.smithyFile(), m, id.copyIdValue());
+                } else if (result instanceof NodeSearch.Result.ObjectKey(var key, var container, var m)
+                           && !container.isMapShape()) {
+                    yield container.getMember(key.name());
+                }
+                yield Optional.empty();
+            }
+
+            case IdlPosition.NodeMemberTarget nodeMemberTarget -> {
+                var result = searchNodeMemberTarget(nodeMemberTarget);
+                if (result instanceof NodeSearch.Result.TerminalShape(Shape shape, var ignored)
+                    && shape.hasTrait(IdRefTrait.class)) {
+                    yield findShape(nodeMemberTarget.smithyFile(), model, id.copyIdValue());
+                }
+                yield Optional.empty();
+            }
+
+            // Note: This could be made more specific, at least for mixins
+            case IdlPosition.ElidedMember elidedMember ->
+                    findElidedMemberParent(elidedMember, model, id);
+
+            case IdlPosition pos when pos.isEasyShapeReference() ->
+                    findShape(pos.smithyFile(), model, id.copyIdValue());
+
+            default -> Optional.empty();
+        };
+    }
+
+    record ForResourceAndMixins(ResourceShape resource, List<Shape> mixins) {}
+
+    static ForResourceAndMixins findForResourceAndMixins(
+            SyntaxSearch.ForResourceAndMixins forResourceAndMixins,
+            SmithyFile smithyFile,
+            Model model
+    ) {
+        ResourceShape resourceShape = null;
+        if (forResourceAndMixins.forResource() != null) {
+            String resourceNameOrId = forResourceAndMixins.forResource()
+                    .resource()
+                    .copyValueFrom(smithyFile.document());
+
+            resourceShape = findShape(smithyFile, model, resourceNameOrId)
+                    .flatMap(Shape::asResourceShape)
+                    .orElse(null);
+        }
+        List<Shape> mixins = List.of();
+        if (forResourceAndMixins.mixins() != null) {
+            mixins = new ArrayList<>(forResourceAndMixins.mixins().mixins().size());
+            for (Syntax.Ident ident : forResourceAndMixins.mixins().mixins()) {
+                String mixinNameOrId = ident.copyValueFrom(smithyFile.document());
+                findShape(smithyFile, model, mixinNameOrId).ifPresent(mixins::add);
+            }
+        }
+
+        return new ForResourceAndMixins(resourceShape, mixins);
+    }
+
+    /**
+     * @param elidedMember The elided member position
+     * @param model The model to search in
+     * @param id The identifier of the elided member
+     * @return The shape the elided member comes from, if found.
+     */
+    static Optional<? extends Shape> findElidedMemberParent(
+            IdlPosition.ElidedMember elidedMember,
+            Model model,
+            DocumentId id
+    ) {
+        var forResourceAndMixins = findForResourceAndMixins(
+                SyntaxSearch.closestForResourceAndMixinsBeforeMember(
+                        elidedMember.smithyFile().statements(),
+                        elidedMember.statementIndex()),
+                elidedMember.smithyFile(),
+                model);
+
+        String searchToken = id.copyIdValueForElidedMember();
+
+        // TODO: Handle ambiguity
+        Optional<ResourceShape> foundResource = Optional.ofNullable(forResourceAndMixins.resource())
+                .filter(shape -> shape.getIdentifiers().containsKey(searchToken)
+                                 || shape.getProperties().containsKey(searchToken));
+        if (foundResource.isPresent()) {
+            return foundResource;
+        }
+
+        return forResourceAndMixins.mixins()
+                .stream()
+                .filter(shape -> shape.getAllMembers().containsKey(searchToken))
+                .findFirst();
+    }
+
+    /**
+     * @param traitValue The trait value position
+     * @param model The model to search in
+     * @return The shape that {@code traitValue} is being applied to, if found.
+     */
+    static Optional<Shape> findTraitTarget(IdlPosition.TraitValue traitValue, Model model) {
+        Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefAfterTrait(
+                traitValue.smithyFile().statements(),
+                traitValue.statementIndex());
+
+        if (shapeDef == null) {
+            return Optional.empty();
+        }
+
+        String shapeName = shapeDef.shapeName().copyValueFrom(traitValue.smithyFile().document());
+        return findShape(traitValue.smithyFile(), model, shapeName);
+    }
+
+    /**
+     * @param shape The shape to check
+     * @return Whether {@code shape} is represented as an object in a
+     *  {@link software.amazon.smithy.lsp.syntax.Syntax.Node}.
+     */
+    static boolean isObjectShape(Shape shape) {
+        return switch (shape.getType()) {
+            case STRUCTURE, UNION, MAP -> true;
+            default -> false;
+        };
+    }
+
+    /**
+     * @param metadataValue The metadata value position
+     * @return The result of searching from the given metadata value within the
+     *  {@link Builtins} model.
+     */
+    static NodeSearch.Result searchMetadataValue(IdlPosition.MetadataValue metadataValue) {
+        String metadataKey = metadataValue.metadata().key().copyValueFrom(metadataValue.smithyFile().document());
+        Shape metadataValueShapeDef = Builtins.getMetadataValue(metadataKey);
+        if (metadataValueShapeDef == null) {
+            return NodeSearch.Result.NONE;
+        }
+
+        NodeCursor cursor = NodeCursor.create(
+                metadataValue.smithyFile().document(),
+                metadataValue.metadata().value(),
+                metadataValue.documentIndex());
+        var dynamicTargets = DynamicMemberTarget.forMetadata(metadataKey, metadataValue.smithyFile());
+        return NodeSearch.search(cursor, Builtins.MODEL, metadataValueShapeDef, dynamicTargets);
+    }
+
+    /**
+     * @param nodeMemberTarget The node member target position
+     * @return The result of searching from the given node member target value
+     *  within the {@link Builtins} model.
+     */
+    static NodeSearch.Result searchNodeMemberTarget(IdlPosition.NodeMemberTarget nodeMemberTarget) {
+        Syntax.Statement.ShapeDef shapeDef = SyntaxSearch.closestShapeDefBeforeMember(
+                nodeMemberTarget.smithyFile().statements(),
+                nodeMemberTarget.statementIndex());
+
+        if (shapeDef == null) {
+            return NodeSearch.Result.NONE;
+        }
+
+        String shapeType = shapeDef.shapeType().copyValueFrom(nodeMemberTarget.smithyFile().document());
+        String memberName = nodeMemberTarget.nodeMemberDef()
+                .name()
+                .copyValueFrom(nodeMemberTarget.smithyFile().document());
+        Shape memberShapeDef  = Builtins.getMemberTargetForShapeType(shapeType, memberName);
+
+        if (memberShapeDef == null) {
+            return NodeSearch.Result.NONE;
+        }
+
+        // This is a workaround for the case when you just have 'operations: <nothing>'.
+        // Alternatively, we could add an 'empty' Node value, if this situation comes up
+        // elsewhere.
+        //
+        // TODO: Note that searchTraitValue has to do a similar thing, but parsing
+        //  trait values always yields at least an empty Kvps, so it is kind of the same.
+        if (nodeMemberTarget.nodeMemberDef().value() == null) {
+            return new NodeSearch.Result.TerminalShape(memberShapeDef, Builtins.MODEL);
+        }
+
+        NodeCursor cursor = NodeCursor.create(
+                nodeMemberTarget.smithyFile().document(),
+                nodeMemberTarget.nodeMemberDef().value(),
+                nodeMemberTarget.documentIndex());
+        return NodeSearch.search(cursor, Builtins.MODEL, memberShapeDef);
+    }
+
+    /**
+     * @param traitValue The trait value position
+     * @param model The model to search
+     * @return The result of searching from {@code traitValue} within {@code model}.
+     */
+    static NodeSearch.Result searchTraitValue(IdlPosition.TraitValue traitValue, Model model) {
+        String traitName = traitValue.traitApplication().id().copyValueFrom(traitValue.smithyFile().document());
+        Optional<Shape> maybeTraitShape = findShape(traitValue.smithyFile(), model, traitName);
+        if (maybeTraitShape.isEmpty()) {
+            return NodeSearch.Result.NONE;
+        }
+
+        Shape traitShape = maybeTraitShape.get();
+        NodeCursor cursor = NodeCursor.create(
+                traitValue.smithyFile().document(),
+                traitValue.traitApplication().value(),
+                traitValue.documentIndex());
+        if (cursor.isTerminal() && isObjectShape(traitShape)) {
+            // In this case, we've just started to type '@myTrait(foo)', which to the parser looks like 'foo' is just
+            // an identifier. But this would mean you don't get member completions when typing the first trait value
+            // member, so we can modify the node path to make it _look_ like it's actually a key
+            cursor.edges().addFirst(new NodeCursor.Obj(new Syntax.Node.Kvps()));
+        }
+
+        var dynamicTargets = DynamicMemberTarget.forTrait(traitShape, traitValue);
+        return NodeSearch.search(cursor, model, traitShape, dynamicTargets);
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java
new file mode 100644
index 00000000..c1de0327
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompletions.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import software.amazon.smithy.lsp.document.DocumentId;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.util.StreamUtils;
+import software.amazon.smithy.model.Model;
+
+final class SimpleCompletions {
+    private final Project project;
+    private final Matcher matcher;
+    private final Mapper mapper;
+
+    private SimpleCompletions(Project project, Matcher matcher, Mapper mapper) {
+        this.project = project;
+        this.matcher = matcher;
+        this.mapper = mapper;
+    }
+
+    List<CompletionItem> getCompletionItems(Candidates candidates) {
+        return switch (candidates) {
+            case Candidates.Constant(var value)
+                    when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value));
+
+            case Candidates.Literals(var literals) -> literals.stream()
+                    .filter(matcher::testLiteral)
+                    .map(mapper::literal)
+                    .toList();
+
+            case Candidates.Labeled(var labeled) -> labeled.entrySet().stream()
+                    .filter(matcher::testLabeled)
+                    .map(mapper::labeled)
+                    .toList();
+
+            case Candidates.Members(var members) -> members.entrySet().stream()
+                    .filter(matcher::testMember)
+                    .map(mapper::member)
+                    .toList();
+
+            case Candidates.ElidedMembers(var memberNames) -> memberNames.stream()
+                    .filter(matcher::testElided)
+                    .map(mapper::elided)
+                    .toList();
+
+            case Candidates.Custom custom
+                    // TODO: Need to get rid of this stupid null check
+                    when project != null -> getCompletionItems(customCandidates(custom));
+
+            case Candidates.And(var one, var two) -> {
+                List<CompletionItem> oneItems = getCompletionItems(one);
+                List<CompletionItem> twoItems = getCompletionItems(two);
+                List<CompletionItem> completionItems = new ArrayList<>(oneItems.size() + twoItems.size());
+                completionItems.addAll(oneItems);
+                completionItems.addAll(twoItems);
+                yield completionItems;
+            }
+            default -> List.of();
+        };
+    }
+
+    private Candidates customCandidates(Candidates.Custom custom) {
+        return switch (custom) {
+            case NAMESPACE_FILTER -> new Candidates.Labeled(Stream.concat(Stream.of("*"), streamNamespaces())
+                    .collect(StreamUtils.toWrappedMap()));
+
+            case VALIDATOR_NAME -> Candidates.VALIDATOR_NAMES;
+
+            case PROJECT_NAMESPACES -> new Candidates.Literals(streamNamespaces().toList());
+        };
+    }
+
+    private Stream<String> streamNamespaces() {
+        return project.smithyFiles().values().stream()
+                .map(smithyFile -> smithyFile.namespace().toString())
+                .filter(namespace -> !namespace.isEmpty());
+    }
+
+    static Builder builder(DocumentId id, Range insertRange) {
+        return new Builder(id, insertRange);
+    }
+
+    static final class Builder {
+        private final DocumentId id;
+        private final Range insertRange;
+        private Project project = null;
+        private Set<String> exclude = null;
+        private CompletionItemKind literalKind = CompletionItemKind.Field;
+
+        private Builder(DocumentId id, Range insertRange) {
+            this.id = id;
+            this.insertRange = insertRange;
+        }
+
+        Builder project(Project project) {
+            this.project = project;
+            return this;
+        }
+
+        Builder exclude(Set<String> exclude) {
+            this.exclude = exclude;
+            return this;
+        }
+
+        Builder literalKind(CompletionItemKind literalKind) {
+            this.literalKind = literalKind;
+            return this;
+        }
+
+        SimpleCompletions buildSimpleCompletions() {
+            Matcher matcher = getMatcher(id, exclude);
+            Mapper mapper = new Mapper(insertRange, literalKind);
+            return new SimpleCompletions(project, matcher, mapper);
+        }
+
+        ShapeCompletions buildShapeCompletions(IdlPosition idlPosition, Model model) {
+            return ShapeCompletions.create(idlPosition, model, getMatchToken(id), insertRange);
+        }
+    }
+
+    private static Matcher getMatcher(DocumentId id, Set<String> exclude) {
+        String matchToken = getMatchToken(id);
+        if (exclude == null || exclude.isEmpty()) {
+            return new DefaultMatcher(matchToken);
+        } else {
+            return new ExcludingMatcher(matchToken, exclude);
+        }
+    }
+
+    private static String getMatchToken(DocumentId id) {
+        return id != null
+                ? id.copyIdValue().toLowerCase()
+                : "";
+    }
+
+    private sealed interface Matcher extends Predicate<String> {
+        String matchToken();
+
+        default boolean testConstant(String constant) {
+            return test(constant);
+        }
+
+        default boolean testLiteral(String literal) {
+            return test(literal);
+        }
+
+        default boolean testLabeled(Map.Entry<String, String> labeled) {
+            return test(labeled.getKey()) || test(labeled.getValue());
+        }
+
+        default boolean testMember(Map.Entry<String, Candidates.Constant> member) {
+            return test(member.getKey());
+        }
+
+        default boolean testElided(String memberName) {
+            return test(memberName) || test("$" + memberName);
+        }
+
+        @Override
+        default boolean test(String s) {
+            return s.toLowerCase().startsWith(matchToken());
+        }
+    }
+
+    private record DefaultMatcher(String matchToken) implements Matcher {}
+
+    private record ExcludingMatcher(String matchToken, Set<String> exclude) implements Matcher {
+        @Override
+        public boolean testElided(String memberName) {
+            // Exclusion set doesn't contain member names with leading '$', so we don't
+            // want to delegate to the regular `test` method
+            return !exclude.contains(memberName)
+                   && (Matcher.super.test(memberName) || Matcher.super.test("$" + memberName));
+        }
+
+        @Override
+        public boolean test(String s) {
+            return !exclude.contains(s) && Matcher.super.test(s);
+        }
+    }
+
+    private record Mapper(Range insertRange, CompletionItemKind literalKind) {
+        CompletionItem constant(String value) {
+            return textEditCompletion(value, CompletionItemKind.Constant);
+        }
+
+        CompletionItem literal(String value) {
+            return textEditCompletion(value, CompletionItemKind.Field);
+        }
+
+        CompletionItem labeled(Map.Entry<String, String> entry) {
+            return textEditCompletion(entry.getKey(), CompletionItemKind.EnumMember, entry.getValue());
+        }
+
+        CompletionItem member(Map.Entry<String, Candidates.Constant> entry) {
+            String value = entry.getKey() + ": " + entry.getValue().value();
+            return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value);
+        }
+
+        CompletionItem elided(String memberName) {
+            return textEditCompletion("$" + memberName, CompletionItemKind.Field);
+        }
+
+        private CompletionItem textEditCompletion(String label, CompletionItemKind kind) {
+            return textEditCompletion(label, kind, label);
+        }
+
+        private CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) {
+            CompletionItem item = new CompletionItem(label);
+            item.setKind(kind);
+            TextEdit textEdit = new TextEdit(insertRange, insertText);
+            item.setTextEdit(Either.forLeft(textEdit));
+            return item;
+        }
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java
index 86b8b550..dafbb7ba 100644
--- a/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java
+++ b/src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java
@@ -33,6 +33,7 @@
 import software.amazon.smithy.lsp.document.DocumentShape;
 import software.amazon.smithy.lsp.document.DocumentVersion;
 import software.amazon.smithy.lsp.protocol.LspAdapter;
+import software.amazon.smithy.lsp.syntax.Syntax;
 import software.amazon.smithy.lsp.util.Result;
 import software.amazon.smithy.model.Model;
 import software.amazon.smithy.model.loader.ModelAssembler;
@@ -253,8 +254,10 @@ public static SmithyFile.Builder buildSmithyFile(String path, Document document,
         DocumentParser documentParser = DocumentParser.forDocument(document);
         DocumentNamespace namespace = documentParser.documentNamespace();
         DocumentImports imports = documentParser.documentImports();
-        Map<Position, DocumentShape> documentShapes = documentParser.documentShapes(shapes);
+        Map<Position, DocumentShape> documentShapes = documentParser.documentShapes();
         DocumentVersion documentVersion = documentParser.documentVersion();
+        Syntax.IdlParse parse = Syntax.parseIdl(document);
+        List<Syntax.Statement> statements = parse.statements();
         return SmithyFile.builder()
                 .path(path)
                 .document(document)
@@ -262,7 +265,9 @@ public static SmithyFile.Builder buildSmithyFile(String path, Document document,
                 .namespace(namespace)
                 .imports(imports)
                 .documentShapes(documentShapes)
-                .documentVersion(documentVersion);
+                .documentVersion(documentVersion)
+                .statements(statements);
+                // .changeVersion(document.changeVersion());
     }
 
     // This is gross, but necessary to deal with the way that array metadata gets merged.
diff --git a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java
index a30cec1e..ba6d3680 100644
--- a/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java
+++ b/src/main/java/software/amazon/smithy/lsp/project/SmithyFile.java
@@ -7,6 +7,7 @@
 
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -16,7 +17,11 @@
 import software.amazon.smithy.lsp.document.DocumentNamespace;
 import software.amazon.smithy.lsp.document.DocumentShape;
 import software.amazon.smithy.lsp.document.DocumentVersion;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.model.loader.Prelude;
 import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.traits.PrivateTrait;
 
 /**
  * The language server's representation of a Smithy file.
@@ -34,6 +39,8 @@ public final class SmithyFile implements ProjectFile {
     private final DocumentImports imports;
     private final Map<Position, DocumentShape> documentShapes;
     private final DocumentVersion documentVersion;
+    private List<Syntax.Statement> statements;
+    private int changeVersion;
 
     private SmithyFile(Builder builder) {
         this.path = builder.path;
@@ -43,6 +50,8 @@ private SmithyFile(Builder builder) {
         this.imports = builder.imports;
         this.documentShapes = builder.documentShapes;
         this.documentVersion = builder.documentVersion;
+        this.statements = builder.statements;
+        this.changeVersion = builder.changeVersion;
     }
 
     /**
@@ -141,6 +150,44 @@ public boolean hasImport(String shapeId) {
         return imports.imports().contains(shapeId);
     }
 
+    public boolean isAccessible(Shape shape) {
+        return shape.getId().getNamespace().contentEquals(namespace())
+                || !shape.hasTrait(PrivateTrait.ID);
+    }
+
+    public int changeVersion() {
+        return changeVersion;
+    }
+
+    public void setChangeVersion(int changeVersion) {
+        this.changeVersion = changeVersion;
+    }
+
+    /**
+     * @return The parsed statements in this file
+     */
+    public List<Syntax.Statement> statements() {
+        return statements;
+    }
+
+    /**
+     * Re-parses the underlying {@link #document()}, updating {@link #statements()}.
+     */
+    public void reparse() {
+        Syntax.IdlParse parse = Syntax.parseIdl(document);
+        this.statements = parse.statements();
+    }
+
+    /**
+     * @param shapeId The shape id to check
+     * @return Whether the given shape id is in scope for this file
+     */
+    public boolean inScope(ShapeId shapeId) {
+        return Prelude.isPublicPreludeShape(shapeId)
+                || shapeId.getNamespace().contentEquals(namespace())
+                || hasImport(shapeId.toString());
+    }
+
     /**
      * @return A {@link SmithyFile} builder
      */
@@ -156,6 +203,8 @@ public static final class Builder {
         private DocumentImports imports;
         private Map<Position, DocumentShape> documentShapes;
         private DocumentVersion documentVersion;
+        private List<Syntax.Statement> statements;
+        private int changeVersion;
 
         private Builder() {
         }
@@ -195,6 +244,16 @@ public Builder documentVersion(DocumentVersion documentVersion) {
             return this;
         }
 
+        public Builder statements(List<Syntax.Statement> statements) {
+            this.statements = statements;
+            return this;
+        }
+
+        public Builder changeVersion(int changeVersion) {
+            this.changeVersion = changeVersion;
+            return this;
+        }
+
         public SmithyFile build() {
             return new SmithyFile(this);
         }
diff --git a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java
index 59e62ead..106fa18d 100644
--- a/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java
+++ b/src/main/java/software/amazon/smithy/lsp/protocol/LspAdapter.java
@@ -15,6 +15,7 @@
 import org.eclipse.lsp4j.Location;
 import org.eclipse.lsp4j.Position;
 import org.eclipse.lsp4j.Range;
+import software.amazon.smithy.model.FromSourceLocation;
 import software.amazon.smithy.model.SourceLocation;
 
 /**
@@ -126,10 +127,11 @@ public static Position toPosition(SourceLocation sourceLocation) {
      * Get a {@link Location} from a {@link SourceLocation}, with the filename
      * transformed to a URI, and the line/column made 0-indexed.
      *
-     * @param sourceLocation The source location to get a Location from
+     * @param fromSourceLocation The source location to get a Location from
      * @return The equivalent Location
      */
-    public static Location toLocation(SourceLocation sourceLocation) {
+    public static Location toLocation(FromSourceLocation fromSourceLocation) {
+        SourceLocation sourceLocation = fromSourceLocation.getSourceLocation();
         return new Location(toUri(sourceLocation.getFilename()), point(
                 new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1)));
     }
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java
new file mode 100644
index 00000000..a8e4edb9
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/NodeCursor.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.util.ArrayList;
+import java.util.List;
+import software.amazon.smithy.lsp.document.Document;
+
+/**
+ * A moveable index into a path from the root of a {@link Syntax.Node} to a
+ * position somewhere within that node. The path supports iteration both
+ * forward and backward, as well as storing a 'checkpoint' along the path
+ * that can be returned to at a later point.
+ */
+public final class NodeCursor {
+    private final List<Edge> edges;
+    private int pos = 0;
+    private int checkpoint = 0;
+
+    NodeCursor(List<Edge> edges) {
+        this.edges = edges;
+    }
+
+    /**
+     * @param document The document the node value is within
+     * @param value The node value to create the cursor for
+     * @param documentIndex The index within the document to create the cursor for
+     * @return A node cursor from the start of {@code value} to {@code documentIndex}
+     *  within {@code document}.
+     */
+    public static NodeCursor create(Document document, Syntax.Node value, int documentIndex) {
+        List<NodeCursor.Edge> edges = new ArrayList<>();
+        NodeCursor cursor = new NodeCursor(edges);
+
+        if (value == null || documentIndex < 0) {
+            return cursor;
+        }
+
+        Syntax.Node next = value;
+        while (true) {
+            iteration: switch (next) {
+                case Syntax.Node.Kvps kvps -> {
+                    edges.add(new NodeCursor.Obj(kvps));
+                    Syntax.Node.Kvp lastKvp = null;
+                    for (Syntax.Node.Kvp kvp : kvps.kvps()) {
+                        if (kvp.key.isIn(documentIndex)) {
+                            String key = kvp.key.copyValueFrom(document);
+                            edges.add(new NodeCursor.Key(key, kvps));
+                            edges.add(new NodeCursor.Terminal(kvp));
+                            return cursor;
+                        } else if (kvp.inValue(documentIndex)) {
+                            if (kvp.value == null) {
+                                lastKvp = kvp;
+                                break;
+                            }
+                            String key = kvp.key.copyValueFrom(document);
+                            edges.add(new NodeCursor.ValueForKey(key, kvps));
+                            next = kvp.value;
+                            break iteration;
+                        } else {
+                            lastKvp = kvp;
+                        }
+                    }
+                    if (lastKvp != null && lastKvp.value == null) {
+                        edges.add(new NodeCursor.ValueForKey(lastKvp.key.copyValueFrom(document), kvps));
+                        edges.add(new NodeCursor.Terminal(lastKvp));
+                        return cursor;
+                    }
+                    return cursor;
+                }
+                case Syntax.Node.Obj obj -> {
+                    next = obj.kvps;
+                }
+                case Syntax.Node.Arr arr -> {
+                    edges.add(new NodeCursor.Arr(arr));
+                    for (int i = 0; i < arr.elements.size(); i++) {
+                        Syntax.Node elem = arr.elements.get(i);
+                        if (elem.isIn(documentIndex)) {
+                            edges.add(new NodeCursor.Elem(i, arr));
+                            next = elem;
+                            break iteration;
+                        }
+                    }
+                    return cursor;
+                }
+                case null -> {
+                    edges.add(new NodeCursor.Terminal(null));
+                    return cursor;
+                }
+                default -> {
+                    edges.add(new NodeCursor.Terminal(next));
+                    return cursor;
+                }
+            }
+        }
+    }
+
+    public List<Edge> edges() {
+        return edges;
+    }
+
+    /**
+     * @return Whether the cursor is not at the end of the path. A return value
+     *  of {@code true} means {@link #next()} may be called safely.
+     */
+    public boolean hasNext() {
+        return pos < edges.size();
+    }
+
+    /**
+     * @return The next edge along the path. Also moves the cursor forward.
+     */
+    public Edge next() {
+        Edge edge = edges.get(pos);
+        pos++;
+        return edge;
+    }
+
+    /**
+     * @return Whether the cursor is not at the start of the path. A return value
+     *  of {@code true} means {@link #previous()} may be called safely.
+     */
+    public boolean hasPrevious() {
+        return edges.size() - pos >= 0;
+    }
+
+    /**
+     * @return The previous edge along the path. Also moves the cursor backward.
+     */
+    public Edge previous() {
+        pos--;
+        return edges.get(pos);
+    }
+
+    /**
+     * @return Whether the path consists of a single, terminal, node.
+     */
+    public boolean isTerminal() {
+        return edges.size() == 1 && edges.get(0) instanceof Terminal;
+    }
+
+    /**
+     * Store the current cursor position to be returned to later. Subsequent
+     * calls overwrite the checkpoint.
+     */
+    public void setCheckpoint() {
+        this.checkpoint = pos;
+    }
+
+    /**
+     * Return to a previously set checkpoint. Subsequent calls continue to
+     * the same checkpoint, unless overwritten.
+     */
+    public void returnToCheckpoint() {
+        this.pos = checkpoint;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder builder = new StringBuilder();
+        for (Edge edge : edges) {
+            switch (edge) {
+                case Obj ignored -> builder.append("Obj,");
+                case Arr ignored -> builder.append("Arr,");
+                case Terminal ignored -> builder.append("Terminal,");
+                case Elem elem -> builder.append("Elem(").append(elem.index).append("),");
+                case Key key -> builder.append("Key(").append(key.name).append("),");
+                case ValueForKey valueForKey -> builder.append("ValueForKey(").append(valueForKey.keyName).append("),");
+            }
+        }
+        return builder.toString();
+    }
+
+    /**
+     * An edge along a path within a {@link Syntax.Node}. Edges are fine-grained
+     * structurally, so there is a distinction between e.g. a path into an object,
+     * an object key, and a value for an object key, but there is no distinction
+     * between e.g. a path into a string value vs a numeric value. Each edge stores
+     * a reference to the underlying node, or a reference to the parent node.
+     */
+    public sealed interface Edge {}
+
+    /**
+     * Within an object, i.e. within the braces: '{}'.
+     * @param node The value of the underlying node at this edge.
+     */
+    public record Obj(Syntax.Node.Kvps node) implements Edge {}
+
+    /**
+     * Within an array/list, i.e. within the brackets: '[]'.
+     * @param node The value of the underlying node at this edge.
+     */
+    public record Arr(Syntax.Node.Arr node) implements Edge {}
+
+    /**
+     * The end of a path. Will always be present at the end of any non-empty path.
+     * @param node The value of the underlying node at this edge.
+     */
+    public record Terminal(Syntax.Node node) implements Edge {}
+
+    /**
+     * Within a key of an object, i.e. '{"here": null}'
+     * @param name The name of the key.
+     * @param parent The object node the key is within.
+     */
+    public record Key(String name, Syntax.Node.Kvps parent) implements Edge {}
+
+    /**
+     * Within a value corresponding to a key of an object, i.e. '{"key": "here"}'
+     * @param keyName The name of the key.
+     * @param parent The object node the value is within.
+     */
+    public record ValueForKey(String keyName, Syntax.Node.Kvps parent) implements Edge {}
+
+    /**
+     * Within an element of an array/list, i.e. '["here"]'.
+     * @param index The index of the element.
+     * @param parent The array node the element is within.
+     */
+    public record Elem(int index, Syntax.Node.Arr parent) implements Edge {}
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java
new file mode 100644
index 00000000..087050f1
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/Parser.java
@@ -0,0 +1,1010 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.utils.SimpleParser;
+
+/**
+ * Parser for {@link Syntax.Node} and {@link Syntax.Statement}. See
+ * {@link Syntax} for more details on the design of the parser.
+ *
+ * <p>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<Syntax.Err> errors = new ArrayList<>();
+    final List<Syntax.Statement> statements = new ArrayList<>();
+    private final Document document;
+
+    Parser(Document document) {
+        super(document.borrowText());
+        this.document = document;
+    }
+
+    Syntax.Node parseNode() {
+        ws();
+        return switch (peek()) {
+            case '{' -> obj();
+            case '"' -> str();
+            case '[' -> arr();
+            case '-' -> num();
+            default -> {
+                if (isDigit()) {
+                    yield num();
+                } else if (isIdentStart()) {
+                    yield ident();
+                }
+
+                int start = position();
+                do {
+                    skip();
+                } while (!isWs() && !isNodeStructuralBreakpoint() && !eof());
+                int end = position();
+                Syntax.Node.Err err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end));
+                err.start = start;
+                err.end = end;
+                yield err;
+            }
+        };
+    }
+
+    void parseIdl() {
+        try {
+            ws();
+            while (!eof()) {
+                statement();
+                ws();
+            }
+        } catch (Parser.Eof e) {
+            // This is used to stop parsing when eof is encountered even if we're
+            // within many layers of method calls.
+            Syntax.Statement.Err err = new Syntax.Statement.Err(e.message);
+            err.start = position();
+            err.end = position();
+            errors.add(err);
+        }
+    }
+
+    private void setStart(Syntax.Item item) {
+        if (eof()) {
+            item.start = position() - 1;
+        } else {
+            item.start = position();
+        }
+    }
+
+    private int positionForStart() {
+        if (eof()) {
+            return position() - 1;
+        } else {
+            return position();
+        }
+    }
+
+    private void setEnd(Syntax.Item item) {
+        item.end = position();
+    }
+
+    private void rewindTo(int pos) {
+        int line = document.lineOfIndex(pos);
+        int lineIndex = document.indexOfLine(line);
+        this.rewind(pos, line + 1, pos - lineIndex + 1);
+    }
+
+    private Syntax.Node traitNode() {
+        skip(); // '('
+        ws();
+        return switch (peek()) {
+            case '{' -> obj();
+            case '"' -> {
+                int pos = position();
+                Syntax.Node str = str();
+                ws();
+                if (is(':')) {
+                    yield traitValueKvps(pos);
+                } else {
+                    yield str;
+                }
+            }
+            case '[' -> arr();
+            case '-' -> num();
+            default -> {
+                if (isDigit()) {
+                    yield num();
+                } else if (isIdentStart()) {
+                    int pos = position();
+                    Syntax.Node ident = nodeIdent();
+                    ws();
+                    if (is(':')) {
+                        yield traitValueKvps(pos);
+                    } else {
+                        yield ident;
+                    }
+                } else if (is(')')) {
+                    Syntax.Node.Kvps kvps = new Syntax.Node.Kvps();
+                    setStart(kvps);
+                    setEnd(kvps);
+                    skip();
+                    yield kvps;
+                }
+
+                int start = position();
+                do {
+                    skip();
+                } while (!isWs() && !isStructuralBreakpoint() && !eof());
+                int end = position();
+                Syntax.Node.Err err;
+                if (eof()) {
+                    err = new Syntax.Node.Err("unexpected eof");
+                } else {
+                    err = new Syntax.Node.Err("unexpected token " + document.copySpan(start, end));
+                }
+                err.start = start;
+                err.end = end;
+                yield err;
+            }
+        };
+    }
+
+    private Syntax.Node traitValueKvps(int from) {
+        rewindTo(from);
+        Syntax.Node.Kvps kvps = new Syntax.Node.Kvps();
+        setStart(kvps);
+        while (!eof()) {
+            if (is(')')) {
+                setEnd(kvps);
+                skip();
+                return kvps;
+            }
+
+            Syntax.Node.Err kvpErr = kvp(kvps, ')');
+            if (kvpErr != null) {
+                errors.add(kvpErr);
+            }
+
+            ws();
+        }
+        kvps.end = position() - 1;
+        return kvps;
+    }
+
+    private Syntax.Node nodeIdent() {
+        int start = position();
+        // assume there's _something_ here
+        do {
+            skip();
+        } while (!isWs() && !isStructuralBreakpoint() && !eof());
+        return new Syntax.Ident(start, position());
+    }
+
+    private Syntax.Node.Obj obj() {
+        Syntax.Node.Obj obj = new Syntax.Node.Obj();
+        setStart(obj);
+        skip();
+        ws();
+        while (!eof()) {
+            if (is('}')) {
+                skip();
+                setEnd(obj);
+                return obj;
+            }
+
+            Syntax.Err kvpErr = kvp(obj.kvps, '}');
+            if (kvpErr != null) {
+                errors.add(kvpErr);
+            }
+
+            ws();
+        }
+
+        Syntax.Node.Err err = new Syntax.Node.Err("missing }");
+        setStart(err);
+        setEnd(err);
+        errors.add(err);
+
+        setEnd(obj);
+        return obj;
+    }
+
+    private Syntax.Node.Err kvp(Syntax.Node.Kvps kvps, char close) {
+        int start = positionForStart();
+        Syntax.Node keyValue = parseNode();
+        Syntax.Node.Err err = null;
+        Syntax.Node.Str key = null;
+        switch (keyValue) {
+            case Syntax.Node.Str s -> {
+                key = s;
+            }
+            case Syntax.Node.Err e -> {
+                err = e;
+            }
+            default -> {
+                err = nodeErr(keyValue, "unexpected " + keyValue.type());
+            }
+        }
+
+        ws();
+
+        Syntax.Node.Kvp kvp = null;
+        if (key != null) {
+            kvp = new Syntax.Node.Kvp(key);
+            kvp.start = start;
+            kvps.add(kvp);
+        }
+
+        if (is(':')) {
+            if (kvp != null) {
+                kvp.colonPos = position();
+            }
+            skip();
+            ws();
+        } else if (eof()) {
+            return nodeErr("unexpected eof");
+        } else {
+            if (err != null) {
+                errors.add(err);
+            }
+
+            err = nodeErr("expected :");
+        }
+
+        if (is(close)) {
+            if (err != null) {
+                errors.add(err);
+            }
+
+            return nodeErr("expected value");
+        }
+
+        if (is(',')) {
+            skip();
+            if (kvp != null) {
+                setEnd(kvp);
+            }
+            if (err != null) {
+                errors.add(err);
+            }
+
+            return nodeErr("expected value");
+        }
+
+        Syntax.Node value = parseNode();
+        if (value instanceof Syntax.Node.Err e) {
+            if (err != null) {
+                errors.add(err);
+            }
+            err = e;
+        } else if (err == null) {
+            kvp.value = value;
+            if (is(',')) {
+                skip();
+            }
+            return null;
+        }
+
+        return err;
+    }
+
+    private Syntax.Node.Arr arr() {
+        Syntax.Node.Arr arr = new Syntax.Node.Arr();
+        setStart(arr);
+        skip();
+        ws();
+        while (!eof()) {
+            if (is(']')) {
+                skip();
+                setEnd(arr);
+                return arr;
+            }
+
+            Syntax.Node elem = parseNode();
+            if (elem instanceof Syntax.Node.Err e) {
+                errors.add(e);
+            } else {
+                arr.elements.add(elem);
+            }
+            ws();
+        }
+
+        Syntax.Node.Err err = nodeErr("missing ]");
+        errors.add(err);
+
+        setEnd(arr);
+        return arr;
+    }
+
+    private Syntax.Node str() {
+        int start = position();
+        skip(); // '"'
+        if (is('"')) {
+            skip();
+
+            if (is('"')) {
+                skip();
+
+                // text block
+                int end = document.nextIndexOf("\"\"\"", position());
+                if (end == -1) {
+                    rewindTo(document.length() - 1);
+                    Syntax.Node.Err err = new Syntax.Node.Err("unclosed text block");
+                    err.start = start;
+                    err.end = document.length();
+                    return err;
+                }
+
+                rewindTo(end + 3);
+                Syntax.Node.Str str = new Syntax.Node.Str();
+                str.start = start;
+                setEnd(str);
+                return str;
+            }
+
+            skip();
+            Syntax.Node.Str str = new Syntax.Node.Str();
+            str.start = start;
+            setEnd(str);
+            return str;
+        }
+
+        int last = '"';
+
+        // Potential micro-optimization - only loop while position < line end
+        while (!isNl() && !eof()) {
+            if (is('"') && last != '\\') {
+                skip(); // '"'
+                Syntax.Node.Str str = new Syntax.Node.Str();
+                str.start = start;
+                setEnd(str);
+                return str;
+            }
+            last = peek();
+            skip();
+        }
+
+        Syntax.Node.Err err = new Syntax.Node.Err("unclosed string literal");
+        err.start = start;
+        setEnd(err);
+        return err;
+    }
+
+    private Syntax.Node num() {
+        int start = position();
+        while (!isWs() && !isNodeStructuralBreakpoint() && !eof()) {
+            skip();
+        }
+
+        String token = document.copySpan(start, position());
+        if (token == null) {
+            throw new RuntimeException("unhandled eof in node num");
+        }
+
+        Syntax.Node value;
+        try {
+            BigDecimal numValue = new BigDecimal(token);
+            value = new Syntax.Node.Num(numValue);
+        } catch (NumberFormatException e) {
+            value = new Syntax.Node.Err(String.format("%s is not a valid number", token));
+        }
+        value.start = start;
+        setEnd(value);
+        return value;
+    }
+
+    private boolean isNodeStructuralBreakpoint() {
+        return switch (peek()) {
+            case '{', '[', '}', ']', ',', ':', ')' -> true;
+            default -> false;
+        };
+    }
+
+    private Syntax.Node.Err nodeErr(Syntax.Node from, String message) {
+        Syntax.Node.Err err = new Syntax.Node.Err(message);
+        err.start = from.start;
+        err.end = from.end;
+        return err;
+    }
+
+    private Syntax.Node.Err nodeErr(String message) {
+        Syntax.Node.Err err = new Syntax.Node.Err(message);
+        setStart(err);
+        setEnd(err);
+        return err;
+    }
+
+    private void statement() {
+        if (is('@')) {
+            traitApplication(null);
+        } else if (is('$')) {
+            control();
+        } else {
+            // Shape, apply
+            int start = position();
+            Syntax.Ident ident = ident();
+            if (ident.isEmpty()) {
+                if (!isWs()) {
+                    skip();
+                }
+                return;
+            }
+
+            sp();
+            Syntax.Ident name = ident();
+            if (name.isEmpty()) {
+                Syntax.Statement.Incomplete incomplete = new Syntax.Statement.Incomplete(ident);
+                incomplete.start = start;
+                incomplete.end = position();
+                statements.add(incomplete);
+
+                if (!isWs()) {
+                    skip();
+                }
+                return;
+            }
+
+            String identCopy = ident.copyValueFrom(document);
+
+            switch (identCopy) {
+                case "apply" -> {
+                    apply(start, name);
+                    return;
+                }
+                case "metadata" -> {
+                    metadata(start, name);
+                    return;
+                }
+                case "use" -> {
+                    use(start, name);
+                    return;
+                }
+                case "namespace" -> {
+                    namespace(start, name);
+                    return;
+                }
+                default -> {
+                }
+            }
+
+            Syntax.Statement.ShapeDef shapeDef = new Syntax.Statement.ShapeDef(ident, name);
+            shapeDef.start = start;
+            setEnd(shapeDef);
+            statements.add(shapeDef);
+
+            sp();
+            optionalForResourceAndMixins();
+            ws();
+
+            switch (identCopy) {
+                case "enum", "intEnum" -> {
+                    var block = startBlock(null);
+
+                    ws();
+                    while (!is('}') && !eof()) {
+                        enumMember(block);
+                        ws();
+                    }
+
+                    endBlock(block);
+                }
+                case "structure", "list", "map", "union" -> {
+                    var block = startBlock(null);
+
+                    ws();
+                    while (!is('}') && !eof()) {
+                        member(block);
+                        ws();
+                    }
+
+                    endBlock(block);
+                }
+                case "resource", "service" -> {
+                    var block = startBlock(null);
+
+                    ws();
+                    while (!is('}') && !eof()) {
+                        nodeMember(block);
+                        ws();
+                    }
+
+                    endBlock(block);
+                }
+                case "operation" -> {
+                    var block = startBlock(null);
+                    // This is different from the other member parsing because it needs more fine-grained loop/branch
+                    // control to deal with inline structures
+                    operationMembers(block);
+                    endBlock(block);
+                }
+                default -> {
+                }
+            }
+        }
+    }
+
+    private Syntax.Statement.Block startBlock(Syntax.Statement.Block parent) {
+        Syntax.Statement.Block block = new Syntax.Statement.Block(parent, statements.size());
+        setStart(block);
+        statements.add(block);
+        if (is('{')) {
+            skip();
+        } else {
+            addErr(position(), position(), "expected {");
+            recoverToMemberStart();
+        }
+        return block;
+    }
+
+    private void endBlock(Syntax.Statement.Block block) {
+        block.lastStatementIndex = statements.size() - 1;
+        throwIfEofAndFinish("expected }", block); // This will stop execution
+        skip(); // '}'
+        setEnd(block);
+    }
+
+    private void operationMembers(Syntax.Statement.Block parent) {
+        ws();
+        while (!is('}') && !eof()) {
+            int opMemberStart = position();
+            Syntax.Ident memberName = ident();
+
+            int colonPos = -1;
+            sp();
+            if (is(':')) {
+                colonPos = position();
+                skip(); // ':'
+            } else {
+                addErr(position(), position(), "expected :");
+                if (isWs()) {
+                    var memberDef = new Syntax.Statement.MemberDef(parent, memberName);
+                    memberDef.start = opMemberStart;
+                    setEnd(memberDef);
+                    statements.add(memberDef);
+                    ws();
+                    continue;
+                }
+            }
+
+            if (is('=')) {
+                skip(); // '='
+                inlineMember(parent, opMemberStart, memberName);
+                ws();
+                continue;
+            }
+
+            ws();
+
+            if (isIdentStart()) {
+                var opMemberDef = new Syntax.Statement.MemberDef(parent, memberName);
+                opMemberDef.start = opMemberStart;
+                opMemberDef.colonPos = colonPos;
+                opMemberDef.target = ident();
+                setEnd(opMemberDef);
+                statements.add(opMemberDef);
+            } else {
+                var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, memberName);
+                nodeMemberDef.start = opMemberStart;
+                nodeMemberDef.colonPos = colonPos;
+                nodeMemberDef.value = parseNode();
+                setEnd(nodeMemberDef);
+                statements.add(nodeMemberDef);
+            }
+
+            ws();
+        }
+    }
+
+    private void control() {
+        int start = position();
+        skip(); // '$'
+        Syntax.Ident ident = ident();
+        Syntax.Statement.Control control = new Syntax.Statement.Control(ident);
+        control.start = start;
+        statements.add(control);
+        sp();
+
+        if (!is(':')) {
+            addErr(position(), position(), "expected :");
+            if (isWs()) {
+                setEnd(control);
+                return;
+            }
+        } else {
+            skip();
+        }
+
+        control.value = parseNode();
+        setEnd(control);
+    }
+
+    private void apply(int start, Syntax.Ident name) {
+        Syntax.Statement.Apply apply = new Syntax.Statement.Apply(name);
+        apply.start = start;
+        setEnd(apply);
+        statements.add(apply);
+
+        sp();
+        if (is('@')) {
+            traitApplication(null);
+        } else if (is('{')) {
+            var block = startBlock(null);
+
+            ws();
+            while (!is('}') && !eof()) {
+                if (!is('@')) {
+                    addErr(position(), position(), "expected trait");
+                    return;
+                }
+                traitApplication(block);
+                ws();
+            }
+
+            endBlock(block);
+        } else {
+            addErr(position(), position(), "expected trait or block");
+        }
+    }
+
+    private void metadata(int start, Syntax.Ident name) {
+        Syntax.Statement.Metadata metadata = new Syntax.Statement.Metadata(name);
+        metadata.start = start;
+        statements.add(metadata);
+
+        sp();
+        if (!is('=')) {
+            addErr(position(), position(), "expected =");
+            if (isWs()) {
+                setEnd(metadata);
+                return;
+            }
+        } else {
+            skip();
+        }
+        metadata.value = parseNode();
+        setEnd(metadata);
+    }
+
+    private void use(int start, Syntax.Ident name) {
+        Syntax.Statement.Use use = new Syntax.Statement.Use(name);
+        use.start = start;
+        setEnd(use);
+        statements.add(use);
+    }
+
+    private void namespace(int start, Syntax.Ident name) {
+        Syntax.Statement.Namespace namespace = new Syntax.Statement.Namespace(name);
+        namespace.start = start;
+        setEnd(namespace);
+        statements.add(namespace);
+    }
+
+    private void optionalForResourceAndMixins() {
+        int maybeStart = position();
+        Syntax.Ident maybe = optIdent();
+
+        if (maybe.copyValueFrom(document).equals("for")) {
+            sp();
+            Syntax.Ident resource = ident();
+            Syntax.Statement.ForResource forResource = new Syntax.Statement.ForResource(resource);
+            forResource.start = maybeStart;
+            statements.add(forResource);
+            ws();
+            setEnd(forResource);
+            maybeStart = position();
+            maybe = optIdent();
+        }
+
+        if (maybe.copyValueFrom(document).equals("with")) {
+            sp();
+            Syntax.Statement.Mixins mixins = new Syntax.Statement.Mixins();
+            mixins.start = maybeStart;
+
+            if (!is('[')) {
+                addErr(position(), position(), "expected [");
+
+                // If we're on an identifier, just assume the [ was meant to be there
+                if (!isIdentStart()) {
+                    setEnd(mixins);
+                    statements.add(mixins);
+                    return;
+                }
+            } else {
+                skip();
+            }
+
+            ws();
+            while (!isStructuralBreakpoint() && !eof()) {
+                mixins.mixins.add(ident());
+                ws();
+            }
+
+            if (is(']')) {
+                skip(); // ']'
+            } else {
+                // We either have another structural breakpoint, or eof
+                addErr(position(), position(), "expected ]");
+            }
+
+            setEnd(mixins);
+            statements.add(mixins);
+        }
+    }
+
+    private void member(Syntax.Statement.Block parent) {
+        if (is('@')) {
+            traitApplication(parent);
+        } else if (is('$')) {
+            elidedMember(parent);
+        } else if (isIdentStart()) {
+            int start = positionForStart();
+            Syntax.Ident name = ident();
+            Syntax.Statement.MemberDef memberDef = new Syntax.Statement.MemberDef(parent, name);
+            memberDef.start = start;
+            statements.add(memberDef);
+
+            sp();
+            if (is(':')) {
+                memberDef.colonPos = position();
+                skip();
+            } else {
+                addErr(position(), position(), "expected :");
+                if (isWs() || is('}')) {
+                    setEnd(memberDef);
+                    statements.add(memberDef);
+                    return;
+                }
+            }
+            ws();
+
+            memberDef.target = ident();
+            setEnd(memberDef);
+            ws();
+
+            if (is('=')) {
+                skip();
+                parseNode();
+                ws();
+            }
+
+        } else {
+            addErr(position(), position(),
+                    "unexpected token " + peekSingleCharForMessage() + " expected trait or member");
+            recoverToMemberStart();
+        }
+    }
+
+    private void enumMember(Syntax.Statement.Block parent) {
+        if (is('@')) {
+            traitApplication(parent);
+        } else if (isIdentStart()) {
+            int start = positionForStart();
+            Syntax.Ident name = ident();
+            var enumMemberDef = new Syntax.Statement.EnumMemberDef(parent, name);
+            enumMemberDef.start = start;
+            statements.add(enumMemberDef);
+
+            ws();
+            if (is('=')) {
+                skip(); // '='
+                ws();
+                enumMemberDef.value = parseNode();
+            }
+            setEnd(enumMemberDef);
+        } else {
+            addErr(position(), position(),
+                    "unexpected token " + peekSingleCharForMessage() + " expected trait or member");
+            recoverToMemberStart();
+        }
+    }
+
+    private void elidedMember(Syntax.Statement.Block parent) {
+        int start = positionForStart();
+        skip(); // '$'
+        Syntax.Ident name = ident();
+        var elidedMemberDef = new Syntax.Statement.ElidedMemberDef(parent, name);
+        elidedMemberDef.start = start;
+        setEnd(elidedMemberDef);
+        statements.add(elidedMemberDef);
+    }
+
+    private void inlineMember(Syntax.Statement.Block parent, int start, Syntax.Ident name) {
+        var inlineMemberDef = new Syntax.Statement.InlineMemberDef(parent, name);
+        inlineMemberDef.start = start;
+        setEnd(inlineMemberDef);
+        statements.add(inlineMemberDef);
+
+        ws();
+        while (is('@')) {
+            traitApplication(parent);
+            ws();
+        }
+        throwIfEof("expected {");
+
+        optionalForResourceAndMixins();
+        ws();
+
+        var block = startBlock(parent);
+        ws();
+        while (!is('}') && !eof()) {
+            member(block);
+            ws();
+        }
+        endBlock(block);
+    }
+
+    private void nodeMember(Syntax.Statement.Block parent) {
+        int start = positionForStart();
+        Syntax.Ident name = ident();
+        var nodeMemberDef = new Syntax.Statement.NodeMemberDef(parent, name);
+        nodeMemberDef.start = start;
+
+        sp();
+        if (is(':')) {
+            nodeMemberDef.colonPos = position();
+            skip(); // ':'
+        } else {
+            addErr(position(), position(), "expected :");
+            if (isWs() || is('}')) {
+                setEnd(nodeMemberDef);
+                statements.add(nodeMemberDef);
+                return;
+            }
+        }
+
+        ws();
+        if (is('}')) {
+            addErr(nodeMemberDef.colonPos, nodeMemberDef.colonPos, "expected node");
+        } else {
+            nodeMemberDef.value = parseNode();
+        }
+        setEnd(nodeMemberDef);
+        statements.add(nodeMemberDef);
+    }
+
+    private void traitApplication(Syntax.Statement.Block parent) {
+        int startPos = position();
+        skip(); // '@'
+        Syntax.Ident id = ident();
+        var application = new Syntax.Statement.TraitApplication(parent, id);
+        application.start = startPos;
+        statements.add(application);
+
+        if (is('(')) {
+            int start = position();
+            application.value = traitNode();
+            application.value.start = start;
+            ws();
+            if (is(')')) {
+                setEnd(application.value);
+                skip(); // ')'
+            }
+            // Otherwise, traitNode() probably ate it.
+        }
+        setEnd(application);
+    }
+
+    private Syntax.Ident optIdent() {
+        if (!isIdentStart()) {
+            return Syntax.Ident.EMPTY;
+        }
+        return ident();
+    }
+
+    private Syntax.Ident ident() {
+        int start = position();
+        if (!isIdentStart()) {
+            addErr(start, start, "expected identifier");
+            return Syntax.Ident.EMPTY;
+        }
+
+        do {
+            skip();
+        } while (isIdentChar());
+
+        int end = position();
+        if (start == end) {
+            addErr(start, end, "expected identifier");
+            return Syntax.Ident.EMPTY;
+        }
+        return new Syntax.Ident(start, end);
+    }
+
+    private void addErr(int start, int end, String message) {
+        Syntax.Statement.Err err = new Syntax.Statement.Err(message);
+        err.start = start;
+        err.end = end;
+        errors.add(err);
+    }
+
+    private void recoverToMemberStart() {
+        ws();
+        while (!isIdentStart() && !is('@') && !is('$') && !eof()) {
+            skip();
+            ws();
+        }
+
+        throwIfEof("expected member or trait");
+    }
+
+    private boolean isStructuralBreakpoint() {
+        return switch (peek()) {
+            case '{', '[', '(', '}', ']', ')', ':', '=', '@' -> true;
+            default -> false;
+        };
+    }
+
+    private boolean isIdentStart() {
+        char peeked = peek();
+        return Character.isLetter(peeked) || peeked == '_';
+    }
+
+    private boolean isIdentChar() {
+        char peeked = peek();
+        return Character.isLetterOrDigit(peeked) || peeked == '_' || peeked == '$' || peeked == '.' || peeked == '#';
+    }
+
+    private boolean isDigit() {
+        return Character.isDigit(peek());
+    }
+
+    private boolean isNl() {
+        return switch (peek()) {
+            case '\n', '\r' -> true;
+            default -> false;
+        };
+    }
+
+    private boolean isWs() {
+        return switch (peek()) {
+            case '\n', '\r', ' ', ',', '\t' -> true;
+            default -> false;
+        };
+    }
+
+    private boolean is(char c) {
+        return peek() == c;
+    }
+
+    private void throwIfEof(String message) {
+        if (eof()) {
+            throw new Eof(message);
+        }
+    }
+
+    private void throwIfEofAndFinish(String message, Syntax.Item item) {
+        if (eof()) {
+            setEnd(item);
+            throw new Eof(message);
+        }
+    }
+
+    /**
+     * Used to halt parsing when we reach the end of the file,
+     * without having to bubble up multiple layers.
+     */
+    private static final class Eof extends RuntimeException {
+        final String message;
+
+        Eof(String message) {
+            this.message = message;
+        }
+    }
+
+    @Override
+    public void ws() {
+        while (this.isWs() || is('/')) {
+            if (is('/')) {
+                while (!isNl() && !eof()) {
+                    this.skip();
+                }
+            } else {
+                this.skip();
+            }
+        }
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java
new file mode 100644
index 00000000..d6740fbf
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/Syntax.java
@@ -0,0 +1,765 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.lsp4j.Range;
+import software.amazon.smithy.lsp.document.Document;
+
+/**
+ * Provides classes that represent the syntactic structure of a Smithy file, and
+ * a means to parse Smithy files into those classes.
+ * <p></p>
+ * <h3>IDL Syntax</h3>
+ * The result of a parse, {@link Syntax.IdlParse}, is a list of {@link Statement},
+ * rather than a syntax tree. For example, the following:
+ * <code>
+ *     \@someTrait
+ *     structure Foo with [Bar] {
+ *         \@otherTrait
+ *         foo: String
+ *     }
+ * </code>
+ * Produces the following list of statements:
+ * <code>
+ *     TraitApplication,
+ *     ShapeDef,
+ *     Mixins,
+ *     Block,
+ *     TraitApplication,
+ *     MemberDef
+ * </code>
+ * 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.
+ *
+ * <p>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.
+ *
+ * <p>There are a few things to note about the public API of {@link Statement}s
+ * produced by the parser.
+ * - Any `final` field is definitely assigned, whereas any non `final` field
+ *   may be null (other than {@link Statement#start} and {@link Statement#end},
+ *   which are definitely assigned).
+ * - Concrete text is not stored in {@link Statement}s. Instead,
+ *   {@link Statement#start} and {@link Statement#end} can be used to copy a
+ *   value from the underlying document as needed. This is done to reduce the
+ *   memory footprint of parsing.
+ * <p></p>
+ * <h3>Node Syntax</h3>
+ * This class also provides classes for the JSON-like Smithy Node, which can
+ * be used standalone (see {@link Syntax#parseNode(Document)}). {@link Node}
+ * is a more typical recursive parse tree, so parsing produces a single
+ * {@link Node}, and any given {@link Node} may be a {@link Node.Err}. Like
+ * {@link Statement}, the parser tries to be as lenient as possible here too.
+ */
+public final class Syntax {
+    private Syntax() {
+    }
+
+    public record IdlParse(List<Statement> statements, List<Err> errors) {}
+
+    public record NodeParse(Node value, List<Err> errors) {}
+
+    /**
+     * @param document The document to parse
+     * @return The IDL parse result
+     */
+    public static IdlParse parseIdl(Document document) {
+        Parser parser = new Parser(document);
+        parser.parseIdl();
+        return new IdlParse(parser.statements, parser.errors);
+    }
+
+    /**
+     * @param document The document to parse
+     * @return The Node parse result
+     */
+    public static NodeParse parseNode(Document document) {
+        Parser parser = new Parser(document);
+        Node node = parser.parseNode();
+        return new NodeParse(node, parser.errors);
+    }
+
+    /**
+     * Any syntactic construct has this base type. Mostly used to share
+     * {@link #start()} and {@link #end()} that all items have.
+     */
+    public abstract static sealed class Item {
+        int start;
+        int end;
+
+        public final int start() {
+            return start;
+        }
+
+        public final int end() {
+            return end;
+        }
+
+        /**
+         * @param pos The character offset in a file to check
+         * @return Whether {@code pos} is within this item
+         */
+        public final boolean isIn(int pos) {
+            return start <= pos && end > pos;
+        }
+
+        /**
+         * @param document The document to get the range in
+         * @return The range of this item in the given {@code document}
+         */
+        public final Range rangeIn(Document document) {
+            return document.rangeBetween(start, end);
+        }
+    }
+
+    /**
+     * Common type of all JSON-like node syntax productions.
+     */
+    public abstract static sealed class Node extends Item {
+        /**
+         * @return The type of the node.
+         */
+        public final Type type() {
+            return switch (this) {
+                case Kvps ignored -> Type.Kvps;
+                case Kvp ignored -> Type.Kvp;
+                case Obj ignored -> Type.Obj;
+                case Arr ignored -> Type.Arr;
+                case Ident ignored -> Type.Ident;
+                case Str ignored -> Type.Str;
+                case Num ignored -> Type.Num;
+                case Err ignored -> Type.Err;
+            };
+        }
+
+        /**
+         * Applies this node to {@code consumer}, and traverses this node in
+         * depth-first order.
+         *
+         * @param consumer Consumer to do something with each node.
+         */
+        public final void consume(Consumer<Node> 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<Kvp> kvps = new ArrayList<>();
+
+            void add(Kvp kvp) {
+                kvps.add(kvp);
+            }
+
+            public List<Kvp> 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<Node> elements = new ArrayList<>();
+
+            public List<Node> elements() {
+                return elements;
+            }
+        }
+
+        /**
+         * A string value. The Smithy {@link Node}s can also be regular
+         * identifiers, so this class a single subclass {@link Ident}.
+         */
+        public static sealed class Str extends Node {
+            /**
+             * @param document Document to copy the string value from
+             * @return The literal string value, excluding enclosing ""
+             */
+            public String copyValueFrom(Document document) {
+                return document.copySpan(start + 1, end - 1); // Don't include the '"'s
+            }
+        }
+
+        /**
+         * A numeric value.
+         */
+        public static final class Num extends Node {
+            final BigDecimal value;
+
+            Num(BigDecimal value) {
+                this.value = value;
+            }
+
+            public BigDecimal value() {
+                return value;
+            }
+        }
+
+        /**
+         * An error representing an invalid {@link Node} value.
+         */
+        public static final class Err extends Node implements Syntax.Err {
+            final String message;
+
+            Err(String message) {
+                this.message = message;
+            }
+
+            @Override
+            public String message() {
+                return message;
+            }
+        }
+    }
+
+    /**
+     * Common type of all IDL syntax productions.
+     */
+    public abstract static sealed class Statement extends Item {
+        /**
+         * @return The type of the statement.
+         */
+        public final Type type() {
+            return switch (this) {
+                case Incomplete ignored -> Type.Incomplete;
+                case Control ignored -> Type.Control;
+                case Metadata ignored -> Type.Metadata;
+                case Namespace ignored -> Type.Namespace;
+                case Use ignored -> Type.Use;
+                case Apply ignored -> Type.Apply;
+                case ShapeDef ignored -> Type.ShapeDef;
+                case ForResource ignored -> Type.ForResource;
+                case Mixins ignored -> Type.Mixins;
+                case TraitApplication ignored -> Type.TraitApplication;
+                case MemberDef ignored -> Type.MemberDef;
+                case EnumMemberDef ignored -> Type.EnumMemberDef;
+                case ElidedMemberDef ignored -> Type.ElidedMemberDef;
+                case InlineMemberDef ignored -> Type.InlineMemberDef;
+                case NodeMemberDef ignored -> Type.NodeMemberDef;
+                case Block ignored -> Type.Block;
+                case Err ignored -> Type.Err;
+            };
+        }
+
+        public enum Type {
+            Incomplete,
+            Control,
+            Metadata,
+            Namespace,
+            Use,
+            Apply,
+            ShapeNode,
+            ShapeDef,
+            ForResource,
+            Mixins,
+            TraitApplication,
+            MemberDef,
+            EnumMemberDef,
+            ElidedMemberDef,
+            InlineMemberDef,
+            NodeMemberDef,
+            Block,
+            Err;
+        }
+
+        /**
+         * A single identifier that can't be associated with an actual statement.
+         * For example, `stru` by itself is an incomplete statement.
+         */
+        public static final class Incomplete extends Statement {
+            final Ident ident;
+
+            Incomplete(Ident ident) {
+                this.ident = ident;
+            }
+
+            public Ident ident() {
+                return ident;
+            }
+        }
+
+        /**
+         * A control statement.
+         */
+        public static final class Control extends Statement {
+            final Ident key;
+            Node value;
+
+            Control(Ident key) {
+                this.key = key;
+            }
+
+            public Ident key() {
+                return key;
+            }
+
+            public Node value() {
+                return value;
+            }
+        }
+
+        /**
+         * A metadata statement.
+         */
+        public static final class Metadata extends Statement {
+            final Ident key;
+            Node value;
+
+            Metadata(Ident key) {
+                this.key = key;
+            }
+
+            public Ident key() {
+                return key;
+            }
+
+            public Node value() {
+                return value;
+            }
+        }
+
+        /**
+         * A namespace statement, i.e. `namespace` followed by an identifier.
+         */
+        public static final class Namespace extends Statement {
+            final Ident namespace;
+
+            Namespace(Ident namespace) {
+                this.namespace = namespace;
+            }
+
+            public Ident namespace() {
+                return namespace;
+            }
+        }
+
+        /**
+         * A use statement, i.e. `use` followed by an identifier.
+         */
+        public static final class Use extends Statement {
+            final Ident use;
+
+            Use(Ident use) {
+                this.use = use;
+            }
+
+            public Ident use() {
+                return use;
+            }
+        }
+
+        /**
+         * An apply statement, i.e. `apply` followed by an identifier. Doesn't
+         * include, require, or care about subsequent trait applications.
+         */
+        public static final class Apply extends Statement {
+            final Ident id;
+
+            Apply(Ident id) {
+                this.id = id;
+            }
+
+            public Ident id() {
+                return id;
+            }
+        }
+
+        /**
+         * A shape definition, i.e. a shape type followed by an identifier.
+         */
+        public static final class ShapeDef extends Statement {
+            final Ident shapeType;
+            final Ident shapeName;
+
+            ShapeDef(Ident shapeType, Ident shapeName) {
+                this.shapeType = shapeType;
+                this.shapeName = shapeName;
+            }
+
+            public Ident shapeType() {
+                return shapeType;
+            }
+
+            public Ident shapeName() {
+                return shapeName;
+            }
+        }
+
+        /**
+         * `for` followed by an identifier. Only appears after a {@link ShapeDef}
+         * or after an {@link InlineMemberDef}.
+         */
+        public static final class ForResource extends Statement {
+            final Ident resource;
+
+            ForResource(Ident resource) {
+                this.resource = resource;
+            }
+
+            public Ident resource() {
+                return resource;
+            }
+        }
+
+        /**
+         * `with` followed by an array. The array may not be present in text,
+         * but it is in this production. Only appears after a {@link ShapeDef},
+         * {@link InlineMemberDef}, or {@link ForResource}.
+         */
+        public static final class Mixins extends Statement {
+            final List<Ident> mixins = new ArrayList<>();
+
+            public List<Ident> mixins() {
+                return mixins;
+            }
+        }
+
+        /**
+         * Common type of productions that can appear within shape bodies, i.e.
+         * within a {@link Block}.
+         *
+         * <p>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.</p>
+         *
+         * @see SyntaxSearch#statementIndex(List, int)
+         */
+        abstract static sealed class MemberStatement extends Statement {
+            final Block parent;
+
+            protected MemberStatement(Block parent) {
+                this.parent = parent;
+            }
+
+            /**
+             * @return The possibly null block enclosing this statement.
+             */
+            public Block parent() {
+                return parent;
+            }
+        }
+
+        /**
+         * A trait application, i.e. `@` followed by an identifier.
+         */
+        public static final class TraitApplication extends MemberStatement {
+            final Ident id;
+            Node value;
+
+            TraitApplication(Block parent, Ident id) {
+                super(parent);
+                this.id = id;
+            }
+
+            public Ident id() {
+                return id;
+            }
+
+            public Node value() {
+                return value;
+            }
+        }
+
+        /**
+         * A member definition, i.e. identifier `:` identifier. Only appears
+         * in {@link Block}s.
+         */
+        public static final class MemberDef extends MemberStatement {
+            final Ident name;
+            int colonPos = -1;
+            Ident target;
+
+            MemberDef(Block parent, Ident name) {
+                super(parent);
+                this.name = name;
+            }
+
+            public Ident name() {
+                return name;
+            }
+
+            public Ident target() {
+                return target;
+            }
+
+            /**
+             * @param pos The character offset to check
+             * @return Whether the given offset is within this member's target
+             */
+            public boolean inTarget(int pos) {
+                if (colonPos < 0) {
+                    return false;
+                } else if (target == null || target.isEmpty()) {
+                    return pos > colonPos;
+                } else {
+                    return target.isIn(pos);
+                }
+            }
+        }
+
+        /**
+         * An enum member definition, i.e. an identifier followed by an optional
+         * value assignment. Only appears in {@link Block}s.
+         */
+        public static final class EnumMemberDef extends MemberStatement {
+            final Ident name;
+            Node value;
+
+            EnumMemberDef(Block parent, Ident name) {
+                super(parent);
+                this.name = name;
+            }
+
+            public Ident name() {
+                return name;
+            }
+        }
+
+        /**
+         * An elided member definition, i.e. `$` followed by an identifier. Only
+         * appears in {@link Block}s.
+         */
+        public static final class ElidedMemberDef extends MemberStatement {
+            final Ident name;
+
+            ElidedMemberDef(Block parent, Ident name) {
+                super(parent);
+                this.name = name;
+            }
+
+            public Ident name() {
+                return name;
+            }
+        }
+
+        /**
+         * An inline member definition, i.e. an identifier followed by `:=`. Only
+         * appears in {@link Block}s, and doesn't include the actual definition,
+         * just the member name.
+         */
+        public static final class InlineMemberDef extends MemberStatement {
+            final Ident name;
+
+            InlineMemberDef(Block parent, Ident name) {
+                super(parent);
+                this.name = name;
+            }
+
+            public Ident name() {
+                return name;
+            }
+        }
+
+        /**
+         * A member definition with a node value, i.e. identifier `:` node value.
+         * Only appears in {@link Block}s.
+         */
+        public static final class NodeMemberDef extends MemberStatement {
+            final Ident name;
+            int colonPos = -1;
+            Node value;
+
+            NodeMemberDef(Block parent, Ident name) {
+                super(parent);
+                this.name = name;
+            }
+
+            public Ident name() {
+                return name;
+            }
+
+            public Node value() {
+                return value;
+            }
+
+            /**
+             * @param pos The character offset to check
+             * @return Whether the given {@code pos} is within this member's value
+             */
+            public boolean inValue(int pos) {
+                return (value != null && value.isIn(pos))
+                        || (colonPos >= 0 && pos > colonPos);
+            }
+        }
+
+        /**
+         * Used to indicate the start of a block, i.e. {}.
+         */
+        public static final class Block extends MemberStatement {
+            final int statementIndex;
+            int lastStatementIndex;
+
+            Block(Block parent, int lastStatementIndex) {
+                super(parent);
+                this.statementIndex = lastStatementIndex;
+                this.lastStatementIndex = lastStatementIndex;
+            }
+
+            public int statementIndex() {
+                return statementIndex;
+            }
+
+            public int lastStatementIndex() {
+                return lastStatementIndex;
+            }
+        }
+
+        /**
+         * An error that occurred during IDL parsing. This is distinct from
+         * {@link Node.Err} primarily because {@link Node.Err} is an actual
+         * value a {@link Node} can have.
+         */
+        public static final class Err extends Statement implements Syntax.Err {
+            final String message;
+
+            Err(String message) {
+                this.message = message;
+            }
+
+            @Override
+            public String message() {
+                return message;
+            }
+        }
+    }
+
+    /**
+     * An identifier in a {@link Node} or {@link Statement}. Starts with any
+     * alpha or `_` character, followed by any sequence of Shape ID characters
+     * (i.e. `.`, `#`, `$`, `_` digits, alphas).
+     */
+    public static final class Ident extends Node.Str {
+        static final Ident EMPTY = new Ident(-1, -1);
+
+        Ident(int start, int end) {
+            this.start = start;
+            this.end = end;
+        }
+
+        public boolean isEmpty() {
+            return (start - end) == 0;
+        }
+
+        @Override
+        public String copyValueFrom(Document document) {
+            if (start < 0 && end < 0) {
+                return "";
+            }
+            return document.copySpan(start, end); // There's no '"'s here
+        }
+    }
+
+    /**
+     * Represents any syntax error, either {@link Node} or {@link Statement}.
+     */
+    public sealed interface Err {
+        /**
+         * @return The start index of the error.
+         */
+        int start();
+
+        /**
+         * @return The end index of the error.
+         */
+        int end();
+
+        /**
+         * @return The error message.
+         */
+        String message();
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java b/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java
new file mode 100644
index 00000000..ae3720b4
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/syntax/SyntaxSearch.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import software.amazon.smithy.lsp.document.Document;
+
+/**
+ * Low-level API to query specific information about {@link Syntax.Statement}s
+ * and {@link Syntax.Node}s.
+ */
+public final class SyntaxSearch {
+    private SyntaxSearch() {
+    }
+
+    /**
+     * @param statements The statements to search
+     * @param position The character offset in the document
+     * @return The index of the statement in the list of statements that the
+     * given position is within, or -1 if it was not found.
+     */
+    public static int statementIndex(List<Syntax.Statement> 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<Syntax.Statement> statements, int lower, int upper, int position) {
+        int ogLower = lower;
+        lower += 1;
+        while (lower <= upper) {
+            int mid = (lower + upper) / 2;
+            Syntax.Statement statement = statements.get(mid);
+            if (statement.isIn(position)) {
+                // Could have nested blocks, like in an inline structure definition
+                if (statement instanceof Syntax.Statement.Block) {
+                    return statementIndexBetween(statements, mid, upper, position);
+                }
+                return mid;
+            } else if (statement.start() > position) {
+                upper = mid - 1;
+            } else if (statement.end() < position) {
+                lower = mid + 1;
+            } else {
+                return ogLower;
+            }
+        }
+
+        return ogLower;
+    }
+
+    /**
+     * @param statements The statements to search
+     * @param memberStatementIndex The index of the statement to search from
+     * @return The closest shape def statement appearing before the given index
+     * or {@code null} if none was found.
+     */
+    public static Syntax.Statement.ShapeDef closestShapeDefBeforeMember(
+            List<Syntax.Statement> statements,
+            int memberStatementIndex
+    ) {
+        int searchStatementIdx = memberStatementIndex - 1;
+        while (searchStatementIdx >= 0) {
+            Syntax.Statement searchStatement = statements.get(searchStatementIdx);
+            if (searchStatement instanceof Syntax.Statement.ShapeDef shapeDef) {
+                return shapeDef;
+            }
+            searchStatementIdx--;
+        }
+        return null;
+    }
+
+    /**
+     * @param forResource The nullable for-resource statement
+     * @param mixins The nullable mixins statement
+     */
+    public record ForResourceAndMixins(Syntax.Statement.ForResource forResource, Syntax.Statement.Mixins mixins) {}
+
+    /**
+     * @param statements The statements to search
+     * @param memberStatementIndex The index of the statement to search from
+     * @return The closest adjacent {@link Syntax.Statement.ForResource} and
+     * {@link Syntax.Statement.Mixins} to the statement at the given index.
+     */
+    public static ForResourceAndMixins closestForResourceAndMixinsBeforeMember(
+            List<Syntax.Statement> statements,
+            int memberStatementIndex
+    ) {
+        int searchStatementIndex = memberStatementIndex;
+        while (searchStatementIndex >= 0) {
+            Syntax.Statement searchStatement = statements.get(searchStatementIndex);
+            if (searchStatement instanceof Syntax.Statement.Block) {
+                Syntax.Statement.ForResource forResource = null;
+                Syntax.Statement.Mixins mixins = null;
+
+                int lastSearchIndex = searchStatementIndex - 2;
+                searchStatementIndex--;
+                while (searchStatementIndex >= 0 && searchStatementIndex >= lastSearchIndex) {
+                    Syntax.Statement candidateStatement = statements.get(searchStatementIndex);
+                    if (candidateStatement instanceof Syntax.Statement.Mixins m) {
+                        mixins = m;
+                    } else if (candidateStatement instanceof Syntax.Statement.ForResource f) {
+                        forResource = f;
+                    }
+                    searchStatementIndex--;
+                }
+
+                return new ForResourceAndMixins(forResource, mixins);
+            }
+            searchStatementIndex--;
+        }
+
+        return new ForResourceAndMixins(null, null);
+    }
+
+    /**
+     * @param document The document to search within
+     * @param statements The statements to search
+     * @param memberStatementIndex The index of the member statement to search around
+     * @return The names of other members around (but not including) the member at
+     *  {@code memberStatementIndex}.
+     */
+    public static Set<String> otherMemberNames(
+            Document document,
+            List<Syntax.Statement> statements,
+            int memberStatementIndex
+    ) {
+        Set<String> found = new HashSet<>();
+        int searchIndex = memberStatementIndex;
+        int lastMemberStatementIndex = memberStatementIndex;
+        while (searchIndex >= 0) {
+            Syntax.Statement statement = statements.get(searchIndex);
+            if (statement instanceof Syntax.Statement.Block block) {
+                lastMemberStatementIndex = block.lastStatementIndex();
+                break;
+            } else if (searchIndex != memberStatementIndex) {
+                addMemberName(document, found, statement);
+            }
+            searchIndex--;
+        }
+        searchIndex = memberStatementIndex + 1;
+        while (searchIndex <= lastMemberStatementIndex) {
+            Syntax.Statement statement = statements.get(searchIndex);
+            addMemberName(document, found, statement);
+            searchIndex++;
+        }
+        return found;
+    }
+
+    private static void addMemberName(Document document, Set<String> memberNames, Syntax.Statement statement) {
+        switch (statement) {
+            case Syntax.Statement.MemberDef def -> memberNames.add(def.name().copyValueFrom(document));
+            case Syntax.Statement.NodeMemberDef def -> memberNames.add(def.name().copyValueFrom(document));
+            case Syntax.Statement.InlineMemberDef def -> memberNames.add(def.name().copyValueFrom(document));
+            case Syntax.Statement.ElidedMemberDef def -> memberNames.add(def.name().copyValueFrom(document));
+            default -> {
+            }
+        }
+    }
+
+    /**
+     * @param statements The statements to search
+     * @param traitStatementIndex The index of the trait statement to search from
+     * @return The closest shape def statement after {@code traitStatementIndex},
+     *  or null if none was found.
+     */
+    public static Syntax.Statement.ShapeDef closestShapeDefAfterTrait(
+            List<Syntax.Statement> statements,
+            int traitStatementIndex
+    ) {
+        for (int i = traitStatementIndex + 1; i < statements.size(); i++) {
+            Syntax.Statement statement = statements.get(i);
+            if (statement instanceof Syntax.Statement.ShapeDef shapeDef) {
+                return shapeDef;
+            } else if (!(statement instanceof Syntax.Statement.TraitApplication)) {
+                return null;
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java
new file mode 100644
index 00000000..86b0d669
--- /dev/null
+++ b/src/main/java/software/amazon/smithy/lsp/util/StreamUtils.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.util;
+
+import java.util.Map;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+
+public final class StreamUtils {
+    private StreamUtils() {
+    }
+
+    public static <T, U> Collector<Map.Entry<T, U>, ?, Map<T, U>> toMap() {
+        return Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue);
+    }
+
+    public static Collector<String, ?, Map<String, String>> toWrappedMap() {
+        return Collectors.toMap(s -> s, s -> "\"" + s + "\"");
+    }
+}
diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep
deleted file mode 100644
index 445d5757..00000000
--- a/src/main/resources/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-Delete this file as soon as actual an actual resources is added to this directory.
\ No newline at end of file
diff --git a/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy b/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy
new file mode 100644
index 00000000..238cd0f5
--- /dev/null
+++ b/src/main/resources/software/amazon/smithy/lsp/language/builtins.smithy
@@ -0,0 +1,37 @@
+$version: "2.0"
+
+namespace smithy.lang.server
+
+string SmithyIdlVersion
+
+string AnyNamespace
+
+string ValidatorName
+
+structure ValidatorConfig {}
+
+string Selector
+
+@idRef
+string AnyShape
+
+@idRef
+string AnyTrait
+
+@idRef
+string AnyMixin
+
+@idRef
+string AnyString
+
+@idRef
+string AnyError
+
+@idRef
+string AnyOperation
+
+@idRef
+string AnyResource
+
+@idRef
+string AnyMemberTarget
diff --git a/src/main/resources/software/amazon/smithy/lsp/language/control.smithy b/src/main/resources/software/amazon/smithy/lsp/language/control.smithy
new file mode 100644
index 00000000..eb0fdd5e
--- /dev/null
+++ b/src/main/resources/software/amazon/smithy/lsp/language/control.smithy
@@ -0,0 +1,17 @@
+$version: "2.0"
+
+namespace smithy.lang.server
+
+structure BuiltinControl {
+    /// Defines the [version](https://smithy.io/2.0/spec/idl.html#smithy-version)
+    /// of the smithy idl used in this model file.
+    version: SmithyIdlVersion = "2.0"
+
+    /// Defines the suffix used when generating names for
+    /// [inline operation input](https://smithy.io/2.0/spec/idl.html#idl-inline-input-output).
+    operationInputSuffix: String = "Input"
+
+    /// Defines the suffix used when generating names for
+    /// [inline operation output](https://smithy.io/2.0/spec/idl.html#idl-inline-input-output).
+    operationOutputSuffix: String = "Output"
+}
diff --git a/src/main/resources/software/amazon/smithy/lsp/language/members.smithy b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy
new file mode 100644
index 00000000..42b50fe8
--- /dev/null
+++ b/src/main/resources/software/amazon/smithy/lsp/language/members.smithy
@@ -0,0 +1,75 @@
+$version: "2.0"
+
+namespace smithy.lang.server
+
+structure ShapeMemberTargets {
+    service: ServiceShape
+    operation: OperationShape
+    resource: ResourceShape
+    list: ListShape
+    map: MapShape
+}
+
+structure ServiceShape {
+    version: String
+    operations: Operations
+    resources: Resources
+    errors: Errors
+    rename: Rename
+}
+
+list Operations {
+    member: AnyOperation
+}
+
+list Resources {
+    member: AnyResource
+}
+
+list Errors {
+    member: AnyError
+}
+
+map Rename {
+    key: AnyShape
+    value: String
+}
+
+structure OperationShape {
+    input: AnyMemberTarget
+    output: AnyMemberTarget
+    errors: Errors
+}
+
+structure ResourceShape {
+    identifiers: Identifiers
+    properties: Properties
+    create: AnyOperation
+    put: AnyOperation
+    read: AnyOperation
+    update: AnyOperation
+    delete: AnyOperation
+    list: AnyOperation
+    operations: Operations
+    collectionOperations: Operations
+    resources: Resources
+}
+
+map Identifiers {
+    key: String
+    value: AnyString
+}
+
+map Properties {
+    key: String
+    value: AnyMemberTarget
+}
+
+structure ListShape {
+    member: AnyMemberTarget
+}
+
+structure MapShape {
+    key: AnyString
+    value: AnyMemberTarget
+}
diff --git a/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy
new file mode 100644
index 00000000..a3c38cbb
--- /dev/null
+++ b/src/main/resources/software/amazon/smithy/lsp/language/metadata.smithy
@@ -0,0 +1,95 @@
+$version: "2.0"
+
+namespace smithy.lang.server
+
+structure BuiltinMetadata {
+    /// Suppressions are used to suppress specific validation events.
+    /// See [Suppressions](https://smithy.io/2.0/spec/model-validation.html#suppressions)
+    suppressions: Suppressions
+
+    /// An array of validator objects used to constrain the model.
+    /// See [Validators](https://smithy.io/2.0/spec/model-validation.html#validators)
+    validators: Validators
+
+    /// An array of severity override objects used to raise the severity of non-suppressed validation events.
+    /// See [Severity overrides](https://smithy.io/2.0/spec/model-validation.html#severity-overrides)
+    severityOverrides: SeverityOverrides
+}
+
+list Suppressions {
+    member: Suppression
+}
+
+list Validators {
+    member: Validator
+}
+
+list SeverityOverrides {
+    member: SeverityOverride
+}
+
+structure Suppression {
+    /// The hierarchical validation event ID to suppress.
+    id: String
+
+    /// The validation event is only suppressed if it matches the supplied namespace.
+    /// A value of * can be provided to match any namespace.
+    /// * is useful for suppressing validation events that are not bound to any specific shape.
+    namespace: AnyNamespace
+
+    /// Provides an optional reason for the suppression.
+    reason: String
+}
+
+structure Validator {
+    name: ValidatorName
+    id: String
+    message: String
+    severity: ValidatorSeverity
+    namespaces: AnyNamespaces
+    selector: String
+    configuration: ValidatorConfig
+}
+
+enum ValidatorSeverity {
+    NOTE = "NOTE"
+    WARNING = "WARNING"
+    DANGER = "DANGER"
+}
+
+list AnyNamespaces {
+    member: AnyNamespace
+}
+
+structure SeverityOverride {
+    id: String
+    namespace: AnyNamespace
+    severity: SeverityOverrideSeverity
+}
+
+enum SeverityOverrideSeverity {
+    WARNING = "WARNING"
+    DANGER = "DANGER"
+}
+
+structure BuiltinValidators {
+    EmitEachSelector: EmitEachSelectorConfig
+    EmitNoneSelector: EmitNoneSelectorConfig
+    UnreferencedShapes: UnreferencedShapesConfig
+}
+
+structure EmitEachSelectorConfig {
+    @required
+    selector: Selector
+    bindToTrait: AnyTrait
+    messageTemplate: String
+}
+
+structure EmitNoneSelectorConfig {
+    @required
+    selector: Selector
+}
+
+structure UnreferencedShapesConfig {
+    selector: Selector = "service"
+}
diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java
index 4048b749..cab974b4 100644
--- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java
+++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java
@@ -5,6 +5,7 @@
 
 package software.amazon.smithy.lsp;
 
+import java.util.Collection;
 import org.eclipse.lsp4j.CompletionItem;
 import org.eclipse.lsp4j.Diagnostic;
 import org.eclipse.lsp4j.Range;
@@ -59,6 +60,34 @@ public void describeMismatchSafely(TextEdit textEdit, Description description) {
         };
     }
 
+    public static Matcher<Collection<TextEdit>> togetherMakeEditedDocument(Document document, String expected) {
+        return new CustomTypeSafeMatcher<>("make edited document " + expected) {
+            @Override
+            protected boolean matchesSafely(Collection<TextEdit> item) {
+                Document copy = document.copy();
+                for (TextEdit edit : item) {
+                    copy.applyEdit(edit.getRange(), edit.getNewText());
+                }
+                return copy.copyText().equals(expected);
+            }
+
+            @Override
+            public void describeMismatchSafely(Collection<TextEdit> item, Description description) {
+                Document copy = document.copy();
+                for (TextEdit edit : item) {
+                    copy.applyEdit(edit.getRange(), edit.getNewText());
+                }
+                String actual = copy.copyText();
+                description.appendText(String.format("""
+                        expected:
+                        '%s'
+                        but was:
+                        '%s'
+                        """, expected, actual));
+            }
+        };
+    }
+
     public static Matcher<Range> hasText(Document document, Matcher<String> expected) {
         return new CustomTypeSafeMatcher<>("text in range") {
             @Override
diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java
index cbbb4228..61cd6936 100644
--- a/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java
+++ b/src/test/java/software/amazon/smithy/lsp/SmithyLanguageServerTest.java
@@ -184,7 +184,7 @@ public void completionImports() throws Exception {
         CompletionParams completionParams = new RequestBuilders.PositionRequest()
                 .uri(uri)
                 .line(4)
-                .character(10)
+                .character(11)
                 .buildCompletion();
         List<CompletionItem> completions = server.completion(completionParams).get().getLeft();
 
@@ -1776,7 +1776,7 @@ public void useCompletionDoesntAutoImport() throws Exception {
         List<CompletionItem> completions = server.completion(RequestBuilders.positionRequest()
                 .uri(uri)
                 .line(2)
-                .character(5)
+                .character(6)
                 .buildCompletion())
                 .get()
                 .getLeft();
diff --git a/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java
new file mode 100644
index 00000000..6cb2b594
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/TextWithPositions.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp;
+
+import static software.amazon.smithy.lsp.document.DocumentTest.safeString;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.lsp4j.Position;
+import software.amazon.smithy.lsp.document.Document;
+
+/**
+ * Wraps some text and positions within that text for easier testing of features
+ * that operate on cursor positions within a text document.
+ *
+ * @param text The underlying text
+ * @param positions The positions within {@code text}
+ */
+public record TextWithPositions(String text, Position... positions) {
+    private static final String POSITION_MARKER = "%";
+
+    /**
+     * A convenience method for constructing {@link TextWithPositions} without
+     * manually specifying the positions, which are error-prone and hard to
+     * read.
+     *
+     * <p>The string provided to this method can contain position markers,
+     * the {@code %} character, denoting where {@link #positions} should
+     * be. Each marker will be removed from {@link #text}.</p>
+     *
+     * @param raw The raw string with position markers
+     * @return {@link TextWithPositions} with positions where the markers were,
+     *  and those markers removed.
+     */
+    public static TextWithPositions from(String raw) {
+        Document document = Document.of(safeString(raw));
+        List<Position> positions = new ArrayList<>();
+        int i = 0;
+        while (true) {
+            int next = document.nextIndexOf(POSITION_MARKER, i);
+            if (next < 0) {
+                break;
+            }
+            Position position = document.positionAtIndex(next);
+            positions.add(position);
+            i = next + 1;
+        }
+        String text = document.copyText().replace(POSITION_MARKER, "");
+        return new TextWithPositions(text, positions.toArray(new Position[0]));
+    }}
diff --git a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java
index db29102a..b3bdbede 100644
--- a/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java
+++ b/src/test/java/software/amazon/smithy/lsp/document/DocumentParserTest.java
@@ -6,115 +6,19 @@
 package software.amazon.smithy.lsp.document;
 
 import static org.hamcrest.CoreMatchers.equalTo;
-import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.containsInAnyOrder;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static software.amazon.smithy.lsp.document.DocumentTest.safeIndex;
 import static software.amazon.smithy.lsp.document.DocumentTest.safeString;
 import static software.amazon.smithy.lsp.document.DocumentTest.string;
 
 import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
 import org.eclipse.lsp4j.Position;
 import org.eclipse.lsp4j.Range;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.lsp.protocol.LspAdapter;
-import software.amazon.smithy.model.Model;
-import software.amazon.smithy.model.SourceLocation;
-import software.amazon.smithy.model.shapes.Shape;
 
 public class DocumentParserTest {
-    @Test
-    public void jumpsToLines() {
-        String text = """
-                abc
-                def
-                ghi
-
-
-                """;
-        DocumentParser parser = DocumentParser.of(safeString(text));
-        assertEquals(0, parser.position());
-        assertEquals(1, parser.line());
-        assertEquals(1, parser.column());
-
-        parser.jumpToLine(0);
-        assertEquals(0, parser.position());
-        assertEquals(1, parser.line());
-        assertEquals(1, parser.column());
-
-        parser.jumpToLine(1);
-        assertEquals(safeIndex(4, 1), parser.position());
-        assertEquals(2, parser.line());
-        assertEquals(1, parser.column());
-
-        parser.jumpToLine(2);
-        assertEquals(safeIndex(8, 2), parser.position());
-        assertEquals(3, parser.line());
-        assertEquals(1, parser.column());
-
-        parser.jumpToLine(3);
-        assertEquals(safeIndex(12, 3), parser.position());
-        assertEquals(4, parser.line());
-        assertEquals(1, parser.column());
-
-        parser.jumpToLine(4);
-        assertEquals(safeIndex(13, 4), parser.position());
-        assertEquals(5, parser.line());
-        assertEquals(1, parser.column());
-    }
-
-    @Test
-    public void jumpsToSource() {
-        String text = "abc\ndef\nghi\n";
-        DocumentParser parser = DocumentParser.of(safeString(text));
-        assertThat(parser.position(), is(0));
-        assertThat(parser.line(), is(1));
-        assertThat(parser.column(), is(1));
-        assertThat(parser.currentPosition(), equalTo(new Position(0, 0)));
-
-        boolean ok = parser.jumpToSource(new SourceLocation("", 1, 2));
-        assertThat(ok, is(true));
-        assertThat(parser.position(), is(1));
-        assertThat(parser.line(), is(1));
-        assertThat(parser.column(), is(2));
-        assertThat(parser.currentPosition(), equalTo(new Position(0, 1)));
-
-        ok = parser.jumpToSource(new SourceLocation("", 1, 4));
-        assertThat(ok, is(true));
-        assertThat(parser.position(), is(3));
-        assertThat(parser.line(), is(1));
-        assertThat(parser.column(), is(4));
-        assertThat(parser.currentPosition(), equalTo(new Position(0, 3)));
-
-        ok = parser.jumpToSource(new SourceLocation("", 1, 6));
-        assertThat(ok, is(false));
-        assertThat(parser.position(), is(3));
-        assertThat(parser.line(), is(1));
-        assertThat(parser.column(), is(4));
-        assertThat(parser.currentPosition(), equalTo(new Position(0, 3)));
-
-        ok = parser.jumpToSource(new SourceLocation("", 2, 1));
-        assertThat(ok, is(true));
-        assertThat(parser.position(), is(safeIndex(4, 1)));
-        assertThat(parser.line(), is(2));
-        assertThat(parser.column(), is(1));
-        assertThat(parser.currentPosition(), equalTo(new Position(1, 0)));
-
-        ok = parser.jumpToSource(new SourceLocation("", 4, 1));
-        assertThat(ok, is(false));
-
-        ok = parser.jumpToSource(new SourceLocation("", 3, 4));
-        assertThat(ok, is(true));
-        assertThat(parser.position(), is(safeIndex(11, 2)));
-        assertThat(parser.line(), is(3));
-        assertThat(parser.column(), is(4));
-        assertThat(parser.currentPosition(), equalTo(new Position(2, 3)));
-    }
-
     @Test
     public void getsDocumentNamespace() {
         DocumentParser noNamespace = DocumentParser.of(safeString("abc\ndef\n"));
@@ -142,7 +46,7 @@ public void getsDocumentNamespace() {
         assertThat(wsPrefixedNamespace.documentNamespace().statementRange(), equalTo(LspAdapter.of(1, 4, 1, 21)));
         assertThat(notNamespace.documentNamespace(), nullValue());
         assertThat(trailingComment.documentNamespace().namespace().toString(), equalTo("com.foo"));
-        assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 22)));
+        assertThat(trailingComment.documentNamespace().statementRange(), equalTo(LspAdapter.of(0, 0, 0, 17)));
     }
 
     @Test
@@ -200,7 +104,7 @@ public void getsDocumentVersion() {
         assertThat(noVersion.documentVersion(), nullValue());
         assertThat(notVersion.documentVersion(), nullValue());
         assertThat(noDollar.documentVersion(), nullValue());
-        assertThat(noColon.documentVersion(), nullValue());
+        assertThat(noColon.documentVersion().version(), equalTo("2"));
         assertThat(commented.documentVersion(), nullValue());
         assertThat(leadingWs.documentVersion().version(), equalTo("2"));
         assertThat(leadingLines.documentVersion().version(), equalTo("2"));
@@ -253,16 +157,8 @@ enum Baz {
                     }
                 }
                 """;
-        Set<Shape> shapes = Model.assembler()
-                .addUnparsedModel("main.smithy", text)
-                .assemble()
-                .unwrap()
-                .shapes()
-                .filter(shape -> shape.getId().getNamespace().equals("com.foo"))
-                .collect(Collectors.toSet());
-
         DocumentParser parser = DocumentParser.of(safeString(text));
-        Map<Position, DocumentShape> documentShapes = parser.documentShapes(shapes);
+        Map<Position, DocumentShape> documentShapes = parser.documentShapes();
 
         DocumentShape fooDef = documentShapes.get(new Position(2, 7));
         DocumentShape barDef = documentShapes.get(new Position(3, 10));
@@ -279,7 +175,6 @@ enum Baz {
         DocumentShape mixedDef = documentShapes.get(new Position(17, 10));
         DocumentShape elided = documentShapes.get(new Position(18, 4));
         DocumentShape get = documentShapes.get(new Position(20, 10));
-        DocumentShape getInput = documentShapes.get(new Position(21, 13));
         DocumentShape getInputA = documentShapes.get(new Position(22, 8));
 
         assertThat(fooDef.kind(), equalTo(DocumentShape.Kind.DefinedShape));
@@ -315,7 +210,6 @@ enum Baz {
         assertThat(parser.getDocument().borrowRange(elided.range()), string("$elided"));
         assertThat(get.kind(), equalTo(DocumentShape.Kind.DefinedShape));
         assertThat(get.shapeName(), string("Get"));
-        assertThat(getInput.kind(), equalTo(DocumentShape.Kind.Inline));
         assertThat(getInputA.kind(), equalTo(DocumentShape.Kind.DefinedMember));
         assertThat(getInputA.shapeName(), string("a"));
     }
diff --git a/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java
new file mode 100644
index 00000000..a633888c
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/language/CompletionHandlerTest.java
@@ -0,0 +1,1062 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasItems;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.startsWith;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionParams;
+import org.eclipse.lsp4j.Position;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.lsp.LspMatchers;
+import software.amazon.smithy.lsp.RequestBuilders;
+import software.amazon.smithy.lsp.ServerState;
+import software.amazon.smithy.lsp.TestWorkspace;
+import software.amazon.smithy.lsp.TextWithPositions;
+import software.amazon.smithy.lsp.document.Document;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.project.ProjectLoader;
+import software.amazon.smithy.lsp.project.SmithyFile;
+
+public class CompletionHandlerTest {
+    @Test
+    public void getsCompletions() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: String
+                    baz: Integer
+                }
+                
+                @foo(ba%)""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("bar", "baz"));
+    }
+
+    @Test
+    public void completesTraitMemberValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: String
+                    baz: Integer
+                }
+                
+                @foo(bar: %)
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("\"\""));
+    }
+
+    @Test
+    public void completesMetadataMemberValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                metadata suppressions = [{
+                    namespace:%
+                }]""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, not(empty()));
+    }
+
+    @Test
+    public void doesntDuplicateTraitBodyMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: String
+                    baz: String
+                }
+                
+                @foo(bar: "", ba%)""");
+        List<String> comps = getCompLabels(text);
+
+         assertThat(comps, containsInAnyOrder("baz"));
+    }
+
+    @Test
+    public void doesntDuplicateMetadataMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                metadata suppressions = [{
+                    namespace: "foo"
+                %}]
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("id", "reason"));
+    }
+
+    @Test
+    public void doesntDuplicateListAndMapMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                list L {
+                    member: String
+                %}
+                map M {
+                    key: String
+                %}
+                """);
+        List<String> comps = getCompLabels(text);
+
+
+        assertThat(comps, contains("value"));
+    }
+
+    @Test
+    public void doesntDuplicateOperationMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                operation O {
+                    input := {}
+                %}
+                """);
+        List<String> comps = getCompLabels(text);
+        assertThat(comps, containsInAnyOrder("output", "errors"));
+    }
+
+    @Test
+    public void doesntDuplicateServiceMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                service S {
+                    version: "2024-08-31"
+                    operations: []
+                %}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("rename", "resources", "errors"));
+    }
+
+    @Test
+    public void doesntDuplicateResourceMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                resource R {
+                    identifiers: {}
+                    properties: {}
+                    read: Op
+                    create: Op
+                %}
+                
+                operation Op {}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder(
+                "list", "put", "delete", "update", "collectionOperations", "operations", "resources"));
+    }
+
+    @Test
+    public void completesEnumTraitValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                enum foo {
+                    ONE
+                    TWO
+                    THREE
+                }
+                
+                @foo(T%)
+                """);
+        List<CompletionItem> comps = getCompItems(text.text(), text.positions());
+
+        List<String> labels = comps.stream().map(CompletionItem::getLabel).toList();
+        List<String> editText = comps.stream()
+                .map(completionItem -> {
+                    if (completionItem.getTextEdit() != null) {
+                        return completionItem.getTextEdit().getLeft().getNewText();
+                    } else {
+                        return completionItem.getInsertText();
+                    }
+                }).toList();
+
+        assertThat(labels, containsInAnyOrder("TWO", "THREE"));
+        assertThat(editText, containsInAnyOrder("\"TWO\"", "\"THREE\""));
+        // TODO: Fix this issue where the string is inserted within the enclosing ""
+    }
+
+    @Test
+    public void completesFromSingleCharacter() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @http(m%""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("method"));
+    }
+
+    @Test
+    public void completesBuiltinControlKeys() {
+        TextWithPositions text = TextWithPositions.from("""
+                $ver%
+                $ope%""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder(
+                startsWith("$version: \"2.0\""),
+                startsWith("$operationInputSuffix: \"Input\""),
+                startsWith("$operationOutputSuffix: \"Output\"")));
+    }
+
+    @Test
+    public void completesBuiltinMetadataKeys() {
+        TextWithPositions text = TextWithPositions.from("""
+                metadata su%
+                metadata va%""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("suppressions = []", "validators = []"));
+    }
+
+    @Test
+    public void completesStatementKeywords() {
+        TextWithPositions text = TextWithPositions.from("""
+                us%
+                ma%
+                met%
+                nam%
+                blo%
+                boo%
+                str%
+                byt%
+                sho%
+                int%
+                lon%
+                flo%
+                dou%
+                big%
+                tim%
+                doc%
+                enu%
+                lis%
+                uni%
+                ser%
+                res%
+                ope%
+                app%""");
+        List<String> comps = getCompLabels(text);
+
+        String[] keywords = Candidates.KEYWORD.literals().toArray(new String[0]);
+        assertThat(comps, containsInAnyOrder(keywords));
+    }
+
+    @Test
+    public void completesServiceMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                service One {
+                    ver%
+                    ope%
+                    res%
+                    err%
+                    ren%
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("version", "operations", "resources", "errors", "rename"));
+    }
+
+    @Test
+    public void completesResourceMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                resource A {
+                    ide%
+                    pro%
+                    cre%
+                    pu%
+                    rea%
+                    upd%
+                    del%
+                    lis%
+                    ope%
+                    coll%
+                    res%
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder(
+                "identifiers",
+                "properties",
+                "create",
+                "put",
+                "read",
+                "update",
+                "delete",
+                "list",
+                "operations",
+                "collectionOperations",
+                "resources"));
+    }
+
+    @Test
+    public void completesOperationMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                operation Op {
+                    inp%
+                    out%
+                    err%
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("input", "output", "errors"));
+    }
+
+    @Test
+    public void completesListAndMapMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                map M {
+                    k%
+                    v%
+                }
+                list L {
+                    m%
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("key", "value", "member"));
+    }
+
+    @Test
+    public void completesMetadataValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                metadata validators = [{ nam% }]
+                metadata suppressions = [{ rea% }]
+                metadata severityOverrides = [{ sev% }]
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("namespaces", "name", "reason", "severity"));
+    }
+
+    @Test
+    public void completesMetadataValueWithoutStartingCharacter() {
+        TextWithPositions text = TextWithPositions.from("""
+                metadata suppressions = [{%""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("id", "namespace", "reason"));
+    }
+
+    @Test
+    public void completesTraitValueWithoutStartingCharacter() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                @http(%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("method", "uri", "code"));
+    }
+
+    @Test
+    public void completesShapeMemberNameWithoutStartingCharacter() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                list Foo {
+                %
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("member"));
+    }
+
+    // TODO: These next two shouldn't need the space after ':'
+    @Test
+    public void completesMemberTargetsWithoutStartingCharacter() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo
+                structure Foo {
+                    bar: %
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, hasItems("String", "Integer", "Float"));
+    }
+
+    @Test
+    public void completesOperationMemberTargetsWithoutStartingCharacters() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo
+                structure Foo {}
+                operation Bar {
+                    input: %
+                }""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, hasItem("Foo"));
+    }
+
+    @Test
+    public void completesTraitsWithoutStartingCharacter() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo
+                @%""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, hasItem("http"));
+    }
+
+    @Test
+    public void completesOperationErrors() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo
+                @error("client")
+                structure MyError {}
+                
+                operation Foo {
+                    errors: [%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("MyError"));
+    }
+
+    @Test
+    public void completesServiceMemberValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo
+                service Foo {
+                    operations: [%]
+                    resources:  [%]
+                    errors:     [%]
+                }
+                operation MyOp {}
+                resource MyResource {}
+                @error("client")
+                structure MyError {}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("MyOp", "MyResource", "MyError"));
+    }
+
+    @Test
+    public void completesResourceMemberValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo
+                resource Foo {
+                    create: M%
+                    operations: [O%]
+                    resources: [%]
+                }
+                operation MyOp {}
+                operation OtherOp {}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("MyOp", "OtherOp", "Foo"));
+    }
+
+    @Test
+    public void insertionTextHasCorrectRange() {
+        TextWithPositions text = TextWithPositions.from("metadata suppressions = [%]");
+
+        var comps = getCompItems(text.text(), text.positions());
+        var edits = comps.stream().map(item -> item.getTextEdit().getLeft()).toList();
+
+        assertThat(edits, LspMatchers.togetherMakeEditedDocument(Document.of(text.text()), "metadata suppressions = [{}]"));
+    }
+
+    @Test
+    public void completesNamespace() {
+        TextWithPositions text = TextWithPositions.from("""
+                namespace com.foo%""");
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, hasItem("com.foo"));
+    }
+
+    // TODO: This shouldn't need the space after the ':'
+    @Test
+    public void completesInlineOpMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                operation Op {
+                    input :=
+                        @tags([])
+                        {
+                        foo: %
+                        }
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+
+        assertThat(comps, hasItem("String"));
+    }
+
+    @Test
+    public void completesNamespacesInMetadata() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                metadata suppressions = [{
+                    id: "foo"
+                    namespace:%
+                }]
+                namespace com.foo
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, hasItem("*"));
+    }
+
+    @Test
+    public void completesSeverityInMetadata() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                metadata severityOverrides = [{
+                    id: "foo"
+                    severity:%
+                }]
+                namespace com.foo
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("WARNING", "DANGER"));
+    }
+
+    @Test
+    public void completesValidatorNamesInMetadata() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                metadata validators = [{
+                    id: "foo"
+                    name:%
+                }]
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, hasItems("EmitEachSelector", "EmitNoneSelector"));
+    }
+
+    @Test
+    public void completesValidatorConfigInMetadata() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                metadata validators = [{
+                    id: "foo"
+                    name: "EmitNoneSelector"
+                    configuration: {%}
+                }]
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("selector"));
+    }
+
+    @Test
+    public void doesntCompleteTraitsAfterClosingParen() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @error("client")%
+                structure Foo {}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, empty());
+    }
+
+    @Test
+    public void doesntCompleteTraitsAfterClosingParen2() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bool: Boolean
+                }
+                
+                @foo(bool: true)%
+                structure Foo {}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, empty());
+    }
+
+    @Test
+    public void recursiveTraitDef() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: Bar
+                }
+                
+                structure Bar {
+                    bar: Bar
+                }
+                
+                @foo(bar: { bar: { b%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("bar"));
+    }
+
+    @Test
+    public void recursiveTraitDef2() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: Bar
+                }
+                
+                structure Bar {
+                    one: Baz
+                }
+                
+                structure Baz {
+                    two: Bar
+                }
+                
+                @foo(bar: { one: { two: { o%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("one"));
+    }
+
+    @Test
+    public void recursiveTraitDef3() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: Bar
+                }
+                
+                list Bar {
+                    member: Baz
+                }
+                
+                structure Baz {
+                    bar: Bar
+                }
+                
+                @foo(bar: [{bar: [{b%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("bar"));
+    }
+
+    @Test
+    public void recursiveTraitDef4() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: Bar
+                }
+                
+                structure Bar {
+                    baz: Baz
+                }
+                
+                list Baz {
+                    member: Bar
+                }
+                
+                @foo(bar: {baz:[{baz:[{b%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("baz"));
+    }
+
+    @Test
+    public void recursiveTraitDef5() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: Bar
+                }
+                
+                structure Bar {
+                    baz: Baz
+                }
+                
+                map Baz {
+                    key: String
+                    value: Bar
+                }
+                
+                @foo(bar: {baz: {key: {baz: {key: {b%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("baz"));
+    }
+
+    @Test
+    public void completesInlineForResource() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                resource MyResource {
+                }
+                
+                operation Foo {
+                    input := for %
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("MyResource"));
+    }
+
+    @Test
+    public void completesElidedMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                resource MyResource {
+                    identifiers: { one: String }
+                    properties: { abc: String }
+                }
+                
+                resource MyResource2 {
+                    identifiers: { two: String }
+                    properties: { def: String }
+                }
+                
+                @mixin
+                structure MyMixin {
+                    foo: String
+                }
+                
+                @mixin
+                structure MyMixin2 {
+                    bar: String
+                }
+                
+                structure One for MyResource {
+                    $%
+                }
+                
+                structure Two with [MyMixin] {
+                    $%
+                }
+                
+                operation MyOp {
+                    input := for MyResource2 {
+                        $%
+                    }
+                    output := with [MyMixin2] {
+                        $%
+                    }
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("$one", "$foo", "$two", "$bar", "$abc", "$def"));
+    }
+
+    @Test
+    public void traitsWithMaps() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    myMap: MyMap
+                }
+                
+                map MyMap {
+                    key: String
+                    value: String
+                }
+                
+                @foo(myMap: %)
+                structure A {}
+                
+                @foo(myMap: {%})
+                structure B {}
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("{}"));
+    }
+
+    @Test
+    public void applyTarget() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                string Zzz
+                
+                apply Z%
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("Zzz"));
+    }
+
+    @Test
+    public void enumMapKeys() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                enum Keys {
+                    FOO = "foo"
+                    BAR = "bar"
+                }
+                
+                @trait
+                map mapTrait {
+                    key: Keys
+                    value: String
+                }
+                
+                @mapTrait(%)
+                string Foo
+                
+                @mapTrait({%})
+                string Bar
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("FOO", "BAR", "FOO", "BAR"));
+    }
+
+    @Test
+    public void dynamicTraitValues() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace smithy.test
+                
+                @trait
+                list smokeTests {
+                    member: SmokeTestCase
+                }
+                
+                structure SmokeTestCase {
+                    params: Document
+                    vendorParams: Document
+                    vendorParamsShape: ShapeId
+                }
+                
+                @idRef
+                string ShapeId
+                
+                @smokeTests([
+                    {
+                        params: {%}
+                        vendorParamsShape: MyVendorParams
+                        vendorParams: {%}
+                    }
+                ])
+                operation Foo {
+                    input := {
+                        bar: String
+                    }
+                }
+                
+                structure MyVendorParams {
+                    abc: String
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("bar", "abc"));
+    }
+
+    @Test
+    public void doesntDuplicateElidedMembers() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                structure Foo {
+                    abc: String
+                    ade: String
+                }
+                
+                structure Bar with [Foo] {
+                    $abc
+                    $%
+                }
+                
+                structure Baz with [Foo] {
+                    abc: String
+                    $%
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("$ade", "$ade"));
+    }
+
+    @Test
+    public void knownMemberNamesWithElided() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                map Foo {
+                    key: String
+                    value: String
+                }
+                
+                map Bar with [Foo] {
+                    key: String
+                    %
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("value", "$value"));
+    }
+
+    @Test
+    public void unknownMemberNamesWithElided() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                structure Foo {
+                    abc: String
+                    def: String
+                }
+                
+                structure Bar with [Foo] {
+                    $abc
+                    %
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("$def"));
+    }
+
+    @Test
+    public void completesElidedMembersWithoutLeadingDollar() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                structure Foo {
+                    abc: String
+                }
+                
+                structure Bar with [Foo] {
+                    ab%
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, contains("$abc"));
+    }
+
+    @Test
+    public void completesNodeMemberTargetStart() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                service A {
+                    version: %
+                }
+                service B {
+                    operations: %
+                }
+                resource C {
+                    identifiers: %
+                }
+                operation D {
+                    errors: %
+                }
+                """);
+        List<String> comps = getCompLabels(text);
+
+        assertThat(comps, containsInAnyOrder("\"\"", "[]", "{}", "[]"));
+    }
+
+    private static List<String> getCompLabels(TextWithPositions textWithPositions) {
+        return getCompLabels(textWithPositions.text(), textWithPositions.positions());
+    }
+
+    private static List<String> getCompLabels(String text, Position... positions) {
+        return getCompItems(text, positions).stream().map(CompletionItem::getLabel).toList();
+    }
+
+    private static List<CompletionItem> getCompItems(String text, Position... positions) {
+        TestWorkspace workspace = TestWorkspace.singleModel(text);
+        Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap();
+        String uri = workspace.getUri("main.smithy");
+        SmithyFile smithyFile = project.getSmithyFile(uri);
+
+        List<CompletionItem> completionItems = new ArrayList<>();
+        CompletionHandler handler = new CompletionHandler(project, smithyFile);
+        for (Position position : positions) {
+            CompletionParams params = RequestBuilders.positionRequest()
+                    .uri(uri)
+                    .position(position)
+                    .buildCompletion();
+            completionItems.addAll(handler.handle(params, () -> {}));
+        }
+
+        return completionItems;
+    }
+}
diff --git a/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java
new file mode 100644
index 00000000..5383d527
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/language/DefinitionHandlerTest.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.endsWith;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static software.amazon.smithy.lsp.document.DocumentTest.safeString;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.lsp4j.DefinitionParams;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.lsp.RequestBuilders;
+import software.amazon.smithy.lsp.ServerState;
+import software.amazon.smithy.lsp.TestWorkspace;
+import software.amazon.smithy.lsp.TextWithPositions;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.project.ProjectLoader;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.lsp.syntax.Syntax;
+import software.amazon.smithy.lsp.syntax.SyntaxSearch;
+
+public class DefinitionHandlerTest {
+    @Test
+    public void getsPreludeTraitIdLocations() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @tags([])
+                string Foo
+                """);
+        GetLocationsResult onAt = getLocations(text, new Position(3, 0));
+        GetLocationsResult ok = getLocations(text, new Position(3, 1));
+        GetLocationsResult atEnd = getLocations(text, new Position(3, 5));
+
+        assertThat(onAt.locations, empty());
+
+        assertThat(ok.locations, hasSize(1));
+        assertThat(ok.locations.getFirst().getUri(), endsWith("prelude.smithy"));
+        assertIsShapeDef(ok, ok.locations.getFirst(), "list tags");
+
+        assertThat(atEnd.locations, empty());
+    }
+
+    @Test
+    public void getsTraitIdsLocationsInCurrentFile() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                string foo
+                
+                @foo
+                string Bar
+                """);
+        GetLocationsResult result = getLocations(text, new Position(6, 1));
+
+        assertThat(result.locations, hasSize(1));
+        Location location = result.locations.getFirst();
+        assertThat(location.getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, location, "string foo");
+    }
+
+    @Test
+    public void shapeDefs() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                structure Foo {}
+                
+                structure Bar {
+                    foo: Foo
+                }
+                """);
+        GetLocationsResult onShapeDef = getLocations(text, new Position(3, 10));
+        assertThat(onShapeDef.locations, hasSize(1));
+        assertThat(onShapeDef.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(onShapeDef, onShapeDef.locations.getFirst(), "structure Foo");
+
+        GetLocationsResult memberTarget = getLocations(text, new Position(6, 9));
+        assertThat(memberTarget.locations, hasSize(1));
+        assertThat(memberTarget.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(memberTarget, memberTarget.locations.getFirst(), "structure Foo");
+    }
+
+    @Test
+    public void forResource() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                resource Foo {}
+                
+                structure Bar for Foo {}
+                """);
+        GetLocationsResult result = getLocations(text, new Position(5, 18));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "resource Foo");
+    }
+
+    @Test
+    public void mixin() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                structure Foo {}
+                
+                structure Bar with [Foo] {}
+                """);
+        GetLocationsResult result = getLocations(text, new Position(6, 20));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "structure Foo");
+    }
+
+    @Test
+    public void useTarget() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                use smithy.api#tags
+                """);
+        GetLocationsResult result = getLocations(text, new Position(2, 4));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "list tags");
+    }
+
+    @Test
+    public void applyTarget() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                structure Foo {}
+                
+                apply Foo @tags([])
+                """);
+        GetLocationsResult result = getLocations(text, new Position(5, 6));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "structure Foo");
+    }
+
+    @Test
+    public void nodeMemberTarget() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                service Foo {
+                    version: "0"
+                    operations: [Bar]
+                }
+                
+                operation Bar {}
+                """);
+        GetLocationsResult result = getLocations(text, new Position(5, 17));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "operation Bar");
+    }
+
+    @Test
+    public void nestedNodeMemberTarget() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                resource Foo {
+                    identifiers: {
+                        foo: String
+                    }
+                }
+                """);
+        GetLocationsResult result = getLocations(text, new Position(5, 13));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("prelude.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "string String");
+    }
+
+    @Test
+    public void traitValueTopLevelKey() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: String
+                }
+                
+                @foo(bar: "")
+                string Baz
+                """);
+        GetLocationsResult result = getLocations(text, new Position(8, 7));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "bar: String");
+    }
+
+    @Test
+    public void traitValueNestedKey() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @trait
+                structure foo {
+                    bar: BarList
+                }
+                
+                list BarList {
+                    member: Bar
+                }
+                
+                structure Bar {
+                    baz: String
+                }
+                
+                @foo(bar: [{ baz: "one" }, { baz: "two" }])
+                string S
+                """);
+        GetLocationsResult result = getLocations(text, new Position(16, 29));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "baz: String");
+    }
+
+    @Test
+    public void elidedMixinMember() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                structure Foo {
+                    bar: String
+                }
+                
+                structure Bar with [Foo] {
+                    $bar
+                }
+                """);
+        GetLocationsResult result = getLocations(text, new Position(9, 4));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "structure Foo");
+    }
+
+    @Test
+    public void elidedResourceMember() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                resource Foo {
+                    identifiers: {
+                        bar: String
+                    }
+                }
+                
+                structure Bar for Foo {
+                    $bar
+                }
+                """);
+        GetLocationsResult result = getLocations(text, new Position(10, 4));
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "resource Foo");
+    }
+
+    @Test
+    public void idRefTraitValue() {
+        TextWithPositions text = TextWithPositions.from("""
+                $version: "2"
+                namespace com.foo
+                
+                @idRef
+                string ShapeId
+                
+                @trait
+                structure foo {
+                    id: ShapeId
+                }
+                
+                string Bar
+                
+                @foo(id: %Bar)
+                structure Baz {}
+                """);
+        GetLocationsResult result = getLocations(text);
+        assertThat(result.locations, hasSize(1));
+        assertThat(result.locations.getFirst().getUri(), endsWith("main.smithy"));
+        assertIsShapeDef(result, result.locations.getFirst(), "string Bar");
+    }
+
+    private static void assertIsShapeDef(
+            GetLocationsResult result,
+            Location location,
+            String expected
+    ) {
+        SmithyFile smithyFile = result.handler.project.getSmithyFile(location.getUri());
+        assertThat(smithyFile, notNullValue());
+
+        int documentIndex = smithyFile.document().indexOfPosition(location.getRange().getStart());
+        assertThat(documentIndex, greaterThanOrEqualTo(0));
+
+        int statementIndex = SyntaxSearch.statementIndex(smithyFile.statements(), documentIndex);
+        assertThat(statementIndex, greaterThanOrEqualTo(0));
+
+        var statement = smithyFile.statements().get(statementIndex);
+        if (statement instanceof Syntax.Statement.ShapeDef shapeDef) {
+            String shapeType = shapeDef.shapeType().copyValueFrom(smithyFile.document());
+            String shapeName = shapeDef.shapeName().copyValueFrom(smithyFile.document());
+            assertThat(shapeType + " " + shapeName, equalTo(expected));
+        } else if (statement instanceof Syntax.Statement.MemberDef memberDef) {
+            String memberName = memberDef.name().copyValueFrom(smithyFile.document());
+            String memberTarget = memberDef.target().copyValueFrom(smithyFile.document());
+            assertThat(memberName + ": " + memberTarget, equalTo(expected));
+        } else {
+            fail("Expected shape or member def, but was " + statement.getClass().getName());
+        }
+    }
+
+    record GetLocationsResult(DefinitionHandler handler, List<Location> locations) {}
+
+    private static GetLocationsResult getLocations(TextWithPositions textWithPositions) {
+        return getLocations(textWithPositions.text(), textWithPositions.positions());
+    }
+
+    private static GetLocationsResult getLocations(String text, Position... positions) {
+        TestWorkspace workspace = TestWorkspace.singleModel(text);
+        Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap();
+        String uri = workspace.getUri("main.smithy");
+        SmithyFile smithyFile = project.getSmithyFile(uri);
+
+        List<Location> locations = new ArrayList<>();
+        DefinitionHandler handler = new DefinitionHandler(project, smithyFile);
+        for (Position position : positions) {
+            DefinitionParams params = RequestBuilders.positionRequest()
+                    .uri(uri)
+                    .position(position)
+                    .buildDefinition();
+            locations.addAll(handler.handle(params));
+        }
+
+        return new GetLocationsResult(handler, locations);
+    }
+}
diff --git a/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java
new file mode 100644
index 00000000..026f3f93
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/language/HoverHandlerTest.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.language;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsString;
+import static software.amazon.smithy.lsp.document.DocumentTest.safeString;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.lsp4j.HoverParams;
+import org.eclipse.lsp4j.Position;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.lsp.RequestBuilders;
+import software.amazon.smithy.lsp.ServerState;
+import software.amazon.smithy.lsp.TestWorkspace;
+import software.amazon.smithy.lsp.project.Project;
+import software.amazon.smithy.lsp.project.ProjectLoader;
+import software.amazon.smithy.lsp.project.SmithyFile;
+import software.amazon.smithy.model.validation.Severity;
+
+public class HoverHandlerTest {
+    @Test
+    public void controlKey() {
+        String text = safeString("""
+                $version: "2"
+                """);
+        List<String> hovers = getHovers(text, new Position(0, 1));
+
+        assertThat(hovers, contains(containsString("version")));
+    }
+
+    @Test
+    public void metadataKey() {
+        String text = safeString("""
+                metadata suppressions = []
+                """);
+        List<String> hovers = getHovers(text, new Position(0, 9));
+
+        assertThat(hovers, contains(containsString("suppressions")));
+    }
+
+    @Test
+    public void metadataValue() {
+        String text = safeString("""
+                metadata suppressions = [{id: "foo"}]
+                """);
+        List<String> hovers = getHovers(text, new Position(0, 26));
+
+        assertThat(hovers, contains(containsString("id")));
+    }
+
+    @Test
+    public void traitValue() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @http(method: "GET", uri: "/")
+                operation Foo {}
+                """);
+        List<String> hovers = getHovers(text, new Position(3, 7));
+
+        assertThat(hovers, contains(containsString("method: NonEmptyString")));
+    }
+
+    @Test
+    public void elidedMember() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                @mixin
+                structure Foo {
+                    bar: String
+                }
+                
+                structure Bar with [Foo] {
+                    $bar
+                }
+                """);
+        List<String> hovers = getHovers(text, new Position(9, 5));
+
+        assertThat(hovers, contains(containsString("bar: String")));
+    }
+
+    @Test
+    public void nodeMemberTarget() {
+        String text = safeString("""
+                $version: "2"
+                namespace com.foo
+                
+                service Foo {
+                    version: "0"
+                    operations: [Bar]
+                }
+                
+                operation Bar {}
+                """);
+        List<String> hovers = getHovers(text, new Position(5, 17));
+
+        assertThat(hovers, contains(containsString("operation Bar")));
+    }
+
+    private static List<String> getHovers(String text, Position... positions) {
+        TestWorkspace workspace = TestWorkspace.singleModel(text);
+        Project project = ProjectLoader.load(workspace.getRoot(), new ServerState()).unwrap();
+        String uri = workspace.getUri("main.smithy");
+        SmithyFile smithyFile = project.getSmithyFile(uri);
+
+        List<String> hover = new ArrayList<>();
+        HoverHandler handler = new HoverHandler(project, smithyFile, Severity.WARNING);
+        for (Position position : positions) {
+            HoverParams params = RequestBuilders.positionRequest()
+                    .uri(uri)
+                    .position(position)
+                    .buildHover();
+            hover.add(handler.handle(params).getContents().getRight().getValue());
+        }
+
+        return hover;
+    }
+}
diff --git a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java
index 21790ba4..bffea311 100644
--- a/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java
+++ b/src/test/java/software/amazon/smithy/lsp/project/ProjectTest.java
@@ -133,11 +133,11 @@ public void loadsWhenModelHasInvalidSyntax() {
                 .collect(Collectors.toList());
         assertThat(shapeIds, hasItems("com.foo#Foo", "com.foo#Foo$bar"));
 
-        assertThat(main.documentShapes(), hasSize(3));
+        assertThat(main.documentShapes(), hasSize(4));
         List<String> documentShapeNames = main.documentShapes().stream()
                 .map(documentShape -> documentShape.shapeName().toString())
                 .collect(Collectors.toList());
-        assertThat(documentShapeNames, hasItems("Foo", "bar", "String"));
+        assertThat(documentShapeNames, hasItems("Foo", "bar", "String", "A"));
     }
 
     @Test
diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java
new file mode 100644
index 00000000..a41a836e
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/syntax/IdlParserTest.java
@@ -0,0 +1,469 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.instanceOf;
+import static software.amazon.smithy.lsp.document.DocumentTest.safeString;
+
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.smithy.lsp.document.Document;
+
+public class IdlParserTest {
+    @Test
+    public void parses() {
+        String text = """
+                string Foo
+                @tags(["foo"])
+                structure Bar {
+                    baz: String
+                }
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.TraitApplication,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.MemberDef);
+    }
+
+    @Test
+    public void parsesStatements() {
+        String text = """
+                $version: "2"
+                metadata foo = [{ bar: 2 }]
+                namespace com.foo
+                
+                use com.bar#baz
+                
+                @baz
+                structure Foo {
+                    @baz
+                    bar: String
+                }
+                
+                enum Bar {
+                    BAZ = "BAZ"
+                }
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.Control,
+                Syntax.Statement.Type.Metadata,
+                Syntax.Statement.Type.Namespace,
+                Syntax.Statement.Type.Use,
+                Syntax.Statement.Type.TraitApplication,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.TraitApplication,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.EnumMemberDef);
+    }
+
+    @Test
+    public void parsesMixinsAndForResource() {
+        String text = """
+                structure Foo with [Mix] {}
+                structure Bar for Resource {}
+                structure Baz for Resource with [Mix] {}
+                structure Bux with [One, Two, Three] {}
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.Mixins,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.ForResource,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.ForResource,
+                Syntax.Statement.Type.Mixins,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.Mixins);
+    }
+
+    @Test
+    public void parsesOp() {
+        String text = """
+                operation One {}
+                operation Two {
+                    input: Input
+                }
+                operation Three {
+                    input: Input
+                    output: Output
+                }
+                operation Four {
+                    input: Input
+                    errors: [Err]
+                }
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.NodeMemberDef);
+    }
+
+    @Test
+    public void parsesOpInline() {
+        String text = """
+                operation One {
+                    input := {
+                        foo: String
+                    }
+                    output := {
+                        @foo
+                        foo: String
+                    }
+                }
+                operation Two {
+                    input := for Foo {
+                        foo: String
+                    }
+                    output := with [Bar] {
+                        bar: String
+                    }
+                }
+                operation Three {
+                    input := for Foo with [Bar, Baz] {}
+                }
+                operation Four {
+                    input := @foo {}
+                }
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.TraitApplication,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.ForResource,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.Mixins,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.ForResource,
+                Syntax.Statement.Type.Mixins,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.TraitApplication);
+    }
+
+    @Test
+    public void parsesOpInlineWithTraits() {
+        String text = safeString("""
+                operation Op {
+                    input := @foo {
+                        foo: Foo
+                    }
+                    output := {}
+                }""");
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.InlineMemberDef,
+                Syntax.Statement.Type.TraitApplication,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.InlineMemberDef);
+    }
+
+    @Test
+    public void parsesServiceAndResource() {
+        String text = """
+                service Foo {
+                    version: "2024-08-15
+                    operations: [
+                        Op1
+                        Op2
+                    ]
+                    errors: [
+                        Err1
+                        Err2
+                    ]
+                }
+                resource Bar {
+                    identifiers: { id: String }
+                    properties: { prop: String }
+                }
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.NodeMemberDef,
+                Syntax.Statement.Type.NodeMemberDef,
+                Syntax.Statement.Type.NodeMemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.NodeMemberDef,
+                Syntax.Statement.Type.NodeMemberDef);
+    }
+
+    @Test
+    public void ignoresComments() {
+        String text = """
+                // one
+                $version: "2" // two
+                
+                namespace com.foo // three
+                // four
+                use com.bar#baz // five
+                
+                // six
+                @baz // seven
+                structure Foo // eight
+                { // nine
+                // ten
+                    bar: String // eleven
+                } // twelve
+                
+                enum Bar // thirteen
+                { // fourteen
+                    // fifteen
+                    BAR // sixteen
+                } // seventeen
+                service Baz // eighteen
+                { // nineteen
+                // twenty
+                    version: "" // twenty one
+                } // twenty two
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.Control,
+                Syntax.Statement.Type.Namespace,
+                Syntax.Statement.Type.Use,
+                Syntax.Statement.Type.TraitApplication,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.EnumMemberDef,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.NodeMemberDef);
+    }
+
+    @Test
+    public void defaultAssignments() {
+        String text = """
+                structure Foo {
+                    one: One = ""
+                    two: Two = 2
+                    three: Three = false
+                    four: Four = []
+                    five: Five = {}
+                }
+                """;
+
+        assertTypesEqual(text,
+                Syntax.Statement.Type.ShapeDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.MemberDef,
+                Syntax.Statement.Type.MemberDef);
+    }
+
+    @Test
+    public void stringKeysInTraits() {
+        String text = """
+                @foo(
+                    "bar": "baz"
+                )
+                """;
+        Syntax.IdlParse parse = Syntax.parseIdl(Document.of(text));
+        assertThat(parse.statements(), hasSize(1));
+        assertThat(parse.statements().get(0), instanceOf(Syntax.Statement.TraitApplication.class));
+
+        Syntax.Statement.TraitApplication traitApplication = (Syntax.Statement.TraitApplication) parse.statements().get(0);
+        var nodeTypes = NodeParserTest.getNodeTypes(traitApplication.value());
+
+        assertThat(nodeTypes, contains(
+                Syntax.Node.Type.Kvps,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Str));
+    }
+
+    @ParameterizedTest
+    @MethodSource("brokenProvider")
+    public void broken(String desc, String text, List<String> expectedErrorMessages, List<Syntax.Statement.Type> expectedTypes) {
+        if (desc.equals("trait missing member value")) {
+            System.out.println();
+        }
+        Syntax.IdlParse parse = Syntax.parseIdl(Document.of(text));
+        List<String> errorMessages = parse.errors().stream().map(Syntax.Err::message).toList();
+        List<Syntax.Statement.Type> types = parse.statements().stream()
+                .map(Syntax.Statement::type)
+                .filter(type -> type != Syntax.Statement.Type.Block)
+                .toList();
+
+        assertThat(desc, errorMessages, equalTo(expectedErrorMessages));
+        assertThat(desc, types, equalTo(expectedTypes));
+    }
+
+    record InvalidSyntaxTestCase(
+            String description,
+            String text,
+            List<String> expectedErrorMessages,
+            List<Syntax.Statement.Type> expectedTypes
+    ) {}
+
+    private static final List<InvalidSyntaxTestCase> INVALID_SYNTAX_TEST_CASES = List.of(
+            new InvalidSyntaxTestCase(
+                    "empty",
+                    "",
+                    List.of(),
+                    List.of()
+            ),
+            new InvalidSyntaxTestCase(
+                    "just shape type",
+                    "structure",
+                    List.of("expected identifier"),
+                    List.of(Syntax.Statement.Type.Incomplete)
+            ),
+            new InvalidSyntaxTestCase(
+                    "missing resource",
+                    "string Foo for",
+                    List.of("expected identifier"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.ForResource)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unexpected line break",
+                    "string \nstring Foo",
+                    List.of("expected identifier"),
+                    List.of(Syntax.Statement.Type.Incomplete, Syntax.Statement.Type.ShapeDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unexpected token",
+                    "string [",
+                    List.of("expected identifier"),
+                    List.of(Syntax.Statement.Type.Incomplete)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unexpected token 2",
+                    "string Foo [",
+                    List.of("expected identifier"),
+                    List.of(Syntax.Statement.Type.ShapeDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "enum missing {",
+                    "enum Foo\nBAR}",
+                    List.of("expected {"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "enum missing }",
+                    "enum Foo {BAR",
+                    List.of("expected }"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.EnumMemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "regular shape missing {",
+                    "structure Foo\nbar: String}",
+                    List.of("expected {"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "regular shape missing }",
+                    "structure Foo {bar: String",
+                    List.of("expected }"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.MemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "op with inline missing {",
+                    "operation Foo\ninput := {}}",
+                    List.of("expected {"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "op with inline missing }",
+                    "operation Foo{input:={}",
+                    List.of("expected }"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.InlineMemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "node shape with missing {",
+                    "resource Foo\nidentifiers:{}}",
+                    List.of("expected {"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "node shape with missing }",
+                    "service Foo{operations:[]",
+                    List.of("expected }"),
+                    List.of(Syntax.Statement.Type.ShapeDef, Syntax.Statement.Type.NodeMemberDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "apply missing @",
+                    "apply Foo",
+                    List.of("expected trait or block"),
+                    List.of(Syntax.Statement.Type.Apply)
+            ),
+            new InvalidSyntaxTestCase(
+                    "apply missing }",
+                    "apply Foo {@bar",
+                    List.of("expected }"),
+                    List.of(Syntax.Statement.Type.Apply, Syntax.Statement.Type.TraitApplication)
+            ),
+            new InvalidSyntaxTestCase(
+                    "trait missing member value",
+                    "@foo(bar: )\nstring Foo",
+                    List.of("expected value"),
+                    List.of(Syntax.Statement.Type.TraitApplication, Syntax.Statement.Type.ShapeDef)
+            ),
+            new InvalidSyntaxTestCase(
+                    "inline with member missing target",
+                    """
+                    operation Op {
+                        input :=
+                            @tags([])
+                            {
+                            foo:\s
+                            }
+                    }""",
+                    List.of("expected identifier"),
+                    List.of(
+                            Syntax.Statement.Type.ShapeDef,
+                            Syntax.Statement.Type.InlineMemberDef,
+                            Syntax.Statement.Type.TraitApplication,
+                            Syntax.Statement.Type.MemberDef)
+            )
+    );
+
+    private static Stream<Arguments> brokenProvider() {
+        return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of(
+                invalidSyntaxTestCase.description,
+                invalidSyntaxTestCase.text,
+                invalidSyntaxTestCase.expectedErrorMessages,
+                invalidSyntaxTestCase.expectedTypes));
+    }
+
+    private static void assertTypesEqual(String text, Syntax.Statement.Type... types) {
+        Syntax.IdlParse parse = Syntax.parseIdl(Document.of(text));
+        List<Syntax.Statement.Type> actualTypes = parse.statements().stream()
+                .map(Syntax.Statement::type)
+                .filter(type -> type != Syntax.Statement.Type.Block)
+                .toList();
+        assertThat(actualTypes, contains(types));
+    }
+}
diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java
new file mode 100644
index 00000000..e6b7dabe
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/syntax/NodeParserTest.java
@@ -0,0 +1,375 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.smithy.lsp.syntax;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.smithy.lsp.document.Document;
+
+public class NodeParserTest {
+    @Test
+    public void goodEmptyObj() {
+        String text = "{}";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps);
+    }
+
+    @Test
+    public void goodEmptyObjWithWs() {
+        String text = "{  }";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps);
+    }
+
+    @Test
+    public void goodObjSingleKey() {
+        String text = """
+                {"abc": "def"}""";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Str);
+    }
+
+    @Test
+    public void goodObjMultiKey() {
+        String text = """
+                {"abc": "def", "ghi": "jkl"}""";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Str);
+    }
+
+    @Test
+    public void goodNestedObjs() {
+        String text = """
+                {"abc": {"abc": {"abc": "abc"}, "def": "def"}}""";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Obj,
+                Syntax.Node.Type.Kvps,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Kvp,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Str);
+    }
+
+    @Test
+    public void goodEmptyArr() {
+        String text = "[]";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Arr);
+    }
+
+    @Test
+    public void goodEmptyArrWithWs() {
+        String text = "[  ]";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Arr);
+    }
+
+    @Test
+    public void goodSingleElemArr() {
+        String text = "[1]";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Num);
+    }
+
+    @Test
+    public void goodMultiElemArr() {
+        String text = """
+                [1, 2, "3"]""";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Str);
+    }
+
+    @Test
+    public void goodNestedArr() {
+        String text = """
+                [[1, [1, 2], []] 3]""";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Num);
+    }
+
+    @ParameterizedTest
+    @MethodSource("goodStringsProvider")
+    public void goodStrings(String text, String expectedValue) {
+        Document document = Document.of(text);
+        Syntax.Node value = Syntax.parseNode(document).value();
+        if (value instanceof Syntax.Node.Str s) {
+            String actualValue = s.copyValueFrom(document);
+            if (!expectedValue.equals(actualValue)) {
+                fail(String.format("expected text of %s to be parsed as a string with value %s, but was %s",
+                        text, expectedValue, actualValue));
+            }
+        } else {
+            fail(String.format("expected text of %s to be parsed as a string, but was %s",
+                    text, value.type()));
+        }
+    }
+
+    private static Stream<Arguments> goodStringsProvider() {
+        return Stream.of(
+                Arguments.of("\"foo\"", "foo"),
+                Arguments.of("\"\"", "")
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("goodIdentsProvider")
+    public void goodIdents(String text, String expectedValue) {
+        Document document = Document.of(text);
+        Syntax.Node value = Syntax.parseNode(document).value();
+        if (value instanceof Syntax.Ident ident) {
+            String actualValue = ident.copyValueFrom(document);
+            if (!expectedValue.equals(actualValue)) {
+                fail(String.format("expected text of %s to be parsed as an ident with value %s, but was %s",
+                        text, expectedValue, actualValue));
+            }
+        } else {
+            fail(String.format("expected text of %s to be parsed as an ident, but was %s",
+                    text, value.type()));
+        }
+    }
+
+    private static Stream<Arguments> goodIdentsProvider() {
+        return Stream.of(
+                Arguments.of("true", "true"),
+                Arguments.of("false", "false"),
+                Arguments.of("null", "null")
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("goodNumbersProvider")
+    public void goodNumbers(String text, BigDecimal expectedValue) {
+        Document document = Document.of(text);
+        Syntax.Node value = Syntax.parseNode(document).value();
+
+        if (value instanceof Syntax.Node.Num num) {
+            if (!expectedValue.equals(num.value)) {
+                fail(String.format("Expected text of %s to be parsed as a number with value %s, but was %s",
+                        text, expectedValue, num.value));
+            }
+        } else {
+            fail(String.format("Expected text of %s to be parsed as a number but was %s",
+                    text, value.type()));
+        }
+    }
+
+    private static Stream<Arguments> goodNumbersProvider() {
+        return Stream.of(
+                Arguments.of("-10", BigDecimal.valueOf(-10)),
+                Arguments.of("0", BigDecimal.valueOf(0)),
+                Arguments.of("123", BigDecimal.valueOf(123))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("brokenProvider")
+    public void broken(String desc, String text, List<String> expectedErrorMessages, List<Syntax.Node.Type> expectedTypes) {
+        Syntax.NodeParse parse = Syntax.parseNode(Document.of(text));
+        List<String> errorMessages = parse.errors().stream().map(Syntax.Err::message).toList();
+        List<Syntax.Node.Type> types = getNodeTypes(parse.value());
+
+        assertThat(desc, errorMessages, equalTo(expectedErrorMessages));
+        assertThat(desc, types, equalTo(expectedTypes));
+    }
+
+    record InvalidSyntaxTestCase(
+            String description,
+            String text,
+            List<String> expectedErrorMessages,
+            List<Syntax.Node.Type> expectedTypes
+    ) {}
+
+    private static final List<InvalidSyntaxTestCase> INVALID_SYNTAX_TEST_CASES = List.of(
+            new InvalidSyntaxTestCase(
+                    "invalid element token",
+                    "[1, 2}]",
+                    List.of("unexpected token }"),
+                    List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Num)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed empty",
+                    "[",
+                    List.of("missing ]"),
+                    List.of(Syntax.Node.Type.Arr)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed",
+                    "[1,",
+                    List.of("missing ]"),
+                    List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed with sp",
+                    "[1,   ",
+                    List.of("missing ]"),
+                    List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed with multi elem",
+                    "[1,a",
+                    List.of("missing ]"),
+                    List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed with multi elem and sp",
+                    "[1,a ",
+                    List.of("missing ]"),
+                    List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Num, Syntax.Node.Type.Ident)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed with multi elem no ,",
+                    "[a 2",
+                    List.of("missing ]"),
+                    List.of(Syntax.Node.Type.Arr, Syntax.Node.Type.Ident, Syntax.Node.Type.Num)
+            ),
+            new InvalidSyntaxTestCase(
+                    "unclosed in member",
+                    "{foo: [1, 2}",
+                    List.of("unexpected token }", "missing ]", "missing }"),
+                    List.of(
+                            Syntax.Node.Type.Obj,
+                            Syntax.Node.Type.Kvps,
+                            Syntax.Node.Type.Kvp,
+                            Syntax.Node.Type.Ident,
+                            Syntax.Node.Type.Arr,
+                            Syntax.Node.Type.Num,
+                            Syntax.Node.Type.Num)
+            ),
+            new InvalidSyntaxTestCase(
+                    "Non-string key with no value",
+                    "{1}",
+                    List.of("unexpected Num", "expected :", "expected value"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps)
+            ),
+            new InvalidSyntaxTestCase(
+                    "Non-string key with : but no value",
+                    "{1:}",
+                    List.of("unexpected Num", "expected value"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps)
+            ),
+            new InvalidSyntaxTestCase(
+                    "String key with no value",
+                    "{\"1\"}",
+                    List.of("expected :", "expected value"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str)
+            ),
+            new InvalidSyntaxTestCase(
+                    "String key with : but no value",
+                    "{\"1\":}",
+                    List.of("expected value"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str)
+            ),
+            new InvalidSyntaxTestCase(
+                    "String key with no value but a trailing ,",
+                    "{\"1\",}",
+                    List.of("expected :", "expected value"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str)
+            ),
+            new InvalidSyntaxTestCase(
+                    "String key with : but no value and a trailing ,",
+                    "{\"1\":,}",
+                    List.of("expected value"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str)
+            ),
+            new InvalidSyntaxTestCase(
+                    "Invalid key",
+                    "{\"abc}",
+                    List.of("unexpected eof", "missing }"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps)
+            ),
+            new InvalidSyntaxTestCase(
+                    "Missing :",
+                    "{\"abc\" 1}",
+                    List.of("expected :"),
+                    List.of(Syntax.Node.Type.Obj, Syntax.Node.Type.Kvps, Syntax.Node.Type.Kvp, Syntax.Node.Type.Str)
+            )
+    );
+
+    private static Stream<Arguments> brokenProvider() {
+        return INVALID_SYNTAX_TEST_CASES.stream().map(invalidSyntaxTestCase -> Arguments.of(
+                invalidSyntaxTestCase.description,
+                invalidSyntaxTestCase.text,
+                invalidSyntaxTestCase.expectedErrorMessages,
+                invalidSyntaxTestCase.expectedTypes));
+    }
+
+    @Test
+    public void parsesStringsWithEscapes() {
+        String text = """
+                "a\\"b"
+                """;
+        assertTypesEqual(text,
+                Syntax.Node.Type.Str);
+    }
+
+    @Test
+    public void parsesTextBlocks() {
+        String text = "[\"\"\"foo\"\"\", 2, \"bar\", 3, \"\", 4, \"\"\"\"\"\"]";
+        assertTypesEqual(text,
+                Syntax.Node.Type.Arr,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Str,
+                Syntax.Node.Type.Num,
+                Syntax.Node.Type.Str);
+    }
+
+    private static void assertTypesEqual(String text, Syntax.Node.Type... types) {
+        assertThat(getNodeTypes(Syntax.parseNode(Document.of(text)).value()), contains(types));
+    }
+
+    static List<Syntax.Node.Type> getNodeTypes(Syntax.Node value) {
+        List<Syntax.Node.Type> types = new ArrayList<>();
+        value.consume(v -> types.add(v.type()));
+        return types;
+    }
+}
diff --git a/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java b/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java
new file mode 100644
index 00000000..cc8d9c16
--- /dev/null
+++ b/src/test/java/software/amazon/smithy/lsp/syntax/SyntaxSearchTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.lsp.syntax;
+
+import static org.junit.jupiter.api.Assertions.fail;
+import static software.amazon.smithy.lsp.document.DocumentTest.safeString;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.lsp.document.Document;
+
+public class SyntaxSearchTest {
+    @Test
+    public void findsNodeCursor() {
+        String text = safeString("""
+                {
+                    "foo": "bar"
+                }""");
+        Document document = Document.of(text);
+        Syntax.Node value = Syntax.parseNode(document).value();
+        NodeCursor cursor = NodeCursor.create(document, value, document.indexOfPosition(1, 4));
+
+        assertCursorMatches(cursor, new NodeCursor(List.of(
+                new NodeCursor.Obj(null),
+                new NodeCursor.Key("foo", null),
+                new NodeCursor.Terminal(null)
+        )));
+    }
+
+    @Test
+    public void findsNodeCursorWhenBroken() {
+        String text = safeString("""
+                {
+                    "foo"
+                }""");
+        Document document = Document.of(text);
+        Syntax.Node value = Syntax.parseNode(document).value();
+        NodeCursor cursor = NodeCursor.create(document, value, document.indexOfPosition(1, 4));
+
+        assertCursorMatches(cursor, new NodeCursor(List.of(
+                new NodeCursor.Obj(null),
+                new NodeCursor.Key("foo", null),
+                new NodeCursor.Terminal(null)
+        )));
+    }
+
+    private static void assertCursorMatches(NodeCursor actual, NodeCursor expected) {
+        if (!actual.toString().equals(expected.toString())) {
+            fail("Expected cursor to match:\n" + expected + "\nbut was:\n" + actual);
+        }
+    }
+}