polytypo 1.17.1 → 1.17.3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/polytypo/data/VERSION +1 -1
  3. data/lib/polytypo/data/fixtures/bg.json +1 -1
  4. data/lib/polytypo/data/fixtures/cs.json +1 -1
  5. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  6. data/lib/polytypo/data/fixtures/de-DE.json +9 -26
  7. data/lib/polytypo/data/fixtures/el.json +1 -1
  8. data/lib/polytypo/data/fixtures/en-GB.json +172 -1
  9. data/lib/polytypo/data/fixtures/en-US.json +181 -516
  10. data/lib/polytypo/data/fixtures/es.json +1 -1
  11. data/lib/polytypo/data/fixtures/fi.json +1 -1
  12. data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
  13. data/lib/polytypo/data/fixtures/fr.json +48 -37
  14. data/lib/polytypo/data/fixtures/hr.json +1 -1
  15. data/lib/polytypo/data/fixtures/hu.json +1 -1
  16. data/lib/polytypo/data/fixtures/it.json +1 -1
  17. data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
  18. data/lib/polytypo/data/fixtures/lt.json +1 -1
  19. data/lib/polytypo/data/fixtures/nl.json +1 -1
  20. data/lib/polytypo/data/fixtures/pl.json +1 -1
  21. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  22. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  23. data/lib/polytypo/data/fixtures/ro.json +1 -1
  24. data/lib/polytypo/data/fixtures/ru.json +1 -1
  25. data/lib/polytypo/data/fixtures/sk.json +1 -1
  26. data/lib/polytypo/data/fixtures/sl.json +1 -1
  27. data/lib/polytypo/data/fixtures/sr.json +1 -1
  28. data/lib/polytypo/data/fixtures/sv.json +1 -1
  29. data/lib/polytypo/data/fixtures/tr.json +1 -1
  30. data/lib/polytypo/data/fixtures/uk.json +1 -1
  31. data/lib/polytypo/data/locales/registry.json +1 -1
  32. data/lib/polytypo/data/rules/modes.md +124 -9
  33. data/lib/polytypo/data/rules/order.json +1 -1
  34. data/lib/polytypo/modes/markdown.rb +109 -5
  35. data/lib/polytypo/modes/runner.rb +13 -4
  36. data/lib/polytypo/modes/spans.rb +45 -3
  37. data/lib/polytypo/version.rb +1 -1
  38. metadata +1 -1
@@ -287,12 +287,108 @@ module Polytypo
287
287
  parser_source = normalize_lone_cr(mask_frontmatter(source, block))
288
288
 
289
289
  spans = []
290
+ state = { stack: [], frontmatter_end: block.nil? ? 0 : block.end, labels: [], url_ends: Set.new }
290
291
  ParseError.wrap do
291
- doc = Commonmarker.parse(parser_source, options: { parse: { sourcepos_chars: true } })
292
- walk(doc, chars, line_starts, spans, { stack: [], frontmatter_end: block.nil? ? 0 : block.end })
292
+ doc = Commonmarker.parse(
293
+ parser_source,
294
+ options: { parse: { sourcepos_chars: true }, extension: { footnotes: true } }
295
+ )
296
+ walk(doc, chars, line_starts, spans, state)
297
+ end
298
+ trim_url_tails(chars, Spans.normalize_spans(remove_ranges(spans, state[:labels])), state[:url_ends])
299
+ end
300
+
301
+ URL_PREFIXES = ["www.", "http://", "https://"].freeze
302
+
303
+ # modes.md 3.7.3 (spec 1.17.2): does a bare web address begin at +i+ and fit before +limit+?
304
+ # ASCII case-insensitive, and no test on the character before it.
305
+ def self.starts_url?(chars, i, limit)
306
+ URL_PREFIXES.any? do |word|
307
+ next false if i + word.length > limit
308
+
309
+ word.each_char.with_index.all? do |ch, k|
310
+ c = chars[i + k]
311
+ (c.ord >= 0x41 && c.ord <= 0x5A ? c.downcase : c) == ch
312
+ end
313
+ end
314
+ end
315
+ private_class_method :starts_url?
316
+
317
+ URL_TERMINATORS = [SPACE, TAB, LF, CR, "<"].freeze
318
+
319
+ # modes.md 3.7.3 (spec 1.17.2): the tail of a bare web address, found by scanning the source
320
+ # rather than asking a parser, because parsers disagree about where one may start and end. A
321
+ # span that begins at the U+0020 or U+0009 ending such an address is flagged +url_tail+, which
322
+ # guards its first code point (modes.md 3.4, Spans.filter_boundary_edits).
323
+ def self.trim_url_tails(chars, spans, url_ends)
324
+ out = []
325
+ spans.each do |span|
326
+ from = span.start
327
+ i = span.start
328
+ guard = span.url_tail == true ||
329
+ (url_ends.include?(span.start) && [SPACE, TAB].include?(chars[span.start]))
330
+ while i < span.end
331
+ unless (i == span.start && url_ends.include?(i)) || starts_url?(chars, i, span.end)
332
+ i += 1
333
+ next
334
+ end
335
+ e = i
336
+ e += 1 while e < span.end && !URL_TERMINATORS.include?(chars[e])
337
+ if e == i
338
+ i += 1
339
+ next
340
+ end
341
+ out << Spans::Span.new(from, i, guard ? true : nil) if i > from
342
+ guard = e < span.end && [SPACE, TAB].include?(chars[e])
343
+ from = e
344
+ i = e
345
+ end
346
+ out << Spans::Span.new(from, span.end, guard ? true : nil) if from < span.end
293
347
  end
