asciidoc-pubkit 0.6.2 → 0.8.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.
@@ -8,10 +8,13 @@ module AsciidocPubkit
8
8
  Commands:
9
9
  writing criteria Print the shared Japanese prose criteria
10
10
  writing prompt Generate a Japanese technical writing prompt
11
- review scan FILE Collect Japanese prose and review candidates
11
+ review scan FILE Collect Japanese prose or headings and review candidates
12
12
  review score FILE Score Japanese prose; optionally use a local AI CLI
13
13
  review prompt SESSION Generate an English review prompt with Japanese source excerpts
14
14
  review verify SESSION Compare edited sources with the saved baseline
15
+ replace check FILE Report prose-only replacement candidates
16
+ replace diff FILE Preview prose replacements as a unified diff
17
+ replace apply FILE Validate and apply explicit replacement rules
15
18
 
16
19
  Options:
17
20
  --help Show help (also supported for each command)
@@ -19,7 +22,7 @@ module AsciidocPubkit
19
22
 
20
23
  Use --lang ja with writing commands and review commands. Other review and
21
24
  writing languages are not supported yet and return an error.
22
- No command edits manuscripts. review score --agent explicitly invokes a
25
+ Only replace apply edits manuscripts. review score --agent explicitly invokes a
23
26
  local Codex or Claude CLI, which may connect to its configured AI service.
24
27
  TEXT
25
28
 
@@ -29,22 +32,30 @@ module AsciidocPubkit
29
32
  out.puts VERSION
30
33
  return 0
31
34
  end
32
- if args.empty? || args == ['--help'] || args == ['-h'] || %w[review writing].any? { |group| args == [group, '--help'] }
35
+ if args.empty? || args == ['--help'] || args == ['-h'] || %w[review writing replace].any? { |group| args == [group, '--help'] }
33
36
  out.puts HELP
34
37
  return 0
35
38
  end
36
39
  group, command = args.shift(2)
37
40
  valid = (group == 'review' && %w[scan score prompt verify].include?(command)) ||
38
- (group == 'writing' && %w[criteria prompt].include?(command))
41
+ (group == 'writing' && %w[criteria prompt].include?(command)) ||
42
+ (group == 'replace' && %w[check diff apply].include?(command))
39
43
  raise Error, 'Unknown command. Use --help.' unless valid
40
44
  options = { attributes: {} }
41
45
  parser = OptionParser.new do |opts|
42
- input_name = group == 'writing' ? '' : (%w[scan score].include?(command) ? ' FILE' : ' SESSION')
46
+ input_name = group == 'writing' ? '' : ((group == 'replace' || %w[scan score].include?(command)) ? ' FILE' : ' SESSION')
43
47
  opts.banner = "Usage: asciidoc-pubkit #{group} #{command} [options]#{input_name}"
44
- opts.on('-o', '--output PATH', group == 'review' && command == 'scan' ? 'New session directory (default: .pubkit/review)' : 'New output file (default: standard output)') { |v| options[:output] = v }
48
+ opts.on('-o', '--output PATH', group == 'review' && command == 'scan' ? 'New session directory (default: .pubkit/review for prose, .pubkit/headings for headings)' : 'New output file (default: standard output)') { |v| options[:output] = v }
45
49
  opts.on('--lang LANG', 'Language (ja only in this release)') { |v| options[:language] = v }
46
- if group == 'review' && %w[scan score].include?(command)
50
+ if group == 'replace'
51
+ opts.on('--rules FILE', 'Required prh-format replacement rules (limited compatibility)') { |v| options[:rules] = v }
52
+ opts.on('--base-dir DIR', 'Set the Asciidoctor base directory') { |v| options[:base_dir] = v }
53
+ opts.on('--config FILE', 'Use review parsing configuration and exclusions') { |v| options[:config] = v }
54
+ opts.on('--only FILE', 'Select one included file') { |v| options[:only] = v }
55
+ elsif group == 'review' && %w[scan score].include?(command)
47
56
  if command == 'scan'
