Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
833b4b9
Add new Ball API methods
gillisd Apr 15, 2026
8636b9f
DRY up Entry and Ball methods
gillisd Apr 15, 2026
4872e34
Save tarball scripts for now
gillisd Apr 15, 2026
4f958d4
Add tar -xO analagous option to unpack
gillisd Apr 16, 2026
18f19be
Add filter command
gillisd Apr 16, 2026
a2d4a54
Update .gitignore with new items
gillisd Apr 17, 2026
477f473
Make specs more idiomatic, run rubocop
gillisd Apr 17, 2026
3d4eaf4
Add warning gem as dependency
gillisd Apr 17, 2026
75923d5
Add more issues
gillisd Apr 17, 2026
ed1e18a
Draft of filter
gillisd May 29, 2026
5d3e99e
Remove throwaway tarball experiment scripts
gillisd Jul 2, 2026
ee5adbf
Order gemspec deps; keep planning docs out of VCS and lint
gillisd Jul 2, 2026
3a72137
Fix filter FILE argument handling and add specs
gillisd Jul 2, 2026
390ea1d
Read unpack input via File.read, add -O and not-found coverage
gillisd Jul 2, 2026
9df2d46
Require a body for Entry#valid?
gillisd Jul 2, 2026
7aaec25
Emit raw bytes for unpack -O
gillisd Jul 2, 2026
a6686f5
Harden filter argument parsing
gillisd Jul 2, 2026
44bfd9b
Make Ball#entries and #warnings private again
gillisd Jul 2, 2026
c476fe8
Fix Ball doc comment about filesystem access
gillisd Jul 2, 2026
429af6f
Remove unused Entry#name= setter
gillisd Jul 2, 2026
272d18f
Bump version to 0.2.1
gillisd Jul 7, 2026
1e06538
Merge branch 'master' into feature/expand-ball-public-api
gillisd Aug 6, 2026
b185b92
Add rubocop-rspec
gillisd Aug 6, 2026
f7cdd56
Add missing rubocop-rubycw dep
gillisd Aug 6, 2026
45d2a0e
noop
gillisd Aug 6, 2026
41dfbee
Update config
gillisd Aug 6, 2026
d0a4d30
Add name setter on entry
gillisd Aug 6, 2026
f39dcbb
Use subject over let
gillisd Aug 6, 2026
e0bb5d8
Add builder pattern support
gillisd Aug 6, 2026
0abe5b0
Remove this
gillisd Aug 6, 2026
82525ef
Bump version to 0.2.2
gillisd Aug 6, 2026
122684a
Remove
gillisd Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ Gemfile.lock
*.gem
.rspec_status
.patches/
env.sh
off.sh
on.sh
codeball.txt
ball.tar.gz
docs/superpowers/
6 changes: 2 additions & 4 deletions .idea/codeball.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .idea/inspectionProfiles/Project_Default.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ plugins:
AllCops:
NewCops: enable
TargetRubyVersion: 3.4.8
Exclude:
- "docs/**/*"
- "references/**/*"

# ===========================================================================
# Overrides from stock — personal style preferences
Expand Down Expand Up @@ -338,5 +341,3 @@ Style/TrailingCommaInHashLiteral:

Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: comma


1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ gem "rubocop-md"
gem "rubocop-minitest"
gem "rubocop-performance"
gem "rubocop-rake"
gem "rubocop-rspec"
gem "ruby-filemagic", "~> 0.7.3"
gem "warning", "~> 1.5"
1 change: 1 addition & 0 deletions codeball.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

spec.add_dependency "command_kit", "~> 0.6"
spec.add_dependency "warning"
spec.add_dependency "zeitwerk"
spec.metadata["rubygems_mfa_required"] = "true"
end
2 changes: 1 addition & 1 deletion issues.rec
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@ Id: 370D054E-8144-11F1-9126-FE6CB9572C2F
Updated: Thu, 16 Jul 2026 11:29:00 -0700
Title: "cannot load such file -- filemagic"
Description: filemagic should be optional
Status: open
Status: open
47 changes: 40 additions & 7 deletions lib/codeball/ball.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ module Codeball
# A codeball -- the aggregate root.
#
# Ball starts empty and grows as entries are added, like a snowball.
# It does not touch the filesystem. Parse is a thin factory that
# wires Cursor -> Stream -> Ball.
# An instance holds parsed entries in memory and does no I/O itself.
# Two class factories build one from source: parse (from an in-memory
# string, wiring Cursor -> Stream -> Ball) and load_file (which reads
# the source file from disk, then parses it).
#
class Ball
def self.parse(text)
Expand All @@ -16,17 +18,40 @@ def self.parse(text)
ball
end

def self.load_file(path)
pathname = Pathname(path)
raise "No file found" unless pathname.file?

parse(pathname.read)
end

def initialize
@entries = []
@warnings = []
end

def add_entry(entry)
def add_entry(entry = Entry.new)
yield entry if block_given?
raise ArgumentError, "Entry cannot be nil" if entry.nil?

@entries << entry
@warnings << entry.error if entry.errors?
@warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated?
end

def remove_entry(identifier)
to_remove = (
case identifier
in Entry then identifier
in String then each_entry.find { it.header == identifier }
else raise ArgumentError, "#{identifier} is not a valid Entry or identifier"
end
)
raise ArgumentError, "#{identifier} did not match an existing Entry in this Ball" unless to_remove

