asciidoc-pubkit 0.6.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bce892bd925140f338afaba12cce7b039e5863d7b082ef6ddb7c18b3930a5b9f
4
- data.tar.gz: 6490e4783d15787651083e083bd57687d01a446d47c69a5202638e8493b9d541
3
+ metadata.gz: 5e6439ccb97f49f7e31290cb8d60b1f361cf444889ac0631cdd1f9e28f8a0d9c
4
+ data.tar.gz: 484ff3e7ac4475ea693ff7d5751bc34e1537b66dda90bb7d425bb55acb467322
5
5
  SHA512:
6
- metadata.gz: 3bf0e618fc48a4c45f67429e1b74f3996a3d6aa2d71029bc87e112091551d3f612f1b4b2c1b5ed8e78f314fd87be412175b8009c00c741f711f902d0c9ffe89e
7
- data.tar.gz: 2827a75447bac7e95bf5161bac061a3d5a089f7d0390801bbfe22a51851cdf206b4a6e512d61074e4021481aac01c2d3fd63bf7caf8db3f46e8fd8628fd30b06
6
+ metadata.gz: 9ccf0130e06770e7b12774385daab670fffde6a128e83af1d67300ada91ea6c15fc281645cfae6da6b7b009cca36d01429287ee36461c11f2d43c439d8307fe5
7
+ data.tar.gz: f70b717d0633729647ac733670604e3d6ace15354efc31167f8af8994b62531ddba1c74f6456c293ccf16bbee4833ddbd2efb655c520151edee6b6559d94ca0c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-10-01
4
+
5
+ - Add explicit `replace check`, `replace diff`, and `replace apply` commands for
6
+ mapped running prose using a strict subset of prh-format YAML rules. Protect
7
+ blocks and recognized inline spans, reject overlapping edits, test rule specs,
8
+ and validate staged sources before applying replacements without MeCab or AI.
9
+
3
10
  ## 0.6.3 — 2026-10-01
4
11
 
5
12
  - Summarize review and scoring features at the start of the README architecture
data/README.md CHANGED
@@ -7,9 +7,10 @@
7
7
 
8
8
  A toolkit for authoring and reviewing AsciiDoc books.
9
9
 
10
- Version 0.6.3 provides shared Japanese technical writing criteria, an authoring
10
+ Version 0.8.0 provides shared Japanese technical writing criteria, an authoring
11
11
  prompt, separate prose and heading review prompts, manuscript scoring, and
12
- baseline verification for edited manuscripts.
12
+ baseline verification for edited manuscripts, plus explicit prose-only mechanical
13
+ replacements using a limited prh-format rule set.
13
14
  The CLI, diagnostics, documentation, and generated instructions are in English.
14
15
  Japanese text is retained in manuscript excerpts, rule dictionaries, and fixtures.
15
16
 
@@ -18,7 +19,9 @@ Japanese text is retained in manuscript excerpts, rule dictionaries, and fixture
18
19
  Version 0.6.3 adds separate heading review with `review scan --scope headings`.