57
+ opts.on('--scope SCOPE', 'prose (default) or headings; separate review sessions') { |v| options[:scope] = v }
58
+ opts.on('--heading-rules FILE', 'Replace default heading rules with a YAML rule set') { |v| options[:heading_rules] = v }
48
59
  opts.on('-y', '--yes', 'Answer yes to replacement confirmation') { options[:yes] = true }
49
60
  opts.on('--no-input', 'Never prompt; fail on existing output unless --yes') { options[:no_input] = true }
50
61
  else
@@ -83,6 +94,15 @@ module AsciidocPubkit
83
94
  end
84
95
  raise Error, 'Exactly one input is required. Use --help.' unless args.length == 1
85
96
  Language.validate!(options[:language], operation: 'review') if options[:language]
97
+ if group == 'replace'
98
+ raise Error, 'replace apply does not accept --output.' if command == 'apply' && options[:output]
99
+ replacement = Replacement.new(args.first, options)
100
+ content = command == 'diff' ? replacement.diff : replacement.report
101
+ replacement.apply if command == 'apply'
102
+ content += "Applied #{replacement.candidates.length} replacements.\n" if command == 'apply'
103
+ output(content, options[:output], out)
104
+ return command == 'check' && !replacement.candidates.empty? ? 1 : 0
105
+ end
86
106
  if command == 'score'
87
107
  if options[:output] && (File.exist?(options[:output]) || File.symlink?(options[:output]))
88
108
  raise Error, "Output already exists: #{options[:output]}"
@@ -106,7 +126,11 @@ module AsciidocPubkit
106
126
  true
107
127
  end
108
128
  end
109
- out.puts "Scanned #{result['paragraphs']} paragraphs; found #{result['findings']} review candidates."
129
+ if result['scope'] == 'headings'
130
+ out.puts "Scanned #{result['headings']} headings; found #{result['findings']} review candidates."
131
+ else
132
+ out.puts "Scanned #{result['paragraphs']} paragraphs; found #{result['findings']} review candidates."
133
+ end
110
134
  out.puts "Tokenizer: #{result['tokenizer']}#{result['tokenizer'] == 'literal' ? ' (limited phrase matching; no morphological analysis)' : ' (UTF-8 IPADIC)'}"
111
135
  out.puts "Coverage notices: #{result['coverage_notices']}. See document.json for limitations."
112
136
  out.puts "Review session: #{result['session']}"
@@ -2,16 +2,19 @@
2
2
 
3
3
  module AsciidocPubkit
4
4
  class Document
5
- attr_reader :sources, :paragraphs, :coverage, :diagnostics, :structure
5
+ attr_reader :sources, :paragraphs, :coverage, :diagnostics, :structure, :headings, :outline, :scope
6
6
 
7
7
  def initialize(entry, settings, only: nil)
8
8
  @entry = File.realpath(entry)
9
9
  @root = File.realpath(settings.fetch('base_dir'))
10
10
  @sources = {}
11
11
  @paragraphs = []
12
+ @headings = []
13
+ @outline = []
12
14
  @coverage = []
13
15
  @diagnostics = []
14
16
  @settings = settings
17
+ @scope = settings.fetch('scope', 'prose')
15
18
  @only = only && File.realpath(only)
16
19
  capture(@entry)
17
20
  logger = Asciidoctor::MemoryLogger.new
@@ -43,6 +46,23 @@ module AsciidocPubkit
43
46
  node.id, (node.title if node.respond_to?(:title?) && node.title?),
44
47
  (node.lines if %i[listing literal pass].include?(node.context))]
45
48
  end
49
+ @heading_lines = {}
50
+ @source_lines.each do |path, lines|
51
+ lines.each_with_index do |line, offset|
52
+ match = line.match(/\A(={1,6}[ \t]+)(.*?)([ \t]*)\z/)
53
+ @heading_lines[[path, offset + 1]] = match if match
54
+ end
55
+ end
56
+ @section_indexes = nodes.each_with_index.to_h
57
+ nodes.each_with_index { |node, index| collect_heading(node, index) if node.context == :section && node != doc.header }
58
+ # One physical title reused by includes cannot be edited independently.
59
+ reused = @headings.group_by { |h| [h['file'], h['line']] }.select { |_, group| group.length > 1 }
60
+ @headings.reject! do |heading|
61
+ next false unless reused.key?([heading['file'], heading['line']])
62
+ @coverage << { 'context' => 'heading', 'file' => heading['file'], 'line' => heading['line'],
63
+ 'reason' => 'The source title is reused by multiple sections and cannot be edited independently.' }
64
+ true
65
+ end
46
66
  nodes.each { |node| collect(node) }
