fast_ignore 0.10.1 → 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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