AttributesHandler.java

/*
 * Copyright (C) 2015, 2022 Ivan Motsch <ivan.motsch@bsiag.com> and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */
package org.eclipse.jgit.attributes;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.function.Supplier;

import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.attributes.Attribute.State;
import org.eclipse.jgit.dircache.DirCacheIterator;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.treewalk.AbstractTreeIterator;
import org.eclipse.jgit.treewalk.CanonicalTreeParser;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.TreeWalk.OperationType;
import org.eclipse.jgit.treewalk.WorkingTreeIterator;

/**
 * The attributes handler knows how to retrieve, parse and merge attributes from
 * the various gitattributes files. Furthermore it collects and expands macro
 * expressions. The method {@link #getAttributes()} yields the ready processed
 * attributes for the current path represented by the
 * {@link org.eclipse.jgit.treewalk.TreeWalk}
 * <p>
 * The implementation is based on the specifications in
 * http://git-scm.com/docs/gitattributes
 *
 * @since 4.3
 */
public class AttributesHandler {
	private static final String MACRO_PREFIX = "[attr]"; //$NON-NLS-1$

	private static final String BINARY_RULE_KEY = "binary"; //$NON-NLS-1$

	/**
	 * This is the default <b>binary</b> rule that is present in any git folder
	 * <code>[attr]binary -diff -merge -text</code>
	 */
	private static final List<Attribute> BINARY_RULE_ATTRIBUTES = new AttributesRule(
			MACRO_PREFIX + BINARY_RULE_KEY, "-diff -merge -text") //$NON-NLS-1$
					.getAttributes();

	private final TreeWalk treeWalk;

	private final Supplier<CanonicalTreeParser> attributesTree;

	private final AttributesNode globalNode;

	private final AttributesNode infoNode;

	private final Map<String, List<Attribute>> expansions = new HashMap<>();

	/**
	 * Create an {@link org.eclipse.jgit.attributes.AttributesHandler} with
	 * default rules as well as merged rules from global, info and worktree root
	 * attributes
	 *
	 * @param treeWalk
	 *            a {@link org.eclipse.jgit.treewalk.TreeWalk}
	 * @throws java.io.IOException
	 * @deprecated since 6.1, use {@link #AttributesHandler(TreeWalk, Supplier)}
	 *             instead
	 */
	@Deprecated
	public AttributesHandler(TreeWalk treeWalk) throws IOException {
		this(treeWalk, () -> treeWalk.getTree(CanonicalTreeParser.class));
	}

	/**
	 * Create an {@link org.eclipse.jgit.attributes.AttributesHandler} with
	 * default rules as well as merged rules from global, info and worktree root
	 * attributes
	 *
	 * @param treeWalk
	 *            a {@link org.eclipse.jgit.treewalk.TreeWalk}
	 * @param attributesTree
	 *            the tree to read .gitattributes from
	 * @throws java.io.IOException
	 * @since 6.1
	 */
	public AttributesHandler(TreeWalk treeWalk,
			Supplier<CanonicalTreeParser> attributesTree) throws IOException {
		this.treeWalk = treeWalk;
		this.attributesTree = attributesTree;
		AttributesNodeProvider attributesNodeProvider = treeWalk
				.getAttributesNodeProvider();
		this.globalNode = attributesNodeProvider != null
				? attributesNodeProvider.getGlobalAttributesNode() : null;
		this.infoNode = attributesNodeProvider != null
				? attributesNodeProvider.getInfoAttributesNode() : null;

		AttributesNode rootNode = attributesNode(treeWalk,
				rootOf(treeWalk.getTree(WorkingTreeIterator.class)),
				rootOf(treeWalk.getTree(DirCacheIterator.class)),
				rootOf(attributesTree.get()));

		expansions.put(BINARY_RULE_KEY, BINARY_RULE_ATTRIBUTES);
		for (AttributesNode node : new AttributesNode[] { globalNode, rootNode,
				infoNode }) {
			if (node == null) {
				continue;
			}
			for (AttributesRule rule : node.getRules()) {
				if (rule.getPattern().startsWith(MACRO_PREFIX)) {
					expansions.put(rule.getPattern()
							.substring(MACRO_PREFIX.length()).trim(),
							rule.getAttributes());
				}
			}
		}
	}

