All Downloads are FREE. Search and download functionalities are using the official Maven repository.

software.amazon.smithy.lsp.project.Project Maven / Gradle / Ivy

There is a newer version: 0.5.0
Show newest version
/*
 * 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); } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy