github.com/ianlewis/go-gitignore@v0.1.1-0.20231110021210-4a0f15cbd56f/gitignore.go (about)

     1  // Copyright 2016 Denormal Limited
     2  // Copyright 2023 Google LLC
     3  //
     4  // Licensed under the Apache License, Version 2.0 (the "License");
     5  // you may not use this file except in compliance with the License.
     6  // You may obtain a copy of the License at
     7  //
     8  //      http://www.apache.org/licenses/LICENSE-2.0
     9  //
    10  // Unless required by applicable law or agreed to in writing, software
    11  // distributed under the License is distributed on an "AS IS" BASIS,
    12  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  // See the License for the specific language governing permissions and
    14  // limitations under the License.
    15  
    16  package gitignore
    17  
    18  import (
    19  	"io"
    20  	"os"
    21  	"path/filepath"
    22  	"runtime"
    23  	"strings"
    24  )
    25  
    26  // use an empty GitIgnore for cached lookups
    27  var empty = &ignore{}
    28  
    29  // GitIgnore is the interface to .gitignore files and repositories. It defines
    30  // methods for testing files for matching the .gitignore file, and then
    31  // determining whether a file should be ignored or included.
    32  type GitIgnore interface {
    33  	// Base returns the directory containing the .gitignore file.
    34  	Base() string
    35  
    36  	// Match attempts to match the path against this GitIgnore, and will
    37  	// return its Match if successful. Match will invoke the GitIgnore error
    38  	// handler (if defined) if it is not possible to determine the absolute
    39  	// path of the given path, or if its not possible to determine if the
    40  	// path represents a file or a directory. If an error occurs, Match
    41  	// returns nil and the error handler (if defined via New, NewWithErrors
    42  	// or NewWithCache) will be invoked.
    43  	Match(path string) Match
    44  
    45  	// Absolute attempts to match an absolute path against this GitIgnore. If
    46  	// the path is not located under the base directory of this GitIgnore, or
    47  	// is not matched by this GitIgnore, nil is returned.
    48  	Absolute(string, bool) Match
    49  
    50  	// Relative attempts to match a path relative to the GitIgnore base
    51  	// directory. isdir is used to indicate whether the path represents a file
    52  	// or a directory. If the path is not matched by the GitIgnore, nil is
    53  	// returned.
    54  	Relative(path string, isdir bool) Match
    55  
    56  	// Ignore returns true if the path is ignored by this GitIgnore. Paths
    57  	// that are not matched by this GitIgnore are not ignored. Internally,
    58  	// Ignore uses Match, and will return false if Match() returns nil for path.
    59  	Ignore(path string) bool
    60  
    61  	// Include returns true if the path is included by this GitIgnore. Paths
    62  	// that are not matched by this GitIgnore are always included. Internally,
    63  	// Include uses Match, and will return true if Match() returns nil for path.
    64  	Include(path string) bool
    65  }
    66  
    67  // ignore is the implementation of a .gitignore file.
    68  type ignore struct {
    69  	_base    string
    70  	_pattern []Pattern
    71  	_errors  func(Error) bool
    72  }
    73  
    74  // New creates a new GitIgnore instance from the patterns listed in t,
    75  // representing a .gitignore file in the base directory. If errors is given, it
    76  // will be invoked for every error encountered when parsing the .gitignore
    77  // patterns. Parsing will terminate if errors is called and returns false,
    78  // otherwise, parsing will continue until end of file has been reached.
    79  func New(r io.Reader, base string, errors func(Error) bool) GitIgnore {
    80  	// do we have an error handler?
    81  	_errors := errors
    82  	if _errors == nil {
    83  		_errors = func(e Error) bool { return true }
    84  	}
    85  
    86  	// extract the patterns from the reader
    87  	_parser := NewParser(r, _errors)
    88  	_patterns := _parser.Parse()
    89  
    90  	return &ignore{_base: base, _pattern: _patterns, _errors: _errors}
    91  } // New()
    92  
    93  // NewFromFile creates a GitIgnore instance from the given file. An error
    94  // will be returned if file cannot be opened or its absolute path determined.
    95  func NewFromFile(file string) (GitIgnore, error) {
    96  	// define an error handler to catch any file access errors
    97  	//		- record the first encountered error
    98  	var _error Error
    99  	_errors := func(e Error) bool {
   100  		if _error == nil {
   101  			_error = e
   102  		}
   103  		return true
   104  	}
   105  
   106  	// attempt to retrieve the GitIgnore represented by this file
   107  	_ignore := NewWithErrors(file, _errors)
   108  
   109  	// did we encounter an error?
   110  	//		- if the error has a zero Position then it was encountered
   111  	//		  before parsing was attempted, so we return that error
   112  	if _error != nil {
   113  		if _error.Position().Zero() {
   114  			return nil, _error.Underlying()
   115  		}
   116  	}
   117  
   118  	// otherwise, we ignore the parser errors
   119  	return _ignore, nil
   120  } // NewFromFile()
   121  
   122  // NewWithErrors creates a GitIgnore instance from the given file.
   123  // If errors is given, it will be invoked for every error encountered when
   124  // parsing the .gitignore patterns. Parsing will terminate if errors is called
   125  // and returns false, otherwise, parsing will continue until end of file has
   126  // been reached. NewWithErrors returns nil if the .gitignore could not be read.
   127  func NewWithErrors(file string, errors func(Error) bool) GitIgnore {
   128  	var _err error
   129  
   130  	// do we have an error handler?
   131  	_file := file
   132  	_errors := errors
   133  	if _errors == nil {
   134  		_errors = func(e Error) bool { return true }
   135  	} else {
   136  		// augment the error handler to include the .gitignore file name
   137  		//		- we do this here since the parser and lexer interfaces are
   138  		//		  not aware of file names
   139  		_errors = func(e Error) bool {
   140  			// augment the position with the file name
   141  			_position := e.Position()
   142  			_position.File = _file
   143  
   144  			// create a new error with the updated Position
   145  			_error := NewError(e.Underlying(), _position)
   146  
   147  			// invoke the original error handler
   148  			return errors(_error)
   149  		}
   150  	}
   151  
   152  	// we need the absolute path for the GitIgnore base
   153  	_file, _err = filepath.Abs(file)
   154  	if _err != nil {
   155  		_errors(NewError(_err, Position{}))
   156  		return nil
   157  	}
   158  	_base := filepath.Dir(_file)
   159  
   160  	// attempt to open the ignore file to create the io.Reader
   161  	_fh, _err := os.Open(_file)
   162  	if _err != nil {
   163  		_errors(NewError(_err, Position{}))
   164  		return nil
   165  	}
   166  	// NOTE: We close the file handle here because the `ignore` type doesn't
   167  	// make use of the reader after the initial call to New()
   168  	defer _fh.Close()
   169  
   170  	// return the GitIgnore instance
   171  	return New(_fh, _base, _errors)
   172  } // NewWithErrors()
   173  
   174  // NewWithCache returns a GitIgnore instance (using NewWithErrors)
   175  // for the given file. If the file has been loaded before, its GitIgnore
   176  // instance will be returned from the cache rather than being reloaded. If
   177  // cache is not defined, NewWithCache will behave as NewWithErrors
   178  //
   179  // If NewWithErrors returns nil, NewWithCache will store an empty
   180  // GitIgnore (i.e. no patterns) against the file to prevent repeated parse
   181  // attempts on subsequent requests for the same file. Subsequent calls to
   182  // NewWithCache for a file that could not be loaded due to an error will
   183  // return nil.
   184  //
   185  // If errors is given, it will be invoked for every error encountered when
   186  // parsing the .gitignore patterns. Parsing will terminate if errors is called
   187  // and returns false, otherwise, parsing will continue until end of file has
   188  // been reached.
   189  func NewWithCache(file string, cache Cache, errors func(Error) bool) GitIgnore {
   190  	// do we have an error handler?
   191  	_errors := errors
   192  	if _errors == nil {
   193  		_errors = func(e Error) bool { return true }
   194  	}
   195  
   196  	// use the file absolute path as its key into the cache
   197  	_abs, _err := filepath.Abs(file)
   198  	if _err != nil {
   199  		_errors(NewError(_err, Position{}))
   200  		return nil
   201  	}
   202  
   203  	var _ignore GitIgnore
   204  	if cache != nil {
   205  		_ignore = cache.Get(_abs)
   206  	}
   207  	if _ignore == nil {
   208  		_ignore = NewWithErrors(file, _errors)
   209  		if _ignore == nil {
   210  			// if the load failed, cache an empty GitIgnore to prevent
   211  			// further attempts to load this file
   212  			_ignore = empty
   213  		}
   214  		if cache != nil {
   215  			cache.Set(_abs, _ignore)
   216  		}
   217  	}
   218  
   219  	// return the ignore (if we have it)
   220  	if _ignore != empty {
   221  		return _ignore
   222  	}
   223  
   224  	return nil
   225  } // NewWithCache()
   226  
   227  // Base returns the directory containing the .gitignore file for this GitIgnore.
   228  func (i *ignore) Base() string {
   229  	return i._base
   230  } // Base()
   231  
   232  // Match attempts to match the path against this GitIgnore, and will
   233  // return its Match if successful. Match will invoke the GitIgnore error
   234  // handler (if defined) if it is not possible to determine the absolute
   235  // path of the given path, or if its not possible to determine if the
   236  // path represents a file or a directory. If an error occurs, Match
   237  // returns nil and the error handler (if defined via New, NewWithErrors
   238  // or NewWithCache) will be invoked.
   239  func (i *ignore) Match(path string) Match {
   240  	// ensure we have the absolute path for the given file
   241  	_path, _err := filepath.Abs(path)
   242  	if _err != nil {
   243  		i._errors(NewError(_err, Position{}))
   244  		return nil
   245  	}
   246  
   247  	// is the path a file or a directory?
   248  	_info, _err := os.Stat(_path)
   249  	if _err != nil {
   250  		i._errors(NewError(_err, Position{}))
   251  		return nil
   252  	}
   253  	_isdir := _info.IsDir()
   254  
   255  	// attempt to match the absolute path
   256  	return i.Absolute(_path, _isdir)
   257  } // Match()
   258  
   259  // Absolute attempts to match an absolute path against this GitIgnore. If
   260  // the path is not located under the base directory of this GitIgnore, or
   261  // is not matched by this GitIgnore, nil is returned.
   262  func (i *ignore) Absolute(path string, isdir bool) Match {
   263  	// does the file share the same directory as this ignore file?
   264  	if !strings.HasPrefix(path, i._base) {
   265  		return nil
   266  	}
   267  
   268  	// extract the relative path of this file
   269  	_prefix := len(i._base) + 1
   270  	_rel := string(path[_prefix:])
   271  	return i.Relative(_rel, isdir)
   272  } // Absolute()
   273  
   274  // Relative attempts to match a path relative to the GitIgnore base
   275  // directory. isdir is used to indicate whether the path represents a file
   276  // or a directory. If the path is not matched by the GitIgnore, nil is
   277  // returned.
   278  func (i *ignore) Relative(path string, isdir bool) Match {
   279  	// if we are on Windows, then translate the path to Unix form
   280  	_rel := path
   281  	if runtime.GOOS == "windows" {
   282  		_rel = filepath.ToSlash(_rel)
   283  	}
   284  
   285  	// iterate over the patterns for this ignore file
   286  	//      - iterate in reverse, since later patterns overwrite earlier
   287  	for _i := len(i._pattern) - 1; _i >= 0; _i-- {
   288  		_pattern := i._pattern[_i]
   289  		if _pattern.Match(_rel, isdir) {
   290  			return _pattern
   291  		}
   292  	}
   293  
   294  	// we don't match this file
   295  	return nil
   296  } // Relative()
   297  
   298  // Ignore returns true if the path is ignored by this GitIgnore. Paths
   299  // that are not matched by this GitIgnore are not ignored. Internally,
   300  // Ignore uses Match, and will return false if Match() returns nil for path.
   301  func (i *ignore) Ignore(path string) bool {
   302  	_match := i.Match(path)
   303  	if _match != nil {
   304  		return _match.Ignore()
   305  	}
   306  
   307  	// we didn't match this path, so we don't ignore it
   308  	return false
   309  } // Ignore()
   310  
   311  // Include returns true if the path is included by this GitIgnore. Paths
   312  // that are not matched by this GitIgnore are always included. Internally,
   313  // Include uses Match, and will return true if Match() returns nil for path.
   314  func (i *ignore) Include(path string) bool {
   315  	_match := i.Match(path)
   316  	if _match != nil {
   317  		return _match.Include()
   318  	}
   319  
   320  	// we didn't match this path, so we include it
   321  	return true
   322  } // Include()
   323  
   324  // ensure Ignore satisfies the GitIgnore interface
   325  var _ GitIgnore = &ignore{}