metanorma-plugin-lutaml 0.7.53 → 0.7.55.pre.alpha.1

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: d08e2206e64a9c020c1059501939d9a44cfa5e0412368b84e9702442ed38e5ca
4
+ data.tar.gz: 3d1bc9fbb60c1a750ab70d2ffcd98e61257e4a7d21f44886cb485456b56af81c
5
5
  SHA512:
6
- metadata.gz: '028f5021f95d108c7c55efba46e5a8bad6558690593b636c71d244bf7896ad639d10176853551127256b0d0206993ebf8587700786de846dacbad547ce066c39'
7
- data.tar.gz: c7136a9bd3a6937583aaa6f04e03e3867fbbb9cc18b1715cf91ce369329badd4a7ea0e602b5c1ae3d4303a5f14b9838786ee2d605d821ed70bc22401de15ce2e
6
+ metadata.gz: 8777cde7a2a1379e9cabb417192d3bc026786cb04739e10578c2eede1764bdffe2b113be22837f376957c7ba6525ba831fd78192c1c381fc5c70a0ec0151d661
7
+ data.tar.gz: 6ac5da62726d19874eceeb0c39710ad034e890e78402f41f516d1f00b5954aed441b9176ec403c97cd228d7537e6e8e3cd710495e2bee3c2927ae598d39d84a1
@@ -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 }}
data/Gemfile CHANGED
@@ -24,3 +24,22 @@ gem "simplecov"
24
24
  gem "timecop"
25
25
  gem "vcr"
26
26
  gem "webmock"
27
+
28
+ # leptris 1.9.367.0 is the highest match for standoc's '~> 1.9.361' but
29
+ # publishes only linux prebuilds plus a ruby gem with no vendored
30
+ # libleptris; windows-latest falls back to that ruby gem and dies on
31
+ # require "metanorma-standoc". 1.9.361.1 is the latest that still ships
32
+ # x64-mingw-ucrt (and darwin) binaries. Lift once upstream packs Windows
33
+ # again.
34
+ gem "leptris", "~> 1.9.361.1"
35
+
36
+ # pre-release line pins (see metanorma release wave)
37
+ gem "isodoc", github: "metanorma/isodoc", branch: "main"
38
+ gem "metanorma-standoc", github: "metanorma/metanorma-standoc", branch: "main"
39
+ gem "suma", github: "metanorma/suma", branch: "main"
40
+ # standoc main's block_attributes reference
41
+ # Metanorma::Document::Components::AncillaryBlocks::SvgmapElement, which
42
+ # the released metanorma-document 0.5.1 predates; ref-pinned to the
43
+ # document main tree the suite is green against
44
+ gem "metanorma-document", github: "metanorma/metanorma-document",
45
+ ref: "ca4eb4b26f7ab0d2994a4710d0cf158607a832b4"
data/Gemfile.devel CHANGED
@@ -1,12 +1,12 @@
1
- gem "metanorma-standoc", github: "metanorma/metanorma-standoc", ref: "5ca49c85bbcc1422e48c4b625a66f7f026dc6832"
1
+ # gem "metanorma-standoc", github: "metanorma/metanorma-standoc", ref: "5ca49c85bbcc1422e48c4b625a66f7f026dc6832"
2
2
  gem "metanorma", github: "metanorma/metanorma", ref: "a899a9a6fc933dca0a53166d6633b609311b14e6"
3
3
  # relaton v3 prerelease line (rubyzip ~> 3.4, matching lutaml-model);
4
4
  # relaton-cli's v2line branch allows relaton 3 while keeping version
5
5
  # 2.1.2 for metanorma-standoc's constraint; the metanorma/standoc
6
6
  # refs are the known-good pair (newer standoc@main needs unreleased
7
7
  # metanorma-document components)
8
- gem "relaton", github: "relaton/relaton", ref: "65742ac31bce9e006072e1ace440c984352201f3"
9
- gem "relaton-cli", github: "relaton/relaton-cli", branch: "feat/allow-relaton-v3-v2line"
8
+ gem "relaton", "~> 3.0.0.pre.alpha"
9
+ gem "relaton-cli", "~> 3.0.0.pre.alpha"
10
10
 
11
11
  # lutaml-lml 0.1.5 adds Lutaml::Lml.parse + Formatter::Graphviz used by
