software.amazon.smithy.lsp.project.Project Maven / Gradle / Ivy
Show all versions of smithy-language-server Show documentation
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.lsp.project;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import software.amazon.smithy.lsp.document.Document;
import software.amazon.smithy.lsp.protocol.LspAdapter;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.SourceLocation;
import software.amazon.smithy.model.loader.ModelAssembler;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.shapes.AbstractShapeBuilder;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.traits.Trait;
import software.amazon.smithy.model.validation.ValidatedResult;
import software.amazon.smithy.utils.IoUtils;
/**
* A Smithy project open on the client. It keeps track of its Smithy files and
* dependencies, and the currently loaded model.
*/
public final class Project {
private static final Logger LOGGER = Logger.getLogger(Project.class.getName());
private final Path root;
private final ProjectConfig config;
private final List dependencies;
private final Map smithyFiles;
private final Supplier assemblerFactory;
private ValidatedResult modelResult;
// TODO: Move this into SmithyFileDependenciesIndex
private Map> perFileMetadata;
private SmithyFileDependenciesIndex smithyFileDependenciesIndex;
private Project(Builder builder) {
this.root = Objects.requireNonNull(builder.root);
this.config = builder.config;
this.dependencies = builder.dependencies;
this.smithyFiles = builder.smithyFiles;
this.modelResult = builder.modelResult;
this.assemblerFactory = builder.assemblerFactory;
this.perFileMetadata = builder.perFileMetadata;
this.smithyFileDependenciesIndex = builder.smithyFileDependenciesIndex;
}
/**
* Create an empty project with no Smithy files, dependencies, or loaded model.
*
* @param root Root path of the project
* @return The empty project
*/
public static Project empty(Path root) {
return builder()
.root(root)
.modelResult(ValidatedResult.empty())
.build();
}
/**
* @return The path of the root directory of the project
*/
public Path root() {
return root;
}
/**
* @return The paths of all Smithy sources specified
* in this project's smithy build configuration files,
* normalized and resolved against {@link #root()}.
*/
public List sources() {
return config.sources().stream()
.map(root::resolve)
.map(Path::normalize)
.collect(Collectors.toList());
}
/**
* @return The paths of all Smithy imports specified
* in this project's smithy build configuration files,
* normalized and resolved against {@link #root()}.
*/
public List imports() {
return config.imports().stream()
.map(root::resolve)
.map(Path::normalize)
.collect(Collectors.toList());
}
/**
* @return The paths of all resolved dependencies
*/
public List dependencies() {
return dependencies;
}
/**
* @return A map of paths to the {@link SmithyFile} at that path, containing
* all smithy files loaded in the project.
*/
public Map smithyFiles() {
return this.smithyFiles;
}
/**
* @return The latest result of loading this project
*/
public ValidatedResult modelResult() {
return modelResult;
}
/**
* @param uri The URI of the {@link Document} to get
* @return The {@link Document} corresponding to the given {@code uri} if
* it exists in this project, otherwise {@code null}
*/
public Document getDocument(String uri) {
String path = LspAdapter.toPath(uri);
SmithyFile smithyFile = smithyFiles.get(path);
if (smithyFile == null) {
return null;
}
return smithyFile.document();
}
/**
* @param uri The URI of the {@link SmithyFile} to get
* @return The {@link SmithyFile} corresponding to the given {@code uri} if
* it exists in this project, otherwise {@code null}
*/
public SmithyFile getSmithyFile(String uri) {
String path = LspAdapter.toPath(uri);
return smithyFiles.get(path);
}
/**
* Update this project's model without running validation.
*
* @param uri The URI of the Smithy file to update
*/
public void updateModelWithoutValidating(String uri) {
updateFiles(Collections.emptySet(), Collections.emptySet(), Collections.singleton(uri), false);
}
/**
* Update this project's model and run validation.
*
* @param uri The URI of the Smithy file to update
*/
public void updateAndValidateModel(String uri) {
updateFiles(Collections.emptySet(), Collections.emptySet(), Collections.singleton(uri), true);
}
/**
* Updates this project by adding and removing files. Runs model validation.
*
* Added files are assumed to not be managed by the client, and are loaded from disk.
*
* @param addUris URIs of files to add
* @param removeUris URIs of files to remove
*/
public void updateFiles(Set addUris, Set removeUris) {
updateFiles(addUris, removeUris, Collections.emptySet(), true);
}
/**
* Updates this project by adding, removing, and changing files. Can optionally run validation.
*
* Added files are assumed to not be managed by the client, and are loaded from disk.
*
* @param addUris URIs of files to add
* @param removeUris URIs of files to remove
* @param changeUris URIs of files that changed
* @param validate Whether to run model validation.
*/
public void updateFiles(Set addUris, Set removeUris, Set changeUris, boolean validate) {
if (modelResult.getResult().isEmpty()) {
// TODO: If there's no model, we didn't collect the smithy files (so no document), so I'm thinking
// maybe we do nothing here. But we could also still update the document, and
// just compute the shapes later?
LOGGER.severe("Attempted to update files in project with no model: "
+ addUris + " " + removeUris + " " + changeUris);
return;
}
if (addUris.isEmpty() && removeUris.isEmpty() && changeUris.isEmpty()) {
LOGGER.info("No files provided to update");
return;
}
Model currentModel = modelResult.getResult().get(); // unwrap would throw if the model is broken
ModelAssembler assembler = assemblerFactory.get();
// So we don't have to recompute the paths later
Set removedPaths = new HashSet<>(removeUris.size());
Set changedPaths = new HashSet<>(changeUris.size());
Set visited = new HashSet<>();
if (!removeUris.isEmpty() || !changeUris.isEmpty()) {
Model.Builder builder = prepBuilderForReload(currentModel);
for (String uri : removeUris) {
String path = LspAdapter.toPath(uri);
removedPaths.add(path);
removeFileForReload(assembler, builder, path, visited);
removeDependentsForReload(assembler, builder, path, visited);
// Note: no need to remove anything from sources/imports, since they're
// based on what's in the build files.
smithyFiles.remove(path);
}
for (String uri : changeUris) {
String path = LspAdapter.toPath(uri);
changedPaths.add(path);
removeFileForReload(assembler, builder, path, visited);
removeDependentsForReload(assembler, builder, path, visited);
}
// visited will be a superset of removePaths
addRemainingMetadataForReload(builder, visited);
assembler.addModel(builder.build());
for (String visitedPath : visited) {
// Only add back stuff we aren't trying to remove.
// Only removed paths will have had their SmithyFile removed.
if (!removedPaths.contains(visitedPath)) {
assembler.addUnparsedModel(visitedPath, smithyFiles.get(visitedPath).document().copyText());
}
}
} else {
assembler.addModel(currentModel);
}
for (String uri : addUris) {
assembler.addImport(LspAdapter.toPath(uri));
}
if (!validate) {
assembler.disableValidation();
}
this.modelResult = assembler.assemble();
this.perFileMetadata = ProjectLoader.computePerFileMetadata(this.modelResult);
this.smithyFileDependenciesIndex = SmithyFileDependenciesIndex.compute(this.modelResult);
for (String visitedPath : visited) {
if (!removedPaths.contains(visitedPath)) {
SmithyFile current = smithyFiles.get(visitedPath);
Set updatedShapes = getFileShapes(visitedPath, smithyFiles.get(visitedPath).shapes());
// Only recompute the rest of the smithy file if it changed
if (changedPaths.contains(visitedPath)) {
// TODO: Could cache validation events
this.smithyFiles.put(visitedPath,
ProjectLoader.buildSmithyFile(visitedPath, current.document(), updatedShapes).build());
} else {
current.setShapes(updatedShapes);
}
}
}
for (String uri : addUris) {
String path = LspAdapter.toPath(uri);
Set fileShapes = getFileShapes(path, Collections.emptySet());
Document document = Document.of(IoUtils.readUtf8File(path));
SmithyFile smithyFile = ProjectLoader.buildSmithyFile(path, document, fileShapes).build();
smithyFiles.put(path, smithyFile);
}
}
// This mainly exists to explain why we remove the metadata
private Model.Builder prepBuilderForReload(Model model) {
return model.toBuilder()
// clearing the metadata here, and adding back only metadata from other files
// is the only sure-fire way to make sure everything is truly removed, and we
// don't lose anything
.clearMetadata();
}
private void removeFileForReload(
ModelAssembler assembler,
Model.Builder builder,
String path,
Set visited
) {
if (path == null || visited.contains(path) || path.equals(SourceLocation.none().getFilename())) {
return;
}
visited.add(path);
for (Shape shape : smithyFiles.get(path).shapes()) {
builder.removeShape(shape.getId());
// This shape may have traits applied to it in other files,
// so simply removing the shape loses the information about
// those traits.
// This shape's dependencies files will be removed and re-loaded
smithyFileDependenciesIndex.getDependenciesFiles(shape).forEach((depPath) ->
removeFileForReload(assembler, builder, depPath, visited));
// Traits applied in other files are re-added to the assembler so if/when the shape
// is reloaded, it will have those traits
smithyFileDependenciesIndex.getTraitsAppliedInOtherFiles(shape).forEach((trait) ->
assembler.addTrait(shape.getId(), trait));
}
}
private void removeDependentsForReload(
ModelAssembler assembler,
Model.Builder builder,
String path,
Set visited
) {
// This file may apply traits to shapes in other files. Normally, letting the assembler simply reparse
// the file would be fine because it would ignore the duplicated trait application coming from the same
// source location. But if the apply statement is changed/removed, the old application isn't removed, so we
// could get a duplicate trait, or a merged array trait.
smithyFileDependenciesIndex.getDependentFiles(path).forEach((depPath) ->
removeFileForReload(assembler, builder, depPath, visited));
smithyFileDependenciesIndex.getAppliedTraitsInFile(path).forEach((shapeId, traits) -> {
Shape shape = builder.getCurrentShapes().get(shapeId);
if (shape != null) {
builder.removeShape(shapeId);
AbstractShapeBuilder, ?> b = Shape.shapeToBuilder(shape);
for (Trait trait : traits) {
b.removeTrait(trait.toShapeId());
}
builder.addShape(b.build());
}
});
}
private void addRemainingMetadataForReload(Model.Builder builder, Set filesToSkip) {
for (Map.Entry> e : this.perFileMetadata.entrySet()) {
if (!filesToSkip.contains(e.getKey())) {
e.getValue().forEach(builder::putMetadataProperty);
}
}
}
private Set getFileShapes(String path, Set orDefault) {
return this.modelResult.getResult()
.map(model -> model.shapes()
.filter(shape -> shape.getSourceLocation().getFilename().equals(path))
.collect(Collectors.toSet()))
.orElse(orDefault);
}
static Builder builder() {
return new Builder();
}
static final class Builder {
private Path root;
private ProjectConfig config = ProjectConfig.empty();
private final List dependencies = new ArrayList<>();
private final Map smithyFiles = new HashMap<>();
private ValidatedResult modelResult;
private Supplier assemblerFactory = Model::assembler;
private Map> perFileMetadata = new HashMap<>();
private SmithyFileDependenciesIndex smithyFileDependenciesIndex = new SmithyFileDependenciesIndex();
private Builder() {
}
public Builder root(Path root) {
this.root = root;
return this;
}
public Builder config(ProjectConfig config) {
this.config = config;
return this;
}
public Builder dependencies(List paths) {
this.dependencies.clear();
this.dependencies.addAll(paths);
return this;
}
public Builder addDependency(Path path) {
this.dependencies.add(path);
return this;
}
public Builder smithyFiles(Map smithyFiles) {
this.smithyFiles.clear();
this.smithyFiles.putAll(smithyFiles);
return this;
}
public Builder modelResult(ValidatedResult modelResult) {
this.modelResult = modelResult;
return this;
}
public Builder assemblerFactory(Supplier assemblerFactory) {
this.assemblerFactory = assemblerFactory;
return this;
}
public Builder perFileMetadata(Map> perFileMetadata) {
this.perFileMetadata = perFileMetadata;
return this;
}
public Builder smithyFileDependenciesIndex(SmithyFileDependenciesIndex smithyFileDependenciesIndex) {
this.smithyFileDependenciesIndex = smithyFileDependenciesIndex;
return this;
}
public Project build() {
return new Project(this);
}
}
}