294
- spans
348
+ out
349
+ end
350
+ private_class_method :trim_url_tails
351
+
352
+ def self.remove_ranges(spans, ranges)
353
+ return spans if ranges.empty?
354
+
355
+ spans.flat_map do |span|
356
+ ranges.reduce([span]) do |pieces, r|
357
+ pieces.flat_map do |q|
358
+ next [q] if r.end <= q.start || r.start >= q.end
359
+
360
+ res = []
361
+ res << Spans::Span.new(q.start, r.start) if q.start < r.start
362
+ res << Spans::Span.new(r.end, q.end) if r.end < q.end
363
+ res
364
+ end
365
+ end
366
+ end
367
+ end
368
+ private_class_method :remove_ranges
369
+
370
+ # modes.md 3.7.3 (spec 1.17.2): the text of a shortcut or collapsed reference is its label.
371
+ # A node exists only when the parser formed it from a definition, so the kind is read off the
372
+ # source after the closing bracket of the text: nothing (shortcut), "[]" (collapsed); a
373
+ # resource "(...)" or a non-empty "[label]" (full) leaves the text processable.
374
+ def self.reference_label_range(node, chars, line_starts)
375
+ sp = node.source_position
376
+ start_offset = offset_of(line_starts, sp[:start_line], sp[:start_column])
377
+ end_offset = offset_of(line_starts, sp[:end_line], sp[:end_column])
378
+ last = node.to_a.last
379
+ return nil if last.nil?
380
+
381
+ lsp = last.source_position
382
+ close = offset_of(line_starts, lsp[:end_line], lsp[:end_column]) + 1
383
+ return nil unless chars[close] == "]"
384
+
385
+ shortcut = end_offset == close
386
+ collapsed = end_offset == close + 2 && chars[close + 1] == "[" && chars[close + 2] == "]"
387
+ return nil unless shortcut || collapsed
388
+
389
+ Spans::Span.new(start_offset + (node.type == :image ? 2 : 1), close)
295
390
  end
391
+ private_class_method :reference_label_range
296
392
 
297
393
  # line_starts[i] is the 0-based code-point index at which comrak's 1-based line i begins.
298
394
  # comrak counts CommonMark line endings, which are U+000A, U+000D, and U+000D U+000A --
@@ -347,12 +443,20 @@ module Polytypo
347
443
  if type == :link || type == :image
348
444
  sp = node.source_position
349
445
  start_offset = offset_of(line_starts, sp[:start_line], sp[:start_column])
350
- unless chars[start_offset] == "["
446
+ opener = type == :image ? start_offset + 1 : start_offset
447
+ unless chars[opener] == "[" && (type == :link || chars[start_offset] == "!")
351
448
  # No leading "[" at the link's own source position: this is an autolink
352
449
  # (<https://…> or a bare GFM autolink literal), not a real [text](url) link --