12
12
  # the lutaml_diagram renderer; 0.1.1 (previously resolved) lacks them
@@ -208,11 +208,19 @@ classes:
208
208
  used: false
209
209
  guidance: |
210
210
  Drop guidance message here.
211
+ data_types:
212
+ - name: Name Of Datatype
213
+ attributes:
214
+ - name: Name Of Attribute (e.g. Root:value)
215
+ used: false
216
+ guidance: |
217
+ Drop guidance message here.
211
218
  ...
212
219
  ----
213
220
 
214
221
  If you want to define the guidance, you can define the `name` of the class
215
- under `classes`. Then define which `attributes` you want to add guidance by the
222
+ under `classes` (or the datatype under `data_types`). Then define which
223
+ `attributes` you want to add guidance by the
216
224
  `name`. Set `used` to show the attribute is used or not. Drop the message of
217
225
  guidance in `guidance`.
218
226
 
@@ -311,11 +319,19 @@ classes:
311
319
  used: false
312
320
  guidance: |
313
321
  Drop guidance message here.
322
+ data_types:
323
+ - name: "NameOfDatatype"
324
+ attributes:
325
+ - name: Name Of Attribute (e.g. Root:value)
326
+ used: false
327
+ guidance: |
328
+ Drop guidance message here.
314
329
  ...
315
330
  ----
316
331
 
317
332
  If you want to define the guidance, you can define the `name` of the class
318
- under `classes`. Then define which `attributes` you want to add guidance by the
333
+ under `classes` (or the datatype under `data_types`). Then define which
334
+ `attributes` you want to add guidance by the
319
335
  `name`. Set `used` to show the attribute is used or not. Drop the message of
320
336
  guidance in `guidance`.
321
337
 
@@ -7,9 +7,22 @@ module Metanorma
7
7
  class Guidance < ::Lutaml::Model::Serializable
8
8
  attribute :classes, GuidanceKlass, collection: true,
9
9
  initialize_empty: true
10
+ attribute :data_types, GuidanceKlass, collection: true,
11
+ initialize_empty: true
10
12
 
11
13
  yaml do
12
14
  map "classes", to: :classes
15
+ map "data_types", to: :data_types
16
+ end
17
+
18
+ # Drops index into both keys (KlassDrop reads ["classes"],
19
+ # DataTypeDrop reads ["data_types"]); empty collections are
20
+ # omitted from as_yaml, so pin both keys here.
21
+ def to_yaml_hash
22
+ super.tap do |hash|
23
+ hash["classes"] ||= []
24
+ hash["data_types"] ||= []
25
+ end
13
26
  end
14
27
  end
15
28
  end
@@ -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,8 @@
1
- {% assign root = klass.generalization %}
1
+ {%- comment -%}
2
+ parentless types (e.g. Union-stereotyped data types) carry no
3
+ generalization: render the drop itself so members are not lost
4
+ {%- endcomment -%}
5
+ {%- assign root = klass.generalization | default: klass -%}
2
6
 
3
7
  {%- capture upper_klass_name -%}
4
8
  {{ root.general.upper_klass }}:{{ root.general.name }}
@@ -35,7 +39,8 @@ h| Property Name h| Property Type and Multiplicity h| Definition
35
39
 
36
40
  3+h| Self-defined Properties
37
41
  h| Property Name h| Property Type and Multiplicity h| Definition
38
- {% for attr in root.owned_props %}
42
+ {%- assign self_props = root.owned_props | default: root.owned_attributes -%}
43
+ {% for attr in self_props %}
39
44
  {%- unless attr.has_association? -%}
40
45
  {%- capture name_col -%}
41
46
  {{ attr.name_ns }}:{{ attr.name }}
@@ -74,4 +79,4 @@ h| Property Name h| Property Type and Multiplicity h| Definition
74
79
  | {{ attr.definition }}
75
80
  {%- endif -%}
76
81
  {% endfor %}
