copy_code 0.3.0.beta → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4d333eaa0ad272d522245a8da8924bb4735f2579bf1820b5e4698ab8eeb1b99
4
- data.tar.gz: b750c71bd022f0630007266b73a3f0781d693722d4ccfbca6c840b9c4137e00a
3
+ metadata.gz: 02ec338e62fcb51c5376e10f75ba426814a591a994c8b9e3f2fd7471f02807f0
4
+ data.tar.gz: 758322b549d523a450ece9e8bec80ff4fba3cfc7014ab26a4f0f4e529612fa2b
5
5
  SHA512:
6
- metadata.gz: 1c14cafc2fd57a0c6d32ce6b9e73c527909a245b9ea11e49be207e835618132db32a55ec52831f5fec29b385a9c9a2b533dbff6f1b924a583748ccbffd98fedf
7
- data.tar.gz: a6456aa45e75e3529f623c0d4ae4b110623e0dbb939254f86ec1a0a1df56bacc97ac8ca6200f59946741c43e43b64336eb9af074b37400b6a2ca08f138b45444
6
+ metadata.gz: b23b134e6dc1b70a4aa3e50a3da9e581e8534efc82597bd69ba459618f241805e0b6185bb259dbdbafa697f9a29181a294fa39e8fcec48f68e38bf179512ecc4
7
+ data.tar.gz: c86b884001aa6ca983fa29445625693bc2909b6d5bd33a0cb8a1410267518cacf82a2f747f23d2112d6cb54f5ac744c7a9c0dbd3a2216d3ec099a02dbef61d4f
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on Keep a Changelog, and this project adheres to SemVer.
6
+
7
+ ## [0.5.0] - 2026-01-12
8
+ ### Added
9
+ - CLI flag `-o/--output-path` to control where text output is written.
10
+ - Aruba RSpec integration specs for the CLI.
11
+ - SimpleCov startup to measure coverage.
12
+ - Support for `.cc_ignore` as an alternate ignore file name.
13
+
14
+ ### Changed
15
+ - Text output defaults to the project root when the target is a file.
16
+ - CLI now validates `-p/--print` values and errors on invalid output methods.
17
+ - CLI now errors when a target path does not exist.
18
+
19
+ ### Fixed
20
+ - Directory ignore rules now match directory paths and nested contents.
21
+ - Directory glob rules now handle `**/` patterns correctly.
22
+ - Ignore filtering leaves files outside the root unfiltered.
23
+ - Relative path resolution returns absolute paths for files outside the root.
24
+ - Core now supports file targets in addition to directories.
25
+ - `pbcopy` fallback now warns and writes to stdout when unavailable.
26
+ - Gemspec no longer packages built `.gem` artifacts.
data/README.md CHANGED
@@ -25,6 +25,7 @@ A smart, flexible CLI tool to copy source code from a directory (or project) int
25
25
  | -------------------- | -------------------------------------------------------------------- |
26
26
  | -e, --extensions=EXT | Comma-separated list of file extensions to include (e.g. `-e rb,py`) |
27
27
  | -p, --print=OUT | Output format: `pbcopy` (clipboard) or `txt` (file) |
28
+ | -o, --output-path=PATH | Output path or directory for `txt` mode (default: `code_output.txt`) |
28
29
  | -h | Show help information |
29
30
 
30
31
  ## 🚀 Installation
@@ -62,6 +63,18 @@ Copy .js, .ts, and .json files from a specific folder and write to a file:
62
63
  copy_code ~/projects/my-app -e js,ts,json -p txt
63
64
  ```
64
65
 
66
+ Write output to a specific file:
67
+
68
+ ```sh
69
+ copy_code ~/projects/my-app -e rb -p txt -o ~/tmp/my_code.txt
70
+ ```
71
+
72
+ Write output to the project root:
73
+
74
+ ```sh
75
+ copy_code ~/projects/my-app -e rb -p txt -o .
76
+ ```
77
+
65
78
  Copy all .rb and .py files from the current directory and subdirectories:
66
79
 
67
80
  ```sh
@@ -3,43 +3,66 @@
3
3
  # copy_code/lib/copy_code/cli/ignore_loader.rb
4
4
  #
5
5
  # This file defines the CopyCode::CLI::IgnoreLoader class, which is responsible
6
- # for loading ignore patterns from a `.copy_codeignore` file.
6
+ # for loading ignore patterns from `.ccignore` or `.cc_ignore`.
7
7
  #
8
8
  # It checks the first project target path passed to the CLI, and falls back
9
- # to the user's `~/.ccignore` if not found.
9
+ # to the user's `~/.ccignore` or `~/.cc_ignore` if not found.
10
+
11
+ require_relative "../filters/ignore_path_filter"
10
12
 
11
13
  module CopyCode
12
14
  module CLI
13
- # IgnoreLoader loads a list of path substrings from a `.ccignore` file.
14
- # It will attempt to read from the first provided target path.
15
- #
16
- # If no ignore file exists in that path, it will fall back to ~/.ccignore.
15
+ # IgnoreLoader loads ignore patterns from a `.ccignore` file, falling back to
16
+ # the user's `~/.ccignore`. The result includes the directory the patterns
17
+ # should be evaluated relative to.
17
18
  class IgnoreLoader
19
+ Result = Struct.new(:patterns, :base_dir, keyword_init: true)
20
+
18
21
  # @param ignore_patterns [Array<String>] substrings to match in file paths
19
- def initialize(ignore_patterns = [])
22
+ def initialize(ignore_patterns = [], base_dir: Dir.pwd)
20
23
  @patterns = ignore_patterns || []
24
+ @base_dir = base_dir
21
25
  end
22
26
 
23
27
  # Loads ignore patterns from a given target directory or fallback path.
24
28
  #
25
29
  # @param target_path [String] the root of the scanned project
26
- # @return [Array<String>] a list of path substrings to ignore
30
+ # @return [Result] a result containing patterns and the base directory
27
31
  def self.load(target_path)
28
- local_file = File.join(target_path, ".ccignore")
29
- fallback_file = File.expand_path("~/.ccignore")
32
+ target_path = File.expand_path(target_path)
33
+ local_file = first_existing_file(target_path, [".ccignore", ".cc_ignore"])
34
+ fallback_file = first_existing_file(File.expand_path("~"), [".ccignore", ".cc_ignore"])
35
+
36
+ file = local_file || fallback_file
37
+ base_dir = file ? File.dirname(file) : target_path
38
+ patterns = []
30
39
 
31
- file = File.exist?(local_file) ? local_file : fallback_file
32
- return [] unless File.exist?(file)
40
+ if file && File.exist?(file)
41
+ patterns = File.readlines(file, chomp: true)
42
+ patterns.map!(&:strip)
43
+ patterns.reject!(&:empty?)
44
+ patterns.reject! { |line| line.start_with?("#") }
45
+ end
33
46
 
34
- lines = File.readlines(file).map(&:strip)
35
- lines.reject!(&:empty?)
36
- lines || []
47
+ Result.new(patterns: patterns, base_dir: base_dir)
37
48
  end
38
49
 
39
50
  # @param file [String]
40
51
  # @return [Boolean] whether the file should be excluded
41
52
  def exclude?(file)
42
- @patterns.any? { |pattern| file.include?("/#{pattern}/") }
53
+ Filters::IgnorePathFilter.new(@patterns, root: @base_dir).exclude?(file)
54
+ end
55
+
56
+ # @param base_dir [String]
57
+ # @param names [Array<String>]
58
+ # @return [String, nil] the first existing file path
59
+ def self.first_existing_file(base_dir, names)
60
+ names.each do |name|
61
+ path = File.join(base_dir, name)
62
+ return path if File.exist?(path)
63
+ end
64
+
65
+ nil
43
66
  end
44
67
  end
45
68
  end
@@ -20,17 +20,30 @@ module CopyCode
20
20
  #
21
21
  # @param content [String] the fully formatted output content
22
22
  # @param method [String] the output method ("txt" or "pbcopy")
23
+ # @param output_path [String, nil] optional path for text file output
23
24
  # @return [void]
24
- def self.write(content, method)
25
- case method
25
+ def self.write(content, method, output_path: nil)
26
+ normalized_method = method.to_s.strip.downcase
27
+ case normalized_method
26
28
  when "txt"
27
- File.write("code_output.txt", content)
28
- puts "✅ Saved to code_output.txt"
29
+ path = output_path || "code_output.txt"
30
+ File.write(path, content)
31
+ puts "✅ Saved to #{path}"
29
32
  else
30
- IO.popen("pbcopy", "w") { |io| io.write(content) }
31
- puts " Copied to clipboard"
33
+ if pbcopy_available?
34
+ IO.popen("pbcopy", "w") { |io| io.write(content) }
35
+ puts "✅ Copied to clipboard"
36
+ else
37
+ warn "[WARN] pbcopy not available; writing to stdout"
38
+ puts content
39
+ end
32
40
  end
33
41
  end
42
+
43
+ def self.pbcopy_available?
44
+ system("command", "-v", "pbcopy", out: File::NULL, err: File::NULL)
45
+ end
46
+ private_class_method :pbcopy_available?
34
47
  end
35
48
  end
36
49
  end
@@ -28,6 +28,7 @@ module CopyCode
28
28
  @options = {
29
29
  extensions: [],
30
30
  output: "pbcopy",
31
+ output_path: nil,
31
32
  targets: []
32
33
  }
33
34
  end
@@ -39,6 +40,7 @@ module CopyCode
39
40
  build_parser.parse!(@argv)
40
41
  assign_targets
41
42
  validate_targets
43
+ normalize_output
42
44
  @options
43
45
  end
44
46
 
@@ -49,13 +51,20 @@ module CopyCode
49
51
  # @return [OptionParser]
50
52
  def build_parser
51
53
  OptionParser.new do |opts|
52
- opts.banner = "Usage: copy_code [options] [paths]"
54
+ opts.banner = "Usage: copy_code [paths] [options]"
53
55
  opts.on("-eEXT", "--extensions=EXT", "Comma-separated list (e.g. rb,py,js)") do |ext|
54
56
  @options[:extensions] = parse_extensions(ext)
55
57
  end
56
58
  opts.on("-pOUT", "--print=OUT", "Output method: pbcopy (default) or txt") do |out|
57
59
  @options[:output] = out
58
60
  end
61
+ opts.on("-oPATH", "--output-path=PATH", "Path or directory for txt output (default: code_output.txt)") do |path|
62
+ @options[:output_path] = path
63
+ end
64
+ opts.on("-h", "--help", "Displays help information") do
65
+ puts opts
66
+ exit
67
+ end
59
68
  end
60
69
  end
61
70
 
@@ -71,7 +80,7 @@ module CopyCode
71
80
  #
72
81
  # @return [void]
73
82
  def assign_targets
74
- @options[:targets] = @argv unless @argv.empty?
83
+ @options[:targets] = @argv.empty? ? [Dir.pwd] : @argv
75
84
  @options[:targets].map! { |t| File.expand_path(t) }
76
85
  end
77
86
 
@@ -80,9 +89,18 @@ module CopyCode
80
89
  # @return [void]
81
90
  def validate_targets
82
91
  @options[:targets].each do |path|
83
- warn "[WARN] Target path does not exist: #{path}" unless Dir.exist?(path)
92
+ next if File.exist?(path)
93
+
94
+ raise OptionParser::InvalidArgument, "Target path does not exist: #{path}"
84
95
  end
85
96
  end
97
+
98
+ def normalize_output
99
+ @options[:output] = @options[:output].to_s.strip.downcase
100
+ return if %w[pbcopy txt].include?(@options[:output])
101
+
102
+ raise OptionParser::InvalidArgument, "Output must be pbcopy or txt"
103
+ end
86
104
  end
87
105
  end
88
106
  end
data/lib/copy_code/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  # copy_code/lib/copy_code/cli/runner.rb
4
6
  #
5
7
  # This file defines the CopyCode::CLI::Runner class, which is the core orchestrator
@@ -35,7 +37,7 @@ module CopyCode
35
37
  #
36
38
  # @return [void]
37
39
  def execute
38
- OutputWriter.write(result, options[:output])
40
+ OutputWriter.write(result, options[:output], output_path: output_path)
39
41
  end
40
42
 
41
43
  private
@@ -44,10 +46,16 @@ module CopyCode
44
46
  #
45
47
  # @return [Array<#exclude?>] array of filter objects
46
48
  def filters
47
- @filters ||= [
48
- Filters::FileExtensionFilter.new(options[:extensions]),
49
- Filters::IgnorePathFilter.new(IgnoreLoader.load(options[:targets].first))
50
- ]
49
+ @filters ||= begin
50
+ ignore_config = IgnoreLoader.load(options[:targets].first)
51
+ [
52
+ Filters::FileExtensionFilter.new(options[:extensions]),
53
+ Filters::IgnorePathFilter.new(
54
+ ignore_config.patterns,
55
+ root: ignore_config.base_dir
56
+ )
57
+ ]
58
+ end
51
59
  end
52
60
 
53
61
  # Creates the core file discovery/formatting engine.
@@ -80,6 +88,31 @@ module CopyCode
80
88
  def options
81
89
  @options ||= Parser.new(@argv).parse
82
90
  end
91
+
92
+ # Determines where to write the text output, if requested.
93
+ #
94
+ # @return [String, nil] absolute output path or nil when not writing a file
95
+ def output_path
96
+ return nil unless options[:output].to_s.strip.casecmp("txt").zero?
97
+
98
+ explicit_path = options[:output_path]
99
+ target = options[:targets].first
100
+ base_dir = File.directory?(target) ? target : Dir.pwd
101
+ return File.join(base_dir, "code_output.txt") if explicit_path.nil? || explicit_path.strip.empty?
102
+
103
+ resolved_path = File.expand_path(explicit_path, base_dir)
104
+ return File.join(resolved_path, "code_output.txt") if directory_like?(explicit_path, resolved_path)
105
+
106
+ resolved_path
107
+ end
108
+
109
+ def directory_like?(explicit_path, resolved_path)
110
+ return true if explicit_path.end_with?(File::SEPARATOR)
111
+ return true if [".", ".."].include?(explicit_path)
112
+ return true if File.directory?(resolved_path)
113
+
114
+ false
115
+ end
83
116
  end
84
117
  end
85
118
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pry"
4
-
5
3
  module CopyCode
6
4
  # Core file aggregator and formatter for the CopyCode domain
7
5
  class Core
@@ -17,11 +15,7 @@ module CopyCode
17
15
  # @return [Array<String>] list of absolute file paths
18
16
  def gather_files
19
17
  @targets.flat_map do |target|
20
- Dir.glob("#{target}/**/*", File::FNM_DOTMATCH).select do |file|
21
- is_file = File.file?(file)
22
- is_included = include_file?(file)
23
- is_file && is_included
24
- end
18
+ files_for_target(target).select { |file| include_file?(file) }
25
19
  end
26
20
  end
27
21
 
@@ -39,6 +33,13 @@ module CopyCode
39
33
 
40
34
  private
41
35
 
36
+ def files_for_target(target)
37
+ return [target] if File.file?(target)
38
+ return [] unless File.directory?(target)
39
+
40
+ Dir.glob("#{target}/**/*", File::FNM_DOTMATCH).select { |file| File.file?(file) }
41
+ end
42
+
42
43
  def include_file?(file)
43
44
  @filters.none? { |f| f.exclude?(file) }
44
45
  end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CopyCode
4
+ module Domain
5
+ module Filtering
6
+ # IgnoreRule represents a single gitignore-style rule.
7
+ class IgnoreRule
8
+ MATCH_OPTIONS = File::FNM_PATHNAME | File::FNM_EXTGLOB
9
+
10
+ def self.from(raw_pattern)
11
+ pattern = raw_pattern.to_s.strip
12
+ return if pattern.empty?
13
+
14
+ new(pattern)
15
+ end
16
+
17
+ def initialize(pattern)
18
+ @negated = pattern.start_with?("!")
19
+ pattern = pattern[1..] if @negated
20
+
21
+ @directory_only = pattern.end_with?("/")
22
+ pattern = pattern.chomp("/")
23
+
24
+ @anchored = pattern.start_with?("/")
25
+ pattern = pattern.sub(%r{\A/+}, "")
26
+
27
+ @pattern = pattern
28
+ @globs = compile_globs(pattern)
29
+ end
30
+
31
+ # Applies the rule to the provided path, returning the updated ignored state.
32
+ #
33
+ # @param path [String] relative path from the project root
34
+ # @param ignored [Boolean] current ignored state
35
+ # @return [Boolean] new ignored state
36
+ def apply(path, ignored)
37
+ return ignored unless matches?(path)
38
+
39
+ @negated ? false : true
40
+ end
41
+
42
+ private
43
+
44
+ def matches?(path)
45
+ return false if @globs.empty?
46
+
47
+ return directory_match?(path) if @directory_only
48
+
49
+ @globs.any? { |glob| File.fnmatch?(glob, path, MATCH_OPTIONS) }
50
+ end
51
+
52
+ def compile_globs(pattern)
53
+ return [] if pattern.nil? || pattern.empty?
54
+
55
+ base = if @anchored
56
+ pattern
57
+ else
58
+ compile_unanchored(pattern)
59
+ end
60
+
61
+ glob_variants(base)
62
+ end
63
+
64
+ def compile_unanchored(pattern)
65
+ pattern.start_with?("**/") ? pattern : "**/#{pattern}"
66
+ end
67
+
68
+ def glob_variants(base)
69
+ sanitized = base.gsub(%r{//+}, "/")
70
+
71
+ if @directory_only
72
+ [
73
+ sanitized,
74
+ "#{sanitized}/**"
75
+ ]
76
+ elsif base.include?("/")
77
+ [sanitized]
78
+ else
79
+ [
80
+ sanitized,
81
+ "#{sanitized}/**"
82
+ ]
83
+ end
84
+ end
85
+
86
+ def directory_match?(path)
87
+ return false if @pattern.nil? || @pattern.empty?
88
+
89
+ if glob_pattern?(@pattern)
90
+ return directory_glob_match?(path, @pattern)
91
+ end
92
+
93
+ if @anchored
94
+ return path == @pattern || path.start_with?("#{@pattern}/")
95
+ end
96
+
97
+ path.match?(%r{(^|/)#{Regexp.escape(@pattern)}(/|$)})
98
+ end
99
+
100
+ def directory_glob_match?(path, pattern)
101
+ sanitized_pattern = strip_leading_double_star(pattern)
102
+ regex = glob_to_regex(sanitized_pattern)
103
+ if @anchored
104
+ if pattern.start_with?("**/")
105
+ path.match?(%r{\A(?:.*/)?#{regex}(?:/|$)})
106
+ else
107
+ path.match?(%r{\A#{regex}(?:/|$)})
108
+ end
109
+ else
110
+ path.match?(%r{(^|/)#{regex}(?:/|$)})
111
+ end
112
+ end
113
+
114
+ def glob_to_regex(pattern)
115
+ regex = +""
116
+ index = 0
117
+
118
+ while index < pattern.length
119
+ char = pattern[index]
120
+ next_char = pattern[index + 1]
121
+
122
+ if char == "*" && next_char == "*"
123
+ regex << ".*"
124
+ index += 2
125
+ next
126
+ end
127
+
128
+ case char
129
+ when "*"
130
+ regex << "[^/]*"
131
+ when "?"
132
+ regex << "[^/]"
133
+ when "["
134
+ index = append_character_class(regex, pattern, index)
135
+ when "{"
136
+ index = append_brace_group(regex, pattern, index)
137
+ else
138
+ regex << Regexp.escape(char)
139
+ end
140
+
141
+ index += 1
142
+ end
143
+
144
+ regex
145
+ end
146
+
147
+ def append_character_class(regex, pattern, start_index)
148
+ closing = pattern.index("]", start_index + 1)
149
+ return append_literal(regex, pattern[start_index], start_index) unless closing
150
+
151
+ content = pattern[(start_index + 1)...closing]
152
+ if content.start_with?("!")
153
+ content = "^" + Regexp.escape(content[1..])
154
+ else
155
+ content = Regexp.escape(content)
156
+ end
157
+
158
+ regex << "[#{content}]"
159
+ closing
160
+ end
161
+
162
+ def append_brace_group(regex, pattern, start_index)
163
+ closing = pattern.index("}", start_index + 1)
164
+ return append_literal(regex, pattern[start_index], start_index) unless closing
165
+
166
+ content = pattern[(start_index + 1)...closing]
167
+ parts = content.split(",").map { |part| Regexp.escape(part) }
168
+ regex << "(?:#{parts.join("|")})"
169
+ closing
170
+ end
171
+
172
+ def append_literal(regex, char, index)
173
+ regex << Regexp.escape(char)
174
+ index
175
+ end
176
+
177
+ def glob_pattern?(value)
178
+ value.match?(/[?*\[\{]/)
179
+ end
180
+
181
+ def strip_leading_double_star(pattern)
182
+ pattern.start_with?("**/") ? pattern.delete_prefix("**/") : pattern
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CopyCode
4
+ module Domain
5
+ module Filtering
6
+ # IgnoreRuleSet aggregates ordered ignore rules and evaluates them.
7
+ class IgnoreRuleSet
8
+ def initialize(patterns)
9
+ @rules = build_rules(patterns)
10
+ end
11
+
12
+ # @param path [String] relative path from the root
13
+ # @return [Boolean] whether the path should be ignored
14
+ def ignored?(path)
15
+ ignored = false
16
+ @rules.each do |rule|
17
+ ignored = rule.apply(path, ignored)
18
+ end
19
+ ignored
20
+ end
21
+
22
+ def empty?
23
+ @rules.empty?
24
+ end
25
+
26
+ private
27
+
28
+ def build_rules(patterns)
29
+ Array(patterns).filter_map do |pattern|
30
+ IgnoreRule.from(pattern)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CopyCode
4
+ module Domain
5
+ module Filtering
6
+ # PatternWhitelist encapsulates glob-based inclusion rules for filenames.
7
+ class PatternWhitelist
8
+ MATCH_OPTIONS = File::FNM_EXTGLOB | File::FNM_PATHNAME
9
+
10
+ def initialize(patterns)
11
+ @patterns = build_patterns(patterns)
12
+ end
13
+
14
+ # @param file [String]
15
+ # @return [Boolean] whether the file should be included by the whitelist
16
+ def include?(file)
17
+ return true if @patterns.empty?
18
+
19
+ basename = File.basename(file)
20
+ @patterns.any? { |pattern| File.fnmatch?(pattern, basename, MATCH_OPTIONS) }
21
+ end
22
+
23
+ private
24
+
25
+ def build_patterns(patterns)
26
+ Array(patterns).filter_map do |pattern|
27
+ normalized_pattern(pattern)
28
+ end
29
+ end
30
+
31
+ def normalized_pattern(raw)
32
+ value = raw.to_s.strip
33
+ return if value.empty?
34
+
35
+ return value if glob_pattern?(value)
36
+
37
+ normalized = value.sub(/\A\./, "")
38
+ "*.#{normalized}"
39
+ end
40
+
41
+ def glob_pattern?(value)
42
+ value.match?(/[?*\[\{]/)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module CopyCode
6
+ module Domain
7
+ module Filtering
8
+ # RelativePathResolver converts absolute file paths into paths relative to a root.
9
+ class RelativePathResolver
10
+ def initialize(root:)
11
+ @root = Pathname.new(File.expand_path(root || Dir.pwd))
12
+ end
13
+
14
+ # @param file [String]
15
+ # @return [String] the path relative to the configured root
16
+ def call(file)
17
+ file_path = Pathname.new(File.expand_path(file))
18
+ relative = file_path.relative_path_from(@root).to_s
19
+ return file_path.to_s if outside_root?(relative)
20
+
21
+ relative
22
+ rescue ArgumentError
23
+ file_path.to_s
24
+ end
25
+
26
+ private
27
+
28
+ def outside_root?(relative)
29
+ relative == ".." || relative.start_with?("..#{File::SEPARATOR}")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filtering/pattern_whitelist"
4
+ require_relative "filtering/ignore_rule"
5
+ require_relative "filtering/ignore_rule_set"
6
+ require_relative "filtering/relative_path_resolver"
7
+
8
+ module CopyCode
9
+ module Domain
10
+ module Filtering; end
11
+ end
12
+ end
@@ -1,20 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../domain/filtering"
4
+
3
5
  module CopyCode
4
6
  module Filters
5
- # Filters files by allowed extension
7
+ # Filters files by allowed extension or glob pattern, delegating to the domain whitelist.
6
8
  class FileExtensionFilter
7
9
  # @param extensions [Array<String>] file extensions without dot (e.g., "rb", "js")
8
10
  def initialize(extensions)
9
- @extensions = extensions.map { |ext| ".#{ext.gsub(/^\./, "")}" }
11
+ @whitelist = Domain::Filtering::PatternWhitelist.new(extensions)
10
12
  end
11
13
 
12
14
  # @param file [String]
13
15
  # @return [Boolean] whether the file should be excluded
14
16
  def exclude?(file)
15
- return false if @extensions.empty?
16
-
17
- !@extensions.any? { |ext| file.end_with?(ext) }
17
+ !@whitelist.include?(file)
18
18
  end
19
19
  end
20
20
  end
@@ -1,18 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+ require_relative "../domain/filtering"
5
+
3
6
  module CopyCode
4
7
  module Filters
5
- # Filters files by path substrings (e.g., ".venv", "node_modules")
8
+ # Filters files by gitignore-style path rules.
6
9
  class IgnorePathFilter
7
- # @param ignore_patterns [Array<String>] substrings to match in file paths
8
- def initialize(ignore_patterns)
9
- @patterns = ignore_patterns
10
+ # @param ignore_patterns [Array<String>] gitignore-like patterns
11
+ # @param root [String] directory the patterns are relative to
12
+ def initialize(ignore_patterns, root:)
13
+ @resolver = Domain::Filtering::RelativePathResolver.new(root: root)
14
+ @rules = Domain::Filtering::IgnoreRuleSet.new(ignore_patterns)
10
15
  end
11
16
 
12
17
  # @param file [String]
13
18
  # @return [Boolean] whether the file should be excluded
14
19
  def exclude?(file)
15
- @patterns.any? { |pattern| file.include?("/#{pattern}/") }
20
+ return false if @rules.empty?
21
+
22
+ relative = @resolver.call(file)
23
+ return false if Pathname.new(relative).absolute?
24
+
25
+ @rules.ignored?(relative)
16
26
  end
17
27
  end
18
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CopyCode
4
- VERSION = "0.3.0.beta"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/copy_code.rb CHANGED
@@ -4,12 +4,11 @@ require_relative "copy_code/version"
4
4
  require_relative "copy_code/cli"
5
5
  require_relative "copy_code/core"
6
6
 
7
+ require_relative "copy_code/domain/filtering"
7
8
  require_relative "copy_code/filters/file_extension_filter"
8
9
  require_relative "copy_code/filters/ignore_path_filter"
9
10
  require_relative "copy_code/cli/ignore_loader"
10
11
  require_relative "copy_code/cli/output_writer"
11
12
  require_relative "copy_code/cli/parser"
12
13
 
13
- module CopyCode
14
- # maybe some top-level stuff here
15
- end
14
+ module CopyCode; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copy_code
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0.beta
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - t0nylombardi
@@ -12,7 +12,7 @@ dependencies: []
12
12
  description: Find and copy code files from a project, with ignore rules and output
13
13
  options
14
14
  email:
15
- - alombardi.331@gmail.com
15
+ - iam@t0nylombardi.com
16
16
  executables:
17
17
  - console
18
18
  - copy_code
@@ -20,6 +20,7 @@ executables:
20
20
  extensions: []
21
21
  extra_rdoc_files: []
22
22
  files:
23
+ - CHANGELOG.md
23
24
  - LICENSE
24
25
  - README.md
25
26
  - Rakefile
@@ -32,6 +33,11 @@ files:
32
33
  - lib/copy_code/cli/output_writer.rb
33
34
  - lib/copy_code/cli/parser.rb
34
35
  - lib/copy_code/core.rb
36
+ - lib/copy_code/domain/filtering.rb
37
+ - lib/copy_code/domain/filtering/ignore_rule.rb
38
+ - lib/copy_code/domain/filtering/ignore_rule_set.rb
39
+ - lib/copy_code/domain/filtering/pattern_whitelist.rb
40
+ - lib/copy_code/domain/filtering/relative_path_resolver.rb
35
41
  - lib/copy_code/filters/file_extension_filter.rb
36
42
  - lib/copy_code/filters/ignore_path_filter.rb
37
43
  - lib/copy_code/version.rb
@@ -41,7 +47,6 @@ licenses:
41
47
  metadata:
42
48
  homepage_uri: https://github.com/t0nylombardi/copy_code
43
49
  source_code_uri: https://github.com/t0nylombardi/copy_code
44
- changelog_uri: https://github.com/t0nylombardi/copy_code/blob/main/CHANGELOG.md
45
50
  rdoc_options: []
46
51
  require_paths:
47
52
  - lib
@@ -56,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
56
61
  - !ruby/object:Gem::Version
57
62
  version: '0'
58
63
  requirements: []
59
- rubygems_version: 3.7.1
64
+ rubygems_version: 3.6.9
60
65
  specification_version: 4
61
66
  summary: CLI tool to copy code from a directory
62
67
  test_files: []