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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -11
- data/README.md +286 -13
- data/data/heading-rules.ja.yml +23 -0
- data/data/writing/ja/headings.md +45 -0
- data/examples/replacements.yml +17 -0
- data/lib/asciidoc_pubkit/cli.rb +32 -8
- data/lib/asciidoc_pubkit/document.rb +56 -4
- data/lib/asciidoc_pubkit/heading_rules.rb +88 -0
- data/lib/asciidoc_pubkit/replacement.rb +210 -0
- data/lib/asciidoc_pubkit/session.rb +117 -11
- data/lib/asciidoc_pubkit/settings.rb +16 -4
- data/lib/asciidoc_pubkit/writing.rb +5 -0
- data/lib/asciidoc_pubkit.rb +3 -1
- metadata +9 -3
data/lib/asciidoc_pubkit/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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 == '
|
|
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
|
-
|
|
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
|
-
|
|
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
|