asciidoc-pubkit 0.6.3 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bce892bd925140f338afaba12cce7b039e5863d7b082ef6ddb7c18b3930a5b9f
4
- data.tar.gz: 6490e4783d15787651083e083bd57687d01a446d47c69a5202638e8493b9d541
3
+ metadata.gz: 8340a490160e7c0c2ca089657884c882eca5861dd27206dc9a3513ab1b5cfa2d
4
+ data.tar.gz: b4c2428c0408f2301563ee3e4586e9422c7b4875f927779146d9ebacec678568
5
5
  SHA512:
6
- metadata.gz: 3bf0e618fc48a4c45f67429e1b74f3996a3d6aa2d71029bc87e112091551d3f612f1b4b2c1b5ed8e78f314fd87be412175b8009c00c741f711f902d0c9ffe89e
7
- data.tar.gz: 2827a75447bac7e95bf5161bac061a3d5a089f7d0390801bbfe22a51851cdf206b4a6e512d61074e4021481aac01c2d3fd63bf7caf8db3f46e8fd8628fd30b06
6
+ metadata.gz: b919c53366fb83912221bf4a642b23db13725535c82449e54f6859d6a6bc76a04582c7b859eb4fb4f9cde814637dd535625eea31ffd62af48f02a6a71642c4f5
7
+ data.tar.gz: 18d123157532330403229b1365b97e2dcba308de2ed5d07aa16583c973d801bfa1e6aac001f299be7ab445beaf7a1600f204ff058362f1cd84dc7362f7466aca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 — 2026-10-01
4
+
5
+ - Add an opening README feature table and describe prose replacements before
6
+ scoring and review.
7
+
8
+ - Support replacement regex `/i` with Ruby case-insensitive matching and translate
9
+ `\b`/`\B` assertions to limited ECMAScript Unicode word boundaries. Preserve
10
+ capture numbering, source positions, protected spans, and backspace-in-class
11
+ semantics; retain Ruby behavior elsewhere and reject other flags.
12
+
13
+ - Treat null replacement `rules`, including bare `rules:`, as an empty local
14
+ rule set while retaining imported rules and rejecting other non-array values.
15
+
16
+ - Accept nonempty string arrays in replacement `pattern` as an alias for
17
+ `patterns`, retaining strict validation, specs, protection, and conflict checks.
18
+
19
+ - Document omitted replacement `rules` as an empty set, including version-only
20
+ files, and verify CLI no-op behavior and application through import-only chains.
21
+
22
+ - Support nested local `imports` in replacement rules, resolving paths relative
23
+ to each importing file and accepting import-only files and `path` mappings.
24
+ Deduplicate physical files, reject cycles and unsupported import options, and
25
+ preserve validation, conflict detection, and prose-only replacement scope.
26
+
27
+ ## 0.8.0 — 2026-10-01
28
+
29
+ - Add explicit `replace check`, `replace diff`, and `replace apply` commands for
30
+ mapped running prose using a strict subset of prh-format YAML rules. Protect
31
+ blocks and recognized inline spans, reject overlapping edits, test rule specs,
32
+ and validate staged sources before applying replacements without MeCab or AI.
33
+
3
34
  ## 0.6.3 — 2026-10-01
4
35
 
5
36
  - Summarize review and scoring features at the start of the README architecture
data/README.md CHANGED
@@ -7,18 +7,42 @@
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.1 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
 
