eu.numberfour.n4js.external.NpmPackageToProjectAdapter.java Source code

Java tutorial

Introduction

Here is the source code for eu.numberfour.n4js.external.NpmPackageToProjectAdapter.java

Source

/**
 * Copyright (c) 2016 NumberFour AG.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   NumberFour AG - Initial API and implementation
 */
package eu.numberfour.n4js.external;

import static com.google.common.collect.Sets.newHashSet;
import static eu.numberfour.n4js.external.libraries.PackageJson.PACKAGE_JSON;
import static eu.numberfour.n4js.n4mf.utils.N4MFConstants.N4MF_MANIFEST;
import static java.util.Collections.emptyList;
import static org.eclipse.core.runtime.Status.OK_STATUS;
import static org.eclipse.xtext.util.Tuples.pair;

import java.io.File;
import java.io.FileFilter;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.apache.log4j.Logger;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.MultiStatus;
import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.emf.ecore.resource.ResourceSet;
import org.eclipse.xtext.resource.XtextResourceSet;
import org.eclipse.xtext.util.Pair;

import com.google.common.collect.Iterables;
import com.google.inject.Inject;
import com.google.inject.Provider;

import eu.numberfour.n4js.external.libraries.PackageJson;
import eu.numberfour.n4js.n4mf.ProjectDescription;
import eu.numberfour.n4js.n4mf.resource.ManifestMerger;
import eu.numberfour.n4js.n4mf.utils.N4MFConstants;
import eu.numberfour.n4js.utils.Arrays2;
import eu.numberfour.n4js.utils.OSInfo;
import eu.numberfour.n4js.utils.StatusHelper;
import eu.numberfour.n4js.utils.Version;
import eu.numberfour.n4js.utils.git.GitUtils;
import eu.numberfour.n4js.utils.io.FileCopier;
import eu.numberfour.n4js.utils.io.FileDeleter;

/**
 * Adapts given npm package to n4js project form
 */
public class NpmPackageToProjectAdapter {

    private static Logger LOGGER = Logger.getLogger(NpmPackageToProjectAdapter.class);

    @Inject
    private N4JSNpmManifestContentProvider manifestContentProvider;

    @Inject
    private StatusHelper statusHelper;

    @Inject
    private TargetPlatformInstallLocationProvider installLocationProvider;

    @Inject
    private ManifestMerger manifestMerger;

    @Inject
    NpmMainModuleResolver npmMainModuleResolver;

    @Inject
    private Provider<XtextResourceSet> resourceSetProvider;

    /** Default filter for manifest fragments */
    private final static FileFilter ONLY_MANIFEST_FRAGMENTS = new FileFilter() {
        private final static String MANIFEST_FRAGMENT = N4MFConstants.MANIFEST_FRAGMENT;

        @Override
        public boolean accept(File pathname) {
            return pathname.toPath().endsWith(MANIFEST_FRAGMENT);
        }
    };