	/**
	 * See {@link org.eclipse.jgit.treewalk.TreeWalk#getAttributes()}
	 *
	 * @return the {@link org.eclipse.jgit.attributes.Attributes} for the
	 *         current path represented by the
	 *         {@link org.eclipse.jgit.treewalk.TreeWalk}
	 * @throws java.io.IOException
	 */
	public Attributes getAttributes() throws IOException {
		String entryPath = treeWalk.getPathString();
		boolean isDirectory = (treeWalk.getFileMode() == FileMode.TREE);
		Attributes attributes = new Attributes();

		// Gets the info attributes
		mergeInfoAttributes(entryPath, isDirectory, attributes);

		// Gets the attributes located on the current entry path
		mergePerDirectoryEntryAttributes(entryPath, entryPath.lastIndexOf('/'),
				isDirectory,
				treeWalk.getTree(WorkingTreeIterator.class),
				treeWalk.getTree(DirCacheIterator.class),
				attributesTree.get(),
				attributes);

		// Gets the attributes located in the global attribute file
		mergeGlobalAttributes(entryPath, isDirectory, attributes);

		// now after all attributes are collected - in the correct hierarchy
		// order - remove all unspecified entries (the ! marker)
		for (Attribute a : attributes.getAll()) {
			if (a.getState() == State.UNSPECIFIED)
				attributes.remove(a.getKey());
		}

		return attributes;
	}

	/**
	 * Merges the matching GLOBAL attributes for an entry path.
	 *
	 * @param entryPath
	 *            the path to test. The path must be relative to this attribute
	 *            node's own repository path, and in repository path format
	 *            (uses '/' and not '\').
	 * @param isDirectory
	 *            true if the target item is a directory.
	 * @param result
	 *            that will hold the attributes matching this entry path. This
	 *            method will NOT override any existing entry in attributes.
	 */
	private void mergeGlobalAttributes(String entryPath, boolean isDirectory,
			Attributes result) {
		mergeAttributes(globalNode, entryPath, isDirectory, result);
	}

	/**
	 * Merges the matching INFO attributes for an entry path.
	 *
	 * @param entryPath
	 *            the path to test. The path must be relative to this attribute
	 *            node's own repository path, and in repository path format
	 *            (uses '/' and not '\').
	 * @param isDirectory
	 *            true if the target item is a directory.
	 * @param result
	 *            that will hold the attributes matching this entry path. This
	 *            method will NOT override any existing entry in attributes.
	 */
	private void mergeInfoAttributes(String entryPath, boolean isDirectory,
			Attributes result) {
		mergeAttributes(infoNode, entryPath, isDirectory, result);
	}

	/**
	 * Merges the matching working directory attributes for an entry path.
	 *
	 * @param entryPath
	 *            the path to test. The path must be relative to this attribute
	 *            node's own repository path, and in repository path format
	 *            (uses '/' and not '\').
	 * @param nameRoot
	 *            index of the '/' preceeding the current level, or -1 if none
	 * @param isDirectory
	 *            true if the target item is a directory.
	 * @param workingTreeIterator
	 * @param dirCacheIterator
	 * @param otherTree
	 * @param result
	 *            that will hold the attributes matching this entry path. This
	 *            method will NOT override any existing entry in attributes.
	 * @throws IOException
	 */
	private void mergePerDirectoryEntryAttributes(String entryPath,
			int nameRoot, boolean isDirectory,
			@Nullable WorkingTreeIterator workingTreeIterator,
			@Nullable DirCacheIterator dirCacheIterator,
			@Nullable CanonicalTreeParser otherTree, Attributes result)
					throws IOException {
		// Prevents infinite recurrence
		if (workingTreeIterator != null || dirCacheIterator != null
				|| otherTree != null) {
			AttributesNode attributesNode = attributesNode(
					treeWalk, workingTreeIterator, dirCacheIterator, otherTree);
			if (attributesNode != null) {
				mergeAttributes(attributesNode,
						entryPath.substring(nameRoot + 1), isDirectory,
						result);
			}
			mergePerDirectoryEntryAttributes(entryPath,
					entryPath.lastIndexOf('/', nameRoot - 1), isDirectory,
					parentOf(workingTreeIterator), parentOf(dirCacheIterator),
					parentOf(otherTree), result);
		}
	}

	/**
	 * Merges the matching node attributes for an entry path.
	 *
	 * @param node
	 *            the node to scan for matches to entryPath
	 * @param entryPath
	 *            the path to test. The path must be relative to this attribute
	 *            node's own repository path, and in repository path format
	 *            (uses '/' and not '\').
	 * @param isDirectory
	 *            true if the target item is a directory.
	 * @param result
	 *            that will hold the attributes matching this entry path. This
	 *            method will NOT override any existing entry in attributes.
	 */
	protected void mergeAttributes(@Nullable AttributesNode node,
			String entryPath,
			boolean isDirectory, Attributes result) {
		if (node == null)
			return;
		List<AttributesRule> rules = node.getRules();
		// Parse rules in the reverse order that they were read since the last
		// entry should be used
		ListIterator<AttributesRule> ruleIterator = rules
				.listIterator(rules.size());
		while (ruleIterator.hasPrevious()) {
			AttributesRule rule = ruleIterator.previous();
			if (rule.isMatch(entryPath, isDirectory)) {
				ListIterator<Attribute> attributeIte = rule.getAttributes()
						.listIterator(rule.getAttributes().size());
				// Parses the attributes in the reverse order that they were
				// read since the last entry should be used
				while (attributeIte.hasPrevious()) {
					expandMacro(attributeIte.previous(), result);
				}
			}
		}
	}