47
67
  ensure
48
68
  Asciidoctor::LoggerManager.logger = previous_logger
@@ -64,11 +84,39 @@ module AsciidocPubkit
64
84
  end
65
85
 
66
86
  def to_h
67
- { 'paragraphs' => paragraphs, 'coverage' => coverage, 'structure' => structure }
87
+ { 'paragraphs' => paragraphs, 'headings' => headings, 'outline' => outline, 'coverage' => coverage, 'structure' => structure }
68
88
  end
69
89
 
70
90
  private
71
91
 
92
+ # Only ATX section titles that match original source text are editable.
93
+ # Converted inline titles, attribute substitutions and old-style titles stay protected.
94
+ def collect_heading(node, index)
95
+ parent_index = @section_indexes[node.parent] if node.parent.context == :section
96
+ @outline << { 'index' => index, 'parent_index' => parent_index,
97
+ 'level' => node.level, 'section_id' => node.id, 'text' => node.title }
98
+ return unless @settings.fetch('scope', 'prose') == 'headings'
99
+ cursor = node.source_location
100
+ path, line = cursor && [cursor.file, cursor.lineno]
101
+ match = @heading_lines[[path, line]]
102
+ unless match && match[2] == node.title
103
+ @coverage << { 'context' => 'heading', 'file' => path, 'line' => line,
104
+ 'reason' => 'Source title could not be resolved unambiguously; only plain ATX section titles are editable.', 'text' => node.title }
105
+ return
106
+ end
107
+ relative = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
108
+ return if @only && @only != path
109
+ if @settings.fetch('exclude').any? { |glob| File.fnmatch?(glob, relative, File::FNM_PATHNAME | File::FNM_DOTMATCH) }
110
+ @coverage << { 'context' => 'heading', 'file' => path, 'line' => line, 'reason' => 'Excluded by configuration.' }
111
+ return
112
+ end
113
+ @headings << { 'id' => AsciidocPubkit.hash_text([path, line, match[2]].join("\0"))[0, 16],
114
+ 'index' => index, 'parent_index' => parent_index,
115
+ 'file' => path, 'line' => line, 'column' => match[1].length + 1,
116
+ 'prefix' => match[1], 'suffix' => match[3], 'level' => node.level,
117
+ 'section_id' => node.id, 'text' => match[2] }
118
+ end
119
+
72
120
  def collect(node)
73
121
  if %i[list_item table quote verse listing literal pass].include?(node.context)
74
122
  @coverage << { 'context' => node.context.to_s, 'reason' => 'Not reviewed as running prose.' }
@@ -99,16 +147,20 @@ module AsciidocPubkit
99
147
  return
100
148
  end
101
149
  headings = []
150
+ section_index = nil
102
151
  parent = node.parent
103
152
  while parent
104
- headings.unshift(parent.title) if parent.context == :section && parent.title
153
+ if parent.context == :section && parent.title
154
+ headings.unshift(parent.title)
155
+ section_index ||= @section_indexes[parent]
156
+ end
105
157
  parent = parent.parent
106
158
  end
107
159
  text = lines.join("\n")
108
160
  @paragraphs << {
109
161
  'id' => AsciidocPubkit.hash_text([path, line, text].join("\0"))[0, 16],
110
162
  'file' => path, 'line' => line, 'end_line' => line + lines.length - 1,
111
- 'headings' => headings, 'text' => text
163
+ 'section_index' => section_index, 'headings' => headings, 'text' => text
112
164
  }
113
165
  end
