Skip to content

Commit

Permalink
Fix applying traits in partial load
Browse files Browse the repository at this point in the history
The partial loading strategy, which is meant to reduce the amount
of unnecessary re-parsing of unchanged files on every file change,
works by removing shapes defined in the file that has changed. But
this wasn't taking into account traits applied using `apply` in
other files. When a shape is removed, all the traits are removed,
and the `apply` isn't persisted. So we need to keep track of all
trait applications that aren't in the same file as the shape def.
Additionally, list traits have their values merged, so we actually
need to either partially remove a trait (i.e. only certain elements), or
just reload all the files that contain part of the trait's value. This
implementation does the latter, which also requires creating a sort
of dependency graph of which files need other files to be loaded with
them. There's likely room for optimization here (potentially switching
to the first approach), but we will have to guage the performance.

This commit also consolidates the project updating logic for adding,
removing, and changing files into a single method of Project, and
adds a bunch of tests around different situations of `apply`.
  • Loading branch information
milesziemer committed Jun 10, 2024
1 parent c9193ad commit 62413b1
Show file tree
Hide file tree
Showing 9 changed files with 681 additions and 117 deletions.
234 changes: 149 additions & 85 deletions src/main/java/software/amazon/smithy/lsp/project/Project.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ private ProjectConfig(Builder builder) {
this.mavenConfig = builder.mavenConfig;
}

static ProjectConfig empty() {
return builder().build();
}

