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 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.
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright © 2026 Ismo Kärkkäinen
4
+ # Licensed under Universal Permissive License. See LICENSE.txt.
5
+
6
+ # Name and version.
7
+ module Include2Ignore
8
+ NAME = 'include2ignore'
9
+ VERSION = '0.1.0'
10
+ 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: []