fast_ignore 0.10.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,65 @@
2
2
 
3
3
  class FastIgnore
4
4
  module Backports
5
- module_function
5
+ ruby_major, ruby_minor = RUBY_VERSION.split('.', 2)
6
+ unless ruby_major.to_i > 2 || ruby_major.to_i == 2 && ruby_minor.to_i > 5
7
+ module DirEachChild
8
+ refine ::Dir.singleton_class do
9
+ def children(path)
10
+ Dir.entries(path) - ['.', '..']
11
+ end
12
+ end
13
+ end
6
14
 
7
- def ruby_version_less_than?(major, minor)
8
- ruby_major, ruby_minor = RUBY_VERSION.split('.', 2)
15
+ module DeletePrefixSuffix
16
+ refine ::String do
17
+ # delete_prefix!(prefix) -> self or nil
18
+ # Deletes leading prefix from str, returning nil if no change was made.
19
+ #
20
+ # "hello".delete_prefix!("hel") #=> "lo"
21
+ # "hello".delete_prefix!("llo") #=> nil
22
+ def delete_prefix!(str)
23
+ return unless start_with?(str)
9
24
 
10
- return true if major > ruby_major.to_i
11
- return true if minor > ruby_minor.to_i
25
+ slice!(0..(str.length - 1))
26
+ self
27
+ end
12
28
 
13
- false
29
+ # delete_suffix!(suffix) -> self or nil
30
+ # Deletes trailing suffix from str, returning nil if no change was made.
31
+ #
32
+ # "hello".delete_suffix!("llo") #=> "he"
33
+ # "hello".delete_suffix!("hel") #=> nil
34
+ def delete_suffix!(str)
35
+ return unless end_with?(str)
36
+
37
+ slice!(-str.length..-1)
38
+ self
39
+ end
40
+
41
+ # delete_prefix(prefix) -> new_str click to toggle source
42
+ # Returns a copy of str with leading prefix deleted.
43
+ #
44
+ # "hello".delete_prefix("hel") #=> "lo"
45
+ # "hello".delete_prefix("llo") #=> "hello"
46
+ def delete_prefix(str)
47
+ s = dup
48
+ s.delete_prefix!(str)
49
+ s
50
+ end
51
+
52
+ # delete_suffix(suffix) -> new_str
53
+ # Returns a copy of str with trailing suffix deleted.
54
+ #
55
+ # "hello".delete_suffix("llo") #=> "he"
56
+ # "hello".delete_suffix("hel") #=> "hello"
57
+ def delete_suffix(str) # leftovers:allowed
58
+ s = dup
59
+ s.delete_suffix!(str)
60
+ s
61
+ end
62
+ end
63
+ end
14
64
  end
15
65
  end
16
66
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FastIgnore
4
+ module FNMatchToRegex
5
+ # This doesn't look rubyish because i ported it from rust (the only rust i ever wrote that worked)
6
+ class << self
7
+ def call(pattern) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
8
+ re = '\\A'.dup
9
+
10
+ in_character_group = false
11
+ has_characters_in_group = false
12
+ escape_next_character = false
13
+ last_char_opened_character_group = false
14
+ negated_character_group = false
15
+ stars = 0
16
+
17
+ pattern.each_char do |char| # rubocop:disable Metrics/BlockLength
18
+ if escape_next_character
19
+ re << Regexp.escape(char)
20
+ escape_next_character = false
21
+ elsif char == '\\' # single char, just needs to be escaped
22
+ escape_next_character = true
23
+ elsif in_character_group
24
+ if char == '/'
25
+ if negated_character_group
26
+ has_characters_in_group = true
27
+ re << char
28
+ end
29
+ elsif char == '^'
30
+ if last_char_opened_character_group
31
+ re << char
32
+ negated_character_group = true
33
+ else
34
+ re << '\\^'
35
+ has_characters_in_group = true
36
+ end
37
+ # not characters in group
38
+ elsif char == ']'
39
+ break unless has_characters_in_group
40
+
41
+ re << ']'
42
+ in_character_group = false
43
+ has_characters_in_group = false
44
+ negated_character_group = false
45
+ last_char_opened_character_group = false
46
+ elsif char == '-'
47
+ has_characters_in_group = true
48
+ re << char
49
+ else
50
+ has_characters_in_group = true
51
+ re << Regexp.escape(char)
52
+ end
53
+ last_char_opened_character_group = false
54
+ elsif char == '*'
55
+ stars += 1
56
+ elsif char == '/'
57
+ re << if stars >= 2
58
+ '(?:.*/)?'
59
+ elsif stars.positive?
60
+ '[^/]*/'
61
+ else
62
+ char
63
+ end
64
+ stars = 0
65
+ else
66
+ if stars.positive?
67
+ re << '[^/]*'
68
+ stars = 0
69
+ end
70
+ if char == '?'
71
+ re << '[^/]'
72
+ elsif char == '['
73
+ re << '['
74
+ in_character_group = true
75
+ last_char_opened_character_group = true
76
+ else
77
+ re << Regexp.escape(char)
78
+ end
79
+ end
80
+ end
81
+
82
+ if in_character_group
83
+ return /(?!)/ # impossible to match anything
84
+ end
85
+
86
+ if stars >= 2
87
+ re << '.*'
88
+ elsif stars.positive?
89
+ re << '[^/]*'
90
+ end
91
+ re << '\\z'
92
+ Regexp.new(re, Regexp::IGNORECASE)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -2,70 +2,48 @@
2
2
 