static Builder builder() {
return new Builder();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ public static Result<Project, List<Exception>> load(

return Result.ok(projectBuilder.smithyFiles(smithyFiles)
.perFileMetadata(computePerFileMetadata(modelResult))
.smithyFileDependenciesIndex(SmithyFileDependenciesIndex.compute(modelResult))
.build());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
public final class SmithyFile {
private final String path;
private final Document document;
private final Set<Shape> shapes;
// TODO: If we have more complex use-cases for partially updating SmithyFile, we
// could use a toBuilder()
private Set<Shape> shapes;
private final DocumentNamespace namespace;
private final DocumentImports imports;
private final Map<Position, DocumentShape> documentShapes;
Expand Down Expand Up @@ -64,6 +66,10 @@ public Set<Shape> getShapes() {
return shapes;
}

void setShapes(Set<Shape> shapes) {
this.shapes = shapes;
}

/**
* @return This Smithy file's imports, if they exist
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp.project;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.shapes.ToShapeId;
import software.amazon.smithy.model.traits.Trait;
import software.amazon.smithy.model.validation.ValidatedResult;

/**
* An index that caches rebuild dependency relationships between Smithy files,
* shapes, and traits.
*
* <p>This is specifically for the following scenarios:
* <ol>
* <li>A file applies traits to shapes in other files. If that file changes, the
* applied traits need to be removed before the file is reloaded, so there
* aren't duplicate traits.</li>
* <li>A file has shapes with traits applied in other files. If that file changes,
* the traits need to be re-applied when the model is re-assembled, so they
* aren't lost.</li>
* <li>Either 1 or 2, but specifically with list traits, which are merged via
* <a href="https://smithy.io/2.0/spec/model.html#trait-conflict-resolution">
* trait conflict resolution
* </a>. For these traits, all files that contain parts of the list trait must
* be fully reloaded, since we can only remove the whole trait, not parts of it.
* </li>
* </ol>
*/
final class SmithyFileDependenciesIndex {
static final SmithyFileDependenciesIndex EMPTY = new SmithyFileDependenciesIndex(
new HashMap<>(0), new HashMap<>(0), new HashMap<>(0), new HashMap<>(0));

private final Map<String, Set<String>> filesToDependentFiles;
private final Map<ShapeId, Set<String>> shapeIdsToDependenciesFiles;
private final Map<String, Map<ShapeId, List<Trait>>> filesToTraitsTheyApply;
private final Map<ShapeId, List<Trait>> shapesToAppliedTraitsInOtherFiles;

private SmithyFileDependenciesIndex(
Map<String, Set<String>> filesToDependentFiles,
Map<ShapeId, Set<String>> shapeIdsToDependenciesFiles,
Map<String, Map<ShapeId, List<Trait>>> filesToTraitsTheyApply,
Map<ShapeId, List<Trait>> shapesToAppliedTraitsInOtherFiles
) {
this.filesToDependentFiles = filesToDependentFiles;
this.shapeIdsToDependenciesFiles = shapeIdsToDependenciesFiles;
this.filesToTraitsTheyApply = filesToTraitsTheyApply;
this.shapesToAppliedTraitsInOtherFiles = shapesToAppliedTraitsInOtherFiles;
}

Set<String> getDependentFiles(String path) {
return filesToDependentFiles.getOrDefault(path, Collections.emptySet());
}

Set<String> getDependenciesFiles(ToShapeId toShapeId) {
return shapeIdsToDependenciesFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptySet());
}

Map<ShapeId, List<Trait>> getAppliedTraitsInFile(String path) {
return filesToTraitsTheyApply.getOrDefault(path, Collections.emptyMap());
}

List<Trait> getTraitsAppliedInOtherFiles(ToShapeId toShapeId) {
return shapesToAppliedTraitsInOtherFiles.getOrDefault(toShapeId.toShapeId(), Collections.emptyList());
}

// TODO: Make this take care of metadata too
static SmithyFileDependenciesIndex compute(ValidatedResult<Model> modelResult) {
if (!modelResult.getResult().isPresent()) {
return EMPTY;
}

SmithyFileDependenciesIndex index = new SmithyFileDependenciesIndex(
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());

Model model = modelResult.getResult().get();
for (Shape shape : model.toSet()) {
String shapeSourceFilename = shape.getSourceLocation().getFilename();
for (Trait traitApplication : shape.getAllTraits().values()) {
// We only care about trait applications in the source files
if (traitApplication.isSynthetic()) {
continue;
}

Node traitNode = traitApplication.toNode();
if (traitNode.isArrayNode()) {
for (Node element : traitNode.expectArrayNode()) {
String elementSourceFilename = element.getSourceLocation().getFilename();
if (!elementSourceFilename.equals(shapeSourceFilename)) {
index.filesToDependentFiles.computeIfAbsent(elementSourceFilename, (k) -> new HashSet<>())
.add(shapeSourceFilename);
index.shapeIdsToDependenciesFiles.computeIfAbsent(shape.getId(), (k) -> new HashSet<>())
.add(elementSourceFilename);
}
}
} else {
String traitSourceFilename = traitApplication.getSourceLocation().getFilename();
if (!traitSourceFilename.equals(shapeSourceFilename)) {
index.shapesToAppliedTraitsInOtherFiles.computeIfAbsent(shape.getId(), (k) -> new ArrayList<>())
.add(traitApplication);
index.filesToTraitsTheyApply.computeIfAbsent(traitSourceFilename, (k) -> new HashMap<>())
.computeIfAbsent(shape.getId(), (k) -> new ArrayList<>())
.add(traitApplication);
}
}
}
}

return index;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
import static software.amazon.smithy.lsp.LspMatchers.makesEditedDocument;
import static software.amazon.smithy.lsp.SmithyMatchers.eventWithMessage;
import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithId;
import static software.amazon.smithy.lsp.SmithyMatchers.hasShapeWithIdAndTraits;
import static software.amazon.smithy.lsp.SmithyMatchers.hasValue;
import static software.amazon.smithy.lsp.UtilMatchers.anOptionalOf;

import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
Expand Down Expand Up @@ -58,13 +58,15 @@
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.services.LanguageClient;
import org.hamcrest.Matcher;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import software.amazon.smithy.build.model.SmithyBuildConfig;
import software.amazon.smithy.lsp.document.Document;
import software.amazon.smithy.lsp.protocol.RangeAdapter;
import software.amazon.smithy.model.node.ArrayNode;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.traits.LengthTrait;

public class SmithyLanguageServerTest {
@Test
Expand Down Expand Up @@ -953,7 +955,6 @@ public void addingDetachedFile() {
.text(modelText)
.build());
server.didChangeWatchedFiles(RequestBuilders.didChangeWatchedFiles()
.event(uri, FileChangeType.Deleted)
.event(movedUri, FileChangeType.Created)
.build());

Expand Down Expand Up @@ -1452,8 +1453,7 @@ public void addingDetachedFileWithInvalidSyntax() throws Exception {
}

@Test
@Disabled
public void todoCheckApplysInPartialLoad() throws Exception {
public void appliedTraitsAreMaintainedInPartialLoad() throws Exception {
String modelText1 = "$version: \"2\"\n"
+ "namespace com.foo\n"
+ "string Foo\n";
Expand All @@ -1472,16 +1472,16 @@ public void todoCheckApplysInPartialLoad() throws Exception {
.build());
server.didChange(RequestBuilders.didChange()
.uri(uri2)
.range(RangeAdapter.point(3, 23))
.range(RangeAdapter.of(3, 23, 3, 24))
.text("2")
.build());

server.getLifecycleManager().waitForAllTasks();

assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true));
assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar")));
assertThat(server.getProject().getModelResult(), hasValue(
hasShapeWithIdAndTraits("com.foo#Foo", "smithy.api#length")));
Shape foo = server.getProject().getModelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo"));
assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID));
assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L)));

