metanorma-plugin-lutaml 0.7.53 → 0.7.54

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: 05df720264c4dabcd31cb6957904853bc928cfe2e8cb49eec614dfa8d658eafd
4
- data.tar.gz: 0da436fb95370e1a7d0fa4241a65086d00eb2a6496d314460bc54499d9a0fa1c
3
+ metadata.gz: b6a443f1152ff2cf6f053744b7750e0e96426bc776cf0cb582b9c93f7d828f55
4
+ data.tar.gz: 4f31cad3531f1f38525e2eaacee72917fee007a2a6099a772894ed6159ed5168
5
5
  SHA512:
6
- metadata.gz: '028f5021f95d108c7c55efba46e5a8bad6558690593b636c71d244bf7896ad639d10176853551127256b0d0206993ebf8587700786de846dacbad547ce066c39'
7
- data.tar.gz: c7136a9bd3a6937583aaa6f04e03e3867fbbb9cc18b1715cf91ce369329badd4a7ea0e602b5c1ae3d4303a5f14b9838786ee2d605d821ed70bc22401de15ce2e
6
+ metadata.gz: 4098ac9e010d91d65a98c21eb22abcbd7add8166f7dc93a1f0b2fdeeb790b40c70a274ef8b66f36aaa3402bcc3b481d9283ff4e766586b4a33e4c8dfd75e2463
7
+ data.tar.gz: 9ed735f7bd7b870a8be1ad0847e4b9c47a2a411a23de2a7d98874c6d185286e60123d067c8e34be42b3d339ae021cee51190b50c8c0581a5a04c1ad47c64f654
@@ -20,30 +20,5 @@ jobs:
20
20
  setup-tools: graphviz
21
21
  submodules: true
22
22
  choco-cache: true
23
- # Build the expressir Rust binding for the vendored gem: without
24
- # it the pure-Ruby parser path makes the heavy suites exceed the
25
- # runner memory ceiling on ruby 3.3 (#304).
26
- after-setup-ruby: |
27
- case "$(uname -s)" in
28
- *MINGW*|*MSYS*)
29
- echo "windows: ruby is mingw-built; the binding needs the
30
- gnu toolchain - skipping (windows passes without it)"
31
- exit 0
32
- ;;
33
- esac
34
- if ! command -v cargo >/dev/null 2>&1; then
35
- echo "cargo not available; skipping expressir binding build"
36
- exit 0
37
- fi
38
- gem install rb_sys --no-document
39
- gem_path=$(bundle info expressir --path)
40
- ext_dir="$gem_path/ext/expressir_core"
41
- if [ -f "$ext_dir/extconf.rb" ]; then
42
- (cd "$ext_dir" && ruby extconf.rb && make)
43
- bundle_path="$ext_dir/expressir_core.bundle"
44
- if [ "$(uname)" = "Darwin" ] && [ -f "$bundle_path" ]; then
45
- codesign -f -s - "$bundle_path"
46
- fi
47
- fi
48
23
  secrets:
49
24
  pat_token: ${{ secrets.METANORMA_CI_PAT_TOKEN }}
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Lutaml
6
+ # The lutaml macro family churns transients of hundreds of MB per
7
+ # invocation on top of a multi-GB live parsed-XMI set. The churn is
8
+ # malloc-side (libxml/moxml/liquid), so the GC's heap-growth
9
+ # heuristics fire too late under memory caps; collect at macro
10
+ # boundaries whenever uncollected malloc has exceeded the budget.
11
+ module GcBudget
12
+ BUDGET_BYTES = 512 * (1 << 20)
13
+
14
+ class << self
15
+ def gc_when_bloated!
16
+ GC.start if GC.stat(:malloc_increase_bytes) > BUDGET_BYTES
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,4 +1,4 @@
1
- {% assign root = klass.generalization %}
1
+ {%- assign root = klass.generalization -%}
2
2
 
3
3
  {%- capture upper_klass_name -%}
4
4
  {{ root.general.upper_klass }}:{{ root.general.name }}
@@ -21,57 +21,57 @@ h| Stereotype 2+| {{ stereotype }}
21
21
 
22
22
  3+h| Inherited Properties
23
23
  h| Property Name h| Property Type and Multiplicity h| Definition
24
- {% for attr in root.inherited_props %}
25
- {%- unless attr.has_association? -%}
26
- {%- capture name_col -%}
24
+ {%- for attr in root.inherited_props -%}
25
+ {%- unless attr.has_association? -%}
26
+ {%- capture name_col -%}
27
27
  {{ attr.name_ns }}:{{ attr.name }}
28
28
  ({{ attr.gen_name }})
29
29
  {%- endcapture -%}
30
30
  | {{ name_col | newline_to_br }}
31
31
  | {{ attr.type }} [{{ attr.cardinality.min }}..{{ attr.cardinality.max }}]
