polytypo 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/polytypo/data/README.md +20 -0
- data/lib/polytypo/data/UNICODE +1 -0
- data/lib/polytypo/data/VERSION +1 -0
- data/lib/polytypo/data/fixtures/de-CH.json +501 -0
- data/lib/polytypo/data/fixtures/de-DE.json +547 -0
- data/lib/polytypo/data/fixtures/el.json +239 -0
- data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
- data/lib/polytypo/data/fixtures/en-US.json +1807 -0
- data/lib/polytypo/data/fixtures/fi.json +1306 -0
- data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
- data/lib/polytypo/data/fixtures/fr.json +603 -0
- data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
- data/lib/polytypo/data/fixtures/ru.json +688 -0
- data/lib/polytypo/data/fixtures/sv.json +1290 -0
- data/lib/polytypo/data/locales/de-CH.json +77 -0
- data/lib/polytypo/data/locales/de-DE.json +76 -0
- data/lib/polytypo/data/locales/el.json +90 -0
- data/lib/polytypo/data/locales/en-GB.json +115 -0
- data/lib/polytypo/data/locales/en-US.json +133 -0
- data/lib/polytypo/data/locales/fi.json +136 -0
- data/lib/polytypo/data/locales/fr-CA.json +78 -0
- data/lib/polytypo/data/locales/fr.json +84 -0
- data/lib/polytypo/data/locales/registry.json +9 -0
- data/lib/polytypo/data/locales/ru.json +112 -0
- data/lib/polytypo/data/locales/sv.json +124 -0
- data/lib/polytypo/data/rules/dashes.md +1238 -0
- data/lib/polytypo/data/rules/order.json +78 -0
- data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
- data/lib/polytypo/data/schema/locale.schema.json +235 -0
- data/lib/polytypo/data/schema/registry.schema.json +29 -0
- data/lib/polytypo/data/schema/resolution.schema.json +50 -0
- data/lib/polytypo/engine/codepoints.rb +24 -0
- data/lib/polytypo/engine/edits.rb +64 -0
- data/lib/polytypo/engine/locale.rb +138 -0
- data/lib/polytypo/engine/pipeline.rb +61 -0
- data/lib/polytypo/engine/registry.rb +47 -0
- data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
- data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
- data/lib/polytypo/engine/rules/dashes.rb +125 -0
- data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
- data/lib/polytypo/engine/rules/hyphen.rb +207 -0
- data/lib/polytypo/engine/rules/nbsp.rb +616 -0
- data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
- data/lib/polytypo/engine/rules/quotes.rb +420 -0
- data/lib/polytypo/engine/rules/ranges.rb +124 -0
- data/lib/polytypo/engine/rules/spaces.rb +232 -0
- data/lib/polytypo/engine/rules/symbols.rb +291 -0
- data/lib/polytypo/engine/rules.rb +19 -0
- data/lib/polytypo/engine/sentinels.rb +23 -0
- data/lib/polytypo/engine/unicode_util.rb +390 -0
- data/lib/polytypo/errors.rb +24 -0
- data/lib/polytypo/modes/html.rb +233 -0
- data/lib/polytypo/modes/markdown.rb +187 -0
- data/lib/polytypo/modes/parse_error.rb +19 -0
- data/lib/polytypo/modes/runner.rb +57 -0
- data/lib/polytypo/modes/spans.rb +132 -0
- data/lib/polytypo/version.rb +5 -0
- data/lib/polytypo.rb +91 -0
- data/polytypo.gemspec +37 -0
- metadata +122 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "codepoints"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Polytypo
|
|
8
|
+
module Engine
|
|
9
|
+
# Locale resolution (spec/rules/locale-resolution.md) and locale data loading. Resolution is
|
|
10
|
+
# specified centrally and index-based -- no regex, no host locale, no platform
|
|
11
|
+
# locale-negotiation library (ARCHITECTURE.md sections 4.1, 4.4, 4.7). Data is embedded in the
|
|
12
|
+
# installed gem (lib/polytypo/data/), read once and memoized -- resolution itself stays a pure
|
|
13
|
+
# function of (locale, registry), never mutated after first load.
|
|
14
|
+
module Locale
|
|
15
|
+
DATA_DIR = File.join(__dir__, "..", "data")
|
|
16
|
+
|
|
17
|
+
HYPHEN = 0x2d
|
|
18
|
+
UNDERSCORE = 0x5f
|
|
19
|
+
UPPER_A = 0x41
|
|
20
|
+
UPPER_Z = 0x5a
|
|
21
|
+
LOWER_A = 0x61
|
|
22
|
+
LOWER_Z = 0x7a
|
|
23
|
+
CASE_GAP = 0x20
|
|
24
|
+
|
|
25
|
+
def self.registry
|
|
26
|
+
@registry ||= JSON.parse(File.read(File.join(DATA_DIR, "locales", "registry.json")))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.locale_data_raw(locale_id)
|
|
30
|
+
@locale_data_raw ||= {}
|
|
31
|
+
@locale_data_raw[locale_id] ||=
|
|
32
|
+
JSON.parse(File.read(File.join(DATA_DIR, "locales", "#{locale_id}.json")))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.lower_ascii?(cp)
|
|
36
|
+
cp >= LOWER_A && cp <= LOWER_Z
|
|
37
|
+
end
|
|
38
|
+
private_class_method :lower_ascii?
|
|
39
|
+
|
|
40
|
+
def self.upper_ascii?(cp)
|
|
41
|
+
cp >= UPPER_A && cp <= UPPER_Z
|
|
42
|
+
end
|
|
43
|
+
private_class_method :upper_ascii?
|
|
44
|
+
|
|
45
|
+
# spec/rules/locale-resolution.md 3.2. ASCII arithmetic only: no downcase/upcase, no ICU,
|
|
46
|
+
# no host locale, so a Turkish process resolves "EN-us" exactly as a Finnish one does
|
|
47
|
+
# (ARCHITECTURE.md 4.4).
|
|
48
|
+
def self.canonicalize(tag)
|
|
49
|
+
c = Codepoints.to_codepoints(tag).map { |cp| cp == UNDERSCORE ? HYPHEN : cp }
|
|
50
|
+
m = c.length
|
|
51
|
+
if m >= 2
|
|
52
|
+
[0, 1].each { |j| c[j] += CASE_GAP if upper_ascii?(c[j]) }
|
|
53
|
+
end
|
|
54
|
+
if m == 5 && c[2] == HYPHEN
|
|
55
|
+
[3, 4].each { |j| c[j] -= CASE_GAP if lower_ascii?(c[j]) }
|
|
56
|
+
end
|
|
57
|
+
c
|
|
58
|
+
end
|
|
59
|
+
private_class_method :canonicalize
|
|
60
|
+
|
|
61
|
+
# spec/rules/locale-resolution.md 3.3. Exactly two accepted shapes, tested by index rather
|
|
62
|
+
# than by pattern (ARCHITECTURE.md 4.1).
|
|
63
|
+
def self.accepted_shape?(c)
|
|
64
|
+
case c.length
|
|
65
|
+
when 2
|
|
66
|
+
lower_ascii?(c[0]) && lower_ascii?(c[1])
|
|
67
|
+
when 5
|
|
68
|
+
lower_ascii?(c[0]) && lower_ascii?(c[1]) && c[2] == HYPHEN &&
|
|
69
|
+
upper_ascii?(c[3]) && upper_ascii?(c[4])
|
|
70
|
+
else
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :accepted_shape?
|
|
75
|
+
|
|
76
|
+
# Alias values are concrete locales by registry invariant (locale-resolution.md 2). A
|
|
77
|
+
# violation is a spec-data bug, reported rather than trusted.
|
|
78
|
+
def self.alias_target(tag)
|
|
79
|
+
reg = registry
|
|
80
|
+
return nil unless reg["aliases"].key?(tag)
|
|
81
|
+
|
|
82
|
+
target = reg["aliases"][tag]
|
|
83
|
+
unless reg["locales"].include?(target)
|
|
84
|
+
raise Polytypo::Error.new(
|
|
85
|
+
Polytypo::CODE_MALFORMED_LOCALE_DATA,
|
|
86
|
+
"registry alias #{tag.inspect} points at #{target.inspect}, which is not a declared locale"
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
target
|
|
90
|
+
end
|
|
91
|
+
private_class_method :alias_target
|
|
92
|
+
|
|
93
|
+
# Exact match before alias, so a registry that wrongly lists a tag in both cannot make
|
|
94
|
+
# lookup order observable (locale-resolution.md 3.4).
|
|
95
|
+
def self.lookup(tag)
|
|
96
|
+
return tag if registry["locales"].include?(tag)
|
|
97
|
+
|
|
98
|
+
alias_target(tag)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :lookup
|
|
101
|
+
|
|
102
|
+
# Exact match, then the registry alias table, then the language subtag alone once with no
|
|
103
|
+
# chain. Never a platform locale negotiator (ARCHITECTURE.md 4.7). An empty or malformed tag
|
|
104
|
+
# has no accepted shape and falls through to the same unknown-locale error as any other
|
|
105
|
+
# unresolvable tag.
|
|
106
|
+
def self.resolve(tag)
|
|
107
|
+
unless tag.is_a?(String) && !tag.empty?
|
|
108
|
+
raise Polytypo::Error.new(
|
|
109
|
+
Polytypo::CODE_UNKNOWN_LOCALE,
|
|
110
|
+
"the locale option is required and must be a non-empty string; received #{tag.inspect}"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
c = canonicalize(tag)
|
|
115
|
+
if accepted_shape?(c)
|
|
116
|
+
canonical = Codepoints.from_codepoints(c)
|
|
117
|
+
direct = lookup(canonical)
|
|
118
|
+
return direct unless direct.nil?
|
|
119
|
+
|
|
120
|
+
if c.length == 5
|
|
121
|
+
base = Codepoints.from_codepoints(c[0, 2])
|
|
122
|
+
resolved = lookup(base)
|
|
123
|
+
return resolved unless resolved.nil?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
raise Polytypo::Error.new(
|
|
128
|
+
Polytypo::CODE_UNKNOWN_LOCALE,
|
|
129
|
+
"unknown locale #{tag.inspect}. Known locales: #{registry["locales"].join(", ")}."
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.locale_data(tag)
|
|
134
|
+
locale_data_raw(resolve(tag))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "edits"
|
|
4
|
+
require_relative "locale"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Polytypo
|
|
9
|
+
module Engine
|
|
10
|
+
# Per-call context every rule reads, alongside the code-point array and the locale data.
|
|
11
|
+
RuleContext = Struct.new(:mode, :dialect, :locale, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Resolves the locale, builds the rule plan, and runs each enabled rule in
|
|
14
|
+
# spec/rules/order.json order over a code-point array, applying its edits before the next
|
|
15
|
+
# rule sees it. No module-level mutable state beyond the immutable, load-once-on-first-use
|
|
16
|
+
# spec data in Registry/Locale (ARCHITECTURE.md section 7).
|
|
17
|
+
module Pipeline
|
|
18
|
+
# Defaults + opt-out overrides. rules_option is opt-out only (ARCHITECTURE.md section 7):
|
|
19
|
+
# it may only disable a default-on rule or enable the one default-off rule ("ranges"). An
|
|
20
|
+
# unknown key raises POLYTYPO_UNKNOWN_RULE.
|
|
21
|
+
def self.resolve_rule_plan(rules_option)
|
|
22
|
+
defaults = Registry.rule_defaults
|
|
23
|
+
enabled = defaults.dup
|
|
24
|
+
(rules_option || {}).each do |rule_id, flag|
|
|
25
|
+
unless defaults.key?(rule_id)
|
|
26
|
+
raise Polytypo::Error.new(Polytypo::CODE_UNKNOWN_RULE, "unknown rule id: #{rule_id.inspect}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
enabled[rule_id] = flag ? true : false
|
|
30
|
+
end
|
|
31
|
+
Registry.rule_order.select { |id| enabled[id] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds the rule plan, then resolves the locale and loads its data -- the setup step
|
|
35
|
+
# shared by the text pipeline and the span-runner (html/markdown modes). Order matters and
|
|
36
|
+
# is public, tested behaviour: an unknown-rule error must win over an unknown-locale error
|
|
37
|
+
# when both are present (mirrors every other port exactly).
|
|
38
|
+
def self.prepare(locale, rules_option)
|
|
39
|
+
plan = resolve_rule_plan(rules_option)
|
|
40
|
+
resolved_locale = Locale.resolve(locale)
|
|
41
|
+
locale_data = Locale.locale_data_raw(resolved_locale)
|
|
42
|
+
[resolved_locale, locale_data, plan]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Runs each enabled rule in order.json order over cp, applying its edits before the next
|
|
46
|
+
# rule sees the array. Shared by text mode and the span-runner (modes.md 3.5), which
|
|
47
|
+
# interposes the two boundary filters of modes.md 3.4 between rule and apply.
|
|
48
|
+
def self.run_rules(cp, plan, locale_data, ctx)
|
|
49
|
+
current = cp
|
|
50
|
+
plan.each do |rule_id|
|
|
51
|
+
fn = Registry.rule(rule_id)
|
|
52
|
+
edits = fn.call(current, locale_data, ctx)
|
|
53
|
+
next if edits.empty?
|
|
54
|
+
|
|
55
|
+
current = Edits.apply_edits(current, edits, rule_id)
|
|
56
|
+
end
|
|
57
|
+
current
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Polytypo
|
|
6
|
+
module Engine
|
|
7
|
+
# Rule order and defaults, derived from lib/polytypo/data/rules/order.json at load time --
|
|
8
|
+
# never hand-duplicated (ARCHITECTURE.md section 4.5: order.json is the single source of
|
|
9
|
+
# truth, never registration order, never Hash-iteration order).
|
|
10
|
+
module Registry
|
|
11
|
+
DATA_DIR = File.join(__dir__, "..", "data")
|
|
12
|
+
|
|
13
|
+
def self.order_data
|
|
14
|
+
@order_data ||= JSON.parse(File.read(File.join(DATA_DIR, "rules", "order.json")))
|
|
15
|
+
end
|
|
16
|
+
private_class_method :order_data
|
|
17
|
+
|
|
18
|
+
# Rule ids in ascending pipeline order.
|
|
19
|
+
def self.rule_order
|
|
20
|
+
@rule_order ||= order_data["rules"].sort_by { |r| r["order"] }.map { |r| r["id"] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Rule id -> default enabled/disabled (only "ranges" defaults to false). order.json's
|
|
24
|
+
# "default" field is the string "on"/"off", not a JSON boolean.
|
|
25
|
+
def self.rule_defaults
|
|
26
|
+
@rule_defaults ||= order_data["rules"].to_h { |r| [r["id"], r["default"] == "on"] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@rules = {}
|
|
30
|
+
|
|
31
|
+
# Adds a rule implementation to the registry. Called from each rule file's own load,
|
|
32
|
+
# mirroring the other ports' static-registration pattern -- requiring the rules directory
|
|
33
|
+
# is enough, with no separate caller-side step.
|
|
34
|
+
def self.register(rule_id, callable)
|
|
35
|
+
@rules[rule_id] = callable
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.rule(rule_id)
|
|
39
|
+
@rules.fetch(rule_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.known_rule_ids
|
|
43
|
+
rule_defaults.keys
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "quote_ambiguity"
|
|
4
|
+
require_relative "../sentinels"
|
|
5
|
+
require_relative "../unicode_util"
|
|
6
|
+
require_relative "../edits"
|
|
7
|
+
require_relative "../registry"
|
|
8
|
+
|
|
9
|
+
module Polytypo
|
|
10
|
+
module Engine
|
|
11
|
+
module Rules
|
|
12
|
+
# spec/rules/apostrophe.md (spec 0.5.0), order 50.
|
|
13
|
+
#
|
|
14
|
+
# Converts a straight U+0027 to U+2019 where it is genuinely an apostrophe: a contraction,
|
|
15
|
+
# an elision, a possessive, or a decade elision. Runs immediately after `quotes` (order 40)
|
|
16
|
+
# and sees only the U+0027 marks quotes declined to claim. Every edit is one code point
|
|
17
|
+
# replacing one code point; the rule never inserts, never deletes, and never touches U+2019
|
|
18
|
+
# itself.
|
|
19
|
+
#
|
|
20
|
+
# As of spec 0.5.0 this rule additionally skips a small, precisely-defined set of positions
|
|
21
|
+
# entirely (apostrophe.md 3.4) -- the shared ambiguous-medial-span preserve set,
|
|
22
|
+
# QuoteAmbiguity.compute_preserve_indices -- rather than applying its case ladder to them.
|
|
23
|
+
module Apostrophe
|
|
24
|
+
SQ = 0x27
|
|
25
|
+
RIGHT_SINGLE = 0x2019
|
|
26
|
+
|
|
27
|
+
# OPENISH is apostrophe.md 3.1 OPENISH. Unlike quotes.md's OPENISH, this is not "every
|
|
28
|
+
# QUOTEMARK" -- only the specific opening-shaped glyphs the spec lists. Engine::MARKER is
|
|
29
|
+
# a member (modes.md 3.3's table names this rule explicitly).
|
|
30
|
+
OPENISH = [
|
|
31
|
+
Engine::MARKER,
|
|
32
|
+
0x28, 0x5B, 0x7B, 0xAB,
|
|
33
|
+
0x2018, 0x201A, 0x201B, 0x201C, 0x201E, 0x201F, 0x2039,
|
|
34
|
+
0x2D, 0x2011, 0x2013, 0x2014
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# CLOSEISH is apostrophe.md 3.1 CLOSEISH. U+2019 is a member and U+0027 is a member of
|
|
38
|
+
# neither this nor OPENISH -- apostrophe.md 5 turns exactly that asymmetry into the
|
|
39
|
+
# idempotency argument. U+2011 sits beside U+002D because `hyphen` (order 35) converts one
|
|
40
|
+
# to the other.
|
|
41
|
+
CLOSEISH = [
|
|
42
|
+
Engine::MARKER,
|
|
43
|
+
0x29, 0x5D, 0x7D, 0xBB,
|
|
44
|
+
0x2019, 0x201D, 0x203A,
|
|
45
|
+
0x2C, 0x2E, 0x3B, 0x3A, 0x21, 0x3F, 0x2026,
|
|
46
|
+
0x2D, 0x2011, 0x2013, 0x2014
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
BREAK = [0x0A, 0x0D, 0x0B, 0x0C, 0x85, 0x2028, 0x2029, Engine::LINE_MARKER].freeze
|
|
50
|
+
|
|
51
|
+
# SPACELIKE, including Engine::LINE_MARKER as a member of BREAK for every rule everywhere
|
|
52
|
+
# (modes.md 3.2).
|
|
53
|
+
def self.spacelike?(cp)
|
|
54
|
+
QuoteAmbiguity.inline_space?(cp) || BREAK.include?(cp)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.digit?(cp)
|
|
58
|
+
cp >= 0x30 && cp <= 0x39
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.alnum?(cp)
|
|
62
|
+
digit?(cp) || UnicodeUtil.letter?(cp)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# apostrophe? is the case ladder of apostrophe.md 3.3, first match wins. Every verdict
|
|
66
|
+
# is a pure function of exactly two neighbouring code points; there is no lookahead and no
|
|
67
|
+
# state carried between candidates.
|
|
68
|
+
def self.apostrophe?(left, right)
|
|
69
|
+
# Case 1 -- prime guard, first so it wins over case 3: `6' 2"`, `55° 40' N`, `6'2"`. A
|
|
70
|
+
# foot mark is not an apostrophe.
|
|
71
|
+
return false if digit?(left) && !UnicodeUtil.letter?(right)
|
|
72
|
+
|
|
73
|
+
# Case 2 -- medial apostrophe: `don't`, `l'été`, `O'Brien`, `1990's`.
|
|
74
|
+
return true if alnum?(left) && alnum?(right)
|
|
75
|
+
|
|
76
|
+
# Case 3 -- trailing elision or possessive: `the dogs' bowls`, `Jesus'`, `rock 'n'` (the
|
|
77
|
+
# trailing mark).
|
|
78
|
+
if left != Engine::NONE && UnicodeUtil.letter?(left) &&
|
|
79
|
+
(right == Engine::NONE || spacelike?(right) || CLOSEISH.include?(right))
|
|
80
|
+
return true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Case 4 -- leading elision: `'90s`, `'tis`, `'em`, `'n'` (the leading mark). The
|
|
84
|
+
# replacement is U+2019, never U+2018 -- a leading elision is a raised comma, not an
|
|
85
|
+
# opening quotation mark, and `quotes` has already had its chance to claim the mark as a
|
|
86
|
+
# quotation and declined (quotes.md 3.2, 5).
|
|
87
|
+
if (left == Engine::NONE || spacelike?(left) || OPENISH.include?(left)) && alnum?(right)
|
|
88
|
+
return true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Case 5 -- nothing inferable: `a ' b`, `''`. Leave it.
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.scan(cp, locale_data, _ctx)
|
|
96
|
+
n = cp.length
|
|
97
|
+
# apostrophe.md 3.4, spec 0.5.0: positions in the shared ambiguous-medial-span preserve
|
|
98
|
+
# set (an ambiguous shape with no cited elisionIdioms match) are skipped entirely,
|
|
99
|
+
# before left/right are even read -- this rule's own case ladder would otherwise curl
|
|
100
|
+
# both marks of e.g. `rock 'n' roll` independently and silently defeat quotes'
|
|
101
|
+
# deliberate veto.
|
|
102
|
+
idioms = (locale_data["quotes"] || {})["elisionIdioms"] || []
|
|
103
|
+
preserve = QuoteAmbiguity.compute_preserve_indices(cp, idioms)
|
|
104
|
+
|
|
105
|
+
edits = []
|
|
106
|
+
(0...n).each do |i|
|
|
107
|
+
next unless cp[i] == SQ
|
|
108
|
+
next if preserve.key?(i)
|
|
109
|
+
|
|
110
|
+
left = QuoteAmbiguity.at(cp, i - 1)
|
|
111
|
+
right = QuoteAmbiguity.at(cp, i + 1)
|
|
112
|
+
next unless apostrophe?(left, right)
|
|
113
|
+
|
|
114
|
+
edits << Engine::Edit.new(i, i + 1, [RIGHT_SINGLE], "apostrophe")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
edits
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
Polytypo::Engine::Registry.register(
|
|
125
|
+
"apostrophe",
|
|
126
|
+
->(cp, locale_data, ctx) { Polytypo::Engine::Rules::Apostrophe.scan(cp, locale_data, ctx) }
|
|
127
|
+
)
|