File: GitDataReader\GitIgnore.Matcher.cs
Web Access
Project: src\src\sourcelink\src\Microsoft.Build.Tasks.Git\Microsoft.Build.Tasks.Git.csproj (Microsoft.Build.Tasks.Git)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the License.txt file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;

namespace Microsoft.Build.Tasks.Git
{
    partial class GitIgnore
    {
        internal sealed class Matcher
        {
            public GitIgnore Ignore { get; }

            /// <summary>
            /// Maps full posix slash-terminated directory name to a pattern group.
            /// </summary>
            private readonly Dictionary<string, PatternGroup?> _patternGroups;

            /// <summary>
            /// The result of "is ignored" for directories.
            /// </summary>
            private readonly Dictionary<string, bool> _directoryIgnoreStateCache;

            private readonly List<PatternGroup> _reusableGroupList;

            internal Matcher(GitIgnore ignore)
            {
                Ignore = ignore;
                _patternGroups = new Dictionary<string, PatternGroup?>(StringComparer.Ordinal);
                _directoryIgnoreStateCache = new Dictionary<string, bool>(Ignore.PathComparer);
                _reusableGroupList = new List<PatternGroup>();
            }

            // test only:
            internal IReadOnlyDictionary<string, bool> DirectoryIgnoreStateCache
                => _directoryIgnoreStateCache;

            private PatternGroup? GetPatternGroup(string directory)
            {
                Debug.Assert(PathUtils.HasTrailingSlash(directory));

                if (_patternGroups.TryGetValue(directory, out var group))
                {
                    return group;
                }

                PatternGroup? parent;
                if (directory.Equals(Ignore.WorkingDirectory, Ignore.PathComparison))
                {
                    parent = Ignore.Root;
                }
                else
                {
                    var parentDirectory = directory[..(directory.LastIndexOf('/', directory.Length - 2, directory.Length - 1) + 1)];
                    parent = GetPatternGroup(parentDirectory);
                }

                group = LoadFromFile(directory + GitIgnoreFileName, parent) ?? parent;

                _patternGroups.Add(directory, group);
                return group;
            }

            /// <summary>
            /// Checks if the specified file path is ignored.
            /// </summary>
            /// <param name="fullPath">Normalized path.</param>
            /// <returns>True if the path is ignored, fale if it is not, null if it is outside of the working directory.</returns>
            public bool? IsNormalizedFilePathIgnored(string fullPath)
            {
                if (!PathUtils.IsAbsolute(fullPath))
                {
                    throw new ArgumentException(Resources.PathMustBeAbsolute, nameof(fullPath));
                }

                if (PathUtils.HasTrailingDirectorySeparator(fullPath))
                {
                    throw new ArgumentException(Resources.PathMustBeFilePath, nameof(fullPath));
                }

                return IsPathIgnored(PathUtils.ToPosixPath(fullPath), isDirectoryPath: false);
            }

            /// <summary>
            /// Checks if the specified path is ignored.
            /// </summary>
            /// <param name="fullPath">Full path.</param>
            /// <returns>True if the path is ignored, fale if it is not, null if it is outside of the working directory.</returns>
            public bool? IsPathIgnored(string fullPath)
            {
                if (!PathUtils.IsAbsolute(fullPath))
                {
                    throw new ArgumentException(Resources.PathMustBeAbsolute, nameof(fullPath));
                }

                // git uses the FS case-sensitivity for checking directory existence:
                var isDirectoryPath = PathUtils.HasTrailingDirectorySeparator(fullPath) || Directory.Exists(fullPath);

                var fullPathNoSlash = PathUtils.TrimTrailingSlash(PathUtils.ToPosixPath(Path.GetFullPath(fullPath)));
                if (isDirectoryPath && fullPathNoSlash.Equals(Ignore._workingDirectoryNoSlash, Ignore.PathComparison))
                {
                    return false;
                }

                return IsPathIgnored(fullPathNoSlash, isDirectoryPath);
            }