19
20
  See [Heading review](#heading-review).
20
21
 
21
- The CLI does not automatically rewrite manuscripts or publish books. EPUB, image,
22
+ Version 0.8.0 adds explicit prose replacements with `replace apply`;
23
+ see [Prose replacements](#prose-replacements). Review and scoring do not edit
24
+ manuscripts. The CLI does not publish books. EPUB, image,
22
25
  and book scaffolding commands are planned extensions, not available features.
23
26
 
24
27
  Ruby 3.2 or later is required. Asciidoctor is installed as a gem dependency.
@@ -33,7 +36,7 @@ configured model provider. No scoring mode edits manuscripts.
33
36
 
34
37
  Version 0.1.1 added morphological analysis. Version 0.1.0 used literal matching.
35
38
 
36
- The CLI has `writing` and `review` command groups. Both default to `--lang ja`.
39
+ The CLI has `writing`, `review`, and `replace` command groups. All default to `--lang ja`.
37
40
  Language-specific criteria live under `data/writing/<language>/`; review rules
38
41
  use `data/review-rules.<language>.yml`. Writing and review have separate lists
39
42
  of supported languages, so a future writing language need not imply review
@@ -83,7 +86,7 @@ Add the gem to your project's `Gemfile` to manage its version with Bundler:
83
86
 
84
87
  ```ruby
85
88
  source 'https://rubygems.org'
86
- gem 'asciidoc-pubkit', '~> 0.6.3'
89
+ gem 'asciidoc-pubkit', '~> 0.8.0'
87
90
  ```
88
91
 
89
92
  Then install dependencies and run the CLI through Bundler:
@@ -105,7 +108,7 @@ git clone https://github.com/cybergarage/asciidoc-pubkit.git
105
108
  cd asciidoc-pubkit
106
109
  bundle install
107
110
  gem build asciidoc-pubkit.gemspec
108
- gem install ./asciidoc-pubkit-0.6.3.gem
111
+ gem install ./asciidoc-pubkit-0.8.0.gem
109
112
  asciidoc-pubkit --version
110
113
  ```
111
114
 
@@ -129,7 +132,7 @@ asciidoc-pubkit writing prompt --lang ja --output writing-prompt.md
129
132
  ```
130
133
 
131
134
  For example, `--lang en` exits with an unsupported-language error. No English
132
- review or writing criteria are shipped in 0.6.3.
135
+ review or writing criteria are shipped in 0.8.0.
133
136
 
134
137
  For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
135
138
  paragraphs deliberately contain review candidates; its code block must remain
@@ -750,6 +753,107 @@ Regex patterns are not interpreted in glossary or allow-list entries. Unknown
750
753
  configuration keys are rejected. A bare `mecab_command` is resolved through PATH;
751
754
  use an absolute path for an explicit executable override.
752
755
 
756
+ ## Prose replacements
757
+
758
+ Available starting with version 0.8.0.
759
+ It applies author-supplied mechanical replacement rules without MeCab or an AI
760
+ CLI. Review candidates and glossary variants remain advisory and are never
761
+ implicitly applied. Replacement rules are separate from review rules.
762
+
763
+ ```sh
764
+ asciidoc-pubkit replace check book.adoc --rules replacements.yml
765
+ asciidoc-pubkit replace diff book.adoc --rules replacements.yml
766
+ asciidoc-pubkit replace apply book.adoc --rules replacements.yml
767
+ ```
768
+
769
+ `check` reports original text, proposed text, rule IDs (`rule-1`, etc.), source
770
+ paths, one-based Unicode lines/columns, and a coverage-notice count. Its exit code
771
+ is 1 when replacements are available, 0 otherwise. `diff` previews unified diffs
772
+ without editing; it requires the external `diff` command. `apply` explicitly
773
+ authorizes source replacement without prompting. Successful diff/apply commands
774
+ return 0; invalid rules, conflicts, unsafe edits, and input/output failures return
775
+ 2. Check/diff accept `--output` for a new file only; apply rejects `--output`.
776
+
777
+ All three accept `--lang ja`, `--base-dir`, `--config`, and `--only` (an included
778
+ source file). Normal configuration discovery and `review.base_dir`, `language`,
779
+ `attributes`, and `exclude` control parsing and selection. `--rules` is required;
780
+ `review.rules` and glossary entries do not supply replacement rules. There are
781
+ no built-in replacement rules and no automatic search for `prh.yml`.
782
+
783
+ ### Limited prh-format rules
784
+
785
+ The supported format is a strict subset of [prh](https://github.com/prh/prh):
786
+
787
+ ```yaml
788
+ version: 1
789
+ rules:
790
+ - expected: '®'
791
+ pattern: '&reg;'
792
+ - expected: '($1)'
793
+ pattern: '/(([^()\r\n]+))/'
794
+ specs:
795
+ - from: '設定(任意)'
796
+ to: '設定(任意)'
797
+ - expected: '/'
798
+ patterns:
799
+ - '/'
800
+ ```
801
+
802
+ The root contains exactly `version: 1` and `rules`. Each rule requires a string
803
+ `expected` (empty strings permit deletion) and exactly one of `pattern` (a
804
+ nonempty string) or `patterns` (a nonempty array of nonempty strings). Optional
805
+ `specs` is an array of exact `from`/`to` string pairs, validated on load. Specs
806
+ exercise the rule on plain text, not AsciiDoc selection or inline protection.
807
+
808
+ Strings not beginning with `/` match literally. `/.../` denotes a regex with no
809
+ flags; all occurrences are collected. The implementation uses Ruby regexes with
810
+ a timeout and accepts a limited common syntax: character classes, ordinary
811
+ captures, noncapturing groups, lookarounds, alternatives, anchors, quantifiers,
812
+ and the escapes `\n`, `\r`, `\t`, `\d`, `\D`, `\s`, `\S`, `\w`, `\W`
813
+ and escaped punctuation. Ruby regex character-class behavior applies; this is
814
+ not a JavaScript regex engine or a promise of full prh equivalence. Engine-specific
815
+ groups/escapes and flags are rejected. A leading literal slash must be expressed
816
+ as an escaped regex, for example `/\//`. Zero-length matches are rejected.
817
+
818
+ Replacement strings support `$1` through `$99` for existing capture groups and
819
+ `$$` for a literal dollar sign. Unsupported dollar references are rejected when
820
+ matched. Missing optional captures expand to empty strings. YAML single quotes
821
+ are recommended to keep backslashes literal. Unknown fields, including `imports`,
822
+ `options`, and `regexpMustEmpty`, and omitted patterns are rejected rather than
823
+ ignored. Regex flags and prh's automatic pattern generation are not supported.
824
+
825
+ ### Selection and preservation
826
+
827
+ Only unambiguously source-mapped running prose in the existing default review
828
+ scope is eligible. Headings, lists, tables, quotations, listing/source, literal,
829
+ and passthrough blocks are excluded. Recognized inline code, quotations, URLs,
830
+ macros, and attribute references are protected by the existing conservative
831
+ inline heuristic, not a complete inline parser. A match overlapping any protected
832
+ span is skipped; matching is performed on the original text, never on blanked
833
+ placeholders. Backtick-to-bold and list-marker conversions are outside this scope.
834
+
835
+ Candidates are calculated once against the original sources; replacement results
836
+ are not searched again. Overlapping edits are conflicts, not resolved by rule
837
+ order. Matches/results containing line breaks are rejected. Source files retain
838
+ UTF-8 bytes, original line endings, and unrelated content; repeated include
839
+ occurrences must not create overlapping edits.
840
+
841
+ Before presenting a plan, the complete captured source set is copied to temporary
842
+ staging and reparsed. Parsing diagnostics, structural changes, new/deleted protected
843
+ inline tokens, and protected-content/source-membership changes reject the plan.
844
+ Relative includes within the base directory are supported. Absolute includes and
845
+ includes whose paths depend on the original filesystem may fail staged validation;
846
+ they are not rewritten or silently accepted. Symlink entrypoints are rejected.
847
+
848
+ Apply stages all changed files beside their destinations, checks all captured
849
+ source bytes against the plan, and renames the prepared files into place while
850
+ preserving permission bits. An ordinary installation error restores sources
851
+ already installed. This is not a crash-atomic transaction across multiple files;
852
+ keep manuscripts under version control and avoid concurrent edits during apply.
853
+ No review session is created or replaced. Existing sessions retain their original
854
+ baselines; applying replacements can make a session stale for prompt generation.
855
+ Mechanical validation does not establish semantic correctness.
856
+
753
857
  ## Rules and coverage
754
858
 
755
859
  | Rule | Severity | Purpose |
@@ -840,12 +944,12 @@ reports analyzer changes instead of treating results from different dictionaries
840
944
  as directly comparable. Prompt generation uses saved evidence and does not need
841
945
  MeCab. Changed rules or dictionary settings require a new scan.
842
946
 
843
- Sessions from earlier tool versions are not compatible with 0.6.3 (session schema 2). Keep the original baseline for
947
+ Sessions from earlier tool versions are not compatible with 0.8.0 (session schema 2). Keep the original baseline for
844
948
  an ongoing review and finish it with the original version, or start a new review
845
949
  pass in a different directory:
846
950
 
847
951
  ```sh
848
- asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.6.3
952
+ asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.8.0
849
953
  ```
850
954
 
851
955
  Severity describes review priority, not proof of an error. There is no AI-authorship
@@ -0,0 +1,17 @@
1
+ version: 1
2
+
3
+ rules:
4
+ - expected: '®'
5
+ pattern: '&reg;'
6
+ specs:
7
+ - from: '製品&reg;'
8
+ to: '製品®'
9
+ - expected: '($1)'
10
+ pattern: '/(([^()\r\n]+))/'
11
+ specs:
12
+ - from: '設定(任意)'
13
+ to: '設定(任意)'
14
+ - expected: ' '
15
+ pattern: ' '
16
+ - expected: '/'
17
+ pattern: '/'
@@ -12,6 +12,9 @@ module AsciidocPubkit
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,21 +32,27 @@ 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
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'
48
57
  opts.on('--scope SCOPE', 'prose (default) or headings; separate review sessions') { |v| options[:scope] = v }
49
58
  opts.on('--heading-rules FILE', 'Replace default heading rules with a YAML rule set') { |v| options[:heading_rules] = v }
@@ -85,6 +94,15 @@ module AsciidocPubkit
85
94
  end
86
95
  raise Error, 'Exactly one input is required. Use --help.' unless args.length == 1
87
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
88
106
  if command == 'score'
89
107
  if options[:output] && (File.exist?(options[:output]) || File.symlink?(options[:output]))
90
108
  raise Error, "Output already exists: #{options[:output]}"
@@ -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
@@ -39,7 +39,7 @@ module AsciidocPubkit
39
39
  raise Error, '--heading-rules requires --scope headings.'
40
40
  end
41
41
  heading_rules_path = options[:heading_rules] ? File.expand_path(options[:heading_rules]) : (review['heading_rules'] ? File.expand_path(review['heading_rules'], base) : HeadingRuleSet.default_path(language))
42
- rules_path = options[:rules] ? File.expand_path(options[:rules]) : (review['rules'] ? File.expand_path(review['rules'], base) : RuleSet.default_path(language))
42
+ rules_path = options[:replacement] ? RuleSet.default_path(language) : options[:rules] ? File.expand_path(options[:rules]) : (review['rules'] ? File.expand_path(review['rules'], base) : RuleSet.default_path(language))
43
43
  @data = {
44
44
  'scope' => scope,
45
45
  'heading_rules' => scope == 'headings' ? HeadingRuleSet.load(heading_rules_path) : nil,
@@ -9,7 +9,7 @@ require 'pathname'
9
9
  require 'optparse'
10
10
 
11
11
  module AsciidocPubkit
12
- VERSION = '0.6.3'
12
+ VERSION = '0.8.0'
13
13
  class Error < StandardError; end
14
14
 
15
15
  def self.hash_text(text)
@@ -34,4 +34,5 @@ require_relative 'asciidoc_pubkit/rules'
34
34
  require_relative 'asciidoc_pubkit/session'
35
35
  require_relative 'asciidoc_pubkit/local_evaluator'
36
36
  require_relative 'asciidoc_pubkit/score'
37
+ require_relative 'asciidoc_pubkit/replacement'
37
38
  require_relative 'asciidoc_pubkit/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asciidoc-pubkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - CyberGarage
@@ -38,7 +38,8 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.0'
40
40
  description: Generate Japanese technical writing prompts, review AsciiDoc prose and
41
- section headings separately, and verify protected manuscript content.
41
+ section headings separately, verify protected manuscript content, and explicitly
42
+ apply prose-only mechanical replacements.
42
43
  executables:
43
44
  - asciidoc-pubkit
44
45
  extensions: []
@@ -52,6 +53,7 @@ files:
52
53
  - data/writing/ja/criteria.md
53
54
  - data/writing/ja/headings.md
54
55
  - examples/book.adoc
56
+ - examples/replacements.yml
55
57
  - exe/asciidoc-pubkit
56
58
  - lib/asciidoc_pubkit.rb
57
59
  - lib/asciidoc_pubkit/cli.rb
@@ -60,6 +62,7 @@ files:
60
62
  - lib/asciidoc_pubkit/language.rb
61
63
  - lib/asciidoc_pubkit/local_evaluator.rb
62
64
  - lib/asciidoc_pubkit/morphology.rb
65
+ - lib/asciidoc_pubkit/replacement.rb
63
66
  - lib/asciidoc_pubkit/rule_set.rb
64
67
  - lib/asciidoc_pubkit/rules.rb
65
68
  - lib/asciidoc_pubkit/score.rb