include2ignore 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +17 -0
- data/bin/include2ignore +172 -0
- data/lib/include2ignore/config.rb +112 -0
- data/lib/include2ignore/ignore.rb +164 -0
- data/lib/include2ignore/include.rb +326 -0
- data/lib/include2ignore/search.rb +127 -0
- data/lib/include2ignore/version.rb +10 -0
- data/lib/include2ignore/warnings.rb +62 -0
- data/lib/include2ignore.rb +11 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1980856274a5f39c2eaf284ba7c0f42263261a873a1fca9d7257243b47585852
|
|
4
|
+
data.tar.gz: b3891c0ab2157af33f311c0869694e9d296bf60b5642cc8a1ea3f2bd3a17c002
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ba2816257df56abb506128ccba3bff9a901db0b5062e014e24836402b6db4a687a2fe00c3ab7ab41e856e354fb077d1ea603f6121bcdf6abb5d9116f26a4e425
|
|
7
|
+
data.tar.gz: e0f4a554fd8729f0c9c9fbbbf18b9c48d867c36e8024b5bd0ed88c1c4a76db3868f71f92975ca868d9dad41301481b7de60d60709769919d15740a1a4fa8a557
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (c) 2026 Ismo Kärkkäinen
|
|
2
|
+
|
|
3
|
+
The Universal Permissive License (UPL), Version 1.0
|
|
4
|
+
|
|
5
|
+
Subject to the condition set forth below, permission is hereby granted to any person obtaining a copy of this software, associated documentation and/or data (collectively the "Software"), free of charge and under any and all copyright rights in the Software, and any and all patent rights owned or freely licensable by each licensor hereunder covering either (i) the unmodified Software as contributed to or provided by such licensor, or (ii) the Larger Works (as defined below), to deal in both
|
|
6
|
+
|
|
7
|
+
(a) the Software, and
|
|
8
|
+
|
|
9
|
+
(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if one is included with the Software (each a “Larger Work” to which the Software is contributed by such licensors),
|
|
10
|
+
|
|
11
|
+
without restriction, including without limitation the rights to copy, create derivative works of, display, perform, and distribute the Software and make, use, sell, offer for sale, import, export, have made, and have sold the Software and the Larger Work(s), and to sublicense the foregoing rights on either these or other terms.
|
|
12
|
+
|
|
13
|
+
This license is subject to the following condition:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and either this complete permission notice or at a minimum a reference to the UPL must be included in all copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/bin/include2ignore
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
5
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
6
|
+
|
|
7
|
+
require_relative '../lib/include2ignore'
|
|
8
|
+
require 'optparse'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read(filename)
|
|
12
|
+
rules = Include2Ignore::Include::Classifier.new
|
|
13
|
+
f = filename.nil? ? $stdin : File.new(filename, 'rt')
|
|
14
|
+
idx = 0
|
|
15
|
+
f.each_line(chomp: true) do |line|
|
|
16
|
+
idx += 1
|
|
17
|
+
Include2Ignore::Warnings.where("#{idx}: #{line}")
|
|
18
|
+
rules.add(line)
|
|
19
|
+
end
|
|
20
|
+
f.close unless filename.nil?
|
|
21
|
+
rules.close
|
|
22
|
+
rules
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def main
|
|
26
|
+
input_name = nil
|
|
27
|
+
output_name = nil
|
|
28
|
+
output_format = nil
|
|
29
|
+
directory = Dir.pwd
|
|
30
|
+
skip_name = nil
|
|
31
|
+
dump_format = nil
|
|
32
|
+
verbose = false
|
|
33
|
+
|
|
34
|
+
parser = OptionParser.new do |opts|
|
|
35
|
+
opts.summary_indent = ' '
|
|
36
|
+
opts.summary_width = 24
|
|
37
|
+
opts.banner = 'Usage: include2ignore [options]'
|
|
38
|
+
opts.separator ''
|
|
39
|
+
opts.separator 'Options:'
|
|
40
|
+
opts.on('-i', '--input FILE', 'Read from include FILE, not stdin.') do |f|
|
|
41
|
+
input_name = f
|
|
42
|
+
end
|
|
43
|
+
opts.on('-o', '--output FILE', 'Write to ignore FILE, not stdout.') do |f|
|
|
44
|
+
output_name = f
|
|
45
|
+
end
|
|
46
|
+
opts.on('-f', '--format FORMAT', 'Output format: file name or internal format name.') do |fmt|
|
|
47
|
+
output_format = fmt
|
|
48
|
+
end
|
|
49
|
+
opts.on('-d', '--directory DIR', 'Use base DIR, not current directory.') do |d|
|
|
50
|
+
directory = d
|
|
51
|
+
end
|
|
52
|
+
opts.on('--dir-link ACTION', "Symlink handling: #{Include2Ignore::Search::DIR_SYMLINK_HANDLING.map { |h| h == Include2Ignore::Search.dir_symlink_handling ? "*#{h}*" : h }.join(', ')}") do |action|
|
|
53
|
+
a = action.downcase.to_sym
|
|
54
|
+
unless Include2Ignore::Search::DIR_SYMLINK_HANDLING.include?(a)
|
|
55
|
+
$stderr.puts("Allowed --dir-symlink values: #{Include2Ignore::Search::DIR_SYMLINK_HANDLING.join(', ')}, got: #{action}")
|
|
56
|
+
return 1
|
|
57
|
+
end
|
|
58
|
+
Include2Ignore::Search.dir_symlink_handling = a
|
|
59
|
+
end
|
|
60
|
+
opts.on('--file-link ACTION', "Symlink handling: #{Include2Ignore::Search::FILE_SYMLINK_HANDLING.map { |h| h == Include2Ignore::Search.file_symlink_handling ? "*#{h}*" : h }.join(', ')}") do |action|
|
|
61
|
+
a = action.downcase.to_sym
|
|
62
|
+
unless Include2Ignore::Search::FILE_SYMLINK_HANDLING.include?(a)
|
|
63
|
+
$stderr.puts("Allowed --file-symlink values: #{Include2Ignore::Search::FILE_SYMLINK_HANDLING.join(', ')}, got: #{action}")
|
|
64
|
+
return 1
|
|
65
|
+
end
|
|
66
|
+
Include2Ignore::Search.file_symlink_handling = a
|
|
67
|
+
end
|
|
68
|
+
opts.on('--skip FILE', 'Read skip rules from FILE.') do |f|
|
|
69
|
+
skip_name = f
|
|
70
|
+
end
|
|
71
|
+
opts.on('--verbose', 'Verbose output, shows pattern interpretations.') do
|
|
72
|
+
verbose = true
|
|
73
|
+
end
|
|
74
|
+
opts.on('--dump FORMAT', 'Dump internal FORMAT to output and exit.') do |fmt|
|
|
75
|
+
dump_format = fmt
|
|
76
|
+
end
|
|
77
|
+
opts.on('-h', '--help', 'Print this help and exit.') do
|
|
78
|
+
$stdout.puts %(#{opts}
|
|
79
|
+
Reads include file, finds files under directory, and outputs ignore file.
|
|
80
|
+
|
|
81
|
+
#{Include2Ignore::Include.doc}
|
|
82
|
+
#{Include2Ignore::Search.doc}
|
|
83
|
+
#{Include2Ignore::IgnoreConfiguration.doc}
|
|
84
|
+
Directory with nothing under it included or explicitly excluded is ignored to
|
|
85
|
+
avoid listing all files under it.
|
|
86
|
+
|
|
87
|
+
Skip rules can be used to prevent searching of directories or to skip files
|
|
88
|
+
like ACTION "skip" does for symlinks.
|
|
89
|
+
)
|
|
90
|
+
return 0
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
parser.order!
|
|
94
|
+
|
|
95
|
+
# Dump internal output.
|
|
96
|
+
unless dump_format.nil?
|
|
97
|
+
return 0 unless Include2Ignore::IgnoreConfiguration.write(output_name, dump_format).nil?
|
|
98
|
+
$stderr.puts("Not an internal format name: #{dump_format}")
|
|
99
|
+
return 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Output format.
|
|
103
|
+
format = Include2Ignore::IgnoreConfiguration.read(output_format)
|
|
104
|
+
if format.nil?
|
|
105
|
+
$stderr.puts("Not a file nor format name: #{output_format}")
|
|
106
|
+
return 1
|
|
107
|
+
end
|
|
108
|
+
begin
|
|
109
|
+
format = Include2Ignore::IgnoreConfiguration::ConfigurationAlternatives.new(format)
|
|
110
|
+
rescue ArgumentError => e
|
|
111
|
+
$stderr.puts("Configuration is not valid: #{e}")
|
|
112
|
+
return 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Directory.
|
|
116
|
+
unless File.directory?(directory)
|
|
117
|
+
$stderr.puts("Not a directory: #{directory}")
|
|
118
|
+
return 1
|
|
119
|
+
end
|
|
120
|
+
directory = File.realdirpath(directory)
|
|
121
|
+
|
|
122
|
+
Include2Ignore::Warnings.output_explanations(verbose)
|
|
123
|
+
|
|
124
|
+
# Input file.
|
|
125
|
+
begin
|
|
126
|
+
rules = read(input_name)
|
|
127
|
+
rescue Exception => e
|
|
128
|
+
$stderr.puts(%(Failed to read input:#{input_name.nil? ? ' ' : " #{input_name}: "}#{e}))
|
|
129
|
+
return 2
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Skip rules or empty default.
|
|
133
|
+
if skip_name.nil?
|
|
134
|
+
skip = Include2Ignore::Include::Classifier.new
|
|
135
|
+
skip.close
|
|
136
|
+
else
|
|
137
|
+
begin
|
|
138
|
+
skip = read(skip_name)
|
|
139
|
+
rescue Exception => e
|
|
140
|
+
$stderr.puts("Failed to read input: #{skip_name}: #{e}")
|
|
141
|
+
return 2
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Search.
|
|
146
|
+
results = Include2Ignore::Classified.new
|
|
147
|
+
begin
|
|
148
|
+
Include2Ignore::Search.traverse(directory, skip) do |path_parts, is_dir, invalid|
|
|
149
|
+
kind = is_dir ? Include2Ignore::Include::DIR : Include2Ignore::Include::FILE
|
|
150
|
+
exclude = invalid ? true : rules.exclude(path_parts, kind)
|
|
151
|
+
results.add(path_parts, exclude, is_dir)
|
|
152
|
+
end
|
|
153
|
+
rescue Errno::EACCES, Errno::ENOENT => e
|
|
154
|
+
$stderr.puts("Error traversing directory: #{e}")
|
|
155
|
+
return 4
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Shorten and save results.
|
|
159
|
+
results.pick
|
|
160
|
+
included, _undecided, excluded = results.counts
|
|
161
|
+
begin
|
|
162
|
+
out = Include2Ignore::Ignore.new(output_name, format.select(included, excluded))
|
|
163
|
+
rescue Exception => e
|
|
164
|
+
$stderr.puts("Error opening output file: #{e}")
|
|
165
|
+
return 3
|
|
166
|
+
end
|
|
167
|
+
results.output(out)
|
|
168
|
+
out.close
|
|
169
|
+
0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
exit(main) if File.basename(__FILE__) == File.basename($PROGRAM_NAME)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
4
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
require 'yaml'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
module Include2Ignore
|
|
10
|
+
# Configuration file handling.
|
|
11
|
+
module IgnoreConfiguration
|
|
12
|
+
# Built-in configs to be dumped outside.
|
|
13
|
+
ALL = <<EOF
|
|
14
|
+
include_majority:
|
|
15
|
+
include: '#' # Include lines as comments.
|
|
16
|
+
ignore: '' # Ignore lines have no prefix.
|
|
17
|
+
EOF
|
|
18
|
+
|
|
19
|
+
DEFAULT = <<EOF
|
|
20
|
+
include_majority:
|
|
21
|
+
include: false # Include lines omitted.
|
|
22
|
+
ignore: '' # Ignore lines have no prefix.
|
|
23
|
+
EOF
|
|
24
|
+
|
|
25
|
+
DOCKER = <<EOF
|
|
26
|
+
include_majority: # More included files than ignored.
|
|
27
|
+
include: false # Include lines omitted.
|
|
28
|
+
ignore: '' # Ignore lines have no prefix.
|
|
29
|
+
ignore_majority: # More ignored files, flip for smaller file.
|
|
30
|
+
header: '*' # Start with ignore everything.
|
|
31
|
+
include: '!' # Includes are exceptions to ignore all.
|
|
32
|
+
ignore: false # Header covers everything.
|
|
33
|
+
EOF
|
|
34
|
+
|
|
35
|
+
DEFAULT_FORMAT = :default
|
|
36
|
+
BUILTINS = {
|
|
37
|
+
all: ALL,
|
|
38
|
+
default: DEFAULT,
|
|
39
|
+
docker: DOCKER
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def self.doc
|
|
43
|
+
<<EOF
|
|
44
|
+
If FORMAT is a file name it is read. Otherwise internal format is used.
|
|
45
|
+
Internal formats: #{BUILTINS.keys.sort!.join(', ')}. Defaults to "#{DEFAULT_FORMAT}".
|
|
46
|
+
|
|
47
|
+
If format allows, ignore file may ignore everything and add exceptions if that
|
|
48
|
+
produces a file with fewer entries. Dump internal format "docker" for info.
|
|
49
|
+
EOF
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.get(name)
|
|
53
|
+
BUILTINS[name.downcase.to_sym]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads from file, or from built-ins, or provides default if name is nil.
|
|
57
|
+
def self.read(name)
|
|
58
|
+
return YAML.safe_load(BUILTINS[DEFAULT_FORMAT]) if name.nil?
|
|
59
|
+
begin
|
|
60
|
+
YAML.safe_load_file(name)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
YAML.safe_load(get(name))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.write(nil_filename_or_has_puts_string, format)
|
|
67
|
+
fmt = get(format)
|
|
68
|
+
unless fmt.nil?
|
|
69
|
+
if nil_filename_or_has_puts_string.nil?
|
|
70
|
+
output = $stdout
|
|
71
|
+
elsif nil_filename_or_has_puts_string.is_a?(String)
|
|
72
|
+
output = File.new(nil_filename_or_has_puts_string, 'wt')
|
|
73
|
+
elsif nil_filename_or_has_puts_string.respond_to?(:puts)
|
|
74
|
+
output = nil_filename_or_has_puts_string
|
|
75
|
+
else
|
|
76
|
+
raise ArgumentError, 'Include2Ignore::IgnoreConfiguration::write: Not nil, string, nor puts in argument'
|
|
77
|
+
end
|
|
78
|
+
output.puts(fmt)
|
|
79
|
+
end
|
|
80
|
+
fmt
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Contains per-line configuration.
|
|
84
|
+
class Configuration
|
|
85
|
+
attr_reader :keep, :drop, :header
|
|
86
|
+
|
|
87
|
+
def initialize(config)
|
|
88
|
+
@keep = config.fetch('include', false)
|
|
89
|
+
@drop = config.fetch('ignore', false)
|
|
90
|
+
@header = config['header']
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Handles configuration file contents and selection of applicable config.
|
|
95
|
+
class ConfigurationAlternatives
|
|
96
|
+
attr_reader :include_majority, :ignore_majority
|
|
97
|
+
|
|
98
|
+
def initialize(config)
|
|
99
|
+
raise ArgumentError, 'Not a hash' unless config.is_a?(Hash)
|
|
100
|
+
@include_majority = config.key?('include_majority') ? Configuration.new(config['include_majority']) : nil
|
|
101
|
+
@ignore_majority = config.key?('ignore_majority') ? Configuration.new(config['ignore_majority']) : nil
|
|
102
|
+
raise ArgumentError, 'No majority configurations.' if @include_majority.nil? && @ignore_majority.nil?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def select(include_count, ignore_count)
|
|
106
|
+
return @include_majority if @ignore_majority.nil?
|
|
107
|
+
return @ignore_majority if @include_majority.nil?
|
|
108
|
+
include_count < ignore_count ? @ignore_majority : @include_majority
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
4
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
module Include2Ignore
|
|
7
|
+
# Writes to ignore file, output object, or standard output.
|
|
8
|
+
class Ignore
|
|
9
|
+
attr_reader :output, :puts_include, :puts_exclude
|
|
10
|
+
|
|
11
|
+
def initialize(nil_filename_or_has_puts_string, config)
|
|
12
|
+
if nil_filename_or_has_puts_string.nil?
|
|
13
|
+
@output = $stdout
|
|
14
|
+
elsif nil_filename_or_has_puts_string.is_a?(String)
|
|
15
|
+
@output = File.new(nil_filename_or_has_puts_string, 'wt')
|
|
16
|
+
elsif nil_filename_or_has_puts_string.respond_to?(:puts)
|
|
17
|
+
@output = nil_filename_or_has_puts_string
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, 'Include2Ignore::Ignore::initialize: Not nil, string, nor puts in argument'
|
|
20
|
+
end
|
|
21
|
+
@output.puts(config.header) if config.header
|
|
22
|
+
@puts_include = func(config.keep)
|
|
23
|
+
@puts_exclude = func(config.drop)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_include(line)
|
|
27
|
+
@puts_include.call(line)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add_exclude(line)
|
|
31
|
+
@puts_exclude.call(line)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def close
|
|
35
|
+
@output.close if @output.respond_to?(:close)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def func(prefix)
|
|
41
|
+
return ->(line) {} unless prefix.is_a?(String)
|
|
42
|
+
return ->(line) { @output.puts(line) } if prefix.empty?
|
|
43
|
+
->(line) { @output.puts("#{prefix}#{line}") }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Helper for Classified.
|
|
48
|
+
class Level
|
|
49
|
+
attr_reader :level, :children, :kind, :name, :included, :undecided, :excluded
|
|
50
|
+
|
|
51
|
+
def initialize(level, path_parts, is_directory)
|
|
52
|
+
@level = level
|
|
53
|
+
@children = {}
|
|
54
|
+
@kind = nil
|
|
55
|
+
@name = Level.decided_name(path_parts[0..level], (path_parts.size == level + 1) ? is_directory : true)
|
|
56
|
+
@included = 0
|
|
57
|
+
@undecided = 0
|
|
58
|
+
@excluded = 0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add(path_parts, exclude, is_directory)
|
|
62
|
+
if path_parts.size == @level + 1
|
|
63
|
+
@kind = exclude
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
child = @children[path_parts[@level + 1]]
|
|
67
|
+
if child.nil?
|
|
68
|
+
child = Level.new(@level + 1, path_parts, is_directory)
|
|
69
|
+
@children[path_parts[@level + 1]] = child
|
|
70
|
+
end
|
|
71
|
+
child.add(path_parts, exclude, is_directory)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.decided_name(path_parts, is_directory)
|
|
75
|
+
parts = [ '' ].concat(path_parts)
|
|
76
|
+
parts.push('') if is_directory
|
|
77
|
+
File.join(parts)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def counts
|
|
81
|
+
@included = 0
|
|
82
|
+
@undecided = 0
|
|
83
|
+
@excluded = 0
|
|
84
|
+
|
|
85
|
+
case @kind
|
|
86
|
+
when true then @excluded = 1
|
|
87
|
+
when false then @included = 1
|
|
88
|
+
else
|
|
89
|
+
@undecided = 1 if @children.empty?
|
|
90
|
+
end
|
|
91
|
+
@children.each_value do |child|
|
|
92
|
+
inc, und, ex = child.counts
|
|
93
|
+
@included += inc
|
|
94
|
+
@undecided += und
|
|
95
|
+
@excluded += ex
|
|
96
|
+
end
|
|
97
|
+
[ @included, @undecided, @excluded ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def exclude_undecided
|
|
101
|
+
return unless @kind.nil?
|
|
102
|
+
counts if @included.zero? && @undecided.zero? && @excluded.zero?
|
|
103
|
+
if @undecided.positive? && @included.zero?
|
|
104
|
+
# Anything explicitly excluded under this is covered.
|
|
105
|
+
@kind = true
|
|
106
|
+
@children.clear
|
|
107
|
+
else
|
|
108
|
+
@children.each_value(&:exclude_undecided)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def output(saver)
|
|
113
|
+
unless @kind.nil?
|
|
114
|
+
@kind ? saver.add_exclude(@name) : saver.add_include(@name)
|
|
115
|
+
end
|
|
116
|
+
@children.keys.sort!.each do |key|
|
|
117
|
+
@children[key].output(saver)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Stores decisions for all encountered files.
|
|
123
|
+
class Classified
|
|
124
|
+
attr_reader :root
|
|
125
|
+
|
|
126
|
+
def initialize
|
|
127
|
+
@root = {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def add(path_parts, exclude, is_directory)
|
|
131
|
+
return if path_parts.empty?
|
|
132
|
+
first = path_parts.first
|
|
133
|
+
child = @root[first]
|
|
134
|
+
if child.nil?
|
|
135
|
+
child = Level.new(0, path_parts, is_directory)
|
|
136
|
+
@root[first] = child
|
|
137
|
+
end
|
|
138
|
+
child.add(path_parts, exclude, is_directory)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def counts
|
|
142
|
+
included = 0
|
|
143
|
+
undecided = 0
|
|
144
|
+
excluded = 0
|
|
145
|
+
@root.each_value do |child|
|
|
146
|
+
inc, und, ex = child.counts
|
|
147
|
+
included += inc
|
|
148
|
+
undecided += und
|
|
149
|
+
excluded += ex
|
|
150
|
+
end
|
|
151
|
+
[ included, undecided, excluded ]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def pick
|
|
155
|
+
@root.each_value(&:exclude_undecided)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def output(saver)
|
|
159
|
+
@root.keys.sort!.each do |key|
|
|
160
|
+
@root[key].output(saver)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
4
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
module Include2Ignore
|
|
7
|
+
# Code related to reading and processing the include file, and comparing with file names.
|
|
8
|
+
module Include
|
|
9
|
+
RULES = [
|
|
10
|
+
[ '\\', :any, :skip, '\\ escapes the meaning of the next character, except newline.' ].freeze,
|
|
11
|
+
[ '\\', :first, :include ].freeze,
|
|
12
|
+
[ '#', :first, :comment, '# starting a line makes the line a comment.' ].freeze,
|
|
13
|
+
[ '!', :first, :exclude, '! starting a line makes the line an exclusion pattern' ].freeze,
|
|
14
|
+
[ '*', :any, :any, '* matches any full or partial file or directory name.' ].freeze,
|
|
15
|
+
[ '**', :any, nil, '** separated using "/" matches one or more directory names.' ].freeze,
|
|
16
|
+
[ '/', :any, :end ].freeze
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
FIRSTS = RULES.select { |r| r[1] == :first }.to_h { |r| [ r.first, r[2] ] }.freeze
|
|
20
|
+
ANYS = RULES.select { |r| r[1] == :any && !r[2].nil? }.to_h { |r| [ r.first, r[2] ] }.freeze
|
|
21
|
+
|
|
22
|
+
FILE = :file
|
|
23
|
+
DIR = :dir
|
|
24
|
+
|
|
25
|
+
ANY_ONE = Regexp.new('^.*$')
|
|
26
|
+
|
|
27
|
+
def self.doc
|
|
28
|
+
<<EOF
|
|
29
|
+
Include file is one pattern per line. Special characters:
|
|
30
|
+
#{RULES.select { |r| r.last.is_a?(String) }.map { |r| "- #{r.last}\n" }.join}
|
|
31
|
+
Last matching rule decides whether file is included or ignored.
|
|
32
|
+
EOF
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Holds all rules and checks if path is to be excluded.
|
|
36
|
+
class Classifier
|
|
37
|
+
attr_reader :rules
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@rules = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add only relevant rule types. Discards comments and empty rules.
|
|
44
|
+
def add(line)
|
|
45
|
+
r = Rule.new(line)
|
|
46
|
+
@rules.concat(r.anchored) if %i[include exclude].include?(r.kind)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Prepares the rules for exclude method.
|
|
50
|
+
def close
|
|
51
|
+
return if @rules.frozen?
|
|
52
|
+
@rules.reverse!
|
|
53
|
+
@rules.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check rule matching against given array of strings, and FILE/DIR category of last element.
|
|
57
|
+
def exclude(path_parts, category)
|
|
58
|
+
@rules.each do |r|
|
|
59
|
+
return r.exclude if r.match?(path_parts, category)
|
|
60
|
+
end
|
|
61
|
+
nil # Caller needs to decide.
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Helper class for one part of the filename. Used during rule reading.
|
|
66
|
+
class Element
|
|
67
|
+
FILEDIR = :filedir
|
|
68
|
+
|
|
69
|
+
attr_reader :items, :re, :raw
|
|
70
|
+
attr_accessor :category
|
|
71
|
+
|
|
72
|
+
def initialize
|
|
73
|
+
@items = []
|
|
74
|
+
@category = FILEDIR
|
|
75
|
+
@raw = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def push(char)
|
|
79
|
+
raise StandardError, "Include2Ignore::Include::Element::push called after close: #{char}" if @items.nil?
|
|
80
|
+
@raw.push(char)
|
|
81
|
+
item = char
|
|
82
|
+
if @items.last == :skip
|
|
83
|
+
@items.pop
|
|
84
|
+
elsif ANYS.key?(item)
|
|
85
|
+
item = ANYS[item]
|
|
86
|
+
end
|
|
87
|
+
if item == :end
|
|
88
|
+
# Explicit end flags this as directory name.
|
|
89
|
+
@category = DIR unless @items.empty?
|
|
90
|
+
close
|
|
91
|
+
else
|
|
92
|
+
@items.push(item)
|
|
93
|
+
end
|
|
94
|
+
item
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Sets category and performs checks and rule optimizations.
|
|
98
|
+
def close
|
|
99
|
+
return if @items.nil?
|
|
100
|
+
# We never get comments here since Rule detects them and skips the line.
|
|
101
|
+
if @items.last == :skip # Backslash was last character. No multi-line patterns.
|
|
102
|
+
Include2Ignore::Warnings.warn('Ends with \\')
|
|
103
|
+
@items.pop
|
|
104
|
+
end
|
|
105
|
+
if @items.empty?
|
|
106
|
+
@re = :empty
|
|
107
|
+
Include2Ignore::Warnings.explain("#{@raw.join} = Root or empty.")
|
|
108
|
+
elsif @items.size == 1 && @items.first == :any
|
|
109
|
+
@re = ANY_ONE
|
|
110
|
+
Include2Ignore::Warnings.explain("#{@raw.join} = One directory or file.")
|
|
111
|
+
elsif @items.size == 2 && @items.first == :any && @items.last == :any
|
|
112
|
+
# ** with nothing else.
|
|
113
|
+
@re = :anydirs
|
|
114
|
+
Include2Ignore::Warnings.explain("#{@raw.join} = One or more directories.")
|
|
115
|
+
else
|
|
116
|
+
previous = nil
|
|
117
|
+
dropped = false
|
|
118
|
+
@items.delete_if do |item|
|
|
119
|
+
drop = previous == :any && item == :any
|
|
120
|
+
previous = item
|
|
121
|
+
dropped = true if drop
|
|
122
|
+
drop
|
|
123
|
+
end
|
|
124
|
+
Include2Ignore::Warnings.warn('Had consecutive "*" but was not only "**".') if dropped
|
|
125
|
+
Include2Ignore::Warnings.explain('Starts with whitespace.') if @items.first.is_a?(String) && @items.first.lstrip != @items.first
|
|
126
|
+
Include2Ignore::Warnings.explain('Ends with whitespace.') if @items.last.is_a?(String) && @items.last.rstrip != @items.last
|
|
127
|
+
fixeds = []
|
|
128
|
+
@items.each do |item|
|
|
129
|
+
if item == :any
|
|
130
|
+
fixeds.push(item)
|
|
131
|
+
else
|
|
132
|
+
fixeds.push([]) if fixeds.empty? || !fixeds.last.is_a?(Array)
|
|
133
|
+
fixeds.last.push(item)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
parts = fixeds.map do |chars|
|
|
137
|
+
if chars.is_a?(Array)
|
|
138
|
+
Regexp.escape(chars.join)
|
|
139
|
+
else
|
|
140
|
+
'.*'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
expr = "^#{parts.join}$"
|
|
144
|
+
@re = Regexp.new(expr)
|
|
145
|
+
Include2Ignore::Warnings.explain("#{@raw.join} = Regular expression: #{expr}")
|
|
146
|
+
end
|
|
147
|
+
@items = nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Class for rule used to include or exclude as needed. Used during rule reading.
|
|
152
|
+
class Rule
|
|
153
|
+
attr_reader :kind, :elements
|
|
154
|
+
|
|
155
|
+
def initialize(line)
|
|
156
|
+
@kind = :include
|
|
157
|
+
@elements = []
|
|
158
|
+
if line.empty?
|
|
159
|
+
@kind = :comment
|
|
160
|
+
elsif FIRSTS.key?(line[0])
|
|
161
|
+
@kind = FIRSTS[line[0]]
|
|
162
|
+
line = line[1..]
|
|
163
|
+
end
|
|
164
|
+
return if @kind == :comment
|
|
165
|
+
line.each_char do |char|
|
|
166
|
+
@elements.push(Element.new) if @elements.empty? || @elements.last.items.nil?
|
|
167
|
+
@elements.last.push(char)
|
|
168
|
+
end
|
|
169
|
+
@elements.last.close
|
|
170
|
+
# Closed element at root without anything becomes anchor to root.
|
|
171
|
+
# Only last element can be file so ensure categories are DIRs, but keep last.
|
|
172
|
+
if @elements.size > 1
|
|
173
|
+
last = @elements.last.category
|
|
174
|
+
@elements.each { |e| e.category = DIR }
|
|
175
|
+
@elements.last.category = last
|
|
176
|
+
end
|
|
177
|
+
@first_anydirs = @elements.index { |e| e.re == :anydirs } || @elements.size
|
|
178
|
+
@elements.freeze
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Turns elements to arrays of processed elements and FILEDIR is turned
|
|
182
|
+
# to duplicate arrys with last elemnt category different.
|
|
183
|
+
def processed_elements
|
|
184
|
+
out = []
|
|
185
|
+
if @elements.last.category == Element::FILEDIR
|
|
186
|
+
# Split to two rules so that later processing can deal with FILE or DIR.
|
|
187
|
+
out.push(@elements.map { |e| ProcessedElement.new(e.re, e.category == Element::FILEDIR ? FILE : DIR) })
|
|
188
|
+
out.push(@elements.map { |e| ProcessedElement.new(e.re, DIR) })
|
|
189
|
+
else
|
|
190
|
+
out.push(@elements.map { |e| ProcessedElement.new(e.re, e.category) })
|
|
191
|
+
end
|
|
192
|
+
out
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns andchored rules, multiple when needed, with processed elements.
|
|
196
|
+
def anchored
|
|
197
|
+
procd = processed_elements
|
|
198
|
+
if @elements.first.re == :empty
|
|
199
|
+
# No need for the first empty element due to starting "/" since everything anchored from now on.
|
|
200
|
+
procd.map { |p| AnchoredRule.new(p[1..], @kind == :exclude) }
|
|
201
|
+
else
|
|
202
|
+
# Rule "foo" is like "/foo" and "/**/foo" in shorter notation.
|
|
203
|
+
[].concat(
|
|
204
|
+
procd.map { |p| AnchoredRule.new(p, @kind == :exclude) },
|
|
205
|
+
procd.map { |p| AnchoredRule.new([ ProcessedElement.new(:anydirs, DIR) ].concat(p), @kind == :exclude) }
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Contains fixed data for matching.
|
|
212
|
+
class ProcessedElement
|
|
213
|
+
attr_reader :category
|
|
214
|
+
attr_accessor :re
|
|
215
|
+
|
|
216
|
+
def initialize(regexp_or_type, category)
|
|
217
|
+
raise ArgumentError, "Include2Ignore::Include::ProcessedElement::initialize category not allowed: #{category}" unless [DIR, FILE].include?(category)
|
|
218
|
+
raise ArgumentError, "Include2Ignore::Include::ProcessedElement::initialize mismatch: #{regexp_or_type} and #{category}" if regexp_or_type == :anydirs && category != DIR
|
|
219
|
+
@re = regexp_or_type
|
|
220
|
+
@category = category
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def to_s
|
|
224
|
+
"#{@re}:#{@category}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def match?(str, category)
|
|
228
|
+
case category
|
|
229
|
+
when FILE
|
|
230
|
+
return false unless @category == FILE
|
|
231
|
+
when DIR
|
|
232
|
+
return false unless @category == DIR
|
|
233
|
+
return true if @re == :anydirs
|
|
234
|
+
else
|
|
235
|
+
raise ArgumentError, 'Include2Ignore::Include::ProcessedElement::match? category not FILE or DIR'
|
|
236
|
+
end
|
|
237
|
+
return true if str.empty? && @re == :empty
|
|
238
|
+
@re.match?(str)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Rule that contains full path from the base directory. Used in actual matching.
|
|
243
|
+
class AnchoredRule
|
|
244
|
+
attr_reader :elements, :first_anydirs, :exclude
|
|
245
|
+
|
|
246
|
+
def initialize(processed_elements, exclude)
|
|
247
|
+
@elements = optimize(processed_elements)
|
|
248
|
+
@first_anydirs = @elements.index { |e| e.re == :anydirs } || @elements.size
|
|
249
|
+
@exclude = exclude
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def optimize(elements)
|
|
253
|
+
opt = []
|
|
254
|
+
elements.each do |element|
|
|
255
|
+
next if element.re == :empty
|
|
256
|
+
unless opt.empty? || element.category != DIR
|
|
257
|
+
prev = opt.last
|
|
258
|
+
if prev.re == :anydirs
|
|
259
|
+
if element.re == :anydirs
|
|
260
|
+
# .../**/** is same as .../*/**
|
|
261
|
+
prev.re = ANY_ONE
|
|
262
|
+
elsif element.re.object_id.equal?(ANY_ONE.object_id) # Only symbol was tested.
|
|
263
|
+
# For directories, .../**/*/... equals .../*/**/... pattern.
|
|
264
|
+
opt[-1] = element
|
|
265
|
+
element = prev
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
opt.push(element)
|
|
270
|
+
end
|
|
271
|
+
opt
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def to_s
|
|
275
|
+
"#{@exclude ? ' ' : '!'}#{@elements.join(' :: ')}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def matching_element_indexes(part, category, skip_non_anydirs)
|
|
279
|
+
indexes = []
|
|
280
|
+
([ skip_non_anydirs, @first_anydirs ].min...@elements.size).each do |idx|
|
|
281
|
+
element = @elements[idx]
|
|
282
|
+
indexes.push(idx) if element.match?(part, category)
|
|
283
|
+
end
|
|
284
|
+
indexes
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def search(index_arrays, array_index, position_index)
|
|
288
|
+
if array_index == index_arrays.size
|
|
289
|
+
# Last key is known to match last item, so if that were tested, it would pass.
|
|
290
|
+
return position_index == @elements.size - 1
|
|
291
|
+
end
|
|
292
|
+
if @elements[position_index].re == :anydirs
|
|
293
|
+
# :anydir absorbs current key and moves to next position.
|
|
294
|
+
return true if search(index_arrays, array_index + 1, position_index + 1)
|
|
295
|
+
# :anydir absorbs current key and stays for more.
|
|
296
|
+
search(index_arrays, array_index + 1, position_index)
|
|
297
|
+
else
|
|
298
|
+
# Only current item to match with.
|
|
299
|
+
return false if index_arrays[array_index].index(position_index).nil?
|
|
300
|
+
search(index_arrays, array_index + 1, position_index + 1)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def match?(path_parts, category)
|
|
305
|
+
# Anchored to base directory.
|
|
306
|
+
slack = path_parts.size - @elements.size
|
|
307
|
+
return false if slack.negative? # Path too short.
|
|
308
|
+
return false if slack.positive? && @first_anydirs == @elements.size # Path too long.
|
|
309
|
+
# Last key must match last item.
|
|
310
|
+
return false unless @elements.last.match?(path_parts.last, category)
|
|
311
|
+
# First key must match first item.
|
|
312
|
+
return false unless @elements.first.match?(path_parts.first, path_parts.size == 1 ? category : DIR)
|
|
313
|
+
return true if path_parts.size <= 2 # Tested all already.
|
|
314
|
+
# Form arrays of possible match indexes for search. Omit first and last.
|
|
315
|
+
index_arrays = []
|
|
316
|
+
(1...(path_parts.size - 1)).each do |idx|
|
|
317
|
+
part = path_parts[idx]
|
|
318
|
+
idxs = matching_element_indexes(part, DIR, idx)
|
|
319
|
+
return false if idxs.empty?
|
|
320
|
+
index_arrays.push(idxs)
|
|
321
|
+
end
|
|
322
|
+
search(index_arrays, 0, @elements.first.re == :anydirs ? 0 : 1)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
4
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
require_relative 'include'
|
|
7
|
+
require_relative 'warnings'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
module Include2Ignore
|
|
11
|
+
# Directory tree traversal to find files and decide what to do.
|
|
12
|
+
module Search
|
|
13
|
+
DIR_SYMLINK_HANDLING = %i[reject skip check follow].freeze
|
|
14
|
+
@@dir_symlink_handling = DIR_SYMLINK_HANDLING.last
|
|
15
|
+
def self.dir_symlink_handling
|
|
16
|
+
@@dir_symlink_handling
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.dir_symlink_handling=(value)
|
|
20
|
+
raise ArgumentError, "Include2Ignore::Search::dir_symlink_handling: not in #{DIR_SYMLINK_HANDLING.join(', ')}: #{value}" unless DIR_SYMLINK_HANDLING.include?(value)
|
|
21
|
+
@@dir_symlink_handling = value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
FILE_SYMLINK_HANDLING = %i[reject skip check].freeze
|
|
25
|
+
@@file_symlink_handling = FILE_SYMLINK_HANDLING.last
|
|
26
|
+
def self.file_symlink_handling
|
|
27
|
+
@@file_symlink_handling
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.file_symlink_handling=(value)
|
|
31
|
+
raise ArgumentError, "Include2Ignore::Search::file_symlink_handling: not in #{FILE_SYMLINK_HANDLING.join(', ')}: #{value}" unless FILE_SYMLINK_HANDLING.include?(value)
|
|
32
|
+
@@file_symlink_handling = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.doc
|
|
36
|
+
<<EOF
|
|
37
|
+
Symbolic link handling:
|
|
38
|
+
- reject: Always ignore.
|
|
39
|
+
- skip: Do not test, do not follow directory symlinks.
|
|
40
|
+
- check: Check against rules but do not follow directory symlinks.
|
|
41
|
+
- follow: Check and follow directory symlinks.
|
|
42
|
+
EOF
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Helper class for Traverser.
|
|
46
|
+
class TraversalEntry
|
|
47
|
+
include Comparable
|
|
48
|
+
|
|
49
|
+
attr_reader :name, :full_name, :stat, :handle
|
|
50
|
+
|
|
51
|
+
def initialize(name, full_name)
|
|
52
|
+
@name = name
|
|
53
|
+
@full_name = full_name
|
|
54
|
+
@stat = File.lstat(full_name)
|
|
55
|
+
if @stat.symlink?
|
|
56
|
+
begin
|
|
57
|
+
@stat = File.stat(full_name)
|
|
58
|
+
if @stat.directory?
|
|
59
|
+
@handle = Search.dir_symlink_handling
|
|
60
|
+
if @handle == :follow
|
|
61
|
+
@full_name = File.realdirpath(full_name)
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
@handle = Search.file_symlink_handling
|
|
65
|
+
end
|
|
66
|
+
rescue Errno::ENOENT
|
|
67
|
+
Include2Ignore::Warnings.warn("Symlink broken: #{full_name}", true)
|
|
68
|
+
@handle = :reject
|
|
69
|
+
rescue Errno::ELOOP
|
|
70
|
+
Include2Ignore::Warnings.warn("Symlink loop: #{full_name}", true)
|
|
71
|
+
@handle = :reject
|
|
72
|
+
end
|
|
73
|
+
elsif @stat.directory?
|
|
74
|
+
@handle = :follow
|
|
75
|
+
@full_name = File.realdirpath(full_name)
|
|
76
|
+
else
|
|
77
|
+
@handle = :check
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def directory?
|
|
82
|
+
@stat.directory? && @handle == :follow
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def skip?
|
|
86
|
+
@handle == :skip
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reject?
|
|
90
|
+
@handle == :reject
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ino
|
|
94
|
+
@stat.ino
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def path_name
|
|
98
|
+
@name.nil? ? @full_name : @name
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.traverser(path_parts, skip, &block)
|
|
103
|
+
full_dir = File.join(path_parts.map(&:path_name))
|
|
104
|
+
dir = Dir.new(full_dir)
|
|
105
|
+
dir.each_child.map do |name|
|
|
106
|
+
full_name = File.join(full_dir, name)
|
|
107
|
+
t = TraversalEntry.new(name, full_name)
|
|
108
|
+
next if t.skip? # Should yield something to simplify testing.
|
|
109
|
+
path_parts.push(t)
|
|
110
|
+
# If skip rules explicitly include, then we skip.
|
|
111
|
+
names = path_parts[1..].map(&:name)
|
|
112
|
+
s = skip.exclude(names, t.directory? ? Include2Ignore::Include::DIR : Include2Ignore::Include::FILE)
|
|
113
|
+
if s.nil? || s
|
|
114
|
+
yield(names, t.directory?, t.reject?)
|
|
115
|
+
if t.directory? && path_parts.map(&:ino).index(t.ino) == path_parts.size - 1
|
|
116
|
+
traverser(path_parts, skip, &block)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
path_parts.pop
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.traverse(basedir, skip, &)
|
|
124
|
+
Search.traverser([ TraversalEntry.new(nil, basedir) ], skip, &)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
4
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
require 'singleton'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
module Include2Ignore
|
|
10
|
+
# Allows higher-level code to set context, lower-level to display warnings.
|
|
11
|
+
# Usage:
|
|
12
|
+
# At top level where verbosity is set: Include2Ignore::Warnings.output_explanations(verbosity)
|
|
13
|
+
# At level where location is known: Include2Ignore::Warnings.where(description)
|
|
14
|
+
# At lower level where warnings or other are found:
|
|
15
|
+
# Output always: Include2Ignore::Warnings.warn(message)
|
|
16
|
+
# Output only when verbose: Include2Ignore::Warnings.explain(message)
|
|
17
|
+
class Warnings
|
|
18
|
+
include Singleton
|
|
19
|
+
|
|
20
|
+
attr_reader :context
|
|
21
|
+
attr_accessor :verbose, :output
|
|
22
|
+
|
|
23
|
+
def self.output(nil_or_has_puts_string)
|
|
24
|
+
Warnings.instance.output = nil_or_has_puts_string
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def where(location)
|
|
28
|
+
@context = location
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.where(location)
|
|
32
|
+
Warnings.instance.where(location)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def warn(message, omit_context = false)
|
|
36
|
+
@output = $stderr if @output.nil?
|
|
37
|
+
if omit_context
|
|
38
|
+
@output.puts(message)
|
|
39
|
+
else
|
|
40
|
+
@output.puts(@context) unless @context.nil?
|
|
41
|
+
@context = nil
|
|
42
|
+
@output.puts(" #{message}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.warn(message, omit_context = false)
|
|
47
|
+
Warnings.instance.warn(message, omit_context)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.output_explanations(verbose)
|
|
51
|
+
Warnings.instance.verbose = verbose
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def explain(message)
|
|
55
|
+
warn(message) if @verbose
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.explain(message)
|
|
59
|
+
Warnings.instance.explain(message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright © 2026 Ismo Kärkkäinen
|
|
4
|
+
# Licensed under Universal Permissive License. See LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
require_relative 'include2ignore/config'
|
|
7
|
+
require_relative 'include2ignore/ignore'
|
|
8
|
+
require_relative 'include2ignore/include'
|
|
9
|
+
require_relative 'include2ignore/search'
|
|
10
|
+
require_relative 'include2ignore/version'
|
|
11
|
+
require_relative 'include2ignore/warnings'
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: include2ignore
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ismo Kärkkäinen
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Takes a list of file names or patterns, finds all files that do not match any
|
|
14
|
+
of them, or only match exclude patterns in the input file, and outputs them.
|
|
15
|
+
If directory contains no files that are included, outputs the directory
|
|
16
|
+
instead of individual files.
|
|
17
|
+
email: ismokarkkainen@icloud.com
|
|
18
|
+
executables:
|
|
19
|
+
- include2ignore
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- bin/include2ignore
|
|
25
|
+
- lib/include2ignore.rb
|
|
26
|
+
- lib/include2ignore/config.rb
|
|
27
|
+
- lib/include2ignore/ignore.rb
|
|
28
|
+
- lib/include2ignore/include.rb
|
|
29
|
+
- lib/include2ignore/search.rb
|
|
30
|
+
- lib/include2ignore/version.rb
|
|
31
|
+
- lib/include2ignore/warnings.rb
|
|
32
|
+
homepage: https://xn--ismo-krkkinen-gfbd.fi/include2ignore/index.html
|
|
33
|
+
licenses:
|
|
34
|
+
- UPL-1.0
|
|
35
|
+
metadata:
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: 3.3.11
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 4.0.21
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: Reads include file, finds files under directory, and outputs ignore file.
|
|
54
|
+
test_files: []
|