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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +109 -0
  4. data/lib/polytypo/data/README.md +20 -0
  5. data/lib/polytypo/data/UNICODE +1 -0
  6. data/lib/polytypo/data/VERSION +1 -0
  7. data/lib/polytypo/data/fixtures/de-CH.json +501 -0
  8. data/lib/polytypo/data/fixtures/de-DE.json +547 -0
  9. data/lib/polytypo/data/fixtures/el.json +239 -0
  10. data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
  11. data/lib/polytypo/data/fixtures/en-US.json +1807 -0
  12. data/lib/polytypo/data/fixtures/fi.json +1306 -0
  13. data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
  14. data/lib/polytypo/data/fixtures/fr.json +603 -0
  15. data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
  16. data/lib/polytypo/data/fixtures/ru.json +688 -0
  17. data/lib/polytypo/data/fixtures/sv.json +1290 -0
  18. data/lib/polytypo/data/locales/de-CH.json +77 -0
  19. data/lib/polytypo/data/locales/de-DE.json +76 -0
  20. data/lib/polytypo/data/locales/el.json +90 -0
  21. data/lib/polytypo/data/locales/en-GB.json +115 -0
  22. data/lib/polytypo/data/locales/en-US.json +133 -0
  23. data/lib/polytypo/data/locales/fi.json +136 -0
  24. data/lib/polytypo/data/locales/fr-CA.json +78 -0
  25. data/lib/polytypo/data/locales/fr.json +84 -0
  26. data/lib/polytypo/data/locales/registry.json +9 -0
  27. data/lib/polytypo/data/locales/ru.json +112 -0
  28. data/lib/polytypo/data/locales/sv.json +124 -0
  29. data/lib/polytypo/data/rules/dashes.md +1238 -0
  30. data/lib/polytypo/data/rules/order.json +78 -0
  31. data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
  32. data/lib/polytypo/data/schema/locale.schema.json +235 -0
  33. data/lib/polytypo/data/schema/registry.schema.json +29 -0
  34. data/lib/polytypo/data/schema/resolution.schema.json +50 -0
  35. data/lib/polytypo/engine/codepoints.rb +24 -0
  36. data/lib/polytypo/engine/edits.rb +64 -0
  37. data/lib/polytypo/engine/locale.rb +138 -0
  38. data/lib/polytypo/engine/pipeline.rb +61 -0
  39. data/lib/polytypo/engine/registry.rb +47 -0
  40. data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
  41. data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
  42. data/lib/polytypo/engine/rules/dashes.rb +125 -0
  43. data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
  44. data/lib/polytypo/engine/rules/hyphen.rb +207 -0
  45. data/lib/polytypo/engine/rules/nbsp.rb +616 -0
  46. data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
  47. data/lib/polytypo/engine/rules/quotes.rb +420 -0
  48. data/lib/polytypo/engine/rules/ranges.rb +124 -0
  49. data/lib/polytypo/engine/rules/spaces.rb +232 -0
  50. data/lib/polytypo/engine/rules/symbols.rb +291 -0
  51. data/lib/polytypo/engine/rules.rb +19 -0
  52. data/lib/polytypo/engine/sentinels.rb +23 -0
  53. data/lib/polytypo/engine/unicode_util.rb +390 -0
  54. data/lib/polytypo/errors.rb +24 -0
  55. data/lib/polytypo/modes/html.rb +233 -0
  56. data/lib/polytypo/modes/markdown.rb +187 -0
  57. data/lib/polytypo/modes/parse_error.rb +19 -0
  58. data/lib/polytypo/modes/runner.rb +57 -0
  59. data/lib/polytypo/modes/spans.rb +132 -0
  60. data/lib/polytypo/version.rb +5 -0
  61. data/lib/polytypo.rb +91 -0
  62. data/polytypo.gemspec +37 -0
  63. 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
+ )