/* * Copyright 2016 DiffPlug * * 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 com.diffplug.spotless.extra; import static com.diffplug.spotless.extra.LibExtraPreconditions.requireElementsNonNull; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; import org.eclipse.jgit.attributes.Attribute; import org.eclipse.jgit.attributes.AttributesNode; import org.eclipse.jgit.attributes.AttributesRule; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ConfigConstants; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.CoreConfig.EOL; import org.eclipse.jgit.storage.file.FileBasedConfig; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.SystemReader; import com.googlecode.concurrenttrees.radix.ConcurrentRadixTree; import com.googlecode.concurrenttrees.radix.node.Node; import com.googlecode.concurrenttrees.radix.node.concrete.DefaultCharSequenceNodeFactory; import com.diffplug.common.base.Errors; import com.diffplug.common.tree.TreeStream; import com.diffplug.spotless.FileSignature; import com.diffplug.spotless.LazyForwardingEquality; import com.diffplug.spotless.LineEnding; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * Uses [.gitattributes](https://git-scm.com/docs/gitattributes) to determine * the appropriate line ending. Falls back to the `core.eol` property in the * git config if there are no applicable git attributes, then finally falls * back to the platform native. */ public final class GitAttributesLineEndings { // prevent direct instantiation private GitAttributesLineEndings() {} public static Policy create(File projectDir, Supplier<Iterable<File>> toFormat) { return new Policy(projectDir, toFormat); } static class Policy extends LazyForwardingEquality<FileState> implements LineEnding.Policy { private static final long serialVersionUID = 1L; final transient File projectDir; final transient Supplier<Iterable<File>> toFormat; Policy(File projectDir, Supplier<Iterable<File>> toFormat) { this.projectDir = Objects.requireNonNull(projectDir, "projectDir"); this.toFormat = Objects.requireNonNull(toFormat, "toFormat"); } @Override protected FileState calculateState() throws Exception { return new FileState(projectDir, toFormat.get()); } /** * Initializing the state() for up-to-date checking is faster than the full initialization * needed to actually do the formatting. We load the Runtime lazily from the state(). */ transient Runtime runtime; @Override public String getEndingFor(File file) { if (runtime == null) { runtime = state().atRuntime(); } return runtime.getEndingFor(file); } } @SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED") static class FileState implements Serializable { private static final long serialVersionUID = 1L; /** /etc/gitconfig (system-global), ~/.gitconfig, project/.git/config (each might-not exist). */ transient final FileBasedConfig systemConfig, userConfig, repoConfig; /** Global .gitattributes file pointed at by systemConfig or userConfig, and the file in the repo. */ transient final @Nullable File globalAttributesFile, repoAttributesFile; /** git worktree root, might not exist if we're not in a git repo. */ transient final @Nullable File workTree; /** All the .gitattributes files in the work tree that we're formatting. */ transient final List<File> gitattributes; /** The signature of *all* of the files below. */ final FileSignature signature; @SuppressFBWarnings("SIC_INNER_SHOULD_BE_STATIC_ANON") FileState(File projectDir, Iterable<File> toFormat) throws IOException { requireElementsNonNull(toFormat); ///////////////////////////////// // USER AND SYSTEM-WIDE VALUES // ///////////////////////////////// systemConfig = SystemReader.getInstance().openSystemConfig(null, FS.DETECTED); Errors.log().run(systemConfig::load); userConfig = SystemReader.getInstance().openUserConfig(systemConfig, FS.DETECTED); Errors.log().run(userConfig::load); // copy-pasted from org.eclipse.jgit.lib.CoreConfig String globalAttributesPath = userConfig.getString(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_ATTRIBUTESFILE); // copy-pasted from org.eclipse.jgit.internal.storage.file.GlobalAttributesNode if (globalAttributesPath != null) { FS fs = FS.detect(); if (globalAttributesPath.startsWith("~/")) { //$NON-NLS-1$ globalAttributesFile = fs.resolve(fs.userHome(), globalAttributesPath.substring(2)); } else { globalAttributesFile = fs.resolve(null, globalAttributesPath); } } else { globalAttributesFile = null; } ////////////////////////// // REPO-SPECIFIC VALUES // ////////////////////////// FileRepositoryBuilder builder = new FileRepositoryBuilder(); builder.findGitDir(projectDir); if (builder.getGitDir() != null) { workTree = builder.getWorkTree(); repoConfig = new FileBasedConfig(userConfig, new File(builder.getGitDir(), Constants.CONFIG), FS.DETECTED); repoAttributesFile = new File(builder.getGitDir(), Constants.INFO_ATTRIBUTES); } else { workTree = null; // null would make repoConfig.getFile() bomb below repoConfig = new FileBasedConfig(userConfig, null, FS.DETECTED) { @Override public void load() { // empty, do not load } @Override public boolean isOutdated() { // regular class would bomb here return false; } }; repoAttributesFile = null; } Errors.log().run(repoConfig::load); // The .gitattributes files which apply to the files we are formatting gitattributes = gitAttributes(toFormat); // find every actual File which exists above Stream<File> misc = Stream.of(systemConfig.getFile(), userConfig.getFile(), repoConfig.getFile(), globalAttributesFile, repoAttributesFile); List<File> toSign = Stream.concat(gitattributes.stream(), misc) .filter(file -> file != null && file.exists() && file.isFile()) .collect(Collectors.toList()); // sign it for up-to-date checking signature = FileSignature.signAsSet(toSign); } /** Returns all of the .gitattributes files which affect the given files. */ static List<File> gitAttributes(Iterable<File> files) { // build a radix tree out of all the parent folders in these files ConcurrentRadixTree<String> tree = new ConcurrentRadixTree<>(new DefaultCharSequenceNodeFactory()); for (File file : files) { String parentPath = file.getParent() + File.separator; tree.putIfAbsent(parentPath, parentPath); } // traverse the edge nodes to find the outermost folders List<File> edgeFolders = TreeStream.depthFirst(Node::getOutgoingEdges, tree.getNode()) .filter(node -> node.getOutgoingEdges().isEmpty() && node.getValue() != null) .map(node -> new File((String) node.getValue())) .collect(Collectors.toList()); List<File> gitAttrFiles = new ArrayList<>(); Set<File> visitedFolders = new HashSet<>(); for (File edgeFolder : edgeFolders) { gitAttrAddWithParents(edgeFolder, visitedFolders, gitAttrFiles); } return gitAttrFiles; } /** Searches folder and all its parents for gitattributes files. */ private static void gitAttrAddWithParents(File folder, Set<File> visitedFolders, Collection<File> gitAttrFiles) { if (!visitedFolders.add(folder)) { // bail if we already visited this folder return; } File gitAttr = new File(folder, Constants.DOT_GIT_ATTRIBUTES); if (gitAttr.exists() && gitAttr.isFile()) { gitAttrFiles.add(gitAttr); } File parentFile = folder.getParentFile(); if (parentFile != null) { gitAttrAddWithParents(folder.getParentFile(), visitedFolders, gitAttrFiles); } } private Runtime atRuntime() { return new Runtime(parseRules(repoAttributesFile), workTree, repoConfig, parseRules(globalAttributesFile)); } } /** https://github.com/git/git/blob/1fe8f2cf461179c41f64efbd1dc0a9fb3b7a0fb1/Documentation/gitattributes.txt */ static class Runtime { /** .git/info/attributes (and the worktree with that file) */ final List<AttributesRule> infoRules; final @Nullable File workTree; /** Cache of local .gitattributes files. */ final AttributesCache cache = new AttributesCache(); /** Global .gitattributes file. */ final List<AttributesRule> globalRules; /** * Default line ending, determined in this order (paths are a teensy different platform to platform). * * - .git/config (per-repo) * - ~/.gitconfig (per-user) * - /etc/gitconfig (system-wide) * - <platform native> */ final String defaultEnding; private Runtime(List<AttributesRule> infoRules, @Nullable File workTree, Config config, List<AttributesRule> globalRules) { this.infoRules = Objects.requireNonNull(infoRules); this.workTree = workTree; this.defaultEnding = fromEol(config.getEnum(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_EOL, EOL.NATIVE)).str(); this.globalRules = Objects.requireNonNull(globalRules); } private static final String KEY_EOL = "eol"; private static final boolean IS_FOLDER = false; public String getEndingFor(File file) { // handle the info rules first, since they trump everything if (workTree != null && !infoRules.isEmpty()) { String rootPath = workTree.getAbsolutePath(); String path = file.getAbsolutePath(); if (path.startsWith(rootPath)) { String subpath = path.substring(rootPath.length() + 1); String infoResult = findAttributeInRules(subpath, IS_FOLDER, KEY_EOL, infoRules); if (infoResult != null) { return convertEolToLineEnding(infoResult, file); } } } // handle the local .gitattributes (if any) String localResult = cache.valueFor(file, KEY_EOL); if (localResult != null) { return convertEolToLineEnding(localResult, file); } // handle the global .gitattributes String globalResult = findAttributeInRules(file.getAbsolutePath(), IS_FOLDER, KEY_EOL, globalRules); if (globalResult != null) { return convertEolToLineEnding(globalResult, file); } // if all else fails, use the default value return defaultEnding; } private static String convertEolToLineEnding(String eol, File file) { switch (eol.toLowerCase(Locale.ROOT)) { case "lf": return LineEnding.UNIX.str(); case "crlf": return LineEnding.WINDOWS.str(); default: System.err.println(".gitattributes file has unspecified eol value: " + eol + " for " + file + ", defaulting to platform native"); return LineEnding.PLATFORM_NATIVE.str(); } } /** Creates a LineEnding from an EOL. */ private static LineEnding fromEol(EOL eol) { // @formatter:off switch (eol) { case CRLF: return LineEnding.WINDOWS; case LF: return LineEnding.UNIX; case NATIVE: return LineEnding.PLATFORM_NATIVE; default: throw new IllegalArgumentException("Unknown eol " + eol); } // @formatter:on } } /** Parses and caches .gitattributes files. */ static class AttributesCache { final Map<File, List<AttributesRule>> rulesAtPath = new HashMap<>(); /** Returns a value if there is one, or unspecified if there isn't. */ public @Nullable String valueFor(File file, String key) { StringBuilder pathBuilder = new StringBuilder(file.getAbsolutePath().length()); boolean isDirectory = file.isDirectory(); File parent = file.getParentFile(); pathBuilder.append(file.getName()); while (parent != null) { String path = pathBuilder.toString(); String value = findAttributeInRules(path, isDirectory, key, getRulesForFolder(parent)); if (value != null) { return value; } pathBuilder.insert(0, parent.getName() + "/"); parent = parent.getParentFile(); } return null; } /** Returns the gitattributes rules for the given folder. */ private List<AttributesRule> getRulesForFolder(File folder) { return rulesAtPath.computeIfAbsent(folder, f -> parseRules(new File(f, Constants.DOT_GIT_ATTRIBUTES))); } } /** Parses a list of rules from the given file, returning an empty list if the file doesn't exist. */ private static List<AttributesRule> parseRules(@Nullable File file) { if (file != null && file.exists() && file.isFile()) { try (InputStream stream = new FileInputStream(file)) { AttributesNode parsed = new AttributesNode(); parsed.parse(stream); return parsed.getRules(); } catch (IOException e) { // no need to crash the whole plugin System.err.println("Problem parsing " + file.getAbsolutePath()); e.printStackTrace(); } } return Collections.emptyList(); } /** Parses an attribute value from a list of rules, returning null if there is no match for the given key. */ private static @Nullable String findAttributeInRules(String subpath, boolean isFolder, String key, List<AttributesRule> rules) { String value = null; // later rules override earlier ones for (AttributesRule rule : rules) { if (rule.isMatch(subpath, isFolder)) { for (Attribute attribute : rule.getAttributes()) { if (attribute.getKey().equals(key)) { value = attribute.getValue(); } } } } return value; } }