metanorma-utils 2.0.7 → 2.0.8

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: 76df06597b667091ccab17e9c0229f4304769d1c21b762bac6f55494a8314c16
4
- data.tar.gz: 75e494060f32426997f7637a937148866b148d34699d65e18d53309b4070ed7b
3
+ metadata.gz: 4cb1c9e13d72f7f7310918941cd4df7243968915307e4ec830160237b715ea54
4
+ data.tar.gz: 9219b32ec8d1672f82d5c3e7d88f1903d39e4e19ddb481f8c7748c55810779c3
5
5
  SHA512:
6
- metadata.gz: f6ccbe14dc7d8bda84b6b9f8ac1d5c5ea94a149d3f4da74222bb3395e108d42fe60960a922e9db595401ccf48179f9a1f5f6a93424c29cbc1ce9365b3c5437ad
7
- data.tar.gz: b77a8e2e913de8c9d2e56178d73ced28ed2ed90857eca8cf61ba0347b5a936a5f78386c9dd08e18fc40a9e297cf399617d7fc93d7923527c741763e179ba0940
6
+ metadata.gz: f7821e4c4b22b02182b5a66e561fcccfd8a0dc2cfdb304f347eab986d5c5a444565380affcb80a11c6e96a1fcf1cedf255b8c82d8eaccd3dab2937664f66622b
7
+ data.tar.gz: f1a9df51ff4cf0b8c3376407b63f45ef4766845bd3c6659e4f479956df60bfeba1307e86ae91ebc7a033c45a88d25199e0ce9dc79f5b636d9f922a9466eec7cd
data/.rubocop.yml CHANGED
@@ -1,10 +1,31 @@
1
1
  # Auto-generated by Cimas: Do not edit it manually!
2
2
  # See https://github.com/metanorma/cimas
3
3
  inherit_from:
4
- - https://raw.githubusercontent.com/riboseinc/oss-guides/master/ci/rubocop.yml
4
+ - https://raw.githubusercontent.com/riboseinc/oss-guides/main/ci/rubocop.yml
5
+ # .rubocop_todo.yml MUST be the last entry. inherit_from is last-wins:
6
+ # if listed before oss-guides, the shared config's stricter Metrics/
7
+ # (MethodLength, BlockLength, etc.) rules override the todo's per-file
8
+ # grandfathering, and the todo becomes inert on those cops. Empirically
9
+ # verified on suma 2026-07-06: with todo listed first, 34 Metrics/* offenses
10
+ # remained; moved last, cleared. Every gem in the metanorma-org fleet
11
+ # already ships a `.rubocop_todo.yml` on its live tree (audited 2026-07-06,
12
+ # 54/54 have it), so this reference is safe to emit unconditionally.
13
+ - .rubocop_todo.yml
14
+
15
+ # Rubocop plugins enabled centrally so every metanorma-org gem picks them up
16
+ # on cimas sync — best practice belongs at the shared-template layer, not
17
+ # per-repo. Per ronaldtse feedback on metanorma/ci#332.
18
+ plugins:
19
+ - rubocop-rspec
20
+ - rubocop-performance
21
+ - rubocop-rake
5
22
 
6
23
  # local repo-specific modifications
7
24
  # ...
8
25
 
9
26
  AllCops:
10
- TargetRubyVersion: 2.5
27
+ # 3.3 matches the org-wide minimum being pushed via #274 (Raise minimum
28
+ # Ruby version to 3.3 due to EOL of 3.2 on 2026-03-31). Was 3.4 before
29
+ # this commit — 3.4 was above the org's stated minimum and would have
30
+ # applied Rubocop rules that fail-close on gems still targeting 3.3.
31
+ TargetRubyVersion: 3.3
@@ -7,3 +7,5 @@ require_relative "utils/log"
7
7
  require_relative "utils/xml"
8
8
  require_relative "utils/anchor"
9
9
  require_relative "utils/anchor_ranges"