77
- |===
82
+ |===
@@ -0,0 +1,215 @@
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 => deep_stringify(instances.to_h))
121
+ end
122
+
123
+ # Liquid resolves variable lookups by string keys; the compiled
124
+ # LML models hash with symbols
125
+ def deep_stringify(value)
126
+ case value
127
+ when Hash
128
+ value.transform_keys(&:to_s)
129
+ .transform_values { |v| deep_stringify(v) }
130
+ when Array
131
+ value.map { |v| deep_stringify(v) }
132
+ else
133
+ value
134
+ end
135
+ end
136
+
137
+ def resolve_data_paths(document, definition_file, instance_file)
138
+ definition_path = Utils.relative_file_path(document, definition_file)
139
+ unless File.file?(definition_path)
140
+ raise StandardError, index_missing_message(definition_file)
141
+ end
142
+
143
+ instances_path = instance_file &&
144
+ Utils.relative_file_path(document, instance_file)
145
+ if instances_path && !File.file?(instances_path)
146
+ raise StandardError, index_missing_message(instance_file)
147
+ end
148
+
149
+ [definition_path, instances_path || definition_path]
150
+ end
151
+
152
+ def load_instances(path, compiler)
153
+ case File.extname(path)
154
+ when ".lml" then load_lml_instances(path, compiler)
155
+ when ".json" then load_structured_instances(path, compiler,
156
+ :parse_json_data)
157
+ else load_structured_instances(path, compiler, :parse_yaml_data)
158
+ end
159
+ end
160
+
161
+ def load_lml_instances(path, compiler)
162
+ doc = load_lutaml_file(nil, path, {})
163
+ root = doc.instance
164
+ return [] unless root
165
+
166
+ [[root.type.to_s, compiler.hydrate(doc)]]
167
+ rescue StandardError => e
168
+ raise StandardError, "Failed to parse LutaML instances in " \
169
+ "`#{path}`: #{e.message}"
170
+ end
171
+
172
+ def load_structured_instances(path, compiler, parser)
173
+ data = send(parser, path)
174
+ entries = data.is_a?(Array) ? data : [data]
175
+ entries.map { |entry| build_structured_entry(path, compiler, entry) }
176
+ rescue StandardError => e
177
+ raise e if e.message.start_with?("Instance", "Unknown type")
178
+
179
+ raise StandardError, "Failed to load data instances in " \
180
+ "`#{path}`: #{e.message}"
181
+ end
182
+
183
+ def build_structured_entry(path, compiler, entry)
184
+ type = entry.delete("type")
185
+ unless type
186
+ raise StandardError, "Instance in `#{path}` is missing the " \
187
+ "`type` key"
188
+ end
189
+
190
+ klass = compiler.compiled_classes[demodulize(type)]
191
+ unless klass
192
+ raise StandardError,
193
+ "Unknown type `#{type}` in `#{path}`"
194
+ end
195
+
196
+ [demodulize(type), klass.from_hash(entry)]
197
+ end
198
+
199
+ def parse_yaml_data(path)
200
+ require "yaml"
201
+ YAML.safe_load(File.read(path, encoding: "UTF-8"))
202
+ end
203
+
204
+ def parse_json_data(path)
205
+ require "json"
206
+ JSON.parse(File.read(path, encoding: "UTF-8"))
207
+ end
208
+
209
+ def demodulize(name)
210
+ name.to_s.split("::").last
211
+ end
212
+ end
213
+ end
214
+ end
215
+ 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,9 @@ 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
35
  render_table(klass, CONTEXT_NAME, parent, attrs)
35
36
  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.55.pre.alpha.1".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,22 +34,6 @@ 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
-
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
45
-
46
37
  def find_packaged_klass(index, path, root_model_name: nil)
47
38
  segments = path.split("::").reject(&:empty?)
48
39
  if root_model_name && segments.first == root_model_name
@@ -69,6 +60,32 @@ yaml_config_path = nil)
69
60
  end
70
61
  end
71
62
 
63
+ def find_packaged_datatype(index, path, root_model_name: nil)
64
+ segments = path.split("::").reject(&:empty?)
65
+ if root_model_name && segments.first == root_model_name
66
+ segments.shift
67
+ end
68
+ if segments.one?
69
+ index.find_packaged_by_name_and_types(
70
+ segments.first, ["uml:DataType"]
71
+ )
72
+ else
73
+ find_packaged_datatype_by_path(index, segments)
74
+ end
75
+ end
76
+
77
+ def find_packaged_datatype_by_path(index, segments)
78
+ datatype_name = segments.pop
79
+
80
+ candidates = ["uml:DataType"]
81
+ .flat_map { |t| index.packaged_elements_of_type(t) }
82
+ .select { |e| e.name == datatype_name }
83
+
84
+ candidates.find do |datatype|
85
+ match_parent_chain?(index, datatype, segments)
86
+ end
87
+ end
88
+
72
89
  def match_parent_chain?(index, element, parent_segments)
