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

io.github.ascopes.jct.utils.FileUtils Maven / Gradle / Ivy

/*
 * Copyright (C) 2022 - 2024, the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.github.ascopes.jct.utils;

import static io.github.ascopes.jct.utils.IterableUtils.requireAtLeastOne;
import static java.util.stream.Collectors.toUnmodifiableList;

import io.github.ascopes.jct.ex.JctIllegalInputException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
import javax.tools.JavaFileObject.Kind;
import org.jspecify.annotations.Nullable;

/**
 * Utilities for handling files in the file system.
 *
 * @author Ashley Scopes
 * @since 0.0.1
 */
public final class FileUtils extends UtilityClass {

  // Exclude any "empty" extensions. At the time of writing, this will just exclude Kind.EMPTY,
  // but doing this will prevent future API changes from breaking any assumptions we make. In
  // addition to this, sort by the longest extension names first. This will prevent future
  // changes that may add subsets of existing kinds from creating unexpected results.
  private static final List KINDS = Stream
      .of(Kind.values())
      .filter(kind -> !kind.extension.isEmpty())
      .collect(toUnmodifiableList());

  private static final StringSlicer PACKAGE_SLICER = new StringSlicer(".");
  private static final StringSlicer RESOURCE_SLICER = new StringSlicer("/");

  private FileUtils() {
    // Disallow initialisation.
  }

  /**
   * Obtain a URL for the given path.
   *
   * @param path the path to obtain a URL for.
   * @return the URL.
   * @throws JctIllegalInputException if the path does not support being represented as a URL.
   */
  public static URL retrieveRequiredUrl(Path path) {
    try {
      return path.toUri().toURL();
    } catch (MalformedURLException ex) {
      // Many compiler tools make use of the URLs provided by the URL classloader, so not being
      // able to support this is somewhat problematic for us.
      // While we can implement a classloader that does not require URLs, this can lead to some
      // annotation processor libraries being unable to function correctly. To avoid the confusion,
      // we enforce that URLs must be able to be generated for the path. Users are far less likely
      // to want to use custom FileSystem objects that do not have URL protocol handlers than they
      // are to want to assume that our classloaders we expose are instances of URLClassLoader.
      throw new JctIllegalInputException(
          "Cannot obtain a URL for the given path "
              + StringUtils.quoted(path)
              + ", this is likely due to no URL protocol implementation for this file system. "
              + "Unfortunately, this feature is required for consistent classloading to be "
              + "available to the compiler.",
          ex
      );
    }
  }

  /**
   * Recursively resolve the path fragments onto the given root and return it.
   *
   * @param root  the root to start with.
   * @param parts the parts to resolve.
   * @return the resolved path.
   */
  public static Path resolvePathRecursively(Path root, List parts) {
    return resolve(root, parts);
  }

  /**
   * Assert that the given name is a valid name for a directory, and that it does not contain
   * potentially dangerous characters such as double-dots or slashes that could be used to escape
   * the directory we are running from.
   *
   * @param name the directory name to check. May be {@code null} if invalid.
   * @throws JctIllegalInputException if the name is invalid.
   * @throws NullPointerException     if the name is {@code null}.
   */
  public static void assertValidRootName(@Nullable String name) {
    Objects.requireNonNull(name, "name");

    if (name.isBlank()) {
      throw new JctIllegalInputException(
          "Directory name cannot be blank: " + StringUtils.quoted(name)
      );
    }

    if (!name.equals(name.trim())) {
      throw new JctIllegalInputException(
          "Directory name cannot begin or end in spaces: " + StringUtils.quoted(name)
      );
    }

    if (name.contains("/") || name.contains("\\") || name.contains("..") || name.contains("_")) {
      throw new JctIllegalInputException(
          "Invalid file name provided: " + StringUtils.quoted(name)
      );
    }
  }

  /**
   * Convert a path to a binary name of a class.
   *
   * @param path the relative path to convert.
   * @return the expected binary name.
   * @throws JctIllegalInputException if the path is absolute.
   */
  public static String pathToBinaryName(Path path) {
    if (path.isAbsolute()) {
      throw new JctIllegalInputException("Path cannot be absolute: " + StringUtils.quoted(path));
    }

    var count = path.getNameCount();
    var names = new String[count];

    for (var i = 0; i < count; ++i) {
      names[i] = stripFileExtension(path.getName(i).toString());
    }

    return String.join(".", names);
  }

  /**
   * Convert a binary class name to a package name.
   *
   * @param binaryName the binary name to convert.
   * @return the expected package name.
   */
  public static String binaryNameToPackageName(String binaryName) {
    return stripClassName(binaryName);
  }