@entries.delete(to_remove)
end

def validate!
valid = entries.select(&:valid?)
if valid.empty? && warnings.any?
Expand All @@ -36,15 +61,23 @@ def validate!
end
end

def files
each_entry.map(&:header)
end

def entry_count
each_entry.count
end

def each_entry(&) = entries.select(&:valid?).each(&)
def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&)
def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&)
def each_text_entry(&) = each_entry.select(&:text?).each(&)
def each_non_text_entry(&) = each_entry.reject(&:text?).each(&)
def each_warning(&) = warnings.each(&)
def all_text? = entries.select(&:valid?).all?(&:text?)
def all_text? = each_entry.all?(&:text?)
def warning_count = warnings.length

def serialize
entries.select(&:valid?).select(&:text?).map(&:serialize).join
each_text_entry.map(&:serialize).join
end

private
Expand Down
78 changes: 78 additions & 0 deletions lib/codeball/commands/filter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
require "command_kit/command"
require "command_kit/colors"

module Codeball
module Commands
# Filter entries in a codeball by glob pattern.
class Filter < CommandKit::Command
include CommandKit::Colors

usage "[options] [FILE]"
description "Filter entries in a codeball"

option :inverse, short: "-v", desc: "Reverse direction of filtering"

# Flags chosen so glob semantics match what users expect from shell
# globs: FNM_PATHNAME makes '*' stop at '/' and enables '**/' for
# recursive matching; FNM_EXTGLOB enables '{rb,py}' brace expansion.
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB

argument :patterns, required: true, repeats: true, desc: "Patterns to filter on"
argument :file, required: false, desc: "Codeball file (or stdin if omitted)"

examples [
"'*.rb' bundle.txt",
"'*.rb' < bundle.txt",
"'lib/**/*.rb' bundle.txt",
"-v 'test/**' bundle.txt",
]

def run(*args)
patterns, file = split_source(args)
ball = Ball.parse(read_input(file))
report_warnings(ball)
drop_unmatched(ball, patterns)
stdout.puts ball.serialize
end

private

# The trailing argument is the codeball file only when there is at
# least one preceding pattern and it names a readable file on disk;
# otherwise every argument is a pattern and the ball is read from
# stdin. Requiring a preceding pattern keeps a lone argument a pattern
# (so `filter '*.rb'` filters stdin instead of trying to open '*.rb')
# and guarantees patterns is never empty.
def split_source(args)
*leading, last = args
leading.any? && File.file?(last) && File.readable?(last) ? [leading, last] : [args, nil]
end

def read_input(file)
input = file ? File.read(file) : stdin.read
abort_if_empty(input)
input
end

def report_warnings(ball)
ball.each_warning { |msg| stderr.puts colors(stderr).yellow("warning: #{msg}") }
end

def drop_unmatched(ball, patterns)
ball.each_entry.reject { match?(patterns, it) }.each { ball.remove_entry(it) }
end

def match?(patterns, entry)
verb = options[:inverse] ? :none? : :any?
patterns.public_send(verb) { |pattern| File.fnmatch?(pattern, entry.path, FNMATCH_FLAGS) }
end

def abort_if_empty(input)
return unless input.nil? || input.strip.empty?

print_error "no input"
exit 1
end
end
end
end
39 changes: 28 additions & 11 deletions lib/codeball/commands/unpack.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
require "command_kit/commands/command"
require "command_kit/command"
require "command_kit/colors"

module Codeball
module Commands
# Extract files from a codeball.
#
class Unpack < CommandKit::Commands::Command
class Unpack < CommandKit::Command
include CommandKit::Colors

usage "[options] [FILE]"
Expand All @@ -15,6 +15,7 @@ class Unpack < CommandKit::Commands::Command
value: { type: String, default: "." },
desc: "Output directory"

option :stdout, short: "-O", desc: "Write file contents to stdout instead of to files. (Analogous to tar -Ox)"
option :dry_run, short: "-n",
desc: "Preview extraction without writing files"

Expand All @@ -32,29 +33,45 @@ class Unpack < CommandKit::Commands::Command

def run(file = nil)
ball = Ball.parse(read_input(file))
dest = build_destination
report_warnings(ball)
return dump_to_stdout(ball) if options[:stdout]

extract_to_disk(ball)
end

private

def report_warnings(ball)
ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") }
ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } }
end

print_summary(dest.summary(malformed: ball.warning_count))
def dump_to_stdout(ball)
ball.each_entry { |entry| stdout.print entry.contents }
end

private
def extract_to_disk(ball)
dest = build_destination
ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } }
print_summary(dest.summary(malformed: ball.warning_count))
end

def build_destination
Destination.new(options[:output_dir], dry_run: options[:dry_run])
end

def read_input(file)
ARGV.replace(file ? [file] : [])
input = ARGF.read

abort_on_empty(input)
input = case file
when nil, "-" then stdin.read
else File.read(file)
end
abort_if_empty(input)
input
rescue Errno::ENOENT
print_error "no such file: #{file}"
exit 1
end

def abort_on_empty(input)
def abort_if_empty(input)
return unless input.nil? || input.strip.empty?

print_error "no input"
Expand Down
Loading
Loading