32
32
  | {{ attr.definition }}
33
- {%- endunless -%}
34
- {% endfor %}
33
+ {%- endunless -%}
34
+ {%- endfor -%}
35
35
 
36
36
  3+h| Self-defined Properties
37
37
  h| Property Name h| Property Type and Multiplicity h| Definition
38
- {% for attr in root.owned_props %}
39
- {%- unless attr.has_association? -%}
40
- {%- capture name_col -%}
38
+ {%- for attr in root.owned_props -%}
39
+ {%- unless attr.has_association? -%}
40
+ {%- capture name_col -%}
41
41
  {{ attr.name_ns }}:{{ attr.name }}
42
42
  ({{ attr.gen_name }})
43
43
  {%- endcapture -%}
44
44
  | {{ name_col | newline_to_br }}
45
45
  | {{ attr.type }} [{{ attr.cardinality.min }}..{{ attr.cardinality.max }}]
46
46
  | {{ attr.definition }}
47
- {%- endunless -%}
48
- {% endfor %}
47
+ {%- endunless -%}
48
+ {%- endfor -%}
49
49
 
50
50
  3+h| Properties Inherited from Association
51
51
  h| Property Name h| Property Type and Multiplicity h| Definition
52
- {% for attr in root.inherited_assoc_props %}
53
- {%- if attr.has_association? -%}
54
- {%- capture name_col -%}
52
+ {%- for attr in root.inherited_assoc_props -%}
53
+ {%- if attr.has_association? -%}
54
+ {%- capture name_col -%}
55
55
  {{ attr.name_ns }}:{{ attr.name }}
56
56
  ({{ attr.gen_name }})
57
57
  {%- endcapture -%}
58
58
  | {{ name_col | newline_to_br }}
59
59
  | {{ attr.type_ns }}:{{ attr.type }} [{{ attr.cardinality.min }}..{{ attr.cardinality.max }}]
60
60
  | {{ attr.definition }}
61
- {%- endif -%}
62
- {% endfor %}
61
+ {%- endif -%}
62
+ {%- endfor -%}
63
63
 
64
64
  3+h| Properties Defined in Association
65
65
  h| Property Name h| Property Type and Multiplicity h| Definition
66
- {% for attr in root.assoc_props %}
67
- {%- if attr.has_association? -%}
68
- {%- capture name_col -%}
66
+ {%- for attr in root.assoc_props -%}
67
+ {%- if attr.has_association? -%}
68
+ {%- capture name_col -%}
69
69
  {{ attr.name_ns }}:{{ attr.name }}
70
70
  ({{ attr.gen_name }})
71
71
  {%- endcapture -%}
72
72
  | {{ name_col | newline_to_br }}
73
73
  | {{ attr.type_ns }}:{{ attr.type }} [{{ attr.cardinality.min }}..{{ attr.cardinality.max }}]
74
74
  | {{ attr.definition }}
