twinshot 1.2.1
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/LICENSE.txt +21 -0
- data/PLAN.md +107 -0
- data/README.md +179 -0
- data/Rakefile +12 -0
- data/exe/twinshot +6 -0
- data/lib/twinshot/adapters/binary.rb +21 -0
- data/lib/twinshot/adapters/html.rb +27 -0
- data/lib/twinshot/adapters/png.rb +128 -0
- data/lib/twinshot/adapters/txt.rb +27 -0
- data/lib/twinshot/adapters/xml.rb +28 -0
- data/lib/twinshot/adapters.rb +105 -0
- data/lib/twinshot/cli.rb +503 -0
- data/lib/twinshot/compare.rb +249 -0
- data/lib/twinshot/compare_worker.rb +47 -0
- data/lib/twinshot/config.rb +212 -0
- data/lib/twinshot/logo.svg +8 -0
- data/lib/twinshot/opener/darwin.rb +18 -0
- data/lib/twinshot/opener/linux.rb +19 -0
- data/lib/twinshot/opener/windows.rb +20 -0
- data/lib/twinshot/opener.rb +59 -0
- data/lib/twinshot/progress.rb +104 -0
- data/lib/twinshot/report.rb +600 -0
- data/lib/twinshot/soft_check.rb +19 -0
- data/lib/twinshot/tree.rb +31 -0
- data/lib/twinshot/version.rb +5 -0
- data/lib/twinshot.rb +18 -0
- data/logo.svg +100 -0
- data/sig/twinshot.rbs +4 -0
- metadata +130 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "diff/lcs"
|
|
4
|
+
|
|
5
|
+
module Twinshot
|
|
6
|
+
# Per-format comparison strategies. An adapter is registered for a set of
|
|
7
|
+
# extensions and responds to .call(rel, oracle_file, current_file, ctx),
|
|
8
|
+
# returning a result hash (string keys — results travel through JSON in
|
|
9
|
+
# worker processes):
|
|
10
|
+
#
|
|
11
|
+
# { "kind" => "png", "status" => "pass"|"fail", "detail" => "...",
|
|
12
|
+
# "image" => true|nil, ...format-specific keys }
|
|
13
|
+
#
|
|
14
|
+
# ctx carries max_diff_ratio and report_dir.
|
|
15
|
+
module Adapters
|
|
16
|
+
Ctx = Struct.new(:max_diff_ratio, :report_dir, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
@registry = {}
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :registry
|
|
21
|
+
|
|
22
|
+
def register(adapter, *extensions)
|
|
23
|
+
extensions.each { |ext| @registry[ext.to_s.downcase] = adapter }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# nil means "no adapter" — an unknown file is a failure by design.
|
|
27
|
+
def for(filename)
|
|
28
|
+
@registry[File.extname(filename).delete_prefix(".").downcase]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Shared structural comparison (used by html and xml adapters): two
|
|
32
|
+
# canonical line arrays → a result hash. Pass when structures identical.
|
|
33
|
+
def structural_result(kind, a_lines, b_lines)
|
|
34
|
+
return { "kind" => kind, "status" => "pass", "detail" => "structure identical" } if a_lines == b_lines
|
|
35
|
+
|
|
36
|
+
changed, entries = line_diff(a_lines, b_lines)
|
|
37
|
+
{ "kind" => kind, "status" => "fail", "detail" => "#{changed} structural line(s) differ",
|
|
38
|
+
"diff_lines" => changed, "diff" => entries }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Shared canonical DOM serialization: comments dropped, whitespace-only
|
|
42
|
+
# text nodes dropped, runs of whitespace in text squashed to one space,
|
|
43
|
+
# attributes sorted by name. One line per element/text node, indented by
|
|
44
|
+
# depth. downcase_names: true for html (case-insensitive markup),
|
|
45
|
+
# false for xml (case-sensitive).
|
|
46
|
+
def canonical_dom_lines(doc, downcase_names:)
|
|
47
|
+
lines = []
|
|
48
|
+
walk_dom(doc, 0, lines, downcase_names)
|
|
49
|
+
lines
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def walk_dom(node, depth, lines, downcase_names)
|
|
53
|
+
node.children.each do |child|
|
|
54
|
+
case child.type
|
|
55
|
+
when Nokogiri::XML::Node::TEXT_NODE
|
|
56
|
+
text = child.content.gsub(/\s+/, " ").strip
|
|
57
|
+
lines << format("%s%s", " " * depth, text) unless text.empty?
|
|
58
|
+
when Nokogiri::XML::Node::ELEMENT_NODE
|
|
59
|
+
name = downcase_names ? child.name.downcase : child.name
|
|
60
|
+
attrs = child.attributes.sort_by { |name, _| name }
|
|
61
|
+
.map { |name, attr| %( #{name}="#{escape_attr(attr.value)}") }.join
|
|
62
|
+
lines << format("%s<%s%s>", " " * depth, name, attrs)
|
|
63
|
+
walk_dom(child, depth + 1, lines, downcase_names)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def escape_attr(value)
|
|
69
|
+
value.gsub("&", "&").gsub('"', """)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Shared line diff over two line arrays (used by txt and the structural
|
|
73
|
+
# html/xml adapters).
|
|
74
|
+
# Returns [changed_line_count, [ { "t" => "same"|"del"|"add", "text" }, ... ]].
|
|
75
|
+
def line_diff(a_lines, b_lines, cap: 400)
|
|
76
|
+
changed = 0
|
|
77
|
+
entries = []
|
|
78
|
+
Diff::LCS.sdiff(a_lines, b_lines).each do |op|
|
|
79
|
+
case op.action
|
|
80
|
+
when "="
|
|
81
|
+
entries << { "t" => "same", "text" => op.old_element }
|
|
82
|
+
when "-"
|
|
83
|
+
changed += 1
|
|
84
|
+
entries << { "t" => "del", "text" => op.old_element }
|
|
85
|
+
when "+"
|
|
86
|
+
changed += 1
|
|
87
|
+
entries << { "t" => "add", "text" => op.new_element }
|
|
88
|
+
when "!"
|
|
89
|
+
changed += 2
|
|
90
|
+
entries << { "t" => "del", "text" => op.old_element }
|
|
91
|
+
entries << { "t" => "add", "text" => op.new_element }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
entries << { "t" => "same", "text" => "… (diff truncated)" } if entries.size > cap
|
|
95
|
+
[changed, entries.first(cap)]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
require_relative "adapters/binary"
|
|
102
|
+
require_relative "adapters/txt"
|
|
103
|
+
require_relative "adapters/html"
|
|
104
|
+
require_relative "adapters/xml"
|
|
105
|
+
require_relative "adapters/png"
|
data/lib/twinshot/cli.rb
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require_relative "progress"
|
|
8
|
+
|
|
9
|
+
module Twinshot
|
|
10
|
+
# Command-line interface: twinshot help | generate | compare | review.
|
|
11
|
+
#
|
|
12
|
+
# Exit codes: 0 — all pass (or command succeeded), 1 — parity differences
|
|
13
|
+
# found (or a generate command failed), 2 — usage or config error.
|
|
14
|
+
class CLI
|
|
15
|
+
EXIT_OK = 0
|
|
16
|
+
EXIT_DIFF = 1
|
|
17
|
+
EXIT_ERROR = 2
|
|
18
|
+
|
|
19
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
20
|
+
@argv = argv.dup
|
|
21
|
+
@out = out
|
|
22
|
+
@err = err
|
|
23
|
+
@config_path = nil
|
|
24
|
+
@jobs = nil
|
|
25
|
+
@json = false
|
|
26
|
+
@open = nil
|
|
27
|
+
@helped = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Words bash feeds to the hidden completion helper. `completion install`
|
|
31
|
+
# registers the `complete -C` one-liner that triggers this path.
|
|
32
|
+
COMMANDS = %w[compare generate review help completion].freeze
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
# `complete -C twinshot twinshot` protocol: bash runs us with
|
|
36
|
+
# COMP_LINE/COMP_POINT in the env and argv ["twinshot", cur, prev].
|
|
37
|
+
# Intercepted first — before option parsing — so a half-typed option
|
|
38
|
+
# can't raise. Hidden: never listed anywhere.
|
|
39
|
+
return complete_c if completer_invocation?
|
|
40
|
+
|
|
41
|
+
# Same candidates from words on the command line, for manual use and
|
|
42
|
+
# tests: twinshot __complete WORD... (last word = one being completed).
|
|
43
|
+
return complete(@argv.drop(1)) if @argv.first == "__complete"
|
|
44
|
+
|
|
45
|
+
global_parser.order!(@argv)
|
|
46
|
+
return version_text if @version
|
|
47
|
+
|
|
48
|
+
if @helped
|
|
49
|
+
@out.puts global_parser
|
|
50
|
+
return EXIT_OK
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
command = @argv.shift
|
|
54
|
+
case command
|
|
55
|
+
when "compare" then compare
|
|
56
|
+
when "generate" then generate
|
|
57
|
+
when "review" then review
|
|
58
|
+
when "completion" then completion
|
|
59
|
+
when "help" then help
|
|
60
|
+
when nil
|
|
61
|
+
@err.puts global_parser
|
|
62
|
+
EXIT_ERROR
|
|
63
|
+
else
|
|
64
|
+
@err.puts "twinshot: unknown command #{command.inspect}"
|
|
65
|
+
@err.puts global_parser
|
|
66
|
+
EXIT_ERROR
|
|
67
|
+
end
|
|
68
|
+
rescue OptionParser::ParseError, Config::Error, Compare::Error => e
|
|
69
|
+
@err.puts "twinshot: #{e.message}"
|
|
70
|
+
EXIT_ERROR
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def version_text
|
|
76
|
+
@out.puts "twinshot #{Twinshot::VERSION}"
|
|
77
|
+
EXIT_OK
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def global_parser
|
|
81
|
+
OptionParser.new do |o|
|
|
82
|
+
o.banner = "twinshot — 1:1 artifact parity: oracle vs current"
|
|
83
|
+
o.separator ""
|
|
84
|
+
o.separator "Usage: twinshot [options] COMMAND [options]"
|
|
85
|
+
o.separator ""
|
|
86
|
+
o.separator "Commands:"
|
|
87
|
+
o.separator " compare [GROUP|all] compare every group or just one (oracle vs"
|
|
88
|
+
o.separator " current trees), write reports"
|
|
89
|
+
o.separator " generate [GROUP:SIDE] run generate commands: dogs:oracle, a bare"
|
|
90
|
+
o.separator " side (default group), a group, or 'all'"
|
|
91
|
+
o.separator " review [GROUP|all] regenerate a group (or every group), then"
|
|
92
|
+
o.separator " compare it — generate and compare in one run"
|
|
93
|
+
o.separator " completion install install bash completion (works on the next TAB)"
|
|
94
|
+
o.separator " help [COMMAND] show help"
|
|
95
|
+
o.separator ""
|
|
96
|
+
o.separator "Options:"
|
|
97
|
+
o.on("-c", "--config PATH", "Path to twinshot.yml (default: #{Config::DEFAULT_FILENAME})") do |v|
|
|
98
|
+
@config_path = v
|
|
99
|
+
end
|
|
100
|
+
o.on("-V", "--version", "Show version") { @version = true }
|
|
101
|
+
o.on("-h", "--help", "Show this help") { @helped = true }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def compare_parser
|
|
106
|
+
OptionParser.new do |o|
|
|
107
|
+
o.banner = "Usage: twinshot compare [GROUP|all] [-c PATH] [-j N] [--json] [--[no-]open]"
|
|
108
|
+
o.separator ""
|
|
109
|
+
o.separator "Compares the oracle and current trees: structure first, then every"
|
|
110
|
+
o.separator "file through a format adapter (png/html/txt/binary). Unknown files"
|
|
111
|
+
o.separator "and structure mismatches are failures. With GROUP only that group"
|
|
112
|
+
o.separator "runs and only its report is written; 'all' (the default) — every"
|
|
113
|
+
o.separator "configured group. Each compared group gets its own folder in"
|
|
114
|
+
o.separator "twinshot-report/ (report_for_human.html + report_for_agent.md"
|
|
115
|
+
o.separator "+ summary.json + diffs/)."
|
|
116
|
+
o.separator ""
|
|
117
|
+
o.separator "Exit status: 0 — all pass, 1 — differences found, 2 — config error."
|
|
118
|
+
o.separator "soft_check entries are exact paths or globs (code/x/*);"
|
|
119
|
+
o.separator "their content diffs and missing/extra structure results"
|
|
120
|
+
o.separator "downgrade to warnings: shown in the reports, never change"
|
|
121
|
+
o.separator "the exit code."
|
|
122
|
+
o.separator ""
|
|
123
|
+
o.separator "Options:"
|
|
124
|
+
add_compare_options(o)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def review_parser
|
|
129
|
+
OptionParser.new do |o|
|
|
130
|
+
o.banner = "Usage: twinshot review [GROUP|all] [-c PATH] [-j N] [--json] [--[no-]open]"
|
|
131
|
+
o.separator ""
|
|
132
|
+
o.separator "Generate and compare in one run: regenerates both sides of"
|
|
133
|
+
o.separator "every selected group (a tree without a generate command is"
|
|
134
|
+
o.separator "taken as it is), then compares exactly like `compare` and"
|
|
135
|
+
o.separator "writes the same reports. A failed generate command fails"
|
|
136
|
+
o.separator "the run without comparing — reports over stale trees would"
|
|
137
|
+
o.separator "be misleading."
|
|
138
|
+
o.separator ""
|
|
139
|
+
o.separator "Exit status: as compare (0 — all pass, 1 — differences or a"
|
|
140
|
+
o.separator "failed generate command, 2 — config error)."
|
|
141
|
+
o.separator ""
|
|
142
|
+
o.separator "Options:"
|
|
143
|
+
add_compare_options(o)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The options shared by compare and review.
|
|
148
|
+
def add_compare_options(opts)
|
|
149
|
+
opts.on("-c", "--config PATH", "Path to twinshot.yml (default: #{Config::DEFAULT_FILENAME})") do |v|
|
|
150
|
+
@config_path = v
|
|
151
|
+
end
|
|
152
|
+
opts.on("-j", "--jobs N", Integer, "Worker processes (default: auto, capped at #{Compare::MAX_WORKERS})") do |v|
|
|
153
|
+
@jobs = v
|
|
154
|
+
end
|
|
155
|
+
opts.on("--json", "Print the machine summary to stdout") { @json = true }
|
|
156
|
+
opts.on("--[no-]open", "Open the report in the default browser",
|
|
157
|
+
"(default: on when stdout is a tty)") { |v| @open = v }
|
|
158
|
+
opts.on("-h", "--help", "Show this help") { @helped = true }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def generate_parser
|
|
162
|
+
OptionParser.new do |o|
|
|
163
|
+
o.banner = "Usage: twinshot generate [GROUP|all|SIDE][:oracle|current]"
|
|
164
|
+
o.separator ""
|
|
165
|
+
o.separator "Runs the generate commands configured in twinshot.yml, with"
|
|
166
|
+
o.separator "the config's directory as cwd. The selector picks what to"
|
|
167
|
+
o.separator "regenerate:"
|
|
168
|
+
o.separator " dogs:oracle, dogs:current one tree of a named group"
|
|
169
|
+
o.separator " oracle, current the default group's tree (no"
|
|
170
|
+
o.separator " namespace means default, as in"
|
|
171
|
+
o.separator " default:oracle)"
|
|
172
|
+
o.separator " dogs a whole group: both trees"
|
|
173
|
+
o.separator " all, all:oracle every group: both trees, or one"
|
|
174
|
+
o.separator " side of every group"
|
|
175
|
+
o.separator "No selector at all: every configured tree — all groups,"
|
|
176
|
+
o.separator "oracle then current — announced as a list and regenerated"
|
|
177
|
+
o.separator "sequentially. Optional: a compare run just takes the two"
|
|
178
|
+
o.separator "directories as they are."
|
|
179
|
+
o.separator ""
|
|
180
|
+
o.separator "Exit status: 0 — all commands succeeded, 1 — a command failed,"
|
|
181
|
+
o.separator "2 — nothing configured / usage error."
|
|
182
|
+
o.separator ""
|
|
183
|
+
o.separator "Options:"
|
|
184
|
+
o.on("-c", "--config PATH", "Path to twinshot.yml (default: #{Config::DEFAULT_FILENAME})") do |v|
|
|
185
|
+
@config_path = v
|
|
186
|
+
end
|
|
187
|
+
o.on("-h", "--help", "Show this help") { @helped = true }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def compare
|
|
192
|
+
compare_parser.order!(@argv)
|
|
193
|
+
if @helped
|
|
194
|
+
@out.puts compare_parser
|
|
195
|
+
return EXIT_OK
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
config = Config.load(@config_path)
|
|
199
|
+
run_compare(config, select_groups(config))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The compare run shared by compare and review: compares the groups,
|
|
203
|
+
# writes the reports, prints the summary, opens the report. Returns the
|
|
204
|
+
# exit code (0 — all pass, 1 — differences found).
|
|
205
|
+
def run_compare(config, groups)
|
|
206
|
+
progress = Progress.new(out: @out, quiet: @json)
|
|
207
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
208
|
+
summary = Compare.new(config).run(jobs: @jobs, progress: progress, groups: groups)
|
|
209
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
210
|
+
Report.new(config, summary).write
|
|
211
|
+
@out.puts JSON.pretty_generate(summary) if @json
|
|
212
|
+
progress.summary(summary, config.report_root, elapsed: elapsed)
|
|
213
|
+
open_report(config, groups)
|
|
214
|
+
summary["failed"].zero? ? EXIT_OK : EXIT_DIFF
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The report opens in the browser after a compare run: by default when
|
|
218
|
+
# stdout is a tty (so CI and --json stay quiet), --open forces it,
|
|
219
|
+
# --no-open turns it off. The first compared group's report is the one
|
|
220
|
+
# opened. A failed launch is a warning, not a parity result — it never
|
|
221
|
+
# changes the exit code.
|
|
222
|
+
def open_report(config, groups)
|
|
223
|
+
should_open = @open.nil? ? !@json && @out.tty? : @open
|
|
224
|
+
return unless should_open
|
|
225
|
+
|
|
226
|
+
Opener.open(File.join(config.report_dir(groups.first), Config::HUMAN_REPORT))
|
|
227
|
+
rescue Opener::Error => e
|
|
228
|
+
@err.puts "twinshot: #{e.message}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The positional GROUP|all selector shared by compare and generate:
|
|
232
|
+
# 'all' or nothing — every configured group, otherwise the named one
|
|
233
|
+
# (Config#group raises on an unknown name).
|
|
234
|
+
def select_groups(config)
|
|
235
|
+
selector = @argv.shift
|
|
236
|
+
return config.groups if selector.nil? || selector == "all"
|
|
237
|
+
|
|
238
|
+
[config.group(selector)]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The generate selector [GROUP|all|SIDE][:SIDE]. 'dogs:oracle' — one
|
|
242
|
+
# tree of a named group; a bare side ('oracle') — the default group's
|
|
243
|
+
# tree, no namespace means default ('default:oracle' is the same thing);
|
|
244
|
+
# 'dogs' — both trees of one group; 'all', 'all:oracle' or nothing —
|
|
245
|
+
# every group, both trees or one side. Group names can never be sides
|
|
246
|
+
# (reserved in Config), so a bare side is unambiguous.
|
|
247
|
+
def generate_selection(config, selector)
|
|
248
|
+
return [config.groups, nil] if selector.nil? || selector == "all"
|
|
249
|
+
|
|
250
|
+
name, side = selector.split(":", 2)
|
|
251
|
+
return [[config.group(Config::DEFAULT_GROUP)], name] if side.nil? && Config::SIDES.include?(name)
|
|
252
|
+
|
|
253
|
+
unless side.nil? || Config::SIDES.include?(side)
|
|
254
|
+
raise Config::Error, "unknown side: #{side.inspect} in #{selector.inspect} (expected oracle or current)"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
groups = if name == "all" then config.groups
|
|
258
|
+
elsif name.empty? then [config.group(Config::DEFAULT_GROUP)]
|
|
259
|
+
else [config.group(name)]
|
|
260
|
+
end
|
|
261
|
+
[groups, side]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def generate
|
|
265
|
+
generate_parser.order!(@argv)
|
|
266
|
+
if @helped
|
|
267
|
+
@out.puts generate_parser
|
|
268
|
+
return EXIT_OK
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
config = Config.load(@config_path)
|
|
272
|
+
groups, side = generate_selection(config, @argv.shift)
|
|
273
|
+
if (extra = @argv.shift)
|
|
274
|
+
@err.puts "twinshot: unexpected argument #{extra.inspect}"
|
|
275
|
+
@err.puts generate_parser
|
|
276
|
+
return EXIT_ERROR
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
targets = generate_targets(groups, side)
|
|
280
|
+
if targets.empty?
|
|
281
|
+
what = side ? "#{side}.generate" : "generate"
|
|
282
|
+
scope = groups.size == 1 ? "for group #{groups.first.name}" : "in any selected group"
|
|
283
|
+
@err.puts "twinshot: no #{what} configured #{scope} in #{File.basename(config.path)}"
|
|
284
|
+
return EXIT_ERROR
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
run_generate(config, targets) ? EXIT_OK : EXIT_DIFF
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The review command is generate + compare in one run: both sides of the selected
|
|
291
|
+
# groups regenerate first (a tree without a generate command is taken as
|
|
292
|
+
# it is — nothing to rebuild is not an error here, the compare still
|
|
293
|
+
# runs), then the compare. A failed generate command fails the whole run
|
|
294
|
+
# with no compare and no reports.
|
|
295
|
+
def review
|
|
296
|
+
review_parser.order!(@argv)
|
|
297
|
+
if @helped
|
|
298
|
+
@out.puts review_parser
|
|
299
|
+
return EXIT_OK
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
config = Config.load(@config_path)
|
|
303
|
+
groups = select_groups(config)
|
|
304
|
+
targets = generate_targets(groups)
|
|
305
|
+
return run_compare(config, groups) if targets.empty?
|
|
306
|
+
|
|
307
|
+
run_generate(config, targets) ? run_compare(config, groups) : EXIT_DIFF
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Trees (group, side, command) with a generate command, in oracle-then-
|
|
311
|
+
# current order. Trees without one are simply skipped.
|
|
312
|
+
def generate_targets(groups, side = nil)
|
|
313
|
+
sides = side ? [side] : Config::SIDES
|
|
314
|
+
groups.flat_map do |g|
|
|
315
|
+
sides.filter_map do |s|
|
|
316
|
+
command = g.generate_command(s)
|
|
317
|
+
[g, s, command] if command
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Says the full list of trees about to be regenerated first, then runs
|
|
323
|
+
# the commands one by one from the config's directory; a failing command
|
|
324
|
+
# doesn't stop the rest. true — all succeeded.
|
|
325
|
+
def run_generate(config, targets)
|
|
326
|
+
@out.puts "Generate:"
|
|
327
|
+
targets.each { |group, tree, _command| @out.puts "- #{group.name}/#{tree}" }
|
|
328
|
+
targets.map do |group, tree, command|
|
|
329
|
+
@out.puts "twinshot: [#{group.name}/#{tree}] #{command}"
|
|
330
|
+
system(command, chdir: config.root)
|
|
331
|
+
end.all?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# The `complete -C` contract: bash passes the whole line in COMP_LINE and
|
|
335
|
+
# the cursor in COMP_POINT; argv is ["twinshot", cur, prev]. Split the
|
|
336
|
+
# line up to the cursor the way bash word-splits it, drop the program
|
|
337
|
+
# name, make sure a word is being completed (append "" after a trailing
|
|
338
|
+
# space), and reuse the shared candidate logic.
|
|
339
|
+
def complete_c
|
|
340
|
+
line = ENV.fetch("COMP_LINE", "")
|
|
341
|
+
before = line[0, [ENV.fetch("COMP_POINT", line.length).to_i, line.length].min]
|
|
342
|
+
words = begin
|
|
343
|
+
Shellwords.split(before)
|
|
344
|
+
rescue ArgumentError # unbalanced quote mid-word while typing
|
|
345
|
+
before.split
|
|
346
|
+
end
|
|
347
|
+
words = words.drop(1)
|
|
348
|
+
words << "" if before.empty? || before.end_with?(" ")
|
|
349
|
+
complete(words)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def completer_invocation?
|
|
353
|
+
ENV.key?("COMP_LINE") && @argv.size == 3 && @argv.first == "twinshot"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Completion: prints the candidates for the words typed so far, one per
|
|
357
|
+
# line. `words` is everything after `twinshot`; the last word is the one
|
|
358
|
+
# being completed (possibly empty), the rest are what the user already
|
|
359
|
+
# typed. We filter by prefix ourselves, so callers (the complete -C
|
|
360
|
+
# protocol, `__complete`, tests) get the final list as-is.
|
|
361
|
+
def complete(words)
|
|
362
|
+
current = words.last.to_s
|
|
363
|
+
typed = words[0...-1]
|
|
364
|
+
candidates_for(typed, current).select { |c| c.start_with?(current) }.each { |c| @out.puts c }
|
|
365
|
+
EXIT_OK
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def candidates_for(typed, current = "")
|
|
369
|
+
case completion_command(typed)
|
|
370
|
+
when nil then COMMANDS + parser_flags(global_parser)
|
|
371
|
+
when "help" then COMMANDS
|
|
372
|
+
when "completion" then %w[install]
|
|
373
|
+
when "compare", "review"
|
|
374
|
+
# Only the first positional is a group name (or 'all'); after that —
|
|
375
|
+
# and for a bogus word — just the options.
|
|
376
|
+
positionals(typed).empty? ? group_candidates(typed) | parser_flags(compare_parser) : parser_flags(compare_parser)
|
|
377
|
+
when "generate"
|
|
378
|
+
flags = parser_flags(generate_parser)
|
|
379
|
+
if positionals(typed).empty?
|
|
380
|
+
# Half-typed 'GROUP:' completes the sides; otherwise group names,
|
|
381
|
+
# 'all' and the bare sides (a bare side = the default group).
|
|
382
|
+
return side_completions(current) if current.include?(":")
|
|
383
|
+
|
|
384
|
+
group_candidates(typed) | flags | Config::SIDES
|
|
385
|
+
else
|
|
386
|
+
flags
|
|
387
|
+
end
|
|
388
|
+
else
|
|
389
|
+
[]
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# 'dogs:<TAB>' / 'all:<TAB>' → the side spellings on that prefix.
|
|
394
|
+
def side_completions(current)
|
|
395
|
+
prefix = current[0..current.index(":")]
|
|
396
|
+
["#{prefix}oracle", "#{prefix}current"]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# The command word, skipping global flags and their values that may
|
|
400
|
+
# precede it (`twinshot -c x.yml generate ...`). nil — no command yet.
|
|
401
|
+
def completion_command(typed)
|
|
402
|
+
walk_flags(typed) { |word, _i| return word }
|
|
403
|
+
nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Positional words after the command, with flags and their values
|
|
407
|
+
# (-c/--config) skipped.
|
|
408
|
+
def positionals(typed)
|
|
409
|
+
command = completion_command(typed)
|
|
410
|
+
return [] unless command
|
|
411
|
+
|
|
412
|
+
rest = typed.drop(typed.index(command) + 1)
|
|
413
|
+
found = []
|
|
414
|
+
walk_flags(rest) { |word, _i| found << word }
|
|
415
|
+
found
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def walk_flags(words)
|
|
419
|
+
i = 0
|
|
420
|
+
while i < words.size
|
|
421
|
+
case words[i]
|
|
422
|
+
when "-c", "--config" then i += 2
|
|
423
|
+
when /\A-/ then i += 1
|
|
424
|
+
else yield words[i], i
|
|
425
|
+
i += 1
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# The flag spellings straight out of the parser, so completion can never
|
|
431
|
+
# drift from the real options. summarize() renders each switch as
|
|
432
|
+
# " -c, --config PATH description" — keep option lines, take the
|
|
433
|
+
# flag column (first run of the line), drop trailing commas and argument
|
|
434
|
+
# names (PATH, N), and expand --[no-]open into both spellings.
|
|
435
|
+
def parser_flags(parser)
|
|
436
|
+
parser.summarize.map(&:strip)
|
|
437
|
+
.select { |line| line.start_with?("-") }
|
|
438
|
+
.flat_map { |line| line.split(/\s{2,}/).first.to_s.split }
|
|
439
|
+
.map { |token| token.chomp(",") }
|
|
440
|
+
.select { |token| token.start_with?("-") }
|
|
441
|
+
.flat_map do |flag|
|
|
442
|
+
if flag =~ /\A--\[no-\](\S+)\z/
|
|
443
|
+
["--#{Regexp.last_match(1)}",
|
|
444
|
+
"--no-#{Regexp.last_match(1)}"]
|
|
445
|
+
else
|
|
446
|
+
[flag]
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Group names for the first positional of compare/generate, read from
|
|
452
|
+
# the config the user pointed at (or the default one), plus 'all'. Any
|
|
453
|
+
# config problem — missing file, bad YAML — quietly leaves `all` as the
|
|
454
|
+
# only candidate.
|
|
455
|
+
def group_candidates(typed)
|
|
456
|
+
flag = typed.rindex("-c") || typed.rindex("--config")
|
|
457
|
+
Config.load(flag && typed[flag + 1]).groups.map(&:name) + ["all"]
|
|
458
|
+
rescue Config::Error
|
|
459
|
+
["all"]
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# `twinshot completion install`: writes the `complete -C` one-liner into
|
|
463
|
+
# the bash-completion user dir, which bash-completion lazy-loads on the
|
|
464
|
+
# first TAB — no .bashrc edits, nothing sourced manually. Uninstall is
|
|
465
|
+
# just deleting the file.
|
|
466
|
+
def completion
|
|
467
|
+
action = @argv.shift
|
|
468
|
+
case action
|
|
469
|
+
when "install"
|
|
470
|
+
dir = File.join(data_home, "bash-completion", "completions")
|
|
471
|
+
target = File.join(dir, "twinshot")
|
|
472
|
+
FileUtils.mkdir_p(dir)
|
|
473
|
+
File.write(target, "complete -C twinshot twinshot\n")
|
|
474
|
+
@out.puts "Wrote #{target}"
|
|
475
|
+
@out.puts "Completion is live in a new shell (loaded on your first TAB)."
|
|
476
|
+
EXIT_OK
|
|
477
|
+
else
|
|
478
|
+
@err.puts "twinshot: expected 'completion install'"
|
|
479
|
+
EXIT_ERROR
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def data_home
|
|
484
|
+
xdg = ENV["XDG_DATA_HOME"]
|
|
485
|
+
xdg&.start_with?("/") ? xdg : File.join(Dir.home, ".local", "share")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def help
|
|
489
|
+
topic = @argv.shift
|
|
490
|
+
text = case topic
|
|
491
|
+
when nil then global_parser.to_s
|
|
492
|
+
when "compare" then compare_parser.to_s
|
|
493
|
+
when "generate" then generate_parser.to_s
|
|
494
|
+
when "review" then review_parser.to_s
|
|
495
|
+
else
|
|
496
|
+
@err.puts "twinshot: unknown help topic #{topic.inspect} (expected compare, generate or review)"
|
|
497
|
+
return EXIT_ERROR
|
|
498
|
+
end
|
|
499
|
+
@out.puts text
|
|
500
|
+
EXIT_OK
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|