    /**
     * Adapts NPM packages in provided folder to the N4JS project structure. Only package folders which match requested
     * packages are adapted. It is expected that npm flattens packages structures, therefore it is assumed that other
     * folders are dependencies (also transitive) of the requested packages.
     *
     * Requested npm packages already look like N4JS projects (i.e. have N4MF manifest file), those packages are not
     * adapted (proper structure is assumed), but they will be returned in to the caller to allow further processing
     * (i.e. passing them to the builder).
     *
     * Returned set of N4JS project folders will not include those installed by the npm but without matching names in
     * provided set of expected packages. Those packages are treated as transitive dependencies and are not return to
     * the caller.
     *
     * @param namesOfPackagesToAdapt
     *            names of the expected packages
     * @return folders of adapted npm packages
     */
    public Pair<IStatus, Collection<File>> adaptPackages(Iterable<String> namesOfPackagesToAdapt) {

        final File nodeModulesFolder = new File(installLocationProvider.getTargetPlatformNodeModulesLocation());
        final Collection<String> names = newHashSet(namesOfPackagesToAdapt);
        final Collection<File> adaptedProjects = newHashSet();
        final File[] packageRoots = nodeModulesFolder
                .listFiles(packageName -> names.contains(packageName.getName()));

        // Initial pessimistic.
        final MultiStatus status = statusHelper
                .createMultiError("Error while adapting npm packages to N4JS format.");

        final File n4jsdsFolder = getNpmsTypeDefinitionsFolder();

        for (File packageRoot : packageRoots) {
            try {
                PackageJson packageJson = getPackageJson(packageRoot);
                final File manifest = new File(packageRoot, N4MF_MANIFEST);
                // looks like n4js project skip adaptation
                if (manifest.exists() && manifest.isFile()) {
                    adaptedProjects.add(packageRoot);
                    if (!names.remove(packageRoot.getName())) {
                        throw new IOException("Unexpectederror occurred while adapting '" + packageRoot.getName()
                                + "' npm package into N4JS format.");
                    }
                } else {

                    if (manifest.isDirectory()) {
                        throw new IOException(
                                "The manifest location is occupied by the folder '" + manifest + "'.");
                    }

                    manifest.createNewFile();

                    try {
                        generateManifestContent(packageRoot, packageJson, manifest);
                        adaptedProjects.add(packageRoot);
                        if (!names.remove(packageRoot.getName())) {
                            throw new IOException("Unexpectederror occurred while adapting '"
                                    + packageRoot.getName() + "' npm package into N4JS format.");
                        }
                    } catch (final Exception e) {
                        try {
                            FileDeleter.delete(manifest);
                        } catch (IOException ioe) {
                            // Intentionally swallowed to get the original cause.
                            LOGGER.error("Error while trying to clean up corrupted " + manifest + " file.", e);
                        }
                        throw e;
                    }

                }

                if (n4jsdsFolder != null) {
                    addTypeDefinitions(packageRoot, packageJson, manifest, n4jsdsFolder);
                }

            } catch (final Exception e) {
                status.add(statusHelper.createError("Unexpectederror occurred while adapting '"
                        + packageRoot.getName() + "' npm package into N4JS format.", e));
            }

        }

        if (Arrays2.isEmpty(status.getChildren())) {
            return pair(OK_STATUS, adaptedProjects);
        } else {
            return pair(status, emptyList());
        }

    }

    /**
     * Reads, parses and returns with the content of the {@code package.json} file as a POJO for the given NPM
     * package root location.
     *
     * @param packageRoot
     *            the root location of the NPM package.
     *
     * @return the POJO instance that represents the read, parsed content of the {@code package.json} file.
     *
     * @throws IOException
     *             if {@code package.json} file does not exists, hence the content cannot be read.
     */
    /* default */ PackageJson getPackageJson(File packageRoot) throws IOException {

        final File packageJsonResource = new File(packageRoot, PACKAGE_JSON);
        if (!packageJsonResource.exists() || !packageJsonResource.isFile()) {
            throw new IOException("Cannot read package.json content for package '" + packageJsonResource.getName()
                    + "' at '" + packageJsonResource + "'.");
        }
        return PackageJson.readValue(packageJsonResource.toURI());
    }

    private static String NPM_DEFINITIONS_FOLDER_NAME = "npm";

    private File getNpmsTypeDefinitionsFolder() {
        return getNpmsTypeDefinitionsFolder(true);
    }