75
- {%- endif -%}
76
- {% endfor %}
77
- |===
75
+ {%- endif -%}
76
+ {%- endfor -%}
77
+ |===
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Lutaml
6
+ # Renders LutaML data instances through a Liquid template.
7
+ #
8
+ # Data definitions and instances in separate files:
9
+ #
10
+ # [lutaml_data,<definitions.lml>,<instances.lml|yaml|json>,<context>]
11
+ # ----
12
+ # {% for check in context.S158Checks.checks %}
13
+ # {{ check.dev_id }}
14
+ # {% endfor %}
15
+ # ----
16
+ #
17
+ # Data definitions and instances in the same file:
18
+ #
19
+ # [lutaml_data,<definitions.lml>,<context>]
20
+ #
21
+ class LutamlDataPreprocessor < BasePreprocessor
22
+ DATA_FILE_EXTENSIONS = %w[.lml .yaml .yml .json].freeze
23
+ DEFAULT_CONTEXT_NAME = "lutaml_data"
24
+
25
+ DATA_BLOCK_REGEX = %r{
26
+ ^
27
+ \[
28
+ \blutaml_data\b
29
+ ,(?<definition_file>[^,\[\]]+)
30
+ (?<rest>.*)
31
+ \]
32
+ }x
33
+
34
+ protected
35
+
36
+ def lutaml_liquid?(line)
37
+ line.match(DATA_BLOCK_REGEX)
38
+ end
39
+
40
+ def index_type_name
41
+ "LutaML data"
42
+ end
43
+
44
+ def load_lutaml_file(_document, file_path, _options)
45
+ ::Lutaml::Lml.parse_document(File.new(file_path, encoding: "UTF-8"))
46
+ end
47
+
48
+ private
49
+
50
+ def process_text_blocks(document, input_lines, _express_indexes)
51
+ line = input_lines.next
52
+ block_header_match = lutaml_liquid?(line)
53
+ return [line] unless block_header_match
54
+
55
+ definition_file, instance_file, context_name, options =
56
+ parse_header(block_header_match)
57
+
58
+ end_mark = input_lines.next
59
+ lines = extract_block_lines(input_lines, end_mark)
60
+
61
+ render_data_template(
62
+ document: document,
63
+ lines: lines,
64
+ definition_file: definition_file,
65
+ instance_file: instance_file,
66
+ context_name: context_name,
67
+ options: options,
68
+ )
69
+ end
70
+
71
+ def parse_header(match)
72
+ definition_file = match[:definition_file].strip
73
+ segments = match[:rest].split(",").map(&:strip)
74
+ options, segments = split_options(segments)
75
+
76
+ data_segments, name_segments = segments
77
+ .reject(&:empty?).partition { |segment| data_file?(segment) }
78
+
79
+ [definition_file, data_segments.last,
80
+ name_segments.last || DEFAULT_CONTEXT_NAME, options]
81
+ end
82
+
83
+ def split_options(segments)
84
+ options_start = segments.index { |segment| segment.include?("=") }
85
+ return [{}, segments] unless options_start
86
+
87
+ options = parse_options(",#{segments[options_start..].join(',')}")
88
+ [options, segments[0...options_start]]
89
+ end
90
+
91
+ def data_file?(segment)
92
+ DATA_FILE_EXTENSIONS.include?(File.extname(segment))
93
+ end
94
+
95
+ def render_data_template(document:, lines:, # rubocop:disable Metrics/ParameterLists
96
+ definition_file:, instance_file:,
97
+ context_name:, options:)
98
+ definition_path, instances_path = resolve_data_paths(
99
+ document, definition_file, instance_file
100
+ )
101
+
102
+ compiler = ::Lutaml::Lml::ModelCompiler.new
103
+ compiler.compile(File.new(definition_path, encoding: "UTF-8"))
104
+ instances = load_instances(instances_path, compiler)
105
+
106
+ [render_liquid(lines, document, options, context_name, instances)]
107
+ rescue StandardError => e
108
+ ::Metanorma::Util.log(
109
+ "[#{self.class.name}] Failed to parse LutaML data block: " \
110
+ "#{e.message}",
111
+ :error,
112
+ )
113
+ raise e
114
+ end
115
+
116
+ def render_liquid(lines, document, options, context_name, instances)
117
+ parsed_template = template(lines)
118
+ parsed_template.registers[:file_system] =
119
+ build_file_system(document, options)
120
+ parsed_template.render(context_name => instances.to_h)
121
+ end
122
+
123
+ def resolve_data_paths(document, definition_file, instance_file)
124
+ definition_path = Utils.relative_file_path(document, definition_file)
125
+ unless File.file?(definition_path)
126
+ raise StandardError, index_missing_message(definition_file)
127
+ end
128
+
129
+ instances_path = instance_file &&
130
+ Utils.relative_file_path(document, instance_file)
131
+ if instances_path && !File.file?(instances_path)
132
+ raise StandardError, index_missing_message(instance_file)
133
+ end
134
+
135
+ [definition_path, instances_path || definition_path]
136
+ end
137
+
138
+ def load_instances(path, compiler)
139
+ case File.extname(path)
140
+ when ".lml" then load_lml_instances(path, compiler)
141
+ when ".json" then load_structured_instances(path, compiler,
142
+ :parse_json_data)
143
+ else load_structured_instances(path, compiler, :parse_yaml_data)
144
+ end
145
+ end
146
+
147
+ def load_lml_instances(path, compiler)
148
+ doc = load_lutaml_file(nil, path, {})
149
+ root = doc.instance
150
+ return [] unless root
151
+
152
+ [[root.type.to_s, compiler.hydrate(doc)]]
153
+ rescue StandardError => e
154
+ raise StandardError, "Failed to parse LutaML instances in " \
155
+ "`#{path}`: #{e.message}"
156
+ end
157
+
158
+ def load_structured_instances(path, compiler, parser)
159
+ data = send(parser, path)
160
+ entries = data.is_a?(Array) ? data : [data]
161
+ entries.map { |entry| build_structured_entry(path, compiler, entry) }
162
+ rescue StandardError => e
163
+ raise e if e.message.start_with?("Instance", "Unknown type")
164
+
165
+ raise StandardError, "Failed to load data instances in " \
166
+ "`#{path}`: #{e.message}"
167
+ end
168
+
169
+ def build_structured_entry(path, compiler, entry)
170
+ type = entry.delete("type")
171
+ unless type
172
+ raise StandardError, "Instance in `#{path}` is missing the " \
173
+ "`type` key"
174
+ end
175
+
176
+ klass = compiler.compiled_classes[demodulize(type)]
177
+ unless klass
178
+ raise StandardError,
179
+ "Unknown type `#{type}` in `#{path}`"
180
+ end
181
+
182
+ [demodulize(type), klass.from_hash(entry)]
183
+ end
184
+
185
+ def parse_yaml_data(path)
186
+ require "yaml"
187
+ YAML.safe_load(File.read(path, encoding: "UTF-8"))
188
+ end
189
+
190
+ def parse_json_data(path)
191
+ require "json"
192
+ JSON.parse(File.read(path, encoding: "UTF-8"))
193
+ end
194
+
195
+ def demodulize(name)
196
+ name.to_s.split("::").last
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -3,6 +3,8 @@
3
3
  require "ogc/gml"