17
+ ## Available features
18
+
19
+ The following commands are available in version 0.8.1.
20
+
21
+ | Feature | Commands | Behavior |
22
+ | --- | --- | --- |
23
+ | [Writing criteria and prompts](#writing-commands) | `writing criteria`, `writing prompt` | Print Japanese prose criteria or generate a writing prompt without a manuscript or MeCab |
24
+ | [Replacement preview](#prose-replacements) | `replace check`, `replace diff` | Report mechanical replacement candidates or preview a diff for mapped running prose; preserve source files |
25
+ | [Replacement application](#prose-replacements) | `replace apply` | Apply author-supplied rules after checking protected content and document structure; no MeCab or AI required |
26
+ | [Prose review](#review-workflow) | `review scan` | Collect mapped Japanese running prose and contextual review candidates with MeCab/IPADIC by default |
27
+ | [Heading review](#heading-review) | `review scan --scope headings` | Collect editable section titles separately, retaining the outline and read-only body evidence |
28
+ | [Review prompts](#prompt) | `review prompt` | Generate one Markdown prompt from a saved session for review and editing by an external reviewer |
29
+ | [Baseline verification](#verify) | `review verify` | Check edited sources against a saved baseline for mechanical preservation; meaning is not verified |
30
+ | [Manuscript scoring](#score-an-asciidoc-manuscript) | `review score` | Report a prose candidate-density score; explicit `--agent codex` or `--agent claude` optionally invokes a local AI CLI for readability ratings |
31
+
32
+ Version 0.8.1 additionally supports nested replacement-rule imports,
33
+ omitted/null `rules`, arrays in `pattern`, `/i`, and limited ECMAScript word
34
+ boundaries. See [Prose replacements](#prose-replacements) for supported syntax
35
+ and preservation limits. Only `replace apply` edits manuscripts directly;
36
+ review prompts supply instructions for an external reviewer.
37
+
16
38
  ## Status
17
39
 
18
40
  Version 0.6.3 adds separate heading review with `review scan --scope headings`.
19
41
  See [Heading review](#heading-review).
20
42
 
21
- The CLI does not automatically rewrite manuscripts or publish books. EPUB, image,
43
+ Version 0.8.0 adds explicit prose replacements with `replace apply`;
44
+ see [Prose replacements](#prose-replacements). Review and scoring do not edit
45
+ manuscripts. The CLI does not publish books. EPUB, image,
22
46
  and book scaffolding commands are planned extensions, not available features.
23
47
 
24
48
  Ruby 3.2 or later is required. Asciidoctor is installed as a gem dependency.
@@ -33,7 +57,7 @@ configured model provider. No scoring mode edits manuscripts.
33
57
 
34
58
  Version 0.1.1 added morphological analysis. Version 0.1.0 used literal matching.
35
59
 
36
- The CLI has `writing` and `review` command groups. Both default to `--lang ja`.
60
+ The CLI has `writing`, `review`, and `replace` command groups. All default to `--lang ja`.
37
61
  Language-specific criteria live under `data/writing/<language>/`; review rules
38
62
  use `data/review-rules.<language>.yml`. Writing and review have separate lists
39
63
  of supported languages, so a future writing language need not imply review
@@ -83,7 +107,7 @@ Add the gem to your project's `Gemfile` to manage its version with Bundler:
83
107
 
84
108
  ```ruby
85
109
  source 'https://rubygems.org'
86
- gem 'asciidoc-pubkit', '~> 0.6.3'
110
+ gem 'asciidoc-pubkit', '~> 0.8.1'
87
111
  ```
88
112
 
89
113
  Then install dependencies and run the CLI through Bundler:
@@ -105,7 +129,7 @@ git clone https://github.com/cybergarage/asciidoc-pubkit.git
105
129
  cd asciidoc-pubkit
106
130
  bundle install
107
131
  gem build asciidoc-pubkit.gemspec
108
- gem install ./asciidoc-pubkit-0.6.3.gem
132
+ gem install ./asciidoc-pubkit-0.8.1.gem
109
133
  asciidoc-pubkit --version
110
134
  ```
111
135
 
@@ -129,12 +153,192 @@ asciidoc-pubkit writing prompt --lang ja --output writing-prompt.md
129
153
  ```
130
154
 
131
155
  For example, `--lang en` exits with an unsupported-language error. No English
132
- review or writing criteria are shipped in 0.6.3.
156
+ review or writing criteria are shipped in 0.8.1.
133
157
 
134
158
  For a small trial, use `examples/book.adoc` as the scan input. Its Japanese
135
159
  paragraphs deliberately contain review candidates; its code block must remain
136
160
  unchanged.
137
161
 
162
+ ## Prose replacements
163
+
164
+ Available starting with version 0.8.0.
165
+ It applies author-supplied mechanical replacement rules without MeCab or an AI
166
+ CLI. Review candidates and glossary variants remain advisory and are never
167
+ implicitly applied. Replacement rules are separate from review rules.
168
+
169
+ ```sh
170
+ asciidoc-pubkit replace check book.adoc --rules replacements.yml
171
+ asciidoc-pubkit replace diff book.adoc --rules replacements.yml
172
+ asciidoc-pubkit replace apply book.adoc --rules replacements.yml
173
+ ```
174
+
175
+ `check` reports original text, proposed text, rule IDs (`rule-1`, etc.), source
176
+ paths, one-based Unicode lines/columns, and a coverage-notice count. Its exit code
177
+ is 1 when replacements are available, 0 otherwise. `diff` previews unified diffs
178
+ without editing; it requires the external `diff` command. `apply` explicitly
179
+ authorizes source replacement without prompting. Successful diff/apply commands
180
+ return 0; invalid rules, conflicts, unsafe edits, and input/output failures return
181
+ 2. Check/diff accept `--output` for a new file only; apply rejects `--output`.
182
+
183
+ All three accept `--lang ja`, `--base-dir`, `--config`, and `--only` (an included
184
+ source file). Normal configuration discovery and `review.base_dir`, `language`,
185
+ `attributes`, and `exclude` control parsing and selection. `--rules` is required;
186
+ `review.rules` and glossary entries do not supply replacement rules. There are
187
+ no built-in replacement rules and no automatic search for `prh.yml`.
188
+
189
+ ### Limited prh-format rules
190
+
191
+ The supported format is a strict subset of [prh](https://github.com/prh/prh):
192
+
193
+ ```yaml
194
+ version: 1
195
+ rules:
196
+ - expected: '®'
197
+ pattern: '&reg;'
198
+ - expected: '($1)'
199
+ pattern: '/(([^()\r\n]+))/'
200
+ specs:
201
+ - from: '設定(任意)'
202
+ to: '設定(任意)'
203
+ - expected: '/'
204
+ patterns:
205
+ - '/'
206
+ ```
207
+
208
+ The root requires `version: 1` and accepts optional `rules` and `imports` arrays.
209
+ Starting with version 0.8.1, omitted arrays default to empty. Version 0.8.0
210
+ requires `rules` and rejects `imports`. Each rule requires a string
211
+ `expected` (empty strings permit deletion) and exactly one of `pattern` (a
212
+ nonempty string or, starting with version 0.8.1, a nonempty array of nonempty
213
+ strings) or `patterns` (a nonempty array of nonempty strings). Array-valued
214
+ `pattern` is equivalent to `patterns`; specifying both keys is rejected. Optional
215
+ `specs` is an array of exact `from`/`to` string pairs, validated on load. Specs
216
+ exercise the rule on plain text, not AsciiDoc selection or inline protection.
217
+
218
+ Strings not beginning with `/` match literally. `/.../` denotes a regex; the
219
+ version 0.8.1 also accepts `/.../i` for Ruby case-insensitive matching.
220
+ Version 0.8.0 rejects all flags. All occurrences are collected. The implementation
221
+ uses Ruby regexes with
222
+ a timeout and accepts a limited common syntax: character classes, ordinary
223
+ captures, noncapturing groups, lookarounds, alternatives, anchors, quantifiers,
224
+ and the escapes `\n`, `\r`, `\t`, `\d`, `\D`, `\s`, `\S`, `\w`, `\W`
225
+ and escaped punctuation. Ruby regex character-class behavior applies; this is
226
+ not a JavaScript regex engine or a promise of full prh equivalence. Engine-specific
227
+ groups/escapes and flags other than a single `i` are rejected. A leading literal slash must be expressed
228
+ as an escaped regex, for example `/\//`. Zero-length matches are rejected.
229
+
230
+ Replacement strings support `$1` through `$99` for existing capture groups and
231
+ `$$` for a literal dollar sign. Unsupported dollar references are rejected when
232
+ matched. Missing optional captures expand to empty strings. YAML single quotes
233
+ are recommended to keep backslashes literal. Unknown fields, including
234
+ `options` and `regexpMustEmpty`, and omitted patterns are rejected rather than
235
+ ignored. prh's automatic pattern generation is not supported.
236
+
237
+ For example, version 0.8.1 also accepts:
238
+
239
+ ```yaml
240
+ version: 1
241
+ rules:
242
+ - expected: 'ハードウェア'
243
+ pattern:
244
+ - 'ハードウエア'
245
+ - 'ハードウェアー'
246
+ ```
247
+
248
+ Each array entry uses the same literal/regex syntax and validation as a single
249
+ pattern. Arrays do not change supported flags or overlap handling.
250
+
251
+ ### Limited ECMAScript boundary compatibility
252
+
253
+ Version 0.8.1 translates `\b` and `\B` outside character classes into Ruby
254
+ lookarounds with ECMAScript word-character semantics, following prh's default
255
+ Unicode mode. Word characters are ASCII letters, digits, and underscore; Japanese
256
+ characters are non-word characters. Thus `/\bTips\b/` matches `Tips` in
257
+ `前Tips後`, but not in `Tips2`, `_Tips`, or `ATips`.
258
+ With `/i`, long s (`ſ`) and Kelvin sign (`K`) also count as word characters under
259
+ ECMAScript Unicode case folding. The generated assertions add no capture groups
260
+ and preserve original match offsets. Inside a character class, `\b` means a
261
+ backspace character; `\B` there is rejected. Escaped literal backslashes are
262
+ preserved.
263
+
264
+ Only the boundary assertions emulate ECMAScript. `/i` uses Ruby's
265
+ `Regexp::IGNORECASE`; Unicode case folding can differ from JavaScript (for
266
+ example multi-character folds). Other constructs, including whitespace classes,
267
+ retain Ruby semantics. This is limited prh compatibility, not full ECMAScript
268
+ conformance. Flags `g`, `m`, `s`, `u`, `y`, `d`, and `v` and duplicate `i` are
269
+ rejected; all occurrences are already collected independently of `g`.
270
+
271
+ ### Importing replacement rules
272
+
273
+ Version 0.8.1 supports nested `imports`; version 0.8.0 does not. For example:
274
+
275
+ ```yaml
276
+ version: 1
277
+ imports:
278
+ - ../../rules/prh.yml
279
+ - path: ./terminology.yml
280
+ rules:
281
+ - expected: '®'
282
+ pattern: '&reg;'
283
+ ```
284
+
285
+ Each entry is a nonempty local file path string or a mapping containing exactly
286
+ `path`. Relative paths resolve from the importing YAML file, including nested
287
+ imports, independently of the working directory and manuscript base directory.
288
+ Absolute local paths are also accepted; URL imports are rejected. An import-only
289
+ file may omit `rules`. A file containing only `version: 1` is also valid and
290
+ contributes no rules. With no imported or local rules, `check`, `diff`, and
291
+ `apply` succeed without changing manuscript sources, and `diff` emits no patch.
292
+ Starting with version 0.8.1, a bare `rules:`, `rules: null`, and `rules: ~`
293
+ also contribute no local rules, just like omission or `rules: []`; imported rules
294
+ are still loaded. Other non-array values remain invalid. This normalization
295
+ applies only to `rules`, not to `imports` or individual rule fields.
296
+ Every imported file must use version 1 and pass the same
297
+ strict rule validation and specs as the entry file.
298
+
299
+ Imports are loaded in listed order, recursively before each file's own rules.
300
+ Each physical file is loaded once per plan, including repeated paths, symlink
301
+ aliases, and shared dependencies. Rule IDs are assigned across the flattened
302
+ set. Cycles (including symlink aliases), missing files, and import chains deeper
303
+ than 100 files fail rather than silently omitting rules. Imported and local rules
304
+ are combined, not overridden: overlapping replacement candidates remain errors
305
+ and replacements are still calculated only against the original text.
306
+
307
+ Import options such as `ignoreRules` and `disableImports` remain unsupported and
308
+ are rejected. Source selection and protection are unchanged by imports.
309
+
310
+ ### Selection and preservation
311
+
312
+ Only unambiguously source-mapped running prose in the existing default review
313
+ scope is eligible. Headings, lists, tables, quotations, listing/source, literal,
314
+ and passthrough blocks are excluded. Recognized inline code, quotations, URLs,
315
+ macros, and attribute references are protected by the existing conservative
316
+ inline heuristic, not a complete inline parser. A match overlapping any protected
317
+ span is skipped; matching is performed on the original text, never on blanked
318
+ placeholders. Backtick-to-bold and list-marker conversions are outside this scope.
319
+
320
+ Candidates are calculated once against the original sources; replacement results
321
+ are not searched again. Overlapping edits are conflicts, not resolved by rule
322
+ order. Matches/results containing line breaks are rejected. Source files retain
323
+ UTF-8 bytes, original line endings, and unrelated content; repeated include
324
+ occurrences must not create overlapping edits.
325
+
326
+ Before presenting a plan, the complete captured source set is copied to temporary
327
+ staging and reparsed. Parsing diagnostics, structural changes, new/deleted protected
328
+ inline tokens, and protected-content/source-membership changes reject the plan.
329
+ Relative includes within the base directory are supported. Absolute includes and
330
+ includes whose paths depend on the original filesystem may fail staged validation;
331
+ they are not rewritten or silently accepted. Symlink entrypoints are rejected.
332
+
333
+ Apply stages all changed files beside their destinations, checks all captured
334
+ source bytes against the plan, and renames the prepared files into place while
335
+ preserving permission bits. An ordinary installation error restores sources
336
+ already installed. This is not a crash-atomic transaction across multiple files;
337
+ keep manuscripts under version control and avoid concurrent edits during apply.
338
+ No review session is created or replaced. Existing sessions retain their original
339
+ baselines; applying replacements can make a session stale for prompt generation.
340
+ Mechanical validation does not establish semantic correctness.
341
+
138
342
  ## Score an AsciiDoc manuscript
139
343
 
140
344
  Pass any AsciiDoc entry file directly; no review session or gold annotations are
@@ -840,12 +1044,12 @@ reports analyzer changes instead of treating results from different dictionaries
840
1044
  as directly comparable. Prompt generation uses saved evidence and does not need
841
1045
  MeCab. Changed rules or dictionary settings require a new scan.
842
1046
 
843
- Sessions from earlier tool versions are not compatible with 0.6.3 (session schema 2). Keep the original baseline for
1047
+ Sessions from earlier tool versions are not compatible with 0.8.1 (session schema 2). Keep the original baseline for
844
1048
  an ongoing review and finish it with the original version, or start a new review
845
1049
  pass in a different directory:
846
1050
 
847
1051
  ```sh
848
- asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.6.3
1052
+ asciidoc-pubkit review scan book.adoc --output .pubkit/review-0.8.1
849
1053
  ```
850
1054
 
851
1055
  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,280 @@
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
+ collect(path, [], {}).each_with_index.map do |rule, index|
11
+ unless rule.is_a?(Hash) && (rule.keys - %w[expected pattern patterns specs]).empty? &&
12
+ rule['expected'].is_a?(String) && (rule.key?('pattern') ^ rule.key?('patterns'))
13
+ raise Error, "Rule #{index + 1} requires expected and exactly one of pattern or patterns; unsupported keys are rejected."
14
+ end
15
+ patterns = rule.key?('pattern') ? rule['pattern'] : rule['patterns']
16
+ patterns = [patterns] if rule.key?('pattern') && patterns.is_a?(String)
17
+ unless patterns.is_a?(Array) && !patterns.empty? && patterns.all? { |p| p.is_a?(String) && !p.empty? }
18
+ raise Error, 'Replacement patterns must be nonempty strings.'
19
+ end
20
+ compiled = patterns.map do |pattern|
21
+ if pattern.start_with?('/')
22
+ match = pattern.match(%r{\A/(.*)/([a-z]*)\z}m)
23
+ unless match && ['', 'i'].include?(match[2])
24
+ raise Error, 'Unsupported regex flags; only optional i is supported (all matches are collected).'
25
+ end
26
+ body = match[1]
27
+ # Deliberately small common JavaScript/Ruby subset; Ruby-only escapes
28
+ # and engine-specific groups must not silently change JS semantics.
29
+ if body.match?(/\\[^\\\/.*+?()\[\]{}^$|nrtdDsSwWbB-]/) || body.match?(/\(\?(?![:=!]|<[=!])/) || body.include?('&&')
30
+ raise Error, 'Unsupported regex syntax in replacement rule.'
31
+ end
32
+ ignore_case = match[2] == 'i'
33
+ body = javascript_boundaries(body, ignore_case: ignore_case)
34
+ Regexp.new(body.gsub('\\/', '/'), ignore_case ? Regexp::IGNORECASE : 0, timeout: 0.1)
35
+ else
36
+ Regexp.new(Regexp.escape(pattern), timeout: 0.1)
37
+ end
38
+ end
39
+ compiled.each do |regex|
40
+ regex.match('') # Detect common empty matches; runtime checks cover contextual ones.
41
+ raise Error, 'Zero-length replacement matches are unsupported.' if regex.match('')
42
+ end
43
+ specs = rule.fetch('specs', [])
44
+ raise Error, 'specs must be an array.' unless specs.is_a?(Array)
45
+ specs.each do |spec|
46
+ RuleSet.mapping(spec, %w[from to], 'Replacement spec')
47
+ raise Error, 'Spec from/to must be strings.' unless spec.values.all? { |v| v.is_a?(String) }
48
+ result = spec['from'].dup
49
+ edits = matches(spec['from'], compiled, rule['expected'])
50
+ reject_overlaps!(edits)
51
+ edits.reverse_each { |edit| result[edit[:start]...edit[:finish]] = edit[:after] }
52
+ raise Error, "Replacement spec failed for rule #{index + 1}." unless result == spec['to']
53
+ end
54
+ { id: "rule-#{index + 1}", patterns: compiled, expected: rule['expected'] }
55
+ end
56
+ rescue RegexpError, Regexp::TimeoutError => e
57
+ raise Error, "Invalid replacement regex: #{e.message}"
58
+ end
59
+
60
+ # prh uses Unicode JS regexes: word characters are ASCII letters/digits/_;
61
+ # Unicode ignore-case additionally folds long s and Kelvin sign to ASCII.
62
+ # Scope out Ruby's ignore-case so these assertion classes stay exact.
63
+ def self.javascript_boundaries(body, ignore_case:)
64
+ word = ignore_case ? '(?-i:[A-Za-z0-9_ſK])' : '[A-Za-z0-9_]'
65
+ boundary = "(?:(?<=#{word})(?!#{word})|(?<!#{word})(?=#{word}))"
66
+ nonboundary = "(?:(?<=#{word})(?=#{word})|(?<!#{word})(?!#{word}))"
67
+ chars = body.chars
68
+ result = +''
69
+ in_class = false
70
+ index = 0
71
+ while index < chars.length
72
+ char = chars[index]
73
+ if char == '\\' && index + 1 < chars.length
74
+ following = chars[index + 1]
75
+ if following == 'b'
76
+ result << (in_class ? '\x08' : boundary)
77
+ elsif following == 'B'
78
+ raise Error, '\B inside a character class is unsupported.' if in_class
79
+ result << nonboundary
80
+ else
81
+ result << char << following
82
+ end
83
+ index += 2
84
+ next
85
+ end
86
+ in_class = true if char == '['
87
+ in_class = false if char == ']'
88
+ result << char
89
+ index += 1
90
+ end
91
+ result
92
+ end
93
+
94
+ def self.collect(path, active, visited)
95
+ path = File.realpath(path)
96
+ if active.include?(path)
97
+ raise Error, "Circular replacement imports: #{(active + [path]).join(' -> ')}"
98
+ end
99
+ return [] if visited[path]
100
+ raise Error, 'Replacement imports exceed the maximum depth of 100 files.' if active.length >= 100
101
+ data = YAML.safe_load(AsciidocPubkit.read_text(path), permitted_classes: [], aliases: false)
102
+ unless data.is_a?(Hash) && data.key?('version') && (data.keys - %w[version rules imports]).empty?
103
+ raise Error, "Replacement rules in #{path} require version and accept only rules and imports."
104
+ end
105
+ unless data['version'].is_a?(Integer) && data['version'] == 1
106
+ raise Error, "Replacement version in #{path} must be integer 1."
107
+ end
108
+ rules = data.fetch('rules', [])
109
+ rules = [] if rules.nil?
110
+ imports = data.fetch('imports', [])
111
+ raise Error, "rules in #{path} must be an array." unless rules.is_a?(Array)
112
+ raise Error, "imports in #{path} must be an array." unless imports.is_a?(Array)
113
+ imported = imports.flat_map do |entry|
114
+ if entry.is_a?(Hash)
115
+ RuleSet.mapping(entry, ['path'], "Replacement import in #{path}")
116
+ entry = entry['path']
117
+ end
118
+ unless entry.is_a?(String) && !entry.strip.empty? && !entry.match?(/\A[a-z][a-z0-9+.-]*:/i)
119
+ raise Error, "Replacement imports in #{path} must specify nonempty local file paths."
120
+ end
121
+ collect(File.expand_path(entry, File.dirname(path)), active + [path], visited)
122
+ end
123
+ visited[path] = true
124
+ imported + rules
125
+ rescue Psych::Exception => e
126
+ raise Error, "Invalid replacement YAML in #{path}: #{e.message}"
127
+ end
128
+
129
+ def self.expand(template, match)
130
+ template.gsub(/\$\$|\$[1-9][0-9]?|\$./) do |ref|
131
+ if ref == '$$'
132
+ '$'
133
+ elsif ref.match?(/\A\$[1-9][0-9]?\z/) && ref[1..].to_i < match.length
134
+ match[ref[1..].to_i] || ''
135
+ else
136
+ raise Error, "Unsupported replacement reference: #{ref}"
137
+ end
138
+ end
139
+ end
140
+
141
+ def self.matches(text, patterns, expected)
142
+ patterns.flat_map do |regex|
143
+ text.to_enum(:scan, regex).map do
144
+ match = Regexp.last_match
145
+ raise Error, 'Zero-length replacement matches are unsupported.' if match.begin(0) == match.end(0)
146
+ { start: match.begin(0), finish: match.end(0), before: match[0], after: expand(expected, match) }
147
+ end
148
+ end.reject { |edit| edit[:before] == edit[:after] }.uniq.sort_by { |edit| edit[:start] }
149
+ rescue Regexp::TimeoutError
150
+ raise Error, 'Replacement regex timed out.'
151
+ end
152
+
153
+ def self.reject_overlaps!(edits)
154
+ edits.sort_by { |edit| edit[:start] }.each_cons(2) do |left, right|
155
+ raise Error, 'Conflicting replacement candidates overlap; revise the rules.' if left[:finish] > right[:start]
156
+ end
157
+ end
158
+ end
159
+
160
+ class Replacement
161
+ attr_reader :candidates
162
+
163
+ def initialize(entry, options)
164
+ raise Error, 'Replacement commands require --rules FILE.' unless options[:rules]
165
+ raise Error, 'Replacement entry must not be a symlink.' if File.symlink?(entry)
166
+ @entry = File.realpath(entry)
167
+ @only = options[:only] && File.realpath(options[:only])
168
+ @settings = Settings.new(entry, options.merge(replacement: true)).data
169
+ @document = Document.new(entry, @settings, only: options[:only])
170
+ raise Error, 'Replacement input has parsing diagnostics.' unless @document.diagnostics.empty?
171
+ rules = ReplacementRules.load(options[:rules])
172
+ @candidates = []
173
+ @document.paragraphs.each do |paragraph|
174
+ text = paragraph['text']
175
+ protected_ranges = text.to_enum(:scan, Rules::INLINE).map do
176
+ match = Regexp.last_match
177
+ match.begin(0)...match.end(0)
178
+ end
179
+ raw = @document.sources.fetch(paragraph['file'])
180
+ lines = raw.lines
181
+ line_offsets = [0]
182
+ lines.each { |line| line_offsets << line_offsets.last + line.length }
183
+ rules.each do |rule|
184
+ ReplacementRules.matches(text, rule[:patterns], rule[:expected]).each do |edit|
185
+ next if protected_ranges.any? { |range| edit[:start] < range.end && edit[:finish] > range.begin }
186
+ # Keep physical line boundaries intact, including CRLF.
187
+ raise Error, 'Replacement matches and results must stay on one line.' if (edit[:before] + edit[:after]).match?(/[\r\n]/)
188
+ prefix = text[0...edit[:start]]
189
+ line = paragraph['line'] + prefix.count("\n")
190
+ column = (prefix.rindex("\n") ? prefix.length - prefix.rindex("\n") : prefix.length + 1)
191
+ start = line_offsets.fetch(line - 1) + column - 1
192
+ raise Error, 'Replacement source mapping is ambiguous.' unless raw[start, edit[:before].length] == edit[:before]
193
+ @candidates << edit.merge(start: start, finish: start + edit[:before].length,
194
+ file: paragraph['file'], line: line, column: column, rule: rule[:id])
195
+ end
196
+ end
197
+ end
198
+ @candidates.uniq!
199
+ @candidates.sort_by! { |edit| [edit[:file], edit[:start], edit[:rule]] }
200
+ @candidates.group_by { |edit| edit[:file] }.each_value { |edits| ReplacementRules.reject_overlaps!(edits) }
201
+ @updated = @document.sources.transform_values(&:dup)
202
+ @candidates.reverse_each { |edit| @updated[edit[:file]][edit[:start]...edit[:finish]] = edit[:after] }
203
+ validate unless @candidates.empty?
204
+ end
205
+
206
+ def report
207
+ candidates.map { |edit| "#{edit[:file]}:#{edit[:line]}:#{edit[:column]}: #{edit[:rule]} #{edit[:before].inspect} -> #{edit[:after].inspect}\n" }.join +
208
+ "#{candidates.length} replacements; #{@document.coverage.length} coverage notices. Only mapped running prose is eligible.\n"
209
+ end
210
+
211
+ def changed
212
+ @updated.select { |path, text| text != @document.sources[path] }
213
+ end
214
+
215
+ def diff
216
+ changed.map do |path, text|
217
+ Tempfile.create('pubkit-before') do |before|
218
+ Tempfile.create('pubkit-after') do |after|
219
+ before.binmode.write(@document.sources[path]); before.flush
220
+ after.binmode.write(text); after.flush
221
+ output, error, status = Open3.capture3('diff', '-u', '--label', "#{path} (before)", '--label', "#{path} (after)", before.path, after.path)
222
+ raise Error, "Cannot generate diff: #{error}" unless [0, 1].include?(status.exitstatus)
223
+ output
224
+ end
225
+ end
226
+ end.join
227
+ end
228
+
229
+ def validate
230
+ root = File.realpath(@settings['base_dir'])
231
+ Dir.mktmpdir('pubkit-replacement-') do |stage|
232
+ stage = File.realpath(stage)
233
+ @updated.each do |path, text|
234
+ destination = File.join(stage, Pathname.new(path).relative_path_from(Pathname.new(root)).to_s)
235
+ FileUtils.mkdir_p(File.dirname(destination))
236
+ File.binwrite(destination, text)
237
+ end
238
+ staged_entry = File.join(stage, Pathname.new(@entry).relative_path_from(Pathname.new(root)).to_s)
239
+ staged_only = @only && File.join(stage, Pathname.new(@only).relative_path_from(Pathname.new(root)).to_s)
240
+ staged = Document.new(staged_entry, @settings.merge('base_dir' => stage), only: staged_only)
241
+ raise Error, 'Replacement changes parsing or document structure.' unless staged.diagnostics.empty? && staged.structure == @document.structure
242
+ original = Session.protected_content(@document).transform_keys { |path| Pathname.new(path).relative_path_from(Pathname.new(root)).to_s }
243
+ result = Session.protected_content(staged).transform_keys { |path| Pathname.new(path).relative_path_from(Pathname.new(stage)).to_s }
244
+ # --only can leave otherwise eligible paragraphs protected in the original.
245
+ if original != result
246
+ raise Error, 'Replacement changes source membership or protected content.'
247
+ end
248
+ end
249
+ end
250
+
251
+ def apply
252
+ installed = []
253
+ prepared = []
254
+ begin
255
+ changed.each do |path, text|
256
+ file = Tempfile.new('.pubkit-replace-', File.dirname(path))
257
+ prepared << [path, file]
258
+ file.binmode.write(text)
259
+ file.flush
260
+ file.fsync
261
+ File.chmod(File.stat(path).mode & 0o777, file.path)
262
+ end
263
+ @document.sources.each do |path, raw|
264
+ unless !File.symlink?(path) && File.binread(path) == raw.b
265
+ raise Error, "Source changed before replacement: #{path}"
266
+ end
267
+ end
268
+ prepared.each do |path, file|
269
+ File.rename(file.path, path)
270
+ installed << path
271
+ end
272
+ rescue StandardError
273
+ installed.reverse_each { |path| File.binwrite(path, @document.sources.fetch(path)) }
274
+ raise
275
+ ensure
276
+ prepared.each { |_, file| file.close! }
277
+ end
278
+ end
279
+ end
280
+ 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.1'
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.1
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