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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +113 -9
- data/examples/replacements.yml +17 -0
- data/lib/asciidoc_pubkit/cli.rb +23 -5
- data/lib/asciidoc_pubkit/replacement.rb +210 -0
- data/lib/asciidoc_pubkit/settings.rb +1 -1
- data/lib/asciidoc_pubkit.rb +2 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e6439ccb97f49f7e31290cb8d60b1f361cf444889ac0631cdd1f9e28f8a0d9c
|
|
4
|
+
data.tar.gz: 484ff3e7ac4475ea693ff7d5751bc34e1537b66dda90bb7d425bb55acb467322
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
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.
|
|
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.
|
|
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: '®'
|
|
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.
|
|
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.
|
|
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: '®'
|
|
6
|
+
specs:
|
|
7
|
+
- from: '製品®'
|
|
8
|
+
to: '製品®'
|
|
9
|
+
- expected: '($1)'
|
|
10
|
+
pattern: '/(([^()\r\n]+))/'
|
|
11
|
+
specs:
|
|
12
|
+
- from: '設定(任意)'
|
|
13
|
+
to: '設定(任意)'
|
|
14
|
+
- expected: ' '
|
|
15
|
+
pattern: ' '
|
|
16
|
+
- expected: '/'
|
|
17
|
+
pattern: '/'
|
data/lib/asciidoc_pubkit/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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 == '
|
|
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,
|
data/lib/asciidoc_pubkit.rb
CHANGED
|
@@ -9,7 +9,7 @@ require 'pathname'
|
|
|
9
9
|
require 'optparse'
|
|
10
10
|
|
|
11
11
|
module AsciidocPubkit
|
|
12
|
-
VERSION = '0.
|
|
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.
|
|
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,
|
|
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
|