4
4
  require "liquid"
5
5
 
6
+ require "metanorma/plugin/lutaml/gc_budget"
7
+
6
8
  module Metanorma
7
9
  module Plugin
8
10
  module Lutaml
@@ -10,6 +12,7 @@ module Metanorma
10
12
  private
11
13
 
12
14
  def render(tmpl, parent, attrs, orig_gml_path)
15
+ GcBudget.gc_when_bloated!
13
16
  dict = get_gml_dictionary(parent, orig_gml_path)
14
17
  tmpl.assigns[attrs["context"]] = GmlDictionaryDrop.new(dict)
15
18
  rendered_tmpl = tmpl.render
@@ -17,12 +20,19 @@ module Metanorma
17
20
  parse_content(block, rendered_tmpl, attrs)
18
21
  end
19
22
 
23
+ # A document-scale compile resolves the same dictionary files many
24
+ # times (the plateau corpus cites codelists up to 34 times each);
25
+ # re-reading and re-parsing per macro was pure duplicate work.
26
+ GML_DICTIONARY_CACHE = CacheStore.new(max_size: 512)
27
+
20
28
  def get_gml_dictionary(parent, orig_gml_path)
21
29
  gml_path = Utils.relative_file_path(
22
30
  parent.document, orig_gml_path
23
31
  )
24
32
 
25
- ::Ogc::Gml::Dictionary.from_xml(xml_content(gml_path))
33
+ GML_DICTIONARY_CACHE.fetch_or_store(gml_path) do
34
+ ::Ogc::Gml::Dictionary.from_xml(xml_content(gml_path))
35
+ end
26
36
  end
27
37
 
28
38
  def xml_content(filepath)
@@ -28,8 +28,8 @@ module Metanorma
28
28
  guidance = get_guidance(parent.document, attrs["guidance"])
29
29
  end
30
30
 
31
- klass = serialize_klass_drop_by_name(xmi_path, path, parent.document,
32
- guidance)
31
+ klass = serialize_klass_or_datatype_drop_by_name(
32
+ xmi_path, path, parent.document, guidance)
33
33
 
34
34
  render_table(klass, CONTEXT_NAME, parent, attrs)
35
35
  end
@@ -1,7 +1,7 @@
1
1
  module Metanorma
2
2
  module Plugin
3
3
  module Lutaml
4
- VERSION = "0.7.53".freeze
4
+ VERSION = "0.7.54".freeze
5
5
  end
6
6
  end
7
7
  end
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "metanorma/plugin/lutaml/gc_budget"
4
+
3
5
  module Metanorma
4
6
  module Plugin
5
7
  module Lutaml
6
8
  module XmiCache
7
- XMI_PARSE_CACHE = XmiParseCache.new
9
+ # Bounded parse-once cache: every macro touching a source (the
10
+ # full file, or its package slice under lutaml-ea-xmi-load: partial)
11
+ # reuses the one parsed graph; the LRU bound keeps the retained
12
+ # graphs to a few live at once, which the section-ordered
13
+ # macros of a document turn into near-perfect reuse.
14
+ XMI_PARSE_CACHE = XmiParseCache.new(max_size: 4)
8
15
 
9
16
  def lutaml_document_from_file_or_cache(document, file_path, yaml_config,
10
17
  yaml_config_path = nil)
@@ -27,21 +34,8 @@ yaml_config_path = nil)
27
34
  end
28
35
  end
29
36
 
30
- def find_class_by_xmi_id(container, xmi_id)
31
- container.classes.find { |node| node.xmi_id == xmi_id } ||
32
- container.packages
33
- .lazy
34
- .filter_map { |pkg| find_class_by_xmi_id(pkg, xmi_id) }
35
- .first
36
- end
37
37
 
38
- def find_enum_by_xmi_id(container, xmi_id)
39
- container.enums.find { |node| node.xmi_id == xmi_id } ||
40
- container.packages
41
- .lazy
42
- .filter_map { |pkg| find_enum_by_xmi_id(pkg, xmi_id) }
43
- .first
44
- end
38
+
45
39
 
46
40
  def find_packaged_klass(index, path, root_model_name: nil)
47
41
  segments = path.split("::").reject(&:empty?)
@@ -69,6 +63,32 @@ yaml_config_path = nil)
69
63
  end