73
90
  current = element
74
91
  parent_segments.reverse_each do |pkg_name|
@@ -85,23 +102,55 @@ yaml_config_path = nil)
85
102
  .find { |e| e.name == name }
86
103
  end
87
104
 
88
- def serialize_klass_drop_by_name(xmi_path, name, _document = nil,
89
- guidance = nil)
90
- parsed = XMI_PARSE_CACHE.fetch(xmi_path)
105
+ def serialize_klass_or_datatype_drop_by_name(xmi_path,
106
+ name, _document = nil, guidance = nil)
107
+ serialize_klass_drop_by_name(xmi_path, name, _document, guidance) ||
108
+ serialize_datatype_drop_by_name(xmi_path, name, _document, guidance)
109
+ end
110
+
111
+ def serialize_klass_drop_by_name(xmi_path,
112
+ name, _document = nil, guidance = nil)
113
+ GcBudget.gc_when_bloated!
114
+ xmi_path, name, slice = XmiPartialLoadRegistry.rewrite(xmi_path, name)
115
+ parsed = XMI_PARSE_CACHE.fetch(xmi_path, slice: slice)
91
116
  klass = resolve_packaged_klass(parsed, name)
92
- warn "Class not found for name: #{name}" if klass.nil?
117
+ if klass.nil?
118
+ warn "Class not found for name: #{name}"
119
+ return nil
120
+ end
121
+
93
122
  ::Ea::Xmi::LiquidDrops::KlassDrop.new(
94
123
  klass, guidance, parsed.drop_options
95
124
  )
96
125
  end
97
126
 
127
+ def serialize_datatype_drop_by_name(xmi_path, name, _document = nil,
128
+ guidance = nil)
129
+ GcBudget.gc_when_bloated!
130
+ xmi_path, name, slice = XmiPartialLoadRegistry.rewrite(xmi_path, name)
131
+ parsed = XMI_PARSE_CACHE.fetch(xmi_path, slice: slice)
132
+ datatype = resolve_packaged_datatype(parsed, name)
133
+ if datatype.nil?
134
+ warn "Datatype not found for name: #{name}"
135
+ return nil
136
+ end
137
+
138
+ ::Ea::Xmi::LiquidDrops::DataTypeDrop.new(
139
+ datatype, guidance, parsed.drop_options
140
+ )
141
+ end
142
+
98
143
  def serialize_enum_drop_by_name(xmi_path, name, _document = nil)
99
- parsed = XMI_PARSE_CACHE.fetch(xmi_path)
144
+ GcBudget.gc_when_bloated!
145
+ xmi_path, name, slice = XmiPartialLoadRegistry.rewrite(xmi_path, name)
146
+ parsed = XMI_PARSE_CACHE.fetch(xmi_path, slice: slice)
100
147
  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
- )
148
+ if raw_enum.nil?
149
+ warn "Enumeration not found for name: #{name}"
150
+ return nil
151
+ end
152
+
153
+ enum = parsed.xmi_id_index[raw_enum.id]
105
154
  ::Ea::Xmi::LiquidDrops::EnumDrop.new(
106
155
  enum, parsed.drop_options
107
156
  )
@@ -115,9 +164,16 @@ guidance = nil)
115
164
  parsed.parser.xmi_index, name,
116
165
  root_model_name: root_model_name
117
166
  )