            private bool? IsPathIgnored(string normalizedPosixPath, bool isDirectoryPath)
            {
                Debug.Assert(PathUtils.IsAbsolute(normalizedPosixPath));
                Debug.Assert(PathUtils.IsPosixPath(normalizedPosixPath));
                Debug.Assert(!PathUtils.HasTrailingSlash(normalizedPosixPath));

                // paths outside of working directory:
                if (!normalizedPosixPath.StartsWith(Ignore.WorkingDirectory, Ignore.PathComparison))
                {
                    return null;
                }

                if (isDirectoryPath && _directoryIgnoreStateCache.TryGetValue(normalizedPosixPath, out var isIgnored))
                {
                    return isIgnored;
                }

                isIgnored = IsIgnoredRecursive(normalizedPosixPath, isDirectoryPath);
                if (isDirectoryPath)
                {
                    _directoryIgnoreStateCache.Add(normalizedPosixPath, isIgnored);
                }

                return isIgnored;
            }

            private bool IsIgnoredRecursive(string normalizedPosixPath, bool isDirectoryPath)
            {
                SplitPath(normalizedPosixPath, out var directory, out var fileName);
                if (directory == null || !directory.StartsWith(Ignore.WorkingDirectory, Ignore.PathComparison))
                {
                    return false;
                }

                var isIgnored = IsIgnored(normalizedPosixPath, directory, fileName, isDirectoryPath);
                if (isIgnored)
                {
                    return true;
                }

                // The target file/directory itself is not ignored, but its containing directory might be.
                normalizedPosixPath = PathUtils.TrimTrailingSlash(directory);
                if (_directoryIgnoreStateCache.TryGetValue(normalizedPosixPath, out isIgnored))
                {
                    return isIgnored;
                }

                isIgnored = IsIgnoredRecursive(normalizedPosixPath, isDirectoryPath: true);
                _directoryIgnoreStateCache.Add(normalizedPosixPath, isIgnored);
                return isIgnored;
            }

            private static void SplitPath(string fullPath, out string? directoryWithSlash, out string fileName)
            {
                Debug.Assert(!PathUtils.HasTrailingSlash(fullPath));
                var i = fullPath.LastIndexOf('/');
                if (i < 0)
                {
                    directoryWithSlash = null;
                    fileName = fullPath;
                }
                else
                {
                    directoryWithSlash = fullPath[..(i + 1)];
                    fileName = fullPath[(i + 1)..];
                }
            }

            private bool IsIgnored(string normalizedPosixPath, string directory, string fileName, bool isDirectoryPath)
            {
                // Default patterns can't be overriden by a negative pattern:
                if (fileName.Equals(".git", Ignore.PathComparison))
                {
                    return true;
                }

                var isIgnored = false;
                
                // Visit groups in reverse order.
                // Patterns specified closer to the target file override those specified above.
                _reusableGroupList.Clear();
                var groups = _reusableGroupList;
                for (PatternGroup? patternGroup = GetPatternGroup(directory); patternGroup != null; patternGroup = patternGroup.Parent)
                {
                    groups.Add(patternGroup);
                }

                for (var i = groups.Count - 1; i >= 0; i--)
                {
                    var patternGroup = groups[i];

                    if (!normalizedPosixPath.StartsWith(patternGroup.ContainingDirectory, Ignore.PathComparison))
                    {
                        continue;
                    }

                    string? lazyRelativePath = null;

                    foreach (var pattern in patternGroup.Patterns)
                    {
                        // If a pattern is matched as ignored only look for a negative pattern that matches as well.
                        // If a pattern is not matched then skip negative patterns.
                        if (isIgnored != pattern.IsNegative)
                        {
                            continue;
                        }

                        if (pattern.IsDirectoryPattern && !isDirectoryPath)
                        {
                            continue;
                        }

                        var matchPath = pattern.IsFullPathPattern ?
                            lazyRelativePath ??= normalizedPosixPath[patternGroup.ContainingDirectory.Length..] :
                            fileName;

                        if (Glob.IsMatch(pattern.Glob, matchPath, Ignore.IgnoreCase, matchWildCardWithDirectorySeparator: false))
                        {
                            // TODO: optimize negative pattern lookup (once we match, do we need to continue matching?)
                            isIgnored = !pattern.IsNegative;
                        }
                    }
                }

                return isIgnored;
            }
        }
    }
}