70
64
  end
71
65
 
66
+ def find_packaged_datatype(index, path, root_model_name: nil)
67
+ segments = path.split("::").reject(&:empty?)
68
+ if root_model_name && segments.first == root_model_name
69
+ segments.shift
70
+ end
71
+ if segments.one?
72
+ index.find_packaged_by_name_and_types(
73
+ segments.first, ["uml:DataType"]
74
+ )
75
+ else
76
+ find_packaged_datatype_by_path(index, segments)
77
+ end
78
+ end
79
+
80
+ def find_packaged_datatype_by_path(index, segments)
81
+ datatype_name = segments.pop
82
+
83
+ candidates = ["uml:DataType"]
84
+ .flat_map { |t| index.packaged_elements_of_type(t) }
85
+ .select { |e| e.name == datatype_name }
86
+
87
+ candidates.find do |datatype|
88
+ match_parent_chain?(index, datatype, segments)
89
+ end
90
+ end
91
+
72
92
  def match_parent_chain?(index, element, parent_segments)
73
93
  current = element
74
94
  parent_segments.reverse_each do |pkg_name|
@@ -85,23 +105,54 @@ yaml_config_path = nil)
85
105
  .find { |e| e.name == name }
86
106
  end
87
107
 
88
- def serialize_klass_drop_by_name(xmi_path, name, _document = nil,
89
- guidance = nil)
90
- parsed = XMI_PARSE_CACHE.fetch(xmi_path)
108
+ def serialize_klass_or_datatype_drop_by_name(xmi_path,
109
+ name, _document = nil, guidance = nil)
110
+ serialize_klass_drop_by_name(xmi_path, name, _document, guidance) ||
111
+ serialize_datatype_drop_by_name(xmi_path, name, _document)
112
+ end
113
+
114
+ def serialize_klass_drop_by_name(xmi_path,
115
+ name, _document = nil, guidance = nil)
116
+ GcBudget.gc_when_bloated!
117
+ xmi_path, name, slice = XmiPartialLoadRegistry.rewrite(xmi_path, name)
118
+ parsed = XMI_PARSE_CACHE.fetch(xmi_path, slice: slice)
91
119
  klass = resolve_packaged_klass(parsed, name)
92
- warn "Class not found for name: #{name}" if klass.nil?
120
+ if klass.nil?
121
+ warn "Class not found for name: #{name}"
122
+ return nil
123
+ end
124
+
93
125
  ::Ea::Xmi::LiquidDrops::KlassDrop.new(
94
126
  klass, guidance, parsed.drop_options
95
127
  )
96
128
  end
97
129
 
130
+ def serialize_datatype_drop_by_name(xmi_path, name, _document = nil)
131
+ GcBudget.gc_when_bloated!
132
+ xmi_path, name, slice = XmiPartialLoadRegistry.rewrite(xmi_path, name)
133
+ parsed = XMI_PARSE_CACHE.fetch(xmi_path, slice: slice)
134
+ datatype = resolve_packaged_datatype(parsed, name)
135
+ if datatype.nil?
136
+ warn "Datatype not found for name: #{name}"
137
+ nil
138
+ end
139
+
140
+ ::Ea::Xmi::LiquidDrops::DataTypeDrop.new(
141
+ datatype, parsed.drop_options
142
+ )
143
+ end
144
+
98
145
  def serialize_enum_drop_by_name(xmi_path, name, _document = nil)
99
- parsed = XMI_PARSE_CACHE.fetch(xmi_path)
146
+ GcBudget.gc_when_bloated!
147
+ xmi_path, name, slice = XmiPartialLoadRegistry.rewrite(xmi_path, name)
148
+ parsed = XMI_PARSE_CACHE.fetch(xmi_path, slice: slice)
100
149
  raw_enum = find_packaged_enum(parsed.parser.xmi_index, name)
101
- warn "Enumeration not found for name: #{name}" if raw_enum.nil?
102
- enum = raw_enum && find_enum_by_xmi_id(
103
- parsed.uml_document, raw_enum.id
104
- )
150
+ if raw_enum.nil?
151
+ warn "Enumeration not found for name: #{name}"
152
+ return nil
153
+ end
154
+
155
+ enum = parsed.xmi_id_index[raw_enum.id]
105
156
  ::Ea::Xmi::LiquidDrops::EnumDrop.new(
106
157
  enum, parsed.drop_options
107
158
  )
@@ -115,9 +166,16 @@ guidance = nil)
115
166
  parsed.parser.xmi_index, name,
116
167
  root_model_name: root_model_name
117
168
  )