118
- raw_klass && find_class_by_xmi_id(
119
- parsed.uml_document, raw_klass.id
167
+ raw_klass && parsed.xmi_id_index[raw_klass.id]
168
+ end
169
+
170
+ def resolve_packaged_datatype(parsed, name)
171
+ root_model_name = parsed.parser.xmi_root_model.model.name
172
+ raw_datatype = find_packaged_datatype(
173
+ parsed.parser.xmi_index, name,
174
+ root_model_name: root_model_name
120
175
  )
176
+ raw_datatype && parsed.xmi_id_index[raw_datatype.id]
121
177
  end
122
178
  end
123
179
  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,15 +30,13 @@ 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
- spec.add_dependency "isodoc"
36
- spec.add_dependency "liquid"
35
+ spec.add_dependency "isodoc", ">= 3.5.0"
36
+ spec.add_dependency "liquid", "~> 5"
37
37
  spec.add_dependency "lutaml-lml"
38
38
  spec.add_dependency "lutaml-model", "~> 0.8.4"
39
39
  spec.add_dependency "lutaml-uml", ">= 0.5", "< 2"
40
40
  spec.add_dependency "ogc-gml", "~> 1.1"
41
- spec.add_dependency "relaton-cli"
42
-
43
41
  spec.metadata["rubygems_mfa_required"] = "true"
44
42
  end
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.55.pre.alpha.1
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-22 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
@@ -78,28 +78,28 @@ dependencies:
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: '0'
81
+ version: 3.5.0
82
82
  type: :runtime
83
83
  prerelease: false
84
84
  version_requirements: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - ">="
87
87
  - !ruby/object:Gem::Version
88
- version: '0'
88
+ version: 3.5.0
89
89
  - !ruby/object:Gem::Dependency
90
90
  name: liquid
91
91
  requirement: !ruby/object:Gem::Requirement
92
92
  requirements:
93
- - - ">="
93
+ - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '0'
95
+ version: '5'
96
96
  type: :runtime
97
97
  prerelease: false
98
98
  version_requirements: !ruby/object:Gem::Requirement
99
99
  requirements:
100
- - - ">="
100
+ - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '0'
102
+ version: '5'
103
103
  - !ruby/object:Gem::Dependency
104
104
  name: lutaml-lml
105
105
  requirement: !ruby/object:Gem::Requirement
@@ -162,20 +162,6 @@ dependencies:
162
162
  - - "~>"
163
163
  - !ruby/object:Gem::Version
164
164
  version: '1.1'
165
- - !ruby/object:Gem::Dependency
166
- name: relaton-cli
167
- requirement: !ruby/object:Gem::Requirement
168
- requirements:
169
- - - ">="
170
- - !ruby/object:Gem::Version
171
- version: '0'
172
- type: :runtime
173
- prerelease: false
174
- version_requirements: !ruby/object:Gem::Requirement
175
- requirements:
176
- - - ">="
177
- - !ruby/object:Gem::Version
178
- version: '0'
179
165
  description: Metanorma plugin for LutaML
180
166
  email:
181
167
  - open.source@ribose.com
@@ -224,6 +210,7 @@ files:
224
210
  - lib/metanorma/plugin/lutaml/express_remark_adapter.rb
225
211
  - lib/metanorma/plugin/lutaml/express_remarks_decorator.rb
226
212
  - lib/metanorma/plugin/lutaml/file_not_found_error.rb
213
+ - lib/metanorma/plugin/lutaml/gc_budget.rb
227
214
  - lib/metanorma/plugin/lutaml/json2_text_preprocessor.rb
228
215
  - lib/metanorma/plugin/lutaml/liquid.rb
229
216
  - lib/metanorma/plugin/lutaml/liquid/custom_blocks.rb
@@ -250,6 +237,7 @@ files:
250
237
  - lib/metanorma/plugin/lutaml/liquid_templates/_packages_entity_list_class.liquid
251
238
  - lib/metanorma/plugin/lutaml/liquid_templates/_packages_entity_list_classes.liquid
252
239
  - lib/metanorma/plugin/lutaml/liquid_templates/_packages_enum.liquid
240
+ - lib/metanorma/plugin/lutaml/lutaml_data_preprocessor.rb
253
241
  - lib/metanorma/plugin/lutaml/lutaml_diagram_base.rb
254
242
  - lib/metanorma/plugin/lutaml/lutaml_diagram_block.rb
255
243
  - lib/metanorma/plugin/lutaml/lutaml_diagram_block_macro.rb
@@ -277,6 +265,7 @@ files:
277
265
  - lib/metanorma/plugin/lutaml/xmi_context_builder.rb
278
266
  - lib/metanorma/plugin/lutaml/xmi_package_filter.rb
279
267
  - lib/metanorma/plugin/lutaml/xmi_parse_cache.rb
268
+ - lib/metanorma/plugin/lutaml/xmi_partial_load_registry.rb
280
269
  - lib/metanorma/plugin/lutaml/xmi_renderer.rb
281
270
  - lib/metanorma/plugin/lutaml/yaml2_text_preprocessor.rb
282
271
  - metanorma-plugin-lutaml.gemspec