353
- # skipped whole, per modes.md 3.7.3.
450
+ # skipped whole, per modes.md 3.7.3. The end of a literal that begins with a web
451
+ # prefix is where the tail scan resumes.
452
+ if type == :link && chars[start_offset] != "<" &&
453
+ starts_url?(chars, start_offset, chars.length)
454
+ state[:url_ends] << (offset_of(line_starts, sp[:end_line], sp[:end_column]) + 1)
455
+ end
354
456
  return
355
457
  end
458
+ label = reference_label_range(node, chars, line_starts)
459
+ state[:labels] << label unless label.nil?
356
460
  # A real link/image: fall through to the generic child walk below, which emits the
357
461
  # link text (or image alt text) as processable prose. The destination/title are never
358
462
  # represented as child nodes at all, so there is nothing further to exclude.
@@ -13,12 +13,16 @@ module Polytypo
13
13
  module Runner
14
14
  # The same sequence as Engine::Pipeline.run_rules, with the two boundary filters of
15
15
  # modes.md 3.4 interposed.
16
- def self.run_rules_over_spans(cp, plan, locale_data, ctx)
16
+ def self.run_rules_over_spans(cp, plan, locale_data, ctx, guarded = [])
17
17
  current = cp
18
+ markdown = ctx.mode == "markdown"
18
19
  plan.each do |rule_id|
19
20
  fn = Engine::Registry.rule(rule_id)
20
21
  edits = fn.call(current, locale_data, ctx)
21
- filtered = Spans.filter_boundary_edits(current, edits, Spans.span_ranges_of(current))
22
+ filtered = Spans.filter_boundary_edits(
23
+ current, edits, Spans.span_ranges_of(current),
24
+ delimiter_neighbours: markdown, guarded: guarded
25
+ )
22
26
  next if filtered.empty?
23
27
 
24
28
  current = Engine::Edits.apply_edits(current, filtered, rule_id)
@@ -61,7 +65,8 @@ module Polytypo
61
65
  return [] if normalized.empty?
62
66
 
63
67
  concatenated = Spans.concatenate_spans(source_cp, normalized)
64
- transformed = run_rules_over_spans(concatenated, plan, locale_data, ctx)
68
+ guarded = normalized.map { |s| s.url_tail == true }
69
+ transformed = run_rules_over_spans(concatenated, plan, locale_data, ctx, guarded)
65
70
  pieces = Spans.split_on_marker(transformed, normalized.length)
66
71
  normalized.each_with_index.map { |span, i| [span, pieces[i]] }
67
72
  end
@@ -91,12 +96,16 @@ module Polytypo
91
96
  normalized = Spans.normalize_spans(spans)
92
97
  return [] if normalized.empty?
93
98
 
99
+ guarded = normalized.map { |s| s.url_tail == true }
94
100
  Engine::Pipeline.run_rules_recording(
95
101
  Spans.concatenate_spans(source_cp, normalized),
96
102
  plan, locale_data, ctx,
97
103
  Spans.origin_of_spans(normalized),
98
104
  source_cp.length
99
- ) { |current, edits| Spans.filter_boundary_edits(current, edits, Spans.span_ranges_of(current)) }
105
+ ) do |current, edits|
106
+ Spans.filter_boundary_edits(current, edits, Spans.span_ranges_of(current),
107
+ delimiter_neighbours: ctx.mode == "markdown", guarded: guarded)
108
+ end
100
109
  end
101
110
  end
102
111
  end
@@ -18,7 +18,10 @@ module Polytypo
18
18
  # A processable span, identified by its offsets in the original source, addressed as
19
19
  # code-point indices (ARCHITECTURE.md section 4.2) -- Ruby String indices are already
20
20
  # code-point indices for a UTF-8 String, no separate byte/char distinction to manage here.
21
- Span = Struct.new(:start, :end)
21
+ #
22
+ # +url_tail+ is true (or nil) on a span that begins at the U+0020 or U+0009 ending a bare web
23
+ # address, so the filter can guard its first code point (modes.md 3.7.3, spec 1.17.2).
24
+ Span = Struct.new(:start, :end, :url_tail)
22
25
 
23
26
  # A span's extent in the concatenated code-point array: s0/s1 of modes.md 3.4. `:first`