118
- raw_klass && find_class_by_xmi_id(
119
- parsed.uml_document, raw_klass.id
169
+ raw_klass && parsed.xmi_id_index[raw_klass.id]
170
+ end
171
+
172
+ def resolve_packaged_datatype(parsed, name)
173
+ root_model_name = parsed.parser.xmi_root_model.model.name
174
+ raw_datatype = find_packaged_datatype(
175
+ parsed.parser.xmi_index, name,
176
+ root_model_name: root_model_name
120
177
  )
178
+ raw_datatype && parsed.xmi_id_index[raw_datatype.id]
121
179
  end
122
180
  end
123
181
  end
@@ -1,36 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
3
4
  require "xmi"
4
5
  require "ea/xmi"
5
6
 
6
7
  module Metanorma
7
8
  module Plugin
8
9
  module Lutaml
9
- ParsedXmi = Struct.new(:parser, :uml_document, :drop_options,
10
- keyword_init: true)
11
-
12
10
  class XmiParseCache
13
11
  def initialize(max_size: 50)
14
12
  @parse_cache = CacheStore.new(max_size: max_size)
15
13
  @drop_cache = CacheStore.new(max_size: max_size)
16
14
  end
17
15
 
18
- def fetch(full_path)
19
- @parse_cache.fetch_or_store(full_path) do
20
- xmi_model = ::Xmi::Sparx::Root.parse_xml(File.read(full_path))
21
- parser = ::Ea::Xmi::Parser.new
22
- uml_document = parser.parse(xmi_model)
23
- ParsedXmi.new(
24
- parser: parser,
25
- uml_document: uml_document,
26
- drop_options: build_drop_options(parser),
27
- )
16
+ # slice: a XmiPartialLoadRegistry token; when given, the macro parses
17
+ # the package slice prepared for it instead of the whole source
18
+ def fetch(full_path, retain: true, slice: nil)
19
+ key = slice || full_path
20
+ return fetch_unretained(full_path, slice) unless retain
21
+
22
+ @parse_cache.fetch_or_store(key) do
23
+ load_parsed(full_path, slice)
28
24
  end
29
25
  end
30
26
 
31
- def fetch_drop(full_path, guidance: nil)
32
- parsed = fetch(full_path)
33
- @drop_cache.fetch_or_store([full_path, guidance]) do
27
+ def fetch_unretained(full_path, slice = nil)
28
+ load_parsed(full_path, slice)
29
+ end
30
+
31
+ def fetch_drop(full_path, guidance: nil, slice: nil)
32
+ parsed = fetch(full_path, slice: slice)
33
+ @drop_cache.fetch_or_store([key_for(full_path, slice), guidance]) do
34
34
  ::Ea::Xmi::LiquidDrops::RootDrop.new(
35
35
  parsed.uml_document, guidance, parsed.drop_options
36
36
  )
@@ -44,16 +44,20 @@ module Metanorma
44
44
 
45
45
  private
46
46
 
47
- def build_drop_options(parser)
48
- lookup = ::Ea::Xmi::LookupService.new(parser)
49
- {
50
- xmi_root_model: parser.xmi_root_model,
51
- id_name_mapping: parser.id_name_mapping,
52
- lookup: lookup,
53
- with_gen: true,
54
- with_assoc: true,
55
- with_absolute_path: true,
56
- }
47
+ def key_for(full_path, slice)
48
+ slice || full_path
49
+ end
50
+
51
+ # The prepared slice is parsed whole: assembling it already
52
+ # streamed the source once, so nothing re-reads the original
53
+ # export here.
54
+ def load_parsed(full_path, slice)
55
+ if slice
56
+ xml = XmiPartialLoadRegistry.slice_string(slice)
57
+ ::Ea::Xmi.load_graph(StringIO.new(xml))
58
+ else
59
+ ::Ea::Xmi.load_graph(full_path)
60
+ end
57
61
  end
58
62
  end
59
63
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "utils"
5
+
6
+ module Metanorma
7
+ module Plugin
8
+ module Lutaml
9
+ # Loads the XMI sources of table macros partially, through
10
+ # Ea::Xmi.load_graph's partial mode.
11
+ #
12
+ # EA exports of plateau scale reach ~100 MB, more than half of it
13
+ # diagram presentation data, and fully materializing the Xmi tree
14
+ # plus the Ea graph costs 10+ GB of live objects - more than a
15
+ # standard build runner has. With the `lutaml-ea-xmi-load` document
16
+ # attribute set to `partial`, every klass and enum table macro's
17
+ # (package, name) pair seeds a per-package reference closure; one
18
+ # streaming pass per source assembles the closures in memory and
19
+ # each macro parses only its own package's slice. Nothing is
20
+ # written to disk.
21
+ #
22
+ # Without the attribute nothing is preloaded and behavior is
23
+ # byte-for-byte the whole-load path.
24
+ module XmiPartialLoadRegistry
25
+ MACRO_REGEXP = /lutaml_(?:klass|enum)_table::([^\[<]+)\[([^\]]*)\]/
26
+ MIN_SOURCE_BYTES = 2 * 1024 * 1024
27
+
28
+ class << self
29
+ # Rewrites (xmi_path, name_path) to the partial-load form of
30
+ # the macro's class: [path, bare name, slice token]. The
31
+ # token is nil when no slice was prepared, in which case the
32
+ # macro whole-loads the source unchanged.
33
+ def rewrite(xmi_path, name_path)
34
+ return [xmi_path, name_path, nil] unless @registry
35
+
36
+ entry = @registry[xmi_path]
37
+ return [xmi_path, name_path, nil] unless entry
38
+
39
+ group_key = entry[name_path] || entry[bare_name(name_path)]
40
+ return [xmi_path, name_path, nil] unless group_key
41
+
42
+ [xmi_path, bare_name(name_path), [xmi_path, group_key]]
43
+ end
44
+
45
+ # The prepared slice for a token from #rewrite.
46
+ def slice_string(token)
47
+ @slices[token]
48
+ end
49
+
50
+ def slice?(xmi_path)
51
+ return false unless @registry
52
+
53
+ entry = @registry[xmi_path]
54
+ !entry.nil? && entry.any?
55
+ end
56
+
57
+ def reset!
58
+ @registry = nil
59
+ @slices = nil
60
+ end
61
+
62
+ def prepare(document, lines) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
63
+ reset!
64
+ # header attributes are parsed after preprocessors run, so
65
+ # the opt-in is also detected in the raw lines
66
+ return unless enabled?(document, lines)
67
+
68
+ groups = collect_groups(document, lines)
69
+ return if groups.empty?
70
+
71
+ @registry = {}
72
+ @slices = {}
73
+ groups.each do |path, entries|
74
+ next unless File.size(path) >= MIN_SOURCE_BYTES
75
+
76
+ slice_groups = {}
77
+ macro_keys = {}
78
+ entries.each do |key, value|
79
+ if value.is_a?(Array) # group_key => wanted pairs
80
+ slice_groups[key] = value
81
+ else # macro key => group_key
82
+ (macro_keys[value] ||= []) << key
83
+ end
84
+ end
85
+ # one index pass and one write pass over the source; the
86
+ # slices stay in memory for the parse cache to consume,
87
+ # so nothing is written to disk
88
+ ::Ea::Xmi::Slicer.slices(path, slice_groups).each do |group_key, xml|
89
+ token = [path, group_key]
90
+ @slices[token] = xml
91
+ macro_keys[group_key]&.each do |k|
92
+ (@registry[path] ||= {})[k] = group_key
93
+ end
94
+ end
95
+ end
96
+ @registry = nil if @registry.empty?
97
+ end
98
+
99
+ private
100
+
101
+ def enabled?(document, lines)
102
+ document.attributes["lutaml-ea-xmi-load"] == "partial" ||
103
+ lines.any? { |l| l.match?(/\A:lutaml-ea-xmi-load:\s*partial\s*$/) }
104
+ end
105
+
106
+ # Slices are grouped per package, not per class: the tables
107
+ # of classes in one package share most of their closure
108
+ # (partners, connectors, ancestors), so a package slice
109
+ # carries each shared subtree once instead of once per class.
110
+ def collect_groups(document, lines)
111
+ groups = Hash.new { |h, k| h[k] = {} }
112
+ lines.each do |line|
113
+ MACRO_REGEXP.match(line) do |m|
114
+ path = Utils.relative_file_path(document, m[1].strip)
115
+ package = m[2][/&?(?:^|,)package="([^"]+)"/, 1]
116
+ name = m[2][/&?(?:^|,)name="([^"]+)"/, 1]
117
+ next if name.nil?
118
+
119
+ key = package ? "#{package}::#{name}" : name
120
+ group_key = package || "_"
121
+ (groups[path][group_key] ||= []) << [package, name]
122
+ groups[path][key] = group_key
123
+ end
124
+ end
125
+ groups
126
+ end
127
+
128
+ def bare_name(name_path)
129
+ name_path.to_s.split("::").last
130
+ end
131
+ end
132
+ end
133
+
134
+ # Scans the fully expanded document source once, before parsing,
135
+ # and prepares the partial-load seeds for every table macro. The
136
+ # lines pass through unchanged; reading them through the incoming
137
+ # reader expands include directives, which is where plateau
138
+ # documents keep their table macros.
139
+ class XmiPartialLoadPreprocessor < ::Asciidoctor::Extensions::Preprocessor
140
+ def process(document, reader)
141
+ input_lines = reader.readlines
142
+ XmiPartialLoadRegistry.prepare(document, input_lines)
143
+ reader.class.new(document, input_lines)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "liquid"
4
4
 