	/**
	 * Expand a macro
	 *
	 * @param attr
	 *            a {@link org.eclipse.jgit.attributes.Attribute}
	 * @param result
	 *            contains the (recursive) expanded and merged macro attributes
	 *            including the attribute iself
	 */
	protected void expandMacro(Attribute attr, Attributes result) {
		// loop detection = exists check
		if (result.containsKey(attr.getKey()))
			return;

		// also add macro to result set, same does native git
		result.put(attr);

		List<Attribute> expansion = expansions.get(attr.getKey());
		if (expansion == null) {
			return;
		}
		switch (attr.getState()) {
		case UNSET: {
			for (Attribute e : expansion) {
				switch (e.getState()) {
				case SET:
					expandMacro(new Attribute(e.getKey(), State.UNSET), result);
					break;
				case UNSET:
					expandMacro(new Attribute(e.getKey(), State.SET), result);
					break;
				case UNSPECIFIED:
					expandMacro(new Attribute(e.getKey(), State.UNSPECIFIED),
							result);
					break;
				case CUSTOM:
				default:
					expandMacro(e, result);
				}
			}
			break;
		}
		case CUSTOM: {
			for (Attribute e : expansion) {
				switch (e.getState()) {
				case SET:
				case UNSET:
				case UNSPECIFIED:
					expandMacro(e, result);
					break;
				case CUSTOM:
				default:
					expandMacro(new Attribute(e.getKey(), attr.getValue()),
							result);
				}
			}
			break;
		}
		case UNSPECIFIED: {
			for (Attribute e : expansion) {
				expandMacro(new Attribute(e.getKey(), State.UNSPECIFIED),
						result);
			}
			break;
		}
		case SET:
		default:
			for (Attribute e : expansion) {
				expandMacro(e, result);
			}
			break;
		}
	}

	/**
	 * Get the {@link AttributesNode} for the current entry.
	 * <p>
	 * This method implements the fallback mechanism between the index and the
	 * working tree depending on the operation type
	 * </p>
	 *
	 * @param treeWalk
	 * @param workingTreeIterator
	 * @param dirCacheIterator
	 * @param otherTree
	 * @return a {@link AttributesNode} of the current entry,
	 *         {@link NullPointerException} otherwise.
	 * @throws IOException
	 *             It raises an {@link IOException} if a problem appears while
	 *             parsing one on the attributes file.
	 */
	private static AttributesNode attributesNode(TreeWalk treeWalk,
			@Nullable WorkingTreeIterator workingTreeIterator,
			@Nullable DirCacheIterator dirCacheIterator,
			@Nullable CanonicalTreeParser otherTree) throws IOException {
		AttributesNode attributesNode = null;
		switch (treeWalk.getOperationType()) {
		case CHECKIN_OP:
			if (workingTreeIterator != null) {
				attributesNode = workingTreeIterator.getEntryAttributesNode();
			}
			if (attributesNode == null && dirCacheIterator != null) {
				attributesNode = dirCacheIterator
						.getEntryAttributesNode(treeWalk.getObjectReader());
			}
			if (attributesNode == null && otherTree != null) {
				attributesNode = otherTree
						.getEntryAttributesNode(treeWalk.getObjectReader());
			}
			break;
		case CHECKOUT_OP:
			if (otherTree != null) {
				attributesNode = otherTree
						.getEntryAttributesNode(treeWalk.getObjectReader());
			}
			if (attributesNode == null && dirCacheIterator != null) {
				attributesNode = dirCacheIterator
						.getEntryAttributesNode(treeWalk.getObjectReader());
			}
			if (attributesNode == null && workingTreeIterator != null) {
				attributesNode = workingTreeIterator.getEntryAttributesNode();
			}
			break;
		default:
			throw new IllegalStateException(
					"The only supported operation types are:" //$NON-NLS-1$
							+ OperationType.CHECKIN_OP + "," //$NON-NLS-1$
							+ OperationType.CHECKOUT_OP);
		}

		return attributesNode;
	}

	private static <T extends AbstractTreeIterator> T parentOf(@Nullable T node) {
		if(node==null) return null;
		@SuppressWarnings("unchecked")
		Class<T> type = (Class<T>) node.getClass();
		AbstractTreeIterator parent = node.parent;
		if (type.isInstance(parent)) {
			return type.cast(parent);
		}
		return null;
	}

	private static <T extends AbstractTreeIterator> T rootOf(
			@Nullable T node) {
		if(node==null) return null;
		AbstractTreeIterator t=node;
		while (t!= null && t.parent != null) {
			t= t.parent;
		}
		@SuppressWarnings("unchecked")
		Class<T> type = (Class<T>) node.getClass();
		if (type.isInstance(t)) {
			return type.cast(t);
		}
		return null;
	}

}