24
27
  # deliberately shadows Struct#first with the equivalent one-argument-less accessor -- this
@@ -50,7 +53,7 @@ module Polytypo
50
53
  )
51
54
  end
52
55
  if span.start == last.end
53
- out[-1] = Span.new(last.start, span.end)
56
+ out[-1] = Span.new(last.start, span.end, last.url_tail)
54
57
  else
55
58
  out << span
56
59
  end
@@ -106,6 +109,35 @@ module Polytypo
106
109
  end
107
110
  private_class_method :span_containing
108
111
 
112
+ DELIMITERS = [0x2A, 0x5F, 0x7E].freeze
113
+
114
+ def self.markdown_whitespace?(c)
115
+ return false if c.nil?
116
+
117
+ [0x09, 0x0A, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, 0x202F, 0x205F, 0x3000].include?(c) ||
118
+ (c >= 0x2000 && c <= 0x200A)
119
+ end
120
+ private_class_method :markdown_whitespace?
121
+
122
+ # modes.md 3.4 delimiter-neighbour rule (markdown only, spec 1.17.2): an edit is discarded
123
+ # when, beside U+002A, U+005F or U+007E, exactly one of the old and new neighbour is
124
+ # CommonMark whitespace. A marker belongs to neither class, nor does a position outside the
125
+ # array. An insertion has q = p - 1.
126
+ def self.changes_delimiter_flank?(cp, p, q, replacement)
127
+ r = replacement.length
128
+ before_edit = p.positive? ? cp[p - 1] : nil
129
+ if DELIMITERS.include?(before_edit)
130
+ after = r.positive? ? replacement[0] : cp[q + 1]
131
+ return true if markdown_whitespace?(cp[p]) != markdown_whitespace?(after)
132
+ end
133
+ if q + 1 < cp.length && DELIMITERS.include?(cp[q + 1])
134
+ after = r.positive? ? replacement[r - 1] : before_edit
135
+ return true if markdown_whitespace?(q.negative? ? nil : cp[q]) != markdown_whitespace?(after)
136
+ end
137
+ false
138
+ end
139
+ private_class_method :changes_delimiter_flank?
140
+
109
141
  # modes.md 3.4, two safety nets, both pure functions of (p, q, r, s0, s1):
110
142
  #
111
143
  # 1. No edit may contain a marker -- one that does is a bug, discarded rather than
@@ -118,7 +150,11 @@ module Polytypo
118
150
  # clause tests the CHARACTER, and only U+0020 needs testing -- it is the one code
119
151
  # point any rule emits whose meaning comes from its position rather than from itself
120
152
  # (modes.md 5 item 2).
121
- def self.filter_boundary_edits(cp, edits, ranges)
153
+ #
154
+ # +delimiter_neighbours+ (markdown only) enables the delimiter-neighbour rule of modes.md 3.4,
155
+ # and +guarded+ holds, per span, whether its first code point is the terminator of a bare web
156
+ # address (modes.md 3.7.3); both spec 1.17.2.
157
+ def self.filter_boundary_edits(cp, edits, ranges, delimiter_neighbours: false, guarded: [])
122
158
  edits.select do |edit|
123
159
  next false if (edit.start...edit.end).any? { |i| Engine.marker?(cp[i]) }
124
160
 
@@ -126,7 +162,13 @@ module Polytypo
126
162
  q = edit.end - 1
127
163
  d = edit.end - edit.start
128
164
  r = edit.replacement.length
165
+ next false if delimiter_neighbours && changes_delimiter_flank?(cp, p, q, edit.replacement)
166
+
129
167
  span = span_containing(ranges, p)
168
+ if span && p == span.first && guarded[ranges.index(span)] == true &&
169
+ (r.zero? || edit.replacement[0] != SPACE)
170
+ next false
171
+ end
130
172
  next true unless span && (p == span.first || q == span.last)
131
173
  next false if r > d
132
174
  next false if r.positive? && p == span.first && edit.replacement[0] == SPACE && cp[p] != SPACE
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Polytypo
4
- VERSION = "1.17.1"
4
+ VERSION = "1.17.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polytypo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.1
4
+ version: 1.17.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Iurii Rogulia