114
166
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciidocPubkit
4
+ class HeadingRuleSet
5
+ KEYS = %w[schema_version terms fixed_titles duplicate_siblings style].freeze
6
+
7
+ def self.default_path(language)
8
+ Language.validate!(language, operation: 'review')
9
+ File.expand_path("../../data/heading-rules.#{language}.yml", __dir__)
10
+ end
11
+
12
+ def self.load(path)
13
+ validate(YAML.safe_load(AsciidocPubkit.read_text(path), permitted_classes: [], aliases: false))
14
+ rescue Psych::Exception => e
15
+ raise Error, "Invalid heading rule YAML in #{path}: #{e.message}"
16
+ end
17
+
18
+ def self.validate(data)
19
+ RuleSet.mapping(data, KEYS, 'Heading rule set')
20
+ raise Error, 'Heading rule schema_version must be integer 1.' unless data['schema_version'].is_a?(Integer) && data['schema_version'] == 1
21
+ raise Error, 'Heading terms must be a mapping.' unless data['terms'].is_a?(Hash)
22
+ data['terms'].each do |name, entry|
23
+ raise Error, 'Heading rule names must be nonempty strings.' unless name.is_a?(String) && !name.strip.empty?
24
+ RuleSet.mapping(entry, %w[terms question], "Heading rule #{name}")
25
+ RuleSet.strings(entry['terms'], "Heading rule #{name}.terms")
26
+ unless entry['question'].is_a?(String) && !entry['question'].strip.empty?
27
+ raise Error, "Heading rule #{name}.question must be a nonempty string."
28
+ end
29
+ end
30
+ RuleSet.strings(data['fixed_titles'], 'Heading fixed_titles')
31
+ unless [true, false].include?(data['duplicate_siblings'])
32
+ raise Error, 'Heading duplicate_siblings must be boolean.'
33
+ end
34
+ raise Error, 'Heading style must be mixed, nominal, or action.' unless %w[mixed nominal action].include?(data['style'])
35
+ data
36
+ end
37
+ end
38
+
39
+ class HeadingRules
40
+ def self.scan(headings, settings, tokenizer: nil)
41
+ rules = HeadingRuleSet.validate(settings.fetch('heading_rules'))
42
+ tokenizer ||= Morphology.new(settings) if settings.fetch('tokenizer', 'mecab') == 'mecab'
43
+ findings = []
44
+ duplicates = headings.group_by { |h| [h['parent_index'], h['text']] }
45
+ # Use the configured analyzer; never silently substitute literal analysis.
46
+ tokens = tokenizer ? Rules.tokenize_paragraphs(headings, tokenizer) : []
47
+ headings.each_with_index do |heading, index|
48
+ next if rules['fixed_titles'].include?(heading['text'])
49
+ text = Rules.mask(heading['text'])
50
+ rules['terms'].each do |rule, entry|
51
+ entry['terms'].each do |term|
52
+ next if settings['allows'].include?(term)
53
+ text.to_enum(:scan, Regexp.new(Regexp.escape(term))).each do
54
+ match = Regexp.last_match
55
+ add(findings, heading, match.begin(0), term, rule, entry['question'])
56
+ end
57
+ end
58
+ end
59
+ settings['glossary'].each do |canonical, variants|
60
+ variants.each do |variant|
61
+ next if settings['allows'].include?(variant)
62
+ text.to_enum(:scan, Regexp.new(Regexp.escape(variant))).each do
63
+ match = Regexp.last_match
64
+ add(findings, heading, match.begin(0), variant, 'heading-glossary-variant', "Check #{canonical.inspect} against the book's glossary. Preserve official names and identifiers.")
65
+ end
66
+ end
67
+ end
68
+ if rules['duplicate_siblings'] && duplicates.fetch([heading['parent_index'], heading['text']]).length > 1
69
+ add(findings, heading, 0, heading['text'], 'heading-duplicate', 'Sibling headings have identical titles. Check whether their subjects can be distinguished; necessary repetition is valid.')
70
+ end
71
+ next if rules['style'] == 'mixed' || text.strip.empty?
72
+ last = tokenizer && tokens[index].last
73
+ # These are cues for review, not a complete grammatical classifier.
74
+ action = last ? %w[動詞 助動詞].include?(last['pos']) : !!text.match(/(?:する|します|できる|ない|学ぶ|使う|選ぶ|読む|続ける|分ける|確かめる)\z/)
75
+ next if (rules['style'] == 'action') == action
76
+ add(findings, heading, 0, heading['text'], 'heading-style', "Check this heading against the book's #{rules['style']} preference. Preserve questions, principles and justified exceptions; do not change meaning to fit a template.")
77
+ end
78
+ findings
79
+ end
80
+
81
+ def self.add(findings, heading, offset, match, rule, question)
82
+ findings << { 'id' => AsciidocPubkit.hash_text([heading['id'], rule, offset, match].join(':'))[0, 16],
83
+ 'heading_id' => heading['id'], 'rule' => rule, 'severity' => 'hint',
84
+ 'file' => heading['file'], 'line' => heading['line'],
85
+ 'column' => heading['column'] + offset, 'match' => match, 'question' => question }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'tempfile'
5
+ require 'open3'
6
+
7
+ module AsciidocPubkit
8
+ class ReplacementRules
9
+ def self.load(path)
10
+ data = YAML.safe_load(AsciidocPubkit.read_text(path), permitted_classes: [], aliases: false)
11
+ RuleSet.mapping(data, %w[version rules], 'Replacement rules')
12
+ raise Error, 'Replacement version must be integer 1.' unless data['version'].is_a?(Integer) && data['version'] == 1
13
+ raise Error, 'rules must be an array.' unless data['rules'].is_a?(Array)
14
+ data['rules'].each_with_index.map do |rule, index|
15
+ unless rule.is_a?(Hash) && (rule.keys - %w[expected pattern patterns specs]).empty? &&
16
+ rule['expected'].is_a?(String) && (rule.key?('pattern') ^ rule.key?('patterns'))
17
+ raise Error, "Rule #{index + 1} requires expected and exactly one of pattern or patterns; unsupported keys are rejected."
18
+ end
19
+ patterns = rule.key?('pattern') ? [rule['pattern']] : rule['patterns']
20
+ unless patterns.is_a?(Array) && !patterns.empty? && patterns.all? { |p| p.is_a?(String) && !p.empty? }
21
+ raise Error, 'Replacement patterns must be nonempty strings.'
22
+ end
23
+ compiled = patterns.map do |pattern|
24
+ if pattern.start_with?('/')
25
+ match = pattern.match(%r{\A/(.*)/([a-z]*)\z}m)
26
+ raise Error, 'Regex flags are unsupported; omit flags (all matches are collected).' unless match && match[2].empty?
27
+ body = match[1]
28
+ # Deliberately small common JavaScript/Ruby subset; Ruby-only escapes
29
+ # and engine-specific groups must not silently change JS semantics.
30
+ if body.match?(/\\[^\\\/.*+?()\[\]{}^$|nrtdDsSwW-]/) || body.match?(/\(\?(?![:=!]|<[=!])/) || body.include?('&&')
31
+ raise Error, 'Unsupported regex syntax in replacement rule.'
32
+ end
33
+ Regexp.new(body.gsub('\\/', '/'), timeout: 0.1)
34
+ else
35
+ Regexp.new(Regexp.escape(pattern), timeout: 0.1)
36
+ end
37
+ end
38
+ compiled.each do |regex|
39
+ regex.match('') # Detect common empty matches; runtime checks cover contextual ones.
40
+ raise Error, 'Zero-length replacement matches are unsupported.' if regex.match('')
41
+ end
42
+ specs = rule.fetch('specs', [])
43
+ raise Error, 'specs must be an array.' unless specs.is_a?(Array)
44
+ specs.each do |spec|
45
+ RuleSet.mapping(spec, %w[from to], 'Replacement spec')
46
+ raise Error, 'Spec from/to must be strings.' unless spec.values.all? { |v| v.is_a?(String) }
47
+ result = spec['from'].dup
48
+ edits = matches(spec['from'], compiled, rule['expected'])
49
+ reject_overlaps!(edits)
50
+ edits.reverse_each { |edit| result[edit[:start]...edit[:finish]] = edit[:after] }
51
+ raise Error, "Replacement spec failed for rule #{index + 1}." unless result == spec['to']
52
+ end
53
+ { id: "rule-#{index + 1}", patterns: compiled, expected: rule['expected'] }
54
+ end
55
+ rescue RegexpError, Regexp::TimeoutError => e
56
+ raise Error, "Invalid replacement regex: #{e.message}"
57
+ end
58
+
59
+ def self.expand(template, match)
60
+ template.gsub(/\$\$|\$[1-9][0-9]?|\$./) do |ref|
61
+ if ref == '$$'
62
+ '$'
63
+ elsif ref.match?(/\A\$[1-9][0-9]?\z/) && ref[1..].to_i < match.length
64
+ match[ref[1..].to_i] || ''
65
+ else
66
+ raise Error, "Unsupported replacement reference: #{ref}"
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.matches(text, patterns, expected)
72
+ patterns.flat_map do |regex|
73
+ text.to_enum(:scan, regex).map do
74
+ match = Regexp.last_match
75
+ raise Error, 'Zero-length replacement matches are unsupported.' if match.begin(0) == match.end(0)
76
+ { start: match.begin(0), finish: match.end(0), before: match[0], after: expand(expected, match) }
77
+ end
78
+ end.reject { |edit| edit[:before] == edit[:after] }.uniq.sort_by { |edit| edit[:start] }
79
+ rescue Regexp::TimeoutError
80
+ raise Error, 'Replacement regex timed out.'
81
+ end
82
+
83
+ def self.reject_overlaps!(edits)
84
+ edits.sort_by { |edit| edit[:start] }.each_cons(2) do |left, right|
85
+ raise Error, 'Conflicting replacement candidates overlap; revise the rules.' if left[:finish] > right[:start]
86
+ end
87
+ end
88
+ end
89
+
90
+ class Replacement
91
+ attr_reader :candidates
92
+
93
+ def initialize(entry, options)
94
+ raise Error, 'Replacement commands require --rules FILE.' unless options[:rules]
95
+ raise Error, 'Replacement entry must not be a symlink.' if File.symlink?(entry)
96
+ @entry = File.realpath(entry)
97
+ @only = options[:only] && File.realpath(options[:only])
98
+ @settings = Settings.new(entry, options.merge(replacement: true)).data
99
+ @document = Document.new(entry, @settings, only: options[:only])
100
+ raise Error, 'Replacement input has parsing diagnostics.' unless @document.diagnostics.empty?
101
+ rules = ReplacementRules.load(options[:rules])
102
+ @candidates = []
103
+ @document.paragraphs.each do |paragraph|
104
+ text = paragraph['text']
105
+ protected_ranges = text.to_enum(:scan, Rules::INLINE).map do
106
+ match = Regexp.last_match
107
+ match.begin(0)...match.end(0)
108
+ end
109
+ raw = @document.sources.fetch(paragraph['file'])
110
+ lines = raw.lines
111
+ line_offsets = [0]
112
+ lines.each { |line| line_offsets << line_offsets.last + line.length }
113
+ rules.each do |rule|
114
+ ReplacementRules.matches(text, rule[:patterns], rule[:expected]).each do |edit|
115
+ next if protected_ranges.any? { |range| edit[:start] < range.end && edit[:finish] > range.begin }
116
+ # Keep physical line boundaries intact, including CRLF.
117
+ raise Error, 'Replacement matches and results must stay on one line.' if (edit[:before] + edit[:after]).match?(/[\r\n]/)
118
+ prefix = text[0...edit[:start]]
119
+ line = paragraph['line'] + prefix.count("\n")
120
+ column = (prefix.rindex("\n") ? prefix.length - prefix.rindex("\n") : prefix.length + 1)
121
+ start = line_offsets.fetch(line - 1) + column - 1
122
+ raise Error, 'Replacement source mapping is ambiguous.' unless raw[start, edit[:before].length] == edit[:before]
123
+ @candidates << edit.merge(start: start, finish: start + edit[:before].length,
124
+ file: paragraph['file'], line: line, column: column, rule: rule[:id])
125
+ end
126
+ end
127
+ end
128
+ @candidates.uniq!
129
+ @candidates.sort_by! { |edit| [edit[:file], edit[:start], edit[:rule]] }
130
+ @candidates.group_by { |edit| edit[:file] }.each_value { |edits| ReplacementRules.reject_overlaps!(edits) }
131
+ @updated = @document.sources.transform_values(&:dup)
132
+ @candidates.reverse_each { |edit| @updated[edit[:file]][edit[:start]...edit[:finish]] = edit[:after] }
133
+ validate unless @candidates.empty?
134
+ end
135
+
136
+ def report
137
+ candidates.map { |edit| "#{edit[:file]}:#{edit[:line]}:#{edit[:column]}: #{edit[:rule]} #{edit[:before].inspect} -> #{edit[:after].inspect}\n" }.join +
138
+ "#{candidates.length} replacements; #{@document.coverage.length} coverage notices. Only mapped running prose is eligible.\n"
139
+ end
140
+
141
+ def changed
142
+ @updated.select { |path, text| text != @document.sources[path] }
143
+ end
144
+
145
+ def diff
146
+ changed.map do |path, text|
147
+ Tempfile.create('pubkit-before') do |before|
148
+ Tempfile.create('pubkit-after') do |after|
149
+ before.binmode.write(@document.sources[path]); before.flush
150
+ after.binmode.write(text); after.flush
151
+ output, error, status = Open3.capture3('diff', '-u', '--label', "#{path} (before)", '--label', "#{path} (after)", before.path, after.path)
152
+ raise Error, "Cannot generate diff: #{error}" unless [0, 1].include?(status.exitstatus)
153
+ output
154
+ end
155
+ end
156
+ end.join
157
+ end
158
+
159
+ def validate
160
+ root = File.realpath(@settings['base_dir'])
161
+ Dir.mktmpdir('pubkit-replacement-') do |stage|
162
+ stage = File.realpath(stage)
163
+ @updated.each do |path, text|
164
+ destination = File.join(stage, Pathname.new(path).relative_path_from(Pathname.new(root)).to_s)
165
+ FileUtils.mkdir_p(File.dirname(destination))
166
+ File.binwrite(destination, text)
167
+ end
168
+ staged_entry = File.join(stage, Pathname.new(@entry).relative_path_from(Pathname.new(root)).to_s)
169
+ staged_only = @only && File.join(stage, Pathname.new(@only).relative_path_from(Pathname.new(root)).to_s)
170
+ staged = Document.new(staged_entry, @settings.merge('base_dir' => stage), only: staged_only)
171
+ raise Error, 'Replacement changes parsing or document structure.' unless staged.diagnostics.empty? && staged.structure == @document.structure
172
+ original = Session.protected_content(@document).transform_keys { |path| Pathname.new(path).relative_path_from(Pathname.new(root)).to_s }
173
+ result = Session.protected_content(staged).transform_keys { |path| Pathname.new(path).relative_path_from(Pathname.new(stage)).to_s }
174
+ # --only can leave otherwise eligible paragraphs protected in the original.
175
+ if original != result
176
+ raise Error, 'Replacement changes source membership or protected content.'
177
+ end
178
+ end
179
+ end
180
+
181
+ def apply
182
+ installed = []
183
+ prepared = []
184
+ begin
185
+ changed.each do |path, text|
186
+ file = Tempfile.new('.pubkit-replace-', File.dirname(path))
187
+ prepared << [path, file]
188
+ file.binmode.write(text)
189
+ file.flush
190
+ file.fsync
191
+ File.chmod(File.stat(path).mode & 0o777, file.path)
192
+ end
193
+ @document.sources.each do |path, raw|
194
+ unless !File.symlink?(path) && File.binread(path) == raw.b
195
+ raise Error, "Source changed before replacement: #{path}"
196
+ end
197
+ end
198
+ prepared.each do |path, file|
199
+ File.rename(file.path, path)
200
+ installed << path
201
+ end
202
+ rescue StandardError
203
+ installed.reverse_each { |path| File.binwrite(path, @document.sources.fetch(path)) }
204
+ raise
205
+ ensure
206
+ prepared.each { |_, file| file.close! }
207
+ end
208
+ end
209
+ end
210
+ end