String uri1 = workspace.getUri("model-0.smithy");

Expand All @@ -1497,12 +1497,11 @@ public void todoCheckApplysInPartialLoad() throws Exception {

server.getLifecycleManager().waitForAllTasks();

assertThat(server.getProject().getModelResult().getResult().isPresent(), is(true));
assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Bar")));
assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Baz")));
assertThat(server.getProject().getModelResult(), hasValue(hasShapeWithId("com.foo#Another")));
assertThat(server.getProject().getModelResult(), hasValue(
hasShapeWithIdAndTraits("com.foo#Foo", "smithy.api#length")));
foo = server.getProject().getModelResult().getResult().get().expectShape(ShapeId.from("com.foo#Foo"));
assertThat(foo.getIntroducedTraits().keySet(), containsInAnyOrder(LengthTrait.ID));
assertThat(foo.expectTrait(LengthTrait.class).getMin(), anOptionalOf(equalTo(2L)));
}

public static SmithyLanguageServer initFromWorkspace(TestWorkspace workspace) {
Expand Down
28 changes: 10 additions & 18 deletions src/test/java/software/amazon/smithy/lsp/SmithyMatchers.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ public final class SmithyMatchers {
private SmithyMatchers() {}

public static <T> Matcher<ValidatedResult<T>> hasValue(Matcher<T> matcher) {
return new CustomTypeSafeMatcher<ValidatedResult<T>>("a validated result with a value") {
return new CustomTypeSafeMatcher<ValidatedResult<T>>("A validated result with value " + matcher.toString()) {
@Override
protected boolean matchesSafely(ValidatedResult<T> item) {
return item.getResult().isPresent() && matcher.matches(item.getResult().get());
}

@Override
public void describeMismatchSafely(ValidatedResult<T> item, Description description) {
if (item.getResult().isPresent()) {
matcher.describeMismatch(item.getResult().get(), description);
} else {
description.appendText("Expected a value but result was empty.");
}
}
};
}

Expand All @@ -46,23 +55,6 @@ public void describeMismatchSafely(Model model, Description description) {
};
}

public static Matcher<Model> hasShapeWithIdAndTraits(String id, String... traitIds) {
return new CustomTypeSafeMatcher<Model>("a model with a shape with id " + id + " that has traits: " + String.join(",", traitIds)) {
@Override
protected boolean matchesSafely(Model item) {
if (!item.getShape(ShapeId.from(id)).isPresent()) {
return false;
}
Shape shape = item.expectShape(ShapeId.from(id));
boolean hasTraits = true;
for (String traitId : traitIds) {
hasTraits = hasTraits && shape.hasTrait(traitId);
}
return hasTraits;
}
};
}

public static Matcher<ValidationEvent> eventWithMessage(Matcher<String> message) {
return new CustomTypeSafeMatcher<ValidationEvent>("has matching message") {
@Override
Expand Down
33 changes: 33 additions & 0 deletions src/test/java/software/amazon/smithy/lsp/UtilMatchers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.lsp;

import java.util.Optional;
import org.hamcrest.CustomTypeSafeMatcher;
import org.hamcrest.Description;
import org.hamcrest.Matcher;

public final class UtilMatchers {
private UtilMatchers() {}

public static <T> Matcher<Optional<T>> anOptionalOf(Matcher<T> matcher) {
return new CustomTypeSafeMatcher<Optional<T>>("An optional that is present with value " + matcher.toString()) {
@Override
protected boolean matchesSafely(Optional<T> item) {
return item.isPresent() && matcher.matches(item.get());
}

@Override
public void describeMismatchSafely(Optional<T> item, Description description) {
if (!item.isPresent()) {
description.appendText("was an empty optional");
} else {
matcher.describeMismatch(item.get(), description);
}
}
};
}
}
Loading

0 comments on commit 62413b1

Please sign in to comment.