  /**
   * Convert a binary class name to a simple class name.
   *
   * @param binaryName the binary name to convert.
   * @return the expected simple class name.
   */
  public static String binaryNameToSimpleClassName(String binaryName) {
    var lastDot = binaryName.lastIndexOf('.');

    if (lastDot == -1) {
      // The class has no package
      return binaryName;
    }

    return binaryName.substring(lastDot + 1);
  }

  /**
   * Convert a binary class name to a path.
   *
   * @param directory  the base directory the package resides within. This is used to ensure the
   *                   correct path root and provider is picked.
   * @param binaryName the binary name to convert.
   * @param kind       the kind of the file.
   * @return the expected path.
   */
  public static Path binaryNameToPath(Path directory, String binaryName, Kind kind) {
    var packageName = binaryNameToPackageName(binaryName);
    var classFileName = binaryNameToSimpleClassName(binaryName) + kind.extension;
    return resolve(directory, PACKAGE_SLICER.splitToArray(packageName)).resolve(classFileName);
  }

  /**
   * Convert a given package name to a path.
   *
   * @param directory   the base directory the package resides within. This is used to ensure the
   *                    correct path root and provider is picked.
   * @param packageName the name of the package.
   * @return the expected path.
   */
  public static Path packageNameToPath(Path directory, String packageName) {
    for (var part : PACKAGE_SLICER.splitToArray(packageName)) {
      directory = directory.resolve(part);
    }
    return directory;
  }

  /**
   * Convert a simple class name to a path.
   *
   * @param packageDirectory the directory the class resides within.
   * @param className        the simple class name.
   * @param kind             the kind of the file.
   * @return the expected path.
   */
  public static Path simpleClassNameToPath(Path packageDirectory, String className, Kind kind) {
    var classFileName = className + kind.extension;
    return resolve(packageDirectory, classFileName);
  }

  /**
   * Convert a resource name that is found in a given package to a NIO path.
   *
   * @param directory    the base directory the package resides within. This is used to ensure the
   *                     correct path root and provider is picked.
   * @param packageName  the package name that the resource resides within.
   * @param relativeName the relative name of the resource.
   * @return the expected path.
   */
  public static Path resourceNameToPath(Path directory, String packageName, String relativeName) {
    if (!relativeName.startsWith("/")) {
      var baseDir = resolve(directory, PACKAGE_SLICER.splitToArray(packageName));
      return relativeResourceNameToPath(baseDir, relativeName);
    }

    // If we have a relative name that starts with a `/`, then we assume that it is relative
    // to the root package, so we ignore the given package name. We only then use the
    // directory to determine the file system to work off of.

    // Prepend the root part, as this gets dropped otherwise.
    var parts = new ArrayList();
    parts.add("/");

    for (var part : RESOURCE_SLICER.splitToArray(relativeName)) {
      if (!part.isEmpty()) {
        parts.add(part);
      }
    }

    return resolve(directory, parts);
  }

  /**
   * Convert a relative class path resource path to a NIO path.
   *
   * @param directory the directory the resource sits within.
   * @param pathFragments the path fragments to put together.
   * @return the path to the resource on the file system.
   */
  public static Path relativeResourceNameToPath(Path directory, String... pathFragments) {
    requireAtLeastOne(pathFragments, "pathFragments");
    return resolve(directory, pathFragments);
  }

  /**
   * Determine the kind of file in the given path.
   *
   * @param path the path to inspect.
   * @return the kind of file. If not known, this will return {@link Kind#OTHER}.
   */
  public static Kind pathToKind(Path path) {
    // path.getFileName() will be null if the path is the root path. Shouldn't ever
    // result in this being called ideally, but this prevents unexpected NullPointerExceptions
    // elsewhere.
    var fileName = Objects.toString(path.getFileName(), "");

    for (var kind : KINDS) {
      if (fileName.endsWith(kind.extension)) {
        return kind;
      }
    }

    return Kind.OTHER;
  }

  /**
   * Return a predicate for NIO paths that filters out any files that do not match one of the given
   * file kinds.
   *
   * 

Note: this expects the file to exist for the predicate to return * {@code true}. Any non-existent files will always return {@code false}, even if their name * matches one of the provided kinds. * * @param kinds the set of kinds of file to allow. * @return the predicate. */ public static Predicate fileWithAnyKind(Set kinds) { return path -> Files.isRegularFile(path) && kinds.contains(pathToKind(path)); } private static Path resolve(Path root, String... parts) { return resolve(root, List.of(parts)); } private static Path resolve(Path root, Iterable parts) { for (var part : parts) { root = root.resolve(part); } return root.normalize(); } private static String stripClassName(String binaryName) { var classIndex = binaryName.lastIndexOf('.'); return classIndex == -1 ? "" : binaryName.substring(0, classIndex); } private static String stripFileExtension(String name) { var extIndex = name.lastIndexOf('.'); return extIndex == -1 ? name : name.substring(0, extIndex); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy