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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +13 -0
- data/lib/copy_code/cli/ignore_loader.rb +39 -16
- data/lib/copy_code/cli/output_writer.rb +19 -6
- data/lib/copy_code/cli/parser.rb +21 -3
- data/lib/copy_code/cli.rb +38 -5
- data/lib/copy_code/core.rb +8 -7
- data/lib/copy_code/domain/filtering/ignore_rule.rb +187 -0
- data/lib/copy_code/domain/filtering/ignore_rule_set.rb +36 -0
- data/lib/copy_code/domain/filtering/pattern_whitelist.rb +47 -0
- data/lib/copy_code/domain/filtering/relative_path_resolver.rb +34 -0
- data/lib/copy_code/domain/filtering.rb +12 -0
- data/lib/copy_code/filters/file_extension_filter.rb +5 -5
- data/lib/copy_code/filters/ignore_path_filter.rb +15 -5
- data/lib/copy_code/version.rb +1 -1
- data/lib/copy_code.rb +2 -3
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02ec338e62fcb51c5376e10f75ba426814a591a994c8b9e3f2fd7471f02807f0
|
|
4
|
+
data.tar.gz: 758322b549d523a450ece9e8bec80ff4fba3cfc7014ab26a4f0f4e529612fa2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
14
|
-
#
|
|
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 [
|
|
30
|
+
# @return [Result] a result containing patterns and the base directory
|
|
27
31
|
def self.load(target_path)
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
+
path = output_path || "code_output.txt"
|
|
30
|
+
File.write(path, content)
|
|
31
|
+
puts "✅ Saved to #{path}"
|
|
29
32
|
else
|
|
30
|
-
|
|
31
|
-
|
|
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
|
data/lib/copy_code/cli/parser.rb
CHANGED
|
@@ -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 [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
data/lib/copy_code/core.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
8
|
+
# Filters files by gitignore-style path rules.
|
|
6
9
|
class IgnorePathFilter
|
|
7
|
-
# @param ignore_patterns [Array<String>]
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
data/lib/copy_code/version.rb
CHANGED
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.
|
|
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
|
-
-
|
|
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.
|
|
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: []
|