    /**
     * Returns with the root folder of all available npm package definitions. Or returns with {@code null} if no
     * definitions are available. Also performs an on demand {@code git pull}.
     *
     * @param performGitPull
     *            {@code true} if a git pull has to be performed in the local clone.
     *
     * @return the root folder of all npm package definitions or {@code null} if missing.
     */
    /* default */ File getNpmsTypeDefinitionsFolder(final boolean performGitPull) {
        File repositoryLocation = new File(installLocationProvider.getTargetPlatformLocalGitRepositoryLocation());

        if (performGitPull) {
            // pull changes
            GitUtils.pull(repositoryLocation.toPath());
        }

        final File definitionsRoot = new File(repositoryLocation, NPM_DEFINITIONS_FOLDER_NAME);
        if (definitionsRoot.exists() && definitionsRoot.isDirectory()) {
            return definitionsRoot;
        } else {
            LOGGER.error(
                    "Cannot locate local git repository clone for N4JS definition files: " + definitionsRoot + ".");
            return null;
        }
    }

    /**
     * Add type definitions (N4JSDs) to the npm package. Types are added only if matching version is found.
     *
     * This method suppresses any potential issues as adding type definitions to some npm package does not
     * affect overall npm usage. Still, errors are {@link #LOGGER logged} to help troubleshooting potential
     * issues and returns with an {@link IStatus status} instance that represents the problem if any.
     *
     * @param packageRoot
     *            npm package folder.
     * @param packageJson
     *            {@link PackageJson package.json} of that package.
     * @param manifest
     *            file that will be adjusted according to manifest fragments.
     * @param definitionsFolder
     *            root folder for npm type definitions.
     *
     * @return a status representing the outcome of performed the operation.
     */
    /* default */ IStatus addTypeDefinitions(File packageRoot, PackageJson packageJson, File manifest,
            File definitionsFolder) {

        String packageName = packageRoot.getName();
        File packageN4JSDsRoot = new File(definitionsFolder, packageName);
        if (!(packageN4JSDsRoot.exists() && packageN4JSDsRoot.isDirectory())) {
            LOGGER.info("No type definitions found for '" + packageName + "' npm package.");
            return statusHelper.OK();
        }

        String packageJsonVersion = packageJson.getVersion();
        Version packageVersion = Version.createFromString(packageJsonVersion);
        String[] list = packageN4JSDsRoot.list();
        Set<Version> availableTypeDefinitionsVersions = new HashSet<>();
        for (int i = 0; i < list.length; i++) {
            String version = list[i];
            Version availableTypeDefinitionsVersion = Version.createFromString(version);
            if (!Version.MISSING.equals(availableTypeDefinitionsVersion)) {
                availableTypeDefinitionsVersions.add(availableTypeDefinitionsVersion);
            }
        }

        Version closestMatchingVersion = Version.findClosestMatching(availableTypeDefinitionsVersions,
                packageVersion);
        if (Version.MISSING.equals(closestMatchingVersion)) {
            LOGGER.info("No proper versions can be found for '" + packageName + "' npm package.");
            LOGGER.info("Desired version was: " + packageVersion + ".");
            if (availableTypeDefinitionsVersions.isEmpty()) {
                LOGGER.info("No versions were available.");
            } else if (1 == availableTypeDefinitionsVersions.size()) {
                final Version head = availableTypeDefinitionsVersions.iterator().next();
                LOGGER.info("The following version was available for '" + packageName + "': " + head + ".");
            } else {
                final String versions = Iterables.toString(availableTypeDefinitionsVersions);
                LOGGER.info("The following versions were available for '" + packageName + "': " + versions + ".");
            }
            return statusHelper.OK();
        }

        File packageVersionedN4JSD = new File(packageN4JSDsRoot, closestMatchingVersion.toString());
        if (!(definitionsFolder.exists() && definitionsFolder.isDirectory())) {
            final String message = "Cannot find type definitions folder for '" + packageName
                    + "' npm package for version '" + closestMatchingVersion + "'.";
            LOGGER.error(message);
            return statusHelper.createError(message);
        }

        try {
            FileCopier.copy(packageVersionedN4JSD.toPath(), packageRoot.toPath());
        } catch (IOException e) {
            final String message = "Error while trying to update type definitions content for '" + packageName
                    + "' npm package.";
            LOGGER.error(message);
            return statusHelper.createError(message, e);
        }

        // adjust manifest according to type definitions manifest fragments
        File[] manifestFragments = packageRoot.listFiles(ONLY_MANIFEST_FRAGMENTS);
        return adjustManifest(manifest, manifestFragments);
    }