5
+ require "metanorma/plugin/lutaml/gc_budget"
6
+
5
7
  module Metanorma
6
8
  module Plugin
7
9
  module Lutaml
@@ -72,6 +74,9 @@ module Metanorma
72
74
  end
73
75
 
74
76
  rendered_table = table_tmpl.render
77
+ # The asciidoctor ingestion of a rendered table transiently
78
+ # dwarfs its input; collect the liquid phase's garbage first.
79
+ GcBudget.gc_when_bloated!
75
80
  block = create_open_block(parent, "", attrs)
76
81
  parse_content(block, rendered_table, attrs)
77
82
  end
@@ -31,6 +31,8 @@ module Metanorma
31
31
  autoload :LutamlDiagramBase, "metanorma/plugin/lutaml/lutaml_diagram_base"
32
32
  autoload :LutamlDiagramBlock,
33
33
  "metanorma/plugin/lutaml/lutaml_diagram_block"
34
+ autoload :LutamlDataPreprocessor,
35
+ "metanorma/plugin/lutaml/lutaml_data_preprocessor"
34
36
  autoload :LutamlDiagramBlockMacro,
35
37
  "metanorma/plugin/lutaml/lutaml_diagram_block_macro"
36
38
  autoload :LutamlEaDiagramBlockMacro,