10
+ require_relative "utils/gc_budget"
11
+ require_relative "utils/asciidoctor_table_buffer"
data/lib/utils/anchor.rb CHANGED
@@ -105,7 +105,7 @@ module Metanorma
105
105
  def to_ncname_prep(name, asciionly)
106
106
  name = name&.to_s
107
107
  name.nil? and name = ""
108
- asciionly and name = HTMLEntities.new.encode(name,
108
+ asciionly and name = HTML_ENTITIES.encode(name,
109
109
  :basic, :hexadecimal)
110
110
  [name, name.nil? || name.empty? || name.match?(SAFE_NCNAME_REGEXP)]
111
111
  end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asciidoctor"
4
+
5
+ # Asciidoctor 2.0.x accumulates a multi-line table cell by rebuilding the
6
+ # whole cell buffer String on every appended line
7
+ # (`parser_ctx.buffer = %(#{parser_ctx.buffer}#{line}#{LF})` in
8
+ # Asciidoctor::Parser.parse_table), so building a cell that spans N lines
9
+ # is quadratic in the bytes of the cell. Generated tables with cells that
10
+ # span tens of thousands of lines allocate ~10 GB of transient Strings
11
+ # and exceed modest memory caps before the table finishes parsing.
12
+ #
13
+ # The patch below carries the fix inside metanorma-utils: it prepends a
14
+ # corrected Asciidoctor::Parser.parse_table whose appends go through
15
+ # ParserContext#append_to_buffer, and prepends ParserContext methods that
16
+ # accumulate the cell as an Array of segments joined once at close (and
17
+ # whenever the CSV quote check needs the whole record). No String owned
18
+ # by the buffer is ever mutated, so no frozen-string workaround is
19
+ # needed, and the per-line buffer-sized String churn the GC chases is
20
+ # gone.
21
+ #
22
+ # This is also the shape the asciidoctor maintainers have stated they
23
+ # would accept upstream: in-place string mutation is barred there by
24
+ # frozen-string literals and Opal compatibility, and their stated
25
+ # solution is "an array that is later concatenated" (the comment thread
26
+ # of asciidoctor PR #4879). An upstream fix of either stated shape stands
27
+ # the prepend down (see the disarm check at the end of this file).
28
+ module Asciidoctor
29
+ # Replaces the buffer-mutating ParserContext methods; the two stock
30
+ # readers of the buffer (the CSV quote check and close_cell) drain the
31
+ # pending segments into the materialized buffer and delegate upward.
32
+ module TableCellBufferParserContextPatch
33
+ # Appends the String to the buffer of the currently open cell as a
34
+ # pending segment; amortized O(1) per accumulated line.
35
+ def append_to_buffer(str)
36
+ (@buffer_segments ||= []) << str
37
+ nil
38
+ end
39
+
40
+ def skip_past_delimiter(pre)
41
+ (@buffer_segments ||= []) << pre << @delimiter
42
+ nil
43
+ end
44
+
45
+ def skip_past_escaped_delimiter(pre)
46
+ (@buffer_segments ||= []) << pre.chop << @delimiter
47
+ nil
48
+ end
49
+
50
+ def buffer_has_unclosed_quotes?(append = nil, q = '"')
51
+ drain_buffer_segments
52
+ super
53
+ end
54
+
55
+ def close_cell(eol = false)
56
+ drain_buffer_segments
57
+ super
58
+ end
59
+
60
+ private
61
+
62
+ # Materializes the pending segments onto the buffer String the stock
63
+ # methods read. Cost is proportional to the pending segments only;
64
+ # close_cell drains once per cell, and the quote check drains at most
65
+ # once per check - the same order as the stock check's own copy of
66
+ # the full record ((@buffer + append).strip).
67
+ def drain_buffer_segments
68
+ (@buffer_segments ||= []).empty? and return
69
+ @buffer = @buffer + @buffer_segments.join
70
+ @buffer_segments.clear
71
+ nil
72
+ end
73
+ end
74
+
75
+ module TableCellBufferPatch
76
+ def parse_table(table_reader, parent, attributes)
77
+ table = Table.new(parent, attributes)
78
+
79
+ if (attributes.key? 'cols') && !(colspecs = parse_colspecs attributes['cols']).empty?
80
+ table.create_columns colspecs
81
+ explicit_colspecs = true
82
+ end
83
+
84
+ skipped = table_reader.skip_blank_lines || 0
85
+ if attributes['header-option']
86
+ table.has_header_option = true
87
+ elsif skipped == 0 && !attributes['noheader-option']
88
+ # NOTE: assume table has header until we know otherwise; if it doesn't (nil), cells in first row get reprocessed
89
+ table.has_header_option = :implicit
90
+ implicit_header = true
91
+ end
92
+ parser_ctx = Table::ParserContext.new table_reader, table, attributes
93
+ format, loop_idx, implicit_header_boundary = parser_ctx.format, -1, nil
94
+
95
+ while (line = table_reader.read_line)
96
+ if (beyond_first = (loop_idx += 1) > 0) && line.empty?
97
+ line = nil
98
+ implicit_header_boundary += 1 if implicit_header_boundary
99
+ elsif format == 'psv'
100
+ if parser_ctx.starts_with_delimiter? line
101
+ line = line.slice 1, line.length
102
+ # push empty cell spec if cell boundary appears at start of line
103
+ parser_ctx.close_open_cell
104
+ implicit_header_boundary = nil if implicit_header_boundary
105
+ else
106
+ next_cellspec, line = parse_cellspec line, :start, parser_ctx.delimiter
107
+ # if cellspec is not nil, we're at a cell boundary
108
+ if next_cellspec
109
+ parser_ctx.close_open_cell next_cellspec
110
+ implicit_header_boundary = nil if implicit_header_boundary
111
+ # otherwise, the cell continues from previous line
112
+ elsif implicit_header_boundary && implicit_header_boundary == loop_idx
113
+ table.has_header_option = implicit_header = implicit_header_boundary = nil
114
+ end
115
+ end
116
+ end
117
+
118
+ unless beyond_first
119
+ table_reader.mark
120
+ # NOTE implicit header is offset by at least one blank line; implicit_header_boundary tracks size of gap
121
+ if implicit_header
122
+ if table_reader.has_more_lines? && table_reader.peek_line.empty?
123
+ implicit_header_boundary = 1
124
+ else
125
+ table.has_header_option = implicit_header = nil
126
+ end
127
+ end
128
+ end
129
+
130
+ # this loop is used for flow control; internal logic controls how many times it executes
131
+ while true
132
+ if line && (m = parser_ctx.match_delimiter line)
133
+ pre_match, post_match = m.pre_match, m.post_match
134
+ case format
135
+ when 'csv'
136
+ if parser_ctx.buffer_has_unclosed_quotes? pre_match
137
+ parser_ctx.skip_past_delimiter pre_match
138
+ break if (line = post_match).empty?
139
+ redo
140
+ end
141
+ parser_ctx.append_to_buffer pre_match
142
+ when 'dsv'
143
+ if pre_match.end_with? '\\'
144
+ parser_ctx.skip_past_escaped_delimiter pre_match
145
+ if (line = post_match).empty?
146
+ parser_ctx.append_to_buffer LF
147
+ parser_ctx.keep_cell_open
148
+ break
149
+ end
150
+ redo
151
+ end
152
+ parser_ctx.append_to_buffer pre_match
153
+ else # psv
154
+ if pre_match.end_with? '\\'
155
+ parser_ctx.skip_past_escaped_delimiter pre_match
156
+ if (line = post_match).empty?
157
+ parser_ctx.append_to_buffer LF
158
+ parser_ctx.keep_cell_open
159
+ break
160
+ end
161
+ redo
162
+ end
163
+ next_cellspec, cell_text = parse_cellspec pre_match
164
+ parser_ctx.push_cellspec next_cellspec
165
+ parser_ctx.append_to_buffer cell_text
166
+ end
167
+ # don't break if empty to preserve empty cell found at end of line (see issue #1106)
168
+ line = nil if (line = post_match).empty?
169
+ parser_ctx.close_cell
170
+ else
171
+ # no other delimiters to see here; suck up this line into the buffer and move on
172
+ parser_ctx.append_to_buffer %(#{line}#{LF})
173
+ case format
174
+ when 'csv'
175
+ if parser_ctx.buffer_has_unclosed_quotes?
176
+ table.has_header_option = implicit_header = implicit_header_boundary = nil if implicit_header_boundary && loop_idx == 0
177
+ parser_ctx.keep_cell_open
178
+ else
179
+ parser_ctx.close_cell true
180
+ end
181
+ when 'dsv'
182
+ parser_ctx.close_cell true
183
+ else # psv
184
+ parser_ctx.keep_cell_open
185
+ end
186
+ break
187
+ end
188
+ end
189
+
190
+ # NOTE cell may already be closed if table format is csv or dsv
191
+ if parser_ctx.cell_open?
192
+ parser_ctx.close_cell true unless table_reader.has_more_lines?
193
+ else
194
+ table_reader.skip_blank_lines || break
195
+ end
196
+ end
197
+
198
+ parser_ctx.close_table
199
+ table.assign_column_widths unless (table.attributes['colcount'] ||= table.columns.size) == 0 || explicit_colspecs
200
+ table.has_header_option = true if implicit_header
201
+ table.partition_header_footer attributes
202
+
203
+ table
204
+ end
205
+ end
206
+
207
+ class Table
208
+ class ParserContext
209
+ prepend TableCellBufferParserContextPatch
210
+ end
211
+ end
212
+ end
213
+
214
+ module Metanorma
215
+ module Utils
216
+ module AsciidoctorTableBuffer
217
+ # Resolved once, before the prepend: once our parse_table is in
218
+ # place, Parser.method(:parse_table) resolves to the prepended copy
219
+ # and its source_location points at this file, not asciidoctor's.
220
+ PARSER_FILE = ::Asciidoctor::Parser
221
+ .method(:parse_table).source_location.to_a.first
222
+
223
+ class << self
224
+ def apply!
225
+ return false unless defined?(::Asciidoctor::VERSION)
226
+ return true if applied?
227
+
228
+ parser_file = PARSER_FILE
229
+ return false unless parser_file && File.file?(parser_file)
230
+ # Stand down once asciidoctor fixes the quadratic rebuild
231
+ # itself, in either shape: appending in place
232
+ # (append_to_buffer), or accumulating lines in an Array
233
+ # joined at close - the shape the asciidoctor maintainers
234
+ # have stated is acceptable upstream (string mutation is
235
+ # barred there by frozen-string literals and Opal
236
+ # compatibility; see the comment thread of asciidoctor PR
237
+ # #4879). Detecting both matters: this patch prepends a whole
238
+ # copy of parse_table, so shadowing an upstream fix with a
239
+ # stale copy would silently revert their behavior.
240
+ parser_src = File.read(parser_file)
241
+ return false if parser_src.include?("append_to_buffer")
242
+ return false unless parser_src.include?('parser_ctx.buffer = %(#{parser_ctx.buffer')
243
+
244
+ ::Asciidoctor::Parser.singleton_class
245
+ .prepend(::Asciidoctor::TableCellBufferPatch)
246
+ ::Asciidoctor::Table::ParserContext
247
+ .prepend(::Asciidoctor::TableCellBufferParserContextPatch)
248
+ @applied = true
249
+ end
250
+
251
+ def applied?
252
+ @applied ||= false
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
258
+
259
+ Metanorma::Utils::AsciidoctorTableBuffer.apply!
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Utils
5
+ # Large metanorma compiles churn transients of hundreds of MB per
6
+ # phase (macro rendering, XML cleanup, whole-document assembly) on
7
+ # top of a multi-GB live set. The churn is malloc-side
8
+ # (libxml/moxml/liquid/regexp), so the GC's heap-growth heuristics
9
+ # fire too late under memory caps and the process dies thrashing.
10
+ # Call gc_when_bloated! at phase boundaries: collect whenever
11
+ # uncollected malloc has exceeded the budget.
12
+ module GcBudget
13
+ BUDGET_BYTES = 512 * (1 << 20)
14
+
15
+ class << self
16
+ def gc_when_bloated!
17
+ GC.start if GC.stat(:malloc_increase_bytes) > BUDGET_BYTES
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/utils/main.rb CHANGED
@@ -9,6 +9,12 @@ require_relative "cjk"
9
9
 
10
10
  module Metanorma
11
11
  module Utils
12
+ # One shared decoder: instantiating HTMLEntities builds a full entity
13
+ # table per instance, and these helpers run per attribute value / per
14
+ # text node in document-scale pipelines — hundreds of thousands of
15
+ # live decoders were observed retained in a single metanorma compile.
16
+ HTML_ENTITIES = HTMLEntities.new
17
+
12
18
  class << self
13
19
  # , " => ," : CSV definition does not deal with space followed by quote
14
20
  # at start of field
@@ -38,7 +44,7 @@ module Metanorma
38
44
 
39
45
  # TODO needs internationalisation of quote
40
46
  def smartformat(text)
41
- ret = HTMLEntities.new.decode(
47
+ ret = HTML_ENTITIES.decode(
42
48
  text.gsub(/ --? /, "&#8201;&#8212;&#8201;")
43
49
  .gsub("--", "&#8212;"),
44
50
  )
@@ -47,7 +53,7 @@ module Metanorma
47
53
  ret = ret.smart_format
48
54
  ret = ret.gsub(%r{(#{CJK})\u200a}o, "\\1")
49
55
  .gsub(%r{\u200a(#{CJK})}o, "\\1")
50
- HTMLEntities.new.encode(ret, :basic)
56
+ HTML_ENTITIES.encode(ret, :basic)
51
57
  end
52
58
 
53
59
  def endash_date(elem)
data/lib/utils/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Metanorma
2
2
  module Utils
3
- VERSION = "2.0.7".freeze
3
+ VERSION = "2.0.8".freeze
4
4
  end
5
5
  end
data/lib/utils/xml.rb CHANGED
@@ -30,17 +30,29 @@ module Metanorma
30
30
  HERE
31
31
 
32
32
  class << self
33
+ # The parsed NOKOHEAD scaffold, shared by every noko fragment.
34
+ def noko_template
35
+ @noko_template ||= ::Nokogiri::XML.parse(NOKOHEAD)
36
+ end
37
+
33
38
  def attr_code(attributes)
34
39
  attributes.compact.transform_values do |v|
35
- v.is_a?(String) ? HTMLEntities.new.decode(v) : v
40
+ v.is_a?(String) ? HTML_ENTITIES.decode(v) : v
36
41
  end
37
42
  end
38
43
 
39
44
  # block for processing XML document fragments as XHTML,
40
45
  # to allow for HTMLentities
41
46
  # Unescape special chars used in Asciidoctor substitution processing
47
+ # The template document is parsed once per process: noko runs for
48
+ # every inline element and block a converter emits (a metanorma
49
+ # plateau compile measured 73k calls), and re-parsing NOKOHEAD
50
+ # minted a fresh HTML-skeleton Document per call — 75k retained
51
+ # documents with their node caches, a top memory consumer of
52
+ # large compiles. Fragments off the shared document are
53
+ # independent trees; conversion is single-threaded.
42
54
  def noko(_script = "Latn", &block)
43
- fragment = ::Nokogiri::XML.parse(NOKOHEAD).fragment("")
55
+ fragment = noko_template.fragment("")
44
56
  ::Nokogiri::XML::Builder.with fragment, &block
45
57
  fragment
46
58
  .to_xml(encoding: "UTF-8", indent: 0,
@@ -74,7 +86,7 @@ module Metanorma
74
86
  end
75
87
 
76
88
  def noko_html(&block)
77
- doc = ::Nokogiri::XML.parse(NOKOHEAD)
89
+ doc = noko_template.dup
78
90
  fragment = doc.fragment("")
79
91
  ::Nokogiri::XML::Builder.with fragment, &block
80
92
  fragment.to_xml(encoding: "UTF-8", indent: 0,
@@ -97,7 +109,7 @@ module Metanorma
97
109
  end
98
110
 
99
111
  def numeric_escapes(xml)
100
- c = HTMLEntities.new
112
+ c = HTML_ENTITIES
101
113
  xml.split(/(&[^ \r\n\t#&;]+;)/).map do |t|
102
114
  if /^(&[^ \t\r\n#;]+;)/.match?(t)
103
115
  c.encode(c.decode(t), :hexadecimal)
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
  f.match(%r{^(test|spec|features|bin|.github)/}) \
25
25
  || f.match(%r{Rakefile|bin/rspec})
26
26
  end
27
- spec.required_ruby_version = Gem::Requirement.new(">= 3.1.0")
27
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0")
28
28
 
29
29
  spec.add_dependency "asciidoctor", ">= 2"
30
30
  spec.add_dependency "concurrent-ruby"
@@ -47,6 +47,6 @@ Gem::Specification.new do |spec|
47
47
  spec.add_development_dependency "simplecov", "~> 0.15"
48
48
  spec.add_development_dependency "timecop", "~> 0.9"
49
49
  spec.add_development_dependency "webmock"
50
- spec.add_development_dependency "canon", "= 0.2.3"
50
+ spec.add_development_dependency "canon"
51
51
  # spec.metadata["rubygems_mfa_required"] = "true"
52
52
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma-utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.7
4
+ version: 2.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asciidoctor
@@ -294,16 +294,16 @@ dependencies:
294
294
  name: canon
295
295
  requirement: !ruby/object:Gem::Requirement
296
296
  requirements:
297
- - - '='
297
+ - - ">="
298
298
  - !ruby/object:Gem::Version
299
- version: 0.2.3
299
+ version: '0'
300
300
  type: :development
301
301
  prerelease: false
302
302
  version_requirements: !ruby/object:Gem::Requirement
303
303
  requirements:
304
- - - '='
304
+ - - ">="
305
305
  - !ruby/object:Gem::Version
306
- version: 0.2.3
306
+ version: '0'
307
307
  description: 'metanorma-utils provides utilities for the Metanorma stack
308
308
 
309
309
  '
@@ -314,7 +314,6 @@ extensions: []
314
314
  extra_rdoc_files: []
315
315
  files:
316
316
  - ".gitignore"
317
- - ".hound.yml"
318
317
  - ".rubocop.yml"
319
318
  - Gemfile
320
319
  - LICENSE
@@ -323,7 +322,9 @@ files:
323
322
  - lib/sterile/sterile.rb
324
323
  - lib/utils/anchor.rb
325
324
  - lib/utils/anchor_ranges.rb
325
+ - lib/utils/asciidoctor_table_buffer.rb
326
326
  - lib/utils/cjk.rb
327
+ - lib/utils/gc_budget.rb
327
328
  - lib/utils/hash_transform_keys.rb
328
329
  - lib/utils/image.rb
329
330
  - lib/utils/linestatus.rb
@@ -346,7 +347,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
346
347
  requirements:
347
348
  - - ">="
348
349
  - !ruby/object:Gem::Version
349
- version: 3.1.0
350
+ version: 3.3.0
350
351
  required_rubygems_version: !ruby/object:Gem::Requirement
351
352
  requirements:
352
353
  - - ">="
data/.hound.yml DELETED
@@ -1,5 +0,0 @@
1
- # Auto-generated by Cimas: Do not edit it manually!
2
- # See https://github.com/metanorma/cimas
3
- ruby:
4
- enabled: true
5
- config_file: .rubocop.yml