File: GitDataReader\GitIgnore.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.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;

namespace Microsoft.Build.Tasks.Git
{
    internal sealed partial class GitIgnore
    {
        internal sealed class PatternGroup
        {
            /// <summary>
            /// Directory of the .gitignore file that defines the pattern.
            /// Full posix slash terminated path.
            /// </summary>
            public readonly string ContainingDirectory;

            public readonly ImmutableArray<Pattern> Patterns;

            public readonly PatternGroup? Parent;

            public PatternGroup(PatternGroup? parent, string containingDirectory, ImmutableArray<Pattern> patterns)
            {
                NullableDebug.Assert(PathUtils.IsPosixPath(containingDirectory));
                NullableDebug.Assert(PathUtils.HasTrailingSlash(containingDirectory));

                Parent = parent;
                ContainingDirectory = containingDirectory;
                Patterns = patterns;
            }
        }

        internal readonly struct Pattern
        {
            public readonly PatternFlags Flags;
            public readonly string Glob;

            public Pattern(string glob,  PatternFlags flags)
            {
                Glob = glob;
                Flags = flags;
            }

            public bool IsDirectoryPattern => (Flags & PatternFlags.DirectoryPattern) != 0;
            public bool IsFullPathPattern => (Flags & PatternFlags.FullPath) != 0;
            public bool IsNegative => (Flags & PatternFlags.Negative) != 0;

            public override string ToString() 
                => $"{(IsNegative ? "!" : "")}{Glob}{(IsDirectoryPattern ? " <dir>" : "")}{(IsFullPathPattern ? " <path>" : "")}";
        }

        [Flags]
        internal enum PatternFlags
        {
            None = 0,
            Negative = 1,
            DirectoryPattern = 2,
            FullPath = 4,
        }

        private const string GitIgnoreFileName = ".gitignore";

        /// <summary>
        /// Full posix slash terminated path.
        /// </summary>
        public string WorkingDirectory { get; }
        private readonly string _workingDirectoryNoSlash;

        public bool IgnoreCase { get; }

        public PatternGroup? Root { get; }

        internal GitIgnore(PatternGroup? root, string workingDirectory, bool ignoreCase)
        {
            NullableDebug.Assert(PathUtils.IsAbsolute(workingDirectory));

            IgnoreCase = ignoreCase;
            WorkingDirectory = PathUtils.ToPosixDirectoryPath(workingDirectory);
            _workingDirectoryNoSlash = PathUtils.TrimTrailingSlash(WorkingDirectory);
            Root = root;
        }

        private StringComparison PathComparison
            => IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

        private IEqualityComparer<string> PathComparer
            => IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;

        public Matcher CreateMatcher()
            => new Matcher(this);

        /// <exception cref="IOException"/>
        /// <exception cref="ArgumentException"><paramref name="path"/> is invalid</exception>
        internal static PatternGroup? LoadFromFile(string? path, PatternGroup? parent)
        {
            // See https://git-scm.com/docs/gitignore#_pattern_format

            if (!File.Exists(path))
            {
                return null;
            }

            StreamReader reader;
            try
            {
                reader = File.OpenText(path!);
            }
            catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException)
            {
                return null;
            }

            var reusableBuffer = new StringBuilder();

            var directory = PathUtils.ToPosixDirectoryPath(Path.GetFullPath(Path.GetDirectoryName(path)!));
            var patterns = ImmutableArray.CreateBuilder<Pattern>();

            using (reader)
            {
                while (true)
                {
                    var line = reader.ReadLine();
                    if (line == null)
                    {
                        break;
                    }

                    if (TryParsePattern(line, reusableBuffer, out var glob, out var flags))
                    {
                        patterns.Add(new Pattern(glob, flags));
                    }
                }
            }

            if (patterns.Count == 0)
            {
                return null;
            }

            return new PatternGroup(parent, directory, patterns.ToImmutable());
        }

        internal static bool TryParsePattern(string line, StringBuilder reusableBuffer, [NotNullWhen(true)]out string? glob, out PatternFlags flags)
        {
            glob = null;
            flags = PatternFlags.None;
            
            // Trailing spaces are ignored unless '\'-escaped.
            // Leading spaces are significant.
            // Other whitespace (\t, \v, \f) is significant. 
            var e = line.Length - 1;
            while (e >= 0 && line[e] == ' ')
            {
                e--;
            }

            e++;

            // Skip blank line.
            if (e == 0)
            {
                return false;
            }

            // put trailing space back if escaped:
            if (e < line.Length && line[e] == ' ' && line[e - 1] == '\\')
            {
                e++;
            }

            var s = 0;

            // Skip comment.
            if (line[s] == '#')
            {
                return false;
            }

            // Pattern negation.
            if (line[s] == '!')
            {
                flags |= PatternFlags.Negative;
                s++;
            }

            if (s == e)
            {
                return false;
            }

            if (line[e - 1] == '/')
            {
                flags |= PatternFlags.DirectoryPattern;
                e--;
            }

            if (s == e)
            {
                return false;
            }

            if (line.IndexOf('/', s, e - s) >= 0)
            {
                flags |= PatternFlags.FullPath;
            }

            if (line[s] == '/')
            {
                s++;
            }

            if (s == e)
            {
                return false;
            }

            var escape = line.IndexOf('\\', s, e - s);
            if (escape < 0)
            {
                glob = line[s..e];
                return true;
            }

            reusableBuffer.Clear();
            reusableBuffer.Append(line, s, escape - s);

            var i = escape;
            while (i < e)
            {
                var c = line[i++];
                if (c == '\\' && i < e)
                {
                    c = line[i++];
                }

                reusableBuffer.Append(c);
            }

            glob = reusableBuffer.ToString();
            return true;
        }
    }
}