    /**
     * Adjust manifests based on provided manifest fragments.
     *
     * @param manifest
     *            file to be adjusted
     * @param manifestFragments
     *            that will be used to adjust the manifest
     */
    private IStatus adjustManifest(final File manifest, final File... manifestFragments) {

        if (Arrays2.isEmpty(manifestFragments)) {
            // Nothing to merge.
            return statusHelper.OK();
        }

        final URI manifestURI = URI.createFileURI(manifest.getAbsolutePath());

        ProjectDescription pd = null;
        for (int i = 0; i < manifestFragments.length; i++) {
            File fragment = manifestFragments[i];
            if (fragment.exists() && fragment.isFile()) {
                URI manifestFragmentURI = URI.createFileURI(fragment.getAbsolutePath());
                pd = manifestMerger.mergeContent(manifestFragmentURI, manifestURI);
                fragment.delete();
            } else {
                LOGGER.warn("Broken manifest fragment: " + fragment + ".");
            }
        }

        if (pd != null) {
            ResourceSet resourceSet = resourceSetProvider.get();
            Resource resource = resourceSet.getResource(manifestURI, true);
            List<EObject> contents = resource.getContents();
            contents.clear();
            contents.add(pd);
            try {
                resource.save(null);
                return statusHelper.OK();
            } catch (IOException e) {
                final String message = "Error while trying to write N4 manifest content for: " + manifestURI + ".";
                LOGGER.error(message, e);
                return statusHelper.createError(message, e);
            }
        } else {
            final String message = "Failed to merge N4 manifest fragments into '" + manifestURI + "'.";
            LOGGER.error(message);
            return statusHelper.createError(message);
        }
    }

    /**
     * Writes contents of the {@link N4MFConstants#N4MF_MANIFEST manifest file} for a given npm package.
     *
     * @param projectFolder
     *            root folder of the npm package in which manifest is written
     * @param packageJSON
     *            that will be used as manifest data source
     * @param manifest
     *            file to which contents should be written
     */
    private void generateManifestContent(File projectFolder, PackageJson packageJSON, File manifest)
            throws IOException, InterruptedException {

        String projectName = packageJSON.getName();
        String manifestMain = computeMainModule(projectFolder);

        if (!projectFolder.getName().equals(projectName)) {
            LOGGER.warn("project folder and project name are different : " + projectFolder.getName() + " <> + "
                    + packageJSON.getName());
        }

        try (FileWriter fw = new FileWriter(manifest)) {
            fw.write(manifestContentProvider.getContent(projectName, ".", ".", manifestMain));
        }
    }

    /**
     */
    private String computeMainModule(File projectFolder) throws IOException, InterruptedException {
        File main = new File(npmMainModuleResolver.resolveMainModule(projectFolder));

        Path packagePath = projectFolder.toPath();
        Path packageMainModulePath = main.toPath();

        Path mainmoduleRelative = packagePath.relativize(packageMainModulePath);

        String mainSpecifier = mainmoduleRelative.toString();

        // strip extension
        int dotIndex = mainSpecifier.lastIndexOf('.');
        String ext = (dotIndex == -1) ? "" : mainSpecifier.substring(dotIndex);
        mainSpecifier = mainSpecifier.substring(0, (mainSpecifier.length() - ext.length()));

        // replace windows path separators
        if (OSInfo.isWindows())
            mainSpecifier = mainSpecifier.replace(File.separator, "/");

        // strip relative start part
        if (mainSpecifier.startsWith("./"))
            mainSpecifier = mainSpecifier.substring(2);

        return mainSpecifier;
    }

}