@@ -65,6 +67,10 @@ module Metanorma
65
67
  "metanorma/plugin/lutaml/lutaml_xsd_preprocessor"
66
68
  autoload :ParseError, "metanorma/plugin/lutaml/parse_error"
67
69
  autoload :XmiParseCache, "metanorma/plugin/lutaml/xmi_parse_cache"
70
+ autoload :XmiPartialLoadRegistry,
71
+ "metanorma/plugin/lutaml/xmi_partial_load_registry"
72
+ autoload :XmiPartialLoadPreprocessor,
73
+ "metanorma/plugin/lutaml/xmi_partial_load_registry"
68
74
  autoload :SourceExtractor, "metanorma/plugin/lutaml/source_extractor"
69
75
  autoload :Utils, "metanorma/plugin/lutaml/utils"
70
76
  autoload :Yaml2TextPreprocessor,
@@ -30,7 +30,7 @@ Gem::Specification.new do |spec|
30
30
 
31
31
  spec.add_dependency "asciidoctor"
32
32
  spec.add_dependency "coradoc", "~> 1.1.8"
33
- spec.add_dependency "ea"
33
+ spec.add_dependency "ea", ">= 0.6.41" # Ea::Xmi.load/load_graph (lutaml/ea#86): the partial-load API this gem calls
34
34
  spec.add_dependency "expressir", "~> 2.3", ">= 2.3.5"
35
35
  spec.add_dependency "isodoc"
36
36
  spec.add_dependency "liquid"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma-plugin-lutaml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.53
4
+ version: 0.7.54
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-26 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asciidoctor
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
47
+ version: 0.6.41
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: '0'
54
+ version: 0.6.41
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: expressir
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -224,6 +224,7 @@ files:
224
224
  - lib/metanorma/plugin/lutaml/express_remark_adapter.rb
225
225
  - lib/metanorma/plugin/lutaml/express_remarks_decorator.rb
226
226
  - lib/metanorma/plugin/lutaml/file_not_found_error.rb
227
+ - lib/metanorma/plugin/lutaml/gc_budget.rb
227
228
  - lib/metanorma/plugin/lutaml/json2_text_preprocessor.rb
228
229
  - lib/metanorma/plugin/lutaml/liquid.rb
229
230
  - lib/metanorma/plugin/lutaml/liquid/custom_blocks.rb
@@ -250,6 +251,7 @@ files:
250
251
  - lib/metanorma/plugin/lutaml/liquid_templates/_packages_entity_list_class.liquid
251
252
  - lib/metanorma/plugin/lutaml/liquid_templates/_packages_entity_list_classes.liquid
252
253
  - lib/metanorma/plugin/lutaml/liquid_templates/_packages_enum.liquid
254
+ - lib/metanorma/plugin/lutaml/lutaml_data_preprocessor.rb
253
255
  - lib/metanorma/plugin/lutaml/lutaml_diagram_base.rb
254
256
  - lib/metanorma/plugin/lutaml/lutaml_diagram_block.rb
255
257
  - lib/metanorma/plugin/lutaml/lutaml_diagram_block_macro.rb
@@ -277,6 +279,7 @@ files:
277
279
  - lib/metanorma/plugin/lutaml/xmi_context_builder.rb
278
280
  - lib/metanorma/plugin/lutaml/xmi_package_filter.rb
279
281
  - lib/metanorma/plugin/lutaml/xmi_parse_cache.rb
282
+ - lib/metanorma/plugin/lutaml/xmi_partial_load_registry.rb
280
283
  - lib/metanorma/plugin/lutaml/xmi_renderer.rb
281
284
  - lib/metanorma/plugin/lutaml/yaml2_text_preprocessor.rb
282
285
  - metanorma-plugin-lutaml.gemspec