codeball 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +342 -0
- data/.ruby-version +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +25 -0
- data/Rakefile +23 -0
- data/data/wont_pack.md +177 -0
- data/exe/codeball +5 -0
- data/issues.rec +61 -0
- data/lib/codeball/ball.rb +54 -0
- data/lib/codeball/body.rb +9 -0
- data/lib/codeball/border.rb +47 -0
- data/lib/codeball/cli.rb +24 -0
- data/lib/codeball/commands/diff.rb +51 -0
- data/lib/codeball/commands/list.rb +49 -0
- data/lib/codeball/commands/pack.rb +54 -0
- data/lib/codeball/commands/unpack.rb +123 -0
- data/lib/codeball/cursor.rb +115 -0
- data/lib/codeball/destination.rb +76 -0
- data/lib/codeball/entry.rb +107 -0
- data/lib/codeball/error.rb +4 -0
- data/lib/codeball/extraction_result.rb +16 -0
- data/lib/codeball/extraction_summary.rb +17 -0
- data/lib/codeball/footer.rb +9 -0
- data/lib/codeball/header.rb +9 -0
- data/lib/codeball/malformed_ball_error.rb +3 -0
- data/lib/codeball/stream.rb +60 -0
- data/lib/codeball/version.rb +3 -0
- data/lib/codeball.rb +23 -0
- data/lib/command_kit/combined_io.rb +28 -0
- data/lib/command_kit/printing.rb +42 -0
- data/scripts/codeball_xtract +42 -0
- metadata +102 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Codeball
|
|
2
|
+
# A codeball -- the aggregate root.
|
|
3
|
+
#
|
|
4
|
+
# Ball starts empty and grows as entries are added, like a snowball.
|
|
5
|
+
# It does not touch the filesystem. Parse is a thin factory that
|
|
6
|
+
# wires Cursor -> Stream -> Ball.
|
|
7
|
+
#
|
|
8
|
+
class Ball
|
|
9
|
+
def self.parse(text)
|
|
10
|
+
raise MalformedBallError, "empty input, nothing to extract" if text.nil? || text.strip.empty?
|
|
11
|
+
|
|
12
|
+
ball = new
|
|
13
|
+
stream = Stream.new(cursor: Cursor.new(text))
|
|
14
|
+
stream.each_entry { |entry| ball.add_entry(entry) }
|
|
15
|
+
ball.validate!
|
|
16
|
+
ball
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@entries = []
|
|
21
|
+
@warnings = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_entry(entry)
|
|
25
|
+
@entries << entry
|
|
26
|
+
@warnings << entry.error if entry.errors?
|
|
27
|
+
@warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
valid = entries.select(&:valid?)
|
|
32
|
+
if valid.empty? && warnings.any?
|
|
33
|
+
raise MalformedBallError, "no valid entries found (#{warnings.length} malformed)"
|
|
34
|
+
elsif valid.empty?
|
|
35
|
+
raise MalformedBallError, "no content found - is this a codeball?"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def each_entry(&) = entries.select(&:valid?).each(&)
|
|
40
|
+
def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&)
|
|
41
|
+
def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&)
|
|
42
|
+
def each_warning(&) = warnings.each(&)
|
|
43
|
+
def all_text? = entries.select(&:valid?).all?(&:text?)
|
|
44
|
+
def warning_count = warnings.length
|
|
45
|
+
|
|
46
|
+
def serialize
|
|
47
|
+
entries.select(&:valid?).select(&:text?).map(&:serialize).join
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :entries, :warnings
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Codeball
|
|
2
|
+
# Domain knowledge about the visual delimiter between sections in a codeball.
|
|
3
|
+
#
|
|
4
|
+
# Borders are repeated punctuation patterns that separate entries in
|
|
5
|
+
# serialized codeball text. The pattern is fixed, not configurable.
|
|
6
|
+
#
|
|
7
|
+
# During serialization, SEPARATOR is used as-is.
|
|
8
|
+
# During parsing, recognition is heuristic to tolerate mangling
|
|
9
|
+
# by browsers, editors, and clipboard transfer.
|
|
10
|
+
#
|
|
11
|
+
module Border
|
|
12
|
+
PATTERN = "---\t"
|
|
13
|
+
WIDTH = 10
|
|
14
|
+
SEPARATOR = (PATTERN * WIDTH).freeze
|
|
15
|
+
SUFFIX = /[-#=~*_|+][-#=~*_|+\s]{8,}\s*\z/
|
|
16
|
+
MIN_LENGTH = 6
|
|
17
|
+
MIN_PUNCTUATION_LENGTH = 9
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def recognize?(line)
|
|
22
|
+
return false if line.empty?
|
|
23
|
+
return false if line.start_with?("BEGIN ", "END ")
|
|
24
|
+
|
|
25
|
+
stripped = line.gsub(/\s+/, "")
|
|
26
|
+
return false if stripped.empty?
|
|
27
|
+
return false if stripped.length < MIN_LENGTH
|
|
28
|
+
|
|
29
|
+
single_char?(stripped) || punctuation_run?(stripped)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def strip_suffix(text)
|
|
33
|
+
text.match?(SUFFIX) ? text.sub(SUFFIX, "").chomp : text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def single_char?(stripped)
|
|
37
|
+
chars = stripped.chars.uniq
|
|
38
|
+
chars.length == 1 && !chars.first.match?(/[a-zA-Z0-9]/)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def punctuation_run?(stripped)
|
|
42
|
+
stripped.match?(/\A[-#=~*_|+]+\z/) && stripped.length >= MIN_PUNCTUATION_LENGTH
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :single_char?, :punctuation_run?
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/codeball/cli.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "command_kit/commands"
|
|
2
|
+
require "command_kit/commands/auto_load"
|
|
3
|
+
require "command_kit/options/version"
|
|
4
|
+
|
|
5
|
+
module Codeball
|
|
6
|
+
# Main CLI entry point for Codeball.
|
|
7
|
+
#
|
|
8
|
+
class CLI
|
|
9
|
+
include CommandKit::Commands
|
|
10
|
+
include CommandKit::Description
|
|
11
|
+
include CommandKit::Options::Version
|
|
12
|
+
|
|
13
|
+
version Codeball::VERSION
|
|
14
|
+
|
|
15
|
+
# Auto-load subcommands from lib/codeball/commands/*.rb
|
|
16
|
+
include CommandKit::Commands::AutoLoad.new(
|
|
17
|
+
dir: File.join(__dir__, "commands"),
|
|
18
|
+
namespace: "Codeball::Commands",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
command_name "codeball"
|
|
22
|
+
description "Bidirectional file bundler for clipboard-friendly LLM workflows"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "command_kit"
|
|
2
|
+
require "command_kit/command"
|
|
3
|
+
require "command_kit/colors"
|
|
4
|
+
|
|
5
|
+
module Codeball
|
|
6
|
+
module Commands
|
|
7
|
+
# Diff extracted files against local copies.
|
|
8
|
+
#
|
|
9
|
+
# Incomplete -- diff output is not yet implemented.
|
|
10
|
+
#
|
|
11
|
+
class Diff < CommandKit::Command
|
|
12
|
+
include CommandKit::Colors
|
|
13
|
+
|
|
14
|
+
usage "[options] [FILE]"
|
|
15
|
+
description "Diff codeball entries against local files"
|
|
16
|
+
|
|
17
|
+
option :output_dir, short: "-o",
|
|
18
|
+
value: { type: String, default: "." },
|
|
19
|
+
desc: "Directory to compare against"
|
|
20
|
+
|
|
21
|
+
argument :file, required: false,
|
|
22
|
+
desc: "Codeball file (or stdin if omitted)"
|
|
23
|
+
|
|
24
|
+
examples [
|
|
25
|
+
"bundle.txt",
|
|
26
|
+
"< bundle.txt",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def run(file = nil)
|
|
30
|
+
input = read_input(file)
|
|
31
|
+
ball = Ball.parse(input)
|
|
32
|
+
|
|
33
|
+
ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") }
|
|
34
|
+
|
|
35
|
+
# Diff output not yet implemented
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_input(file)
|
|
41
|
+
ARGV.replace(file ? [file] : [])
|
|
42
|
+
input = ARGF.read
|
|
43
|
+
|
|
44
|
+
return input unless input.nil? || input.strip.empty?
|
|
45
|
+
|
|
46
|
+
print_error "no input"
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "command_kit/commands/command"
|
|
2
|
+
require "command_kit/printing/tables"
|
|
3
|
+
require "command_kit/colors"
|
|
4
|
+
require_relative "../../command_kit/printing"
|
|
5
|
+
require_relative "../../command_kit/combined_io"
|
|
6
|
+
|
|
7
|
+
module Codeball
|
|
8
|
+
module Commands
|
|
9
|
+
# List files contained in a codeball.
|
|
10
|
+
class List < CommandKit::Commands::Command
|
|
11
|
+
include CommandKit::CombinedIO
|
|
12
|
+
include CommandKit::Colors
|
|
13
|
+
include CommandKit::Printing::Tables
|
|
14
|
+
|
|
15
|
+
usage "[options] [FILE]"
|
|
16
|
+
description "List files in a codeball"
|
|
17
|
+
|
|
18
|
+
argument :file, required: false, desc: "Codeball file (or stdin if omitted)"
|
|
19
|
+
|
|
20
|
+
examples ["bundle.txt", "< bundle.txt"]
|
|
21
|
+
|
|
22
|
+
def env
|
|
23
|
+
(super || {}).merge("TERM" => "1")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(io)
|
|
27
|
+
input = io.read
|
|
28
|
+
abort_if_empty(input)
|
|
29
|
+
|
|
30
|
+
ball = Ball.parse(input)
|
|
31
|
+
|
|
32
|
+
ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") }
|
|
33
|
+
|
|
34
|
+
rows = []
|
|
35
|
+
ball.each_entry { |e| rows << [e.path, "#{e.line_count} lines"] }
|
|
36
|
+
print_table_color(rows, header: %w[File Lines], color: :green, index: 0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def abort_if_empty(input)
|
|
42
|
+
return unless input.nil? || input.strip.empty?
|
|
43
|
+
|
|
44
|
+
print_error "no input"
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "command_kit/commands/command"
|
|
2
|
+
|
|
3
|
+
module Codeball
|
|
4
|
+
module Commands
|
|
5
|
+
# Pack files into a codeball for clipboard transfer.
|
|
6
|
+
#
|
|
7
|
+
class Pack < CommandKit::Commands::Command
|
|
8
|
+
usage "[options] FILE..."
|
|
9
|
+
|
|
10
|
+
description "Pack files into a codeball for clipboard transfer"
|
|
11
|
+
|
|
12
|
+
option :quiet, short: "-q", long: "--quiet", desc: "Suppress non-error output"
|
|
13
|
+
|
|
14
|
+
argument :files, required: true,
|
|
15
|
+
repeats: true,
|
|
16
|
+
desc: "Files to pack"
|
|
17
|
+
|
|
18
|
+
examples [
|
|
19
|
+
"lib/*.rb",
|
|
20
|
+
"src/**/*.py",
|
|
21
|
+
"README.md lib/*.rb",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def run(*files)
|
|
25
|
+
readable, unreadable = validate_files(files)
|
|
26
|
+
ball = Ball.new
|
|
27
|
+
readable.each do |path|
|
|
28
|
+
entry = Entry.from_file(path)
|
|
29
|
+
ball.add_entry(entry) if entry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
warn_skipped(unreadable, ball)
|
|
33
|
+
puts ball.serialize
|
|
34
|
+
|
|
35
|
+
exit 1 if unreadable.any? || !ball.all_text?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_files(files)
|
|
41
|
+
files
|
|
42
|
+
.map { Pathname(it) }
|
|
43
|
+
.partition { it.exist? && it.readable? }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def warn_skipped(unreadable, ball)
|
|
47
|
+
return if options[:quiet]
|
|
48
|
+
|
|
49
|
+
unreadable.each { print_error "cannot read file: #{it}" }
|
|
50
|
+
ball.each_non_text_entry { |entry| print_error "skipping non-text file: #{entry.path} (#{entry.mime_type})" }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "command_kit/commands/command"
|
|
2
|
+
require "command_kit/colors"
|
|
3
|
+
|
|
4
|
+
module Codeball
|
|
5
|
+
module Commands
|
|
6
|
+
# Extract files from a codeball.
|
|
7
|
+
#
|
|
8
|
+
class Unpack < CommandKit::Commands::Command
|
|
9
|
+
include CommandKit::Colors
|
|
10
|
+
|
|
11
|
+
usage "[options] [FILE]"
|
|
12
|
+
description "Extract files from a codeball"
|
|
13
|
+
|
|
14
|
+
option :output_dir, short: "-o",
|
|
15
|
+
value: { type: String, default: "." },
|
|
16
|
+
desc: "Output directory"
|
|
17
|
+
|
|
18
|
+
option :dry_run, short: "-n",
|
|
19
|
+
desc: "Preview extraction without writing files"
|
|
20
|
+
|
|
21
|
+
option :quiet, short: "-q", long: "--quiet", desc: "Suppress non-error output"
|
|
22
|
+
|
|
23
|
+
argument :file, required: false,
|
|
24
|
+
desc: "Codeball file (or stdin if omitted)"
|
|
25
|
+
|
|
26
|
+
examples [
|
|
27
|
+
"bundle.txt",
|
|
28
|
+
"-n bundle.txt",
|
|
29
|
+
"-o extracted/ bundle.txt",
|
|
30
|
+
"< bundle.txt",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def run(file = nil)
|
|
34
|
+
ball = Ball.parse(read_input(file))
|
|
35
|
+
dest = build_destination
|
|
36
|
+
|
|
37
|
+
ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") }
|
|
38
|
+
ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } }
|
|
39
|
+
|
|
40
|
+
print_summary(dest.summary(malformed: ball.warning_count))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build_destination
|
|
46
|
+
Destination.new(options[:output_dir], dry_run: options[:dry_run])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def read_input(file)
|
|
50
|
+
ARGV.replace(file ? [file] : [])
|
|
51
|
+
input = ARGF.read
|
|
52
|
+
|
|
53
|
+
abort_on_empty(input)
|
|
54
|
+
input
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def abort_on_empty(input)
|
|
58
|
+
return unless input.nil? || input.strip.empty?
|
|
59
|
+
|
|
60
|
+
print_error "no input"
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def puts(...)
|
|
65
|
+
return if options[:quiet]
|
|
66
|
+
|
|
67
|
+
stdout.puts(...)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def warn(...)
|
|
71
|
+
return if options[:quiet]
|
|
72
|
+
|
|
73
|
+
stderr.puts(...)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def print_outcome(outcome)
|
|
77
|
+
case outcome.status
|
|
78
|
+
when :written then print_written(outcome)
|
|
79
|
+
when :dry_run then print_dry_run(outcome)
|
|
80
|
+
when :unsafe then print_unsafe(outcome)
|
|
81
|
+
when :failed then print_failed(outcome)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def print_written(outcome)
|
|
86
|
+
puts "#{colors.green("wrote")}: #{outcome.path} (#{outcome.line_count} lines)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def print_dry_run(outcome)
|
|
90
|
+
puts "#{colors.cyan("[dry-run]")} would write: #{outcome.path} (#{outcome.line_count} lines)"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def print_unsafe(outcome)
|
|
94
|
+
warn colors.yellow("warning: skipping unsafe path #{outcome.path.inspect}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def print_failed(outcome)
|
|
98
|
+
warn colors.red("error: #{outcome.path}: #{outcome.error}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_summary(summary)
|
|
102
|
+
prefix = summary.dry_run? ? "#{colors.cyan("[dry-run]")} " : ""
|
|
103
|
+
puts "---"
|
|
104
|
+
puts "#{prefix}#{summary_parts(summary).join(", ")}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def summary_parts(summary)
|
|
108
|
+
parts = [colors.green("extracted: #{summary.extracted}").to_s]
|
|
109
|
+
parts << skipped_part(summary)
|
|
110
|
+
parts << colors.yellow("malformed: #{summary.malformed}") if summary.malformed.positive?
|
|
111
|
+
parts
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def skipped_part(summary)
|
|
115
|
+
if summary.skipped.positive?
|
|
116
|
+
colors.yellow("skipped: #{summary.skipped}")
|
|
117
|
+
else
|
|
118
|
+
"skipped: 0"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module Codeball
|
|
2
|
+
# A lexer for codeball-formatted text.
|
|
3
|
+
#
|
|
4
|
+
# Cursor walks text line by line and classifies each meaningful
|
|
5
|
+
# element as a typed token: Header, Body, or Footer. Borders are
|
|
6
|
+
# delimiters consumed internally -- they are never yielded.
|
|
7
|
+
#
|
|
8
|
+
# Cursor does not correlate tokens or enforce sequencing.
|
|
9
|
+
# Stream handles assembly; Entry enforces invariants.
|
|
10
|
+
#
|
|
11
|
+
class Cursor
|
|
12
|
+
BEGIN_PATTERN = /\ABEGIN\s+["']?(.+?)["']?\s*\z/
|
|
13
|
+
END_PATTERN = /\AEND\s+["']?(.+?)["']?\s*\z/
|
|
14
|
+
|
|
15
|
+
# Sentinel returned when all tokens have been consumed.
|
|
16
|
+
module EOF; end
|
|
17
|
+
|
|
18
|
+
def initialize(text)
|
|
19
|
+
@lines = text.lines
|
|
20
|
+
@position = 0
|
|
21
|
+
@pending_footer = nil
|
|
22
|
+
@body_lines = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def next_item
|
|
26
|
+
return emit_footer if @pending_footer
|
|
27
|
+
|
|
28
|
+
skip_borders
|
|
29
|
+
return EOF if finished?
|
|
30
|
+
|
|
31
|
+
if @body_lines
|
|
32
|
+
read_body
|
|
33
|
+
else
|
|
34
|
+
read_header_or_eof
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :lines, :position
|
|
41
|
+
|
|
42
|
+
def finished? = position >= lines.length
|
|
43
|
+
def current_line = lines[position]&.strip
|
|
44
|
+
def raw_line = lines[position]
|
|
45
|
+
|
|
46
|
+
def advance
|
|
47
|
+
@position += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def peek_line
|
|
51
|
+
lines[position + 1]&.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def skip_borders
|
|
55
|
+
advance while !finished? && Border.recognize?(current_line)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read_header_or_eof
|
|
59
|
+
match = current_line&.match(BEGIN_PATTERN)
|
|
60
|
+
return EOF unless match
|
|
61
|
+
|
|
62
|
+
advance
|
|
63
|
+
skip_borders
|
|
64
|
+
@body_lines = []
|
|
65
|
+
Header.new(match[1])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_body
|
|
69
|
+
collect_body_lines
|
|
70
|
+
body = Body.new(Border.strip_suffix(@body_lines.join))
|
|
71
|
+
@body_lines = nil
|
|
72
|
+
body
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def collect_body_lines
|
|
76
|
+
until finished?
|
|
77
|
+
return found_end_after_border if border_before_end?
|
|
78
|
+
return found_end_inline if inline_end_marker?
|
|
79
|
+
|
|
80
|
+
@body_lines << raw_line
|
|
81
|
+
advance
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def border_before_end?
|
|
86
|
+
Border.recognize?(current_line) &&
|
|
87
|
+
peek_line&.match?(END_PATTERN)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inline_end_marker?
|
|
91
|
+
return false unless current_line&.match?(END_PATTERN)
|
|
92
|
+
|
|
93
|
+
@body_lines.empty? || @body_lines.last&.match?(Border::SUFFIX)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def found_end_inline
|
|
97
|
+
match = current_line.match(END_PATTERN)
|
|
98
|
+
@pending_footer = match[1]
|
|
99
|
+
advance
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def found_end_after_border
|
|
103
|
+
advance
|
|
104
|
+
end_match = current_line.match(END_PATTERN)
|
|
105
|
+
@pending_footer = end_match[1]
|
|
106
|
+
advance
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def emit_footer
|
|
110
|
+
path = @pending_footer
|
|
111
|
+
@pending_footer = nil
|
|
112
|
+
Footer.new(path)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module Codeball
|
|
4
|
+
# A filesystem context that writes entries to an output directory.
|
|
5
|
+
#
|
|
6
|
+
# Destination decorates a directory path with the ability to receive
|
|
7
|
+
# codeball entries. It owns path safety validation, parent directory
|
|
8
|
+
# creation, and dry-run simulation.
|
|
9
|
+
#
|
|
10
|
+
# Tracks outcomes internally and provides a summary when asked.
|
|
11
|
+
#
|
|
12
|
+
class Destination
|
|
13
|
+
DANGEROUS_PATTERNS = [
|
|
14
|
+
/\A\.\./,
|
|
15
|
+
%r{/\.\.},
|
|
16
|
+
%r{\A/},
|
|
17
|
+
/\A~/,
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :output_dir
|
|
21
|
+
|
|
22
|
+
def initialize(output_dir = ".", dry_run: false)
|
|
23
|
+
@output_dir = Pathname.new(output_dir).expand_path
|
|
24
|
+
@dry_run = dry_run ? true : false
|
|
25
|
+
@results = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dry_run? = @dry_run
|
|
29
|
+
|
|
30
|
+
def write(entry)
|
|
31
|
+
outcome = write_entry(entry)
|
|
32
|
+
@results << outcome
|
|
33
|
+
yield outcome if block_given?
|
|
34
|
+
outcome
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def summary(malformed: 0)
|
|
38
|
+
ExtractionSummary.new(@results, malformed: malformed)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def write_entry(entry)
|
|
44
|
+
return unsafe_result(entry) unless safe_path?(entry.path)
|
|
45
|
+
|
|
46
|
+
resolved = resolve(entry.path)
|
|
47
|
+
dry_run? ? dry_run_result(entry, resolved) : persist(entry, resolved)
|
|
48
|
+
rescue SystemCallError => e
|
|
49
|
+
ExtractionResult.new(path: entry.path, error: e.message, status: :failed)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def safe_path?(path)
|
|
53
|
+
return false if DANGEROUS_PATTERNS.any? { |pattern| path.match?(pattern) }
|
|
54
|
+
|
|
55
|
+
resolve(path).to_s.start_with?(output_dir.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve(path)
|
|
59
|
+
(output_dir / path).expand_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unsafe_result(entry)
|
|
63
|
+
ExtractionResult.new(path: entry.path, status: :unsafe)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dry_run_result(entry, resolved)
|
|
67
|
+
ExtractionResult.new(path: resolved, line_count: entry.line_count, status: :dry_run)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def persist(entry, resolved)
|
|
71
|
+
resolved.parent.mkpath
|
|
72
|
+
resolved.write(entry.contents)
|
|
73
|
+
ExtractionResult.new(path: resolved, line_count: entry.line_count, status: :written)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|