3
3
  class FastIgnore
4
4
  class Rule
5
- # FNMATCH_OPTIONS = (
6
- # ::File::FNM_DOTMATCH |
7
- # ::File::FNM_PATHNAME |
8
- # ::File::FNM_CASEFOLD
9
- # ).freeze # = 14
10
-
11
5
  attr_reader :negation
12
6
  alias_method :negation?, :negation
7
+ undef :negation
13
8
 
14
9
  attr_reader :dir_only
15
10
  alias_method :dir_only?, :dir_only
16
-
17
- attr_reader :shebang
18
- alias_method :file_only?, :shebang
11
+ undef :dir_only
19
12
 
20
13
  attr_reader :unanchored
21
14
  alias_method :unanchored?, :unanchored
15
+ undef :unanchored
16
+
17
+ attr_reader :type
18
+ attr_reader :rule
22
19
 
23
- def initialize(rule, unanchored, dir_only, negation, shebang = nil)
24
- @rule = rule
20
+ def initialize(rule, negation, unanchored = nil, dir_only = nil)
21
+ @rule = rule.is_a?(Regexp) ? rule : ::FastIgnore::FNMatchToRegex.call(rule)
25
22
  @unanchored = unanchored
26
23
  @dir_only = dir_only
27
24
  @negation = negation
28
- @shebang = shebang
25
+
26
+ @type = negation ? 1 : 0
29
27
 
30
28
  freeze
31
29
  end
32
30
 
33
- # :nocov:
34
- def inspect
35
- if shebang
36
- "#<Rule #{'allow ' if @negation}#!:#{@shebang.to_s[15..-4]}>"
37
- else
38
- "#<Rule #{'!' if @negation}#{@rule}#{'/' if @dir_only}>"
39
- end
31
+ def file_only?
32
+ false
40
33
  end
41
- # :nocov:
42
34
 
43
- def match?(path, filename)
44
- if @shebang
45
- match_shebang?(path, filename)
46
- else
47
- # 14 = FNMATCH_OPTIONS
48
- ::File.fnmatch?(@rule, path, 14)
49
- end
35
+ def shebang
36
+ nil
50
37
  end
51
38
 
52
- def match_shebang?(path, filename)
53
- return false if filename.include?('.')
54
-
55
- first_line(path)&.match?(@shebang)
39
+ # :nocov:
40
+ def inspect
41
+ "#<Rule #{'!' if @negation}#{@rule}#{'/' if @dir_only}>"
56
42
  end
43
+ # :nocov:
57
44
 
58
- def first_line(path)
59
- file = ::File.new(path)
60
- first_line = file.sysread(25)
61
- first_line += file.sysread(50) until first_line.include?("\n")
62
- file.close
63
- first_line
64
- rescue ::EOFError, ::SystemCallError
65
- # :nocov:
66
- file&.close
67
- # :nocov:
68
- first_line
45
+ def match?(relative_path, _, _, _)
46
+ @rule.match?(relative_path)
69
47
  end
70
48
  end
71
49
  end
@@ -2,19 +2,15 @@
2
2
 
3
3
  class FastIgnore
4
4
  module RuleBuilder
5
- # rule or nil
6
5
  class << self
7
6
  # :nocov:
8
- if ::FastIgnore::Backports.ruby_version_less_than?(2, 5)
9
- require_relative 'backports/delete_prefix_suffix'
10
- using ::FastIgnore::Backports::DeletePrefixSuffix
11
- end
7
+ using ::FastIgnore::Backports::DeletePrefixSuffix if defined?(::FastIgnore::Backports::DeletePrefixSuffix)
12
8
  # :nocov:
13
9
 
14
10
  def build(rule, allow, expand_path, file_root)
15
- strip(rule)
16
-
17
11
  return shebang_rules(rule, allow) if remove_shebang(rule)
12
+
13
+ strip(rule)
18
14
  return [] if skip?(rule)
19
15
 
20
16
  gitignore_rules(rule, allow, expand_path, file_root)
@@ -36,7 +32,7 @@ class FastIgnore
36
32
  end
37
33
 
38
34
  def shebang_rules(rule, allow)
39
- rules = [::FastIgnore::Rule.new(nil, true, false, allow, /\A#!.*\b#{Regexp.escape(rule)}\b/.freeze)]
35
+ rules = [::FastIgnore::ShebangRule.new(/\A#!.*\b#{Regexp.escape(rule)}\b/i.freeze, allow)]
40
36
  return rules unless allow
41
37
 
42
38
  rules << ::FastIgnore::Rule.new('**/*', true, true, true)
@@ -51,8 +47,11 @@ class FastIgnore
51
47
  dir_only = extract_dir_only(rule)
52
48
  negation = extract_negation(rule, allow)
53
49
 
54
- expand_rule_path(rule, expand_path) if expand_path
55
- unanchored = unanchored?(rule)
50
+ unanchored = if expand_path
51
+ expand_rule_path(rule, expand_path)
52
+ else
53
+ unanchored?(rule)
54
+ end
56
55
  rule.delete_prefix!('/')
57
56
 
58
57
  rule.prepend("#{file_root}#{'**/' if unanchored}") if file_root || unanchored
@@ -74,7 +73,7 @@ class FastIgnore
74
73
  def expand_rule_path(rule, root)
75
74
  rule.replace(::File.expand_path(rule)) if rule.match?(EXPAND_PATH_RE)
76
75
  rule.delete_prefix!(root)
77
- rule.prepend('/') unless rule.start_with?('*') || rule.start_with?('/')
76
+ rule.start_with?('*')
78
77
  end
79
78
 
80
79
  def unanchored?(rule)
@@ -82,10 +81,10 @@ class FastIgnore
82
81
  end
83
82
 
84
83
  def build_gitignore_rules(rule, unanchored, allow, dir_only, negation)
85
- rules = [::FastIgnore::Rule.new(rule.freeze, unanchored, dir_only, negation)]
84
+ rules = [::FastIgnore::Rule.new(rule, negation, unanchored, dir_only)]
86
85
  return rules unless allow
87
86
 
88
- rules << ::FastIgnore::Rule.new("#{rule}/**/*", unanchored, false, negation)
87
+ rules << ::FastIgnore::Rule.new("#{rule}/**/*", negation, unanchored, false)
89
88
  rules + ancestor_rules(rule, unanchored)
90
89
  end
91
90
 
@@ -94,7 +93,7 @@ class FastIgnore
94
93
 
95
94
  while (parent = ::File.dirname(parent)) != '.'
96
95
  rule = ::File.basename(parent) == '**' ? "#{parent}/*" : parent.freeze
97
- ancestor_rules << ::FastIgnore::Rule.new(rule, unanchored, true, true)
96
+ ancestor_rules << ::FastIgnore::Rule.new(rule, true, unanchored, true)
98
97
  end
99
98
 
100
99
  ancestor_rules
@@ -2,39 +2,59 @@
2
2
 
3
3
  class FastIgnore
4
4
  class RuleSet
5
- def initialize(rules, allow)
6
- @dir_rules = rules.reject(&:file_only?).freeze
7
- @file_rules = rules.reject(&:dir_only?).freeze
5
+ attr_reader :gitignore
6
+ alias_method :gitignore?, :gitignore
7
+ undef :gitignore
8
+
9
+ def initialize(rules, allow, gitignore)
10
+ @dir_rules = squash_rules(rules.reject(&:file_only?)).freeze
11
+ @file_rules = squash_rules(rules.reject(&:dir_only?)).freeze
8
12
  @any_not_anchored = rules.any?(&:unanchored?)
9
13
  @has_shebang_rules = rules.any?(&:shebang)
14
+
10
15
  @allowed_recursive = { '.' => true }
11
16
  @allow = allow
17
+ @gitignore = gitignore
12
18
 
13
- freeze
19
+ freeze unless gitignore?
14
20
  end
15
21
 
16
- def freeze
17
- @dir_rules.freeze
18
- @file_rules.freeze
22
+ def <<(other)
23
+ return unless other
19
24
 
20
- super
25
+ @any_not_anchored ||= other.any_not_anchored
26
+ @has_shebang_rules ||= other.has_shebang_rules
27
+ @dir_rules += other.dir_rules
28
+ @file_rules += other.file_rules
21
29
  end
22
30
 
23
- def allowed_recursive?(path, dir, filename)
24
- @allowed_recursive.fetch(path) do
25
- @allowed_recursive[path] =
26
- allowed_recursive?(::File.dirname(path), true, nil) && allowed_unrecursive?(path, dir, filename)
31
+ def allowed_recursive?(relative_path, dir, full_path, filename, content = nil)
32
+ @allowed_recursive.fetch(relative_path) do
33
+ @allowed_recursive[relative_path] =
34
+ allowed_recursive?(::File.dirname(relative_path), true, nil, nil, nil) &&
35
+ allowed_unrecursive?(relative_path, dir, full_path, filename, content)
27
36
  end
28
37
  end
29
38
 
30
- def allowed_unrecursive?(path, dir, filename)
39
+ def allowed_unrecursive?(relative_path, dir, full_path, filename, content)
31
40
  (dir ? @dir_rules : @file_rules).reverse_each do |rule|
32
- return rule.negation? if rule.match?(path, filename)
41
+ return rule.negation? if rule.match?(relative_path, full_path, filename, content)
33
42
  end
34
43
 
35
44
  (not @allow) || (dir && @any_not_anchored)
36
45
  end
37
46
 
47
+ def squash_rules(rules)
48
+ out = rules.chunk_while { |a, b| a.type == b.type }.map do |chunk|
49
+ first = chunk.first
50
+ next first if chunk.length == 1
51
+
52
+ first.class.new(Regexp.union(chunk.map(&:rule)), first.negation?)
53
+ end
54
+
55
+ out
56
+ end
57
+
38
58
  def weight
39
59
  @dir_rules.length + (@has_shebang_rules ? 10 : 0)
40
60
  end
@@ -42,5 +62,9 @@ class FastIgnore
42
62
  def empty?
43
63
  @dir_rules.empty? && @file_rules.empty?
44
64
  end
65
+
66
+ protected
67
+
68
+ attr_reader :dir_rules, :file_rules, :any_not_anchored, :has_shebang_rules
45
69
  end
46
70
  end
@@ -1,20 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class FastIgnore
4
- module RuleSetBuilder
4
+ module RuleSetBuilder # rubocop:disable Metrics/ModuleLength
5
5
  class << self
6
6
  # :nocov:
7
- if ::FastIgnore::Backports.ruby_version_less_than?(2, 5)
8
- require_relative 'backports/delete_prefix_suffix'
9
- using ::FastIgnore::Backports::DeletePrefixSuffix
10
- end
7
+ using ::FastIgnore::Backports::DeletePrefixSuffix if defined?(::FastIgnore::Backports::DeletePrefixSuffix)
11
8
  # :nocov:
12
9
 
13
10
  def build( # rubocop:disable Metrics/ParameterLists
14
11
  root:,
15
12
  ignore_rules: nil,
16
13
  ignore_files: nil,
17
- gitignore: :auto,
14
+ gitignore: true,
18
15
  include_rules: nil,
19
16
  include_files: nil,
20
17
  argv_rules: nil
@@ -22,7 +19,7 @@ class FastIgnore
22
19
  prepare [
23
20
  from_array(ignore_rules),
24
21
  *from_files(ignore_files, project_root: root),
25
- from_array('.git'),
22
+ (from_array('.git') if gitignore),
26
23
  from_gitignore_arg(gitignore, project_root: root),
27
24
  from_array(include_rules, allow: true),
28
25
  *from_files(include_files, allow: true, project_root: root),
@@ -30,6 +27,19 @@ class FastIgnore
30
27
  ]
31
28
  end
32
29
 
30
+ def append_gitignore(rule_sets, project_root:, relative_path:, soft: true)
31
+ new_gitignore = from_file(relative_path, project_root: project_root, gitignore: true, soft: soft)
32
+ return unless new_gitignore
33
+
34
+ base_gitignore = rule_sets.find(&:gitignore?)
35
+ if base_gitignore
36
+ base_gitignore << new_gitignore
37
+ else
38
+ rule_sets << new_gitignore
39
+ prepare(rule_sets)
40
+ end
41
+ end
42
+
33
43
  private
34
44
 
35
45
  def prepare(rule_sets)
@@ -39,12 +49,15 @@ class FastIgnore
39
49
  rule_sets
40
50
  end
41
51
 
42
- def from_file(filename, project_root:, allow: false)
52
+ def from_file(filename, project_root:, allow: false, file_root: nil, gitignore: false, soft: false) # rubocop:disable Metrics/ParameterLists
43
53
  filename = ::File.expand_path(filename, project_root)
44
- raise FastIgnore::Error, "#{filename} is not within #{project_root}" unless filename.start_with?(project_root)
54
+ return if soft && !::File.exist?(filename)
55
+ unless file_root || filename.start_with?(project_root)
56
+ raise FastIgnore::Error, "#{filename} is not within #{project_root}"
57
+ end
45
58
 
46
- file_root = "#{::File.dirname(filename)}/".delete_prefix(project_root)
47
- build_rule_set(::File.readlines(filename), allow, file_root: file_root)
59
+ file_root ||= "#{::File.dirname(filename)}/".delete_prefix(project_root)
60
+ build_rule_set(::File.readlines(filename), allow, file_root: file_root, gitignore: gitignore)
48
61
  end
49
62
 
50
63
  def from_files(files, project_root:, allow: false)
@@ -54,12 +67,53 @@ class FastIgnore
54
67
  end
55
68
 
56
69
  def from_gitignore_arg(gitignore, project_root:)
57
- default_path = ::File.join(project_root, '.gitignore')
58
- case gitignore
59
- when :auto
60
- from_file(default_path, project_root: project_root) if ::File.exist?(default_path)
61
- when true
62
- from_file(default_path, project_root: project_root)
70
+ return unless gitignore
71
+
72
+ gi = ::FastIgnore::RuleSet.new([], false, true)
73
+ gi << from_root_gitignore_file(global_gitignore_path(root: project_root))
74
+ gi << from_root_gitignore_file(::File.join(project_root, '.git/info/exclude'))
75
+ gi << from_root_gitignore_file(::File.join(project_root, '.gitignore'))
76
+ gi
77
+ end
78
+
79
+ def from_root_gitignore_file(path)
80
+ return unless ::File.exist?(path)
81
+
82
+ build_rule_set(::File.readlines(path), false, file_root: '', gitignore: true)
83
+ end
84
+
85
+ def global_gitignore_path(root:)
86
+ gitconfig_gitignore_path(::File.expand_path('.git/config', root)) ||
87
+ gitconfig_gitignore_path(::File.expand_path('~/.gitconfig')) ||
88
+ gitconfig_gitignore_path(xdg_config_path) ||
89
+ gitconfig_gitignore_path('/etc/gitconfig') ||
90
+ default_global_gitignore_path
91
+ end
92
+
93
+ def gitconfig_gitignore_path(config_path)
94
+ return unless config_path
95
+ return unless ::File.exist?(config_path)
96
+
97
+ ignore_path = ::File.readlines(config_path).find { |l| l.sub!(/\A\s*excludesfile\s*=/, '') }
98
+ return unless ignore_path
99
+
100
+ ignore_path.strip!
101
+ return ignore_path if ignore_path.empty? # don't expand path in this case
102
+
103
+ ::File.expand_path(ignore_path)
104
+ end
105
+
106
+ def xdg_config_path
107
+ return unless ENV['XDG_CONFIG_HOME'] && !ENV['XDG_CONFIG_HOME'].empty?
108
+
109
+ ::File.expand_path('git/config', ENV['XDG_CONFIG_HOME'])
110
+ end
111
+
112
+ def default_global_gitignore_path
113
+ if ENV['XDG_CONFIG_HOME'] && !ENV['XDG_CONFIG_HOME'].empty?
114
+ ::File.expand_path('git/ignore', ENV['XDG_CONFIG_HOME'])
115
+ else
116
+ ::File.expand_path('~/.config/git/ignore')
63
117
  end
64
118
  end
65
119
 
@@ -75,12 +129,12 @@ class FastIgnore
75
129
  build_rule_set(rules, allow, expand_path: expand_path)
76
130
  end
77
131
 
78
- def build_rule_set(rules, allow, expand_path: false, file_root: nil)
132
+ def build_rule_set(rules, allow, expand_path: false, file_root: nil, gitignore: false)
79
133
  rules = rules.flat_map do |rule|
80
134
  ::FastIgnore::RuleBuilder.build(rule, allow, expand_path, file_root)
81
135
  end
82
136
 
83
- ::FastIgnore::RuleSet.new(rules, allow).freeze
137
+ ::FastIgnore::RuleSet.new(rules, allow, gitignore)
84
138
  end
85
139
  end
86
140
  end